ergebnis/phpunit-slow-test-detector
A PHPUnit extension (Composer package and Phar) that detects slow tests and reports those exceeding a configurable maximum duration after your test run.
Install the extension as a dev dependency and register it in phpunit.xml. There is nothing to write in your test code — the detector hooks into the runner.
composer require --dev ergebnis/phpunit-slow-test-detector
For PHPUnit 10–13 (bootstrap element, PHPUnit autoloads the class via Composer):
<phpunit bootstrap="vendor/autoload.php">
<extensions>
<bootstrap class="Ergebnis\PHPUnit\SlowTestDetector\Extension"/>
</extensions>
</phpunit>
For PHPUnit 7.5–9 use <extensions><extension class="..."/></extensions>; on PHPUnit 6.5 use <listeners><listener class="..."/></listeners>. If you install the PHAR instead of the Composer package, also set extensionsDirectory on the <phpunit> element.
Then just run the suite:
vendor/bin/phpunit
If any test exceeds the default 500 ms threshold, a table of the slowest tests is printed after the summary — sorted by duration descending, capped at 10 rows, with a "There is N additional slow test that is not listed here." footer when truncated. Data-provider rows appear with a #index suffix, which is often the fastest way to spot that only one dataset is slow.
As of 2.27.0 the extension also emits GitHub Actions annotations for slow tests when the suite runs inside GitHub Actions. You get the same information inline on the PR's checks page, next to the failing-file view, with no configuration and no log scraping. Outside GitHub Actions the console table is unchanged.
Where to look first:
Configuration/Extension/Printer classes under src/ — they show exactly which XML parameters are read, how they are cast, and how the report is rendered.maximum-duration="1" to confirm the extension is actually wired up (everything will be reported), then restore the real threshold.The killer first use case: add it to an existing legacy suite, run once, and you immediately get a ranked list of the 10 tests that dominate CI wall-clock time — usually a handful of tests with sleep(), real HTTP calls, or un-batched database fixtures.
Tune the threshold per suite, not globally. Keep a fast phpunit.xml.dist for unit tests with a low bar (maximum-duration="100") so regressions are caught early, and a separate config for integration/e2e suites with a realistic budget (maximum-duration="2000"). A single shared threshold is either noisy on the fast suite or blind on the slow one.
<extensions>
<bootstrap class="Ergebnis\PHPUnit\SlowTestDetector\Extension">
<parameter name="maximum-duration" value="100"/>
<parameter name="maximum-count" value="25"/>
</bootstrap>
</extensions>
Raise maximum-count in CI, keep it low locally. Ten rows is fine for a developer's terminal; in CI you want the full picture, so set maximum-count to 50 and diff the list between the base branch and the PR branch.
Let GitHub Actions do the surfacing. On a GitHub runner the extension now writes annotations directly, so you no longer need to tee the log and grep it just to make slow tests visible in a PR. A slow test shows up as an annotation on the workflow run, which is exactly where a reviewer is already looking. Keep the table in mind for local work and let annotations do the CI job.
Treat it as a reporting layer, not a gate. Neither the table nor the annotations fail the run — the exit code of an "OK" run stays 0. If you need enforcement, act on the output yourself, or better, on machine-readable data:
vendor/bin/phpunit --log-junit build/junit.xml
# then read the per-test `time` attributes and compare against a threshold
Parsing the human-readable table still works, but it is more fragile than JUnit XML: 2.27.0 changed the report's formatting (column wrapping, a trailing line break), so a regex tuned against the old output can silently stop matching. Prefer --log-junit for any automated check.
Use it as a refactoring loop. Before touching a slow test, run only that suite/config and note the duration; after converting an HTTP call to a fake or a sleep() to a clock abstraction, re-run and confirm the row disappears from the table and from the annotations. Because the report is sorted descending, the top row is your highest-leverage fix.
Watch the data-provider indices. A row like SomeTest::testX with data set #7 at 1.4 s while #0–#6 are all fast almost always means that dataset hits the network, the filesystem, or an unindexed query. That is a much more precise signal than a bare "test is slow" warning.
Keep the report off stdout if you parse it. On PHPUnit 10+ the extension automatically respects PHPUnit's own stderr configuration attribute, so <phpunit stderr="true"> routes the table to stderr and keeps stdout clean for machine-readable output. On PHPUnit 6.5–9 you must set the extension's stderr parameter explicitly.
Compare across branches, not across machines. Durations are wall-clock and vary with hardware, container CPU quotas, and cold caches. Use the table as a relative ranking ("this test is 10× the median") and leave generous headroom in absolute thresholds; don't chase a 480 ms test against a 500 ms bar on shared CI runners.
Custom reporting. 2.27.0 extracted a Printer abstraction and routes the console report through it, alongside the existing Extension/Facade event subscription. If you want durations in Datadog or in a PR comment of your own, write a small extension subscribing to the same test-finished events, or adapt the printer if it is part of the public surface — both are more robust than regexing console output that changes shape between releases. Check the class for an @internal marker before depending on it directly.
maximum-duration is an integer number of milliseconds, not seconds and not a float. 0.5 will not do what you mean; 500 is half a second.--stop-on-failure, a fatal error, or a crash in a bootstrap, you may never see the table (or the annotations), because the report is emitted at the end of the run.<bootstrap class> (10–13) vs <extension class> (7.5–9) vs <listener class> (6.5) vs PHAR setups that additionally need extensionsDirectory. Copying a snippet from the wrong PHPUnit version produces a silently ignored extension or an XML validation error against phpunit.xsd.<phpunit> attributes and not as <php> settings. Putting <parameter> in the wrong place is the single most common configuration error.stderr parameter is ignored on PHPUnit 10+. Set PHPUnit's own stderr attribute instead; the extension reads it from the runner configuration.--log-junit.maximum-duration="1" and run one test. If you get no table, the extension is not registered (wrong class name, missing <extensions> block, or autoloader not loaded because bootstrap="vendor/autoload.php" is missing).maximum-count. Raise the count deliberately or filter with --filter when investigating one test.extensionsDirectory. If you mix Composer install and PHAR, you can end up with two copies of the extension registered and duplicate output.setUpBeforeClass/static warmup time is not what you're optimising for. The detector reports per-test durations; a suite that is slow overall because of a heavy bootstrap will show few or no slow tests. Profile at the process level for that case.--with-all-dependencies jumps that cross PHPUnit majors without re-checking the extension config syntax.How can I help you explore Laravel packages today?