Quick Testing Tips: Self-Contained Tests

Posted on by Matthias Noback

Whenever I read a test method I want to understand it without having to jump around in the test class (or worse, in dependencies). If I want to know more, I should be able to "click" on one of the method calls and find out more.

I'll explain later why I want this, but first I'll show you how to get to this point.

As an example, here is a test I encountered recently:

public function testGetUsernameById(): void
{
    $userRepository = $this->createUserRepository();

    $username = $userRepository->getUsernameById(1);

    self::assertSame('alice', $username);
}

The way I read this:

/*
 * Ah, we're testing the UserRepository, so we instantiate it.
 * The factory method probably injects a connection to the test 
 * database or something:
 */
$userRepository = $this->createUserRepository();

/*
 * Now we fetch a username by its ID. The ID is 1. That's the
 * first time I see it in this test. This probably means that
 * there is no user with this ID and the method will throw
 * an exception or return a default name or something:
 */
$username = $userRepository->getUsernameById(1);

/*
 * Wait, the username is supposed to be "alice"?
 * Where did that come from?
 */
self::assertSame('alice', $username);

So while trying to understand this test that last line surprised me. Where does Alice come from?

As it turns out there is a setupTables() function which is called during the setup phase. This method populates the database with some user data that is used in various ways by one of the test methods in the class.

private function setupTables(): void
{
    $this->connection->table('users')
        ->insert(
            [
                ['user_id' => 1, 'username' => 'alice', 'password' => 'alicepassword'],
                ['user_id' => 2, 'username' => 'bob', 'password' => 'bobpassword'],
                ['user_id' => 3, 'username' => 'john', 'password' => 'johnpassword'],
                ['user_id' => 4, 'username' => 'peter', 'password' => 'peterpassword'],
            ]
        );
    // ...
}

There are some problems with this approach:

  • It's not clear which tests rely on which database records (a common issue with shared database fixtures). So it's hard to change or remove tests, or the test data, when needed. As an example, if we remove one test, maybe some test data could also be removed but we don't really know. If we change some test data, one of the tests may break.
  • It's not clear which of the values is actually relevant. For example, we're interested in user 1, 'alice', but is the password relevant? Most likely not.

The first thing we need to do is ensure that each test only creates the database records that it really needs, e.g.

public function testGetUsernameById(): void
{
    $this->connection->table('users')
        ->insert(
            [
                'user_id' => 1, 
                'username' => 'alice', 
                'password' => 'alicepassword'
            ]
        );

    $userRepository = $this->createUserRepository();

    $username = $userRepository->getUsernameById(1);

    self::assertSame('alice', $username);
}

At this point the test is already much easier to understand on its own. You can clearly see where the number 1 and the string 'alice' come from. There's only that 'alicepassword' string that is irrelevant for this test. Leaving it out gives us an SQL constraint error. But we can still get rid of it here by extracting a method for creating a user record, moving the insert() out of sight:

public function testGetUsernameById(): void
{
    $this->createUser(1, 'alice');

    $userRepository = $this->createUserRepository();

    $username = $userRepository->getUsernameById(1);

    self::assertSame('alice', $username);
}

private function createUser(int $id, string $username): void
{
    $this->connection->table('users')
        ->insert(
            [
                'user_id' => $id, 
                'username' => $username, 
                'password' => 'a-password'
            ]
        );
}

Going back to the beginning of this post:

  • When I read a test method I want to understand it without having to jump around in the test class (or worse, in dependencies).
  • If I want to know more, I should be able to "click" on one of the method calls and find out more.

With just a few simple refactoring steps we've been able to achieve these things. As a consequence we achieve the greater goal, the reason why I stick to these rules: each test method is now self-contained, meaning we can delete or change any of them without influencing the other test methods.

At the point where a test is self-contained like this, I try to go the extra mile by rephrasing it using the Given/When/Then syntax:

Given the user with ID 1 has username "alice"
When getting the username of the user with ID 1
Then the username is "alice"

In my opinion this doesn't add much insight and only shows that the repository can do a SELECT query for something that was just INSERTed. The big question here is: why do we even have to find out what the username is? Once we know we should codify the answer to this question in a test. So instead of testing that single repository method, I'd rather see it being used in its bigger context and read the test for that. E.g.

Given user 1 has username "alice"
When we send a mail to this user
Then the footer of the mail shows "To find out more, log in with your username: alice"

This is actually much better, since this test takes a much safer distance to the subject under test; it leaves the design design to use a repository method for finding the username an implementation detail.

This post has been inspired by some development coaching work I'm doing for PinkWeb at the time of writing. Check out their vacancies if you'd like to join the team as well!

PHP testing fixtures
Comments
This website uses MailComments: you can send your comments to this post by email. Read more about MailComments, including suggestions for writing your comments (in HTML or Markdown).
MichaƂ Lipek
You can use BDD without using Behat. Good tests should test behaviours, so good tests are by definition compliant with the BDD rules. Behat is just a tool that, in theory, should make writing tests easier, although I personally have the opposite opinion.
Dave Farley explained it in a very good way. I recommend his video on YouTube "TDD vs BDD".
Matthias Noback

Totally agreed, I've also described that here: https://matthiasnoback.nl/2021/09/write-unit-tests-like-scenarios/

Brandon A. Olivares

Hi Matthias, great post! Just a question: when you say you try to use the given/when/then syntax, are you saying you tend to use Behat? Or do you somehow phrase your PHPUnit tests in that way? Would love to see an example.

Matthias Noback

Maybe you saw it already, but I'm writing about that approach here: https://matthiasnoback.nl/2021/09/write-unit-tests-like-scenarios/

nicolasleborgne

First of all, thank you Matthias for all the good content you give to the Php community !I am glad to read about this topic which we are huge fans in my current team. Maybe have you heard about the Test Data Builder pattern ? It goes one step further, providing a reusable fixture solution, tolerant to evolution and refactoring, without loosing readability. :)

Matthias Noback

Thanks for providing that link here. Yes, I use that in tests as well. A good rule of thumb is to minimize the number of new operators for each given class. This simplifies refactoring a lot.