Documenting the system under test in PHPUnit

You are working in a code base you inherited from someone else or have not touched in years. Customers have repeatedly asked for a feature or demanded that you fix a bug. You already know which class or classes you need to change, but - you still have difficulty understanding how to use them and what they do.

In your attempts to understand the code before you can change it, you search for usages of that class. Luckily, there are a few references in test cases because, surprisingly, the code base has a few tests written in phpunit/phpunit!

Identifying the system under test

Unfortunately, the organization of test code is unclear.

You inspect test class names, but they do not reveal the system under test (SUT).

Some test cases set up one or more objects in the setUp() and assign these objects to fields. Which of these fields represents the system under test? Or are these collaborators of the system under test?

You inspect test method names, but they also do not reveal the system under test.

You dive deeper into the test methods, hoping to find a clear structure, trying to identify the system under test. The test methods, however, are long and miss a clear structure. Does this section arrange something needed for the tests? Does this part act on the system under test? Here are a few assertions. Are these expectations yet? Hmm, there is more code after expectations, invoking methods on objects. Perhaps these expectations are pre-conditions only and have nothing to do with asserting the behavior of the system under test?

Unfortunately, it's not the class you are interested in, so you continue your investigation.

Finally, after rummaging in the test cases for a while, you have identified the system under test.

Documenting the system under test

Once you have identified in existing test cases what the system under test is, how can you document the system under test for yourself? Or if you use Test-Driven Development, how can you document the system under test for someone else?

How can you prevent yourself and others from going through this time-consuming research again?

As I suggested, you could discuss and establish naming conventions for test cases and test methods. But naming conventions are difficult to enforce and a matter of taste.

As I pointed out, you could use the setUp() method in test classes to set up the system under test and use explicit naming for fields referencing the system under test. But you may realize that the setUp() method was never intended for setting up the system under test, but the environment. You may also conclude that tests are harder to understand if you must go back and forth between the setUp() and test methods to understand what is happening.

Aren't there better options?

Using annotations

If you have read DocBlocks before, you know @link and @see annotations. You can use these annotations to link to a URI, a class, a method, or a field.

<?php

declare(strict_types=1);

namespace Ergebnis\Package\Test\Unit;

use Ergebnis\Package\Example;
use PHPUnit\Framework;

/**
 * @see \Ergebnis\Package\Example
 */
final class ExampleTest extends Framework\TestCase
{
    // ...
}

However, these annotations are missing context.

Therefore, since you already use phpunit/phpunit, why not use annotations that phpunit/phpunit understands and add context to that relation?

@covers annotation

You can use the @covers annotation on the class level to document the system under test.

<?php

declare(strict_types=1);

namespace Ergebnis\Package\Test\Unit;

use Ergebnis\Package\Example;
use PHPUnit\Framework;

/**
 * @covers \Ergebnis\Package\Example
 */
final class ExampleTest extends Framework\TestCase
{
    // ...
}

You can also use the @covers annotation on the method level to document the system under test.

<?php

declare(strict_types=1);

namespace Ergebnis\Package\Test\Unit;

use Ergebnis\Package\Example;
use PHPUnit\Framework;

final class ExampleTest extends Framework\TestCase
{
    /**
     * @covers \Ergebnis\Package\Example
     */
    public function testFromStringRejectsEmptyValue(): void
    {
        // ...
    }

    /**
     * @covers \Ergebnis\Package\Example::fromString
     */
    public function testFromStringReturnsExample(): void
    {
        // ...
    }
}

@coversDefaultClass annotation

You can also use the @coversDefaultClass annotation on the method level in combination with the @covers annotation on the class level to avoid documenting the fully-qualified class name on the test method again.

<?php

declare(strict_types=1);

namespace Ergebnis\Package\Test\Unit;

use Ergebnis\Package\Example;
use PHPUnit\Framework;

/**
 * @coversDefaultClass \Ergebnis\Package\Example
 */
final class ExampleTest extends Framework\TestCase
{
    public function testFromStringRejectsEmptyValue(): void
    {
        // ...
    }

    /**
     * @covers ::fromString
     */
    public function testFromStringReturnsExample(): void
    {
        // ...
    }
}

@coversNothing annotation

You can use the @coversNothing annotation on the class level to document that there is no clear system under test.

<?php

declare(strict_types=1);

namespace Ergebnis\Package\Test\Unit;

use Ergebnis\Package\Example;
use PHPUnit\Framework;

/**
 * @coversNothing
 */
final class ExampleTest extends Framework\TestCase
{
    // ...
}

You can also use the @coversNothing annotation on method level to indicate that there is no clear system under test.

<?php

declare(strict_types=1);

namespace Ergebnis\Package\Test\Unit;

use Ergebnis\Package\Example;
use PHPUnit\Framework;

final class ExampleTest extends Framework\TestCase
{
    /**
     * @coversNothing
     */
    public function testFromStringReturnsExample(): void
    {
        // ...
    }
}

Using attributes

Since the release of phpunit/phpunit:10.0.0, you can use attributes instead of annotations.

CoversClass attribute

You can use the CoversClass attribute on the class level to document the system under test.

<?php

declare(strict_types=1);

namespace Ergebnis\Package\Test\Unit;

use Ergebnis\Package\Example;
use PHPUnit\Framework;

#[Framework\CoversClass(Example::class)]
final class ExampleTest extends Framework\TestCase
{
    // ...
}

CoversFunction attribute

You can use the CoversFunction attribute on the class level to document the system under test.

<?php

declare(strict_types=1);

namespace Ergebnis\Package\Test\Unit;

use Ergebnis\Package\Example;
use PHPUnit\Framework;

#[Framework\Attributes\CoversFunction('dd')]
final class ExampleTest extends Framework\TestCase
{
    // ...
}

