Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.37% covered (success)
97.37%
37 / 38
50.00% covered (danger)
50.00%
1 / 2
CRAP
0.00% covered (danger)
0.00%
0 / 1
TestSuiteBuilder
97.37% covered (success)
97.37%
37 / 38
50.00% covered (danger)
50.00%
1 / 2
8
0.00% covered (danger)
0.00%
0 / 1
 sortByNameAscending
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 buildSuites
97.30% covered (success)
97.30%
36 / 37
0.00% covered (danger)
0.00%
0 / 1
7
1<?php
2
3declare( strict_types = 1 );
4
5namespace MediaWiki\Composer\PhpUnitSplitter;
6
7/**
8 * @license GPL-2.0-or-later
9 */
10class 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}