Upgrading Sylius the TDD Way: Exploring Behat
- CMS & FrameworksDebugging & DeploymentDesign PatternsE-CommerceFrameworksPatterns & PracticesTesting
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.
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
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:
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!