Skip to content

Commit f255146

Browse files
authored
Merge pull request #63300 from nextcloud/chore/noid/improve-phpunit-performance
chore: Improve phpunit performance
2 parents 0417403 + c35a6f4 commit f255146

15 files changed

Lines changed: 610 additions & 133 deletions

File tree

.github/workflows/phpunit-sqlite.yml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,26 @@ jobs:
125125
- name: PHPUnit database tests
126126
run: composer run test:db -- --log-junit junit.xml
127127

128+
- name: Slowest tests
129+
if: always()
130+
continue-on-error: true
131+
run: |
132+
{
133+
echo '```'
134+
php tests/junit-analyzer.php junit.xml 30
135+
echo '```'
136+
} | tee -a "${GITHUB_STEP_SUMMARY:-/dev/null}"
137+
138+
- name: Outgoing HTTP requests
139+
if: always()
140+
continue-on-error: true
141+
run: |
142+
{
143+
echo '```'
144+
php tests/http-analyzer.php http-requests.log 20
145+
echo '```'
146+
} | tee -a "${GITHUB_STEP_SUMMARY:-/dev/null}"
147+
128148
- name: Print logs
129149
if: always()
130150
run: |

apps/settings/tests/UserMigration/AccountMigratorTest.php

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,7 @@
1313
use OCP\Accounts\IAccountManager;
1414
use OCP\AppFramework\App;
1515
use OCP\IAvatarManager;
16-
use OCP\IConfig;
1716
use OCP\IUserManager;
18-
use OCP\Server;
1917
use OCP\UserMigration\IExportDestination;
2018
use OCP\UserMigration\IImportSource;
2119
use PHPUnit\Framework\Constraint\JsonMatches;
@@ -46,7 +44,7 @@ protected function setUp(): void {
4644

4745
$app = new App(Application::APP_ID);
4846
$container = $app->getContainer();
49-
$container->get(IConfig::class)->setSystemValue('has_internet_connection', false);
47+
$this->overwriteSystemConfig('has_internet_connection', false);
5048

5149
$this->userManager = $container->get(IUserManager::class);
5250
$this->avatarManager = $container->get(IAvatarManager::class);
@@ -57,11 +55,6 @@ protected function setUp(): void {
5755
$this->output = $this->createMock(OutputInterface::class);
5856
}
5957

60-
protected function tearDown(): void {
61-
Server::get(IConfig::class)->setSystemValue('has_internet_connection', true);
62-
parent::tearDown();
63-
}
64-
6558
public static function dataImportExportAccount(): array {
6659
return array_map(
6760
static function (string $filename): array {

apps/sharing/tests/Controller/ApiV1ControllerTest.php

Lines changed: 20 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -39,23 +39,26 @@ public function testDefaultShareAccessContext(): void {
3939
$user = Server::get(IUserManager::class)->createUser('user', 'password');
4040
$this->assertNotFalse($user);
4141

42-
self::loginAsUser($user->getUID());
43-
44-
$controller = new ApiV1Controller(
45-
'',
46-
Server::get(IRequest::class),
47-
Server::get(IUserSession::class),
48-
Server::get(ISharingManager::class),
49-
$this->registry,
50-
Server::get(IFactory::class),
51-
Server::get(IURLGenerator::class),
52-
Server::get(IUserManager::class),
53-
Server::get(IDBConnection::class),
54-
);
55-
56-
$this->assertEquals(new ShareAccessContext($user), $controller->accessContext);
57-
58-
self::logout();
42+
try {
43+
self::loginAsUser($user->getUID());
44+
45+
$controller = new ApiV1Controller(
46+
'',
47+
Server::get(IRequest::class),
48+
Server::get(IUserSession::class),
49+
Server::get(ISharingManager::class),
50+
$this->registry,
51+
Server::get(IFactory::class),
52+
Server::get(IURLGenerator::class),
53+
Server::get(IUserManager::class),
54+
Server::get(IDBConnection::class),
55+
);
56+
57+
$this->assertEquals(new ShareAccessContext($user), $controller->accessContext);
58+
} finally {
59+
self::logout();
60+
$user->delete();
61+
}
5962
}
6063

6164
/**

tests/Core/Command/Apps/AppsEnableTest.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@ protected function setUp(): void {
3636

3737
$this->commandTester = new CommandTester($command);
3838

39+
// Not every setup disables the app store, and enabling an unknown app pulls
40+
// the whole catalogue (~30 MB, cached for an hour) to find it is not there.
41+
$this->overwriteSystemConfig('appstoreenabled', false);
42+
3943
Server::get(IAppManager::class)->disableApp('admin_audit');
4044
Server::get(IAppManager::class)->disableApp('comments');
4145
}

tests/bootstrap.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,12 @@
3535
}
3636
}
3737

38+
// Recorded on CI by default; set TEST_LOG_HTTP to a path for a manual run.
39+
$logHttp = getenv('TEST_LOG_HTTP') ?: (getenv('CI') ? OC::$SERVERROOT . '/http-requests.log' : '');
40+
if ($logHttp !== '') {
41+
\Test\HttpRequestLogger::install($logHttp);
42+
}
43+
3844
OC_Hook::clear();
3945

4046
set_include_path(

tests/http-analyzer.php

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
/**
5+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
6+
* SPDX-License-Identifier: AGPL-3.0-or-later
7+
*/
8+
9+
/**
10+
* Rank the outgoing HTTP requests recorded by Test\HttpRequestLogger.
11+
*
12+
* Usage: TEST_LOG_HTTP=http-requests.log phpunit ...
13+
* php tests/http-analyzer.php [http-requests.log] [topN]
14+
*
15+
* Tests should not reach the network: anything listed needs either a mocked
16+
* IClientService or a config value that prevents the request.
17+
*/
18+
19+
$file = $argv[1] ?? 'http-requests.log';
20+
$topCount = (int)($argv[2] ?? 20);
21+
22+
if (!is_readable($file)) {
23+
fwrite(STDERR, "cannot read $file\n");
24+
exit(1);
25+
}
26+
27+
/** @var list<array{test: string, method: string, uri: string, outcome: string, duration: float}> $requests */
28+
$requests = [];
29+
foreach (file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) {
30+
$request = json_decode($line, true);
31+
if (is_array($request)) {
32+
$requests[] = $request;
33+
}
34+
}
35+
36+
if ($requests === []) {
37+
echo "No outgoing HTTP requests were recorded.\n";
38+
exit(0);
39+
}
40+
41+
$totalDuration = array_sum(array_column($requests, 'duration'));
42+
printf("%d requests, %.1fs total\n", count($requests), $totalDuration);
43+
44+
/** @param callable(array): string $key */
45+
function group(array $requests, callable $key): array {
46+
$groups = [];
47+
foreach ($requests as $request) {
48+
$name = $key($request);
49+
$groups[$name] ??= ['duration' => 0.0, 'requests' => 0];
50+
$groups[$name]['duration'] += $request['duration'];
51+
$groups[$name]['requests']++;
52+
}
53+
uasort($groups, static fn (array $a, array $b): int => $b['duration'] <=> $a['duration']);
54+
return $groups;
55+
}
56+
57+
foreach ([
58+
'host' => static fn (array $r): string => parse_url($r['uri'], PHP_URL_HOST) ?: '(unparsed)',
59+
'test' => static fn (array $r): string => $r['test'],
60+
] as $label => $key) {
61+
$groups = group($requests, $key);
62+
printf("\nRequests by %s\n", $label);
63+
printf(" %9s %9s %s\n", 'sum', 'requests', $label);
64+
foreach (array_slice($groups, 0, $topCount, true) as $name => $stats) {
65+
printf(" %8.2fs %9d %s\n", $stats['duration'], $stats['requests'], $name);
66+
}
67+
}
68+
69+
usort($requests, static fn (array $a, array $b): int => $b['duration'] <=> $a['duration']);
70+
printf("\nTop %d slowest requests\n", $topCount);
71+
foreach (array_slice($requests, 0, $topCount) as $request) {
72+
printf(
73+
" %8.2fs %-6s %-4s %s\n %s\n",
74+
$request['duration'],
75+
$request['method'],
76+
$request['outcome'],
77+
$request['uri'],
78+
$request['test'],
79+
);
80+
}

tests/junit-analyzer.php

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
/**
5+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
6+
* SPDX-License-Identifier: AGPL-3.0-or-later
7+
*/
8+
9+
/**
10+
* Analyse a PHPUnit JUnit log: slowest tests, slowest classes, and whether the
11+
* suite degrades over execution order.
12+
*
13+
* Usage: php tests/junit-analyzer.php [junit.xml] [topN]
14+
*
15+
* The bucket table splits the run into equal chunks of execution order. A rising
16+
* median means the suite itself degrades (accumulated DB rows, leaked memory);
17+
* a flat median with rising sum/max means a few slow tests happen to run late.
18+
*/
19+
20+
const BUCKETS = 10;
21+
22+
$file = $argv[1] ?? 'junit.xml';
23+
$topCount = (int)($argv[2] ?? 30);
24+
25+
if (!is_readable($file)) {
26+
fwrite(STDERR, "cannot read $file\n");
27+
exit(1);
28+
}
29+
30+
libxml_use_internal_errors(true);
31+
32+
$reader = new XMLReader();
33+
if (!$reader->open($file)) {
34+
fwrite(STDERR, "cannot open $file\n");
35+
exit(1);
36+
}
37+
38+
/** @var list<array{class: string, name: string, duration: float}> $tests in execution order */
39+
$tests = [];
40+
while ($reader->read()) {
41+
if ($reader->nodeType !== XMLReader::ELEMENT || $reader->name !== 'testcase') {
42+
continue;
43+
}
44+
$tests[] = [
45+
'class' => $reader->getAttribute('class') ?: '(none)',
46+
'name' => (string)$reader->getAttribute('name'),
47+
'duration' => (float)$reader->getAttribute('time'),
48+
];
49+
}
50+
$reader->close();
51+
52+
$testCount = count($tests);
53+
if ($testCount === 0) {
54+
$error = libxml_get_errors()[0] ?? null;
55+
fwrite(STDERR, "no testcase elements found in $file"
56+
. ($error !== null ? ': ' . trim($error->message) : '') . "\n");
57+
exit(1);
58+
}
59+
60+
$totalDuration = array_sum(array_column($tests, 'duration'));
61+
printf("%d tests, %.1fs total (%.1f min)\n\n", $testCount, $totalDuration, $totalDuration / 60);
62+
63+
if ($totalDuration <= 0) {
64+
fwrite(STDERR, "no timing data to rank\n");
65+
exit(0);
66+
}
67+
68+
$chunks = array_chunk($tests, (int)ceil($testCount / BUCKETS));
69+
$buckets = count($chunks);
70+
71+
printf("Execution order, %d buckets (are later tests slower?)\n", $buckets);
72+
echo " bucket tests sum(s) mean(ms) median(ms) max(s) cum%\n";
73+
74+
$durationSoFar = 0.0;
75+
foreach ($chunks as $bucket => $chunk) {
76+
$durations = array_column($chunk, 'duration');
77+
sort($durations);
78+
$inBucket = count($durations);
79+
$bucketDuration = array_sum($durations);
80+
$durationSoFar += $bucketDuration;
81+
82+
printf(
83+
" %3d-%3d%% %7d %9.1f %10.2f %12.2f %9.2f %5.1f%%\n",
84+
$bucket * 100 / $buckets,
85+
($bucket + 1) * 100 / $buckets,
86+
$inBucket,
87+
$bucketDuration,
88+
$bucketDuration / $inBucket * 1000,
89+
$durations[intdiv($inBucket, 2)] * 1000,
90+
max($durations),
91+
$durationSoFar / $totalDuration * 100,
92+
);
93+
}
94+
95+
$slowestFirst = $tests;
96+
usort($slowestFirst, static fn (array $a, array $b): int => $b['duration'] <=> $a['duration']);
97+
98+
printf("\nTop %d slowest tests\n", $topCount);
99+
foreach (array_slice($slowestFirst, 0, $topCount) as $test) {
100+
printf(" %8.2fs %s::%s\n", $test['duration'], $test['class'], $test['name']);
101+
}
102+
103+
/** @var array<string, array{duration: float, tests: int}> $classes */
104+
$classes = [];
105+
foreach ($tests as $test) {
106+
$classes[$test['class']] ??= ['duration' => 0.0, 'tests' => 0];
107+
$classes[$test['class']]['duration'] += $test['duration'];
108+
$classes[$test['class']]['tests']++;
109+
}
110+
uasort($classes, static fn (array $a, array $b): int => $b['duration'] <=> $a['duration']);
111+
112+
printf("\nTop %d slowest classes (sum of its tests)\n", $topCount);
113+
printf(" %9s %7s %10s %s\n", 'sum', 'tests', 'mean(ms)', 'class');
114+
foreach (array_slice($classes, 0, $topCount, true) as $class => $stats) {
115+
printf(
116+
" %8.2fs %7d %10.2f %s\n",
117+
$stats['duration'],
118+
$stats['tests'],
119+
$stats['duration'] / $stats['tests'] * 1000,
120+
$class,
121+
);
122+
}
123+
124+
// How top-heavy is the run? A handful of tests dominating reads very differently
125+
// from the cost being spread evenly.
126+
$durationSoFar = 0.0;
127+
$testsInHalfTheRuntime = 0;
128+
foreach ($slowestFirst as $test) {
129+
$durationSoFar += $test['duration'];
130+
$testsInHalfTheRuntime++;
131+
if ($durationSoFar >= $totalDuration / 2) {
132+
break;
133+
}
134+
}
135+
printf(
136+
"\nThe slowest %d tests (%.1f%% of tests) account for 50%% of the runtime.\n",
137+
$testsInHalfTheRuntime,
138+
$testsInHalfTheRuntime / $testCount * 100,
139+
);

0 commit comments

Comments
 (0)