Let's do TDD (with video)

Published at: 2022-11-23 19:00:00

I am a big fan of Test Driven Development and trying to promote it on every possible occassion. I am doing it because having a good test suite makes me confortable when refactoring or making changes in my code. Also it saved me a couple of times from creating a hidden bug while implementing or changing something.

In this blog post I'll guide you through the process how to start with TDD in any PHP project, how to install phpunit, and even how to make it fun. But let's start with a short introduction about the test driven development.

TLDR;

If you prefer video content scroll down to the bottom, there is a recording from a meetup, where I held a talk about this topic, or just click here to watch it. 

Why developers don't write tests?

We don't have time for it

That is the most common escuse for not writing tests. I can understand the time concern. Developers are usually under time pressure, and don't want to waste more time than necessary for a feature. But. You still have to think about the feature before you start implementing it, right? So while thinkig you could also write down your thoughts in a test. I admit you need some experience to quickly write the test cases, and in the beginning it will take some time. In my oppinion it still worth it. Every new thing you learn takes some time.

It is complicated

Yes, it might be. For the first couple of tests, afterwards it becomes a more streamlined process.

It is boring

Yes, if you:

  • write the tests after the feature, it is definitely boring.

  • write it beforehand it is less boring, but still not fun

  • do real test driven development, it can even be fun. I'll show you that later.

I already tested it manually

Yes, we all do. And it is still necessary, the TDD won't replace all the manual testing. My problem with this approach is: do you test all the other, possibly related features. Do you notice, that while implementing a new feature, you might introduced a bug?

Why do we need tests?

It depends on the situation and the project. Not always necessary. If we make a critical, urgent hotfix. If the feature/fix is so simple that testing would make an unnecessary overhead. Developing a feature that is temporary, or likely to change soon. A feature that is not an important part of the system.

Easy and confident refactoring

Have you been in a situation when all of you knew that a piece of code should be refactored, but everyone was afraid to touch it? I've been there couple of times.

The best thing a about test driven development is that with a good test coverage, refactoring the code becomes easy. When you can rely on your tests, you can confidently refactor the code and assure you don't change the functionality or break something.

Prevent bugs

Many times doing TDD you can think of or find bugs that are edge cases and can not be easily catched by manual testing.

Prevent breaking something else

In bigger systems components can be tied together in mutliple ways. Changing some part of the code might break something else. It is very nice when such things happens the test suite warns you about the mistake, and the broken code doesn't leave your machine, and doesn't create problems in production.

Test cases are reproducible

And last but not least, test cases are reproducible. You can run them as many times you want (best to run it on a CI). Who wants to test manually all the features of a web app after each push? No, I really don't :-)

Ok, but what kind of tests?

Unit tests

As the name suggests they are meant to test a small piece of code (units), for example a class or a function. Their advantage is that the are fast, and cheap (in terms of CI running time). As you can see from the testing  pyramid below, they are at the bottom, we should have a lot of them :-)

Integration tests

They are made to test some functionality of the app. For example test an api endpoint, or a controller action. They usually need database (or a fake/test database) and are more slow and expensive that unit tests. They should be responsible to assure the different components are working nicely together. We need less of these than unit tests.

E2E tests

Test everything from frontend to backend (End To End), usually these are browser tests. These test ensure that the whole system works as expected from user experience, to backed functionalities. They are slow and expensive. My advice is that you only write them for the most critial parts of the system.

Testing pyramid

Let's see some CODE already!

Setting up PHPUnit

I assume that your project already uses composer and PSR-4 autoloading. So let' install phpunit:

composer require --dev phpunit/phpunit

We install phpunit with --dev option to make it a dev dependency only. We don't need phpunit in production, and we can skip it in deploy process for example.

Our composer json looks similar to this

{
    "autoload": {
        "psr-4": {
            "App\\": "app/"
        }
    },
    "autoload-dev": {
        "psr-4": {
            "Tests\\": "tests/"
        }
    },
    "require-dev": {
        "phpunit/phpunit": "^9.5"
    }
}

Our application classes live in the app directory using the App namespace, and our tests live in the tests directory using Tests namespace.

We create the basic phpunit.xml to instruct phpunit how to run our tests:

<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         bootstrap="vendor/autoload.php"
         colors="true"
>
    <testsuites>
        <testsuite name="Unit">
            <directory suffix=".php">./tests</directory>
        </testsuite>
    </testsuites>
    <php>
        <env name="APP_ENV" value="testing"/>
    </php>
</phpunit>

We created one test suite a named it "Unit", which runs all the .php files from the "tests" directory. If you are interested in more details about phpunit configuration, please check the phpunit documentation.

Create the first tests

When doing test drive development, we do the following steps:

  • Write a failing test (but only the bare minimum - for the first step)
  • Write the code which makes the test pass (but only write that much code, which satisfies the test, no more)
  • Add more assertion to the test, so it fails again
  • Implement the required feature
  • Repeat until the feature is complete
  • Refactor the code, and make sure the tests still pass

Lets create our first test in test/Unit/CartTest.php:

<?php

namespace Unit;

use PHPUnit\Framework\TestCase;
use App\Cart;

class CartTest extends TestCase
{
    public function testAddItemToCart()
    {
        $cart = new Cart();

        $this->assertNotNull($cart);
    }
}

Run the phpunit:

vendor/bin/phpunit

The test will obviously fail, because we don't even have the Cart class, so create the class in app/Cart.php

<?php

namespace App;

class Cart
{

}

Run the phpunit again, and our tests should pass now

vendor/bin/phpunit

