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 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.
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?
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.
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:
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!
Frequently Asked Questions (FAQs) about Upgrading Sylius the TDD Way
What are the prerequisites for upgrading Sylius the TDD way?
Before you start upgrading Sylius using the Test-Driven Development (TDD) approach, you need to have a basic understanding of PHP and Symfony. You should also be familiar with the Sylius platform and its features. Additionally, you need to have a local development environment set up with PHP, Composer, and a database system like MySQL or PostgreSQL. Lastly, you should have PHPUnit installed for running tests.
How do I install Sylius for the first time?
To install Sylius for the first time, you need to use Composer, a dependency management tool for PHP. Run the command composer create-project sylius/sylius
in your terminal. This will create a new Sylius project in your current directory. After the installation process is complete, you can start the Symfony server and access your new Sylius project in your web browser.
How do I upgrade my existing Sylius project?
Upgrading an existing Sylius project involves updating the Sylius package using Composer. Run the command composer update sylius/sylius
in your project directory. This will update Sylius to the latest version. After updating, you should run your test suite to ensure that your application still works as expected.
What is Test-Driven Development (TDD)?
Test-Driven Development (TDD) is a software development methodology where you write tests before writing the code that passes those tests. The process involves writing a failing test, writing code to make the test pass, and then refactoring the code to improve its structure and readability. This approach helps ensure that your code is correct, maintainable, and free of bugs.
How do I write tests in Sylius?
Sylius uses PHPUnit for testing. To write tests, you need to create a test class that extends the PHPUnit\Framework\TestCase
class. In this class, you can write test methods that assert the expected behavior of your code. You can run your tests using the phpunit
command in your terminal.
How do I use PHPSpec for testing in Sylius?
PHPSpec is a tool for writing specification-style tests in PHP. To use PHPSpec in Sylius, you need to install it using Composer. Once installed, you can write spec classes that describe the behavior of your code. You can run your specs using the phpspec run
command in your terminal.
What are the benefits of upgrading Sylius the TDD way?
Upgrading Sylius the TDD way ensures that your application remains stable and bug-free during the upgrade process. By writing tests before upgrading, you can catch any potential issues early and fix them before they affect your application. This approach also helps you understand the changes in the new version of Sylius and how they affect your application.
How do I handle database migrations when upgrading Sylius?
Sylius uses Doctrine migrations for managing database changes. When you upgrade Sylius, you may need to run migrations to update your database schema. You can do this using the doctrine:migrations:migrate
command in your terminal.
What should I do if I encounter errors during the upgrade process?
If you encounter errors during the upgrade process, you should first check the error message for clues about what went wrong. You can also check the Sylius documentation and community forums for solutions to common problems. If you’re still stuck, you can ask for help on the Sylius Slack channel or Stack Overflow.
How do I keep my Sylius project up-to-date with the latest version?
To keep your Sylius project up-to-date, you should regularly check for new releases on the Sylius GitHub page. You can also subscribe to the Sylius newsletter to receive updates about new features and improvements. When a new version is released, you can upgrade your project using the process described above.
Deji, a Zend Certified PHP Engineer, works as a Software Engineer with the British Council in London. He's passionate about Open Source, contributes to Drupal and speaks at Drupal Camps. He has worked as a dentist, teacher of English as a Foreign Language and radio journalist. Oh yes, he's four times a dad and supports Arsenal FC.