Mezzio Example: Functional Testing


Where do we start?

It's a good choice to start any application with a solid structure for testing the application with automated testing tools. That's why the first post in this series is about testing. Any well-tested application will typically have more lines of testing code than actual application code. Starting with a good structure for testing will pay dividends down the road.

In this post, I'll show a basic setup for testing Mezzio applications. We'll get to some more advanced testing topics in later posts.

Legacy ZF Testing Tools

Those experienced with Laminas MVC (FKA Zend Framework MVC) applications are likely familiar with the laminas-test library, or Zend\Test for ZF v2 and maybe even Zend_Test, the old ZF v1 testing framework.

The documentation for these components allude to unit testing your application using these tools. That, unfortunately is a misnomer. All of the testing libraries provided by Laminas are geared for integration testing or functional testing your code, but not really unit testing. The difference between unit tests and integration tests is dramatic. Those differences have been blogged about at length. Check the goog for more information about the different types of tests.

A Basic Example

Let's start with an example functional test of your new complete Mezzio application middleware stack. This example should be somewhat familiar if you're used to how laminas-test works with old MVC apps. We'll test the ping route that comes with the Mezzio Skeleton Application.

You may have noticed that skeleton comes with some basic tests of the simple handlers that come with the skeleton. We'll show those in a later post. For now, we're going to start with a functional test of the GET /api/ping REST endpoint from end to end. This test will exercise the entire stack so we can prove that the endpoint “works”.

I've created a couple of simple abstract classes that all of my functional tests can extend, making writing new tests fast and easy. These tests use real instances of your application components – the dependency injection container and the application itself including the middleware pipeline and the routes. In fact the base class might remind you of an application bootstrap like that found in public/index.php in a Mezzio skeleton app.

Here's the base class for all functional tests. You'll note that this does nothing except initialize the application to be tested. Some assumptions must hold true for this to work out of the box. In short, if you start with the Mezzio skeleton, you should be all set. If the paths to your container/pipeline/routes config is non-standard, you might have to do some extra work – perhaps a symlink or two.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
<?php

declare(strict_types=1);

namespace FunctionalTest;

use Helmich\Psr7Assert\Psr7Assertions;
use Mezzio\Application;
use Mezzio\MiddlewareFactory;
use PHPUnit\Framework\TestCase;
use Psr\Container\ContainerInterface;

abstract class AbstractFunctionalTest extends TestCase
{
    use Psr7Assertions;

    /** @var ContainerInterface */
    protected static $container;

    /** @var Application */
    protected static $app;

    public static function setUpBeforeClass(): void
    {
        static::initContainer();
        static::initApp();
        static::initPipeline();
        static::initRoutes();
    }

    public static function tearDownAfterClass(): void
    {
        static::$container = null;
        static::$app = null;
    }

    /**
     * Initialize new container instance.
     */
    protected static function initContainer(): void
    {
        static::$container = require 'config/container.php';
    }

    /**
     * Initialize app.
     */
    protected static function initApp(): void
    {
        static::$app = static::$container->get(Application::class);
    }

    /**
     * Initialize pipeline.
     */
    protected static function initPipeline(): void
    {
        (require 'config/pipeline.php')(
            static::$app,
            static::$container->get(MiddlewareFactory::class),
            static::$container
        );
    }

    /**
     * Initialize routes.
     */
    protected static function initRoutes(): void
    {
        (require 'config/routes.php')(
            static::$app,
            static::$container->get(MiddlewareFactory::class),
            static::$container
        );
    }
}

Any simple tests that require only a minimally bootstrapped application can extend the AbstractFunctionalTest class and test away.

Now it gets a little bit more interesting. Here's an abstract extension that adds some framework for defining REST endpoints in a PHPUnit @dataProvider to be tested and the constraints to assert for those endpoints:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
<?php

declare(strict_types=1);

namespace FunctionalTest;

use Laminas\Diactoros\ServerRequest;
use Laminas\Diactoros\Stream;
use Laminas\Diactoros\Uri;
use PHPUnit\Framework\Constraint;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;

/**
 * Abstract to set up functional testing via endpoint provider config.
 */
abstract class AbstractEndpointTest extends AbstractFunctionalTest
{
    /**
     * Provider for testEndpoint() method.
     *
     * @see self::testEndpoint() for provider signature
     */
    abstract public function endpointProvider(): array;

    protected function getRequest(
        string $method,
        string $uri,
        array $requestHeaders = [],
        array $body = [],
        array $queryParams = []
    ): ServerRequestInterface {
        if ($body) {
            $bodyStream = fopen('php://memory', 'r+');
            fwrite($bodyStream, json_encode($body));
            $body = new Stream($bodyStream);
        }

        return new ServerRequest(
            [],
            [],
            $uri,
            $method,
            $body ?? 'php://input',
            $requestHeaders ?? [],
            [],
            $queryParams
        );
    }

    /**
     * @dataProvider endpointProvider
     *
     * @param Constraint[] $responseConstraints
     */
    public function testEndpoint(
        ServerRequestInterface $request,
        array $responseConstraints = []
    ): void {
        $response = static::$app->handle($request);
        $this->assertInstanceOf(ResponseInterface::class, $response);
        $this->assertResponseConstraints($responseConstraints, $response);
    }

    /**
     * @param Constraint[] $responseConstraints
     */
    protected function assertResponseConstraints(
        array $responseConstraints,
        ResponseInterface $response
    ): void {
        if (empty($responseConstraints)) {
            $responseConstraints[] = self::isSuccess();
        }
        foreach ($responseConstraints as $msg => $constraint) {
            $this->assertThat(
                $response,
                $constraint,
                is_string($msg) ? $msg : ''
            );
        }
    }
}

Now we can actually define a test. Here's a simple implementation of ab endpoint test for GET /api/ping. Note that going forward, we only need to define an array of endpoints to be tested:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
<?php

declare(strict_types=1);

namespace FunctionalTest;

use PHPUnit\Framework\Assert;

/**
 * Tests only PING.
 */
class PingTest extends AbstractEndpointTest
{
    public function endpointProvider(): array
    {
        return [
            // GET /api/ping
            'api.ping.get' => [
                $this->getRequest('GET', '/api/ping'),
                [
                    self::isSuccess(),
                    self::bodyMatchesJson([
                        'ack' => Assert::greaterThanOrEqual(time()),
                    ]),
                ],
            ],
        ];
    }
}

The result of the above example is a test of the GET /api/ping route, asserting simply that:

  1. Response code is >=200 and <300
  2. Response body contains JSON with a property called ack with a value of the current time in UNIX timestamp format.

Series: Mezzio Example

  1. Mezzio Example: Introduction
  2. Mezzio Example: Functional Testing
comments powered by Disqus