Sylius and Cutting Your Teeth on TDD
- CMS & FrameworksDebugging & DeploymentDesign PatternsDevelopment EnvironmentE-CommerceFrameworksPatterns & PracticesTesting
This article was peer reviewed by Christopher Pitt and Thomas Punt. Thanks to all of SitePoint’s peer reviewers for making SitePoint content the best it can be!
Sylius is an e-commerce application / framework based on Symfony. It boasts 100% code coverage, which is impressive for a PHP application of that size. In this article, we are going to walk through the different kinds of tests available and try out some Test and Behavior Driven Development (TDD/BDD). See the Sylius installation guide page for instructions, as this article assumes you have a working installation with example data and you can run Behat, PHPUnit and phpspec tests.
In the web root, there’s a src
folder which holds all Sylius-related code. This allows you to make use of the app
folder for your application development without unnecessarily treading on Sylius’ toes. As we are interested in test-driven development (first, write tests that fail before writing the code) let’s dive in, the Sylius way.
We start by setting up our test database.
php bin/console doctrine:database:create --env=test
php bin/console doctrine:schema:create --env=test
Types of Sylius Tests
Some of the basics of the tools below have already been covered in this post, but we’ll recap them here on Sylius examples to keep with the theme.
PHPUnit
Sylius comes with a lot of PHPUnit functional tests. The configuration file, phpunit.xml.dist
, is in the web root and the unit tests are in the tests
folder. From the root of our application, let’s run tests in tests/Controller/CountryApiTest.php
:
./vendor/phpunit/phpunit/phpunit -c ./phpunit.xml.dist tests/Controller/CountryApiTest
The command is made up of 3 parts – path to PHPUnit, our configuration file and the unit test class. Open tests/Controller/CountryApiTest.php
and take a look at the class. It extends JsonApiTestCase which we can trace back to ApiTestCase and WebTestCase, part of the Symfony framework package.
Phpspec
Behavior Driven Development (BDD) emerged from Test Driven Development (TDD), focusing attention on how a user interacts with an application and how the application behaves. SpecBDD is the part of BDD which considers how the smaller bits of an application work together to make things happen.
Sylius is installed with phpspec
which is the tool required for this. In your root directory, there’s also the phpspec.yml.dist
configuration file. Specifications are written in PHP classes and may be grouped in suites.
Remember, Sylius is a big application so there are a lot of files. Open src/Sylius/Component/Order/spec/Model/OrderItemSpec.php
.
The first thing to note is that no matter how deep the folder structure, you have specifications inside a spec
folder and the source code the tests apply to is easy to find. If you look at the level of the spec
folder, you’ll see Model
and inside it is an OrderItem
class. The spec for that class is spec/Model/OrderItemSpec.php
. Compare the functions and you can see how they are related.
When you run phpspec, you get more output with the --verbose
option and with -fpretty
you can get prettier outputs.
./bin/phpspec run -fpretty --verbose src/Sylius/Component/Order/spec/Model/OrderItemSpec.php
Behat
StoryBDD is the second side of the BDD coin which presents behavior in a narrative, focusing attention on the bigger picture of what should happen in the application. Behat is the primary tool that makes StoryBDD possible. You run Behat, it parses special files called features, and checks whether the behavior of your website matches the description in those files. Other tools are required to mimic or emulate the functionality of browsers. Mink is the library that Behat needs for that, while something like Laravel will use the excellent Dusk.
The description of the behavior you write is in a format called Gherkin. Each behavior is called a scenario, and a single feature file can have multiple scenarios. The files must have an extension of feature
.
Sylius comes with a behat.yml.dist
configuration file and a features
folder with sub-folders where feature files are organized. The configuration file and features folder must be at the same level. Open features/order/managing_orders/browsing_orders.feature
and look at the structure.
Let’s give this feature a test drive:
./bin/behat features/order/managing_orders/browsing_orders.feature
See BDD in Laravel: Getting Started with Behat and PhpSpec for another great article on this subject.
Let’s Do TDD
We’re going to modify the Orders list page the test-driven way. Log into the admin section, admin/login
. The icon in the top left corner looks perfect, but let’s try and change it to something else. Sylius uses Semantic UI for its front-end. Take a look at the icon set. Let’s say we want to replace the “shop” with “shopping basket” icon. Viewing the source of the Orders list page, we want to go from <i class="circular shop icon"></i>
to <i class="circular shopping basket icon"></i>
.
Step 1: Add a Feature
We aren’t starting from a blank page so we go the Sylius way. Order-related features are in features\order\managing_orders
, so we create a file there and call it browsing_orders_with_visual_display.feature
and add:
@viewing_page_icon
Feature: Browsing orders page with icon
In order to identify orders page
As an Administrator
I want to be able to see an icon on the orders page
Background:
Given the store operates on a single channel in "United States"
And the store has a product "PHP T-Shirt"
And the store ships everywhere for free
And the store allows paying with "Cash on Delivery"
And there is a customer "john.doe@gmail.com" that placed an order "#00000022"
And the customer bought a single "PHP T-Shirt"
And the customer chose "Free" shipping method to "United States" with "Cash on Delivery" payment
And I am logged in as an administrator
@ui
Scenario: Seeing an icon on the page
When I browse orders
Then the "shopping basket" icon should be visible on the page
The Background
sets the stage for the tests. For this to work, it needs to include the channel, product, shipping method, customer details, and the user who wants to view the page. Luckily enough Sylius has done the ground work for us.
Step 2: Add a Page Object
Sylius allows us to create new page objects defined in the Behat container in a etc\behat\services\pages.xml
file. We’re going to create an interface and implementation in src\Sylius\Behat\Page\Admin\Order\
.
<container xmlns="http://symfony.com/schema/dic/services" xmlns:xsi="https://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">
<parameters>
<parameter key="sylius.behat.page.admin.order.order_page.class">Sylius\Behat\Page\Admin\Order\OrderPage
</parameter>
</parameters>
<services>
<service id="sylius.behat.page.admin.order.order_page" class="%sylius.behat.page.admin.order.order_page.class%"
parent="sylius.behat.page.admin.order.index" public="false">
</service>
</services>
</container>
We’re informing the Behat service container about our OrderPage
class. Let’s create src/Sylius/Behat/Page/Admin/Order/OrderPageInterface.php
:
<?php
// src/Sylius/Behat/Page/Admin/Order/OrderPageInterface.php
namespace Sylius\Behat\Page\Admin\Order;
use Sylius\Behat\Page\SymfonyPageInterface;
use Sylius\Behat\Page\Admin\Crud\IndexPageInterface;
interface OrderPageInterface extends IndexPageInterface
{
/**
* Match icons by class.
*
* @param string $class
* The class to match icons against.
*
* @return mixed
* The matched icons or false.
*/
public function findIcons($class);
}
Then, src/Sylius/Behat/Page/Admin/Order/OrderPage.php
:
<?php
// src/Sylius/Behat/Page/Admin/Order/OrderPage.php
namespace Sylius\Behat\Page\Admin\Order;
use Behat\Mink\Exception\ElementNotFoundException;
use Behat\Mink\Session;
use Sylius\Behat\Page\SymfonyPage;
use Sylius\Behat\Service\Accessor\TableAccessorInterface;
use Symfony\Component\Routing\RouterInterface;
use Sylius\Behat\Page\Admin\Crud\IndexPage;
class OrderPage extends IndexPage implements OrderPageInterface
{
/**
* @inheritDoc
*/
public function findIcons($class)
{
$foundIcons = $this->getElement('icon')->find('css', '.' . implode('.', explode(' ', $class)));
if (!$foundIcons) {
throw new ElementNotFoundException($this->getSession(), 'Icons with class(es) ' . $class);
}
return $foundIcons;
}
/**
* @inheritDoc
*/
protected function getDefinedElements()
{
return array_merge(parent::getDefinedElements(), [
'icon' => '.icon',
]);
}
}
Sylius has defined over 120 elements (classes and IDs) for identifying different things on pages but apparently not icons. (Search src\Sylius\Behat
for them). We need to find the icon
class so we define our own in the getDefinedElements()
method. Our findIcons()
method looks for the class we pass in from our feature. When icons have more than one name, such as “shopping basket”, we need to find both names.
Step 3: Add a Context for the Tests to Run
A context is a class that is also provided as a service. Starting from src/Sylius/Behat/Resources/config/services.xml
you’ll find <import resource="services/contexts.xml" />
near the top. Following the subsequent imports, you’ll end up in src/Sylius/Behat/Resources/config/services/contexts/
where some context categories have been configured (cli.xml
, domain.xml
, hook.xml
, setup.xml
, transform.xml
and ui.xml
). We’re after the ui.xml
one. Open it. Find the sylius.behat.context.ui.admin.managing_orders
service and we add ours just below it:
<service id="sylius.behat.context.ui.admin.viewing_page_icon" class="Sylius\Behat\Context\Ui\Admin\ViewingPageIconContext">
<argument type="service" id="sylius.behat.page.admin.order.order_page" />
<tag name="fob.context_service" />
</service>
Note the relationship between the service id
and class
. Dots are replaced with backslashes and camel-casing of class names.
Create src\Sylius\Behat\Context\Ui\Admin\ViewingPageIconContext.php
and add this:
<?php
// src/Sylius/Behat/Context/Ui/Admin/ViewingPageIconContext.php
namespace Sylius\Behat\Context\Ui\Admin;
use Behat\Behat\Context\Context;
use Sylius\Behat\Page\Admin\Order\OrderPageInterface;
use Webmozart\Assert\Assert;
use Behat\Behat\Tester\Exception\PendingException;
class ViewingPageIconContext implements Context
{
/**
* @var OrderPageInterface
*/
private $orderPage;
/**
* @param OrderPageInterface $orderPage
*/
public function __construct(OrderPageInterface $orderPage)
{
$this->orderPage = $orderPage;
}
/**
* @Then /^the "([^"]*)" icon should be visible on the page$/
*/
public function theIconShouldBeVisibleOnThePage($class)
{
$icons = $this->orderPage->findIcons($class);
Assert::eq(count($icons), 1, sprintf('Icon with class, %s, not found', $class));
}
}
We inject our OrderPageInterface
into this class so that when Behat finds our scenario, we’re able to call the right method from the injected class. We expect only one icon and we assert it.
Let’s run our test. It fails because there’s no shopping basket icon yet. Now we can go ahead and make the change. Open src/Sylius/Bundle/AdminBundle/Resources/config/routing/order.yml
and change icon: cart
to icon: shopping basket
. Run the test and it should be green.
Next, we make a couple of other changes. Our store is a single-channel business, so we don’t need the Channel column. Secondly, we want the table row to have a different background depending on the state. For this, we’ll write another test before making the change. Cancel one or two orders in the admin section so that we have different states.
-
Update our feature file
features\order\managing_orders\browsing_orders_with_visual_display.feature
with this:@ui Scenario: Checking row of a cancelled order When I view the summary of the order "#00000022" And I cancel this order When I browse orders Then this order should have order payment state "Cancelled" And the row should be marked with the order payment state "Cancelled"
-
We’ll also update
OrderPageInterface
andOrderPage
:// src/Sylius/Behat/Page/Admin/Order/OrderPageInterface.php /** * Match table rows by class. * * @param string $class * The class to match table rows against. * * @return mixed * The matched rows or false. */ public function findTableRows($class);
// src/Sylius/Behat/Page/Admin/Order/OrderPage.php /** * @inheritDoc */ public function findTableRows($class) { $foundRows = $this->getElement('table')->find('css', '.' . $class); if (!$foundRows) { throw new ElementNotFoundException($this->getSession(), 'Rows with class ' . $class); } return $foundRows; }
The table element is already defined in Sylius so we just use it. We find all rows with the class passed in. When you run the test, it’s red. Now we can make the necessary changes.
-
Override “State” and “Channel” columns in the
sylius_admin_order
grid, defined insrc/Sylius/Bundle/AdminBundle/Resources/config/grids/order.yml
.- Create
app\config\grids.yml
and add this:
sylius_grid: grids: sylius_admin_order: fields: channel: enabled: false state: enabled: false
- Tell Sylius about this file by importing it in
app\config\config.yml
:
- { resource: "grids.yml" }
- Create
-
Copy
src\Sylius\Bundle\UiBundle\Resources\views\Macro\table.html.twig
toapp\Resources\SyliusUiBundle\views\Macro\table.html.twig
and in the{% macro row(grid, definition, row) %}
statement, replace<tr class="item">
with<tr class="item {{ row.state | lower }}">
. All we have to do is add CSS for the output classes here. -
Copy
src\Sylius\Bundle\AdminBundle\Resources\views\Order\Label\PaymentState\awaiting_payment.html.twig
toapp\Resources\SyliusAdminBundle\views\Order\Label\PaymentState\awaiting_payment.html.twig
. We want the icon and the label to be uppercase. Change the file to this:<span class="ui olive label"> {{ value|trans|upper }} </span>
-
Copy
src\Sylius\Bundle\AdminBundle\Resources\views\Order\Label\PaymentState\cancelled.html.twig
toapp\Resources\SyliusAdminBundle\views\Order\Label\PaymentState\cancelled.html.twig
. Change the file to this:<span class="ui yellow label"> {{ value|trans|upper }} </span>
-
Copy
src\Sylius\Bundle\AdminBundle\Resources\views\Order\Label\ShippingState\cancelled.html.twig
toapp\Resources\SyliusAdminBundle\views\Order\Label\ShippingState\cancelled.html.twig
. We do the same as above:<span class="ui yellow label"> {{ value|trans|upper }} </span>
-
Copy
src\Sylius\Bundle\AdminBundle\Resources\views\Order\Label\ShippingState\ready.html.twig
toapp\Resources\SyliusAdminBundle\views\Order\Label\ShippingState\ready.html.twig
. We do the same as above:<span class="ui blue label"> {{ value|trans|upper }} </span>
-
Copy
src\Sylius\Bundle\UiBundle\Resources\views\_stylesheets.html.twig
toapp\Resources\SyliusUiBundle\views\_stylesheets.html.twig
. Here we add our custom CSS file just below the default:<link rel="stylesheet" href="{{ asset('assets/admin/css/style.css') }}">
. There are other ways of doing this but this is the quickest way to keep our changes isolated. -
Add
assets/admin/css/style.css
with our magnificent CSS..order-row.new { background-color: #c5effd; } .order-row.cancelled { background-color: #fbe7af; }
Run the test again. It’s all green.
Refresh the orders page but you may also need to clear the cache first. Cancelled orders rows now have a different background color.
Finally, let’s add a test to assert that the Channel column is no more.
-
Update our feature file
features\order\managing_orders\browsing_orders_with_visual_display.feature
with this:Scenario: Not seeing a column on the page When I browse orders Then the "channel" column should not be visible on the page
-
Update
OrderPageInterface.php
with:// src/Sylius/Behat/Page/Admin/Order/OrderPageInterface.php /** * Get table headers. * * @return mixed * The array of headers keys or empty array. */ public function findTableHeaders();
-
Implement the new method in
OrderPage.php
:// src/Sylius/Behat/Page/Admin/Order/OrderPage.php /** * {@inheritdoc} */ public function findTableHeaders() { $headers = $this->getTableAccessor() ->getSortableHeaders($this->getElement('table')); return is_array($headers) ? array_keys($headers) : array(); }
Sylius comes with a
TableAccessor
class with a lot of useful methods for working with tables in tests. The default table headers are sortable and we simply call the method that fetches all sortableth
elements on the page. -
Update our feature context
ViewingPageIconContext.php
:// src\Sylius\Behat\Context\Ui\Admin\ViewingPageIconContext.php /** * @Then /^the "([^"]*)" column should not be visible on the page$/ */ public function theColumnShouldNotBeVisibleOnThePage($column) { $foundHeaders = $this->orderPage->findTableHeaders(); Assert::false(in_array(strtolower($column), $foundHeaders), sprintf('%s column not found', $column)); }
We get all table headers on the order page. The array of header elements is keyed by name e.g. date, customer, channel, number, paymentState, shippingState etc. representing the columns. We check the keys to confirm that our column is no longer on the page.
Conclusion
These are baby steps designed to illustrate a TDD process – think of what you want to achieve, write tests to check when it’s done, tests fail, make the changes required, and tests pass.
We’ve explored StoryBDD by writing a couple of Behat tests to demonstrate this process. Although these are simple examples, the same principles apply when doing more complex things or working with SpecBDD or PHPUnit tests.
Which approach do you take when testing? Only TDD? Only BDD? Both? Maybe BDDed TDD? Let us know!