Merge lp:~dpb/latch-test/dont-be-so-verbose into lp:latch-test

Proposed by David Britton
Status: Merged
Approved by: David Britton
Approved revision: 14
Merged at revision: 10
Proposed branch: lp:~dpb/latch-test/dont-be-so-verbose
Merge into: lp:latch-test
Diff against target: 360 lines (+173/-45)
2 files modified
latch.py (+54/-24)
test_latch.py (+119/-21)
To merge this branch: bzr merge lp:~dpb/latch-test/dont-be-so-verbose
Reviewer Review Type Date Requested Status
Alberto Donato (community) Approve
Free Ekanayaka (community) Approve
🤖 Landscape Builder test results Approve
Review via email: mp+270211@code.launchpad.net

Commit message

- Don't treat failed LP reads the same as an empty set read
- Don't post such big latch messages, people can just click the results link that latch sticks in there.

Description of the change

- Refactored code to make the failed read from launchpad actionable
- Stop posting full text of failure to MP
- Added test cases for refactored code (mostly overlap with existing ones)

Please feel free to flame me for testing what could be done better, mocking, etc. I didn't want to spend too much time here, but wanted to make a good change at the same time.

To post a comment you must log in.
Revision history for this message
🤖 Landscape Builder (landscape-builder) wrote :

Command: make test
Result: Success
Revno: 13
Branch: lp:~davidpbritton/latch-test/dont-be-so-verbose
Jenkins: https://ci.lscape.net/job/latch-test/2272/

review: Approve (test results)
Revision history for this message
Free Ekanayaka (free.ekanayaka) wrote :

Thanks for pushing an MP for this David, looks like a good change to me.

Re testing, yes, my recommendation is to avoid mocks, but since these tests already make use of them, it seems out of scope to fix that here, so +1

Unrelated comment, but it seems to me that it's not possible (or it would be very awkward) to save artifacts generated by latch-test, because of this code in test_cli:

        with temporary_directory() as temp_dir:
            environment.initialize(temp_dir)
            result = environment.execute_test()

which essentially purge everything after tests have run. Would it be possible to add support for specifying an artifacts directory path inside the temporary directory that will moved or copied to the working directory before purging everything? This way we can attach more state information about failed tests using Jenkins artifacts (something that is now pressing because of some flaky integration tests).

review: Approve
Revision history for this message
Alberto Donato (ack) wrote :

Looks good, +1

A few minor comments inline.

review: Approve
Revision history for this message
David Britton (dpb) wrote :

Added bug for free's comment: https://launchpad.net/bugs/1493373

Addressed all of alberto's in this branch.

Revision history for this message
🤖 Landscape Builder (landscape-builder) wrote :

There are additional revisions which have not been approved in review. Please seek review and approval of these new revisions.

Revision history for this message
🤖 Landscape Builder (landscape-builder) wrote :

No approved revision specified.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file 'latch.py'
--- latch.py 2015-04-23 18:43:19 +0000
+++ latch.py 2015-09-08 14:29:55 +0000
@@ -12,11 +12,20 @@
12import sys12import sys
13import tempfile13import tempfile
1414
15
15log = logging.getLogger(__name__)16log = logging.getLogger(__name__)
1617
1718
19class LaunchpadFetchError(Exception):
20 """Error fetching collection from Launchpad."""
21
22
18def fetch_collection(lp, collection):23def fetch_collection(lp, collection):
19 """Fetch a launchpad collection with proper batch awareness."""24 """Fetch a launchpad collection with proper batch awareness.
25
26 @param collection: Collection to read.
27 raises LaunchpadFetchError if any errors come from lp.
28 """
20 try:29 try:
21 if collection.total_size > 0:30 if collection.total_size > 0:
22 for entry in collection.entries:31 for entry in collection.entries:
@@ -25,16 +34,16 @@
25 collection = collection.next_collection34 collection = collection.next_collection
26 for entry in collection.entries:35 for entry in collection.entries:
27 yield lp.load(entry["self_link"])36 yield lp.load(entry["self_link"])
28 except Exception:37 except Exception as e:
29 log.exception("Error retrieving collection")38 log.exception("Error retrieving collection")
3039 raise LaunchpadFetchError(str(e))
3140
32def format_test_result(result, always_output=False):41
42def format_test_result(result, show_output=False):
33 """43 """
34 Format test results for printing/appending to an MP.44 Format test results for printing/appending to an MP.
3545
36 @param always_output True=always append, False=append46 @param show_output True=show, False=hide (default)
37 only on error
38 """47 """
39 string = u"Command: %(command)s\n" \48 string = u"Command: %(command)s\n" \
40 u"Result: %(status)s\n" \49 u"Result: %(status)s\n" \
@@ -42,7 +51,7 @@
42 u"Branch: %(branch)s" % result51 u"Branch: %(branch)s" % result
43 if 'BUILD_URL' in os.environ:52 if 'BUILD_URL' in os.environ:
44 string += u"\nJenkins: %s" % os.environ["BUILD_URL"]53 string += u"\nJenkins: %s" % os.environ["BUILD_URL"]
45 if always_output or result["status"] == u"Fail":54 if show_output:
46 string += u"\n-------------------------------------------\n"55 string += u"\n-------------------------------------------\n"
47 string += u"%s" % result["output"]56 string += u"%s" % result["output"]
48 return string57 return string
@@ -239,32 +248,53 @@
239 log.debug("-> Would be adding result: %s to %s" % (248 log.debug("-> Would be adding result: %s to %s" % (
240 result['status'], self.lp_candidate.web_link))249 result['status'], self.lp_candidate.web_link))
241250
251 def _get_revision_from_comment(self, comment):
252 """Given a comment on an MP, return the revision it tested.
253 0 will be returned if the comment does not apply."""
254 revision_tested = 0
255 if self.lp.me != comment.author:
256 return 0
257 m = re.search("Revno: (.*)", comment.message_body[:1024])
258 if m is None:
259 log.warning("comment (%s) from %s missing revno, skipping" % (
260 comment.self_link, self.lp.me))
261 return 0
262 stored_revision = m.group(1).strip()
263 try:
264 revision_tested = int(stored_revision)
265 except ValueError:
266 log.warning("comment (%s) revno parse error, skipping" % (
267 comment.self_link))
268 return 0
269 return revision_tested
270
242 def are_test_results_stale(self):271 def are_test_results_stale(self):
243 """272 """
244 Evaluate candidate to determine if latest results are stale. Return273 Evaluate candidate to determine if latest results are stale. Return
245 boolean evaluation. Each result is checked for the stored revision274 boolean evaluation. Each result is checked for the stored revision
246 number and compared to the source_branch revno.275 number and compared to the source_branch revno.
276 Error conditions are counted as not stale so as not to trigger
277 retests over and over.
247 """278 """
248 revision_tested = 0279 revision_tested = 0
249 log.debug(280 log.debug(
250 "-> current revno: r%s" % (281 "-> current revno: r%s" % (
251 self.lp_candidate.source_branch.revision_count))282 self.lp_candidate.source_branch.revision_count))
252 for comment in fetch_collection(self.lp,283
253 self.lp_candidate.all_comments):284 revision_tested = 0
254 if self.lp.me != comment.author:285 try:
255 continue286 # Since comments are always in chronological order, we can
256 m = re.search("Revno: (.*)", comment.message_body[:1024])287 # stop after we hit a match going backward
257 if m is None:288 for comment in reversed(fetch_collection(
258 log.warning("comment (%s) from %s missing revno, skipping" % (289 self.lp, self.lp_candidate.all_comments)):
259 comment.self_link, self.lp.me))290 revision_tested = self._get_revision_from_comment(comment)
260 continue291 if revision_tested > 0:
261 stored_revision = m.group(1).strip()292 break
262 try:293 except LaunchpadFetchError:
263 revision_tested = max(revision_tested, int(stored_revision))294 log.exception("Error fetching comments from Launchpad on %s" % (
264 except ValueError:295 self.lp_candidate.web_link))
265 log.warning("comment (%s) revno parse error, skipping" % (296 log.warning("Skipping candidate: %s")
266 comment.self_link))297 return False
267 continue
268298
269 if revision_tested == 0:299 if revision_tested == 0:
270 log.debug("-> results are stale, tested_revision == 0")300 log.debug("-> results are stale, tested_revision == 0")
271301
=== modified file 'test_latch.py'
--- test_latch.py 2015-04-23 16:01:38 +0000
+++ test_latch.py 2015-09-08 14:29:55 +0000
@@ -3,19 +3,45 @@
3import latch3import latch
4import unittest4import unittest
5from mock import MagicMock, patch5from mock import MagicMock, patch
6import logging
7from logging.handlers import BufferingHandler
6import os8import os
7import random
8from subprocess import PIPE, STDOUT, check_call, CalledProcessError9from subprocess import PIPE, STDOUT, check_call, CalledProcessError
910
10OUTPUT = u"output ✈"11OUTPUT = u"output ✈"
11MULTILINE_OUTPUT = u"multiline\noutput ✈"12MULTILINE_OUTPUT = u"multiline\noutput ✈"
1213
14FAKE_ME = MagicMock()
15FAKE_ME.name = "fake-user"
16
1317
14def fetch_collection(self, collection):18def fetch_collection(self, collection):
15 """Return collection directly."""19 """Return collection directly."""
16 return collection20 return collection
1721
1822
23def get_fake_comment(body=None, revno=1, author_name=FAKE_ME.name):
24 """Return a fake comment.
25 @param body: If set, put this in the body, otherwise, formulate the body
26 as a latch comment with a Revno:
27 @param revno: revision number
28 @param author_name: author name
29 """
30 fake_comment = MagicMock()
31 if author_name == FAKE_ME.name:
32 fake_author = FAKE_ME
33 else:
34 fake_author = MagicMock()
35 fake_author.name = author_name
36 fake_comment.author = fake_author
37
38 if body is None:
39 fake_comment.message_body = "Revno: %s" % revno
40 else:
41 fake_comment.message_body = body
42 return fake_comment
43
44
19def add_results(candidate, revision, author):45def add_results(candidate, revision, author):
20 """46 """
21 Add fake test results to a candidate, one for each revision in47 Add fake test results to a candidate, one for each revision in
@@ -23,7 +49,6 @@
23 """49 """
24 candidate.all_comments = []50 candidate.all_comments = []
25 revisions = range(1, revision + 1)51 revisions = range(1, revision + 1)
26 random.shuffle(revisions)
27 for revno in revisions:52 for revno in revisions:
28 comment = MagicMock()53 comment = MagicMock()
29 comment.message_body = "Revno: %s" % revno54 comment.message_body = "Revno: %s" % revno
@@ -89,17 +114,61 @@
89 return (source, target)114 return (source, target)
90115
91116
117def raises_launchpad_fetch_error(*args):
118 raise latch.LaunchpadFetchError("This is an Error.")
119
120
92# For all tests, bypass fetch_collection and just return raw data121# For all tests, bypass fetch_collection and just return raw data
93latch.fetch_collection = fetch_collection122latch.fetch_collection = fetch_collection
94123
95124
125class TestLogHandler(BufferingHandler):
126 """LogHandler so unit tests can inspect logs written."""
127
128 def __init__(self):
129 super(TestLogHandler, self).__init__(0)
130 self.buffer = ()
131
132 def shouldFlush(self):
133 return False
134
135 def emit(self, record):
136 self.buffer += (record.levelname, record.msg),
137 self.buffer += ("ALL", record.msg),
138
139
140class LoggedTest(unittest.TestCase):
141 """Testing class that can track emitted logs"""
142
143 def setUp(self):
144 super(LoggedTest, self).setUp()
145 self.handler = TestLogHandler()
146 self.logger = logging.getLogger(latch.__name__)
147 self.logger.addHandler(self.handler)
148
149 def tearDown(self):
150 self.logger.removeHandler(self.handler)
151 self.handler.close()
152
153 def assertInLogs(self, message, level="ALL"):
154 """Assert that a message is logged. Substrings are OK.
155
156 Optionally restict search to a log level of INFO, WARNING and ERROR.
157 """
158 for log_level, log_message in self.handler.buffer:
159 if level == log_level:
160 if message in log_message:
161 return
162 raise AssertionError("String '%s' not found in %s" % (
163 message, self.handler.buffer))
164
165
96class LatchTest(unittest.TestCase):166class LatchTest(unittest.TestCase):
97167
98 def setUp(self):168 def setUp(self):
99 self.latch = latch.Latch()169 self.latch = latch.Latch()
100 self.latch.lp = MagicMock()170 self.latch.lp = MagicMock()
101 self.latch.lp.me = MagicMock()171 self.latch.lp.me = FAKE_ME
102 self.latch.lp.me.name = "fake-user"
103172
104 def test_scan_branch_nothing(self):173 def test_scan_branch_nothing(self):
105 """Scan list of candidates, nothing should match."""174 """Scan list of candidates, nothing should match."""
@@ -125,7 +194,7 @@
125 def setUp(self):194 def setUp(self):
126 self.latch = latch.Latch()195 self.latch = latch.Latch()
127 self.latch.lp = MagicMock()196 self.latch.lp = MagicMock()
128 self.latch.lp.me.name = "fake-user"197 self.latch.lp.me = FAKE_ME
129198
130 def test__lookup_config(self):199 def test__lookup_config(self):
131 """section and key/value exist in config."""200 """section and key/value exist in config."""
@@ -157,14 +226,14 @@
157 self.latch._lookup_config(config, candidate, "missing_key")226 self.latch._lookup_config(config, candidate, "missing_key")
158227
159228
160class CandidateTest(unittest.TestCase):229class CandidateTest(LoggedTest):
161230
162 def setUp(self):231 def setUp(self):
232 super(CandidateTest, self).setUp()
163 # Create a candidate object for testing, individual tests233 # Create a candidate object for testing, individual tests
164 # will need to fill out the lp_candidate appropriately234 # will need to fill out the lp_candidate appropriately
165 self.lp = MagicMock()235 self.lp = MagicMock()
166 self.lp.me = MagicMock()236 self.lp.me = FAKE_ME
167 self.lp.me.name = "fake-user"
168 self.candidate = latch.Candidate(self.lp, MagicMock())237 self.candidate = latch.Candidate(self.lp, MagicMock())
169238
170 def test_add_reviewer_readonly(self):239 def test_add_reviewer_readonly(self):
@@ -240,6 +309,43 @@
240 "This is a test.")309 "This is a test.")
241 self.assertTrue(self.candidate.are_test_results_stale())310 self.assertTrue(self.candidate.are_test_results_stale())
242311
312 def test_are_test_results_stale_lp_error(self):
313 """Test results revno == source branch."""
314 self.candidate.lp_candidate.source_branch.revision_count = 1
315 add_results(self.candidate.lp_candidate, 1, self.lp.me)
316 old_fetch_collection = latch.fetch_collection
317 try:
318 latch.fetch_collection = raises_launchpad_fetch_error
319 self.assertFalse(
320 self.candidate.are_test_results_stale())
321 self.assertInLogs("Error fetching comments from Launchpad")
322 self.assertInLogs("Skipping candidate")
323 finally:
324 latch.fetch_collection = old_fetch_collection
325
326 def test_get_revision_from_comment(self):
327 """Test comment happy path, it's one of ours, and it has a revno."""
328 comment = get_fake_comment(revno=100)
329 self.assertEqual(
330 self.candidate._get_revision_from_comment(comment), 100)
331
332 def test_get_revision_from_comment_different_author(self):
333 """Test comment filter: comment not from self.lp.me returns 0."""
334 comment = get_fake_comment(author_name="someone-else")
335 self.assertEqual(self.candidate._get_revision_from_comment(comment), 0)
336
337 def test_get_revision_from_comment_no_revno(self):
338 """Test comment filter: revno missing from comment."""
339 comment = get_fake_comment(body="foobar")
340 self.assertEqual(self.candidate._get_revision_from_comment(comment), 0)
341 self.assertInLogs("missing revno, skipping")
342
343 def test_get_revision_from_comment_revno_parse_error(self):
344 """Test comment filter: revno not an int."""
345 comment = get_fake_comment(revno="foobar")
346 self.assertEqual(self.candidate._get_revision_from_comment(comment), 0)
347 self.assertInLogs("revno parse error, skipping")
348
243 def test_add_result_readonly(self):349 def test_add_result_readonly(self):
244 """if readonly, should NOT try to create a comment."""350 """if readonly, should NOT try to create a comment."""
245 result = {351 result = {
@@ -293,25 +399,17 @@
293399
294 def test_format_test_result_force_include_output(self):400 def test_format_test_result_force_include_output(self):
295 """Passing result, output requested to appear."""401 """Passing result, output requested to appear."""
296 string = latch.format_test_result(self.result, always_output=True)402 string = latch.format_test_result(self.result, show_output=True)
297 self.assertIn(u"Result: Success", string)403 self.assertIn(u"Result: Success", string)
298 self.assertIn(MULTILINE_OUTPUT, string)404 self.assertIn(MULTILINE_OUTPUT, string)
299 self.assertNotIn(u"Jenkins:", string)405 self.assertNotIn(u"Jenkins:", string)
300406
301 def test_format_test_result_include_output_with_failure(self):407 def test_format_test_result_with_failure_no_output(self):
302 """Failing result, output should appear."""408 """Failing result, output should not appear since not requested."""
303 self.result["status"] = "Fail"409 self.result["status"] = "Fail"
304 string = latch.format_test_result(self.result)410 string = latch.format_test_result(self.result)
305 self.assertIn(u"Revno: 1234", string)411 self.assertIn(u"Revno: 1234", string)
306 self.assertIn(MULTILINE_OUTPUT, string)412 self.assertNotIn(MULTILINE_OUTPUT, string)
307 self.assertNotIn(u"Jenkins:", string)
308
309 def test_format_test_result_include_output_with_failure_and_request(self):
310 """Failing result, output should appear, even if requested."""
311 self.result["status"] = "Fail"
312 string = latch.format_test_result(self.result, always_output=True)
313 self.assertIn(u"Revno: 1234", string)
314 self.assertIn(MULTILINE_OUTPUT, string)
315 self.assertNotIn(u"Jenkins:", string)413 self.assertNotIn(u"Jenkins:", string)
316414
317 def test_format_test_result_jenkins_build_url(self):415 def test_format_test_result_jenkins_build_url(self):
@@ -333,7 +431,7 @@
333class TestEnvironmentTest(unittest.TestCase):431class TestEnvironmentTest(unittest.TestCase):
334 def setUp(self):432 def setUp(self):
335 self.lp = MagicMock()433 self.lp = MagicMock()
336 self.lp.me.name = "fake-user"434 self.lp.me = FAKE_ME
337 self.environment = latch.TestEnvironment(435 self.environment = latch.TestEnvironment(
338 latch.Candidate(self.lp, get_candidate(self.lp.me.name)),436 latch.Candidate(self.lp, get_candidate(self.lp.me.name)),
339 self.lp,437 self.lp,

Subscribers

People subscribed via source and target branches

to all changes: