PHPUnit with Bref

May 04, 2020

One thing I learned to love with Docker was the easy ability to run phpunit with the exact same environment that will be used in production. Whenever I'm setting up a release pipeline, I like to build a Docker image, start a container from that image, enter it and run the project test code. If everything passes, I can composer install --no-dev, push the image to a docker registry and release it. Yes, if one of my dev dependencies is not actually a dev dependency things can still break. Another source of production bugs in this scenario revolves around environment variables. But the point is to minimize, as much as possible, the difference between production and the test environment. By doing so, we catch some obvious bugs early in the process. In this post I want to walk through my process of doing the same thing when aiming to deploy to AWS Lambda using Bref.

The Test Case

I want to start this off with a test case. Let's suppose we have the following test case to run before any deployments:

<?php declare(strict_types=1);

require 'vendor/autoload.php';

class MyTest extends \PHPUnit\Framework\TestCase
{
    public function test_gmp(): void
    {
        \PHPUnit\Framework\Assert::assertSame('2', gmp_strval(gmp_add(1, 1)));
    }
}

Of course on a real project, we'd be interacting with the project's source code from our tests in order to gauge whether the codebase is in a working state. For brevity, let's assume we have some code that depends on PHP GMP extension and this test is suppose to cover such case. We can go about running this on a local machine with the following Dockerfile and phpunit configuration:

FROM alpine:3.11

RUN apk add composer php7-dom php7-xmlwriter php7-xml php7-gmp php7-tokenizer

RUN composer require phpunit/phpunit

COPY MyTest.php tests/MyTest.php

COPY phpunit.xml .

CMD ["vendor/bin/phpunit"]
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.1/phpunit.xsd"
         bootstrap="vendor/autoload.php"
         executionOrder="depends,defects"
         forceCoversAnnotation="true"
         beStrictAboutCoversAnnotation="true"
         beStrictAboutOutputDuringTests="true"
         beStrictAboutTodoAnnotatedTests="true"
         verbose="true">
    <testsuites>
        <testsuite name="default">
            <directory suffix="Test.php">tests</directory>
        </testsuite>
    </testsuites>

    <filter>
        <whitelist processUncoveredFilesFromWhitelist="true">
            <directory suffix=".php">src</directory>
        </whitelist>
    </filter>
</phpunit>

By placing these 3 files in a folder, I can run the following command and receive the expected output.

 docker build . -t bref/phpunit && docker run bref/phpunit
# docker build . -t bref/phpunit && docker run bref/phpunit
Sending build context to Docker daemon  4.608kB
Step 1/6 : FROM alpine:3.11
 ---> e7d92cdc71fe
Step 2/6 : RUN apk add composer php7-dom php7-xmlwriter php7-xml php7-gmp php7-tokenizer
 ---> Using cache
 ---> c4c49e71d793
Step 3/6 : RUN composer require phpunit/phpunit
 ---> Using cache
 ---> c713de154e43
Step 4/6 : COPY MyTest.php tests/MyTest.php
 ---> Using cache
 ---> 1e41fb02f5d7
Step 5/6 : COPY phpunit.xml .
 ---> Using cache
 ---> 9c30786b9ea6
Step 6/6 : CMD ["vendor/bin/phpunit"]
 ---> Using cache
 ---> 6ffd750bd407
Successfully built 6ffd750bd407
Successfully tagged bref/phpunit:latest
PHPUnit 9.1.4 by Sebastian Bergmann and contributors.

Runtime:       PHP 7.3.17
Configuration: /phpunit.xml

.                                                                   1 / 1 (100%)

Time: 00:00.034, Memory: 4.00 MB

OK (1 test, 1 assertion)

However, if we were to deploy the actual code to AWS Lambda, it would very likely fail to execute with an error saying PHP Warning: Uncaught Error: Call to undefined function gmp_add().

PHPUnit & Bref

Bref comes with a handy Docker image for tests which we can find at https://hub.docker.com/r/bref/php-74. The awesomeness about this image is that it is built with the exact same php binary that Lambda will use to run the deployed code. For more on this image, check https://github.com/brefphp/bref/blob/master/runtime/base/php-74.Dockerfile.

