Merge lp:~allenap/maas/parallel-tests-xunit into lp:~maas-committers/maas/trunk
- parallel-tests-xunit
- Merge into 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 |
Related bugs: |
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.
Description of the change
To post a comment you must log in.
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.**", |
Looks good.