Monday, 28 July 2008

Creating custom PHPUnit assertions

While developing PHP applications and applying developer testing the applications safety net will grow along the timeline, and as normal code, test code should be in a fresh, odour free state too. A common test code smell, amongst others, is the duplication of assertion logic which can reduce reusability, readability and thereby obscure the specific verification intention of tests. To subdue this special smell several patterns and refactorings are available to acquaint the test code with the DRY principle. So in this blog post I'd like to set the focus on some of the aspects of the Custom Assertion pattern, by showing how to create custom PHPUnit assertions, which attacks the above mentioned smell and its retroactive effects with a huge antiperspirant flagon, while also providing the chance to build a customer friendly and domain related test vocabulary.

The first introductive code snippet shows an example of unwanted code duplications and smells in a PHPUnit(i.e. version: 3.2.21) test case class spreading over several test methods. The first test code duplications smell is present when verifying that a given bag is having an expected item count and an explicit 'intent obscuration' smell can be spotted when verifying the bags stock id against an assumed convention via a 'distracting' regular expression.

<?php
require_once 'PHPUnit/Framework.php';
require_once 'Record/Bag.php';
require_once 'Record/Item.php';

class Record_Bag_Test extends PHPUnit_Framework_TestCase
{
private $_bag = null;

protected function setUp()
{
/**
* Creates a new named bag with an unique stock id
* in a format of AAA-NN-AA-NNNNNNNN e.g. XDS-76-YS-00000124,
* where A stands for an alphanumeric and N for a numeric character.
*/
$this->_bag = new Record_Bag('Test_Bag');
}

/**
* @test
*/
public function bagShouldNotContainDuplicateItems()
{
$this->assertTrue(0 === $this->_bag->getItemQuantity(),
'New bag not empty on creation.');

$this->_bag->addItem(new Record_Item('Dubplate 1'));
$this->_bag->addItem(new Record_Item('Dubplate 2'));
$this->_bag->addItem(new Record_Item('Dubplate 2'));

$this->assertTrue(2 === $this->_bag->getItemQuantity(),
'Bag does contain duplicated items.');
}

/**
* @test
*/
public function bagShouldBeReducedByOneItemAfterRemoval()
{
$this->assertTrue(0 === $this->_bag->getItemQuantity(),
'New bag not empty on creation.');

$this->_bag->addItem(new Record_Item('Dubplate 1'));
$this->_bag->addItem(new Record_Item('Dubplate 2'));
$this->_bag->addItem(new Record_Item('Dubplate 3'));

$this->_bag->removeItem('Dubplate 2');

$this->assertTrue(2 === $this->_bag->getItemQuantity(),
'Former three items bag does not contain two items after removal of one.');
}

/**
* @test
*/
public function bagStockIdShouldFollowAgreedConvention()
{
$stockIdPattern = '/^[A-Z]{3}-\d{2}-[A-Z]{2}-\d{8}/U';
$this->assertRegExp($stockIdPattern, $this->_bag->getStockId(),
'Stock id <string:' . $this->_bag->getStockId()
. '> does not follow the agreed convention.');
}

....

protected function tearDown()
{
unset($this->_bag);
}
}

Mechanics of rolling custom assertions

The 'Custom Assertion' pattern can be applied from the very first test code creation activities, in sense of a prefactoring, or refactored towards by extracting the assert duplications and verification intention obscurations into tailormade and intent revealing named assertions.

To define custom assertions are several approaches available, the first and easiest one is to define custom assertions merely for a single test class and make them private (inline) methods of this specific class by facading/wrapping the standard PHPUnit assertion. This approach is outlined in code snippet a).

Another approach is to create/introduce an Assert Class to promote a cleaner reusability in other test case classes or scenarios, this approach might get chosen to collect and organize the evolving domain specific assertions. You will see a code sketch of this approach in code listing b).

Other and more complex approaches would be the utilisation of PHPUnits' Constraint feature which is available since release 3.0.0 or in the near future the use of the Hamcrest test matcher features which might be available from PHPUnit 4.0.0.

Either way the purpose specific assertions should further provide a default and convention conform assertion message, which will be raised on a verification failure, and also the feature to feed in a custom one to avoid gambling annoying Assertion Roulette rounds.

a) Assert definition inside Test Case:
<?php
require_once 'PHPUnit/Framework.php';
require_once 'Record/Bag.php';
require_once 'Record/Item.php';

