60

Slim 4 - Testing

 3 years ago
source link: https://odan.github.io/2020/06/09/slim4-testing.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client
Daniel’s Dev Blog

Daniel's Dev Blog

Developer, Trainer, Open Source Contributor

Blog About me Donate

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/Util/ directory of the app:

namespace App\Util;

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

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

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

use App\Util\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/Util/ directory, put the test in the tests/TestCase/Util/ 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 usful 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);

This could also be used to mock external API calls etc.

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.

Integration Tests

Integration tests ensure that component collaborations work as expected.

  • Test a repository against the actual database
  • Test an application service using a real controller action
  • Test a HTTP integration against the real webservice

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:

  • Make a request (click on a link or submit json data)
  • Test the response
  • Clean up and repeat

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 = '[email protected]';
        $user->firstName = 'John';
        $user->lastName = 'Doe';

        return [
            'User' => [
                $user,
                [
                    'user_id' => 1,
                    'username' => 'admin',
                    'first_name' => 'John',
                    'last_name' => 'Doe',
                    'email' => '[email protected]',
                ]
            ]
        ];
}

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);

Database Testing

The shown HTTP test doesn’t hit the database and is very fast. If you also want to test against the database, you should read my next article post about database testing.

Stay tuned.

Read more

© 2020 Daniel Opitz | Twitter


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK