PHP
Article
By Deji Akala

Upgrading Sylius the TDD Way: Exploring PhpSpec

By Deji Akala
Last chance to win! You'll get a... FREE 6-Month Subscription to SitePoint Premium Plus you'll go in the draw to WIN a new Macbook SitePoint 2017 Survey Yes, let's Do this It only takes 5 min

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!

The post on developing and testing new Sylius features was an introduction to the three types of tests that are used in Sylius – PHPUnit, Phpspec and Behat.

In this part, we’ll extend some core classes to indicate color-coded inventory status. First, we’ll deal with the back end part. In a followup post, we’ll use Behat and test the visual changes. Please follow the instructions in the previous post to get a working instance up and running.


Sylius logo

Sylius has an excellent inventory management solution. However, there’s always some room for a tweak or two. If you look at the list of products (admin/products) there’s no information about available stock. Looking at the variants of a product, we see inventory data, whether it’s tracked or not, and the number of total items in stock, if tracked. It would be nice to see that kind of information on the product listing page, too. In addition, the stock level is all or nothing – for example, a green label says “10 Available on hand” or red “0 Available on hand”. How about something in-between, say a yellow label for “3 Available on hand” to indicate that stock is low? Then, the store admin can decide it’s time to replenish.

--ADVERTISEMENT--

Extend ProductVariant and Product models

We want to extend the behavior of both the ProductVariant and Product models provided by Sylius, so we can see extra information on stock availability when viewing products.

Create a Bundle

First, we create the src/AppBundle/AppBundle.php file and register it in app/AppKernel.php.

<?php

// src/AppBundle/AppBundle.php

namespace AppBundle;

use Symfony\Component\HttpKernel\Bundle\Bundle;

class AppBundle extends Bundle
{
}
<?php

// app/AppKernel.php

public function registerBundles()
{
    $bundles = [
        // ...
        new AppBundle\AppBundle(),
    ];
}

Next, we inform the autoloader about the new bundle by adding it to the “autoload” section of composer.json.

{
  // ...
  "autoload": {
    "psr-4": {
      // ...
      "AppBundle\\": "src/AppBundle"
    }
  }
  // ...
}

We then run composer dump-autoload to re-generate autoloader information. Now, we can start writing some SpecBDD tests.

Writing SpecBDD tests

Our tests will focus on how our Product and ProductVariant classes help us achieve the goals we set for the implementation of the features we described above. Phpspec needs to know about our tests. We open phpspec.yml.dist and add the following under “suites”:

AppBundle: { namespace: AppBundle\Entity, psr4_prefix: AppBundle\Entity, spec_path: src/AppBundle/Entity, src_path: src/AppBundle/Entity }

It might be good to clear the cache now: php bin/console cache:clear. The next thing to do is to have specs where we write the expected behavior of our classes and Phpspec is able to assist us with that right from the start. The describe command is the starting point and it creates a specification class:

php bin/phpspec desc AppBundle/Entity/ProductVariant

The result:

Specification for AppBundle\Entity\ProductVariant created in src/AppBundle/Entity/spec/ProductVariantSpec.php

Let’s run it.

php bin/phpspec run src/AppBundle/Entity/spec/ProductVariantSpec.php

The class, ProductVariant, described in the spec doesn’t exist but Phpspec will offer to create it for us. After it’s been created, we can repeat the two steps above to get our Product class, too.

php bin/phpspec desc AppBundle/Entity/Product
php bin/phpspec run src/AppBundle/Entity/spec/ProductSpec.php

Sylius already has these two classes so we need to extend them in our AppBundle. Since Sylius uses interfaces a lot, we create those too.

<?php

// src/AppBundle/Entity/ProductInterface.php

namespace AppBundle\Entity;

use Sylius\Component\Core\Model\ProductInterface as BaseProductInterface;

interface ProductInterface extends BaseProductInterface
{
}
<?php

// src/AppBundle/Entity/ProductVariantInterface.php

namespace AppBundle\Entity;

use Sylius\Component\Core\Model\ProductVariantInterface as BaseProductVariantInterface;

interface ProductVariantInterface extends BaseProductVariantInterface
{
}
<?php

// src/AppBundle/Entity/Product.php

namespace AppBundle\Entity;

