Twig templating in Behat tests

Lately, in my projects, I needed to use Twig in my Behat tests in order to solve common problems: dynamic identifiers, and avoiding boilerplate tests.

The use cases

Behat allows you to write elegant tests, in a very semantic way. But when it comes to API testing, your tests start getting unreadable.

Let's take a simple example: an API to create orders. I often see this kind of test:

Scenario: I can place an order
    Given I send a POST request to "/v1/order" with body:
        """
        {
            "customer": {
                 "name": "Alice Test",
                 "address": {
                     "street_number": "1Bis",
                     "street_name": "Avenue des Pins",
                     "zipcode": "59000",
                     "city": "LILLE",
                     "country": "France"
                 }
            },
            "placed_at": "2019-05-11T07:48:33Z",
            "order_lines": [
              { "quantity": 3, "reference": "4109591" },
              { "quantity": 1, "reference": "6514909" },
              { "quantity": 5, "reference": "9849081" }
            ],
            "payment":{
                 "vouchers": [ "..." ],
                 "methods": [ "..." ]
            },
            "shipping": [ "..." ],
            "billing": [ "..." ]
        }
        """
    When I send a GET request to "/v1/order/1"
    Then the response status code should be 200

Scenario: I cannot place an order without order lines
    Given I send a POST request to "/v1/order" with body:
        """
        {
            "customer": {
                 "name": "Alice Test",
                 "address": {
                     "street_number": "1Bis",
                     "street_name": "Avenue des Pins",
                     "zipcode": "59000",
                     "city": "LILLE",
                     "country": "France"
                 }
            },
            "placed_at": "2019-05-11T07:48:33Z",
            "order_lines": [ ],
            "payment":{
                 "vouchers": [ "..." ],
                 "methods": [ "..." ]
            },
            "shipping": [ "..." ],
            "billing": [ "..." ]
        }
        """
    When I send a GET request to "/v1/order/1"
    Then the response status code should be 200

    # etc.

Large payloads

An order can be a complex object, especially when lot of business logic is involved, like:

  • Different payment methods;
  • Multi-shipping;
  • Vouchering;
  • etc.

Quickly, your JSON payloads are 200 lines boilerplate JSON code, and that's not a good thing.

Many, too many cases

For an order API, you will need to test:

  • No voucher; one voucher; mutliple voucher;
  • One order line; multiple order lines; no order lines;
  • Valid address; invalid address;
  • Single payment methods; multiple payment methods;
  • Omit required fields;
  • etc.

It's at least 10 or 20 cases that needs to be tested. If you have for each one 100 JSON lines, that's 2,000-3,000 lines of tests, at least. That's also a code repeating issue, because most of the payloads are identical.

Static identifiers

I usually see people dealing with static identifiers in their tests. This is clearly technical debt.

If you cannot work on a dynamically created identifier, you will not be able to execute your test a second time, or change your test suite order.

Meet the TemplatingContext

Core features

When dealing with those cases, the solution I implement is Twig templating for Behat. I always start with a simple Behat context containing simple instructions to start playing with Twig:

# features/bootstrap/TemplatingContext.php

use Behat\Behat\Context\Context;
use Twig\Environment;

class TemplatingContext implements ContextInterface
{
    /** @var Environment */
    private $twig;

    private $variables = [];

    public function __construct(Environment $twig)
    {
        $this->twig = $twig;
    }

    /**
     * @BeforeScenario
     */
    public function cleanVariables(): void
    {
        $this->variables = [];
    }

    public function setVariable(string $name, $value): void
    {
        $this->variables[$name] = $value;
    }

    public function getVariable(string $name, $default = null)
    {
        return $this->variables[$name] ?? $default;
    }

    public function renderTemplate(string $content): string
    {
        $template = $this->twig->createTemplate($content);

        return $this->twig->render($template, $this->variables);
    }

    public function renderPyStringNodeTemplate(PyStringNode $value): PyStringNode
    {
        $line = $value->getLine();
        $raw = $this->renderTemplate($value->getRaw());

        return new PyStringNode(explode("\n", $raw), $line);
    }

    /**
     * @Given a variable :name with value :value
     */
    public function aVariableWithValue(string $name, string $value): void
    {
        $this->variables[$name] = $this->renderTemplate($value);
    }

    /**
     * @Given a variable :name with value:
     */
    public function aVariableWithValueNode(string $name, PyStringNode $value): void
    {
        $value = $this->renderPyStringNodeTemplate($value);
        $this->variables[$name] = $value->getRaw();
    }

This context is simply responsible of holding user-defined variables and render twig templates.

We can see the following core methods in it:

