A unit test provides a strict, written contract that a piece of code must satisfy. As a result, unit tests find problems early in the development cycle.
The goal is to isolate each part of the program and verify that it is correct.
PHPUnit is a well-known testing framework for PHP. It uses assertions to verify that a specific component or unit behaves as expected.
The purpose of this tutorial is to introduce you to the basics of PHPUnit.
Installation
Before we start writing our first unit test, we need to have PHPUnit installed. The installation
process is documented at https://phpunit.de/.
Writing our first test
To get started, we need something to test, so for the first example, I’ve written a simple
PHP class Average
that calculates the average of an array of integers.
src/Average.php
<?php declare(strict_types=1);
class Average
{
private function ensureIsValidArrayOfIntegers(array $numbers): void
{
foreach ($numbers as $number) {
if (!filter_var($number, FILTER_VALIDATE_INT)) {
throw new InvalidArgumentException(
sprintf(
'"%s" is not a valid number',
$number
)
);
}
}
}
public function getAverage(array $numbers): float
{
$this->ensureIsValidArrayOfIntegers($numbers);
return array_sum($numbers) / count($numbers);
}
}
Basic coventions for writing tests with PHPUnit:
- The tests for a class
Class
go into a classClassTest
. ClassTest
inherits (most of the time) fromPHPUnit\Framework\TestCase
.- The tests are public methods that are named
test*
. - Alternatively, we can use the
@test
annotation in a method’s docblock to mark it as a test method. - Inside the test methods, assertion methods are used to assert that an actual value matches an expected value.
To ensure that our class Average
works, we need to create a test class AverageTest
that extends PHPUnit\Framework\TestCase
.
tests/AverageTest.php
<?php declare(strict_types=1);
use PHPUnit\Framework\TestCase;
final class AverageTest extends TestCase
{
/**
* Test for an Exception if invalid argument type is passed.
*/
public function testExceptionFromInvalidArgumentType(): void
{
$this->expectException(TypeError::class);
$average = new Average();
$verage->getAverage('string');
}
/**
* Test for an Exception if invalid argument is passed.
*/
public function testExceptionFromInvalidArgument(): void
{
$this->expectException(InvalidArgumentException::class);
$average = new Average();
$average->getAverage([1, 'a', 3, 4, 5]);
}
/**
* Test for an Exception if an empty array is passed.
*/
public function testExceptionFromEmptyArrayArgument(): void
{
$this->expectException(DivisionByZeroError::class);
$average = new Average();
$average->getAverage([]);
}
public function testGetAverage(): void
{
$average = new Average();
$this->assertEquals(3.0, $average->getAverage([1, 2, 3, 4, 5]));
}
}
Running our tests
Running our tests is as simple as calling the phpunit executable and pointing it at our tests. Here’s an example:
./vendor/bin/phpunit tests
Output
PHPUnit 9.5.0 by Sebastian Bergmann and contributors.
.... 4 / 4 (100%)
Time: 00:00.008, Memory: 4.00 MB
OK (4 tests, 4 assertions)
Fixtures (Setup & Teardown)
The purpose of a fixture is to ensure that there is a well known and fixed environment in which tests are run. This allows for tests to be repeatable, which is one of the key features of an effective test framework.
Examples:
- Loading a database with a specific known set of data.
- Preparation of input data as well as set-up and creation of mock objects.
- Copying a specific known set of files
PHPUnit supports sharing the setup code. Before a test method is run, a template
method called setUp()
is invoked. setUp()
is where we create the objects against
which we will test. Once the test method has finished running, whether it succeeded
or failed, another template method called tearDown()
is invoked. tearDown()
is where
we clean up the objects against which we tested.
In AverageTest.php
, it is tedious to instantiate Average
class in each test case.
So, we move it to setUp()
and tearDown()
.
tests/AverageTest.php
<?php declare(strict_types=1);
use PHPUnit\Framework\TestCase;
final class AverageTest extends TestCase
{
protected $average;
/**
* This function is invoked before each test function.
*/
protected function setUp(): void {
$this->average = new Average();
}
/**
* This function is invoked after each test function.
*/
protected function tearDown(): void {
unset($this->average);
}
/**
* Test for an Exception if invalid argument type is passed.
*/
public function testExceptionFromInvalidArgumentType(): void
{
$this->expectException(TypeError::class);
$this->average->getAverage('string');
}
/**
* Test for an Exception if invalid argument is passed.
*/
public function testExceptionFromInvalidArgument(): void
{
$this->expectException(InvalidArgumentException::class);
$this->average->getAverage([1, 'a', 3, 4, 5]);
}
/**
* Test for an Exception if an empty array is passed.
*/
public function testExceptionFromEmptyArrayArgument(): void
{
$this->expectException(DivisionByZeroError::class);
$this->average->getAverage([]);
}
public function testGetAverage(): void
{
$this->assertEquals(3.0, $this->average->getAverage([1, 2, 3, 4, 5]));
}
}
Data Providers
A test method can accept arbitrary arguments. These arguments are to be provided by one or
more data provider methods. The data provider method to be used is specified using the
@dataProvider
annotation.
tests/AverageTest.php
/**
* @dataProvider averageProvider
*/
public function testGetAverageUsingDataProvider(int $a, int $b, float $expected): void
{
$this->assertSame($expected, $this->average->getAverage([$a, $b]));
}
public function averageProvider(): array
{
return [
[1, 2, 1.5],
[3, 4, 3.5],
[4, 2, 3.0],
[4, 4, 4.0]
];
}
Test Doubles
When we are writing a test in which we cannot (or chose not to) use a real depended-on component (DOC), we can replace it with a Test Double. The Test Double doesn’t have to behave exactly like the real DOC; it merely has to provide the same API as the real one so that the system under test (SUT) thinks it is the real one!
The createStub
and createMock
methods can be used in a test to
automatically generate an object that can act as a test double.
By default, all methods of the original class are replaced with a dummy implementation
that returns null (without calling the original method). We can configure these dummy
implementations to return a value when called Using the will($this->returnValue())
or simply willReturn
method.
When the defaults used by the createStub
and createMock
methods do not match
our needs then we can use the getMockBuilder
method to customize the test double
generation.
Please note that final
, private
, and static
methods cannot be stubbed or mocked.
Stubs
The practice of replacing an object with a test double that (optionally) returns configured return values is referred to as stubbing. You can use a stub to replace a real component on which the SUT depends so that the test has a control point for the indirect inputs of the SUT.
Let’s stub ensureIsValidArrayOfIntegers
method and skip validation.
tests/AverageTest.php
public function testStub(): void
{
/**
* createStub stubs all the methods in the stubbed class.
* Here, ensureIsValidArrayOfIntegers and getAverage methods
* won't be invoked from the Original Average class.
*/
$stub = $this->createStub(Average::class);
/**
* Since we didn't specify the return values,
* default values will be returned based on the functions return type.
*
* Return type of ensureIsValidArrayOfIntegers is void. So, nothing is returned.
* Return type of getAverage is float. So, 0.0 is returned
*/
$this->assertEmpty($stub->ensureIsValidArrayOfIntegers([1, 3.5, 3]));
$this->assertEquals(0.0, round($stub->getAverage([1, 3.5, 3]), 2));
}
tests/AverageTest.php
public function testStubUsingMockBuilder(): void
{
/**
* Here we used getMockBuilder to stub just one method: ensureIsValidArrayOfIntegers.
* This method won't be invoked.
*/
$stub = $this->getMockBuilder(Average::class)
->setMethods(['ensureIsValidArrayOfIntegers'])
->getMock();
$this->assertEmpty($stub->ensureIsValidArrayOfIntegers([1, 3.5, 3]));
/**
* Since, ensureIsValidArrayOfIntegers method is stubbed,
* we are able to get an average even if we don't pass a valid array of Integers.
*
* getAverage method of the original Average class is invoked.
*/
$this->assertEquals(2.5, round($stub->getAverage([1, 3.5, 3]), 2));
}
Mock Objects
The practice of replacing an object with a test double that verifies expectations, for instance asserting that a method has been called, is referred to as mocking.
Let’s create a class Logger
that logs a string.
src/Logger.php
<?php declare(strict_types=1);
class Logger
{
public function log($text): void
{
echo $text;
}
}
We will add a method to the Average
class that uses Logger’s log method to log average.
src/Average.php
public function logAverage(array $numbers, Logger $logger): void
{
$this->ensureIsValidArrayOfIntegers($numbers);
$logger->log(array_sum($numbers) / count($numbers));
}
Now let’s Mock Logger
and test if log
method is called.
tests/AverageTest.php
public function testMock(): void
{
/**
* Here, we are mocking log method of Logger class,
* just to ensure that it is called with the specified arguments.
* The Average class does not need to verify what happens within the Logger log method.
*/
$mockObject = $this->createMock(Logger::class);
$mockObject->expects($this->once())
->method('log')
->with(2.0);
$this->average->logAverage([1, 2, 3], $mockObject);
}
XML Configuration
phpunit.xml
file can be used to compose a test suite and specify other configurations.
The following is an xml configuration that will add all *Test
classes that are
found in *Test.php
files when the tests
directory is recursively traversed.
phpunit.xml
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.3/phpunit.xsd"
bootstrap="vendor/autoload.php"
cacheResultFile=".phpunit.cache/test-results"
executionOrder="depends,defects"
beStrictAboutOutputDuringTests="true"
beStrictAboutTodoAnnotatedTests="true"
failOnRisky="true"
failOnWarning="true"
verbose="true">
<php>
<server name="DOCUMENT_ROOT" value="ABSOLUTE_PATH_TO_DOCUMENT_ROOT"/>
</php>
<testsuites>
<testsuite name="default">
<directory suffix="Test.php">tests</directory>
</testsuite>
</testsuites>
</phpunit>
running our tests
./vendor/bin/phpunit
Code Coverage
Code coverage is a measure used to describe the degree to which the source code of a program is tested by a particular test suite. A program with high code coverage has been more thoroughly tested and has a lower chance of containing software bugs than a program with low code coverage.
To generate code coverage, update phpunit.xml
as follows:
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.3/phpunit.xsd"
bootstrap="vendor/autoload.php"
cacheResultFile=".phpunit.cache/test-results"
executionOrder="depends,defects"
beStrictAboutOutputDuringTests="true"
beStrictAboutTodoAnnotatedTests="true"
failOnRisky="true"
failOnWarning="true"
verbose="true">
<php>
<server name="DOCUMENT_ROOT" value="ABSOLUTE_PATH_TO_DOCUMENT_ROOT"/>
</php>
<coverage cacheDirectory=".phpunit.cache/code-coverage">
<include>
<directory suffix=".php">src</directory>
</include>
<report>
<clover outputFile="report/tests-clover.xml"/>
<html outputDirectory="report"/>
</report>
</coverage>
<testsuites>
<testsuite name="default">
<directory suffix="Test.php">tests</directory>
</testsuite>
</testsuites>
<logging>
<junit outputFile="report/tests-junit.xml"/>
<testdoxHtml outputFile="report/testdox.html"/>
</logging>
</phpunit>
And run tests:
./vendor/bin/phpunit
If you get a warning: XDEBUG_MODE=coverage or xdebug.mode=coverage has to be set
,
Run the tests as:
XDEBUG_MODE=coverage ./vendor/bin/phpunit
Output directory is set as report
. So, open report/index.html
on a browser to see
the code coverage.
I hope this is a good introduction to the world of unit testing. Even though there are several topics I’ve not touched on, I’ve tried to give you a good point where you can start writing your tests.