Our next step is to implement some functions to the cart. So lets expand our test and make it fail again:

public function testAddItemToCart()
{
    $cart = new Cart();

    $cart->addItem('P111', 'Pizza', 1, 110000);
}

Now lets add the $items property and an addItem method to the Cart:

class Cart
{
    protected array $items = [];

    public function addItem(string $code, string $name, int $quantity, int $price)
    {
        $this->items[] = [
            'code' => $code,
            'name' => $name,
            'quantity' => $quantity,
            'price' => $price
        ];
    }
}

Our test passes again, but we didn't do any proper assertation to makes sure that the method does what it should. So let's do this, assert that the number of the items in the cart equals to 1:

public function testAddItemToCart()
{
    $cart = new Cart();

    $cart->addItem('P111', 'Pizza', 1, 110000);

    $this->assertCount(1, $cart->getItems());
    $this->assertNotNull($cart);
}

If we run this test again, it fails, because we don't have getItems method yet, so let's create it:

/**
 * @return array
 */
public function getItems(): array
{
    return $this->items;
}

Running the test will pass, and this way we developed the first version of the method using TDD.

Now we have the ability to add items to cart, our next step is to implement a method to remove items from the cart. So we create a test for it. As a first step we create the test method without any assertion just call the remove items method:

    public function testRemoveItemFromCart()
    {
        $cart = new Cart();
        $cart->addItem('P111', 'Pizza', 1, 110000);         
        $cart->removeItem('P111');     
    }

We have failing test now because the method doesn't exist yet. Let's create it:

    public function removeItem(string $code)
    {
    }

We created the dummy method, but our test is not erroring out anymore. Now we add the assertion to the test and actually implement the method:

    public function testRemoveItemFromCart()
    {
        $cart = new Cart();
        $cart->addItem('P111', 'Pizza', 1, 110000);

        $cart->removeItem('P111');

        $this->assertCount(0, $cart->getItems());
    }

First we do a very basic implementation of the removeItem method:

    public function removeItem(string $code)
    {
        foreach ($this->items as $index => $item)
        {
            if ($item['code'] === $code){
                unset($this->items[$index]);
            }
        }
    }

This basic implementation, but it works, and the test passes. Now let's refactor it. As we already have test coverage, we can do it confidently, knowing that the functionality remanins the same:

    public function removeItem(string $code)
    {

        $this->items = array_filter($this->items, fn($item) => $item['code'] !== $code);

    }

The next step is to make our cart "smarter", so when we add multiple items with the same code, it increments the quantilty instead of adding a new item to the array, so we create the test first:

    public function testAddSameItemMutlipleTimesIncreasesQuantity()
    {
        $cart = new Cart();

        $cart->addItem('P111', 'Pizza', 1, 110000);
        $cart->addItem('P111', 'Pizza', 1, 110000);

        $expected = [
            [
                'code' => 'P111',
                'name' => 'Pizza',
                'quantity' => 2,
                'price' => 110000
            ]
        ];

        $this->assertEquals($expected, $cart->getItems());
    }

This failes becaue the current cart will add the item twice to the array. So we are changing it:

    public function addItem(string $code, string $name, int $quantity, int $price)
    {
        foreach($this->items as $index => $item) {
            if ($item['code'] === $code){
               $this->items[$index]['quantity'] += $quantity;
               return;
            }
        }

        $this->items[] = [
            'code' => $code,
            'name' => $name,
            'quantity' => $quantity,
            'price' => $price
        ];
    }

We added the foreach to check if we already have that item in the cart, and if yes, we increase the quantity. Otherwise just add the item to the items. Now the all the tests will pass. As we added this new feature to the add items, we need to modify the remove items as well to support this functionality. 

We create a test, and add two different items a pizza and two beers :), and assert the removal of one beer decrements the quantity:

    public function testRemoveItemDecreasesQuantityOrRemovesItem()
    {
        $cart = new Cart();
        $cart->addItem('P111', 'Pizza', 1, 110000);
        $cart->addItem('B111', 'Beer', 2, 10000);

        $cart->removeItem('B111');

        $expected = [
            [
                'code' => 'P111',
                'name' => 'Pizza',
                'quantity' => 1,
                'price' => 110000
            ],
            [
                'code' => 'B111',
                'name' => 'Beer',
                'quantity' => 1,
                'price' => 10000
            ]
        ];

        $this->assertEquals($expected, $cart->getItems());
    }

Once we have this failing test we change the removeItem method:

    public function removeItem(string $code)
    {
        foreach($this->items as $index => $item) {
            if ($item['code'] === $code){
                $this->items[$index]['quantity']--;
                return;
            }
        }
    }

Running our new test passes, but oops, we introduced  a bug! Running a whole test suite we notice that our previous test for removeItem fails. 

Let's fix that:

    public function removeItem(string $code)
    {
        foreach($this->items as $index => $item) {
            if ($item['code'] === $code){
                if ($item['quantity'] > 1){
                    $this->items[$index]['quantity']--;
                }
                else {
                    unset($this->items[$index]);
                }

                return;
            }
        }
    }

Now all out tests should pass, and we have a working cart with add and remove functions. This example proves the point that TDD can prevent itroducing bugs in the existing functionality.

Of course we can add more assertions and more features to it, but it is out of scope for this blog post, my goal was to show the method how the TDD can be part of the process of writing code and even be fun.

Some useful resources

If you'd like to learn more about testing, I can recommend to check out the following links:

The code of the above example, and a Laravel example on GitHub:

We held a meetup on this topic, please find the slides from the meetup below:

If interested in the meetup, please find the video of the presentation below: