Merge lp:~jml/launchpad/launchpad-tester into lp:launchpad
- launchpad-tester
- Merge into devel
Status: | Merged |
---|---|
Approved by: | Robert Collins |
Approved revision: | no longer in the source branch. |
Merged at revision: | 11437 |
Proposed branch: | lp:~jml/launchpad/launchpad-tester |
Merge into: | lp:launchpad |
Prerequisite: | lp:~jml/launchpad/subject-and-attachment |
Diff against target: |
333 lines (+181/-78) 2 files modified
lib/devscripts/ec2test/remote.py (+19/-15) lib/devscripts/ec2test/tests/test_remote.py (+162/-63) |
To merge this branch: | bzr merge lp:~jml/launchpad/launchpad-tester |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Robert Collins (community) | Approve | ||
Review via email: mp+33564@code.launchpad.net |
Commit message
Add tests for devscripts.
Description of the change
This branch only makes one small behaviour change -- it flushes the summary
log more regularly so that the "Summary" on an ec2 test web server is always
up-to-date.
More interestingly, it adds tests for LaunchpadTester.
I was going to change the behaviour more significantly, but every time I
did I felt uncomfortable and uncertain.
Jonathan Lange (jml) wrote : | # |
On Tue, Aug 24, 2010 at 8:41 PM, Robert Collins
<email address hidden> wrote:
> Review: Approve
> It might be nice to:
> - use fixtures rather than pushing those helpers around
You mean "prefer composition to inheritance", right? Does bzrlib give
me a way to do make_branch_
TestCaseWithRep
> - flush the detailed log too, so that tribunal stays up to date more.
The detailed log is flushed elsewhere.
jml
Preview Diff
1 | === modified file 'lib/devscripts/ec2test/remote.py' | |||
2 | --- lib/devscripts/ec2test/remote.py 2010-08-24 17:51:03 +0000 | |||
3 | +++ lib/devscripts/ec2test/remote.py 2010-08-24 17:51:06 +0000 | |||
4 | @@ -213,8 +213,6 @@ | |||
5 | 213 | class LaunchpadTester: | 213 | class LaunchpadTester: |
6 | 214 | """Runs Launchpad tests and gathers their results in a useful way.""" | 214 | """Runs Launchpad tests and gathers their results in a useful way.""" |
7 | 215 | 215 | ||
8 | 216 | # XXX: JonathanLange 2010-08-17: LaunchpadTester needs tests. | ||
9 | 217 | |||
10 | 218 | def __init__(self, logger, test_directory, test_options=()): | 216 | def __init__(self, logger, test_directory, test_options=()): |
11 | 219 | """Construct a TestOnMergeRunner. | 217 | """Construct a TestOnMergeRunner. |
12 | 220 | 218 | ||
13 | @@ -236,9 +234,25 @@ | |||
14 | 236 | 234 | ||
15 | 237 | Subclasses must provide their own implementation of this method. | 235 | Subclasses must provide their own implementation of this method. |
16 | 238 | """ | 236 | """ |
18 | 239 | command = ['make', 'check', 'TESTOPTS="%s"' % self._test_options] | 237 | command = ['make', 'check'] |
19 | 238 | if self._test_options: | ||
20 | 239 | command.append('TESTOPTS="%s"' % self._test_options) | ||
21 | 240 | return command | 240 | return command |
22 | 241 | 241 | ||
23 | 242 | def _spawn_test_process(self): | ||
24 | 243 | """Actually run the tests. | ||
25 | 244 | |||
26 | 245 | :return: A `subprocess.Popen` object for the test run. | ||
27 | 246 | """ | ||
28 | 247 | call = self.build_test_command() | ||
29 | 248 | self._logger.write_line("Running %s" % (call,)) | ||
30 | 249 | # bufsize=0 means do not buffer any of the output. We want to | ||
31 | 250 | # display the test output as soon as it is generated. | ||
32 | 251 | return subprocess.Popen( | ||
33 | 252 | call, bufsize=0, | ||
34 | 253 | stdout=subprocess.PIPE, stderr=subprocess.STDOUT, | ||
35 | 254 | cwd=self._test_directory) | ||
36 | 255 | |||
37 | 242 | def test(self): | 256 | def test(self): |
38 | 243 | """Run the tests, log the results. | 257 | """Run the tests, log the results. |
39 | 244 | 258 | ||
40 | @@ -247,20 +261,9 @@ | |||
41 | 247 | the user the test results. | 261 | the user the test results. |
42 | 248 | """ | 262 | """ |
43 | 249 | self._logger.prepare() | 263 | self._logger.prepare() |
44 | 250 | |||
45 | 251 | call = self.build_test_command() | ||
46 | 252 | |||
47 | 253 | try: | 264 | try: |
56 | 254 | self._logger.write_line("Running %s" % (call,)) | 265 | popen = self._spawn_test_process() |
49 | 255 | # XXX: JonathanLange 2010-08-18: bufsize=-1 implies "use the | ||
50 | 256 | # system buffering", when we actually want no buffering at all. | ||
51 | 257 | popen = subprocess.Popen( | ||
52 | 258 | call, bufsize=-1, | ||
53 | 259 | stdout=subprocess.PIPE, stderr=subprocess.STDOUT, | ||
54 | 260 | cwd=self._test_directory) | ||
55 | 261 | |||
57 | 262 | self._gather_test_output(popen.stdout, self._logger) | 266 | self._gather_test_output(popen.stdout, self._logger) |
58 | 263 | |||
59 | 264 | exit_status = popen.wait() | 267 | exit_status = popen.wait() |
60 | 265 | except: | 268 | except: |
61 | 266 | self._logger.error_in_testrunner(sys.exc_info()) | 269 | self._logger.error_in_testrunner(sys.exc_info()) |
62 | @@ -277,6 +280,7 @@ | |||
63 | 277 | for line in input_stream: | 280 | for line in input_stream: |
64 | 278 | subunit_server.lineReceived(line) | 281 | subunit_server.lineReceived(line) |
65 | 279 | logger.got_line(line) | 282 | logger.got_line(line) |
66 | 283 | summary_stream.flush() | ||
67 | 280 | 284 | ||
68 | 281 | 285 | ||
69 | 282 | class Request: | 286 | class Request: |
70 | 283 | 287 | ||
71 | === modified file 'lib/devscripts/ec2test/tests/test_remote.py' | |||
72 | --- lib/devscripts/ec2test/tests/test_remote.py 2010-08-24 17:51:03 +0000 | |||
73 | +++ lib/devscripts/ec2test/tests/test_remote.py 2010-08-24 17:51:06 +0000 | |||
74 | @@ -29,6 +29,7 @@ | |||
75 | 29 | FlagFallStream, | 29 | FlagFallStream, |
76 | 30 | gunzip_data, | 30 | gunzip_data, |
77 | 31 | gzip_data, | 31 | gzip_data, |
78 | 32 | LaunchpadTester, | ||
79 | 32 | remove_pidfile, | 33 | remove_pidfile, |
80 | 33 | Request, | 34 | Request, |
81 | 34 | SummaryResult, | 35 | SummaryResult, |
82 | @@ -37,6 +38,69 @@ | |||
83 | 37 | ) | 38 | ) |
84 | 38 | 39 | ||
85 | 39 | 40 | ||
86 | 41 | class RequestHelpers: | ||
87 | 42 | |||
88 | 43 | def patch(self, obj, name, value): | ||
89 | 44 | orig = getattr(obj, name) | ||
90 | 45 | setattr(obj, name, value) | ||
91 | 46 | self.addCleanup(setattr, obj, name, orig) | ||
92 | 47 | return orig | ||
93 | 48 | |||
94 | 49 | def make_trunk(self, parent_url='http://example.com/bzr/trunk'): | ||
95 | 50 | """Make a trunk branch suitable for use with `Request`. | ||
96 | 51 | |||
97 | 52 | `Request` expects to be given a path to a working tree that has a | ||
98 | 53 | branch with a configured parent URL, so this helper returns such a | ||
99 | 54 | working tree. | ||
100 | 55 | """ | ||
101 | 56 | nick = parent_url.strip('/').split('/')[-1] | ||
102 | 57 | tree = self.make_branch_and_tree(nick) | ||
103 | 58 | tree.branch.set_parent(parent_url) | ||
104 | 59 | return tree | ||
105 | 60 | |||
106 | 61 | def make_request(self, branch_url=None, revno=None, | ||
107 | 62 | trunk=None, sourcecode_path=None, | ||
108 | 63 | emails=None, pqm_message=None): | ||
109 | 64 | """Make a request to test, specifying only things we care about. | ||
110 | 65 | |||
111 | 66 | Note that the returned request object will not ever send email, but | ||
112 | 67 | will instead log "sent" emails to `request.emails_sent`. | ||
113 | 68 | """ | ||
114 | 69 | if trunk is None: | ||
115 | 70 | trunk = self.make_trunk() | ||
116 | 71 | if sourcecode_path is None: | ||
117 | 72 | sourcecode_path = self.make_sourcecode( | ||
118 | 73 | [('a', 'http://example.com/bzr/a', 2), | ||
119 | 74 | ('b', 'http://example.com/bzr/b', 3), | ||
120 | 75 | ('c', 'http://example.com/bzr/c', 5)]) | ||
121 | 76 | request = Request( | ||
122 | 77 | branch_url, revno, trunk.basedir, sourcecode_path, emails, | ||
123 | 78 | pqm_message) | ||
124 | 79 | request.emails_sent = [] | ||
125 | 80 | request._send_email = request.emails_sent.append | ||
126 | 81 | return request | ||
127 | 82 | |||
128 | 83 | def make_sourcecode(self, branches): | ||
129 | 84 | """Make a sourcecode directory with sample branches. | ||
130 | 85 | |||
131 | 86 | :param branches: A list of (name, parent_url, revno) tuples. | ||
132 | 87 | :return: The path to the sourcecode directory. | ||
133 | 88 | """ | ||
134 | 89 | self.build_tree(['sourcecode/']) | ||
135 | 90 | for name, parent_url, revno in branches: | ||
136 | 91 | tree = self.make_branch_and_tree('sourcecode/%s' % (name,)) | ||
137 | 92 | tree.branch.set_parent(parent_url) | ||
138 | 93 | for i in range(revno): | ||
139 | 94 | tree.commit(message=str(i)) | ||
140 | 95 | return 'sourcecode/' | ||
141 | 96 | |||
142 | 97 | def make_logger(self, request=None, echo_to_stdout=False): | ||
143 | 98 | if request is None: | ||
144 | 99 | request = self.make_request() | ||
145 | 100 | return WebTestLogger( | ||
146 | 101 | 'full.log', 'summary.log', 'index.html', request, echo_to_stdout) | ||
147 | 102 | |||
148 | 103 | |||
149 | 40 | class TestFlagFallStream(TestCase): | 104 | class TestFlagFallStream(TestCase): |
150 | 41 | """Tests for `FlagFallStream`.""" | 105 | """Tests for `FlagFallStream`.""" |
151 | 42 | 106 | ||
152 | @@ -122,6 +186,104 @@ | |||
153 | 122 | self.assertEqual(1, len(flush_calls)) | 186 | self.assertEqual(1, len(flush_calls)) |
154 | 123 | 187 | ||
155 | 124 | 188 | ||
156 | 189 | class FakePopen: | ||
157 | 190 | """Fake Popen object so we don't have to spawn processes in tests.""" | ||
158 | 191 | |||
159 | 192 | def __init__(self, output, exit_status): | ||
160 | 193 | self.stdout = StringIO(output) | ||
161 | 194 | self._exit_status = exit_status | ||
162 | 195 | |||
163 | 196 | def wait(self): | ||
164 | 197 | return self._exit_status | ||
165 | 198 | |||
166 | 199 | |||
167 | 200 | class TestLaunchpadTester(TestCaseWithTransport, RequestHelpers): | ||
168 | 201 | |||
169 | 202 | def make_tester(self, logger=None, test_directory=None, test_options=()): | ||
170 | 203 | if not logger: | ||
171 | 204 | logger = self.make_logger() | ||
172 | 205 | if not test_directory: | ||
173 | 206 | test_directory = 'unspecified-test-directory' | ||
174 | 207 | return LaunchpadTester(logger, test_directory, test_options) | ||
175 | 208 | |||
176 | 209 | def test_build_test_command_no_options(self): | ||
177 | 210 | # The LaunchpadTester runs "make check" if given no options. | ||
178 | 211 | tester = self.make_tester() | ||
179 | 212 | command = tester.build_test_command() | ||
180 | 213 | self.assertEqual(['make', 'check'], command) | ||
181 | 214 | |||
182 | 215 | def test_build_test_command_options(self): | ||
183 | 216 | # The LaunchpadTester runs 'make check TESTOPTIONS="<options>"' if | ||
184 | 217 | # given options. | ||
185 | 218 | tester = self.make_tester(test_options=('-vvv', '--subunit')) | ||
186 | 219 | command = tester.build_test_command() | ||
187 | 220 | self.assertEqual( | ||
188 | 221 | ['make', 'check', 'TESTOPTS="-vvv --subunit"'], command) | ||
189 | 222 | |||
190 | 223 | def test_spawn_test_process(self): | ||
191 | 224 | # _spawn_test_process uses subprocess.Popen to run the command | ||
192 | 225 | # returned by build_test_command. stdout & stderr are piped together, | ||
193 | 226 | # the cwd is the test directory specified in the constructor, and the | ||
194 | 227 | # bufsize is zore, meaning "don't buffer". | ||
195 | 228 | popen_calls = [] | ||
196 | 229 | self.patch( | ||
197 | 230 | subprocess, 'Popen', | ||
198 | 231 | lambda *args, **kwargs: popen_calls.append((args, kwargs))) | ||
199 | 232 | tester = self.make_tester(test_directory='test-directory') | ||
200 | 233 | tester._spawn_test_process() | ||
201 | 234 | self.assertEqual( | ||
202 | 235 | [((tester.build_test_command(),), | ||
203 | 236 | {'bufsize': 0, | ||
204 | 237 | 'stdout': subprocess.PIPE, | ||
205 | 238 | 'stderr': subprocess.STDOUT, | ||
206 | 239 | 'cwd': 'test-directory'})], popen_calls) | ||
207 | 240 | |||
208 | 241 | def test_running_test(self): | ||
209 | 242 | # LaunchpadTester.test() runs the test command, and then calls | ||
210 | 243 | # got_result with the result. This test is more of a smoke test to | ||
211 | 244 | # make sure that everything integrates well. | ||
212 | 245 | message = {'Subject': "One Crowded Hour"} | ||
213 | 246 | request = self.make_request(pqm_message=message) | ||
214 | 247 | logger = self.make_logger(request=request) | ||
215 | 248 | tester = self.make_tester(logger=logger) | ||
216 | 249 | output = "test output\n" | ||
217 | 250 | tester._spawn_test_process = lambda: FakePopen(output, 0) | ||
218 | 251 | tester.test() | ||
219 | 252 | # Message being sent implies got_result thought it got a success. | ||
220 | 253 | self.assertEqual([message], request.emails_sent) | ||
221 | 254 | |||
222 | 255 | def test_error_in_testrunner(self): | ||
223 | 256 | # Any exception is raised within LaunchpadTester.test() is an error in | ||
224 | 257 | # the testrunner. When we detect these, we do three things: | ||
225 | 258 | # 1. Log the error to the logger using error_in_testrunner | ||
226 | 259 | # 2. Call got_result with a False value, indicating test suite | ||
227 | 260 | # failure. | ||
228 | 261 | # 3. Re-raise the error. In the script, this triggers an email. | ||
229 | 262 | message = {'Subject': "One Crowded Hour"} | ||
230 | 263 | request = self.make_request(pqm_message=message) | ||
231 | 264 | logger = self.make_logger(request=request) | ||
232 | 265 | tester = self.make_tester(logger=logger) | ||
233 | 266 | # Break the test runner deliberately. In production, this is more | ||
234 | 267 | # likely to be a system error than a programming error. | ||
235 | 268 | tester._spawn_test_process = lambda: 1/0 | ||
236 | 269 | self.assertRaises(ZeroDivisionError, tester.test) | ||
237 | 270 | # Message not being sent implies got_result thought it got a failure. | ||
238 | 271 | self.assertEqual([], request.emails_sent) | ||
239 | 272 | self.assertIn("ERROR IN TESTRUNNER", logger.get_summary_contents()) | ||
240 | 273 | self.assertIn("ZeroDivisionError", logger.get_summary_contents()) | ||
241 | 274 | |||
242 | 275 | def test_nonzero_exit_code(self): | ||
243 | 276 | message = {'Subject': "One Crowded Hour"} | ||
244 | 277 | request = self.make_request(pqm_message=message) | ||
245 | 278 | logger = self.make_logger(request=request) | ||
246 | 279 | tester = self.make_tester(logger=logger) | ||
247 | 280 | output = "test output\n" | ||
248 | 281 | tester._spawn_test_process = lambda: FakePopen(output, 10) | ||
249 | 282 | tester.test() | ||
250 | 283 | # Message not being sent implies got_result thought it got a failure. | ||
251 | 284 | self.assertEqual([], request.emails_sent) | ||
252 | 285 | |||
253 | 286 | |||
254 | 125 | class TestPidfileHelpers(TestCase): | 287 | class TestPidfileHelpers(TestCase): |
255 | 126 | """Tests for `write_pidfile` and `remove_pidfile`.""" | 288 | """Tests for `write_pidfile` and `remove_pidfile`.""" |
256 | 127 | 289 | ||
257 | @@ -163,63 +325,6 @@ | |||
258 | 163 | self.assertEqual(data, gunzip_data(compressed)) | 325 | self.assertEqual(data, gunzip_data(compressed)) |
259 | 164 | 326 | ||
260 | 165 | 327 | ||
261 | 166 | class RequestHelpers: | ||
262 | 167 | |||
263 | 168 | def make_trunk(self, parent_url='http://example.com/bzr/trunk'): | ||
264 | 169 | """Make a trunk branch suitable for use with `Request`. | ||
265 | 170 | |||
266 | 171 | `Request` expects to be given a path to a working tree that has a | ||
267 | 172 | branch with a configured parent URL, so this helper returns such a | ||
268 | 173 | working tree. | ||
269 | 174 | """ | ||
270 | 175 | nick = parent_url.strip('/').split('/')[-1] | ||
271 | 176 | tree = self.make_branch_and_tree(nick) | ||
272 | 177 | tree.branch.set_parent(parent_url) | ||
273 | 178 | return tree | ||
274 | 179 | |||
275 | 180 | def make_request(self, branch_url=None, revno=None, | ||
276 | 181 | trunk=None, sourcecode_path=None, | ||
277 | 182 | emails=None, pqm_message=None): | ||
278 | 183 | """Make a request to test, specifying only things we care about. | ||
279 | 184 | |||
280 | 185 | Note that the returned request object will not ever send email, but | ||
281 | 186 | will instead log "sent" emails to `request.emails_sent`. | ||
282 | 187 | """ | ||
283 | 188 | if trunk is None: | ||
284 | 189 | trunk = self.make_trunk() | ||
285 | 190 | if sourcecode_path is None: | ||
286 | 191 | sourcecode_path = self.make_sourcecode( | ||
287 | 192 | [('a', 'http://example.com/bzr/a', 2), | ||
288 | 193 | ('b', 'http://example.com/bzr/b', 3), | ||
289 | 194 | ('c', 'http://example.com/bzr/c', 5)]) | ||
290 | 195 | request = Request( | ||
291 | 196 | branch_url, revno, trunk.basedir, sourcecode_path, emails, | ||
292 | 197 | pqm_message) | ||
293 | 198 | request.emails_sent = [] | ||
294 | 199 | request._send_email = request.emails_sent.append | ||
295 | 200 | return request | ||
296 | 201 | |||
297 | 202 | def make_sourcecode(self, branches): | ||
298 | 203 | """Make a sourcecode directory with sample branches. | ||
299 | 204 | |||
300 | 205 | :param branches: A list of (name, parent_url, revno) tuples. | ||
301 | 206 | :return: The path to the sourcecode directory. | ||
302 | 207 | """ | ||
303 | 208 | self.build_tree(['sourcecode/']) | ||
304 | 209 | for name, parent_url, revno in branches: | ||
305 | 210 | tree = self.make_branch_and_tree('sourcecode/%s' % (name,)) | ||
306 | 211 | tree.branch.set_parent(parent_url) | ||
307 | 212 | for i in range(revno): | ||
308 | 213 | tree.commit(message=str(i)) | ||
309 | 214 | return 'sourcecode/' | ||
310 | 215 | |||
311 | 216 | def make_logger(self, request=None, echo_to_stdout=False): | ||
312 | 217 | if request is None: | ||
313 | 218 | request = self.make_request() | ||
314 | 219 | return WebTestLogger( | ||
315 | 220 | 'full.log', 'summary.log', 'index.html', request, echo_to_stdout) | ||
316 | 221 | |||
317 | 222 | |||
318 | 223 | class TestRequest(TestCaseWithTransport, RequestHelpers): | 328 | class TestRequest(TestCaseWithTransport, RequestHelpers): |
319 | 224 | """Tests for `Request`.""" | 329 | """Tests for `Request`.""" |
320 | 225 | 330 | ||
321 | @@ -437,12 +542,6 @@ | |||
322 | 437 | 542 | ||
323 | 438 | class TestWebTestLogger(TestCaseWithTransport, RequestHelpers): | 543 | class TestWebTestLogger(TestCaseWithTransport, RequestHelpers): |
324 | 439 | 544 | ||
325 | 440 | def patch(self, obj, name, value): | ||
326 | 441 | orig = getattr(obj, name) | ||
327 | 442 | setattr(obj, name, value) | ||
328 | 443 | self.addCleanup(setattr, obj, name, orig) | ||
329 | 444 | return orig | ||
330 | 445 | |||
331 | 446 | def test_make_in_directory(self): | 545 | def test_make_in_directory(self): |
332 | 447 | # WebTestLogger.make_in_directory constructs a logger that writes to a | 546 | # WebTestLogger.make_in_directory constructs a logger that writes to a |
333 | 448 | # bunch of specific files in a directory. | 547 | # bunch of specific files in a directory. |
It might be nice to:
- use fixtures rather than pushing those helpers around
- flush the detailed log too, so that tribunal stays up to date more.