Slim 4 - Testing

Daniel Opitz
Daniel Opitz
09 Jun 2020

Table of contents

Requirements

This topic assumes a basic understanding of unit tests and phpunit. If unfamiliar with phpunit, read the Getting Started with PHPUnit tutorial and its linked content.

Introduction

Whenever you write a new line of code, you also potentially add new bugs. To build better and more reliable applications, you should test your code using both integration- and unit tests.

This article explains how to integrate PHPUnit as testing framework, but won’t cover PHPUnit itself, which has its own excellent documentation.

Installation

Before creating the first test we are installing phpunit as development dependency with the --dev option:

composer require phpunit/phpunit --dev

Each test - whether it’s a unit test or a integration test - is a PHP class that should live in the tests/TestCase/ directory of your application.

Create a new tests/TestCase/ directory in your project root.

Open composer.json and the following scripts:

"scripts": {
    "test": "phpunit --configuration phpunit.xml",
    "test:coverage": "phpunit --configuration phpunit.xml --coverage-clover build/logs/clover.xml --coverage-html build/coverage"
}

The code coverage output directory is: build/coverage/

PHPUnit is configured by the phpunit.xml file in the root of your Slim application.

Create a new file phpunit.xml and copy/paste this configuration:

<?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="vendor/autoload.php" 
    colors="true" 
    backupGlobals="false" 
    backupStaticAttributes="false">
    <testsuites>
        <testsuite name="Tests">
            <directory suffix="Test.php">tests</directory>
        </testsuite>
    </testsuites>
    <filter>
        <whitelist processUncoveredFilesFromWhitelist="false">
            <directory suffix=".php">src</directory>
            <exclude>
                <directory>vendor</directory>
                <directory>build</directory>
            </exclude>
        </whitelist>
    </filter>
</phpunit>

Try executing PHPUnit by running :

composer test

To start all tests with code coverage, run:

composer test:coverage

If you receive the error message Error: No code coverage driver is available you have to enable Xdebug.

To enable Xdebug, locate or create the [XDebug] section in the php.ini file and update it as follows:

[XDebug]
zend_extension = "<path to xdebug extension>"
xdebug.remote_autostart = 1
xdebug.remote_enable = 1

Test Traits

To be able to perform complete and realistic integration tests we have to setup the container (PSR-11) for each test first. The advantage is that we can also test the complete middleware stack and use the autowire functionality of the depenency injection container.

The following trait will bootstrap the Slim application with the depenency injection container and provides some convenient methods for mocking and creating http requests.

Create a new directory: tests/Traits.

Create a new file tests/Traits/AppTestTrait.php and copy/paste this content:

<?php

namespace App\Test\Traits;

use DI\Container;
use InvalidArgumentException;
use JsonException;
use PHPUnit\Framework\MockObject\MockObject;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\UriInterface;
use Slim\App;
use Slim\Psr7\Factory\ServerRequestFactory;
use UnexpectedValueException;

/**
 * App Test Trait.
 */
trait AppTestTrait
{
    /**
     * @var Container
     */
    protected $container;

    /**
     * @var App
     */
    protected $app;

    /**
     * Bootstrap app.
     *
     * @throws UnexpectedValueException
     *
     * @return void
     */
    protected function setUp(): void
    {
        $this->app = require __DIR__ . '/../../config/bootstrap.php';

        $container = $this->app->getContainer();
        if ($container === null) {
            throw new UnexpectedValueException('Container must be initialized');
        }

        $this->container = $container;
    }

    /**
     * Add mock to container.
     *
     * @param string $class The class or interface
     *
     * @return MockObject The mock
     */
    protected function mock(string $class): MockObject
    {
        if (!class_exists($class)) {
            throw new InvalidArgumentException(sprintf('Class not found: %s', $class));
        }

        $mock = $this->getMockBuilder($class)
            ->disableOriginalConstructor()
            ->getMock();

        $this->container->set($class, $mock);

        return $mock;
    }

    /**
     * Create a server request.
     *
     * @param string $method The HTTP method
     * @param string|UriInterface $uri The URI
     * @param array $serverParams The server parameters
     *
     * @return ServerRequestInterface
     */
    protected function createRequest(
        string $method,
        $uri,
        array $serverParams = []
    ): ServerRequestInterface {
        return (new ServerRequestFactory())->createServerRequest($method, $uri, $serverParams);
    }

