Our test suite had become a bottleneck. Every push to develop or main triggered three separate CI jobs — unit tests, feature tests, and integration tests — each running sequentially after the previous one finished and each downloading the same apt packages from scratch. The result: slow feedback loops and a lot of redundant work.
This post walks through the changes we made to fix that.
The Problem: Three Jobs, Lots of Duplication
Before this change, the CI pipeline defined three independent jobs:
phpunit-unit:
name: "PHP Unit Tests"
steps:
- name: Install packages
run: |
sudo apt update
sudo apt install -y package-a package-b package-c
- name: Execute tests
run: php artisan test --parallel tests/Unit
phpunit-feature:
name: "PHP Feature Tests"
steps:
# same apt install block...
- name: Execute tests
run: php artisan test --parallel tests/Feature
phpunit-integration:
name: "PHP Integration Tests"
steps:
# same apt install block again...
- name: Execute tests
run: php artisan test --parallel tests/Integration
Each job was:
- Checking out the repo independently
- Setting up PHP independently
- Installing Composer dependencies independently (even with caching, cache restores take time)
- Running
apt-get installwith the same packages — every single time, with no caching
Step 1: Collapsing Three Jobs into a Matrix
The first change replaced the three separate job definitions with a single phpunit job that uses GitHub Actions' matrix strategy:
phpunit:
name: "PHP Tests (${{ matrix.suite }})"
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- suite: unit-a
- suite: unit-b
- suite: feature-a
- suite: feature-b
- suite: Integration
All suites run in parallel, with fail-fast: false so a failure in one suite doesn't cancel the others mid-run.
The Execute tests step became a single parameterised command:
- name: Execute tests
run: php artisan test --parallel --testsuite=${{ matrix.suite }} --coverage-clover test-coverage/coverage-${{ matrix.suite }}.xml
Why More Suites Instead of Fewer?
The original split was coarse: all unit tests ran together, and all feature tests ran together. One large bucket would hold everything up even when most of the tests in it finished quickly.
By naming suites explicitly in phpunit.xml, we got finer-grained parallelism:
<testsuite name="unit-a">
<directory suffix=".php">./tests/Unit/GroupA</directory>
</testsuite>
<testsuite name="unit-b">
<directory suffix=".php">./tests/Unit/GroupB</directory>
<directory suffix=".php">./tests/Unit/GroupC</directory>
</testsuite>
<testsuite name="feature-a">
<directory suffix=".php">./tests/Feature/GroupA</directory>
</testsuite>
<testsuite name="feature-b">
<directory suffix=".php">./tests/Feature/GroupB</directory>
<directory suffix=".php">./tests/Feature/GroupC</directory>
</testsuite>
This keeps each matrix leg roughly balanced in terms of test count and makes the overall wall-clock time shorter than when one slow bucket holds everything up.
Step 2: Caching apt Packages
The most wasteful part of the old setup was reinstalling the same system packages on every runner, for every job, from scratch. apt-get install isn't slow in absolute terms, but multiplied across five parallel jobs and every CI run, it adds up.
We replaced the raw apt install block with awalsh128/cache-apt-pkgs-action:
- name: Install packages
uses: awalsh128/cache-apt-pkgs-action@latest
with:
packages: package-a package-b package-c
version: ${{ hashFiles('.apt-packages') }}
The version key is tied to a .apt-packages lockfile committed to the repo. The cache is invalidated only when that file changes — not on every push — so the common case is a fast cache hit rather than a full install.
Any post-install configuration that previously lived inside the install block becomes its own named step. The action calls it only on a cache miss, so it doesn't run unnecessarily on cache hits:
- name: Post-install configuration
run: # your configuration command here
The Downstream Effect: Coverage Merging
Any job that previously depended on the three separate job names now depends on the single phpunit job:
some-downstream-job:
needs: [
"phpunit", # was: phpunit-unit, phpunit-feature, phpunit-integration
...
]
And the coverage merge step includes one file per suite:
./clover-merge -o coverage.xml \
unit-a/coverage-unit-a.xml \
unit-b/coverage-unit-b.xml \
feature-a/coverage-feature-a.xml \
feature-b/coverage-feature-b.xml \
integration/coverage-integration.xml
What This Changes
| Before | After |
|---|---|
| 3 jobs, one per test type | 5 jobs, all parallel |
apt install on every run, every job |
Cached apt packages, invalidated only on lockfile change |
| Coarse path-based test selection | Named testsuites in phpunit.xml |
| ~3× duplicated job boilerplate | Single parameterised job definition |
The wall-clock time for the PHP test stage drops because the legs run concurrently and the apt install step is usually a cache hit. Maintenance is easier too — adding a new suite is a two-line change: one entry in phpunit.xml and one entry in the matrix include list.
If this post was enjoyable or useful for you, please share it! If you have comments, questions, or feedback, you can email my personal email. To get new posts, subscribe use the RSS feed.