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?
Just published: Documenting the system under test in PHPUnit.
— Andreas Möller (@localheinz) February 22, 2023
↓https://t.co/0NFaA2FHjS