Merge lp:~allenap/maas/parallel-tests-xunit into lp:~maas-committers/maas/trunk

Proposed by Gavin Panella
Status: Merged
Approved by: Gavin Panella
Approved revision: no longer in the source branch.
Merged at revision: 5699
Proposed branch: lp:~allenap/maas/parallel-tests-xunit
Merge into: lp:~maas-committers/maas/trunk
Diff against target: 353 lines (+243/-35)
5 files modified
Makefile (+1/-1)
buildout.cfg (+1/-0)
src/maastesting/parallel.py (+95/-24)
src/maastesting/tests/test_parallel.py (+145/-10)
utilities/check-imports (+1/-0)
To merge this branch: bzr merge lp:~allenap/maas/parallel-tests-xunit
Reviewer Review Type Date Requested Status
Blake Rouse (community) Approve
Review via email: mp+316395@code.launchpad.net

Commit message

Add test.parallel arguments to specify subprocesses and results.

To post a comment you must log in.
Revision history for this message
Blake Rouse (blake-rouse) wrote :

Looks good.

review: Approve
Revision history for this message
Gavin Panella (allenap) wrote :

Thanks!

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'Makefile'
2--- Makefile 2017-02-03 08:45:37 +0000
3+++ Makefile 2017-02-04 22:01:56 +0000
4@@ -226,7 +226,7 @@
5 utilities/isolated-make-test
6
7 test: bin/test.parallel
8- @bin/test.parallel
9+ @bin/test.parallel --subprocess-per-core
10
11 test-serial: $(strip $(test-scripts))
12 @bin/maas-region makemigrations --dry-run --exit && exit 1 ||:
13
14=== modified file 'buildout.cfg'
15--- buildout.cfg 2017-01-26 15:57:00 +0000
16+++ buildout.cfg 2017-02-04 22:01:56 +0000
17@@ -38,6 +38,7 @@
18 fixtures
19 hypothesis
20 ipdb
21+ junitxml
22 nose
23 nose-timer
24 postgresfixture
25
26=== modified file 'src/maastesting/parallel.py'
27--- src/maastesting/parallel.py 2017-01-25 16:07:58 +0000
28+++ src/maastesting/parallel.py 2017-02-04 22:01:56 +0000
29@@ -15,6 +15,7 @@
30 import threading
31 import unittest
32
33+import junitxml
34 from maastesting.utils import content_from_file
35 import subunit
36 import testtools
37@@ -166,22 +167,40 @@
38 return split
39
40
41-def print_test(test, status, start_time, stop_time, tags, details):
42- testid = "<none>" if test is None else test.id()
43- duration = (stop_time - start_time).total_seconds()
44- message = "%s: %s (%0.2fs)" % (status.upper(), testid, abs(duration))
45- print(message, flush=True)
46-
47-
48-def test(suite):
49- parts = max(2, os.cpu_count() - 2)
50- split = make_splitter(parts)
51+def make_human_readable_result(stream):
52+ """Make a result that emits messages intended for human consumption."""
53+
54+ def print_result(test, status, start_time, stop_time, tags, details):
55+ testid = "<none>" if test is None else test.id()
56+ duration = (stop_time - start_time).total_seconds()
57+ message = "%s: %s (%0.2fs)" % (status.upper(), testid, abs(duration))
58+ print(message, file=stream, flush=True)
59+
60+ return testtools.MultiTestResult(
61+ testtools.TextTestResult(stream, failfast=False, tb_locals=False),
62+ testtools.TestByTestResult(print_result))
63+
64+
65+def make_subunit_result(stream):
66+ """Make a result that emits a subunit stream."""
67+ return subunit.TestProtocolClient(stream)
68+
69+
70+def make_junit_result(stream):
71+ """Make a result that emits JUnit-compatible XML results."""
72+ return junitxml.JUnitXmlResult(stream)
73+
74+
75+def test(suite, result, processes):
76+ """Test `suite`, emitting results to `result`.
77+
78+ :param suite: The test suite to run.
79+ :param result: The test result to which to report.
80+ :param processes: The number of processes to split up tests amongst.
81+ :return: A boolean signalling success or not.
82+ """
83+ split = make_splitter(processes)
84 suite = testtools.ConcurrentTestSuite(suite, split)
85- result = testtools.MultiTestResult(
86- testtools.TestByTestResult(print_test),
87- testtools.TextTestResult(
88- sys.stdout, failfast=False, tb_locals=False),
89- )
90
91 result.startTestRun()
92 try:
93@@ -189,14 +208,62 @@
94 finally:
95 result.stopTestRun()
96
97- raise SystemExit(0 if result.wasSuccessful() else 2)
98-
99-
100-argument_parser = argparse.ArgumentParser(description=__doc__)
101-
102-
103-def main():
104- args = argument_parser.parse_args() # noqa
105+ return result.wasSuccessful()
106+
107+
108+def make_argument_parser():
109+ """Create an argument parser for the command-line."""
110+ parser = argparse.ArgumentParser(description=__doc__, add_help=False)
111+ parser.add_argument(
112+ "-h", "--help", action="help", help=argparse.SUPPRESS)
113+
114+ core_count = os.cpu_count()
115+
116+ def parse_subprocesses(string):
117+ try:
118+ processes = int(string)
119+ except ValueError:
120+ raise argparse.ArgumentTypeError(
121+ "%r is not an integer" % string)
122+ else:
123+ if processes < 1:
124+ raise argparse.ArgumentTypeError(
125+ "%d is not 1 or greater" % processes)
126+ else:
127+ return processes
128+
129+ args_subprocesses = parser.add_mutually_exclusive_group()
130+ args_subprocesses.add_argument(
131+ "--subprocesses", metavar="N", action="store", type=parse_subprocesses,
132+ dest="subprocesses", default=max(2, core_count - 2), help=(
133+ "The number of testing subprocesses to run concurrently. This "
134+ "defaults to the number of CPU cores available minus 2, but not "
135+ "less than 2. On this machine the default is %(default)s."))
136+ args_subprocesses.add_argument(
137+ "--subprocess-per-core", action="store_const", dest="subprocesses",
138+ const=core_count, help=(
139+ "Run one test process per core. On this machine that would mean "
140+ "that up to %d testing subprocesses would run concurrently."
141+ % core_count))
142+
143+ args_output = parser.add_argument_group("output")
144+ args_output.add_argument(
145+ "--emit-human", dest="result_factory", action="store_const",
146+ const=make_human_readable_result, help="Emit human-readable results.")
147+ args_output.add_argument(
148+ "--emit-subunit", dest="result_factory", action="store_const",
149+ const=make_subunit_result, help="Emit a subunit stream.")
150+ args_output.add_argument(
151+ "--emit-junit", dest="result_factory", action="store_const",
152+ const=make_junit_result, help="Emit JUnit-compatible XML.")
153+ args_output.set_defaults(
154+ result_factory=make_human_readable_result)
155+
156+ return parser
157+
158+
159+def main(args=None):
160+ args = make_argument_parser().parse_args(args)
161 lock = threading.Lock()
162 suite = unittest.TestSuite((
163 # Run the indivisible tests first. These will each consume a worker
164@@ -211,7 +278,11 @@
165 TestScriptDivisible(lock, "bin/test.region"),
166 TestScriptDivisible(lock, "bin/test.testing"),
167 ))
168- test(suite)
169+ result = args.result_factory(sys.stdout)
170+ if test(suite, result, args.subprocesses):
171+ raise SystemExit(0)
172+ else:
173+ raise SystemExit(2)
174
175
176 if __name__ == '__main__':
177
178=== modified file 'src/maastesting/tests/test_parallel.py'
179--- src/maastesting/tests/test_parallel.py 2017-01-23 16:36:20 +0000
180+++ src/maastesting/tests/test_parallel.py 2017-02-04 22:01:56 +0000
181@@ -5,15 +5,150 @@
182
183 __all__ = []
184
185-import types
186+import os
187+import random
188+from unittest.mock import ANY
189
190+import junitxml
191+from maastesting import parallel
192+from maastesting.fixtures import CaptureStandardIO
193+from maastesting.matchers import (
194+ DocTestMatches,
195+ MockCalledOnceWith,
196+ MockNotCalled,
197+)
198 from maastesting.testcase import MAASTestCase
199-from testtools.matchers import IsInstance
200-
201-
202-class TestSmoke(MAASTestCase):
203- """Trivial smoke test."""
204-
205- def test_imports_cleanly(self):
206- from maastesting import parallel
207- self.assertThat(parallel, IsInstance(types.ModuleType))
208+import subunit
209+from testtools import (
210+ ExtendedToOriginalDecorator,
211+ MultiTestResult,
212+ TestByTestResult,
213+ TextTestResult,
214+)
215+from testtools.matchers import (
216+ Equals,
217+ Is,
218+ IsInstance,
219+ MatchesAll,
220+ MatchesListwise,
221+ MatchesStructure,
222+)
223+
224+
225+class TestSubprocessArguments(MAASTestCase):
226+ """Tests for arguments that adjust subprocess behaviour."""
227+
228+ def setUp(self):
229+ super(TestSubprocessArguments, self).setUp()
230+ self.stdio = self.useFixture(CaptureStandardIO())
231+ self.patch_autospec(parallel, "test")
232+ parallel.test.return_value = True
233+
234+ def test__defaults(self):
235+ sysexit = self.assertRaises(SystemExit, parallel.main, [])
236+ self.assertThat(sysexit.code, Equals(0))
237+ self.assertThat(parallel.test, MockCalledOnceWith(
238+ ANY, ANY, max(os.cpu_count() - 2, 2)))
239+
240+ def test__subprocess_count_can_be_specified(self):
241+ count = random.randrange(100, 1000)
242+ sysexit = self.assertRaises(
243+ SystemExit, parallel.main, ["--subprocesses", str(count)])
244+ self.assertThat(sysexit.code, Equals(0))
245+ self.assertThat(parallel.test, MockCalledOnceWith(ANY, ANY, count))
246+
247+ def test__subprocess_count_of_less_than_1_is_rejected(self):
248+ sysexit = self.assertRaises(
249+ SystemExit, parallel.main, ["--subprocesses", "0"])
250+ self.assertThat(sysexit.code, Equals(2))
251+ self.assertThat(parallel.test, MockNotCalled())
252+ self.assertThat(self.stdio.getError(), DocTestMatches(
253+ "usage: ... argument --subprocesses: 0 is not 1 or greater"))
254+
255+ def test__subprocess_count_non_numeric_is_rejected(self):
256+ sysexit = self.assertRaises(
257+ SystemExit, parallel.main, ["--subprocesses", "foo"])
258+ self.assertThat(sysexit.code, Equals(2))
259+ self.assertThat(parallel.test, MockNotCalled())
260+ self.assertThat(self.stdio.getError(), DocTestMatches(
261+ "usage: ... argument --subprocesses: 'foo' is not an integer"))
262+
263+ def test__subprocess_per_core_can_be_specified(self):
264+ sysexit = self.assertRaises(
265+ SystemExit, parallel.main, ["--subprocess-per-core"])
266+ self.assertThat(sysexit.code, Equals(0))
267+ self.assertThat(parallel.test, MockCalledOnceWith(
268+ ANY, ANY, os.cpu_count()))
269+
270+ def test__subprocess_count_and_per_core_cannot_both_be_specified(self):
271+ sysexit = self.assertRaises(SystemExit, parallel.main, [
272+ "--subprocesses", "3", "--subprocess-per-core"])
273+ self.assertThat(sysexit.code, Equals(2))
274+ self.assertThat(parallel.test, MockNotCalled())
275+ self.assertThat(self.stdio.getError(), DocTestMatches(
276+ "usage: ... argument --subprocess-per-core: not allowed with "
277+ "argument --subprocesses"))
278+
279+
280+class TestEmissionArguments(MAASTestCase):
281+ """Tests for arguments that adjust result emission behaviour."""
282+
283+ def setUp(self):
284+ super(TestEmissionArguments, self).setUp()
285+ self.stdio = self.useFixture(CaptureStandardIO())
286+ self.patch_autospec(parallel, "test")
287+ parallel.test.return_value = True
288+
289+ def test__results_are_human_readable_by_default(self):
290+ sysexit = self.assertRaises(SystemExit, parallel.main, [])
291+ self.assertThat(sysexit.code, Equals(0))
292+ self.assertThat(parallel.test, MockCalledOnceWith(ANY, ANY, ANY))
293+ _, result, _ = parallel.test.call_args[0]
294+ self.assertThat(result, IsMultiResultOf(
295+ IsInstance(TextTestResult), IsInstance(TestByTestResult)))
296+
297+ def test__results_can_be_explicitly_specified_as_human_readable(self):
298+ sysexit = self.assertRaises(
299+ SystemExit, parallel.main, ["--emit-human"])
300+ self.assertThat(sysexit.code, Equals(0))
301+ self.assertThat(parallel.test, MockCalledOnceWith(ANY, ANY, ANY))
302+ _, result, _ = parallel.test.call_args[0]
303+ self.assertThat(result, IsMultiResultOf(
304+ IsInstance(TextTestResult), IsInstance(TestByTestResult)))
305+
306+ def test__results_can_be_specified_as_subunit(self):
307+ sysexit = self.assertRaises(
308+ SystemExit, parallel.main, ["--emit-subunit"])
309+ self.assertThat(sysexit.code, Equals(0))
310+ self.assertThat(parallel.test, MockCalledOnceWith(ANY, ANY, ANY))
311+ _, result, _ = parallel.test.call_args[0]
312+ self.assertThat(result, IsInstance(subunit.TestProtocolClient))
313+ self.assertThat(result, MatchesStructure(
314+ _stream=Is(self.stdio.stdout.buffer)))
315+
316+ def test__results_can_be_specified_as_junit(self):
317+ sysexit = self.assertRaises(
318+ SystemExit, parallel.main, ["--emit-junit"])
319+ self.assertThat(sysexit.code, Equals(0))
320+ self.assertThat(parallel.test, MockCalledOnceWith(ANY, ANY, ANY))
321+ _, result, _ = parallel.test.call_args[0]
322+ self.assertThat(result, IsInstance(junitxml.JUnitXmlResult))
323+ self.assertThat(result, MatchesStructure(
324+ _stream=Is(self.stdio.stdout)))
325+
326+
327+def IsMultiResultOf(*results):
328+ """Match a `MultiTestResult` wrapping the given results."""
329+ return MatchesAll(
330+ IsInstance(MultiTestResult),
331+ MatchesStructure(
332+ _results=MatchesListwise([
333+ MatchesAll(
334+ IsInstance(ExtendedToOriginalDecorator),
335+ MatchesStructure(decorated=matcher),
336+ first_only=True)
337+ for matcher in results
338+ ]),
339+ ),
340+ first_only=True,
341+ )
342
343=== modified file 'utilities/check-imports'
344--- utilities/check-imports 2017-01-26 18:46:16 +0000
345+++ utilities/check-imports 2017-02-04 22:01:56 +0000
346@@ -152,6 +152,7 @@
347 "dns|dns.**",
348 "fixtures|fixtures.**",
349 "hypothesis|hypothesis.**",
350+ "junitxml|junitxml.**",
351 "maastesting|maastesting.**",
352 "nose|nose.**",
353 "postgresfixture|postgresfixture.**",