use Sylius\Component\Core\Model\Product as BaseProduct;
use AppBundle\Entity\ProductInterface;

class Product extends BaseProduct implements ProductInterface
{
}

Let’s update the generated ProductVariant class with the $reorderLevel property we want to introduce.

<?php

// src/AppBundle/Entity/ProductVariant.php

namespace AppBundle\Entity;

use AppBundle\Entity\ProductVariantInterface as ProductVariantInterface;
use Sylius\Component\Core\Model\ProductVariant as BaseProductVariant;

class ProductVariant extends BaseProductVariant implements ProductVariantInterface
{
    const REORDER_LEVEL = 5;

    /**
     * @var int
     */
    private $reorderLevel;

}

Overriding Sylius classes

Let’s inform Sylius about our classes so that they will be used in place of the ones provided by Sylius. We append the following to app/config/config.yml:

sylius_product:
    resources:
        product:
            classes:
                model: AppBundle\Entity\Product
        product_variant:
            classes:
                model: AppBundle\Entity\ProductVariant

We’ve added a $reorderLevel property that defaults to 5 to the ProductVariant class. Since this will differ from variant to variant, we need to alter the product_variant table so that we can store this information, too. Since Sylius uses Doctrine ORM, that’s the way to go. To extend the existing table definition, let’s create src/AppBundle/Resources/config/doctrine/ProductVariant.orm.yml, and add this:

AppBundle\Entity\ProductVariant:
    type: entity
    table: sylius_product_variant
    fields:
        reorderLevel:
            column: reorder_level
            type: integer
            nullable: true
           

The key for the field is the property name on the model. We have to specify the column name, otherwise Doctrine defaults to the property name of “reorderLevel”. If the property is a single word e.g. “level”, then the column can be left out of this configuration.

Next, let’s create src/AppBundle/Resources/config/doctrine/Product.orm.yml and add this:

AppBundle\Entity\Product:
    type: entity
    table: sylius_product

It’s empty, but we’re saying our extended Product model still stores data in the unmodified table.

Updating the database

Let’s run the following:

php bin/console doctrine:migrations:diff

This command generates a migration class in app/migrations. It’s usually the last one since the name includes a timestamp. It contains SQL statements to alter the product_variant table. In real projects, the generated code may not suffice but you may edit the file to meet your requirements. To migrate:

php bin/console doctrine:migrations:migrate

If this fails for some reason, try the following instead:

php bin/console doctrine:schema:update --force

Writing more SpecBDD tests

Now that we’ve got our models and database configured, let’s go back to our tests. Our first two examples should now give green results because the classes have been created. Let’s confirm with:

php bin/phpspec run -fpretty --verbose src/AppBundle/Entity/spec/ProductVariantSpec.php
<?php

// src/AppBundle/Entity/spec/ProductVariantSpec.php

namespace spec\AppBundle\Entity;

use AppBundle\Entity\ProductVariant;
use PhpSpec\ObjectBehavior;
use Prophecy\Argument;

class ProductVariantSpec extends ObjectBehavior
{
    function it_is_initializable()
    {
        $this->shouldHaveType(ProductVariant::class);
    }
}

$this represents a special object that will behave exactly like our ProductVariant class. However, it will only have properties and methods we specify in the examples. Let’s add a method to the spec class.

function it_has_reorder_level_by_default()
{
   $this->getReorderLevel()->shouldReturn(ProductVariant::REORDER_LEVEL);
}

We’d like our ProductVariant to have a reorder level value of 5 by default. Remember that our ProductVariant class is still empty. Let’s try and run the spec. The test fails nicely, with a hint:

Do you want me to create
  `AppBundle\Entity\ProductVariant::getReorderLevel()` for you?

Running Phpspec and getting confirmation for auto-creating a method

If we confirm, the getReorderLevel() method is stubbed out in the class. Phpspec expected to find getReorderLevel() which returns an integer, representing the inventory threshold below which the stock should be replenished. All we have to do in the method is return the $reorderLevel property.

Normally, return $this->reorderLevel; would be sufficient but we’ve already got products in our store so we need to find a way to get our default reorder level value. One way of doing this is:

/**
 * @return int
 */
public function getReorderLevel()
{
    return !empty($this->reorderLevel) ? $this->reorderLevel : self::REORDER_LEVEL;
}

