PHP - - By Deji Akala

Upgrading Sylius the TDD Way: Exploring Behat

Extending Sylius

This article was peer reviewed by Christopher Pitt. Thanks to all of SitePoint’s peer reviewers for making SitePoint content the best it can be!

Last time, we developed some new features on top of Sylius’ core to indicate which products and their variants are low on stock and need replenishing. Now, we move on to seeing our changes in the UI, which means we will need to do a bit of StoryBDD testing.

When browsing the list of products, we want to see a new column called Inventory which will hold a sum of all available tracked variants’ stock amounts.


Sylius logo

Writing StoryBDD Tests

Behat is the tool we want to use here. After making sure Behat is working well by running any feature from the Sylius package, we create a new features/product/managing_products/browsing_products_with_inventory.feature file with the following definition:

@managing_inventory
Feature: Browsing products with inventory
    In order to manage my shop merchandise
    As an Administrator
    I want to be able to browse products

    Background:
        Given the store operates on a single channel in "United States"
        And the store has a product "Kubus"
        And it comes in the following variations:
            | name          | price     |
            | Kubus Banana  | $2.00     |
            | Kubus Carrot  | $2.00     |
        And there are 3 units of "Kubus Banana" variant of product "Kubus" available in the inventory
        And there are 5 units of "Kubus Carrot" variant of product "Kubus" available in the inventory
        And I am logged in as an administrator

    @ui
    Scenario: Browsing defined products with inventory
        Given the "Kubus Banana" product variant is tracked by the inventory
        And the "Kubus Carrot" product variant is tracked by the inventory
        When I want to browse products
        Then I should see that the product "Kubus" has 8 on hand quantity

Again, we describe the make-up of our test product, stating the name, price and available stock of the two variants. If we run this feature, we see a list of paths for available contexts. We aren’t interested in any of them, but we also get a hint, so we select None.

php bin/behat features/product/managing_products/browsing_products_with_inventory.feature
--- Use --snippets-for CLI option to generate snippets for following ui_managing_inventory suite steps:

    When I want to browse products
    Then I should see that the product "Kubus" has 18 on hand quantity

We create our context in src/Sylius/Behat/Context/Ui/Admin/ManagingProductsInventoryContext.php and add this:

<?php

// src/Sylius/Behat/Context/Ui/Admin/ManagingProductsInventoryContext.php

namespace Sylius\Behat\Context\Ui\Admin;

use Behat\Behat\Context\Context;

class ManagingProductsInventoryContext implements Context
{
}

Running the feature again doesn’t seem to help as we get the same list of contexts as before. That’s because Sylius doesn’t know anything about our class. We need to configure a service for our context together with what Sylius has in src/Sylius/Behat/Resources/config/services/contexts/ui.xml. We now search for managing_products and add this below it:

<service id="sylius.behat.context.ui.admin.managing_products_inventory" class="Sylius\Behat\Context\Ui\Admin\ManagingProductsInventoryContext">
    <argument type="service" id="sylius.behat.page.admin.product.index" />
    <tag name="fob.context_service" />
</service>

Let’s add our sylius.behat.context.ui.admin.managing_products_inventory service (that’s the id in ui.xml) to the context services for ui_managing_inventory suites in src/Sylius/Behat/Resources/config/suites/ui/inventory/managing_inventory.yml.

We may need to clear the cache. If we run the feature, we now get an option to select Sylius\Behat\Context\Ui\Admin\ManagingProductsInventoryContext. We then get:

--- Sylius\Behat\Context\Ui\Admin\ManagingProductsInventoryContext has missing steps. Define them with these snippets:

    /**
     * @When I want to browse products
     */
    public function iWantToBrowseProducts()
    {
        throw new PendingException();
    }

    /**
     * @Then I should see that the product :arg1 has :arg2 on hand quantity
     */
    public function iShouldSeeThatTheProductHasOnHandQuantity($arg1, $arg2)
    {
        throw new PendingException();
    }

