Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
97.37% |
37 / 38 |
|
50.00% |
1 / 2 |
CRAP | |
0.00% |
0 / 1 |
TestSuiteBuilder | |
97.37% |
37 / 38 |
|
50.00% |
1 / 2 |
8 | |
0.00% |
0 / 1 |
sortByNameAscending | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
buildSuites | |
97.30% |
36 / 37 |
|
0.00% |
0 / 1 |
7 |
1 | <?php |
2 | |
3 | declare( strict_types = 1 ); |
4 | |
5 | namespace MediaWiki\Composer\PhpUnitSplitter; |
6 | |
7 | /** |
8 | * @license GPL-2.0-or-later |
9 | */ |
10 | class TestSuiteBuilder { |
11 | |
12 | private static function sortByNameAscending( TestDescriptor $a, TestDescriptor $b ): int { |
13 | return $a->getFilename() <=> $b->getFilename(); |
14 | } |
15 | |
16 | /** |
17 | * Try to build balanced groups (split_groups / buckets) of tests. We have a couple of |
18 | * objectives here: |
19 | * - the groups should contain a stable ordering of tests so that we reduce the amount |
20 | * of random test failures due to test re-ordering |
21 | * - the groups should reduce the number of interacting extensions where possible. This |
22 | * is achieved with the alphabetical sort on filename - tests of the same extension will |
23 | * be grouped together |
24 | * - the groups should have a similar test execution time |
25 | * |
26 | * Information about test duration may be completely absent (if no test cache information is |
27 | * supplied), or partially absent (if the test has not been seen before). Since we neither |
28 | * want to ignore the duration information nor rely on it, we compromise by filling the buckets |
29 | * until we have reached a maximum by test count *or* by duration. This has the consequence |
30 | * that tests with a duration of zero will be treated somewhat like tests with an average |
31 | * duration. |
32 | * |
33 | * @param array $testDescriptors the list of tests that we want to sort into split_groups |
34 | * @param int $groups the number of split_groups we are targetting |
35 | * @return array a structured array of the resulting split_groups |
36 | */ |
37 | public function buildSuites( array $testDescriptors, int $groups ): array { |
38 | $suites = array_fill( 0, $groups, [ "list" => [], "time" => 0 ] ); |
39 | |
40 | // Sort the tests alphabetically so that tests in the same extension (folder) stay |
41 | // together in the same split_group |
42 | usort( $testDescriptors, [ self::class, "sortByNameAscending" ] ); |
43 | |
44 | // Count the total number of tests (with valid filenames) and set the max number |
45 | // of tests per bucket |
46 | $testCount = array_reduce( |
47 | $testDescriptors, |
48 | static fn ( $acc, $descriptor ) => ( $descriptor->getFilename() ? $acc + 1 : $acc ), |
49 | 0 |
50 | ); |
51 | $bucketTestCount = ceil( $testCount / $groups ); |
52 | |
53 | // Count the total duration of tests (with duration information) and set the max |
54 | // duration per bucket |
55 | $totalDuration = array_reduce( |
56 | $testDescriptors, |
57 | static fn ( $acc, $descriptor ) => $acc + $descriptor->getDuration(), |
58 | 0 |
59 | ); |
60 | $maxBucketDuration = ceil( $totalDuration / $groups ); |
61 | |
62 | // Counters for current bucket and cumulative counters for total progress |
63 | $currentTestIndex = 0; |
64 | $currentBucketDuration = 0; |
65 | $currentBucketIndex = 0; |
66 | $cumulativeTestCount = 0; |
67 | $cumulativeDuration = 0; |
68 | foreach ( $testDescriptors as $testDescriptor ) { |
69 | if ( !$testDescriptor->getFilename() ) { |
70 | // We didn't resolve a matching file for this test, so we skip it |
71 | // from the suite here. This only happens for "known" missing test |
72 | // classes (see PhpUnitXmlManager::EXPECTED_MISSING_CLASSES) - in |
73 | // all other cases a missing test file will throw an exception during |
74 | // suite building. |
75 | continue; |
76 | } |
77 | $suites[$currentBucketIndex]["list"][] = $testDescriptor->getFilename(); |
78 | $suites[$currentBucketIndex]["time"] += $testDescriptor->getDuration(); |
79 | $currentTestIndex += 1; |
80 | $cumulativeTestCount += 1; |
81 | $currentBucketDuration += $testDescriptor->getDuration(); |
82 | $cumulativeDuration += $testDescriptor->getDuration(); |
83 | |
84 | // Advance to the next bucket if we either have reached the limit in number of tests or the |
85 | // limit in test duration |
86 | if ( $currentTestIndex >= $bucketTestCount || $currentBucketDuration > $maxBucketDuration ) { |
87 | // Don't advance past the last bucket. If we reached the last bucket, just dump |
88 | // everything in there. |
89 | if ( $currentBucketIndex < $groups - 1 ) { |
90 | $currentBucketIndex++; |
91 | } |
92 | $currentTestIndex = 0; |
93 | $currentBucketDuration = 0; |
94 | |
95 | // Rebalance the bucket targets - $remainingBuckets will be at least 1 |
96 | $remainingBuckets = $groups - $currentBucketIndex; |
97 | $bucketTestCount = ceil( ( $testCount - $cumulativeTestCount ) / $remainingBuckets ); |
98 | $maxBucketDuration = ceil( ( $totalDuration - $cumulativeDuration ) / $remainingBuckets ); |
99 | } |
100 | } |
101 | return $suites; |
102 | } |
103 | } |