Sylius and Cutting Your Teeth on TDD

Deji Akala
Share

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 logo

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

PHPUnit in action

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

PHPSpec in action in Sylius

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

Feature being tested

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.

A passing icon test

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 and OrderPage:

    // 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 in src/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" }
    
  • Copy src\Sylius\Bundle\UiBundle\Resources\views\Macro\table.html.twig to app\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 to app\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 to app\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 to app\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 to app\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 to app\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.

  1. 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
    
  2. 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();  
    
  3. 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 sortable th elements on the page.

  4. 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.

A passing Behat test

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!