I see too many people writing brittle and bloated unit tests. Often it is experienced developers with years of coding. Ultimately, they never used unit tests extensively. Consequently, they lack understanding of writing resilient and easy-to-support unit tests. You might not think of yourself as one of these guys, but let me show three simple technics that will immediately improve your unit test quality.

TL;DR

  • Test in isolation — do not rely on the behavior of dependencies but mock them instead.
  • Check all edge cases — if you find too many of them, then refactor your code.
  • Verify your tests using mutations — meet Infection , a mutation testing framework. It helps to find missing edge-cases.

Test in Isolation

One of the most common mistakes is a test relying on the behavior of dependencies. It leads to the broken test when a dependency changes its behavior. It also violates incapsulation, but it is another story.

Usually, this mistake is made when covering legacy with unit tests. Also, people are making it when writing new code from scratch. There are two reasons why it happens:

  1. Due to the use of functions or static calls.
  2. Because of not knowing about mocks (or using stubs for dependencies).

Let me give you an example (it is in PHP but keep reading – the same applies to any OOP language):

interface Fighter
{
    public function getHealth(): int;
    public function setHealth(int $health): self;
}

class Fight
{
    public function attack(Fighter $attacker, Fighter $defender)
    {
        $damage = DamageCalculator::calculate($attacker, $defender);
        $defender->setHealth($defender->getHealth() - $damage);
    }
}

class FighterStub
{
    // here goes an implementation, skipping it to keep it concise
}

And they would write a test like that:

class FightTest extends TestCase
{
    public function testFight()
    {
        $attacker = new FighterStub(1,2,10);
        $defender = new FighterStub(2,1,10);

        $fight = new Fight();
        $fight->fight($attacker, $defender);

        $this->assertEquals(10, $defender->getHealth());
    }
}

What is wrong with this test? There are two issues:

  1. It covers the Fight class but relies on the implementation details of DamageCalculator
  2. It uses a stub for Fighter

The DamageCalculator class is a dependency and should be mocked. But to do it, we have to inject it first. Here is the fix:

class Fight
{
    public function __construct(private DamageCalculator $damageCalculator) {}

    public function attack(Fighter $attacker, Fighter $defender)
    {
        $damage = $this->damageCalculator->calculate($attacker, $defender);
        $defender->setHealth($defender->getHealth() - $damage);
    }
}

class FighterStub
{
    // here goes an implementation
}

class FightRunnerTest extends TestCase
{
    public function testFightRunnerNoChange()
    {
        $attacker = new FighterStub(1,2,10);
        $defender = new FighterStub(2,1,10);

        $damageCalculator = $this->getMockBuilder(DamageCalculator::class)->getMock();
        $damageCalculator->method('calculate')->willReturn(10);

        $fight = new FightRunner($damageCalculator);
        $fight->attack($attacker, $defender);

        $this->assertEquals(10, $defender->getHealth());
    }
}

When it comes to the stub, you might argue that stubs are not bad per se. I do not deny that there are valid use-cases for stubs. But normally mocks are better. I only recall one use-case in my experience when a stub was better.

But in this case, you can mock the Fighter interface in one line of code. Compare it to the sub with two methods implemented and a constructor.

Moreover, with mocks, you get a clear and easy-to-read test. You instantly see:

  • What methods are being called
  • What they accept/return

With the stub, you have to remember the meaning of these positional arguments. It requires more mental effort and more error-prone.

Check all edge cases

It might sound obvious, yet people often cover only a happy path. If you feel that finding all edge cases is hard, perhaps your class is doing too much. Long private methods are a red flag for it. Typically it means you need to extract a method and mock the dependency. Say we have the following class:

class UserFilterAdult
{
    private const AGE_ADULT = 18;

    public function __invoke(User $user)
    {
        return $user->getAge() >= self::AGE_ADULT;
    }
}

And a unit test for it:

class UserFilterAdultTest extends TestCase
{
    public function testReturnsTrueForAdults()
    {
        $filter = new UserFilterAdult();
        $user = new User(25);
        
        $this->assertTrue($filter($user));
    }
}

We are only testing the happy path. The code might change to AGE_ADULT = 25, yet the test would still pass. Curious, how do I know if I covered all the edge cases? Proceed to the next chapter to learn.

Verify Your Tests Using Mutations

Mutation testing involves modifying a source code in small ways. Each variant is called a mutant. The modifications made are not random but based on a predefined set of rules. A unit test is runs for every mutant. The green test is called an escaped mutant. Usually, it means we have a missing edge case. Based on escaped mutants a Mutation Score Indicator (MSI) is calculated. You have to strive to get 100%.

Infection is a mutation testing framework for PHP. There are frameworks for other mainstream languages as well. Just google it for your favorite language. For example, for Python, you can use MutPy .

How to Use Infection

The workflow for daily usage is:

  1. You write a new class, e.g. UserFilterAdult
  2. Cover it with a unit test, UserFilterAdultTest
  3. Run Infection for the class

You see the results and try to get a 100% MSI score. Keep in mind that 100% MSI is not always possible. Some expressions might be interchangeable, for example $a * 1 and $a / 1. In both cases, the result is $a.

See it in action:

./infection.phar --filter=UserFilterAdult.php --show-mutations

The output:

Processing source code files: 1/1
.: killed, M: escaped, U: uncovered, E: fatal error, X: syntax error, T: timed out, S: skipped, I: ignored

M.                                                   (2 / 2)
Escaped mutants:
================


1) /home/alex/Documents/PRIVATE/code-examples/src/writing-resilient-unit-tests/UserFilterAdult.php:11    [M] GreaterThanOrEqualTo

--- Original
+++ New
@@ @@
     private const AGE_ADULT = 18;
     public function __invoke(User $user)
     {
-        return $user->getAge() >= self::AGE_ADULT;
+        return $user->getAge() > self::AGE_ADULT;
     }
 }

The test is not checking the border value. Because of that, we got a mutant escaped. Let us fix it:

class UserFilterAdultTest extends TestCase
{
    public function testReturnsTrueForAdults()
    {
        $filter = new UserFilterAdult();
        $user = new User(25);
        
        $this->assertTrue($filter($user));
    }
}

And run Infection again:

       2 mutations were generated:
       2 mutants were killed
       0 mutants were configured to be ignored
       0 mutants were not covered by tests
       0 covered mutants were not detected
       0 errors were encountered
       0 syntax errors were encountered
       0 time outs were encountered
       0 mutants required more time than configured

Metrics:
         Mutation Score Indicator (MSI): 100%
         Mutation Code Coverage: 100%
         Covered Code MSI: 100%

Now we got a 100% MSI score and gained confidence that all edge cases are covered.

Conclusion

I showed you three simple methods that one can learn in one hour. They will make your unit test bullet-proof. If you are not doing it yet — give it a try. I promise your unit test will become better and your life easier.