Skip to content

Commit 546b910

Browse files
committed
mtest: add option to slice tests
Executing tests can take a very long time. As an example, the Git test suite on Windows takes around 4 hours to execute. The Git project has been working around the issue by splitting up CI jobs into multiple slices: one job creates the build artifacts, and then we spawn N test jobs with those artifacts, where each test job executes 1/Nth of the tests. This can be scripted rather easily by using `meson test --list`, selecting every Nth line, but there may be other projects that have a similar need. Wire up a new option "--slice i/n" to `meson test` that does implements this logic. Signed-off-by: Patrick Steinhardt <[email protected]>
1 parent 5bbd030 commit 546b910

File tree

7 files changed

+86
-2
lines changed

7 files changed

+86
-2
lines changed

Diff for: docs/markdown/Unit-tests.md

+5
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,11 @@ name(s), the test name(s) must be contained in the suite(s). This
206206
however is redundant-- it would be more useful to specify either
207207
specific test names or suite(s).
208208

209+
Since version *1.8.0*, you can pass `--slice i/n` to split up the set of tests
210+
into `n` slices and execute the `ith` such slice. This allows you to distribute
211+
a set of long-running tests across multiple machines to decrease the overall
212+
runtime of tests.
213+
209214
### Other test options
210215

211216
Sometimes you need to run the tests multiple times, which is done like this:

Diff for: docs/markdown/snippets/test-slicing.md

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
## New option to execute a slice of tests
2+
3+
When tests take a long time to run a common strategy is to slice up the tests
4+
into multiple sets, where each set is executed on a separate machine. You can
5+
now use the `--slice i/n` argument for `meson test` to create `n` slices and
6+
execute the `ith` slice.

Diff for: man/meson.1

+4-1
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,9 @@ a multiplier to use for test timeout values (usually something like 100 for Valg
328328
.TP
329329
\fB\-\-setup\fR
330330
use the specified test setup
331+
.Tp
332+
\fB\-\-slice SLICE/NUM_SLICES\fR
333+
Split tests into NUM_SLICES slices and execute slice number SLICE. (Since 1.8.0)
331334

332335
.SH The wrap command
333336

@@ -410,7 +413,7 @@ Manage the packagefiles overlay
410413

411414
.B meson rewrite
412415
modifies the project definition.
413-
416+
414417
.B meson rewrite [
415418
.I options
416419
.B ] [

Diff for: mesonbuild/mtest.py

+30-1
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,29 @@ def uniwidth(s: str) -> int:
9999
result += UNIWIDTH_MAPPING[w]
100100
return result
101101

102+
def test_slice(arg: str) -> T.Tuple[int, int]:
103+
values = arg.split('/')
104+
if len(values) != 2:
105+
raise argparse.ArgumentTypeError("value does not conform to format 'SLICE/NUM_SLICES'")
106+
107+
try:
108+
nrslices = int(values[1])
109+
except ValueError:
110+
raise argparse.ArgumentTypeError('NUM_SLICES is not an integer')
111+
if nrslices <= 0:
112+
raise argparse.ArgumentTypeError('NUM_SLICES is not a positive integer')
113+
114+
try:
115+
subslice = int(values[0])
116+
except ValueError:
117+
raise argparse.ArgumentTypeError('SLICE is not an integer')
118+
if subslice <= 0:
119+
raise argparse.ArgumentTypeError('SLICE is not a positive integer')
120+
if subslice > nrslices:
121+
raise argparse.ArgumentTypeError('SLICE exceeds NUM_SLICES')
122+
123+
return subslice, nrslices
124+
102125
# Note: when adding arguments, please also add them to the completion
103126
# scripts in $MESONSRC/data/shell-completions/
104127
def add_arguments(parser: argparse.ArgumentParser) -> None:
@@ -149,12 +172,13 @@ def add_arguments(parser: argparse.ArgumentParser) -> None:
149172
help='Arguments to pass to the specified test(s) or all tests')
150173
parser.add_argument('--max-lines', default=100, dest='max_lines', type=int,
151174
help='Maximum number of lines to show from a long test log. Since 1.5.0.')
175+
parser.add_argument('--slice', default=None, type=test_slice, metavar='SLICE/NUM_SLICES',
176+
help='Split tests into NUM_SLICES slices and execute slice SLICE. Since 1.8.0.')
152177
parser.add_argument('args', nargs='*',
153178
help='Optional list of test names to run. "testname" to run all tests with that name, '
154179
'"subprojname:testname" to specifically run "testname" from "subprojname", '
155180
'"subprojname:" to run all tests defined by "subprojname".')
156181

157-
158182
def print_safe(s: str) -> None:
159183
end = '' if s[-1] == '\n' else '\n'
160184
try:
@@ -1977,6 +2001,11 @@ def get_tests(self, errorfile: T.Optional[T.IO] = None) -> T.List[TestSerialisat
19772001
tests = [t for t in self.tests if self.test_suitable(t)]
19782002
if self.options.args:
19792003
tests = list(self.tests_from_args(tests))
2004+
if self.options.slice:
2005+
our_slice, nslices = self.options.slice
2006+
if nslices > len(tests):
2007+
raise MesonException(f'number of slices ({nslices}) exceeds number of tests ({len(tests)})')
2008+
tests = tests[our_slice - 1::nslices]
19802009

19812010
if not tests:
19822011
print('No suitable tests defined.', file=errorfile)

Diff for: test cases/unit/124 test slice/meson.build

+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
project('test_slice')
2+
3+
python = import('python').find_installation('python3')
4+
5+
foreach i : range(10)
6+
test('test-' + (i + 1).to_string(),
7+
python,
8+
args: [
9+
meson.current_source_dir() / 'test.py'
10+
],
11+
)
12+
endforeach

Diff for: test cases/unit/124 test slice/test.py

Whitespace-only changes.

Diff for: unittests/allplatformstests.py

+29
Original file line numberDiff line numberDiff line change
@@ -5130,6 +5130,35 @@ def test_objc_objcpp_stds(self) -> None:
51305130
def test_c_cpp_objc_objcpp_stds(self) -> None:
51315131
self.__test_multi_stds(test_objc=True)
51325132

5133+
def test_slice(self):
5134+
testdir = os.path.join(self.unit_test_dir, '124 test slice')
5135+
self.init(testdir)
5136+
self.build()
5137+
5138+
for arg, expectation in {'1/1': [1, 2, 3, 4, 5, 6, 7, 8, 9, 10],
5139+
'1/2': [1, 3, 5, 7, 9],
5140+
'2/2': [2, 4, 6, 8, 10],
5141+
'1/10': [1],
5142+
'2/10': [2],
5143+
'10/10': [10],
5144+
}.items():
5145+
output = self._run(self.mtest_command + ['--slice=' + arg])
5146+
tests = sorted([ int(x[5:]) for x in re.findall(r'test-[0-9]*', output) ])
5147+
self.assertEqual(tests, expectation)
5148+
5149+
for arg, expectation in {'': 'error: argument --slice: value does not conform to format \'SLICE/NUM_SLICES\'',
5150+
'0': 'error: argument --slice: value does not conform to format \'SLICE/NUM_SLICES\'',
5151+
'0/1': 'error: argument --slice: SLICE is not a positive integer',
5152+
'a/1': 'error: argument --slice: SLICE is not an integer',
5153+
'1/0': 'error: argument --slice: NUM_SLICES is not a positive integer',
5154+
'1/a': 'error: argument --slice: NUM_SLICES is not an integer',
5155+
'2/1': 'error: argument --slice: SLICE exceeds NUM_SLICES',
5156+
'1/11': 'ERROR: number of slices (11) exceeds number of tests (10)',
5157+
}.items():
5158+
with self.assertRaises(subprocess.CalledProcessError) as cm:
5159+
self._run(self.mtest_command + ['--slice=' + arg])
5160+
self.assertIn(expectation, cm.exception.output)
5161+
51335162
def test_rsp_support(self):
51345163
env = get_fake_env()
51355164
cc = detect_c_compiler(env, MachineChoice.HOST)

0 commit comments

Comments
 (0)