We can just copy and paste the snippets into the context class we created. Out of curiosity we may import PendingException just to see the output. Let’s add use Behat\Behat\Tester\Exception\PendingException; to the top of the class and re-run the feature.

We get an error:

An exception occured in driver: SQLSTATE[HY000] [1049] Unknown database 'xxxx_test' (Doctrine\DBAL\Exception\ConnectionException)

That’s because we haven’t created the test database. These two commands will do that for us now.

php bin/console doctrine:database:create --env=test
php bin/console doctrine:schema:create --env=test

If you’d created the test database before altering the product_variant table for reorder_level column in the previous post, you may be getting an error:

Column not found: 1054 Unknown column 'reorder_level' in 'field list'

Then, let’s update the test database with:

php bin/console doctrine:schema:update --env=test --force

The feature can now be run and you can see this in the middle of the output:

When I want to browse products
      TODO: write pending definition

A visible TODO in the Behat output

The “TODO: write pending definition” is the PendingException message. Going back to our ManagingProductsInventoryContext, something else is missing. The service we configured had an argument: <argument type="service" id="sylius.behat.page.admin.product.index" />; but this isn’t in our class yet.

The sylius.behat.page.admin.product.index service is for visiting the index page of product management. If we look in src/Sylius/Behat/Resources/config/services/pages/admin/product.xml (found by searching for where sylius.behat.page.admin.product.index is defined) we see that the class is Sylius\Behat\Page\Admin\Product\IndexPage. We need to inject the interface into our context class.

Also in the iWantToBrowseProducts() method, we can now visit the index page by calling the right method from our instance of IndexPageInterface. So ourManagingProductsInventoryContext.php should look like this:

<?php

// src/Sylius/Behat/Context/Ui/Admin/ManagingProductsInventoryContext.php

use Behat\Behat\Context\Context;
use Sylius\Behat\Page\Admin\Product\IndexPageInterface;
use Behat\Behat\Tester\Exception\PendingException;

class ManagingProductsInventoryContext implements Context
{
    /**
     * @var IndexPageInterface
     */
    private $indexPage;

    /**
     * @param IndexPageInterface $indexPage
     */
    public function __construct(IndexPageInterface $indexPage)
    {
        $this->indexPage = $indexPage;
    }

    /**
     * @When I want to browse products
     */
    public function iWantToBrowseProducts()
    {
        $this->indexPage->open();
    }

    /**
     * @Then I should see that the product :arg1 has :arg2 on hand quantity
     */
    public function iShouldSeeThatTheProductHasOnHandQuantity($arg1, $arg2)
    {
        throw new PendingException();
    }
}

It passes and we receive a prompt for the other missing definition:

Then I should see that the product "Kubus" has 18 on hand quantity
      TODO: write pending definition

First we want to change the argument names so that we know what they refer to – $product, and $quantity. Type-hinting $product is also a good idea. Sylius has a lot of generic methods we can call to check that we’ve got a column called “inventory” and we have the right number of available items for our test product. This is what the iShouldSeeThatTheProductHasOnHandQuantity() method should look like now:

/**
 * @Then I should see that the product :product has :quantity on hand quantity
 */
public function iShouldSeeThatTheProductHasOnHandQuantity(Product $product, $quantity)
{
    Assert::notNull($this->indexPage->getColumnFields('inventory'));
    Assert::true($this->indexPage->isSingleResourceOnPage([
      'name' => $product->getName(),
      'inventory' => sprintf('%d Available on hand', $quantity),
    ]));
}

We’ve introduced two classes: Assert and Product. Let’s import them with:

use Webmozart\Assert\Assert;
use AppBundle\Entity\Product; 

At the same time, you may remove the PendingException import that’s no longer in use.

Now the scenario fails with a different error message:

Then I should see that the product "Kubus" has 18 on hand quantity
      Column with name "inventory" not found! (InvalidArgumentException)

Behat has opened the product listing page, and there’s no Inventory column. Let’s add it. Lists are usually displayed with the Sylius Grid component. The SyliusGridBundle uses YAML configuration files that describe the structure of a given grid. For the admin section, these are located in src/Sylius/Bundle/AdminBundle/Resources/config/grids/. We only need to replicate what’s in the product_variant.yml for our “inventory” column in the product.yml.

