In every testing talk I’ve attended (or given), there’s one stand-out feature that often has the audience saying “whoa, I had no idea you could do that!” No, it’s sadly not “hey look, you can reliably build quality software with a much lower chance of defects or regressions!”, but rather the inevitable use of PHPUnit’s Data Providers.
With Data Providers, our test suite can become more readable and maintainable while making it trivial to add new testing scenarios. Best of all? PHPUnit ships with Data Providers right out of the box.
What are Data Providers?
In essence, Data Providers are a method of feeding groups of data into a test method that share the same purpose and setup. These are especially helpful for unit testing, as those test methods often follow the pattern of “given the following inputs, I expect to see this output”.
For a practical example, assume we’re testing a method that accepts a value and determines if it is a positive, non-zero integer:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
/** * Determine whether or not $value is a positive, * non-zero integer. * * @param mixed $value The value to evaluate. * * @return bool Whether or not $value is a positive, * non-zero integer. */ function isPositiveNonZeroInteger($value): bool { return is_int($value) && 0 < $value; } |
When writing our tests, we might find ourselves with test methods that look like this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
public function testChecksForIntegers() { $this->assertTrue(isPositiveNonZeroInteger(2)); $this->assertFalse(isPositiveNonZeroInteger(2.25)); } public function testChecksForZero() { $this->assertFalse(isPositiveNonZeroInteger(0)); } public function testChecksForNegativeNumbers() { $this->assertFalse(isPositiveNonZeroInteger(-2)); } |
While these methods aren’t bad, you can see how there’s a lot of shared setup: we’re defining a method that’s calling our isPositiveNonZeroInteger()
function against a provided value, and asserting whether the function returns true or false.
Introducing Data Providers
With Data Providers, we can take our same tests and reduce our suite to one or two methods, and there are two ways to do it:
First, we can use the @dataProvider
annotation, which signals that another method will provide the data (see what they did there?) for your test method. The Data Provider returns an array of scenarios, which are then treated as arguments by the test method:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
/** * @dataProvider isPositiveNonZeroIntegerProvider() */ public function testIsPositiveNonZeroInteger($value, bool $expected) { $this->assertSame($expected, isPositiveNonZeroInteger($value)); } /** * Data provider for testIsPositiveNonZeroInteger(). */ public function isPositiveNonZeroIntegerProvider(): array { return [ [2, true], [2.25, false], [0, false], [-2, false], ]; } |
When PHPUnit runs testIsPositiveNonZeroIntegerProvider()
, it will do so four times (once with each data set), for a total of four separate tests. The best part? If one data set fails, it won’t block the others from running, and adding a new scenario is as easy as adding a new entry to the array returned by isPositiveNonZeroIntegerProvider()
!
Still, we’re doing an awful lot of setup to test some simple Boolean values, which is where the next method of using Data Providers comes in: the @testWith
annotation. This annotation allows us to define a simple matrix of test data right in the test method’s docblock, eliminating the need for a Data Provider method:
1 2 3 4 5 6 7 8 9 10 |
/** * @testWith [2, true] * [2.25, false] * [0, false] * [-2, false] */ public function testIsPositiveNonZeroInteger($value, bool $expected) { $this->assertSame($expected, isPositiveNonZeroInteger($value)); } |
This approach reduces our tests to a single method, where we can trivially add new entries as necessary. Want to see how it behaves with a string? Simply add a new row to the DocBlock!
Using @testWith
for Data Providers is more rigid than using @dataProvider
— for instance, you can’t interpolate variables or do any calculations — but for many unit tests, it’s perfect.
Considerations when using Data Providers
Just because we can use Data Providers doesn’t mean they’ll always make sense to other developers on the project. After all, if our function name wasn’t self-documenting in the form of isPositiveNonZeroInteger()
, people may be confused as to why “2” is true, but “2.25” is false.
As with most things in computing, context is key, so you might consider one (or both) of the following:
Using named data sets
By default, a failing data set will look something like the following in PHPUnit:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
$ phpunit FunctionsTest PHPUnit <version> by Sebastian Bergmann and contributors. .F.. Time: 0 seconds, Memory: 5.75Mb There was 1 failure: 1) FunctionsTest::testIsNonZeroInteger with data set #1 (2.25, false) Failed asserting that true is identical to false. /var/www/app/tests/FunctionsTest.php:9 FAILURES! Tests: 4, Assertions: 4, Failures: 1. |
The message “FunctionsTest::testIsNonZeroInteger with data set #1 (2.25, false)” doesn’t really tell us much about why that particular assertion failed.
If we’re using the @dataProvider
annotation, we can help provide context by setting keys on the returned array:
1 2 3 4 5 6 7 8 9 10 11 12 |
/** * Data provider for testIsPositiveNonZeroInteger(). */ public function isPositiveNonZeroIntegerProvider(): array { return [ 'Control' => [2, true], 'Float value' => [2.25, false], 'Zero' => [0, false], 'Negative number' => [-2, false], ]; } |
Now, a failure for data set #1 will read:
1 2 3 4 |
There was 1 failure: 1) FunctionsTest::testIsNonZeroInteger with data set "Float value" (2.25, false) Failed asserting that true is identical to false. |
Leveraging assertion messages
Another technique that can be helpful is including a $message
parameter in your data sets:
1 2 3 4 5 6 7 8 9 10 |
/** * @testWith [2, true, "2 is a positive, non-zero integer"] * [2.25, false, "Float values are not integers"] * [0, false, "Zero is not non-zero"] * [-2, false, "-2 is not a positive integer"] */ public function testIsPositiveNonZeroInteger($value, bool $expected, string $message = '') { $this->assertSame($expected, isPositiveNonZeroInteger($value), $message); } |
Most (if not all) of the PHPUnit assertions permit you to pass a more-descriptive error message as the final parameter, so this can be a great way to provide additional explanation/context to your data sets.
Leave a Reply