The test passes. We may update the ProductVariantInterface with public function getReorderLevel();. Next, we want to make sure that we can set the reorder level. Here’s the example:

function it_has_reorder_level()
{
    $this->setReorderLevel(10);
    $this->getReorderLevel()->shouldReturn(10);
}

When we run it, Phpspec will know there’s no setReorderLevel() method and offer to create it. Update the created method as follows:

/**
 * @param int $reorderLevel
 */
public function setReorderLevel($reorderLevel)
{
    $this->reorderLevel = $reorderLevel;
}

We run it, and it’s all green. Don’t forget to update the interface, too. Now, we want to check if an item can be reordered, i.e. if it should be flagged as low enough to have it replenished. It has to be tracked and the quantity on hand has to be lower than or equal to the reorder level:

function it_is_reorderable_when_tracked_and_available_stock_is_at_reorder_level()
{
    $this->setTracked(true);
    $this->setOnHand(3);
    $this->setReorderLevel(4);
    $this->isReorderable()->shouldReturn(true);
}

When we run the spec, it finds all methods except isReorderable() which it offers to create for us in the ProductVariant class.

Another offer to create a method

In the method, we need to check that the variant is trackable and the quantity on hand is less than or equal to the reorder level.

public function isReorderable()
{
  return $this->isTracked() && ($this->getOnHand() <= $this->getReorderLevel());
}

All tests pass. Let’s update the interface with public function isReorderable();. You might also want to add a test for when the available stock is more than the reorder level: it shouldn’t be re-orderable.

function it_is_not_reorderable_when_tracked_and_available_stock_is_greater_than_reorder_level()
{
    $this->setTracked(true);
    $this->setOnHand(10);
    $this->setReorderLevel(4);
    $this->isReorderable()->shouldReturn(false);
}

Running the spec now should give us something like this:

Five passing tests

We’re done with ProductVariant. We will do something similar with the variants that are in a product, but in a slightly different way. We need a product for each example we write and it would be tedious to create a product with at least two variants for each.

In Phpspec, we can write a let() method that gets called before each example and letGo() after it’s been run. In src/AppBundle/Entity/spec/ProductSpec.php, we’ll add the following while remembering to import ProductVariantInterface with use AppBundle\Entity\ProductVariantInterface as VariantInterface; at the top of the class.

function let(VariantInterface $firstVariant, VariantInterface $secondVariant)
{
    $firstVariant->setOnHand(4);
    $firstVariant->getOnHand()->willReturn(4);
    $firstVariant->setReorderLevel(4);
    $firstVariant->getReorderLevel()->willReturn(4);
    $firstVariant->isTracked()->willReturn(true);
    $firstVariant->isReorderable()->willReturn(true);
    $firstVariant->setProduct($this)->shouldBeCalled();
    $this->addVariant($firstVariant);

    $secondVariant->setOnHand(10);
    $secondVariant->getOnHand()->willReturn(10);
    $secondVariant->setReorderLevel(3);
    $secondVariant->getReorderLevel()->willReturn(3);
    $secondVariant->isTracked()->willReturn(true);
    $secondVariant->isReorderable()->willReturn(false);
    $secondVariant->setProduct($this)->shouldBeCalled();
    $this->addVariant($secondVariant);
}

We can now proceed with writing examples and making sure our tests are green each step of the way:

php bin/phpspec run -fpretty --verbose src/AppBundle/Entity/spec/ProductSpec.php

The first thing we want to ensure is that the on-hand quantity of a product is a sum of on hand variants in the product. In ProductSpec.php, we add this example:

function it_gets_products_on_hand_as_sum_of_product_variants_on_hand()
{
    $this->getVariants()->shouldHaveCount(2);
    $this->getOnHand()->shouldReturn(14);
}

Of course, when you run it, the Product class doesn’t have a getOnHand() method so we ask Phpspec to create it for us before we implement it.

public function getOnHand()
{
    $onHand = 0;
    foreach ($this->getVariants() as $variant) {
        if ($variant->isTracked()) {
            $onHand += $variant->getOnHand();
        }
    }

    return $this->onHand = $onHand;
}

We mustn’t forget to add public function getOnHand(); to the ProductInterface, too.

Finally, a product is re-orderable when at least one of its variants is re-orderable. The example is as follows:

function it_recognizes_when_to_reorder_stock() {
    $this->isReorderable()->shouldReturn(true);
}

Again, we let Phpsepc create the isReorderable() method. Let’s update the ProductInterface with public function isReorderable();. The complete implementations should be:

public function isReorderable() {
    $variants = $this->getVariants();
    $trackedVariants = $variants->filter(function (ProductVariantInterface $variant) {
        return $variant->isReorderable() === true;
    });
    return $this->reorderable = count($trackedVariants) > 0;
}

We should also be able to check if a product is tracked or not by a similar logic – at least one of its variants must be trackable. The Phpspec example for this is straightforward as the two variants we’ve got are both trackable.

function it_recognizes_product_as_tracked_if_at_least_one_variant_is_tracked() {
    $this->isTracked()->shouldReturn(true);
}

We’ll now run the spec and update the generated isTracked() method with:

public function isTracked()
{
    $variants = $this->getVariants();
    $trackedVariants = $variants->filter(
        function (ProductVariantInterface $variant) {
            return $variant->isTracked() === true;
        }
    );

    return $this->tracked = count($trackedVariants) > 0;
}

All good. Perhaps we should add an example to make sure isTracked() returns false if no variant is tracked. Both variants in our let() method are already being tracked. However, if we pass in two variant variables with matching names as in the let() method, we can override the behavior of the objects in our new example.

function it_recognizes_product_as_not_tracked_if_no_variant_is_tracked(
  VariantInterface $firstVariant,
  VariantInterface $secondVariant
) {
    $firstVariant->isTracked()->willReturn(false);
    $secondVariant->isTracked()->willReturn(false);
    $this->isTracked()->shouldReturn(false);
}

We can also play around with the booleans somewhat. For example, if we change $this->isTracked()->shouldReturn(false); to $this->isTracked()->shouldReturn(true); , we get a message like:

AppBundle/Entity/Product
    recognizes product as not tracked if no variant is tracked
        expected true, but got false.

Let’s change it back to false, so we continue from all greens. That wraps up our tests for the Product and ProductVariant classes. There’s a couple of things we need later in Product, like a getOnHold() method which checks a similar method for its variants. We should end up with the class as follows:

<?php

// src/AppBundle/Entity/Product.php

namespace AppBundle\Entity;

use Sylius\Component\Core\Model\Product as BaseProduct;
use AppBundle\Entity\ProductVariantInterface as ProductVariantInterface;

class Product extends BaseProduct implements ProductInterface
{
    /**
     * @var int
     */
    protected $onHold = 0;

    /**
     * @var int
     */
    protected $onHand = 0;

    /**
     * @var bool
     */
    protected $tracked = false;

    /**
     * @var bool
     */
    protected $reorderable = false;


    public function isTracked()
    {
        $variants = $this->getVariants();
        $trackedVariants = $variants->filter(
            function (ProductVariantInterface $variant) {
                return $variant->isTracked() === true;
            }
        );

        return $this->tracked = count($trackedVariants) > 0;
    }

    public function getOnHand()
    {
        $onHand = 0;
        foreach ($this->getVariants() as $variant) {
            if ($variant->isTracked() === true) {
                $onHand += $variant->getOnHand();
            }
        }

        return $this->onHand = $onHand;
    }

    public function getOnHold()
    {
        $onHold = 0;
        foreach ($this->getVariants() as $variant) {
            if ($variant->isTracked()) {
                $onHold += $variant->getOnHold();
            }
        }

        return $this->onHold = $onHold;
    }

    public function isReorderable()
    {
        $variants = $this->getVariants();
        $trackedVariants = $variants->filter(
            function (ProductVariantInterface $variant) {
                return $variant->isReorderable() === true;
            }
        );

        return $this->reorderable = count($trackedVariants) > 0;
    }
}

Conclusion

We’ve now learned how to extend the core functionality of Sylius the right way – by thoroughly testing while we develop. We added some new features, allowed Phpspec to generate stubs for them, and made all tests turn from red to green before proceeding with our development.

In the final part of this “Extending Sylius” series, we’ll work with Behat and test the visual output of the changes our features have caused. Stay tuned!

Login or Create Account to Comment
Login Create Account
Recommended
Sponsors
Get the most important and interesting stories in tech. Straight to your inbox, daily.
Is it good?Is it good?