inventory:
    type: twig
    path: .
    label: sylius.ui.inventory
    options:
        template: "@SyliusAdmin/ProductVariant/Grid/Field/inventory.html.twig"

Most importantly, we need to override product.yml properly. We copy src/Sylius/Bundle/AdminBundle/Resources/config/grids/product.yml to app/Resources/SyliusAdminBundle/config/grids/product.yml. We add the inventory column configuration above to it, maybe after “name”. After clearing caches and running the tests, everything should pass.

Now, we want to modify inventory.html.twig with our reorder level logic so that we can indicate when stock is getting low. That will take care of both Product and ProductVariant grids since their inventory field shares the same template. Let’s copy src/Sylius/Bundle/AdminBundle/Resources/views/ProductVariant/Grid/Field/inventory.html.twig to app/Resources/SyliusAdminBundle/views/ProductVariant/Grid/Field/inventory.html.twig and replace the contents with:

{% if data.isTracked %}
    {% if data.onHand > 0 %}
        {% set classes = (data.isReorderable) ? 'yellow' : 'green' %}
    {% else %}
        {% set classes = 'red' %}
    {% endif %}
<div class="ui {{ classes }} icon label">
    <i class="cube icon"></i>
    <span class="onHand" data-product-variant-id="{{ data.id }}">{{ data.onHand }}</span> {{ 'sylius.ui.available_on_hand'|trans }}
    {% if data.onHold > 0 %}
    <div class="detail">
        <span class="onHold" data-product-variant-id="{{ data.id }}">{{ data.onHold }}</span> {{ 'sylius.ui.reserved'|trans }}
    </div>
    {% endif %}
</div>
{% else %}
    <span class="ui red label">
        <i class="remove icon"></i>
        {{ 'sylius.ui.not_tracked'|trans }}
    </span>
{% endif %}

After we clear the cache, we can view the list from the admin UI. Let’s edit some product variants of a product, change the inventory level, and view the expected results. If any variant has 5 or fewer tracked items in stock, there’ll be a third color (yellow) to indicate that stock is at reorder level for that item. Everything’s all right except we have no way of changing the reorder level. We’ll round this up with how to customize the product variant form so we can change it. There are 3 important words to remember here – class, service, and configuration.

Customize ProductVariant Form

We customize a form by extending a form class. Symfony uses a service container to standardize how objects are constructed in an application. If only we could see a list of services… perhaps we could find some information about the right one to work with? Let’s try this:

php bin/console debug:container product_variant

You get a list of service ids and going through them, you’ll see one with “sylius.form.type.product_variant” which looks like what we need. Select that and note that the class we need to extend is Sylius\Bundle\ProductBundle\Form\Type\ProductVariantType.

Create a Service

Let’s create src/AppBundle/Resources/config/services.yml and add this:

services:
    app.form.extension.type.product_variant:
        class: AppBundle\Form\Type\Extension\ProductVariantTypeExtension
        tags:
            - { name: form.type_extension, extended_type: Sylius\Bundle\ProductBundle\Form\Type\ProductVariantType }

Create a Class

The service defined above is for a class. Let’s create src/AppBundle/Form/Type/Extension/ProductVariantTypeExtension.php, and add this:

<?php

// src/AppBundle/Form/Type/Extension/ProductVariantTypeExtension.php

namespace AppBundle\Form\Type\Extension;

use Sylius\Bundle\ProductBundle\Form\Type\ProductVariantType;
use Symfony\Component\Form\AbstractTypeExtension;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;

class ProductVariantTypeExtension extends AbstractTypeExtension
{
    /**
     * {@inheritdoc}
     */
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder->add(
          'reorderLevel',
          TextType::class,
          [
            'required' => false,
            'label' => 'sylius.form.variant.reorder_level',
          ]
        );
    }

    /**
     * {@inheritdoc}
     */
    public function getExtendedType()
    {
        return ProductVariantType::class;
    }
}

