Test Examples

Table of contents

Loading...

Test Concept

This is a very minimal test concept covering the most frequent use case of the project. It is not meant to be exhaustive but to give a general idea of how the slim-example-project is tested.

Test Cases

Real test cases from the slim example project are taken to showcase a variety of practical examples.

The test setup is documented in Test Setup and how the test traits are used is detailed in Writing Tests.

Testing page actions

To test that the pages rendered by the server load correctly, the expected status code from the response can be asserted.

Authenticated page load

namespace App\Test\Integration\Client;

use App\Test\Fixture\ClientFixture;
use App\Test\Fixture\ClientStatusFixture;
use App\Test\Fixture\UserFixture;
use App\Test\Trait\AppTestTrait;
use TestTraits\Trait\FixtureTestTrait;
use Fig\Http\Message\StatusCodeInterface;
use Odan\Session\SessionInterface;
use PHPUnit\Framework\TestCase;
use TestTraits\Trait\DatabaseTestTrait;
use TestTraits\Trait\HttpTestTrait;
use TestTraits\Trait\RouteTestTrait;

class ClientReadPageActionTest extends TestCase
{
    use AppTestTrait;
    use HttpTestTrait;
    use RouteTestTrait;
    use DatabaseTestTrait;
    use FixtureTestTrait;

    public function testClientReadPageActionAuthenticated(): void
    {
        // Add required database values to correctly display the page
        // Insert authenticated user
        $userId = $this->insertFixture(UserFixture::class)['id'];
        // Insert linked client status
        $clientStatusId = $this->insertFixture(ClientStatusFixture::class)['id'];
        // Insert client linked to user to be sure that the user is permitted to see the client read page
        $clientRow = $this->insertFixture(
            ClientFixture::class,
            ['user_id' => $userId, 'client_status_id' => $clientStatusId],
        );
        // Create request to client read page
        $request = $this->createRequest('GET', $this->urlFor('client-read-page', ['client_id' => $clientRow['id']]));
        // Simulate logged-in user by setting the user_id session variable
        $this->container->get(SessionInterface::class)->set('user_id', $userId);

        $response = $this->app->handle($request);
        // Assert 200 OK
        self::assertSame(StatusCodeInterface::STATUS_OK, $response->getStatusCode());
    }

    // ...
}

Unauthenticated page load

    // ... 

    public function testClientReadPageActionUnauthenticated(): void
    {
        // Request route to client read page while not being logged in
        $requestRoute = $this->urlFor('client-read-page', ['client_id' => '1']);
        $request = $this->createRequest('GET', $requestRoute);
        $response = $this->app->handle($request);
        // Assert 302 Found redirect to login url
        self::assertSame(StatusCodeInterface::STATUS_FOUND, $response->getStatusCode());

        // Build expected login url with redirect to initial request route as UserAuthenticationMiddleware.php does
        $expectedLoginUrl = $this->urlFor('login-page', [], ['redirect' => $requestRoute]);
        self::assertSame($expectedLoginUrl, $response->getHeaderLine('Location'));
    }

    // ...

Testing validation

Data providers allow the same validation test function to be run with every possible validation error for each field.

Note: this test function uses the AuthorizationTestTrait to insert a user with the correct role from the given Enum case. The AuthorizationTestTrait is part of the slim-example-project.

Test function

File: tests/Integration/User/UserUpdateActionTest.php

    // ... 
    use AppTestTrait;
    use HttpTestTrait;
    use HttpJsonTestTrait;
    use FixtureTestTrait;
    use AuthorizationTestTrait;

    /**
     * Test user submit invalid update data.
     *
     * @param array $requestBody
     * @param array $jsonResponse
     */
    #[DataProviderExternal(\App\Test\Provider\User\UserUpdateProvider::class, 'invalidUserUpdateCases')]
    public function testUserSubmitUpdateInvalid(array $requestBody, array $jsonResponse): void
    {
        // Insert user that is allowed to change content (advisor owner)
        $userRow = $this->insertFixture(
            // Replace user_role_id enum case with database id with AuthorizationTestTrait function addUserRoleId()
            UserFixture::class,
            $this->addUserRoleId(['user_role_id' => UserRole::ADVISOR]),
        );

        $request = $this->createJsonRequest(
            'PUT', $this->urlFor('user-update-submit', ['user_id' => $userRow['id']]), $requestBody
        );

        // Simulate logged-in user by setting the user_id session variable
        $this->container->get(SessionInterface::class)->set('user_id', $userRow['id']);
        $response = $this->app->handle($request);
        // Assert 200 OK
        self::assertSame(StatusCodeInterface::STATUS_UNPROCESSABLE_ENTITY, $response->getStatusCode());
        // database must be unchanged
        $this->assertTableRowEquals($userRow, 'user', $userRow['id']);
        $this->assertJsonData($jsonResponse, $response);
    }

    // ...

