Simplify Your Tests With Custom Assertions
Did you ever have to write tests with quiet complex assertions? I mean when you need to perform some calculations to decide if actual value is ok. If so, you obviously thought that your test is looking kinda weird and heavy.
But there is a way to significantly improve readability of all tests, even the most complex and ugly ones.
Example problem
There is a bunch of algorithms, when you can not create example-based test, i.e. any algorithm using random generation. Our example will be about randomization too.
We have a randomly generated Basket of Balls. The Basket MUST meet the following conditions:
- It must contain
$length
of balls; - Every Ball must have unique number. So, there can be only one ball with given number in a Basket;
- Every Ball in a Basket must have a number within a given range, defined by
$min
and$max
values.
The problem is, we cannot create any example-based test, because of random balls generation. There is another way: we execute test’s action (Basket generation) and check if those properties are met by actual result. We need to repeat this test sometimes to avoid lucky variations. This approach to testing is called property-based testing.
Let’s have a look on a test:
<?php //tests/BasketGeneratorTest.php
class BasketGeneratorTest extends \PHPUnit_Framework_TestCase
{
/** @var array */
private $config;
/** @var int */
private $length;
/** @var BasketGenerator */
private $generator;
protected function setUp()
{
parent::setUp();
$this->config = ['min' => 1, 'max' => 999];
$this->length = 10;
$this->generator = new BasketGenerator($this->config);
}
public function testGenerator()
{
$basket = $this->generator->generate($this->length);
$this->assertCount($this->length, $basket, 'Basket must be exactly given length.');
foreach ($basket as $ball) {
$this->assertTrue(
$ball->getNumber() <= $this->config['max'] && $ball->getNumber() >= $this->config['min'],
$message
);
}
$balls = $basket->getBalls();
for ($i = 0; $i < $this->length; $i++) {
for ($j = $i + 1; $j < $this->length; $j++) {
if ($balls[$i]->equals($balls[$j])) {
throw new \PHPUnit_Framework_AssertionFailedError($message);
}
}
}
}
}
On the following code snippets I wont’ include class definition and setUp() function to save a bit of place.
What we can say about this test at first sight? Assertions are very hard to understand. We need to read the whole block of code to understand what is actually checked by this test. Fortunately, there are plenty of ways to hide this complexity into easy-to-read assertions.
Naive Approach
The first idea is pretty simple: just define an assert function inside the test case:
<?php //tests/BasketGenerator.php
public function testGenerator()
{
$basket = $this->generator->generate($this->length);
$this->assertCount($this->length, $basket, 'Basket must be exactly given length.');
$this->assertUniqueBalls($basket, 'Basket must consist of unique balls.');
$this->assertBallsRange($basket, 'Each Ball in generated Basket has number between configured borders.');
}
private function assertBallsRange(Basket $basket, $message)
{
/** @var Ball $ball */
foreach ($basket as $ball) {
$this->assertTrue(
$ball->getNumber() <= $this->config['max'] && $ball->getNumber() >= $this->config['min'],
$message
);
}
}
private function assertUniqueBalls(Basket $basket, $message)
{
$balls = $basket->getBalls();
for ($i = 0; $i < $this->length; $i++) {
for ($j = $i + 1; $j < $this->length; $j++) {
if ($balls[$i]->equals($balls[$j])) {
throw new \PHPUnit_Framework_AssertionFailedError($message);
}
}
}
}
This approach improves test readability a lot, but you cannot reuse your custom assertions in other tests. If you need this, you may go further and convert these assertions to constraints.
Reusing Custom Assertions
Let’s extract the assertion to a separate class. PHPUnit has a mechanism of Constraint
s for this. You create a Constraint
and assert that a value matches this Constraint
.
<?php // tests/Constraint/UniqueBalls.php
namespace Constraint;
class UniqueBalls extends \PHPUnit_Framework_Constraint {
/**
* This function must return true if given value matches the constraint
* @param Ball[] $balls
* @return boolean
*/
public function matches ($balls) {
$length = count($balls);
for ($i = 0; $i < $length; $i++) {
for ($j = $i + 1; $j < $length; $j++) {
if ($balls[$i]->equals($balls[$j])) {
return false;
}
}
}
return true;
}
}
Then you have two options: to use the constraint directly in assertThat
call or create a base test class with assertion functions:
assertThat example
<?php // tests/BasketGeneratorTest.php
public function testGenerator()
{
$basket = $this->generator->generate($this->length);
$this->assertCount($this->length, $basket, 'Basket must be exactly given length.');
$this->assertThat($basket->getBalls(), new Constraint\UniqueBalls(), 'Basket must consist of unique balls.');
}
Base test case
<?php // tests/BaseTestCase.php
class BaseTestCase extends \PHPUnit_Framework_TestCase
{
public static function assertUniqueBalls(array $balls, $message = '') {
self::assertThat($basket->getBalls(), new Constraint\UniqueBalls(), $message);
}
}
<?php // tests/BasketGenerator.php
class BasketGeneratorTest extends BaseTestCase
{
public function testGenerator()
{
$basket = $this->generator->generate($this->length);
$this->assertCount($this->length, $basket, 'Basket must be exactly given length.');
$this->assertUniqueBalls($basket->getBalls(), 'Basket must consist of unique balls.');
}
}