Balances PHPUnit test execution across parallel jobs based on historical timing data from JUnit XML reports.
This tool helps optimize CI pipelines by distributing tests evenly across parallel runners, minimizing the total execution time.
composer require --dev shipmonk/phpunit-parallel-job-balancervendor/bin/phpunit --log-junit junit.xml
vendor/bin/balance-phpunit-jobs junit.xmlThis outputs PHPUnit testsuite XML fragments to stdout. Copy them into your phpunit.xml to configure parallel test runs.
| Option | Short | Description | Default |
|---|---|---|---|
--jobs=N |
-j |
Number of parallel jobs | 4 |
--exclude=PATH |
-e |
Exclude path from output (repeatable) | - |
--tests-dir=PATH |
- | Base test directory | ./tests |
--test-suite-prefix=PREFIX |
- | Test suite name prefix (generates part1, part2, ...) | part |
--help |
-h |
Show help message | - |
# Balance into 8 parallel jobs
vendor/bin/balance-phpunit-jobs -j 8 junit/*.xml
# With exclusions
vendor/bin/balance-phpunit-jobs \
--jobs=4 \
--exclude=./tests/Integration/Slow \
--exclude=./tests/E2E \
junit/*.xml
# Custom test suite prefix
vendor/bin/balance-phpunit-jobs -j 4 --test-suite-prefix=job junit/*.xml
# Generates: job1, job2, job3, job4<testsuite name="part1">
<!-- 45.123 s --><directory>./tests/Unit/Service</directory>
<!-- 12.456 s --><file>./tests/Unit/SpecificTest.php</file>
</testsuite>
<testsuite name="part2">
<!-- 38.789 s --><directory>./tests/Integration</directory>
<!-- 18.234 s --><directory>./tests/Unit/Repository</directory>
</testsuite>-
Parse JUnit XML reports - Extracts test file paths and their execution times from JUnit XML format (generated by PHPUnit with
--log-junitoption) -
Build timing tree - Creates a hierarchical tree of test paths where each node accumulates the total execution time of all its children
-
Greedy bin-packing with tree splitting - The algorithm:
- Calculates target time per job (total time / job count)
- For each node, finds the job with minimum accumulated time
- If adding the node keeps the job under target OR the node has no children, assigns it to that job
- Otherwise, splits the node into its children for finer-grained distribution
-
Generate PHPUnit XML - Outputs testsuite fragments that can be used to configure parallel PHPUnit runs
The balancer uses historical timing data from JUnit XML reports generated by your CI runs. Since committing files from CI jobs is typically complex, the recommended workflow involves manually updating your phpunit.xml:
test:
parallel: 4
script:
- vendor/bin/phpunit --testsuite "part${CI_NODE_INDEX}" --log-junit junit.xml
artifacts:
paths:
- junit.xml
reports:
junit: junit.xmljobs:
test:
strategy:
matrix:
part: [1, 2, 3, 4]
steps:
- uses: actions/checkout@v4
- name: Run tests
run: vendor/bin/phpunit --testsuite "part${{ matrix.part }}" --log-junit junit.xml
- name: Upload JUnit report
uses: actions/upload-artifact@v4
with:
name: junit-part-${{ matrix.part }}
path: junit.xmlAfter your CI run completes, download the JUnit XML artifacts and run the balancer locally:
# Download artifacts from CI (e.g., into ./junit-reports/)
vendor/bin/balance-phpunit-jobs -j 4 ./junit-reports/*.xmlCopy the generated testsuite fragments from the output and paste them into your phpunit.xml:
<phpunit>
<testsuites>
<!-- Paste the generated testsuites here -->
<testsuite name="part1">
<!-- 45.123 s --><directory>./tests/Unit/Service</directory>
<!-- 12.456 s --><file>./tests/Unit/SpecificTest.php</file>
</testsuite>
<testsuite name="part2">
<!-- 38.789 s --><directory>./tests/Integration</directory>
<!-- 18.234 s --><directory>./tests/Unit/Repository</directory>
</testsuite>
<!-- ... -->
</testsuites>
</phpunit>Commit the updated phpunit.xml to your repository. Future CI runs will use the balanced test distribution.
Re-run this process periodically (e.g., when test execution times drift significantly) to keep the distribution optimal.
Paratest and PHPUnit Parallel Job Balancer solve different problems and can be used together.
| Feature | PHPUnit Parallel Job Balancer | Paratest |
|---|---|---|
| Purpose | Distributes tests across CI jobs | Runs tests in parallel processes |
| Parallelization | Across machines/containers | Within a single machine |
| Timing-based balancing | Yes, uses historical JUnit XML data | Limited (WrapperRunner only) |
| CI integration | Native (GitLab CI, GitHub Actions, etc.) | Requires single machine |
| Test isolation | Full (separate CI jobs) | Process-level |
| Race conditions | None (complete isolation) | Possible (shared resources) |
| Adoption effort | Minimal (no test changes needed) | Often significant |
| Scalability | Scales with CI runners | Limited by CPU cores |
| Output | PHPUnit XML configuration | Direct test execution |
A common challenge with Paratest is that integration tests often need to be adjusted to be compatible with parallel execution. Tests that share resources (databases, files, caches, external services) can interfere with each other when running simultaneously, causing:
- Race conditions - Tests may randomly fail due to timing issues, and these bugs are notoriously hard to debug
- Shared state conflicts - Database records, file locks, or cache entries created by one test may affect another
- Complex test refactoring - Adapting an existing codebase with many integration tests can be very time-consuming
PHPUnit Parallel Job Balancer avoids these issues entirely because each CI job runs in complete isolation (separate container/machine). Your tests don't need any modifications - they run exactly as they would in a sequential PHPUnit execution, just distributed across multiple runners.
- PHP 8.1 or higher
- ext-dom
- ext-simplexml
- Check your code by
composer check - Autofix coding-style by
composer fix:cs - All functionality must be tested
MIT License - see LICENSE file for details.