So how do we go about using this image for PHPUnit testing? For this I like to use a docker-compose file. Here's how we can start:

version: '3.7'

services:
  my-service:
    image: bref/php-74
    entrypoint: /usr/bin/tail
    command: ["-f", "/dev/null"]
    user: root
    working_dir: /var/task
    volumes:
      - .:/var/task
      - ./php/build:/root/build
      - ./tests/setup/bref.ini:/opt/bref/etc/php/conf.d/bref.ini  # Disable opcache

The idea behind setting tail as entrypoint is so that the container stays alive in a long running process. This is extremely helpful in CI servers when we need our tests to wait for the database container to be ready. I wish I had come up with that genius hack, but credit for that goes to Abdala Cerqueira. We mount our code on /var/task which matches the expectation of AWS Lambda. I also like to include a tests/setup/bref.ini in my projects to override the default bref.ini because it comes with opcache enabled.

; On the CLI we want errors to be sent to stdout -> those will end up in CloudWatch
display_errors=1

; Since PHP 7.4 the default value is E_ALL
; We override it to set the recommended configuration value for production.
; See https://github.com/php/php-src/blob/d91abf76e01a3c39424e8192ad049f473f900936/php.ini-production#L463
error_reporting = E_ALL & ~E_DEPRECATED & ~E_STRICT

; This directive determines which super global arrays are registered when PHP
; starts up. G,P,C,E & S are abbreviations for the following respective super
; globals: GET, POST, COOKIE, ENV and SERVER.
; We explicitly populate all variables else ENV is not populated by default.
; See https://github.com/brefphp/bref/pull/291
variables_order="EGPCS"

This file is a slim down version of the original file that comes with Bref, the only change is removing opcache extension. Check out the file at https://github.com/brefphp/bref/blob/master/runtime/layers/function/php.ini.

Lastly, we have the volume for php/build. The idea behind this one is that Bref suggests you to have a php folder at the root of your project and place any ini file you want to be included by AWS Lambda inside of it. I decided to use this folder for extra things, such as installing composer. The thing is, since AWS Lambda base operating system is Amazon Linux, which itself is based off of RedHat, we lose all of our convenience of Alpine such as apk add composer. Inside this folder I usually put two files: dev-composer.sh and prod-composer.sh.

#!/usr/bin/env sh

cd /tmp

php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');"

php composer-setup.php --filename=composer

php composer install --working-dir /var/task --no-interaction
#!/usr/bin/env sh

cd /tmp

php composer install --no-dev --working-dir /var/task --no-interaction

The idea behind all of this is that in my pipeline I'll have a stage that will do roughly the following:

# Start services
- docker-compose up -d my-service

# Install dependencies
- docker-compose exec -T my-service chmod +x /root/build/dev-composer.sh
- docker-compose exec -T my-service /root/build/dev-composer.sh

# Run phpunit
- docker-compose exec -T my-service ./vendor/bin/phpunit

# Remove dev dependencies
- docker-compose exec -T my-service chmod +x /root/build/prod-composer.sh
- docker-compose exec -T my-service /root/build/prod-composer.sh

# Remove any unnecessary file
- rm php/build -rf

# Package and Deploy the Lambda 

However, if we run this right now, we get an error with the following output:

# docker-compose exec -T my-service ./vendor/bin/phpunit
PHPUnit 9.1.4 by Sebastian Bergmann and contributors.

Runtime:       PHP 7.4.2
Configuration: /var/task/phpunit.xml

E                                                                   1 / 1 (100%)

Time: 00:00.065, Memory: 4.00 MB

There was 1 error:

1) MyTest::test_gmp
Error: Call to undefined function gmp_strval()

/var/task/tests/MyTest.php:9

ERRORS!
Tests: 1, Assertions: 0, Errors: 1.

This is because Bref does not come with PHP GMP by default, we need to use an additional layer to get that. You can read a bit about using Bref layers locally at https://github.com/brefphp/extra-php-extensions/issues/9.

