Running our test suite in parallel on GitHub actions

Published on April 23, 2025 by Freek Van der Herten

A couple of years ago, Laravel introduced a great feature which allows to run PHPUnit / Pest tests in parallel. This results in a big boost in performance.

By default, it determines the concurrency level by taking a look at the number of CPU cores your machine has. So, if you're using a modern Mac that has 10 CPU cores, it will run 10 tests at the same time, greatly cutting down on the time your testsuite needs to run completely.

A default runner on GitHub doesn't have that many cores, so you can't leverage parallel testing as good as in your typical local environments.

In this blog post, I'd like to show you a way of running your tests on GitHub, by splitting them up in small chunks that can run concurrently.vWe use this technique to cut down the running time of our vast testsuite from 16 minutes to only just 4. In this blog post all examples will come from our base.

What we're going to do

Like already mentioned in the introduction, a typical test runner on GitHub hasn't got a lot of cores, meaning the default way of running tests in parallel that Laravel offers (running a test per CPU core) doesn't work well.

What you can do at GitHub however is running a lot of GitHub actions jobs in parallel. So what we are going to do is splitting our testsuite in equal chunks, and create a test job on GitHub action per chunk. These chunks can run in parallel, which will immensely decrease the total running time.

I'll tell you how to achieve this technically in the remainder of this blog post, but here's already how the end result will look like on GitHub.

In the screenshot above you can see that our test suite is split up in 12 parts which will all run concurrently. Composer / NPM will only run once to build up the dependencies and assets and they will be used in all 12 testing parts.

Splitting up the testsuite in equal parts

Let's first take a look at how we can split the testsuite in equal parts. We use Pest as a test runner, which offers a --list-tests option to output all tests.

Here's a bit of code to get all test class names from that output.

 $process = new Process([__DIR__ . '/../vendor/bin/pest', '--list-tests']);

$process->mustRun();

$index = $shardNumber - 1;

$allTestNames = Str::of($process->getOutput())
    ->explode("\n")
    ->filter(fn(string $line) => str_contains($line, ' - '))
    ->map(function (string $fullTestName) {
        $testClassName = Str::of($fullTestName)
            ->replace('- ', '')
            ->trim()
            ->between('\\\\', '::')
            ->afterLast('\\')
            ->toString();

        return $testClassName;
    })
    ->filter()
    ->unique();

In $allTestNames will be a collection containing all class names (= file names) that are inside the test suite.

To split the collection up in multiple parts, you can use the split function which accepts the number of parts you want. Here's how you would split up the tests in 12 parts, and get the first part

$testNamesOfFirstPart = $allTestNames
   ->split(12) // split the collection in 12 equal parts
   ->get(key: 0) // get the first part (the index is 0 based)

PHPUnit / Pest also offers a --filter option to only run specific tests. If you only want to run the tests in from the ArchTest class (which is displayed in the screenshot above), you could execute this.

# Will only run the tests from the ArchTest file
vendor/bin/pest --filter=ArchTest

You can use | to specify multiple patterns. Here's how you could execute the tests from multiple files

# Will only run the tests from the ArchTest file
vendor/bin/pest --filter=ArchTest|CheckSitesBeingMonitoredTest

Here's how you could use the $testNamesOfFirstPart from the previous snippet to run the first part of the tests programmatically.

$process = new Process(
    command: [
       './vendor/bin/pest',
       '--filter', 
       $testNamesOfFirstPart->join('|')],
       timeout: null // take as much time as we need
);

$process->start();

/* pipe the Pest output to the console */
foreach ($process as $data) {
    echo $data;
}

$process->wait();

// use the exit code of Pest as the exit code of the script
exit($process->getExitCode());

Running the testsuite parts in parallel at GitHub

Now that you know how you could split up a test suite in equal parts, let's take a look at how we can run all these parts in parallel on GitHub actions.