Data provider

File: tests/Provider/User/UserUpdateProvider.php

<?php

namespace App\Test\Provider\User;

class UserUpdateProvider
{
    public static function invalidUserUpdateCases(): array
    {
        return [
            'min-length' => [
                'request_body' => [
                    'first_name' => 'n',
                    'surname' => 'n',
                    'email' => 'new.email@tes$t.ch',
                    'status' => 'non-existing',
                    'user_role_id' => 99,
                    'theme' => 'invalid',
                ],
                'json_response' => [
                    'status' => 'error',
                    'message' => 'Validation error',
                    'data' => [
                        'errors' => [
                            'first_name' => [0 => 'Minimum length is 2'],
                            'surname' => [0 => 'Minimum length is 2'],
                            'email' => [0 => 'Invalid email'],
                            'status' => [0 => 'Invalid option'],
                            'user_role_id' => [0 => 'Invalid option'],
                            'theme' => [0 => 'Invalid option'],
                        ],
                    ],
                ],
            ],
            'max-length' => [
                'request_body' => [
                    'first_name' => str_repeat('i', 101),
                    'surname' => str_repeat('i', 101),
                    'email' => 'new.email.@test.ch',
                ],
                'json_response' => [
                    'status' => 'error',
                    'message' => 'Validation error',
                    'data' => [
                        'errors' => [
                            'first_name' => [0 => 'Maximum length is 100'],
                            'surname' => [0 => 'Maximum length is 100'],
                            'email' => [0 => 'Invalid email'],
                        ],
                    ],
                ],
            ],
        ];
    }
    // ...
}

Testing authorization

Ideally, every single authorization case is tested.
The permission checks work with hierarchical roles. This means that a user with a higher role can do everything a user with a lower role can do.
For each action, the highest non-authorized role and the lowest authorized role are tested; this covers all cases.

Authorization test trait

User roles are stored in the database and have unique ids that are not known in the data provider.
As explained in the section Inserting fixtures with user roles the user_role_id can be provided as an Enum case such as ['user_role_id' => UserRole::ADMIN].
After including the App\Test\Trait\AuthorizationTestTrait to the test class, the test function can replace this id attribute with the actual id using the function addUserRoleId($userAttr) (first test example below).

Or when two users are needed, the function insertUserFixtures($authenticatedUserAttr, $otherUserAttr) can be used to insert the authenticated user and another user where the given user role id is a UserRole Enum case (second test example below).
This function also replaces the two user attribute arrays with the inserted user row data as the arguments are passed by reference. More info and an example can be found in the insertUserFixtures documentation`.

Data provider

Data providers are an excellent way to provide the user roles of the authenticated user and possibly other relevant users and data as well as the expected response data.

For some requests, changing or setting certain columns requires a higher privilege than others. These variations can be tested with data providers.

Test resource creation with authenticated user example

Click to expand

The requirements for each action are different. However, in the following example, where a new user is created, only the authenticated user needs to be created in advance (and the available user roles, but they are inserted by default in the setUp function).
The role of the user being created with the test request is a relevant variation for the data provider cases as a different privilege is required from the authenticated user to create an Admin user or a Newcomer user.

Authorization rules

  • Newcomer and advisor are not allowed to create any user
  • Managing advisor is allowed to create user with role newcomer and advisor but not higher
  • Admin is allowed to create user with role admin or lower

Data Provider

File: tests/Provider/User/UserCreateProvider.php

<?php namespace App\Test\Provider\User;

use App\Domain\User\Enum\UserRole;
use App\Domain\User\Enum\UserStatus;
use Fig\Http\Message\StatusCodeInterface;

class UserCreateProvider
{
    public static function userCreateAuthorizationCases(): array
    {
        // Set different user role attributes
        $managingAdvisorAttr = ['user_role_id' => UserRole::MANAGING_ADVISOR];
        $adminAttr = ['user_role_id' => UserRole::ADMIN];
        $advisorAttr = ['user_role_id' => UserRole::ADVISOR];

        $authorizedResult = [
            StatusCodeInterface::class => StatusCodeInterface::STATUS_CREATED,
            'dbChanged' => true,
            'jsonResponse' => [
                'status' => 'success',
                'data' => null,
            ],
        ];
        $unauthorizedResult = [
            StatusCodeInterface::class => StatusCodeInterface::STATUS_FORBIDDEN,
            'dbChanged' => false,
            'jsonResponse' => [
                'status' => 'error',
                'message' => 'Not allowed to create user.',
            ],
        ];

        return [
            // Advisor is the highest privilege not allowed to create user
            [ // Advisor - create newcomer - not allowed
                'authenticatedUserAttr' => $advisorAttr,
                'newUserRole' => UserRole::NEWCOMER,
                'expectedResult' => $unauthorizedResult,
            ],
            // Managing advisor
            [ // Managing advisor - create user with role advisor (the highest allowed role) - allowed
                'authenticatedUserAttr' => $managingAdvisorAttr,
                'newUserRole' => UserRole::ADVISOR,
                'expectedResult' => $authorizedResult,
            ],
            [ // Managing advisor - create user with role managing advisor (the lowest not allowed) - not allowed
                'authenticatedUserAttr' => $managingAdvisorAttr,
                'newUserRole' => UserRole::MANAGING_ADVISOR,
                'expectedResult' => $unauthorizedResult,
            ],
            // Admin
            [ // Admin - create user with role admin - allowed
                'authenticatedUserAttr' => $adminAttr,
                'newUserRole' => UserRole::ADMIN,
                'expectedResult' => $authorizedResult,
            ],
        ];
    }
    // ...
}

Test function

As only the authenticated user needs to be created in advance, the test function can insert the authenticated user with insertFixture() and addUserRoleId() to replace the user role enum case with the correct id.

File: tests/Integration/User/UserCreateActionTest.php

class UserCreateActionTest extends TestCase
{
    use AppTestTrait;
    use HttpTestTrait;
    use HttpJsonTestTrait;
    use RouteTestTrait;
    use DatabaseTestTrait;
    use DatabaseExtensionTestTrait;
    use FixtureTestTrait;
    use AuthorizationTestTrait;

    public function testUserSubmitCreateAuthorization(
        array $authenticatedUserAttr,
        ?UserRole $newUserRole,
        array $expectedResult
    ): void {
        // Insert authenticated user and user linked to resource with given attributes containing the user role
        $authenticatedUserRow = $this->insertFixture(
            UserFixture::class,
            $this->addUserRoleId($authenticatedUserAttr),
        );

        $userRoleFinderRepository = $this->container->get(UserRoleFinderRepository::class);

        $requestData = [
            'first_name' => 'Danny',
            'surname' => 'Ric',
            'email' => 'daniel.riccardo@notmclaren.com',
            'password' => '12345678',
            'password2' => '12345678',
            'user_role_id' => $newUserRole ? $userRoleFinderRepository->findUserRoleIdByName(
                $newUserRole->value
            ) : $newUserRole,
            'status' => 'unverified',
            'language' => 'en_US',
        ];

        $request = $this->createJsonRequest(
            'POST',
            $this->urlFor('user-create-submit'),
            $requestData
        );
        // Simulate logged-in user by setting the user_id session variable
        $this->container->get(SessionInterface::class)->set('user_id', $authenticatedUserRow['id']);
        $response = $this->app->handle($request);
        // Assert status code
        self::assertSame($expectedResult[StatusCodeInterface::class], $response->getStatusCode());
        // Assert database
        if ($expectedResult['db_changed'] === true) {
            $userDbRow = $this->findLastInsertedTableRow('user');
            // Request data can be taken to assert database as keys correspond to database columns after removing passwords
            unset($requestData['password'], $requestData['password2']);
            $this->assertTableRowEquals($requestData, 'user', $userDbRow['id']);
        } else {
            // Only 1 rows (authenticated user) expected in user table
            $this->assertTableRowCount(1, 'user');
            $this->assertTableRowCount(0, 'user_activity');
        }
        $this->assertJsonData($expectedResult['json_response'], $response);
    }

    // ...
}

Test ressource update example

Click to expand

To test the client update authorization cases, the authenticated user, the user linked to the resource and the data to be changed are relevant because they impact the expected result.

Authorization rules

  • Newcomer is not allowed to change any client data
  • Advisor is allowed to change client personal info data from any client
  • Advisor isn't allowed to change client status if client is not assigned to them (via user_id in the client table)
  • Advisor is never allowed to assign a client to someone else
  • Managing advisor is allowed to change all data of any client and delete/undelete them

Data provider

The data provider contains the different cases with the expected result.

Foreign key values are not known in the data provider, so they are replaced with 'new' and replaced with the correct id in the test function.

File: tests/Provider/Client/ClientUpdateProvider.php

<?php namespace App\Test\Provider\Client;

use App\Domain\User\Enum\UserRole;
use Fig\Http\Message\StatusCodeInterface;

class ClientUpdateProvider
{

    public static function clientUpdateAuthorizationCases(): array
    {
        // Set different user role attributes
        $managingAdvisorAttr = ['user_role_id' => UserRole::MANAGING_ADVISOR];
        $advisorAttr = ['user_role_id' => UserRole::ADVISOR];
        $newcomerAttr = ['user_role_id' => UserRole::NEWCOMER];

        $personalInfoChanges = [
            'first_name' => 'NewFirstName',
            'last_name' => 'NewLastName',
            'birthdate' => '1999-10-22',
            'location' => 'NewLocation',
            'phone' => '011 111 11 11',
            'email' => 'new.email@test.ch',
            'sex' => 'O',
        ];

        $authorizedResult = [
            StatusCodeInterface::class => StatusCodeInterface::STATUS_OK,
            'dbChanged' => true,
            'jsonResponse' => [
                'status' => 'success',
                'data' => null, // age added in test function if present in request data
            ],
        ];
        $unauthorizedResult = [
            StatusCodeInterface::class => StatusCodeInterface::STATUS_FORBIDDEN,
            'dbChanged' => false,
            'jsonResponse' => [
                'status' => 'error',
                'message' => 'Not allowed to update client.',
            ],
        ];

        // To avoid testing each column separately for each user role, the most basic change is taken to test.
        // [foreign_key => 'new'] will be replaced in test function as user has to be added to the database first.
        return [
            // Newcomer
            // "owner" means from the perspective of the authenticated user
            'newcomer owner personal info' => [ // Newcomer owner - change first name - not allowed
                'userLinkedToClientRow' => $newcomerAttr,
                'authenticatedUserRow' => $newcomerAttr,
                // Data to be changed
                'requestData' => ['first_name' => 'value'],
                'expectedResult' => $unauthorizedResult,
            ],
            // Advisor
            'advisor owner client status' => [ // Advisor owner - change client status - allowed
                'userLinkedToClientRow' => $advisorAttr,
                'authenticatedUserRow' => $advisorAttr,
                // client_status_id contains a temporary value replaced by the test function
                // Data to be changed
                'requestData' => array_merge(['client_status_id' => 'new'], $personalInfoChanges),
                'expectedResult' => $authorizedResult,
            ],
            'advisor owner assigned user' => [ // Advisor owner - change assigned user - not allowed
                'userLinkedToClientRow' => $advisorAttr,
                'authenticatedUserRow' => $advisorAttr,
                // user_id contains a temporary value replaced by the test function
                // Data to be changed
                'requestData' => ['user_id' => 'new'],
                'expectedResult' => $unauthorizedResult,
            ],
            'advisor not owner personal info' => [ // Advisor not owner - change personal info - allowed
                'userLinkedToClientRow' => $managingAdvisorAttr,
                'authenticatedUserRow' => $advisorAttr,
                // Data to be changed
                'requestData' => $personalInfoChanges,
                'expectedResult' => $authorizedResult,
            ],
            'advisor not owner client status' => [ // Advisor not owner - change client status - not allowed
                'userLinkedToClientRow' => $managingAdvisorAttr,
                'authenticatedUserRow' => $advisorAttr,
                // Data to be changed
                'requestData' => ['client_status_id' => 'new'],
                'expectedResult' => $unauthorizedResult,
            ],
            'advisor owner undelete' => [ // Advisor owner - undelete client - not allowed
                'userLinkedToClientRow' => $newcomerAttr,
                'authenticatedUserRow' => $advisorAttr,
                // Data to be changed
                'requestData' => ['deleted_at' => null],
                'expectedResult' => $unauthorizedResult,
            ],
            // Managing advisor
            'managing advisor not owner all changes' => [ // Managing advisor not owner - change all data - allowed
                'userLinkedToClientRow' => $advisorAttr,
                'authenticatedUserRow' => $managingAdvisorAttr,
                // Data to be changed
                'requestData' => array_merge(
                    $personalInfoChanges,
                    ['client_status_id' => 'new', 'user_id' => 'new']
                ),
                'expectedResult' => $authorizedResult,
            ],
            'managing advisor not owner undelete' => [ // Managing advisor not owner - undelete client - allowed
                'userLinkedToClientRow' => $advisorAttr,
                'authenticatedUserRow' => $managingAdvisorAttr,
                // Data to be changed
                'requestData' => ['deleted_at' => null],
                'expectedResult' => $authorizedResult,
            ],
        ];
    }
    // ...
}

Test function

The authenticated user and the user linked to the client are inserted with the function insertUserFixtures() which also replaces the user attributes with the inserted user data.

The client_status_id and user_id foreign key values have to be replaced with the correct id.

After the request is sent, the database and expected response data can be asserted.

File: tests/Integration/Client/ClientUpdateActionTest.php

class ClientUpdateActionTest extends TestCase
{
    use AppTestTrait;
    use HttpTestTrait;
    use HttpJsonTestTrait;
    use RouteTestTrait;
    use DatabaseTestTrait;
    use DatabaseExtensionTestTrait;
    use FixtureTestTrait;
    use AuthorizationTestTrait;

    /**
     * Test client update with different authenticated and assigned user roles.
     *
     * @dataProvider \App\Test\Provider\Client\ClientUpdateProvider::clientUpdateAuthorizationCases()
     *
     * @param array $userLinkedToClientRow client owner attributes containing the user_role_id
     * @param array $authenticatedUserRow authenticated user attributes containing the user_role_id
     * @param array $requestData data to be changed for the request body
     * @param array $expectedResult contains HTTP status code, bool if db_changed and json_response
     *
     * @return void
     */
    public function testClientSubmitUpdateActionAuthorization(
        array $userLinkedToClientRow,
        array $authenticatedUserRow,
        array $requestData,
        array $expectedResult
    ): void {
        // Insert authenticated user and user linked to resource with given attributes containing the user role
        $this->insertUserFixtures($authenticatedUserRow, $userLinkedToClientRow);

        // Insert client status
        $clientStatusId = $this->insertFixture(ClientStatusFixture::class)['id'];
        // Insert client that will be used for this test
        $clientAttributes = ['client_status_id' => $clientStatusId, 'user_id' => $userLinkedToClientRow['id']];
        // If deleted at is provided in the request data, it means that client should be undeleted
        if (array_key_exists('deleted_at', $requestData)) {
            // Add deleted at to client attributes
            $clientAttributes = array_merge($clientAttributes, ['deleted_at' => date('Y-m-d H:i:s')]);
        }
        $clientRow = $this->insertFixture(ClientFixture::class, $clientAttributes,);

        // Insert other user and client status used for the modification request if needed.
        if (isset($requestData['user_id'])) {
            // Replace the value "new" from the data to be changed array with a new,
            // different user id (user linked previously + 1)
            $requestData['user_id'] = $clientRow['user_id'] + 1;
            $this->insertFixture(UserFixture::class, ['id' => $requestData['user_id']]);
        }
        if (isset($requestData['client_status_id'])) {
            // Add previously not existing client status to request data (previous client status + 1)
            $requestData['client_status_id'] = $clientRow['client_status_id'] + 1;
            $this->insertFixture(ClientStatusFixture::class, ['id' => $requestData['client_status_id']]);
        }

        // Simulate logged-in user by setting the user_id session variable
        $this->container->get(SessionInterface::class)->set('user_id', $authenticatedUserRow['id']);

        $request = $this->createJsonRequest(
            'PUT',
            $this->urlFor('client-update-submit', ['client_id' => $clientRow['id']]),
            $requestData
        );

        $response = $this->app->handle($request);

        // Assert status code
        self::assertSame($expectedResult[StatusCodeInterface::class], $response->getStatusCode());

        // Assert database
        if ($expectedResult['db_changed'] === true) {
            // HTML form element names are the same as the database columns, the same request array can be taken
            // to assert the db
            // Check that changes requested in the request body are reflected in the database
            $this->assertTableRowEquals($requestData, 'client', $clientRow['id']);
        } else {
            // If db is not expected to change, data should remain the same as when it was inserted from the fixture
            $this->assertTableRowEquals($clientRow, 'client', $clientRow['id']);
        }

        // If birthdate is in request body, age is returned in response data
        if (array_key_exists('birthdate', $requestData)) {
            $expectedResult['json_response']['data'] = [
                'age' => (new \DateTime())->diff(new \DateTime($requestData['birthdate']))->y,
            ];
        }

        $this->assertJsonData($expectedResult['json_response'], $response);
    }
    // ...
}

Testing emails

The testing section of the mailing chapter details how to test emails sent with the Symfony Mailer.

Testing logging

The section asserting logged messages contains a list of methods to assert logged messages with the Monolog TestHandler.