Skip to content

Writing Tests

samuelgfeller edited this page May 14, 2024 · 29 revisions

Test Concept

After setting up the test environment, 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 never
    • once() - The mocked method is expected to be called once
    • exactly($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 the Content-Type header to application/x-www-form-urlencoded
  • HttpJsonTestTrait: createJsonRequest() - Creates a request object, adds the JSON data to the request body and sets the Content-Type header to application/json.
    Note: the HttpTestTrait must be included as well to use this function as it uses the createRequest() 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 key user_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 the user_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. When null, 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 checking
  • assertTableRowValue($expected, string $table, int $id, string $field,) - Asserts that a specific field of a row has the expected value
  • getTableRowById(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 exist
  • findTableRowById(string $table, int $id) - Returns the row with the given id from the given table or an empty array if it does not exist
  • assertTableRowCount(int $expected, string $table) - Asserts that a table has a specific number of rows
  • getTableRowCount(string $table) - Returns the number of rows in a table
  • assertTableRowExists(string $table, int $id) - Asserts that the given table contains a row with the given id
  • assertTableRowNotExists(string $table, int $id) - Asserts that the given table DOESN'T contain a row with the given id
  • findTableRowsByColumn(string $table, string $whereColumn, mixed $whereValue, ?array $fields = null) - Returns an array of rows from a table where the specified column has the given value
  • findTableRowsWhere(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 join
  • findLastInsertedTableRow(string $table) - Returns the last inserted row from the given table
  • assertTableRowsByColumn(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);
Clone this wiki locally