Cartoon avatar of Charles Sprayberry
cspray.io

Asynchronous testing with amphp and PHPUnit

Recently I have had the opportunity to get back into developing with PHP 7, which means I've been getting my hands dirty with amphp. If you are unfamiliar with the project you should definitely check it out. It provides a series of libraries that makes asynchronous programming in PHP easy and accessible.

I'm pretty big into Test Driven Development and I write a lot of unit tests. Over time I've discovered that it is important that tests aren't painful to write; if they're painful developers who aren't as motivated about TDD will be less inclined to write them. It is also a drag for the developers who do enjoy writing unit tests. Right away with my work with amphp I noticed that needing to have your test running inside a Loop adds some boilerplate to your tests. Certainly not a lot but more than I like to see. Having dug into the internals of PHPUnit in the past I wondered if there might be a way to implicitly have your tests run within the Amp event loop.

After digging around I came up with a proposal for amphp/phpunit-util to add an AsyncTestCase for supporting exactly this use case.

Update: This PR was merged in earlier this year and can be found in amphp/phpunit-util 1.1+.

Example

Below we create a contrived example for how you might test an asynchronous call using the new AsyncTestCase class.

<?php declare(strict_types=1);

// We assume that your PHPUnit bootstrap will include the appropriate class loader and setup your environment

use Amp\Delayed;
use Amp\Promise;
use Amp\PHPUnit\AsyncTestCase;
use function Amp\call;

class AsyncSubject
{
    public function doSomething(): Promise
    {
        return call(function () {
            yield new Delayed(250); // emulate a network call
            yield new Delayed(100); // emulate a call to a file system
            return 'We did it!';
        });
    }
}

class AsyncSubjectTest extends AsyncTestCase
{
    public function testThatWeCanDoIt()
    {
        $subject = new AsyncSubject();
        $response = yield $subject->doSomething();
        $this->assertSame('We did it!', $response);
    }
}

Notice that we didn't need to start, or even reference, Amp\Loop in our test case; all of that is handled for us by the AsyncTestCase. This class does not do anything special with assertions nor does it attempt to provide its own set of async-supported assertions. Any assertions provided by PHPUnit should work out-of-the-box.

Stopping runaway tests

Sometimes it might be possible that your subject under test does not actually stop the amphp loop and it continues to run after your test assertions. Generally speaking you should fix your application code and/or tests so this scenario does not play out; it likely indicates that you will have a runaway application in a non-testing environment. Regardless it is possible to ensure that tests do not run for longer than a certain amount of time with the setTimeout(int) method.

As an example, adding the below setUp method to the test case above would cause it to fail on an early timeout because our test case still had unresolved operations on the Loop.

public function setUp() : void {
    $this->setTimeout(200);
}

Wrapping up

Throughout my programming career there have been a few things that have been "game changers"; TDD was certainly one of them and Amp was another. It opened up the possibilities of what you can do with PHP and helped me become a better programmer with asynchronous technologies. It was exciting getting an opportunity to combine 2 passions; async PHP and unit testing. I hope this improvement makes your tests easier to read and write.

Happy testing!


I would like to give special thanks to @kelunik and @trowski for their invaluable assistance in seeing this project to completion.


Comments for articles on this blog are handled through a GitHub repository. To make comments you will be required to have a GitHub account.

The comments for this article can be found at this issue