Writing Tests
Table of contents
Loading...Test Concept
After setting up the test environment, and knowing what Unit and Integration tests, are, the next step is to define what should be tested.
A test concept is strongly recommended, to have a clear idea of what should be tested and how.
Depending on the requirements and the complexity of the project, the test concept should be more or less detailed.
If there are no clear directives, I find that the easiest and most
forward way is to make a bullet
point list of exactly what should be tested for each page and the expected behaviour.
If use-cases have similar testing requirements and expected behaviour,
they can be grouped together.
Instead of putting a lot of effort in trying to think of all possible cases in advance, I would write down the ones that come to mind and then, while implementing add the new ones that may arise.
An example of such a list for the slim-example-project can be found in the testing examples.
Unit Tests
Unit tests are located in the tests/Unit
directory.
To test individual units of code (e.g. functions, classes, modules) in isolation, the parts
around the unit under test are replaced by test doubles (mocks, stubs, etc.) with predefined
return values. They are fake objects that need to be configured to contain specific
methods and return values.
Mocks, unlike stubs, can be programmed to expect specific method calls and parameters to
verify that the tested unit interacts with the mock as expected.
The TestTraits\Trait\MockTestTrait
provides the mock
function which returns a mock object of the
given class and automatically adds it to the
container.
Now instead of the real class, the mock object is injected and used by the tested class.
The detailed documentation on how mocks can be configured can be found in the PHPUnit documentation Configuring Mock Objects.
Here is a list of some functions that can be used to configure the mock object:
$mock->method($name)
- Sets the name of the mocked method$mock->willReturn($value)
- Sets the return value of the mocked method$mock->willReturnOnConsecutiveCalls($value1, $value2, ...)
- Sets the return value of the mocked method to the given values in the given order$mock->with($value)
- Sets the expected parameter value of the mocked method$mock->expects($count)
- Sets the expected number of times the mocked method is called.$count
can be one of the following:never()
- The mocked method is expected to be called neveronce()
- The mocked method is expected to be called onceexactly($count)
- The mocked method is expected to be called exactly (int)$count
times
These functions can be chained together to configure the mock object.
$mock->method('query')->willReturn('hello world')->expects(self::once());
Unit test example
Let's assume exampleFunction()
in the ExampleClass
is the function under test.
It retrieves a string from the database with the PDO query
function and returns it after
transforming it to uppercase.
The test could look like this:
File: tests/Unit/ExampleClassTest.php
<?php
namespace App\Test\Unit;
use App\Test\Trait\AppTestTrait;
use TestTraits\Trait\MockTestTrait;
use PHPUnit\Framework\TestCase;
class ExampleClassTest extends TestCase
{
use AppTestTrait;
use MockTestTrait;
public function testExample(): void
{
// Mock the PDO class and add it to the container
$pdoMock = $this->mock(\PDO::class);
// Configure the mock to return "hello world" when the query() function is called
// and expect the function to be called once
$pdoMock->method('query')
->willReturn('hello world')
->expects(self::once());
// Get the real instance of the class to test
$exampleClass = $this->container->get(ExampleClass::class);
// Call the function to test
$result = $exampleClass->exampleFunction();
// Assert that the result is the expected value
$this->assertSame('HELLO WORLD', $result);
}
}
Integration Tests
The folder tests/Integration
contains the integration test cases.
To test the overall behavior of the application, an HTTP request to a route is made with a specific request method and request body that traverses all the layers of the application.
Requests
Requests can be created with TestTraits\Trait\HttpTestTrait
and
TestTraits\Trait\HttpJsonTestTrait
.
They provide the following functions:
createRequest()
- Creates a request object and accepts the parameters$method
,$uri
and$serverParams
createFormRequest()
- Creates a request object, adds the form data to the request body and sets theContent-Type
header toapplication/x-www-form-urlencoded
HttpJsonTestTrait
:createJsonRequest()
- Creates a request object, adds the JSON data to the request body and sets theContent-Type
header toapplication/json
.
Note: theHttpTestTrait
must be included as well to use this function as it uses thecreateRequest()
function.
The functions above expect the $uri
which is the full url to the route.
To reference routes by their name, the urlFor()
function from the
TestTraits\Trait\RouteTestTrait
can be used.
To make the request and get the response, $this->app->handle()
can be called with the request as argument.
$app
is the instance of the application bootstrapped in AppTestTrait
.
namespace App\Test\Integration;
use PHPUnit\Framework\TestCase;
use App\Test\Trait\AppTestTrait;
use TestTraits\Trait\HttpTestTrait;
use TestTraits\Trait\RouteTestTrait;
class TestActionTest extends TestCase
{
use AppTestTrait;
use HttpTestTrait;
use RouteTestTrait;
// ...
public function testAction(): void
{
$request = $this->createRequest('GET', $this->urlFor('routeName'))
$response = $this->app->handle($request);
// ...
}
}
Asserting the response
After the request is made, the response can be tested with assertions.
Status code
The status code can be retrieved with $response->getStatusCode()
which can be
verified with assertSame
and the expected code.
self::assertSame(200, $response->getStatusCode());
Response header
The response header can be accessed with $response->getHeaderLine($headerName)
and then
compared with an expected value.
// Assert that the response Location header is the login page (redirect)
self::assertSame($this->urlFor('login-page'), $response->getHeaderLine('Location'));
Response body
To assert that the response body contains a specific string, the assertResponseContains()
function of the HttpTestTrait
can be used.
$this->assertResponseContains('Hello World', (string)$response->getBody());
JSON response body
To verify that the returned JSON data is an exact match to an expected array,
the HttpJsonTestTrait
provides the assertJsonData()
function.
$this->assertJsonData(['key' => 'value'], $response);
For more advanced assertions, the JSON data from the response can be accessed
as an array with $this->getJsonData($response)
.
The HttpJsonTestTrait
also provides a function assertJsonContentType
to assert
the response content type header.
Data Providers
To test a use-case under different conditions, the same test logic can be run with different data.
A test method can accept arbitrary arguments. These arguments are to be provided by one or more data provider methods.
Data providers are public static methods in a "Provider" class or the test class itself that return an array of arrays. For each of these arrays, the test method will be called with the contents of the array as its arguments.
The data provider method to be used is specified using the attributes
PHPUnit\Framework\Attributes\DataProvider
(for the same class as the test method) or
the PHPUnit\Framework\Attributes\DataProviderExternal
(when the provider is in a different class).
Example data provider
File: tests/Provider/Example/ExampleProvider.php
<?php
namespace App\Test\Provider\Example;
class ExampleProvider
{
public static function provideExampleData(): array
{
return [
// Provides 0 as first argument and 1 in the second
[0, 1],
// The data sets can be named with string keys for a more verbose output as
// it will contain the name of the dataset that breaks a test
'one' => [1, 2],
// The array values can also have string keys in which case they are named parameters
// and must correspond to the parameter names.
'two' => ['input' => 2, 'expected' => 3],
// The order of the keys does not matter when they're named.
'three' => ['expected' => 4, 'input' => 3],
];
}
}
The following test function will be run four times.
In the first iteration, the $input
parameter will be 0
and $expected
1
,
in the second 1
and 2
, in the third 2
and 3
and in the
fourth run, input will be 3
and expected 4
.
File: tests/Integration/Example/ExampleTest.php
<?php
namespace App\Test\Integration\Example;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\DataProviderExternal;
use App\Test\Provider\Example\ExampleProvider;
class ExampleTest extends TestCase
{
#[DataProviderExternal(\App\Test\Provider\Example\ExampleProvider::class, 'provideExampleData')]]
public function testExample(int $input, int $expected): void
{
$this->assertSame($expected, $input + 1);
}
}
Fixtures
For a lot of requests, pre-existing data is required in the database.
For example, to test the modification of a resource, it has to exist prior to making the update request.
A user entry that can be authenticated is also required to get past the authentication middleware.
Fixture classes
Fixtures are classes that hold example data that can be inserted into the database. Each table has its own fixture class.
The fixtures are located in the tests/Fixture
directory.
Each fixture has a property $table
with the table name and an array $records
with the
default data to insert.
File: tests/Fixture/ExampleFixture.php
<?php
namespace App\Test\Fixture;
use TestTraits\Interface\FixtureInterface;
class ExampleFixture
{
// Table name
public string $table = 'example';
// Database records
public array $records = [
[
// id set in the fixture is not used as it's auto increment
'id' => 1,
'field_1' => 'value_1',
'field_2' => 'value_2',
],
[
'id' => 2,
'field_1' => 'value_1',
'field_2' => 'value_2',
],
];
}
Inserting fixtures
Different use cases require different data.
To define custom data to be inserted along with the default data of the fixture, the
FixtureTestTrait
provides the insertFixture()
method.
The first parameter is the fixture fully qualified class name
e.g. UserFixture::class
and the second (optional) is an array of attributes.
An array of attributes contains the data for one database row e.g.:
['field_name' => 'value', 'other_field_name' => 'other_value']
.
Multiple attribute arrays can be passed to the function to insert multiple rows with different data as shown below.
Not all fields of the table need to be specified in the attribute array.
For unspecified fields, the values of the first $records
entry from the fixture will be used.
The function returns an array with the inserted data from the fixture including the auto-incremented id or an array for each row that was inserted when multiple rows were passed.
<?php
namespace App\Test\TestCase;
use PHPUnit\Framework\TestCase;
use TestTraits\Trait\FixtureTestTrait;
final class ExampleTestAction extends TestCase
{
// ...
use FixtureTestTrait;
public function testAction(): void
{
// Insert the fixture with the default values
$rowData = $this->insertFixture(ExampleFixture::class);
// Insert the fixture with the given attributes
$rowData = $this->insertFixture(ExampleFixture::class, ['field_1' => 'value_1', ]);
// Insert 2 rows with the given attributes
$rowsData = $this->insertFixture(
ExampleFixture::class, ['field_1' => 'value_1'], ['field_1' => 'value_2']
);
// Multiple rows can also be inserted within the same attribute array
$rowsData = $this->insertFixture(
ExampleFixture::class, [['field_1' => 'value_1'], ['field_1' => 'value_2']]
);
// ...
}
}
Examples can be found in the
README
of the
samuelgfeller/test-traits
package or in the
slim-example-project
,
slim-api-starter
or
slim-starter
projects.
The FixtureTestTrait
uses the DatabaseTestTrait
for the interaction with the database.
Inserting fixtures with user roles - AuthorizationTestTrait
This trait does not come with the
samuelgfeller/test-traits
library.
It is part of the
slim-example-project
.
User roles are inserted by default in AppTestTrait
if the DatabaseTestTrait
is used in a test class.
The ids of the user roles are not known in the data providers or test functions.
Instead of hard coding them, the user_role_id
can
be referenced as a UserRole
Enum case which will be converted to the correct id in the test function with
the help of the App\Test\Trait\AuthorizationTestTrait
.
This trait contains the following functions:
getUserRoleId(UserRole $userRole)
- Returns the id of the given user role enum case.addUserRoleId(array $userAttr)
- Accepts an array of attributes and replaces the value from the keyuser_role_id
to the corresponding role id from the database if it is an enum case.insertUserFixtures(array &$authenticatedUserAttr, ?array &$userAttr)
- Inserts up to two user fixtures with the given attributes replacing theuser_role_id
value with the right id.
Function insertUserFixtures()
This function is most useful when testing authorization cases that require an authenticated user
and another user, which may be linked to the ressource (owner) for example.
It might be the authenticated user itself or another one.
The function accepts two parameters with user attributes e.g. $userLinkedToResourceRow
and $authenticatedUserRow
.
If the authenticated user and the other user attributes are the same, only one user is inserted into the
database.
The function accepts the user attribute parameters as
&
references which means that the
original variable from the calling function is modified without it having to be returned.
If the $userLinkedToResourceRow
and $authenticatedUserRow
variables only contained
the user_role_id
attribute with a UserRole
Enum case as a value when
insertUserFixtures()
was called, these same variables would contain
all the inserted user row values, including the insert id and the correct user role
id, after the function was called.
Example usage
namespace App\Test\Integration;
use PHPUnit\Framework\TestCase;
use App\Test\Trait\AppTestTrait;
use App\Test\Trait\AuthorizationTestTrait;
use TestTraits\Trait\FixtureTestTrait
use TestTraits\Trait\DatabaseTestTrait;
use PHPUnit\Framework\Attributes\DataProvider;
class TestActionTest extends TestCase
{
use AppTestTrait;
use DatabaseTestTrait;
use FixtureTestTrait;
use AuthorizationTestTrait;
public static function userProvider(): array
{
return [
[
'userLinkedToResourceRow' => ['user_role_id' => UserRole::ADMIN],
'authenticatedUserRow' => ['user_role_id' => UserRole::NEWCOMER],
],
[
'userLinkedToResourceRow' => ['user_role_id' => UserRole::ADMIN],
'authenticatedUserRow' => ['user_role_id' => UserRole::ADMIN],
],
];
}
/**
* @param array $userLinkedToResourceRow e.g. ['user_role_id' => UserRole::ADMIN]
* @param array $authenticatedUserRow e.g. ['user_role_id' => UserRole::ADVISOR]
* @return void
*/
#[DataProvider('userProvider')]
public function testAction(array $userLinkedToResourceRow, array $authenticatedUserRow): void
{
// Insert authenticated user and user linked to resource with given attributes (mainly containing the user role)
$this->insertUserFixtures($userLinkedToResourceRow, $authenticatedUserRow);
// $userLinkedToResourceRow and $authenticatedUserRow now contain the inserted user data
// including the auto-incremented id
$authenticatedUserId = $authenticatedUserRow['id'];
// ...
}
}
Database assertions
To verify that the data in the database was changed or inserted as expected after a request,
the TestTraits\Trait\DatabaseTestTrait
provides practical functions to assert the database content.
assertTableRow(array $expectedRow, string $table, int $id, array $fields = null)
- Asserts that a row in the database contains the expected values.
The$fields
array parameter defines the fields that should be compared against the$expectedRow
. Whennull
, all fields are compared.assertTableRowEquals(array $expectedRow, string $table, int $id, array $fields = null)
- Asserts that a row in the database contains the expected values without type checkingassertTableRowValue($expected, string $table, int $id, string $field,)
- Asserts that a specific field of a row has the expected valuegetTableRowById(string $table, int $id, array $fields = null)
- Returns the row with the given id from the given table or throws an exception if it does not existfindTableRowById(string $table, int $id)
- Returns the row with the given id from the given table or an empty array if it does not existassertTableRowCount(int $expected, string $table)
- Asserts that a table has a specific number of rowsgetTableRowCount(string $table)
- Returns the number of rows in a tableassertTableRowExists(string $table, int $id)
- Asserts that the given table contains a row with the given idassertTableRowNotExists(string $table, int $id)
- Asserts that the given table DOESN'T contain a row with the given idfindTableRowsByColumn(string $table, string $whereColumn, mixed $whereValue, ?array $fields = null)
- Returns an array of rows from a table where the specified column has the given valuefindTableRowsWhere(string $table, string $whereString, ?array $fields = null, string $joinString = '',)
- Returns an array of rows from the given table with a custom where clause and optional joinfindLastInsertedTableRow(string $table)
- Returns the last inserted row from the given tableassertTableRowsByColumn(array $expectedRow, string $table, string $whereColumn, mixed $whereValue, ?array $fields = null)
- Asserts that the rows have the expected values where the given column has a certain value
All the functions above accept an additional argument: string $message
.
This allows the PHPUnit fail message to be customized with the given $message
.
Assert table row example
To assert that a row in the database contains the expected values, the function
assertTableRow()
or assertTableRowEquals()
can be used.
The first parameter is the array of expected fields and values, the second one
is the table name and the third the id of the row to check.
With the code below, the function asserts that the row with id 1 in the table example
has the
value value_1
in the field_1
and 42
in the field_2
without considering the other fields.
// Only passes if the values from the row with id 1 in the database have
// the same value and type as the given expected values
$this->assertTableRow(['field_1' => 'value_1', 'field_2' => 42], 'example', 1);
// Type of the value not considered
$this->assertTableRowEquals(['field_1' => 'value_1', 'field_2' => '42'], 'example', 1);