  • clearVariables(): cleans every defined variable, invokes before each scenario;
  • setVariable('name', 'Alice'): defines a variable;
  • getVariable('name'): fetches a variable;
  • renderTemplate('Hello {{ name }}'): renders a template;

Those methods will act as the interface for other contexts, to ease working with it. Two other methods are available, dedicated to PyStringNode objects (the """ blocks for multiline texts, as in example above).

Behat configuration

In order to make this work, you need to use Symfony2Extension (see here) with the following configuration:

# behat.yml.dist
default:
  suites:
    default:
      contexts:
        - TemplatingContext:
            twig: "@twig"

Templating for REST APIs

For now, our new context is not really interesting, since it only allows to define variables with Twig. Let's see how we can leverage it to enhance our contexts.

For this example, we will use the Behatch contexts.

New steps for templating

It's not possible to (easily) override existing steps, to encapsulate them especially. So what I do is that I create a new Context with dedicated sentences for templating-related steps.

For example, I create a RestContext to put my REST related sentences:

use Behatch\Context\BaseContext;
use Behatch\Context\RestContext as BehatchRestContext;
use Behatch\Json\Json;
use Behatch\Json\JsonInspector;

class RestContext extends BaseContext
{
    private $contexts = [];

    /**
     * @var JsonInspector
     */
    private $inspector;

    public function __construct()
    {
        $this->inspector = new JsonInspector('javascript');
    }

    /**
     * @When I remember JSON node :node as :name
     */
    public function rememberJsonPathAs(string $node, string $name): void
    {
        $json = new Json($this->getMink()->getSession()->getPage()->getContent());
        $value = $this->inspector->evaluate($json, $node);

        $this->getContext(TemplatingContext::class)->setVariable($name, $value);
    }

    /**
     * @When I send a :method request to :url with template:
     */
    public function sendRequestWithBody(string $method, string $url, PyStringNode $body): void
    {
        $url = $this->renderTemplate($url);
        $body = $this->renderPyStringNodeTemplate($body);
        $this->getContext(BehatchRestContext::class)->iSendARequestTo($method, $url, $body);
    }

    /**
     * @When I send a :method request to template URL :url
     */
    public function sendRequest(string $method, string $url): void
    {
        $url = $this->renderTemplate($url);
        $this->getBehatchRestContext()->iSendARequestTo($method, $url);
    }

    /**
     * @BeforeScenario
     */
    public function gatherContexts(BeforeScenarioScope $scope): void
    {
        $this->contexts = $scope->getEnvironment()->getContexts();
    }

    protected function getContext(string $className): Context
    {
        foreach ($this->contexts as $context)
        {
            if ($context instanceof $className) {
                return $context;
            }
        }

        throw new \RuntimeException(sprintf(
            'No context with class "%s".',
            $className
        ));
    }
}

Here, different new sentences are available:

  • I remember JSON node "json.path" as "name" takes the payload of the HTTP response and puts it in a Twig variable, accessible to all of your templates later. For example, request an API, remember the JSON payload and build new URLs and new payloads based on their content.
  • I send a GET request to "/v1/order" with template: allows to define the body payload with Twig syntax;
  • I send a GET request to template URL "/v1/order/{{ order.id }}" allows to use templating in an URL path;

And that's all we need to solve our use cases!

Resulting scenario

Now, let's rewrite our test by using those new methods:

Background:
  Given a variable "address" with value:
  """
   {
       "street_number": "1Bis",
       "street_name": "Avenue des Pins",
       "zipcode": "59000",
       "city": "LILLE",
       "country": "France"
   }
  """
  And a variable "customer" with value '{"name": "Alice Test", "address": {{ address|raw }} }'
  And a variable "order_lines" with value:
  """
  { "quantity": 3, "reference": "4109591" },
  { "quantity": 1, "reference": "6514909" },
  { "quantity": 5, "reference": "9849081" }
  """
  And a variable "footer" with value:
  """
            "payment":{
                 "vouchers": [ "..." ],
                 "methods": [ "..." ]
            },
            "shipping": [ "..." ],
            "billing": [ "..." ]
  """

Scenario: I can place an order
    Given I send a POST request to "/v1/order" with template:
        """
        {
            "customer": {{ valid_customer|raw }},
            "placed_at": "2019-05-11T07:48:33Z",
            "order_lines": {{ valid_order_lines|raw }},
            {{ footer|raw }}
        }
        """
    And I remember JSON node "root" as "order"
    When I send a GET request to template URL "/v1/order/{{ order.id }}"
    Then the response status code should be 200

Scenario: I cannot place an order without order lines
    Given I send a POST request to "/v1/order" with template:
        """
        {
            "customer": {{ valid_customer|raw }},
            "placed_at": "2019-05-11T07:48:33Z",
            "order_lines": [ ],
            {{ footer|raw }}
        }
        """
    And I remember JSON node "root" as "order"
    When I send a GET request to "/v1/order/1"
    Then the response status code should be 200

Conclusion and thoughts

This simple method simplifies our use case scenarios from 25 lines to 12 lines. The method shown here can be applied to a wide range of steps, depending on your need and will allow to simplify your tests and make them more readable.

Lot of |raw are present in code, I'm aware of it. The reason for that is that Twig escapes automatically to HTML, and I did not took the time to see how to set escaping context to another language.

I would not recommend using custom Twig filters and functions for testing, because it should stay as simple as possible, and ideally replaceable with a basic Twig engine. It's already a lot of complexity to put Twig inside your Behat tests, you don't want to make it darker with custom test functions.

This is not a Behatch feature, or a public library for a simple reason: I want to share the idea in first place, with this article, and discuss with other people from it. Later, we'll see if it's meant to be shared or if it should just stay as "a trick on a blog".