The label requires a messages configuration file. We’ll create app/Resources/SyliusAdminBundle/translations/messages.en.yml and add this:

sylius:
    form:
        variant:
            reorder_level: 'Reorder level'

We can see the hierarchy of the keys in the name – sylius.form.variant.reorder_level. Let’s inform Sylius about the service in app/config/config.yml, by adding this under imports:

- { resource: "@AppBundle/Resources/config/services.yml"}

Overriding a Template

Now, how do we find the template to override? We go to a product variant list and edit one of them, so that we can identify the template used to display the form. Across the bottom of the page is the Web Debug Toolbar and Profiler.

On the left is the HTTP Status Code and next to it is the Route name. Clicking on the route name shows the profiler. There’s a lot of data about the Request among which is the Request Attributes section, with the following keys: _controller, _route, _route_params and id. The Value column for _sylius shows an arrow, indicating an array. Clicking on it expands the array with “section”, “template”, “redirect”, “permission” and “vars” keys. The value of “template” is SyliusAdminBundle:Crud:update.html.twig which is located at src/Sylius/Bundle/AdminBundle/Resources/views/Crud/update.html.twig.

Looking at update.html.twig, among a number of includes is this one – {% include '@SyliusAdmin/Crud/Update/_content.html.twig' %}. Again, that is found at src/Sylius/Bundle/AdminBundle/Resources/views/Crud/Update/_content.html.twig. There’s nothing interesting in the file. Dead end!

If we go back to the Request Attributes and expand _vars and then the templates key, we see the @SyliusAdmin/ProductVariant/_form.html.twig which we find in src/Sylius/Bundle/AdminBundle/Resources/views/ProductVariant/_form.html.twig. In _form.html.twig, there’s a Twig function knp_menu_get(). Menu? What menu? We’re looking for a form. This is a different type of page, built with different templates. Looking back at the page itself, we notice two tabs – “Details” and “Taxes”. Tabs and menus do go together so, maybe we’re on the right track.

Returning to the list of services we saw with php bin/console debug:container product_variant, we may be able to find one with “menu” and “product_variant”. Indeed, there’s one: sylius.admin.menu_builder.product_variant_form which when we debug, gives us some interesting information:

Debugged menu builder form

For the Tags option, the value is knp_menu.menu_builder (method: createMenu, alias: sylius.admin.product_variant_form). That alias is the first parameter of the knp_menu_get() method in our _form.html.twig template. We’re definitely on the right track.

From the debugging information above, we also know that the Sylius\Bundle\AdminBundle\Menu\ProductVariantFormMenuBuilder class is responsible for the form. Opening up the class and looking at the createMenu() method we see our details and taxes tabs with their respective templates – @SyliusAdmin/ProductVariant/Tab/_details.html.twig and @SyliusAdmin/ProductVariant/Tab/_taxes.html.twig. We can confirm that we’ve found the right template to override by checking src/Sylius/Bundle/AdminBundle/Resources/views/ProductVariant/Tab/_details.html.twig.

Let’s copy the file to app/Resources/SyliusAdminBundle/views/ProductVariant/Tab/_details.html.twig. Finding the inventory section, we add the new field {{ form_row(form.reorderLevel) }} anywhere. I put mine between {{ form_row(form.onHand) }} and {{ form_row(form.tracked) }}. If we go back to the product variant form, the textfield should be right there. We can change the default to any number, and save the file.

Conclusion

Our series’ final part is complete – we were able to add new functionality to Sylius without incurring long-term technical debt, and without sacrificing the app’s 100% test coverage. We covered all types of testing available in this e-commerce framework, and used its best practices to their full potential.

In this part in particular, we’ve looked at writing SpecBDD tests into a bit more detail and how to overwrite Sylius models and forms. There’ll be different and more interesting ways of achieving the same goals. Let us know your take on some of them.

Are you using Sylius yet? Why/why not? What are some pitfalls you see in our approaches? Let’s discuss!

Sponsors