PHP Integration Tests Using the Built-in Server

Microservices have plopped up everywhere and I have written a few of them at work by now. Given the limited scope I find microservices a pleasure to write tests for compared to most (legacy) monolith. In this post I want to share an approach on how to test microservices written in PHP using it’s built-in server and some pitfalls we encountered so far.

Let’s assume we have a nice little microservice built with Slim, Lumen or which ever framework you prefer. We have some routes that return JSON responses and accept various JSON payloads.

We’re able to run this application using PHP’s built-in server by running for example:

ENVIRONMENT=production php -S 0.0.0.0:8080 -t public public/index.php

In this case the public/index.php is the entry file for any HTTP request, and the server is no reachable at http://localhost:8080.

Also you already have a PHPUnit setup available which covers unit tests. For the sake of brevity we will skip the whole PHPUnit setup part and jump directly into writing tests against the built-in server.


Basic Setup

The goal is to write a test case where we might have a body payload which we want to send to specific endpoint and then check if the response has the HTTP status 200 and the returned answer is a JSON containing a specific information. Like this:

<?php
namespace Tests\Integration;

final class ControllerTest extends BaseTest
{
    public function testCorrectCall()
    {
        $body = json_encode(
          [
            'field_one' => 'some data',
            'field_two' => [
              'apple',
              'banana',
            ],
          ]
        );

        /** @var \Psr\Http\Message\ResponseInterface $response */
        $response = $this->dispatch($body, '/api/myfunction', 'POST');

        $this->assertSame($response->getStatusCode(), 200);
        $parsedResponse = json_decode($response->getBody(), true);
        $this->assertArraySubset(['message' => 'success'], $parsedResponse);
    }
}

The interesting part is to resolve the $this->dispatch part. We hand in the endpoint, the HTTP method and the payload and expect to retrieve a reponse object. So the dispatch() function must actually send a HTTP request and return the response object. To extract this from the actual tests, a BaseTest class is created:

<?php
namespace Tests\Integration;

use PHPUnit\Framework\TestCase;
use Symfony\Component\Process\Process;
use GuzzleHttp\Client;

abstract class BaseTest extends TestCase
{

    protected static $process;

    const ENVIRONMENT = "production";
    const HOST = "0.0.0.0";
    const PORT = 9876; // Adjust this to a port you're sure is free

    public static function setUpBeforeClass()
    {
        // The command to spin up the server
        $command = sprintf(
          'ENVIRONMENT=%s php -S %s:%d -t %s %s',
          self::ENVIRONMENT,
          self::HOST,
          self::PORT,
          realpath(__DIR__.'/../../public'),
          realpath(__DIR__.'/../../public/index.php')
        );
        // Using Symfony/Process to get a handler for starting a new process
        self::$process = new Process($command);
        // Disabling the output, otherwise the process might hang after too much output
        self::$process->disableOutput();
        // Actually execute the command and start the process
        self::$process->start();
        // Let's give the server some leeway to fully start
        self::usleep(100000);
    }

    public static function tearDownAfterClass()
    {
        self::$process->stop();
    }

    protected function dispatch($data = null, $path = null, $method = 'POST'): ResponseInterface
    {
        // STEP 2: Using Guzzle we send a request to the server
        $params = [];
        if ($data) {
            $params['body'] = $data;
        }

        // Creating a Guzzle Client with the base_uri, so we can use a relative
        // path for the requests.
        $client = new Client(['base_uri' => 'http://127.0.0.1:' . self::PORT]);
        return $client->request($method, $path, $params)
    }
}

For this to work you have to add these packages to your development dependencies:

composer require --dev symfony/process
composer require --dev guzzlehttp/guzzle

What’s Happening Here?

We’re making use of PHPUnit’s setUpBeforeClass() and tearDownAfterClass() function to control the lifetime of the local PHP server. The symfony/process package gives us a nice handler on executing the PHP command on the local machine. We don’t want to recreate the server for every single test, using one server per TestCase-class is sufficient for our use case.

In the dispatch() function we’re creating a request using guzzlehttp/guzzle. This is sent to server (using the same port as the local server) and it’s Response object is returned to the caller.

Et voilà the testCorrectCall is executed against a local PHP server running your application.

Some Notes

  • We ran into weird issues where tests would be stuck forever, adding self::$process->disableOutput(); resolved this for us.
  • If you have multiple classes inheriting from BaseTest you might want to use different ports. We had issues where the ports were not yet released by the system. Swapping the cont $port for a static $port and either increasing or decreasing it, might help.

Combining Remote Tests with a Mocked Environment

As noted above, most PHP frameworks come with a way to test them in a mocked environment – without spinning up a local PHP server. Our tests don’t make any use of that ability so far.

In an attempt to further enhance the test suite, we wanted to identify occasions where the application behaves differently when accessed directly to when it is accessed via HTTP. In fact, our initial trigger to use remote tests was a scenario where we only had test against a mocked environment and everything looked good in the tests, only to learn that our service would crash for the exact same calls when execute remotely (after being deployed of course).

So here the solution we’re currently using to run tests against both the remote server as well as a mocked environment:

<?php

namespace Tests\Integration;

use GuzzleHttp\Client;
use PHPUnit\Framework\TestCase;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;

abstract class BaseTest extends TestCase
{
    # Added to the part of BaseTest shown above

    protected function dispatch($data = null, $path = null, $method = 'POST', $headers = []): ResponseInterface
    {
        $localResponse = $this->dispatchLocal($data, $path, $method);
        $remoteResponse = $this->dispatchRemote($data, $path, $method);

        $this->ensureLocalAndHttpIdentical($localResponse, $remoteResponse);

        return $remoteResponse;
    }

    private function dispatchLocal($data = null, $path = null, $method = 'POST'): ResponseInterface
    {

        // TODO Mock your application as necessary, so it can be executed and returns an instance of the ResponseInterface
        $req = new Request($method, $uri, $headers, $cookies, $serverParams, $body);

        $this->app->getContainer()['request'] = $req;

        return $this->app->run(true);
    }

    private function dispatchRemote($data = null, $path = null, $method = 'POST'): ResponseInterface
    {
        $params = [];
        if ($data) {
            $params['body'] = $data;
        }

        $client = new Client(['base_uri' => 'http://127.0.0.1:' . self::PORT]);

        return $client->request($method, $path, $params);
    }

    protected function ensureLocalAndHttpIdentical(ResponseInterface $localResponse, ResponseInterface $remoteResponse)
    {
        $this->assertSame($localResponse->getStatusCode(), $remoteResponse->getStatusCode());
        $this->assertEquals((string)$localResponse->getBody(), (string)$remoteResponse->getBody());
    }
}

So when we now use the dispatch() function in any of our tests it will create two requests: one against the mocked application and an actual HTTP request against the local PHP server. As we don’t want to test the properties of both requests individually, we compare the body and the response code int eh ensureLocalAndHttpIdentical() function. Any differences here would cause the tests to fail. Then the $remoteResponse is being returned to the actual test.

This doesn’t remove the need for unit tests. But it added a lot to my ease of mind when refactoring controllers or adding new features. Next for me will be add Mutation Testing to my palette of test approaches (most likely with Infection).