How to run your Laravel Dusk tests in parallel with GitHub Actions

Published on

Laravel Dusk tests are slow, running them in parallel can speed them up significantly. This post explains how to run your Dusk tests in parallel using GitHub actions.

This post assumes you already have your Dusk tests running in your CI pipeline.

A screenshot of a GitHub Actions workflow that runs Laravel Dusk tests in parallel

Multiple runners with a matrix

There is no --parallel flag for Dusk tests. So instead, we use multiple runners and divide the tests amongst them.

In your GitHub Actions workflow file, define a matrix of runners:

jobs:
  tests:
    runs-on: "ubuntu-20.04"
    strategy:
      fail-fast: false
      matrix:
        parallel-runner: [
          "dusk-1/5",
          "dusk-2/5",
          "dusk-3/5",
          "dusk-4/5",
          "dusk-5/5",
        ]

I recommend using fail-fast: false so you get a complete list of failed tests. That way you can fix them all in one go.

We pass the name of the runner to the process that runs our Dusk tests:

- name: Run Dusk Tests
  run: php artisan dusk
  env:
    PARALLEL_TEST_RUNNER: ${{ matrix.parallel-runner }}

Chunking tests with a PHPUnit extension

Now that we have multiple runners, and each runner is receiving its name, we can figure out which tests each runner should run. We can do this using a PHPUnit extension and two PHPUnit hooks: BeforeFirstTestHook and BeforeTestHook:

<?php

namespace Tests\Support;

use Illuminate\Support\Str;
use PHPUnit\Runner\BeforeFirstTestHook;
use PHPUnit\Runner\BeforeTestHook;

class RunTestsInParallel implements BeforeFirstTestHook, BeforeTestHook
{
    public static bool $shouldSkipCurrentTest = false;

    private static int $i = 0;

    private static int $runnerNumber = -1;

    private static int $runnersCount = -1;

    public function executeBeforeFirstTest(): void
    {
        $runnerName = getenv('PARALLEL_TEST_RUNNER'); // for example: "dusk-2/5"

        if (! $runnerName) {
            return;
        }

        [static::$runnerNumber, static::$runnersCount] = Str::of($runnerName)->afterLast('-')->explode('/');

        throw_if(static::$runnerNumber === 0, 'Invalid runner name, must start at 1: '.$runnerName);

        throw_if(static::$runnerNumber > static::$runnersCount, 'Invalid runner name: '.$runnerName);

        echo 'Running tests in parallel, chunk '.static::$runnerNumber.'/'.static::$runnersCount.'...'.PHP_EOL.PHP_EOL;
    }

    public function executeBeforeTest(string $test): void
    {
        if (static::$runnerNumber === -1) {
            return;
        }

        static::$i++;

        static::$shouldSkipCurrentTest = static::$i % static::$runnersCount !== static::$runnerNumber - 1;
    }
}

Register this PHPUnit extension in phpunit.dusk.xml. If you don't have this file yet, create it in the root of your project:

<phpunit bootstrap="vendor/autoload.php" colors="true">
    <testsuites>
        <testsuite name="Browser Test Suite">
            <directory>./tests/Browser</directory>
        </testsuite>
    </testsuites>
    <extensions>
        <extension class="Tests\Support\RunTestsInParallel"/>
    </extensions>
</phpunit>

And now our DuskTestCase can skip tests:

protected function setUp(): void
{
    if (RunTestsInParallel::$shouldSkipCurrentTest) {
        $this->markTestSkipped();
    }

    parent::setUp();
}

And that's it, parallel Dusk tests.

The beauty of this approach is that you can run your tests like you'd normally do. In your local environment, the PARALLEL_TEST_RUNNER variable won't be set, and all tests will be run. In CI, the variable is set, and tests will automatically be chunked based on how many runners you have defined.

Uploading screenshots of failed tests

When a Dusk test fails, it takes a screenshot. These screenshots are usually pretty handy for debugging. If you want to see the screenshots your CI workflow makes, you can upload them as an artifact:

- name: Upload Dusk fail screenshots
  if: failure()
  uses: actions/upload-artifact@v2
  with:
    name: dusk-fail-screenshots
    path: tests/Browser/screenshots/failure-*
    retention-days: 1

Notice how you don't have to give the artifact a unique name for each parallel runner. By default, GitHub will append to the artifact instead of overwriting it. You'll get a single zip file full of screenshots even if you have multiple workflow runners.

Also running PHPUnit tests in parallel

You can also use the approach described in this post to run your unit tests in parallel. Laravel offers the --parallel flag for normal PHPUnit tests, but that won't help much because GitHub runners aren't very powerful.

If you also run your unit tests in parallel, your workflow will look something like this:

jobs:
  tests:
    runs-on: "ubuntu-20.04"
    strategy:
      fail-fast: false
      matrix:
        parallel-runner: [
          "phpunit-1/2",
          "phpunit-1/2",
          "dusk-1/5",
          "dusk-2/5",
          "dusk-3/5",
          "dusk-4/5",
          "dusk-5/5",
        ]

You'll have to make sure your PHPUnit runners don't run your Dusk tests and vice versa. You can do that like this:

- name: Run PHPUnit tests
  if: startsWith(matrix.parallel-runner, 'phpunit')
  run: vendor/bin/phpunit
  env:
    PARALLEL_TEST_RUNNER: ${{ matrix.parallel-runner }}

- name: Run Dusk Tests
  if: startsWith(matrix.parallel-runner, 'dusk')
  run: php artisan dusk
  env:
    PARALLEL_TEST_RUNNER: ${{ matrix.parallel-runner }}

If you've done that, you just have to register the RunTestsInParallel extension in your phpunit.xml and skip the tests in your TestCase.php, and you'll be golden.

Do you want to deploy Laravel with GitHub Actions?

I've spent an unreasonable amount of time making the perfect deployment script for Laravel.

Deploy Laravel with GitHub Actions