Test-Driven Development with PHPUnit

In our previous 2 articles (“What is Test Driven Development?” and “Introduction to PHPUnit”), we discussed what Test-Driven Development is and why you should use it and we gave a very basic intro to PHPUnit. In this article, we’ll work through how to use PHPUnit to develop some new features using TDD.

A Brief Recap of TDD

TDD consists of 5 phases that we’ll be cycling through during the course of this article. They include:

  1. Quickly add a new test
  2. Run all tests and see the new one fail
  3. Make a little change
  4. Run all tests and see them all succeed
  5. Refactor to remove duplication

TDD Steps

If these phases are still a little fuzzy make sure to check out “What is Test-Driven Development?” for a full overview.

This example assumes we have PHPUnit > 8.0 installed in the ./vendor/bin directory of our project directory.

Example - SuperString::isEmpty is true

Let’s say our application has a SuperString class to enhance PHP’s string type and we need to add some functionality to check if the string we have is empty ("").

This is what the SuperString class looks like initially.

class SuperString
{
    private $string = null;

    public function __construct(string $string)
    {
        $this->string = $string;
    }
}

Quickly add a new test

The first thing we’re going to do is create a new test to check the result of one input to SuperString.

<?php

namespace Tests\Unit;

use PHPUnit\Framework\TestCase;
use App\SuperString;

class SuperStringTest extends TestCase
{
    public function testBlankStringCausesIsEmptyToReturnTrue(): void
    {
        $item = new SuperString('');
        $this->assertTrue($item->isEmpty());
    }
}

Notice how small the test is. We’re giving the test a very specific functionality to test and we’re only asserting one thing. If we have more than one assert per test we run the risk of making it difficult to debug later when something does eventually breaks.

Also, note that we’ve named the test function so we can easily understand what the test is doing. testIsEmptyTrue is much shorter but is ultimately not as understandable.

Run all tests and see the new one fail

Now we’ll run PHPUnit to see that we do indeed get a failing test. In this case, we haven’t yet defined the function so we get an “undefined method” error.

scott@keck-warren project % ./vendor/bin/phpunit tests/Unit/StringTest.php

PHPUnit 8.4.1 by Sebastian Bergmann and contributors.

E                                                                   1 / 1 (100%)

Time: 512 ms, Memory: 18.00 MB

There was 1 error:

1) Tests\Unit\SuperStringTest::testBlankStringCausesIsEmptyToReturnTrue
Error: Call to undefined method App\SuperString::isEmpty()

/Users/scott/project/tests/Unit/StringTest.php:13

ERRORS!
Tests: 1, Assertions: 0, Errors: 1.

Make a little change

Again, our goal in this phase is to make the smallest change we can to allow our tests to pass. To that end, we’re going to create our new isEmpty() function and just have it return true. It doesn’t cover all the possible inputs but the goal in this step isn’t to cover all the inputs it’s to get our test to pass. We’ll cover more inputs later.

// in SuperString
public function isEmpty(): bool
{
    return true;
}

Run all tests and see them all succeed

Now we run our test and verify that our test passes.

scott@keck-warren project % ./vendor/bin/phpunit tests/Unit/StringTest.php

PHPUnit 8.4.1 by Sebastian Bergmann and contributors.

.                                                                   1 / 1 (100%)

Time: 335 ms, Memory: 18.00 MB

OK (1 test, 1 assertion)

Refactor to remove duplication

Our code currently doesn’t contain any duplication but it’s important not to get lazy and skip this step.

Example - SuperString::isEmpty is false

Our simple implementation of isEmpty() is going to be wrong most of the time because of its current implementation. Now we need to add another test that checks for opposite cases where the string isn’t "". As a general rule, it’s a good idea to have tests for what we would consider normal input, the extremes of inputs (very large or very small), and spots where we can think of oddities happening.

Quickly add a new test

Here is our test. It’s essentially the inverse of our testBlankStringCausesIsEmptyToReturnTrue() function.

// in SuperStringTest
public function testValidStringCausesIsEmptyToReturnFalse(): void
{
    $item = new SuperString('Test Data');
    $this->assertFalse($item->isEmpty());
}

Run all tests and see the new one fail

scott@keck-warren project % ./vendor/bin/phpunit tests/Unit/StringTest.php

PHPUnit 8.4.1 by Sebastian Bergmann and contributors.