GitHub actions support a matrix parameter. Shortly said, this matrix parameter is used for testing variations of your test suite, and it will run those variations concurrently.

Here's the part of the Oh Dear GitHub workflow where the matrix is being set up. I've omitted several parts for brevity.

# .github/workflows/run-tests.yml

name: Run tests

jobs:
    run-tests:
        name: Run Tests (Part ${{ matrix.shard_number }}/${{ matrix.total_shard_count }})
        runs-on: ubuntu-latest
        strategy:
            matrix:
                total_shard_count: [12]
                shard_number: ['01', '02', '03', '04', '05', '06', '07', '08', '09', '10', '11', '12']

        steps:
           ## 
           ## multiple set up steps omitted for brevity
           ##
            -   name: Run tests
                run: ./vendor/bin/pest

The matrix will create jobs per combination in the matrix. So it will run 1 (only one element in total_shard_count) * 12 (twelve elements in shard_number) = 12 times.

In the Run tests step, pest is executed. This will result in the whole testsuite being executed 12 times. Of course we don't want to execute the whole test suite 12 times, but only each separate 1/12 part of the testsuite.

We can achieve this by not running /vendor/bin/pest but a custom PHP script called github_parallel_test_runner that will receive the total_shard_count and the shard_number as environment variables.

# .github/workflows/run-tests.yml

name: Run tests

jobs:
    run-tests:
        name: Run Tests (Part ${{ matrix.shard_number }}/${{ matrix.total_shard_count }})
        runs-on: ubuntu-latest
        strategy:
            matrix:
                total_shard_count: [12]
                shard_number: ['01', '02', '03', '04', '05', '06', '07', '08', '09', '10', '11', '12']

        steps:
           ## 
           ## multiple set up steps omitted for brevity
           ##
            -   name: Run tests
                run: ./bin/github_parallel_test_runner
                env:
                    TOTAL_SHARD_COUNT: ${{ matrix.total_shard_count }}
                    SHARD_NUMBER: ${{ matrix.shard_number }}

Here's the content of ./bin/github_parallel_test_runner in our code base. It will read the environment variables, execute Pest using the --list-files and --filter flags to only run a part of the tests like explained in the previous section of this post.

#!/usr/bin/env php
<?php

use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Symfony\Component\Process\Process;

require_once 'vendor/autoload.php';

$shardNumber = (int)getenv('SHARD_NUMBER');
$totalShardCount = (int)getenv('TOTAL_SHARD_COUNT');

if ($shardNumber === 0 || $totalShardCount === 0) {
    echo "SHARD_NUMBER and TOTAL_SHARD_COUNT must be set." . PHP_EOL;
    exit(1);
}

new ParallelTests($totalShardCount)->run($shardNumber);

class ParallelTests
{
    public function __construct(
        protected int $totalShardCount,
    )
    {
    }

    public function run(int $shardNumber): never
    {
        $testNames = $this->getTestNames($shardNumber);

        echo "Running {$testNames->count()} tests on node {$shardNumber} of {$this->totalShardCount}..." . PHP_EOL;

        $exitCode = $this->runPestTests($testNames);

        exit($exitCode);
    }

    /** @return Collection<string> */
    protected function getTestNames(int $shardNumber): Collection
    {
        $process = new Process([__DIR__ . '/../vendor/bin/pest', '--list-tests']);

        $process->mustRun();

        $index = $shardNumber - 1;

        $allTestNames = Str::of($process->getOutput())
            ->explode("\n")
            ->filter(fn(string $line) => str_contains($line, ' - '))
            ->map(function (string $fullTestName) {
                $testClassName = Str::of($fullTestName)
                    ->replace('- ', '')
                    ->trim()
                    ->between('\\\\', '::')
                    ->afterLast('\\')
                    ->toString();

                return $testClassName;
            })
            ->filter()
            ->unique();

        echo "Detected {$allTestNames->count()} tests:" . PHP_EOL;

        return $allTestNames
            ->split($this->totalShardCount)
            ->get($index);
    }

