Skip to content

Commit 447f31f

Browse files
committed
mtest: Add support for rust unit tests
Rust has it's own built in unit test format, which is invoked by compiling a rust executable with the `--test` flag to rustc. The tests are then run by simply invoking that binary. They output a custom test format, which this patch adds parsing support for. This means that we can report each subtest in the junit we generate correctly, which should be helpful for orchestration systems like gitlab and jenkins which can parse junit XML.
1 parent 8001ae1 commit 447f31f

File tree

7 files changed

+113
-3
lines changed

7 files changed

+113
-3
lines changed

docs/markdown/Reference-manual.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1740,6 +1740,7 @@ test(..., env: nomalloc, ...)
17401740
to record the outcome of the test).
17411741
- `tap`: [Test Anything Protocol](https://www.testanything.org/).
17421742
- `gtest` *(since 0.55.0)*: for Google Tests.
1743+
- `rust` *(since 0.56.0)*: for native rust tests
17431744

17441745
- `priority` *(since 0.52.0)*:specifies the priority of a test. Tests with a
17451746
higher priority are *started* before tests with a lower priority.
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
## Meson test() now accepts `protocol : 'rust'`
2+
3+
This allows native rust tests to be run and parsed by meson, simply set the
4+
protocol to `rust` and meson takes care of the rest.

mesonbuild/backend/backends.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ class TestProtocol(enum.Enum):
4444
EXITCODE = 0
4545
TAP = 1
4646
GTEST = 2
47+
RUST = 3
4748

4849
@classmethod
4950
def from_str(cls, string: str) -> 'TestProtocol':
@@ -53,13 +54,17 @@ def from_str(cls, string: str) -> 'TestProtocol':
5354
return cls.TAP
5455
elif string == 'gtest':
5556
return cls.GTEST
57+
elif string == 'rust':
58+
return cls.RUST
5659
raise MesonException('unknown test format {}'.format(string))
5760

5861
def __str__(self) -> str:
5962
if self is self.EXITCODE:
6063
return 'exitcode'
6164
elif self is self.GTEST:
6265
return 'gtest'
66+
elif self is self.RUST:
67+
return 'rust'
6368
return 'tap'
6469

6570

mesonbuild/interpreter.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4080,6 +4080,8 @@ def func_benchmark(self, node, args, kwargs):
40804080
def func_test(self, node, args, kwargs):
40814081
if kwargs.get('protocol') == 'gtest':
40824082
FeatureNew.single_use('"gtest" protocol for tests', '0.55.0', self.subproject)
4083+
elif kwargs.get('protocol') == 'rust':
4084+
FeatureNew.single_use('"rust" protocol for tests', '0.56.0', self.subproject)
40834085
self.add_test(node, args, kwargs, True)
40844086

40854087
def unpack_env_kwarg(self, kwargs) -> build.EnvironmentVariables:
@@ -4136,8 +4138,8 @@ def add_test(self, node, args, kwargs, is_base_test):
41364138
if not isinstance(timeout, int):
41374139
raise InterpreterException('Timeout must be an integer.')
41384140
protocol = kwargs.get('protocol', 'exitcode')
4139-
if protocol not in {'exitcode', 'tap', 'gtest'}:
4140-
raise InterpreterException('Protocol must be "exitcode", "tap", or "gtest".')
4141+
if protocol not in {'exitcode', 'tap', 'gtest', 'rust'}:
4142+
raise InterpreterException('Protocol must be one of "exitcode", "tap", "gtest", or "rust".')
41414143
suite = []
41424144
prj = self.subproject if self.is_subproject() else self.build.project_name
41434145
for s in mesonlib.stringlistify(kwargs.get('suite', '')):

mesonbuild/mtest.py

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -326,7 +326,6 @@ def parse(self) -> T.Generator[T.Union['TAPParser.Test', 'TAPParser.Error', 'TAP
326326
yield self.Error('Too many tests run (expected {}, got {})'.format(plan.count, num_tests))
327327

328328

329-
330329
class JunitBuilder:
331330

332331
"""Builder for Junit test results.
@@ -443,6 +442,28 @@ def write(self) -> None:
443442
tree.write(f, encoding='utf-8', xml_declaration=True)
444443

445444

445+
def parse_rust_test(stdout: str) -> T.Dict[str, TestResult]:
446+
"""Parse the output of rust tests."""
447+
res = {} # type; T.Dict[str, TestResult]
448+
449+
def parse_res(res: str) -> TestResult:
450+
if res == 'ok':
451+
return TestResult.OK
452+
elif res == 'ignored':
453+
return TestResult.SKIP
454+
elif res == 'FAILED':
455+
return TestResult.FAIL
456+
raise MesonException('Unsupported output from rust test: {}'.format(res))
457+
458+
for line in stdout.splitlines():
459+
if line.startswith('test ') and not line.startswith('test result'):
460+
_, name, _, result = line.split(' ')
461+
name = name.replace('::', '.')
462+
res[name] = parse_res(result)
463+
464+
return res
465+
466+
446467
class TestRun:
447468

448469
@classmethod
@@ -512,6 +533,25 @@ def make_tap(cls, test: TestSerialisation, test_env: T.Dict[str, str],
512533

513534
return cls(test, test_env, res, results, returncode, starttime, duration, stdo, stde, cmd)
514535

536+
@classmethod
537+
def make_rust(cls, test: TestSerialisation, test_env: T.Dict[str, str],
538+
returncode: int, starttime: float, duration: float,
539+
stdo: str, stde: str,
540+
cmd: T.Optional[T.List[str]]) -> 'TestRun':
541+
results = parse_rust_test(stdo)
542+
543+
failed = TestResult.FAIL in results.values()
544+
# Now determine the overall result of the test based on the outcome of the subcases
545+
if all(t is TestResult.SKIP for t in results.values()):
546+
# This includes the case where num_tests is zero
547+
res = TestResult.SKIP
548+
elif test.should_fail:
549+
res = TestResult.EXPECTEDFAIL if failed else TestResult.UNEXPECTEDPASS
550+
else:
551+
res = TestResult.FAIL if failed else TestResult.OK
552+
553+
return cls(test, test_env, res, results, returncode, starttime, duration, stdo, stde, cmd)
554+
515555
def __init__(self, test: TestSerialisation, test_env: T.Dict[str, str],
516556
res: TestResult, results: T.Dict[str, TestResult], returncode:
517557
int, starttime: float, duration: float,
@@ -796,6 +836,8 @@ def _send_signal_to_process_group(pgid : int, signum : int) -> None:
796836
return TestRun.make_exitcode(self.test, self.test_env, p.returncode, starttime, duration, stdo, stde, cmd)
797837
elif self.test.protocol is TestProtocol.GTEST:
798838
return TestRun.make_gtest(self.test, self.test_env, p.returncode, starttime, duration, stdo, stde, cmd)
839+
elif self.test.protocol is TestProtocol.RUST:
840+
return TestRun.make_rust(self.test, self.test_env, p.returncode, starttime, duration, stdo, stde, cmd)
799841
else:
800842
if self.options.verbose:
801843
print(stdo, end='')
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
project('rust unit tests', 'rust')
2+
3+
t = executable(
4+
'rust_test',
5+
['test.rs'],
6+
rust_args : ['--test'],
7+
)
8+
9+
test(
10+
'rust test (should fail)',
11+
t,
12+
protocol : 'rust',
13+
suite : ['foo'],
14+
should_fail : true,
15+
)
16+
17+
test(
18+
'rust test (should pass)',
19+
t,
20+
args : ['--skip', 'test_add_intentional_fail'],
21+
protocol : 'rust',
22+
suite : ['foo'],
23+
)
24+
25+
26+
test(
27+
'rust test (should skip)',
28+
t,
29+
args : ['--skip', 'test_add'],
30+
protocol : 'rust',
31+
suite : ['foo'],
32+
)

test cases/rust/9 unit tests/test.rs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
pub fn add(a: i32, b: i32) -> i32 {
2+
return a + b;
3+
}
4+
5+
#[cfg(test)]
6+
mod tests {
7+
use super::*;
8+
9+
#[test]
10+
fn test_add() {
11+
assert_eq!(add(1, 2), 3);
12+
}
13+
14+
#[test]
15+
fn test_add_intentional_fail() {
16+
assert_eq!(add(1, 2), 5);
17+
}
18+
19+
#[test]
20+
#[ignore]
21+
fn test_add_intentional_fail2() {
22+
assert_eq!(add(1, 7), 5);
23+
}
24+
}

0 commit comments

Comments
 (0)