From 42553740e4c73baa0caa4f5e9182718e4c8024e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcel=20M=C3=BCller?= Date: Sun, 16 Aug 2026 16:11:27 +0200 Subject: [PATCH] chore: Add junit-analyzer to check the phpunit performance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assisted-by: ClaudeCode:claude-opus-5 Signed-off-by: Marcel Müller --- .github/workflows/phpunit-sqlite.yml | 10 ++ tests/junit-analyzer.php | 139 +++++++++++++++++++++++++++ 2 files changed, 149 insertions(+) create mode 100644 tests/junit-analyzer.php diff --git a/.github/workflows/phpunit-sqlite.yml b/.github/workflows/phpunit-sqlite.yml index c7572e91ae5..c623cce3fb0 100644 --- a/.github/workflows/phpunit-sqlite.yml +++ b/.github/workflows/phpunit-sqlite.yml @@ -125,6 +125,16 @@ jobs: - name: PHPUnit database tests run: composer run test:db -- --log-junit junit.xml + - name: Slowest tests + if: always() + continue-on-error: true + run: | + { + echo '```' + php tests/junit-analyzer.php junit.xml 30 + echo '```' + } | tee -a "${GITHUB_STEP_SUMMARY:-/dev/null}" + - name: Print logs if: always() run: | diff --git a/tests/junit-analyzer.php b/tests/junit-analyzer.php new file mode 100644 index 00000000000..0580d5e62e8 --- /dev/null +++ b/tests/junit-analyzer.php @@ -0,0 +1,139 @@ +open($file)) { + fwrite(STDERR, "cannot open $file\n"); + exit(1); +} + +/** @var list $tests in execution order */ +$tests = []; +while ($reader->read()) { + if ($reader->nodeType !== XMLReader::ELEMENT || $reader->name !== 'testcase') { + continue; + } + $tests[] = [ + 'class' => $reader->getAttribute('class') ?: '(none)', + 'name' => (string)$reader->getAttribute('name'), + 'duration' => (float)$reader->getAttribute('time'), + ]; +} +$reader->close(); + +$testCount = count($tests); +if ($testCount === 0) { + $error = libxml_get_errors()[0] ?? null; + fwrite(STDERR, "no testcase elements found in $file" + . ($error !== null ? ': ' . trim($error->message) : '') . "\n"); + exit(1); +} + +$totalDuration = array_sum(array_column($tests, 'duration')); +printf("%d tests, %.1fs total (%.1f min)\n\n", $testCount, $totalDuration, $totalDuration / 60); + +if ($totalDuration <= 0) { + fwrite(STDERR, "no timing data to rank\n"); + exit(0); +} + +printf("Execution order, %d buckets (are later tests slower?)\n", BUCKETS); +echo " bucket tests sum(s) mean(ms) median(ms) max(s) cum%\n"; + +$durationSoFar = 0.0; +for ($bucket = 0; $bucket < BUCKETS; $bucket++) { + $from = (int)floor($testCount * $bucket / BUCKETS); + $to = (int)floor($testCount * ($bucket + 1) / BUCKETS); + + $durations = array_column(array_slice($tests, $from, $to - $from), 'duration'); + sort($durations); + $inBucket = count($durations); + $bucketDuration = array_sum($durations); + $durationSoFar += $bucketDuration; + + printf( + " %3d-%3d%% %7d %9.1f %10.2f %12.2f %9.2f %5.1f%%\n", + $bucket * (100 / BUCKETS), + ($bucket + 1) * (100 / BUCKETS), + $inBucket, + $bucketDuration, + $inBucket ? $bucketDuration / $inBucket * 1000 : 0, + $inBucket ? $durations[intdiv($inBucket, 2)] * 1000 : 0, + $inBucket ? max($durations) : 0, + $durationSoFar / $totalDuration * 100, + ); +} + +$slowestFirst = $tests; +usort($slowestFirst, static fn (array $a, array $b): int => $b['duration'] <=> $a['duration']); + +printf("\nTop %d slowest tests\n", $topCount); +foreach (array_slice($slowestFirst, 0, $topCount) as $test) { + printf(" %8.2fs %s::%s\n", $test['duration'], $test['class'], $test['name']); +} + +/** @var array $classes */ +$classes = []; +foreach ($tests as $test) { + $classes[$test['class']] ??= ['duration' => 0.0, 'tests' => 0]; + $classes[$test['class']]['duration'] += $test['duration']; + $classes[$test['class']]['tests']++; +} +uasort($classes, static fn (array $a, array $b): int => $b['duration'] <=> $a['duration']); + +printf("\nTop %d slowest classes (sum of its tests)\n", $topCount); +printf(" %9s %7s %10s %s\n", 'sum', 'tests', 'mean(ms)', 'class'); +foreach (array_slice($classes, 0, $topCount, true) as $class => $stats) { + printf( + " %8.2fs %7d %10.2f %s\n", + $stats['duration'], + $stats['tests'], + $stats['duration'] / $stats['tests'] * 1000, + $class, + ); +} + +// How top-heavy is the run? A handful of tests dominating reads very differently +// from the cost being spread evenly. +$durationSoFar = 0.0; +$testsInHalfTheRuntime = 0; +foreach ($slowestFirst as $test) { + $durationSoFar += $test['duration']; + $testsInHalfTheRuntime++; + if ($durationSoFar >= $totalDuration / 2) { + break; + } +} +printf( + "\nThe slowest %d tests (%.1f%% of tests) account for 50%% of the runtime.\n", + $testsInHalfTheRuntime, + $testsInHalfTheRuntime / $testCount * 100, +);