    protected function runPestTests(Collection $testNames): ?int
    {
        $process = new Process(
            command: ['./vendor/bin/pest', '--filter', $testNames->join('|')],
            timeout: null
        );

        $process->start();

        /* pipe the Pest output to the console */
        foreach ($process as $data) {
            echo $data;
        }

        $process->wait();

        return $process->getExitCode();
    }
}

To make this script executable you must execute this command and push the changes permissions...

chmod +x ./bin/github_parallel_test_runner

Only run composer and NPM / Yarn once

In the screenshot above, you could see how there's a "Composer and Yarn" step that is executing only once before all the test parts run. Here's that screenshot again.

A GitHub action workflow can contain multiple jobs, and you can define dependencies between them. In the snippet below you'll see the setup-dependencies job being defined (I've omitted all steps regarding to Yarn / NPM to keep things brief). We save the vendor directory as an artifact and use that saved directory in all of our test jobs. Finally, there's also a step to clean up any created artifacts.

You can see that in the needs key, you can define the steps that a job depends on.

#
# name, and concurrency setup omitted for brevity
#

jobs:
    setup-dependencies:
        name: Composer and Yarn
        runs-on: ubuntu-latest
        steps:
            -   uses: actions/checkout@v3
                with:
                    fetch-depth: 1

            -   name: Setup PHP
                uses: shivammathur/setup-php@v2
                with:
                    php-version: 8.4
                    extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick
                    coverage: none

            -   name: Install composer dependencies
                run: composer install --prefer-dist --no-scripts -q -o

            -   name: Upload vendor directory
                uses: actions/upload-artifact@v4
                with:
                    name: vendor-directory-${{ github.run_id }}
                    path: vendor
                    retention-days: 1

    run-tests:
        needs: [ setup-dependencies]
        name: Run Tests (Part ${{ matrix.shard_number }}/${{ matrix.total_shard_count }})
        runs-on: ubuntu-latest
        strategy:
            fail-fast: false
            matrix:
                total_shard_count: [12]
                shard_number: ['01', '02', '03', '04', '05', '06', '07', '08', '09', '10', '11', '12']
        steps:
		        #
		        # Some setup steps omitted for brevity
		        #
        
            -   uses: actions/checkout@v3
                with:
                    fetch-depth: 1

            -   name: Download vendor directory
                uses: actions/download-artifact@v4
                with:
                    name: vendor-directory-${{ github.run_id }}
                    path: vendor

            -   name: Run tests
                run: ./bin/github-parallel-test-runner
                env:
                    TOTAL_SHARD_COUNT: ${{ matrix.total_shard_count }}
                    SHARD_NUMBER: ${{ matrix.shard_number }}

    cleanup-artifacts:
        name: Clean up artifacts
        needs: [setup-dependencies, run-tests]
        runs-on: ubuntu-latest
        if: always()
        steps:
            -   name: Delete artifacts
                uses: geekyeggo/delete-artifact@v2
                with:
                    name: |
                        vendor-directory-${{ github.run_id }}
                    failOnError: false

Cancelling stale tests

There's another neat little thing that we do in our GitHub action workflow to save some time. Whenever changes are pushed to a certain branch, we're not really interested in the results of the tests of any previous commits / pushes on that branch anymore.

Wouldn't it be nice if the test for any older commits on a branch were automatically cancelled, so the tests for the new commits / push would immediately start?

Well, with this snippet in your workflow, that's exactly what will happen.

name: Run tests

on:
    push:
        paths:
            - '**.php'
            - '.github/workflows/run-tests.yml'
            - 'phpunit.xml.dist'
            - 'composer.json'
            - 'composer.lock'

concurrency:
    group: ${{ github.workflow }}-${{ github.ref }}
    cancel-in-progress: true
    
jobs:
   #
   # omitted for brevity
   #