    /**
     * Create a JSON request.
     *
     * @param string $method The HTTP method
     * @param string|UriInterface $uri The URI
     * @param array|null $data The json data
     *
     * @return ServerRequestInterface
     */
    protected function createJsonRequest(
        string $method,
        $uri,
        array $data = null
    ): ServerRequestInterface {
        $request = $this->createRequest($method, $uri);

        if ($data !== null) {
            $request = $request->withParsedBody($data);
        }

        return $request->withHeader('Content-Type', 'application/json');
    }

 /**
     * Verify that the given array is an exact match for the JSON returned.
     *
     * @param array $expected The expected array
     * @param ResponseInterface $response The response
     *
     * @throws JsonException
     * @return void
     */
    protected function assertJsonData(array $expected, ResponseInterface $response): void
    {
        $actual = (string)$response->getBody();
        $this->assertSame($expected, (array)json_decode($actual, true, 512, JSON_THROW_ON_ERROR));
    }
}

Unit Tests

Unit tests ensure that individual components of the app work as expected.

A unit test is a test against a single PHP class, also called a unit. If you want to test the overall behavior of your application, see the section about Integration Tests.

Suppose, for example, that you have an incredibly simple class called Calculator in the src/Support/ directory of the app:

namespace App\Support;

class Calculator
{
    public function add(int $a, int $b): int
    {
        return $a + $b;
    }
}

To test this, create a CalculatorTest file in the tests/TestCase/Support directory of your application:

// tests/TestCase/Support/CalculatorTest.php
namespace App\Test\TestCase\Support;

use App\Support\Calculator;
use PHPUnit\Framework\TestCase;

class CalculatorTest extends TestCase
{
    public function testAdd(): void
    {
        $calculator = new Calculator();
        $result = $calculator->add(30, 12);

        // assert that your calculator added the numbers correctly!
        $this->assertEquals(42, $result);
    }
}

By convention, the tests/TestCase/ directory should replicate the directory of your module for unit tests. So, if you’re testing a class in the src/Support/ directory, put the test in the tests/TestCase/Support/ directory.

Now run all tests:

composer test

Mocking

When testing Slim applications, you may wish to “mock” certain aspects of your application, so they are not actually executed during a test. For example, when testing an HTTP endpoint that hits the database, you may wish to mock the repository method, so it’s not actually accessing the database during the test.

Phpunit itself provides some useful methods for mocking out of the box. But when you use a dependency injection container (PSR-11), then you also have to set the mocked instances into the container. For this purpose I added the mock helper method into the AppTestTrait. This tiny helper primarily provides a convenience layer over the Phpunit MockObject, so you do not have to manually make complicated mocking method calls.

For example, if you want to mock the database, just use the mock method as follows:

use App\Domain\User\Repository\UserCreatorRepository;

// ...

// Mock the required repository method
$this->mock(UserCreatorRepository::class)
    ->method('insertUser')
    ->willReturn(1);

Warning: Try to avoid excessive mocking.

Try to test and cover all the code you actually deploy.

The downside of mocking is that you are not testing and covering the code that you actually deploy and run on your real system. The phpunit code coverage can never be 100% when you mock your repositories. and the test quality will never be as good as in real integration tests.

Another problem with mocking is that your tests become very large and complex, and the test needs to know all the implementation details that could change quickly during the next refactoring. Maintaining such mocked tests would become more difficult and expensive in the long run.

Mocking makes sense if you have external APIs that must not be touched when the test is running. So, for example, I would only mock HTTP requests for external APIs.

When you write tests for the Actions, better write integration tests that cover the Actions, the Services, and the Repositories all at once. Then your tests are easier to setup, and you cover all the code you actually deploy.

Conclusion: Don’t mock everything, it’s an anti-pattern. If everything is mocked, are we really testing the production code? Don’t hesitate to not mock!

Integration Tests

Integration tests ensure that component collaborations work as expected.

Assertions may test the HTTP API, or side-effects such as database, filesystem, datetime, logging etc.

Depending on your needs, you can choose to run your test against a integration database (with fixtures) or only against a mocked repository. Some people prefer to create repository interfaces and replace them with an empty implementation for testing. Of course you can do that, but the technical effort for testing is much higher then.

HTTP Tests

HTTP testing allows you to verify your API endpoints. This includes the infrastructure supported by the app, such as the database, file system, and network.

HTTP tests have a very specific workflow:

All HTTP requests are performed in memory. We don’t need a http client (like Guzzle) or a webserver to perform the requests.

