-
-
Notifications
You must be signed in to change notification settings - Fork 5.1k
chore: Improve phpunit performance #63300
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
b07428d
test: Fix overwriting and restoring config values
SystemKeeper f958895
test: Disable appstore explicitly in AppsEnableTest
SystemKeeper 60760a2
test: Don't forcefully enable appstore in InstallerTest
SystemKeeper 4255374
chore: Add junit-analyzer to check the phpunit performance
SystemKeeper 05f7a38
chore: Update phpunit to 11.5.56
SystemKeeper 027a89c
chore: Don't build sourcemaps on deprecation warning in phpunit
SystemKeeper 7b9bec1
chore: Reduce hashing and encryption complexity on test run
SystemKeeper 3182f18
fix: Cleanup user in ApiV1ControllerTest
SystemKeeper fd32555
fix: Use array_chunk in junit-analyzer.php
SystemKeeper c35a6f4
feat: Log outgoing http requests in tests
SystemKeeper File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,80 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
| /** | ||
| * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors | ||
| * SPDX-License-Identifier: AGPL-3.0-or-later | ||
| */ | ||
|
|
||
| /** | ||
| * Rank the outgoing HTTP requests recorded by Test\HttpRequestLogger. | ||
| * | ||
| * Usage: TEST_LOG_HTTP=http-requests.log phpunit ... | ||
| * php tests/http-analyzer.php [http-requests.log] [topN] | ||
| * | ||
| * Tests should not reach the network: anything listed needs either a mocked | ||
| * IClientService or a config value that prevents the request. | ||
| */ | ||
|
|
||
| $file = $argv[1] ?? 'http-requests.log'; | ||
| $topCount = (int)($argv[2] ?? 20); | ||
|
|
||
| if (!is_readable($file)) { | ||
| fwrite(STDERR, "cannot read $file\n"); | ||
| exit(1); | ||
| } | ||
|
|
||
| /** @var list<array{test: string, method: string, uri: string, outcome: string, duration: float}> $requests */ | ||
| $requests = []; | ||
| foreach (file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) { | ||
| $request = json_decode($line, true); | ||
| if (is_array($request)) { | ||
| $requests[] = $request; | ||
| } | ||
| } | ||
|
|
||
| if ($requests === []) { | ||
| echo "No outgoing HTTP requests were recorded.\n"; | ||
| exit(0); | ||
| } | ||
|
|
||
| $totalDuration = array_sum(array_column($requests, 'duration')); | ||
| printf("%d requests, %.1fs total\n", count($requests), $totalDuration); | ||
|
|
||
| /** @param callable(array): string $key */ | ||
| function group(array $requests, callable $key): array { | ||
| $groups = []; | ||
| foreach ($requests as $request) { | ||
| $name = $key($request); | ||
| $groups[$name] ??= ['duration' => 0.0, 'requests' => 0]; | ||
| $groups[$name]['duration'] += $request['duration']; | ||
| $groups[$name]['requests']++; | ||
| } | ||
| uasort($groups, static fn (array $a, array $b): int => $b['duration'] <=> $a['duration']); | ||
| return $groups; | ||
| } | ||
|
|
||
| foreach ([ | ||
| 'host' => static fn (array $r): string => parse_url($r['uri'], PHP_URL_HOST) ?: '(unparsed)', | ||
| 'test' => static fn (array $r): string => $r['test'], | ||
| ] as $label => $key) { | ||
| $groups = group($requests, $key); | ||
| printf("\nRequests by %s\n", $label); | ||
| printf(" %9s %9s %s\n", 'sum', 'requests', $label); | ||
| foreach (array_slice($groups, 0, $topCount, true) as $name => $stats) { | ||
| printf(" %8.2fs %9d %s\n", $stats['duration'], $stats['requests'], $name); | ||
| } | ||
| } | ||
|
|
||
| usort($requests, static fn (array $a, array $b): int => $b['duration'] <=> $a['duration']); | ||
| printf("\nTop %d slowest requests\n", $topCount); | ||
| foreach (array_slice($requests, 0, $topCount) as $request) { | ||
| printf( | ||
| " %8.2fs %-6s %-4s %s\n %s\n", | ||
| $request['duration'], | ||
| $request['method'], | ||
| $request['outcome'], | ||
| $request['uri'], | ||
| $request['test'], | ||
| ); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,139 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
| /** | ||
| * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors | ||
| * SPDX-License-Identifier: AGPL-3.0-or-later | ||
| */ | ||
|
|
||
| /** | ||
| * Analyse a PHPUnit JUnit log: slowest tests, slowest classes, and whether the | ||
| * suite degrades over execution order. | ||
| * | ||
| * Usage: php tests/junit-analyzer.php [junit.xml] [topN] | ||
| * | ||
| * The bucket table splits the run into equal chunks of execution order. A rising | ||
| * median means the suite itself degrades (accumulated DB rows, leaked memory); | ||
| * a flat median with rising sum/max means a few slow tests happen to run late. | ||
| */ | ||
|
|
||
| const BUCKETS = 10; | ||
|
|
||
| $file = $argv[1] ?? 'junit.xml'; | ||
| $topCount = (int)($argv[2] ?? 30); | ||
|
|
||
| if (!is_readable($file)) { | ||
| fwrite(STDERR, "cannot read $file\n"); | ||
| exit(1); | ||
| } | ||
|
|
||
| libxml_use_internal_errors(true); | ||
|
|
||
| $reader = new XMLReader(); | ||
| if (!$reader->open($file)) { | ||
| fwrite(STDERR, "cannot open $file\n"); | ||
| exit(1); | ||
| } | ||
|
|
||
| /** @var list<array{class: string, name: string, duration: float}> $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); | ||
| } | ||
|
|
||
| $chunks = array_chunk($tests, (int)ceil($testCount / BUCKETS)); | ||
| $buckets = count($chunks); | ||
|
|
||
| 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; | ||
| foreach ($chunks as $bucket => $chunk) { | ||
| $durations = array_column($chunk, '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, | ||
| $bucketDuration / $inBucket * 1000, | ||
| $durations[intdiv($inBucket, 2)] * 1000, | ||
| max($durations), | ||
| $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<string, array{duration: float, tests: int}> $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, | ||
| ); | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.