The short version is that we're going to docker pull bref/extra-gmp-php-74, start a new container with it and then copy all of the necessary files into the php/build folder. That way, we'll have all of the necessary dependencies ready inside the project for when the pipeline runs and at the end of the pipeline we can run rm php/build -rf to get rid of it before actually uploading the source code to S3 for deployment so that we don't upload several MB of data that will not be used. Remember, when the code is actually running inside AWS Lambda, we'll have the Layer ARN including this extension. The docker image is just a convenience for local development / CI server to keep parity.

Update (March 2021): I found an easier and more efficient way of enabling extensions without having to manually copy the extension files. Read more about it here.

The following sequence of commands will bring the files that we need outside of the container

docker run --entrypoint /bin/bash --user root -w /tmp/php-ext -v $(pwd)/php/build/ext-gmp-php74:/tmp/php-ext -it bref/extra-gmp-php-74
cp /opt/bref-extra/ . -R
cp /opt/bref/include/ . -R
cp /opt/bref/lib64/ . -R

Now that we have all of the necessary files copied into our host machine, we can modify the docker-compose.yaml file to include some extra volumes for our local testing:

version: '3.7'

services:
  my-service:
    image: bref/php-74
    entrypoint: /usr/bin/tail
    command: ["-f", "/dev/null"]
    user: root
    working_dir: /var/task
    volumes:
      - .:/var/task
      - ./php/build:/root/build
      - ./tests/setup/bref.ini:/opt/bref/etc/php/conf.d/bref.ini  # Disable opcache
      
      # php7-gmp exntension: https://github.com/brefphp/extra-php-extensions/tree/master/layers/gmp
      - ./php/build/ext-gmp-php74/bref-extra/gmp.so:/opt/bref-extra/gmp.so
      - ./php/build/ext-gmp-php74/bref-extra/gmp.so:/opt/bref/lib/php/extensions/no-debug-zts-20190902/gmp.so
      - ./php/build/ext-gmp-php74/lib64/libgmp.so:/opt/bref/lib64/libgmp.so
      - ./php/build/ext-gmp-php74/lib64/libgmpxx.so:/opt/bref/lib64/libgmpxx.so
      - ./php/build/ext-gmp-php74/include/gmp-mparam-x86_64.h:/opt/bref/include/gmp-mparam-x86_64.h
      - ./php/build/ext-gmp-php74/include/gmp-mparam.h:/opt/bref/include/gmp-mparam.h
      - ./php/build/ext-gmp-php74/include/gmp-x86_64.h:/opt/bref/include/gmp-x86_64.h
      - ./php/build/ext-gmp-php74/include/gmp.h:/opt/bref/include/gmp.h
      - ./php/build/ext-gmp-php74/include/gmpxx.h:/opt/bref/include/gmpxx.h

Now that we included GMP extension (from the original layer) into our local container, all we need is to enable the extension with a php.ini inside ./php/conf.d/php.ini as follows:

extension=/opt/bref-extra/gmp.so

After shutting down the container and starting it again from the modified docker-compose.yaml, I can successfully run the test

# docker-compose exec -T my-service ./vendor/bin/phpunit
PHPUnit 9.1.4 by Sebastian Bergmann and contributors.

Runtime:       PHP 7.4.2
Configuration: /var/task/phpunit.xml

.                                                                   1 / 1 (100%)

Time: 00:00.037, Memory: 4.00 MB

OK (1 test, 1 assertion)

Conclusion

I know this is a little bit of work, but I honestly feel like it is worth it. After running all of my phpunit tests against my code using the exact same binary as the one that will be in production and making sure that the extensions are an exact match, I can feel more confident in deploying changes. I only have to pay extra attention when depending on new environment variables because we usually define those on the docker-compose file, but have to replicate them on the serverless.yaml template for AWS. The PHP version is the same and the extensions are also the same. That way, if I need to enable a new extension on production, I have to make sure my pipeline will have the same extension available so that I can test my code against it before making any changes to the production lambda. The fact that Bref offers all of these Docker images and keep them up to date is wonderful and makes it easy to grab some new extensions for local development.

As always, hit me up on Twitter with any questions.

Cheers.