Our complete test workflow

To make things clear, here's our entire workflow file including the setup of all the services that our testsuite needs like MySQL, Redis, ClickHouse, Lighthouse and more.

name: Run tests

on:
    push:
        paths:
            - '**.php'
            - '.github/workflows/run-tests.yml'
            - 'phpunit.xml.dist'
            - 'composer.json'
            - 'composer.lock'

concurrency:
    group: ${{ github.workflow }}-${{ github.ref }}
    cancel-in-progress: true

jobs:
    setup-dependencies:
        name: Composer and Yarn
        runs-on: ubuntu-latest
        steps:
            -   uses: actions/checkout@v3
                with:
                    fetch-depth: 1

            -   name: Setup PHP
                uses: shivammathur/setup-php@v2
                with:
                    php-version: 8.4
                    extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick
                    coverage: none

            -   name: Get Composer Cache Directory
                id: composer-cache
                run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT

            -   name: Cache Composer dependencies
                uses: actions/cache@v3
                with:
                    path: ${{ steps.composer-cache.outputs.dir }}
                    key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
                    restore-keys: ${{ runner.os }}-composer-

            -   name: Install composer dependencies
                run: composer install --prefer-dist --no-scripts -q -o

            -   name: Upload vendor directory
                uses: actions/upload-artifact@v4
                with:
                    name: vendor-directory-${{ github.run_id }}
                    path: vendor
                    retention-days: 1

            -   name: Cache Node Modules
                uses: actions/cache@v3
                with:
                    path: node_modules
                    key: ${{ runner.os }}-node-${{ hashFiles('**/yarn.lock') }}
                    restore-keys: ${{ runner.os }}-node-

            -   name: Cache built assets
                uses: actions/cache@v3
                with:
                    path: |
                        public/build
                        public/hot
                        public/css
                        public/js
                    key: ${{ runner.os }}-assets-${{ hashFiles('resources/**/*') }}-${{ hashFiles('**/yarn.lock') }}
                    restore-keys: ${{ runner.os }}-assets-

            -   name: Compile assets
                run: |
                    yarn install --pure-lockfile
                    yarn build
                # Only run build if node_modules or assets cache wasn't hit
                if: steps.node-cache.outputs.cache-hit != 'true' || steps.assets-cache.outputs.cache-hit != 'true'

            -   name: Upload compiled assets
                uses: actions/upload-artifact@v4
                with:
                    name: compiled-assets-${{ github.run_id }}
                    path: |
                        public/build
                        public/hot
                        public/css
                        public/js
                    retention-days: 1

    install-chrome-and-lighthouse:
        name: Install Chrome and Lighthouse
        runs-on: ubuntu-latest
        steps:
            -   uses: actions/checkout@v3
                with:
                    fetch-depth: 1

            -   name: Setup PHP
                uses: shivammathur/setup-php@v2
                with:
                    php-version: 8.4
                    extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick
                    coverage: none

            -   name: Setup problem matchers
                run: |
                    echo "::add-matcher::${{ runner.tool_cache }}/php.json"
                    echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json"

            -   name: Install Chrome Launcher
                run: npm install chrome-launcher

            -   name: Install Lighthouse
                run: npm install lighthouse

            -   name: Cache test environment
                uses: actions/upload-artifact@v4
                with:
                    name: test-env-${{ github.run_id }}
                    path: |
                        node_modules
                    retention-days: 1

    run-tests:
        needs: [ setup-dependencies, install-chrome-and-lighthouse ]
        name: Run Tests (Part ${{ matrix.shard_number }}/${{ matrix.total_shard_count }})
        runs-on: ubuntu-latest
        strategy:
            fail-fast: false
            matrix:
                total_shard_count: [12]
                shard_number: ['01', '02', '03', '04', '05', '06', '07', '08', '09', '10', '11', '12']

        services:
            mysql:
                image: mysql:8.0
                env:
                    MYSQL_ALLOW_EMPTY_PASSWORD: yes
                    MYSQL_DATABASE: ohdear_testing
                ports:
                    - 3306
                options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
            redis:
                image: redis
                ports:
                    - 6379:6379
                options: --entrypoint redis-server
            clickhouse:
                image: clickhouse/clickhouse-server
                options: >-
                    --health-cmd "clickhouse client -q 'SELECT 1'"
                    --health-interval 10s
                    --health-timeout 5s
                    --health-retries 5
                ports:
                    - 8123:8123
                    - 9000:9000
                    - 9009:9009
                env:
                    CLICKHOUSE_DB: ohdear
                    CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: 1

        steps:
            -   uses: actions/checkout@v3
                with:
                    fetch-depth: 1

            -   name: create db
                run: |
                    sudo /etc/init.d/mysql start
                    mysql  -u root -proot -e 'CREATE DATABASE IF NOT EXISTS ohdear_testing;'

            -   name: Setup PHP
                uses: shivammathur/setup-php@v2
                with:
                    php-version: 8.4
                    extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick
                    coverage: none

            -   name: Download test environment
                uses: actions/download-artifact@v4
                with:
                    name: test-env-${{ github.run_id }}
                    path: .

            -   name: Download vendor directory
                uses: actions/download-artifact@v4
                with:
                    name: vendor-directory-${{ github.run_id }}
                    path: vendor

            -   name: Download compiled assets
                uses: actions/download-artifact@v4
                with:
                    name: compiled-assets-${{ github.run_id }}
                    path: public

            -   name: Prepare Laravel Application
                run: |
                    cp .env.example .env
                    php artisan key:generate

            -   name: Set permissions for vendor binaries
                run: chmod -R +x vendor/bin/

            -   name: Run tests
                run: ./bin/github-parallel-test-runner
                env:
                    DB_PORT: ${{ job.services.mysql.ports[3306] }}
                    REDIS_PORT: ${{ job.services.redis.ports[6379] }}
                    CLICKHOUSE_HOST: localhost
                    CLICKHOUSE_PORT: 8123
                    CLICKHOUSE_DATABASE: ohdear
                    CLICKHOUSE_USERNAME: default
                    CLICKHOUSE_PASSWORD:
                    CLICKHOUSE_HTTPS: false
                    TOTAL_SHARD_COUNT: ${{ matrix.total_shard_count }}
                    SHARD_NUMBER: ${{ matrix.shard_number }}

    cleanup-artifacts:
        name: Clean up artifacts
        needs: [setup-dependencies, install-chrome-and-lighthouse, run-tests]
        runs-on: ubuntu-latest
        if: always()
        steps:
            -   name: Delete artifacts
                uses: geekyeggo/delete-artifact@v2
                with:
                    name: |
                        vendor-directory-${{ github.run_id }}
                        compiled-assets-${{ github.run_id }}
                        test-env-${{ github.run_id }}
                    failOnError: false

Pros and cons

There are pros and cons for running tests in parallel on GitHub actions. Let's start with the most important pro first: your test suite will run significantly faster. It's also very easy to increase or decrease the level of parallelism. A faster testsuite means that your entire feedback cycle is faster, which is a big win.

On the cons side, there's certainly some more complexity involved: you need a script to split tests, the workflow becomes more complex.

There's also the risk of uneven test distribution. If your tests vary significantly in execution time, you might end up with some shards finishing much earlier than others, reducing the efficiency gain.

In closing

The approach outlined in this post works exceptionally well for large test suites like we have, where we've seen significant reduction in test execution time. But even smaller projects can benefit from this technique, especially as they grow over time.

Start using Oh Dear today!

  • Access to all features
  • Cancel anytime
  • No credit card required
  • First 30 days free

More updates

Want to get started? We offer a no-strings-attached 30 day trial. No credit card required.

Start monitoring

You're all set in
less than a minute!