on error resume next

Reducing unnecessary details in tests

This post is part of a series

Better Testing

In this series I write about testing strategies I have learned and developed over the years to help you write better tests for your applications.

  1. Reducing unnecessary details in tests
  2. Using abstract tests to save your sanity (coming soon)
  3. DRY-ness in tests (coming soon)
  4. Learning to listen to your tests (coming soon)

Automated tests serve multiple purposes. They assert that your code works the way you expect it to. They allow you to verify that the code still works the way you expect to after making a change. But they also serve another purpose that I think is often overlooked: they are documentation. As such, tests should not only be easy to write and execute, they should be easy to read.

Tests as documentation

Documentation has this annoying property that it seems to become outdated as soon as you hit Save. This might be a bit of an exaggeration but not a big one. I think most of us have stumbled across a comment in a codebase that was saying one thing but the code did something else. I'm curious how many developer hours/days/months have been lost to incorrect documentation. I bet it's a lot.

In my opinion, tests are the ultimate documentation because it is documentation that is provably correct (or incorrect). I have found the best way of familiarizing yourself with a codebase or parts of a codebase is to read through its test suite. If the test suite is well-written (and I realize that's a pretty big if), they serve as an executable specification.

What makes a test "readable"?

What constitutes readable code–in our case, test code–is of course highly subjective and the cause of many a flame war. So this is not about how you should indent your code and where your brackets should go. It is instead about the contents of your test.

In this post I want to talk about unnecessary details in tests. These are things that draw your attention but aren't really important to the test. This can make it hard to understand what the test is really about or which parts you should be paying attention to.

Let's look at an example. Note that I am using some of Laravel's testing helpers but the concept applies to any kind of test.

class GameTest extends TestCase
{
    /** @test */
    public function creatingANewGame(): void
    {
        $user = User::factory()->create();

        $this->actingAs($user)->post(route('games.store'), [
          'name' => 'My super cool game',
          'is_public' => 1,
          'description' => 'A really long description that no one will ever read.',
        ]);

        $this->assertDatabaseHas('games', [
          'name' => 'My super cool game',
          'is_public' => 1,
          'description' => 'A really long description that no one will ever read.',
        ]);
    }
}

In this example, we're testing that we can create a new game by sending a POST request to a specific endpoint. Then, to assert that it worked, we check that an entry exists in the correct table with the correct attributes.

So far so good. Let's look at another example. Here's a test for a value object that represents some kind of serial number.

class SerialNumberTest extends TestCase
{
  /** @test */
  public function canBeConstructedWithAValidSerialNumber(): void
  {
    $serialNumber = SerialNumber::fromString('ABCDEFG10234');

    $this->assertEquals('ABCDEFG10234', (string) $serialNumber);
  }

  /** @test */
  public function throwsAnExceptionIfSerialNumberIsInvalid(): void
  {
    $this->expectException(InvalidArgumentException::class);

    SerialNumber::fromString('9999999999');
  }

  // ... more test cases
}

These tests are obviously very different. One is testing an entire endpoint whereas the other is testing a single value object. That's not what I want you to pay attention to, however.

We're interested in all these strings that get passed around. The name and description of our game, and the string from which we are constructing our SerialNumber value object. They are actually quite different and not in a they-obviously-don't-have-the-same-value kind of way.

They serve completely different purposes in their respective tests.

The value of a string

Let's look at the first test again.

/** @test */
public function creatingANewGame(): void
{
    $user = User::factory()->create();

    $this->actingAs($user)->post(route('games.store'), [
        'name' => 'My super cool game',
        'is_public' => 1,
        'description' => 'A really long description that no one will ever read.',
    ]);

    $this->assertDatabaseHas('games', [
        'name' => 'My super cool game',
        'is_public' => 1,
        'description' => 'A really long description that no one will ever read.',
    ]);
}

Does it matter that the name of our game is "My super cool game"? It doesn't, all we care about is that it matches the value that ends up being saved in the database. In other words, we don't care about the value of this string but about what it represents.

Now, let's look at our value object test again:

/** @test */
public function canBeConstructedWithAValidSerialNumber(): void
{
    $serialNumber = SerialNumber::fromString('ABCDEFG10234');

    $this->assertEquals('ABCDEFG10234', (string) $serialNumber);
}

/** @test */
public function throwsAnExceptionIfSerialNumberIsInvalid(): void
{
    $this->expectException(InvalidArgumentException::class);

    SerialNumber::fromString('9999999999');
}

Part of the responsibility of the value object is to ensure that it always represents a valid serial number. This means that the value of the serial number string does matter, in fact it's the most important bit of information in these tests. It communicates to the reader that the exact value of ABCDEFG10234 is a valid serial number whereas 9999999999 isn't.

Make it obvious what's important and what isn't

So whenever we can, we wan't to make it obvious when we care about the value of a string and when we only care about what it represents. I'm going to share a technique I learned from J.B. Rainsberger's humbly-named course The World's best intro to TDD (no affiliation).

Whenever I don't care about the value of a string, I write it using a ::specific-syntax::. Here's how I would rewrite our first example using this technique.

/** @test */
public function creatingANewGame(): void
{
    $user = User::factory()->create();

    $this->actingAs($user)->post(route('games.store'), [
        'name' => '::name::',
        'is_public' => 1,
        'description' => '::description::',
    ]);

    $this->assertDatabaseHas('games', [
        'name' => '::name::',
        'is_public' => 1,
        'description' => '::description::',
    ]);
}

This communicates that ::name:: and ::description:: represent the name and description of the game, respectively. But it isn't important what their actual values are. They could be anything as long as they match what gets saved in the database.

Then, whenever you see a test where I don't use this ::syntax::, you will know that the specific value of this string is relevant here. Like in the serial number example.

/** @test */
public function canBeConstructedWithAValidSerialNumber(): void
{
    // This can't just be any string. The value matters.
    $serialNumber = SerialNumber::fromString('ABCDEFG10234');

    $this->assertEquals('ABCDEFG10234', (string) $serialNumber);
}

Conclusion

Tests are documentation. Whenever we write tests we need to make sure to remove as much ambiguity as possible. By using the double-colon ::syntax:: we can remove a possible source of confusion. I have found this to be a really valuable change to how I write my tests. I can now tell at a glance which role a string serves in a test.

See you for the next post in this series!

The ugly mug of the author

By Kai Sassnowski

Hi! I'm a thirty-something developer from Munich, Germany. I mostly write about software development, specifically the Laravel PHP Framework.

Check out my Github and Twitter.