Skip to content

shipmonk-rnd/phpunit-parallel-job-balancer

Repository files navigation

PHPUnit Parallel Job Balancer

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

  1. Parse JUnit XML reports - Extracts test file paths and their execution times from JUnit XML format (generated by PHPUnit with --log-junit option)

  2. Build timing tree - Creates a hierarchical tree of test paths where each node accumulates the total execution time of all its children

  3. 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
  4. 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.

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Packages

No packages published

Languages