.F                                                                  2 / 2 (100%)

Time: 163 ms, Memory: 18.00 MB

There was 1 failure:

1) Tests\Unit\SuperStringTest::testValidStringCausesIsEmptyToReturnFalse
Failed asserting that true is false.

/Users/scott/project/tests/Unit/StringTest.php:19

FAILURES!
Tests: 2, Assertions: 2, Failures: 1.

Make a little change

Now we make a small change to our isEmpty() function so it passes all the tests.

// in SuperString
public function isEmpty(): bool
{
    return mb_strlen($this->string) == 0;
}

Run all tests and see them all succeed

scott@keck-warren project % ./vendor/bin/phpunit tests/Unit/StringTest.php

PHPUnit 8.4.1 by Sebastian Bergmann and contributors.

..                                                                  2 / 2 (100%)

Time: 195 ms, Memory: 18.00 MB

OK (2 tests, 2 assertions)

Refactor to remove duplication

Again due to the simple nature of our example there isn’t any duplication in our code at this point.

Example - SuperString::isNotEmpty

Now we have another feature that requires us to check to see if the string isn’t empty. To that end, we’re going to create an isNotEmpty() function that will complement our isEmpty() function.

Quickly add a new test

// in SuperStringTest
public function testBlankStringCausesIsNotEmptyToReturnFalse(): void
{
    $item = new SuperString('');
    $this->assertFalse($item->isNotEmpty());
}

Run all tests and see the new one fail

scott@keck-warren project % ./vendor/bin/phpunit tests/Unit/StringTest.php

PHPUnit 8.4.1 by Sebastian Bergmann and contributors.

..E                                                                 3 / 3 (100%)

Time: 163 ms, Memory: 18.00 MB

There was 1 error:

1) Tests\Unit\SuperStringTest::testBlankStringCausesIsNotEmptyToReturnFalse
Error: Call to undefined method App\SuperString::isNotEmpty()

/Users/scott/project/tests/Unit/StringTest.php:24

ERRORS!
Tests: 3, Assertions: 2, Errors: 1.

Make a little change

In this case, instead of returning false and then creating another test so we can write the functionality by going through all the TDD steps, we’re just going to trust ourselves and create the obvious implementation of the isNotEmpty() function.

// in SuperString
public function isNotEmpty(): bool
{
    return mb_strlen($this->string) != 0;
}

Run all tests and see them all succeed

scott@keck-warren project % ./vendor/bin/phpunit tests/Unit/StringTest.php

PHPUnit 8.4.1 by Sebastian Bergmann and contributors.

...                                                                 3 / 3 (100%)

Time: 167 ms, Memory: 18.00 MB

OK (3 tests, 3 assertions)

Refactor to remove duplication

Now here is where it gets interesting. The last two times we’ve hit this step we haven’t had anything to do but now look at our isEmpty() and isNotEmpty() functions.

// in SuperString
public function isEmpty(): bool
{
    return mb_strlen($this->string) == 0;
}

public function isNotEmpty(): bool
{
    return mb_strlen($this->string) != 0;
}

We can see some minor duplication in the two calls to mb_strlen($this->string). Now we just need to determine how we want to resolve this.

The first solution is to extract that duplication into a new function because we’ll most likely need the same logic again.

public function isEmpty(): bool
{
    return $this->length() == 0;
}

public function isNotEmpty(): bool
{
    return $this->length() != 0;
}

public function length(): int
{
    return mb_strlen($this->string);
}

The second solution is to realize that isNotEmpty() returns the boolean opposite of isEmpty().

public function isEmpty(): bool
{
    return mb_strlen($this->string) == 0;
}

public function isNotEmpty(): bool
{
    return !$this->isEmpty();
}

In the end, the first solution gives us the best flexibility for future expansion so we’ll stick with that.

Finally, we need to run our tests again to verify that no errors crept into our code as we made these changes.

scott@keck-warren project % ./vendor/bin/phpunit tests/Unit/StringTest.php

PHPUnit 8.4.1 by Sebastian Bergmann and contributors.

...                                                                 3 / 3 (100%)

Time: 167 ms, Memory: 18.00 MB

OK (3 tests, 3 assertions)

Thanks for Reading!

Are you using PHPUnit and TDD? How is it going? Let us know in the comments if it’s going great or if you’re running into problems.