Ok, now add your first API test. Let’s assume that you have implemented a RESTful API with Slim.

Create a new file tests/TestCase/Action/UserReaderActionTest.php and add this code to test the endpoint GET /users/1:

<?php

namespace App\Test\TestCase\Action;

use App\Domain\User\Data\UserData;
use App\Domain\User\Repository\UserReaderRepository;
use App\Test\Traits\AppTestTrait;
use PHPUnit\Framework\TestCase;

class UserReaderActionTest extends TestCase
{
    use AppTestTrait;

    /**
     * Test.
     *
     * @dataProvider provideUserReaderAction
     *
     * @param UserData $user The user
     * @param array $expected The expected result
     *
     * @return void
     */
    public function testUserReaderAction(UserData $user, array $expected): void
    {
        // Mock the repository resultset
        $this->mock(UserReaderRepository::class)
            ->method('getUserById')->willReturn($user);

        // Create request with method and url
        $request = $this->createRequest('GET', '/users/1');

        // Make request and fetch response
        $response = $this->app->handle($request);

        // Asserts
        $this->assertSame(200, $response->getStatusCode());
        $this->assertJsonData($expected, $response);
    }

    /**
     * Provider.
     *
     * @return array The data
     */
    public function provideUserReaderAction(): array
    {
        $user = new UserData();
        $user->id = 1;
        $user->username = 'admin';
        $user->email = 'john.doe@example.com';
        $user->firstName = 'John';
        $user->lastName = 'Doe';

        return [
            'User' => [
                $user,
                [
                    'user_id' => 1,
                    'username' => 'admin',
                    'first_name' => 'John',
                    'last_name' => 'Doe',
                    'email' => 'john.doe@example.com',
                ]
            ]
        ];
}

The PHPUnit documentation contains more information about Data Providers

Now run all tests:

composer test

To test a JSON endpoint you can use the createJsonRequest method, e.g.:

$request = $this->createJsonRequest('POST', '/users', ['name' => 'Sally']);
$response = $this->app->handle($request);

Passing a query string

The http_build_query can generate URL-encoded query strings. Example:

$params = [
    'limit' => 10,
];

$url = sprintf('/users?%s', http_build_query($params));
// Result: /users?limit=10

$request = $this->createRequest('GET', $url);

Database Testing

The selective/test-traits component provides a variety of helpful tools to make it easier to test your database driven applications.

Installation:

composer require selective/test-traits --dev

The DatabaseTestTrait provides methods for all these stages of a database test:

Add the DatabaseTestTrait only to a phpunit test class where you want to write a database test:

use PHPUnit\Framework\TestCase;
use Selective\TestTrait\Traits\DatabaseTestTrait;
// ...

class UserCreateActionTest extends TestCase
{
    use AppTestTrait;
    // ...
}

Then invoke the setUpDatabase method and pass the full path to the sql schema file:

protected function setUp(): void
{
    // ...
    
    $this->setUpDatabase(__DIR__ . '/../../resources/schema/schema.sql');
}

The setUpDatabase method installs the database schema into a phpunit specific test database. The database trait fetches the PDO::class instance directly from the DI container. So make sure that the DI container returns the connection from a testing-, and not from a development database. You can do this by defining a different database name in an environment-specific configuration file or by “manually” placing a custom PDO instance in the DI container.

Example configuration file for phpunit: config/local.testing.php

// Phpunit test database
$settings['db']['database'] = 'slim_skeleton_test';

Database asserts

Assert a number of rows in a given table:

$this->assertTableRowCount(1, 'users');

Assert the given row exists:

$this->assertTableRowExists('users', 1);

Assert that the given row does not exist:

$this->assertTableRowNotExists('users', 1);

Assert row values:

$this->assertTableRow($expected, 'users', 1);

Assert a specific set of row values:

$this->assertTableRow($expected, 'users', 1, ['email', 'url']);
$this->assertTableRow($expected, 'users', 1, array_keys($expected));

Assert a specific value in a given table, row and field:

$this->assertTableRowValue('1', 'users', 1, 'id');

Read single value from table by id:

$password = $this->getTableRowById('users', 1)['password'];

Test fixtures

Insert multiple fixtures at once:

use App\Test\Fixture\UserFixture;

$this->insertFixtures([UserFixture::class]);

Insert manual fixtures:

$this->insertFixture('tablename', $row);

Conclusion

We have seen how to create all kinds of tests like unit- and integration tests with phpunit.

Read more