aboutsummaryrefslogtreecommitdiffstats
path: root/includes/composer/PhpUnitSplitter/TestSuiteBuilder.php
blob: b9da8c651cababa2d7fc63ec511615828621549b (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
<?php

declare( strict_types = 1 );

namespace MediaWiki\Composer\PhpUnitSplitter;

/**
 * @license GPL-2.0-or-later
 */
class TestSuiteBuilder {

	private static function sortByNameAscending( TestDescriptor $a, TestDescriptor $b ): int {
		return $a->getFilename() <=> $b->getFilename();
	}

	/**
	 * Try to build balanced groups (split_groups / buckets) of tests. We have a couple of
	 * objectives here:
	 *   - the groups should contain a stable ordering of tests so that we reduce the amount
	 *     of random test failures due to test re-ordering
	 *   - the groups should reduce the number of interacting extensions where possible. This
	 *     is achieved with the alphabetical sort on filename - tests of the same extension will
	 *     be grouped together
	 *   - the groups should have a similar test execution time
	 *
	 * Information about test duration may be completely absent (if no test cache information is
	 * supplied), or partially absent (if the test has not been seen before). Since we neither
	 * want to ignore the duration information nor rely on it, we compromise by filling the buckets
	 * until we have reached a maximum by test count *or* by duration. This has the consequence
	 * that tests with a duration of zero will be treated somewhat like tests with an average
	 * duration.
	 *
	 * @param array $testDescriptors the list of tests that we want to sort into split_groups
	 * @param int $groups the number of split_groups we are targetting
	 * @return array a structured array of the resulting split_groups
	 */
	public function buildSuites( array $testDescriptors, int $groups ): array {
		$suites = array_fill( 0, $groups, [ "list" => [], "time" => 0 ] );

		// Sort the tests alphabetically so that tests in the same extension (folder) stay
		// together in the same split_group
		usort( $testDescriptors, [ self::class, "sortByNameAscending" ] );

		// Count the total number of tests (with valid filenames) and set the max number
		// of tests per bucket
		$testCount = array_reduce(
			$testDescriptors,
			static fn ( $acc, $descriptor ) => ( $descriptor->getFilename() ? $acc + 1 : $acc ),
			0
		);
		$bucketTestCount = ceil( $testCount / $groups );

		// Count the total duration of tests (with duration information) and set the max
		// duration per bucket
		$totalDuration = array_reduce(
			$testDescriptors,
			static fn ( $acc, $descriptor ) => $acc + $descriptor->getDuration(),
			0
		);
		$maxBucketDuration = ceil( $totalDuration / $groups );

		// Counters for current bucket and cumulative counters for total progress
		$currentTestIndex = 0;
		$currentBucketDuration = 0;
		$currentBucketIndex = 0;
		$cumulativeTestCount = 0;
		$cumulativeDuration = 0;
		foreach ( $testDescriptors as $testDescriptor ) {
			if ( !$testDescriptor->getFilename() ) {
				// We didn't resolve a matching file for this test, so we skip it
				// from the suite here. This only happens for "known" missing test
				// classes (see PhpUnitXmlManager::EXPECTED_MISSING_CLASSES) - in
				// all other cases a missing test file will throw an exception during
				// suite building.
				continue;
			}
			$suites[$currentBucketIndex]["list"][] = $testDescriptor->getFilename();
			$suites[$currentBucketIndex]["time"] += $testDescriptor->getDuration();
			$currentTestIndex += 1;
			$cumulativeTestCount += 1;
			$currentBucketDuration += $testDescriptor->getDuration();
			$cumulativeDuration += $testDescriptor->getDuration();

			// Advance to the next bucket if we either have reached the limit in number of tests or the
			// limit in test duration
			if ( $currentTestIndex >= $bucketTestCount || $currentBucketDuration > $maxBucketDuration ) {
				// Don't advance past the last bucket. If we reached the last bucket, just dump
				// everything in there.
				if ( $currentBucketIndex < $groups - 1 ) {
					$currentBucketIndex++;
				}
				$currentTestIndex = 0;
				$currentBucketDuration = 0;

				// Rebalance the bucket targets - $remainingBuckets will be at least 1
				$remainingBuckets = $groups - $currentBucketIndex;
				$bucketTestCount = ceil( ( $testCount - $cumulativeTestCount ) / $remainingBuckets );
				$maxBucketDuration = ceil( ( $totalDuration - $cumulativeDuration ) / $remainingBuckets );
			}
		}
		return $suites;
	}
}