class Record_Bag_Test extends PHPUnit_Framework_TestCase
{
private $_bag = null;

protected function setUp()
{
$this->_bag = new Record_Bag('Test_Bag');
}

....

private function assertBagItemCount($expectedCount, $bag,
$message = 'Expected bag item count <integer:#1> does not match actual count of <integer:#2> items.')
{
if (strpos($message, '#1')) {
$message = str_replace('#1', $expectedCount, $message);
}
if (strpos($message, '#2')) {
$message = str_replace('#2', $bag->getItemQuantity(), $message);
}
$this->assertTrue($expectedCount === $bag->getItemQuantity(), $message);
}
}

b) Assert definition in Assert Class:
<?php
require_once 'PHPUnit/Framework/Assert.php';

class Record_Bag_Assert extends PHPUnit_Framework_Assert
{
/**
* Verifies that a given bag stock id follows the agreed convention.
*
* @param string $stockId
* @param string $message
* @see Record_Bag::createUniqueStockId()
*/
public function assertStockIdFollowsConvention($stockId,
$message = 'Stock id <string:##> does not follow the agreed convention.')
{
if (strpos($message, '##')) {
$message = str_replace('##', $stockId, $message);
}
$stockIdPattern = '/^[A-Z]{3}-\d{2}-[A-Z]{2}-\d{8}/U';
$this->assertRegExp($stockIdPattern, $stockId, $message);
}

....

}

Putting the custom assertions to work

Now as the tailormade assertions are available all verification work can be delegated to them the same way it would be done with any of the standard PHPUnit assertions. In case the custom assertions are hosted in an 'Assert Class' they can be required_once or loaded via __autoload() in the test setup, otherwise if they are defined inside the test case class itself they can be used like regular private methods of that class. The last code extract illustrates the use of the prior outlined 'Assert Class' assertion for verifing the stock id format alongside the use of the 'inline' custom assertion for verifying the amount of items in a given bag.
<?php
require_once 'PHPUnit/Framework.php';
require_once 'Record/Bag/Assert.php';

class Record_Bag_Test extends PHPUnit_Framework_TestCase
{

....

/**
* @test
*/
public function bagShouldNotContainDuplicateItems()
{
$this->assertBagItemCount(0, $bag, 'New bag not empty on creation.');

$this->_bag->addItem(new Record_Item('Dubplate 1'));
$this->_bag->addItem(new Record_Item('Dubplate 2'));
$this->_bag->addItem(new Record_Item('Dubplate 2'));

$this->assertBagItemCount(2, $bag, 'Bag does contain duplicate items.');
}

/**
* @test
*/
public function bagShouldBeReducedByOneItemAfterRemoval()
{
$this->assertBagItemCount(0, $bag, 'New bag not empty on creation.');

$this->_bag->addItem(new Record_Item('Dubplate 1'));
$this->_bag->addItem(new Record_Item('Dubplate 2'));
$this->_bag->addItem(new Record_Item('Dubplate 3'));

$this->_bag->removeItem('Dubplate 2');

$this->assertBagItemCount(2, $bag,
'Former three items bag does not contain two items after removal of one.');
}

/**
* @test
*/
public function bagStockIdShouldFollowAgreedConvention()
{
Record_Bag_Assert::assertStockIdFollowsConvention(
$this->_bag->getStockId());
}

....

}

Adding value through a domain specific test vocabulary

As you might have noticed the readability and intention communication of the test code has been improved significantly from the introductive code snippet towards the last one. Furthermore by distilling a ubiquitous(see Domain Driven Design book by Eric Evans) test language domain experts, which are mostly not fluent in the targeted programming language i.e. PHP, are enabled to read test code and provide valuable feedback or contribute changes to test scenarios affecting their domain.

Another common area where domain specific test vocabularies are used, by providing their own assertion and constraint sets, test case classes and additional test helpers, are extensions to xUnit frameworks e.g. DBUnit for PHPUnit or the Zend Framework MVC testing scaffold.

3 comments:

Anonymous said...

If you call Record_Bag_Assert::assertStockIdFollowsConvention() statically, how can you use $this?

Does this actually work?

Raphael Stolt said...

You're right you might have to change the assertion call in the test bagStockIdShouldFollowAgreedConvention into $this->assertStockIdFollowsConvention($this->_bag->getStockId());.

At the writing of this blog post I remember that it worked, as I generally test and play with the code before putting it out into the wild. And as this blog post is quite old, I will need to rebuild the tests and will change the listings in case you got a point.

Anonymous said...

This is a pretty old post but the answer is still missing ;)

The correct way is to change $this->assertRegExp($stockIdPattern, $stockId, $message); to
self::assertRegExp($stockIdPattern, $stockId, $message);

At least this is the correct way in 3.5.10

Also the function should be static.