shipmonk / phpunit-parallel-job-balancer
Balances PHPUnit test execution across parallel jobs based on JUnit XML timing data
Installs: 13
Dependents: 0
Suggesters: 0
Security: 0
Stars: 5
Watchers: 0
Forks: 0
Open Issues: 0
pkg:composer/shipmonk/phpunit-parallel-job-balancer
Requires
- php: ^8.1
- ext-dom: *
- ext-simplexml: *
- symfony/console: ^6.0 || ^7.0 || ^8.0
Requires (Dev)
- editorconfig-checker/editorconfig-checker: ^10.6.0
- ergebnis/composer-normalize: ^2.44.0
- phpstan/phpstan: ^2.1.0
- phpstan/phpstan-phpunit: ^2.0.0
- phpstan/phpstan-strict-rules: ^2.0.0
- phpunit/phpunit: ^10.5.0 || ^11.0.0 || ^12.0.0
- shipmonk/coding-standard: ^0.2.0
- shipmonk/composer-dependency-analyser: ^1.8.0
- shipmonk/name-collision-detector: ^2.1.0
- shipmonk/phpstan-rules: ^4.0.0
This package is auto-updated.
Last update: 2025-12-04 10:59:21 UTC
README
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.
Installation
composer require --dev shipmonk/phpunit-parallel-job-balancer
Usage
Basic Usage
vendor/bin/phpunit --log-junit junit.xml vendor/bin/balance-phpunit-jobs junit.xml
This outputs PHPUnit testsuite XML fragments to stdout. Copy them into your phpunit.xml to configure parallel test runs.
Options
| 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 | - |
Examples
# 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
Example Output
<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>
How It Works
-
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
Workflow
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:
Step 1: Configure CI to generate JUnit reports
GitLab CI
test: parallel: 4 script: - vendor/bin/phpunit --testsuite "part${CI_NODE_INDEX}" --log-junit junit.xml artifacts: paths: - junit.xml reports: junit: junit.xml
GitHub Actions
jobs: 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.xml
Step 2: Download JUnit reports and run the balancer
After 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/*.xml
Step 3: Update phpunit.xml
Copy 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>
Step 4: Commit and push
Commit the updated phpunit.xml to your repository. Future CI runs will use the balanced test distribution.
Re-balancing
Re-run this process periodically (e.g., when test execution times drift significantly) to keep the distribution optimal.
Comparison with Paratest
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 |
Why PHPUnit Parallel Job Balancer is easier to adopt
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.
Requirements
- PHP 8.1 or higher
- ext-dom
- ext-simplexml
Contributing
- Check your code by
composer check - Autofix coding-style by
composer fix:cs - All functionality must be tested
License
MIT License - see LICENSE file for details.