ancient-web / phpunit-test-time
PHPUnit extension that measures per-test execution time and writes a report sorted by duration
Requires
- php: ^8.1
- ext-mbstring: *
- phpunit/phpunit: ^10.0 || ^11.0 || ^12.0 || ^13.0
Requires (Dev)
- phpstan/phpstan: ^2.1
- phpstan/phpstan-deprecation-rules: ^2.0
- phpstan/phpstan-phpunit: ^2.0
- phpstan/phpstan-strict-rules: ^2.0
- rector/rector: ^2.0
- symplify/easy-coding-standard: ^13.3
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
A PHPUnit extension that measures the execution time of each test and, at the end of the run, reports the slowest ones. The report is printed to the console by default; a file log and/or a CSV report can be enabled as well.
Parallel runs via paratest are supported: each worker writes its own intermediate log, then all
logs are merged into a single report under an exclusive lock (for the same test the maximum
duration is kept). In parallel runs only the file reports are produced — the console report is
skipped, because every worker is a separate process.
Compatibility
- PHP
^8.1 phpunit/phpunit^10.0 || ^11.0 || ^12.0 || ^13.0
The PHPUnit version is chosen by the PHP version you run: 8.1 installs PHPUnit 10, 8.2 → 11,
8.3 → 12, and 8.4+ → 13. On PHPUnit 10 and 11 the PreparationErrored subscriber is not
registered, because that event was introduced in PHPUnit 12.
Installation
composer require --dev ancient-web/phpunit-test-time
Registration
Register the extension in phpunit.xml.dist:
<extensions> <bootstrap class="AncientWeb\PhpUnitTestTime\TestTimeExtension" /> </extensions>
Configuration
All settings are <parameter> elements. Durations are in milliseconds; 0 means “no limit”.
| Parameter | Type | Default | Description |
|---|---|---|---|
console |
bool | true |
print the report to the console |
console-minimum-duration |
int | 500 |
console shows only tests at or above this duration |
console-count |
int | 10 |
maximum number of tests in the console report |
console-maximum-width |
int / max |
0 |
truncate console lines to this width (0 = no truncation, max = detected terminal width) |
log |
bool | false |
write the file log |
log-file |
string | var/test-time.log |
path to the file log |
log-minimum-duration |
int | 0 |
file log threshold (by default everything is written) |
log-count |
int | 0 |
maximum number of tests in the file log |
csv |
bool | false |
write the CSV report |
csv-file |
string | var/test-time.csv |
path to the CSV report |
csv-minimum-duration |
int | 0 |
CSV report threshold (by default everything is written) |
csv-count |
int | 0 |
maximum number of tests in the CSV report |
csv-separator |
string | ; |
CSV field separator: comma, semicolon, colon, tab or pipe |
For example, to report the ten slowest tests on the console and write everything to a file log:
<extensions> <bootstrap class="AncientWeb\PhpUnitTestTime\TestTimeExtension"> <parameter name="log" value="true" /> <parameter name="log-file" value="/tmp/test-time.log" /> </bootstrap> </extensions>
The console report respects PHPUnit's --no-output flag and the stderr configuration.
Per-test maximum duration
A single test method can override the minimum duration with the MaximumDuration
attribute, or with the @maximumDuration / @slowThreshold doc-block annotations (the
latter helps migrating from johnkary/phpunit-speedtrap):
use AncientWeb\PhpUnitTestTime\Attribute\MaximumDuration; final class ExtraSlowTest extends TestCase { #[MaximumDuration(2000)] public function testAllowedToBeSlow(): void { } /** * @maximumDuration 1500 */ public function testAlsoAllowedToBeSlow(): void { } }
The override applies wherever a minimum duration is configured (the console and/or the
file log); an output configured to show everything (0) is not affected.
Paratest
Parallel runs with paratest are supported. Because
every worker is a separate PHP process, the console report is skipped: the file log and/or the CSV
report are the only outputs and are merged from all workers under an exclusive lock (for a test
that ran in several workers the maximum duration is kept).
The worker token is read from TEST_TOKEN (falling back to UNIQUE_TEST_TOKEN, then the process
id when PARATEST is set). Point log-file and/or csv-file at a shared path:
<extensions> <bootstrap class="AncientWeb\PhpUnitTestTime\TestTimeExtension"> <parameter name="console" value="false" /> <parameter name="log" value="true" /> <parameter name="log-file" value="/tmp/test-time.log" /> <parameter name="log-minimum-duration" value="500" /> </bootstrap> </extensions>
Each worker writes <base>.<token>.json, the workers merge every worker log plus the
<base>.json accumulator, render the human report (<base>.log and/or <base>.csv), and remove
the worker logs. The base strips the report extension, so a log and a CSV report that share a name
stem (as the defaults do) also share the merged accumulator. A per-test #[MaximumDuration]
resolved by one worker is carried through the merge, so it still filters the merged report.
Troubleshooting
- No console report. The console only shows tests at or above
console-minimum-duration(default500ms); lower it to0to see everything. Under paratest the console report is intentionally skipped, and PHPUnit's--no-outputdisables it too. - No file log. Check that
logis notfalseand thatlog-fileis writable (the default isgetcwd()/var/test-time.log; its directory is created automatically). - No CSV report. Check that
csvis notfalseand thatcsv-fileis writable (the default isgetcwd()/var/test-time.csv; its directory is created automatically).csv-separatormust be one of,,;,|,:, or a tab. - A test is missing. Its duration is below the output's minimum, it was cut off by
console-count/log-count/csv-count, or a per-testMaximumDurationraised its own threshold. A per-test override only applies to outputs whose minimum duration is non-zero. - Invalid parameter.
InvalidParameteris thrown for a non-booleanconsole/log/csv, a non-negative-integer duration/count, an invalidcsv-separator, or aconsole-maximum-widththat is neither a non-negative integer normax.
Report format
Test execution time report (2026-01-01 12:00:00)
Total tests: 3, total time: 4.5000 s
1. 2.5000 s App\Tests\SlowTest::testSomething
2. 1.5000 s App\Tests\MediumTest::testSomething
3. 0.5000 s App\Tests\FastTest::testSomething
The CSV report has a header and one row per test, sorted by duration descending. Durations are plain seconds, so the file stays machine-readable:
Test case;Execution time (s)
App\Tests\SlowTest::testSomething;2.5000
App\Tests\MediumTest::testSomething;1.5000
App\Tests\FastTest::testSomething;0.5000
Comparison with other extensions
ergebnis/phpunit-slow-test-detector
is the main inspiration for this package, and
johnkary/phpunit-speedtrap is the extension it
largely replaced. All three report slow tests; the differences are summarized below (as of
October 2026, based on each project's documentation).
| phpunit-test-time | ergebnis/phpunit-slow-test-detector | johnkary/phpunit-speedtrap | |
|---|---|---|---|
| Console report | yes (on by default) | yes | yes |
| File report | yes (log + CSV, opt-in) | no | no |
| Threshold per output | yes (console and file separately) | no (one global) | no (one global) |
| Slow-test limit | yes (10) | yes (10) | yes (10) |
| Console width truncation | yes (int / max) |
yes (int / max) |
no |
| Per-test threshold | attribute and annotations | attribute¹ and annotations | @slowThreshold |
| Paratest: merged report | yes | no² | no² |
| Configuration | phpunit.xml parameters |
phpunit.xml parameters |
<arguments> |
| Supported PHPUnit | 10–13 | 6.5–13 | legacy (≤ 9) |
| Supported PHP | ^8.1 |
7.4–8.5 | not stated |
| GitHub Actions annotations | no | yes | no |
¹ On PHPUnit 10 and later, where the MaximumDuration attribute exists; older versions use the
@maximumDuration / @slowThreshold annotations.
² Neither extension merges reports across paratest workers: each worker prints its own console report.
In short, this package adds machine-readable file logs (a human-readable log and a CSV report) that
are merged across paratest workers and preserve per-test thresholds through the merge, while
ergebnis/phpunit-slow-test-detector
focuses on a single console table and can emit GitHub Actions annotations. Both accept the
@slowThreshold annotation for a smooth migration from johnkary/phpunit-speedtrap.
Development
All commands run inside the container (no local PHP required):
docker compose build # build the image (works offline) docker compose run --rm tests # install dependencies and run the test suite docker compose run --rm tests composer it # install dependencies and run the full pipeline docker compose run --rm tests composer coverage # coverage (needs a pcov/xdebug driver) docker compose run --rm tests composer audit # dependency advisories (needs network access) docker compose run --rm tests composer ci # it + coverage + audit docker compose run --rm tests composer phar # build a self-contained PHAR
The quality pipeline runs coding standards (ECS), static analysis
(PHPStan, level max), automated refactoring (Rector), and the test suite. composer it
is offline-friendly; composer coverage needs a coverage driver (none is bundled), and
composer audit/composer ci need network access. See
CONTRIBUTING.md for details.
Testing across PHP versions
The image is built from php:${PHP_VERSION}-cli (default 8.5). Override the version with the
PHP_VERSION build argument:
PHP_VERSION=8.1 docker compose build
docker compose run --rm --entrypoint php tests -v # PHP 8.1.34
composer.lock is gitignored, so each PHP version resolves its own dependencies (PHPUnit 10 on
8.1, 11 on 8.2, 12 on 8.3, 13 on 8.4+). Run the full pipeline on the default (newest) image,
and the test suite on every supported version:
# Full quality pipeline: coding standards + PHPStan + Rector + tests docker compose run --rm tests composer it # Compatibility matrix: the test suite on each supported PHP version (needs network) for version in 8.1 8.2 8.3 8.4 8.5; do echo "== PHP ${version} ==" PHP_VERSION="${version}" docker compose build docker compose run --rm --entrypoint sh tests -c 'composer update --no-interaction && composer test' done PHP_VERSION=8.5 docker compose build # restore the default image
The --entrypoint override re-resolves dependencies for the selected version instead of reusing
a composer.lock that was written for another one. Static analysis needs the PHPUnit classes of
the newest version (for example PreparationErrored only exists from PHPUnit 12), so it runs on
the default image; older versions are covered by the test suite.
composer phar builds build/phpunit-test-time.phar, a self-contained bundle whose stub
registers an autoloader for the AncientWeb\PhpUnitTestTime\ namespace. require it from your
PHPUnit bootstrap if you cannot use Composer autoloading for this package:
require __DIR__.'/phpunit-test-time.phar';
Credits
This project was inspired by
ergebnis/phpunit-slow-test-detector.