CoversNothing attribute

You can use the CoversNothing attribute on the class level to document that there is no clear system under test.

<?php

declare(strict_types=1);

namespace Ergebnis\Package\Test\Unit;

use Ergebnis\Package\Example;
use PHPUnit\Framework;

#[Framework\Attributes\CoversNothing]
final class ExampleTest extends Framework\TestCase
{
    // ...
}

Advantages of documenting the system under test

Documenting the system under test with annotations or attributes has the following advantages:

If you add these annotations or attributes to test classes or test methods, phpunit/phpunit will filter out code that is executed in a test but not intended to contribute to code coverage.

This avoids unintentional code coverage: you execute code in tests, but the tests do not test and only use the code. Your actual test coverage may unintentionally be lower than what you think it is.

Adding annotations or attributes not only documents the systems under test but also forces you to write dedicated tests for code that otherwise you only use in tests.

If you add annotations or attributes to test classes or test methods, you can easily navigate from the test class or test method to the system under test.

This is very useful when you want to see test and system under test at the same time. In Test to the left, production to the right, I suggest using a split view in PhpStorm. I find it very convenient to open the class that contains the test first, then quickly navigate to the system under test.

Disadvantages of documenting the system under test

An obvious disadvantage of documenting the system under test is that you need to add annotations or attributes to the test class.

Enforcing the documentation of the system under test

Now that you have decided to document the system under test using annotations or attributes, how can you enforce it?

If you use annotations and friendsofphp/php-cs-fixer, you can enable the php_unit_test_class_requires_covers fixer.

<?php

declare(strict_types=1);

$finder = PhpCsFixer\Finder::create()->in(__DIR__);

$config = new PhpCsFixer\Config();

$config
    ->setFinder($finder)
    ->setRules([
        // ...
        'php_unit_test_class_requires_covers' => true,
        // ...
    ]);

return $config;

This fixer will add a @coversNothing annotation to a test class when the test class does not have an existing @covers or @coversNothing annotation. While this fixer can not add a @covers annotation to the test class for you, it can at least remind you that you need to add it yourself.

In phpunit/phpunit:9.0.0 and below, you can set the forceCoversAnnotation attribute to true in phpunit.xml.

<?xml version="1.0"?>
<phpunit
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
    bootstrap="vendor/autoload.php"
    forceCoversAnnotation="true"
>
    // ...
</phpunit>

In phpunit/phpunit:10.0.0 and above, you can set the requireCoverageMetadata attribute to true in phpunit.xml.

<?xml version="1.0"?>
<phpunit
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
    bootstrap="vendor/autoload.php"
    requireCoverageMetadata="true"
>
    // ...
</phpunit>

If you run tests with phpunit/phpunit and execute tests that do not have a @covers or @coversNothing annotation, or CoversClass, CoversFunction or CoversNothing attribute, PHPUnit will mark these tests as risky.

PHPUnit 10.0.0 by Sebastian Bergmann and contributors.

Runtime:       PHP 8.1.15
Configuration: test/Unit/phpunit.xml
Random Seed:   1677075031

R                                                                  1 / 1 (100%)

Time: 00:00.010, Memory: 12.00 MB

There was 1 risky test:

1) Ergebnis\Package\Test\Unit\ExampleTest::testFromStringReturnsExample
This test does not define a code coverage target but is expected to do so

/Users/am/Sites/ergebnis/php-package-template/test/Unit/ExampleTest.php:24

OK, but some tests have issues!
Tests: 1, Assertions: 1, Risky: 1.

Aiding the documentation of the system under test

How can you aid the documentation of the system under test?

When you use PhpStorm to generate a test class, PhpStorm uses a file template you can easily adjust.

On macOS, press CMD + .. Go to Editor and File and Code Templates, and in the list of files, you will find file templates for PHPUnit test classes.

For phpunit\phpunit:9.0.0 and below I use a template similar to the following:

<?php

declare(strict_types=1);

#if (${NAMESPACE})
namespace ${NAMESPACE};
#end

#if (${TESTED_NAME} && ${NAMESPACE} && !${TESTED_NAMESPACE})
use ${TESTED_NAME};
#elseif (${TESTED_NAME} && ${TESTED_NAMESPACE} && ${NAMESPACE} != ${TESTED_NAMESPACE})
use ${TESTED_NAMESPACE}\\${TESTED_NAME};
#end
use PHPUnit\Framework;

/**
#if (${TESTED_NAME} && ${NAMESPACE} && !${TESTED_NAMESPACE})
 * @covers \\${TESTED_NAME}
#elseif (${TESTED_NAME} && ${TESTED_NAMESPACE} && ${NAMESPACE} != ${TESTED_NAMESPACE})
 * @covers \\${TESTED_NAMESPACE}\\${TESTED_NAME}
#end
 */
final class ${NAME} extends Framework\TestCase
{
}

Recommendations

If you use phpunit/phpunit:9.0.0 and below, use @covers annotations to document the system under test on the class level. If there is no clear system under test, use the @coversNothing annotation annotation on the class level. Do not document the system under test on the method level, and do not bother with documenting individual methods - this functionality is not present in phpunit/phpunit:10.0.0. Set the forceCoversAnnotation attribute to true in phpunit.xml.

If you use phpunit/phpunit:10.0.0 and above, use CoversClass attributes to document the system under test on the class level. If there is no clear system under test, use the CoversNothing attribute on the class level. Set the requireCoverageMetadata attribute to true in phpunit.xml.

If you use @covers annotations in phpunit/phpunit and friendsofphp/php-cs-fixer, enable the php_unit_test_class_requires_covers fixer.

Do you find this article helpful?

Do you have feedback?

Do you need help with your PHP project?