TL;DR: Use ergebnis/phpunit-slow-test-detector to surface your slow PHPUnit tests, then use that as a metric for parallelizing or splitting your test jobs.
The “which tests are slowing down CI?” problem
Your CI tests are slow. They keep getting slower, and waiting after every push gets old fast.
But when you actually try to speed things up, you can see that “the whole thing takes 5 minutes” — yet “okay, but which tests are dragging it down?” doesn’t jump out at you. You might have a vague hunch that “the tests touching the DB are probably the suspects,” but you haven’t actually measured anything.
Throwing parallel execution (like paratest) at the problem blindly won’t help much either: if you don’t know where the slow tests are concentrated, you can’t decide how to split the jobs. The first step is to put numbers on your slow tests.
That’s where ergebnis/phpunit-slow-test-detector comes in.
What is phpunit-slow-test-detector?
It’s a PHPUnit extension that detects “slow tests” exceeding a configured threshold during a test run, and reports them as a list once the run finishes. It’s inspired by johnkary/phpunit-speedtrap and is licensed under MIT.
The highlights, roughly:
- Threshold-based detection - picks up tests based on a “slower than N milliseconds” criterion
- Ranked list of slow tests - shows a top-N list ordered from slowest
- Per-test threshold override - lets you individually allow cases where “this test is expected to be heavy”
- Wide version support - covers PHP 7.0–8.5 and PHPUnit 6.5–13.0
Keeping it in your CI is handy because you get a heads-up right when your tests start getting slower.
Installation and configuration
Install it as a dev dependency.
composer require --dev ergebnis/phpunit-slow-test-detector
On PHPUnit 10–13, register it as a bootstrap extension in phpunit.xml.
<phpunit
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
>
<extensions>
<bootstrap class="Ergebnis\PHPUnit\SlowTestDetector\Extension">
<parameter name="maximum-count" value="3"/>
<parameter name="maximum-duration" value="250"/>
</bootstrap>
</extensions>
<testsuites>
<testsuite name="unit">
<directory>test/Unit/</directory>
</testsuite>
</testsuites>
</phpunit>
The parameters are simple.
maximum-duration- the threshold (in milliseconds) at which a test is considered “slow”. Default is500maximum-count- the maximum number of slow tests to report. Default is10
I’d recommend starting maximum-duration around the default 500 (0.5s) and lowering it to match the reality of your own project.
※ On PHPUnit 9 and earlier, you register it with an <extension> element plus <arguments>. See the official README for the per-version syntax.
Reading the results
When you run your tests, a list of slow tests is printed at the end.
Detected 11 tests where the duration exceeded the global maximum duration (0.500).
# Duration Test
------------------------------------------------------------------------------------
1 1.604 Ergebnis\PHPUnit\SlowTestDetector\Test\EndToEnd\Default\SleeperTest
::testSleeperSleepsLongerThanDefaultMaximumDurationWithDataProvider#9
2 1.505 Ergebnis\PHPUnit\SlowTestDetector\Test\EndToEnd\Default\SleeperTest
::testSleeperSleepsLongerThanDefaultMaximumDurationWithDataProvider#8
...
------------------------------------------------------------------------------------
It’s straightforward to read: each row lists the rank, the duration (in seconds), and the test’s class name::method name. The leading Detected 11 tests ... also tells you that 11 tests exceeded the threshold (0.500s in this example).
Now the “identity of your slow tests” is visible at a glance. From here you can knock them out from the top of the ranking, or feed the list into how you split your CI jobs.
Overriding the threshold per test
That said, sometimes “this test hits an external API, so of course it’s slow.” Having tests like that show up in the ranking every time just becomes noise.
On PHPUnit 10–13, you can override the threshold per test with an attribute.
use Ergebnis\PHPUnit\SlowTestDetector;
#[SlowTestDetector\Attribute\MaximumDuration(5000)]
public function testExtraExtraSlow(): void
{
// Allow this test up to 5000 milliseconds (5 seconds)
// ...
}
With this, the test won’t appear in the ranking unless it exceeds 5 seconds. By intentionally declaring the “acceptable slowness,” only the genuinely problematic slowness floats to the top.
Using it as a metric for CI efficiency
This is the main point. Once your slow tests are visible, your options for speeding up CI become concrete.
1. Separate slow tests into a dedicated suite
Move the slow tests at the top of the ranking (mostly those involving DB access or external communication) into an Integration or EndToEnd suite, and keep them apart from the fast Unit suite.
<testsuites>
<testsuite name="unit">
<directory>test/Unit/</directory>
</testsuite>
<testsuite name="integration">
<directory>test/Integration/</directory>
</testsuite>
</testsuites>
In CI, you can run the fast unit suite first for immediate feedback, and run the slow integration suite in a separate job in parallel. The list of detected slow tests works directly as the basis for deciding which test goes where.
2. Use it as a metric for splitting parallel jobs
paratest hands out tests to idle processes one test class at a time, so it decides which process runs what on its own. But if your slow tests are concentrated in a single class, the process that picks up that class keeps running long after the others are done, and total runtime ends up determined by that “slowest process.” When the slow test list points you to a class like that, try splitting the class, or use the --functional option to distribute tests per method instead.
The same goes for splitting CI itself into multiple jobs: once you know where the slow tests are, you can distribute them so that each job’s runtime is balanced.
3. Keep the threshold in CI as an “acceptable line” to catch regressions
Set maximum-duration to a line your project can tolerate, and leave it running in CI permanently. When a newly added test, or a test that quietly got heavier, exceeds the threshold, it shows up in the list — so it works as a sensor for slowness regressions in your tests.
Building it into CI not just to “make things faster” but as a mechanism to “notice when things got slower” makes it easier to keep your test suite healthy.
You can do it with JUnit logs too (but it’s a hassle)
By the way, if you’d rather not add yet another Composer dependency, you can find slow tests without a dedicated extension. PHPUnit can emit a JUnit-format log, so you can just parse its time attributes.
# Emit test results in JUnit format
vendor/bin/phpunit --log-junit junit.xml
Each <testcase> in the resulting junit.xml carries its duration (in seconds) in the time attribute, so sorting them in descending order gives you a slowest-first ranking.
// Parse junit.xml and print the 10 slowest tests
$xml = simplexml_load_file('junit.xml');
$durations = [];
foreach ($xml->xpath('//testcase') as $case) {
$key = (string) $case['classname'] . '::' . (string) $case['name'];
$durations[$key] = (float) $case['time'];
}
arsort($durations);
foreach (array_slice($durations, 0, 10, true) as $name => $time) {
printf("%6.3f %s\n", $time, $name);
}
It works, sure — but once you start handling threshold filtering, output formatting, and per-test “this one is heavy on purpose” exceptions all by yourself, it gets tedious fast. phpunit-slow-test-detector takes care of all that out of the box, so if you just want it done quickly, installing the extension is the faster route.