Merge lp:~jml/testtools/forward-current-tags into lp:~testtools-committers/testtools/trunk

Proposed by Jonathan Lange
Status: Merged
Merged at revision: 248
Proposed branch: lp:~jml/testtools/forward-current-tags
Merge into: lp:~testtools-committers/testtools/trunk
Diff against target: 487 lines (+259/-27)
6 files modified
testtools/tags.py (+34/-0)
testtools/testresult/doubles.py (+21/-6)
testtools/testresult/real.py (+43/-14)
testtools/tests/__init__.py (+2/-0)
testtools/tests/test_tags.py (+84/-0)
testtools/tests/test_testresult.py (+75/-7)
To merge this branch: bzr merge lp:~jml/testtools/forward-current-tags
Reviewer Review Type Date Requested Status
testtools committers Pending
Review via email: mp+101538@code.launchpad.net

Commit message

Revamp testtools's tag support to correctly conform to the subunit standard and to have a rigorous test suite.

Description of the change

I tried to write a subunit tag filter and could not due to bugs in the way that tags behave in testtools. At around the same time, we received reports from the Launchpad developers that testtools TestResult and friends assume that tags are one continuous stream, and does not scope them for tests as described in the subunit README.

To address both of these issues, I wrote a bunch of tests that exercise the "tags" contract of TestResults. I've updated TestResult, MultiTestResult, ExtendedToOriginalDecorator, ExtendedTestResult, and ThreadsafeForwardingResult to implement this contract correctly. I don't think I've missed any.

While doing this I discovered that testtools was under the impression that Python 2.7 has tags support. It does not, and I removed the tests that suggested otherwise: it's a bug if code calls a Python 2.6 or 2.7 TestResult expecting tags to work.

To implement this better I made a TagContext object. I originally implemented this with functions alone (you can see the implementation at r253), but switched to a class as it seemed to be more readable to me.

To post a comment you must log in.
262. By Jonathan Lange

Fix typo. Thanks Gary.

263. By Jonathan Lange

Change get_parent() to simple attribute access.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== added file 'testtools/tags.py'
2--- testtools/tags.py 1970-01-01 00:00:00 +0000
3+++ testtools/tags.py 2012-04-12 16:29:22 +0000
4@@ -0,0 +1,34 @@
5+# Copyright (c) 2012 testtools developers. See LICENSE for details.
6+
7+"""Tag support."""
8+
9+
10+class TagContext(object):
11+ """A tag context."""
12+
13+ def __init__(self, parent=None):
14+ """Create a new TagContext.
15+
16+ :parent: If provided, uses this as the parent context. Any tags that
17+ are current on the parent at the time of construction are current
18+ in this context.
19+ """
20+ self.parent = parent
21+ self._tags = set()
22+ if parent:
23+ self._tags.update(parent.get_current_tags())
24+
25+ def get_current_tags(self):
26+ """Return any current tags."""
27+ return set(self._tags)
28+
29+ def change_tags(self, new_tags, gone_tags):
30+ """Change the tags on this context.
31+
32+ :param new_tags: A set of tags to add to this context.
33+ :param gone_tags: A set of tags to remove from this context.
34+ :return: The tags now current on this context.
35+ """
36+ self._tags.update(new_tags)
37+ self._tags.difference_update(gone_tags)
38+ return self.get_current_tags()
39
40=== modified file 'testtools/testresult/doubles.py'
41--- testtools/testresult/doubles.py 2012-01-10 16:16:45 +0000
42+++ testtools/testresult/doubles.py 2012-04-12 16:29:22 +0000
43@@ -9,6 +9,9 @@
44 ]
45
46
47+from testtools.tags import TagContext
48+
49+
50 class LoggingBase(object):
51 """Basic support for logging of results."""
52
53@@ -16,7 +19,6 @@
54 self._events = []
55 self.shouldStop = False
56 self._was_successful = True
57- self.current_tags = set()
58
59
60 class Python26TestResult(LoggingBase):
61@@ -45,10 +47,6 @@
62 def wasSuccessful(self):
63 return self._was_successful
64
65- def tags(self, new_tags, gone_tags):
66- self.current_tags.update(new_tags)
67- self.current_tags.difference_update(gone_tags)
68-
69
70 class Python27TestResult(Python26TestResult):
71 """A precisely python 2.7 like test result, that logs."""
72@@ -72,6 +70,10 @@
73 class ExtendedTestResult(Python27TestResult):
74 """A test result like the proposed extended unittest result API."""
75
76+ def __init__(self):
77+ super(ExtendedTestResult, self).__init__()
78+ self._tags = TagContext()
79+
80 def addError(self, test, err=None, details=None):
81 self._was_successful = False
82 self._events.append(('addError', test, err or details))
83@@ -105,9 +107,22 @@
84 def startTestRun(self):
85 super(ExtendedTestResult, self).startTestRun()
86 self._was_successful = True
87+ self._tags = TagContext()
88+
89+ def startTest(self, test):
90+ super(ExtendedTestResult, self).startTest(test)
91+ self._tags = TagContext(self._tags)
92+
93+ def stopTest(self, test):
94+ self._tags = self._tags.parent
95+ super(ExtendedTestResult, self).stopTest(test)
96+
97+ @property
98+ def current_tags(self):
99+ return self._tags.get_current_tags()
100
101 def tags(self, new_tags, gone_tags):
102- super(ExtendedTestResult, self).tags(new_tags, gone_tags)
103+ self._tags.change_tags(new_tags, gone_tags)
104 self._events.append(('tags', new_tags, gone_tags))
105
106 def time(self, time):
107
108=== modified file 'testtools/testresult/real.py'
109--- testtools/testresult/real.py 2012-02-09 17:52:15 +0000
110+++ testtools/testresult/real.py 2012-04-12 16:29:22 +0000
111@@ -16,6 +16,7 @@
112
113 from testtools.compat import all, str_is_unicode, _u
114 from testtools.content import TracebackContent
115+from testtools.tags import TagContext
116
117 # From http://docs.python.org/library/datetime.html
118 _ZERO = datetime.timedelta(0)
119@@ -171,10 +172,10 @@
120 super(TestResult, self).__init__()
121 self.skip_reasons = {}
122 self.__now = None
123+ self._tags = TagContext()
124 # -- Start: As per python 2.7 --
125 self.expectedFailures = []
126 self.unexpectedSuccesses = []
127- self.current_tags = set()
128 # -- End: As per python 2.7 --
129
130 def stopTestRun(self):
131@@ -183,6 +184,27 @@
132 New in python 2.7
133 """
134
135+ def startTest(self, test):
136+ super(TestResult, self).startTest(test)
137+ self._tags = TagContext(self._tags)
138+
139+ def stopTest(self, test):
140+ self._tags = self._tags.parent
141+ super(TestResult, self).stopTest(test)
142+
143+ @property
144+ def current_tags(self):
145+ """The currently set tags."""
146+ return self._tags.get_current_tags()
147+
148+ def tags(self, new_tags, gone_tags):
149+ """Add and remove tags from the test.
150+
151+ :param new_tags: A set of tags to be added to the stream.
152+ :param gone_tags: A set of tags to be removed from the stream.
153+ """
154+ self._tags.change_tags(new_tags, gone_tags)
155+
156 def time(self, a_datetime):
157 """Provide a timestamp to represent the current time.
158
159@@ -204,21 +226,12 @@
160 deprecated in favour of stopTestRun.
161 """
162
163- def tags(self, new_tags, gone_tags):
164- """Add and remove tags from the test.
165-
166- :param new_tags: A set of tags to be added to the stream.
167- :param gone_tags: A set of tags to be removed from the stream.
168- """
169- self.current_tags.update(new_tags)
170- self.current_tags.difference_update(gone_tags)
171-
172
173 class MultiTestResult(TestResult):
174 """A test result that dispatches to many test results."""
175
176 def __init__(self, *results):
177- TestResult.__init__(self)
178+ super(MultiTestResult, self).__init__()
179 self._results = list(map(ExtendedToOriginalDecorator, results))
180
181 def __repr__(self):
182@@ -231,9 +244,11 @@
183 for result in self._results)
184
185 def startTest(self, test):
186+ super(MultiTestResult, self).startTest(test)
187 return self._dispatch('startTest', test)
188
189 def stopTest(self, test):
190+ super(MultiTestResult, self).stopTest(test)
191 return self._dispatch('stopTest', test)
192
193 def addError(self, test, error=None, details=None):
194@@ -256,12 +271,14 @@
195 return self._dispatch('addUnexpectedSuccess', test, details=details)
196
197 def startTestRun(self):
198+ super(MultiTestResult, self).startTestRun()
199 return self._dispatch('startTestRun')
200
201 def stopTestRun(self):
202 return self._dispatch('stopTestRun')
203
204 def tags(self, new_tags, gone_tags):
205+ super(MultiTestResult, self).tags(new_tags, gone_tags)
206 return self._dispatch('tags', new_tags, gone_tags)
207
208 def time(self, a_datetime):
209@@ -417,6 +434,7 @@
210 test, details=details)
211
212 def startTestRun(self):
213+ super(ThreadsafeForwardingResult, self).startTestRun()
214 self.semaphore.acquire()
215 try:
216 self.result.startTestRun()
217@@ -446,6 +464,7 @@
218
219 def tags(self, new_tags, gone_tags):
220 """See `TestResult`."""
221+ super(ThreadsafeForwardingResult, self).tags(new_tags, gone_tags)
222 if self._test_start is not None:
223 self._test_tags = self._merge_tags(
224 self._test_tags, new_tags, gone_tags)
225@@ -475,6 +494,7 @@
226
227 def __init__(self, decorated):
228 self.decorated = decorated
229+ self._tags = TagContext()
230
231 def __repr__(self):
232 return '<%s %r>' % (self.__class__.__name__, self.decorated)
233@@ -571,6 +591,11 @@
234 _StringException(_details_to_str(details, special='traceback')),
235 None)
236
237+ @property
238+ def current_tags(self):
239+ return getattr(
240+ self.decorated, 'current_tags', self._tags.get_current_tags())
241+
242 def done(self):
243 try:
244 return self.decorated.done()
245@@ -588,9 +613,11 @@
246 return self.decorated.shouldStop
247
248 def startTest(self, test):
249+ self._tags = TagContext(self._tags)
250 return self.decorated.startTest(test)
251
252 def startTestRun(self):
253+ self._tags = TagContext()
254 try:
255 return self.decorated.startTestRun()
256 except AttributeError:
257@@ -600,6 +627,7 @@
258 return self.decorated.stop()
259
260 def stopTest(self, test):
261+ self._tags = self._tags.parent
262 return self.decorated.stopTest(test)
263
264 def stopTestRun(self):
265@@ -610,9 +638,10 @@
266
267 def tags(self, new_tags, gone_tags):
268 method = getattr(self.decorated, 'tags', None)
269- if method is None:
270- return
271- return method(new_tags, gone_tags)
272+ if method is not None:
273+ return method(new_tags, gone_tags)
274+ else:
275+ self._tags.change_tags(new_tags, gone_tags)
276
277 def time(self, a_datetime):
278 method = getattr(self.decorated, 'time', None)
279
280=== modified file 'testtools/tests/__init__.py'
281--- testtools/tests/__init__.py 2011-08-15 13:48:10 +0000
282+++ testtools/tests/__init__.py 2012-04-12 16:29:22 +0000
283@@ -19,6 +19,7 @@
284 test_run,
285 test_runtest,
286 test_spinner,
287+ test_tags,
288 test_testcase,
289 test_testresult,
290 test_testsuite,
291@@ -36,6 +37,7 @@
292 test_run,
293 test_runtest,
294 test_spinner,
295+ test_tags,
296 test_testcase,
297 test_testresult,
298 test_testsuite,
299
300=== added file 'testtools/tests/test_tags.py'
301--- testtools/tests/test_tags.py 1970-01-01 00:00:00 +0000
302+++ testtools/tests/test_tags.py 2012-04-12 16:29:22 +0000
303@@ -0,0 +1,84 @@
304+# Copyright (c) 2012 testtools developers. See LICENSE for details.
305+
306+"""Test tag support."""
307+
308+
309+from testtools import TestCase
310+from testtools.tags import TagContext
311+
312+
313+class TestTags(TestCase):
314+
315+ def test_no_tags(self):
316+ # A tag context has no tags initially.
317+ tag_context = TagContext()
318+ self.assertEqual(set(), tag_context.get_current_tags())
319+
320+ def test_add_tag(self):
321+ # A tag added with change_tags appears in get_current_tags.
322+ tag_context = TagContext()
323+ tag_context.change_tags(set(['foo']), set())
324+ self.assertEqual(set(['foo']), tag_context.get_current_tags())
325+
326+ def test_add_tag_twice(self):
327+ # Calling change_tags twice to add tags adds both tags to the current
328+ # tags.
329+ tag_context = TagContext()
330+ tag_context.change_tags(set(['foo']), set())
331+ tag_context.change_tags(set(['bar']), set())
332+ self.assertEqual(
333+ set(['foo', 'bar']), tag_context.get_current_tags())
334+
335+ def test_change_tags_returns_tags(self):
336+ # change_tags returns the current tags. This is a convenience.
337+ tag_context = TagContext()
338+ tags = tag_context.change_tags(set(['foo']), set())
339+ self.assertEqual(set(['foo']), tags)
340+
341+ def test_remove_tag(self):
342+ # change_tags can remove tags from the context.
343+ tag_context = TagContext()
344+ tag_context.change_tags(set(['foo']), set())
345+ tag_context.change_tags(set(), set(['foo']))
346+ self.assertEqual(set(), tag_context.get_current_tags())
347+
348+ def test_child_context(self):
349+ # A TagContext can have a parent. If so, its tags are the tags of the
350+ # parent at the moment of construction.
351+ parent = TagContext()
352+ parent.change_tags(set(['foo']), set())
353+ child = TagContext(parent)
354+ self.assertEqual(
355+ parent.get_current_tags(), child.get_current_tags())
356+
357+ def test_add_to_child(self):
358+ # Adding a tag to the child context doesn't affect the parent.
359+ parent = TagContext()
360+ parent.change_tags(set(['foo']), set())
361+ child = TagContext(parent)
362+ child.change_tags(set(['bar']), set())
363+ self.assertEqual(set(['foo', 'bar']), child.get_current_tags())
364+ self.assertEqual(set(['foo']), parent.get_current_tags())
365+
366+ def test_remove_in_child(self):
367+ # A tag that was in the parent context can be removed from the child
368+ # context without affect the parent.
369+ parent = TagContext()
370+ parent.change_tags(set(['foo']), set())
371+ child = TagContext(parent)
372+ child.change_tags(set(), set(['foo']))
373+ self.assertEqual(set(), child.get_current_tags())
374+ self.assertEqual(set(['foo']), parent.get_current_tags())
375+
376+ def test_parent(self):
377+ # The parent can be retrieved from a child context.
378+ parent = TagContext()
379+ parent.change_tags(set(['foo']), set())
380+ child = TagContext(parent)
381+ child.change_tags(set(), set(['foo']))
382+ self.assertEqual(parent, child.parent)
383+
384+
385+def test_suite():
386+ from unittest import TestLoader
387+ return TestLoader().loadTestsFromName(__name__)
388
389=== modified file 'testtools/tests/test_testresult.py'
390--- testtools/tests/test_testresult.py 2012-02-09 17:52:15 +0000
391+++ testtools/tests/test_testresult.py 2012-04-12 16:29:22 +0000
392@@ -134,12 +134,6 @@
393 result.stopTest(self)
394 self.assertTrue(result.wasSuccessful())
395
396- def test_tags(self):
397- # tags() does not fail the test run.
398- result = self.makeResult()
399- result.startTest(self)
400- result.tags(set([]), set([]))
401-
402
403 class Python27Contract(Python26Contract):
404
405@@ -192,7 +186,81 @@
406 result.stopTestRun()
407
408
409-class DetailsContract(Python27Contract):
410+class TagsContract(Python27Contract):
411+ """Tests to ensure correct tagging behaviour.
412+
413+ See the subunit docs for guidelines on how this is supposed to work.
414+ """
415+
416+ def test_no_tags_by_default(self):
417+ # Results initially have no tags.
418+ result = self.makeResult()
419+ self.assertEqual(frozenset(), result.current_tags)
420+
421+ def test_adding_tags(self):
422+ # Tags are added using 'tags' and thus become visible in
423+ # 'current_tags'.
424+ result = self.makeResult()
425+ result.tags(set(['foo']), set())
426+ self.assertEqual(set(['foo']), result.current_tags)
427+
428+ def test_removing_tags(self):
429+ # Tags are removed using 'tags'.
430+ result = self.makeResult()
431+ result.tags(set(['foo']), set())
432+ result.tags(set(), set(['foo']))
433+ self.assertEqual(set(), result.current_tags)
434+
435+ def test_startTestRun_resets_tags(self):
436+ # startTestRun makes a new test run, and thus clears all the tags.
437+ result = self.makeResult()
438+ result.tags(set(['foo']), set())
439+ result.startTestRun()
440+ self.assertEqual(set(), result.current_tags)
441+
442+ def test_add_tags_within_test(self):
443+ # Tags can be added after a test has run.
444+ result = self.makeResult()
445+ result.startTestRun()
446+ result.tags(set(['foo']), set())
447+ result.startTest(self)
448+ result.tags(set(['bar']), set())
449+ self.assertEqual(set(['foo', 'bar']), result.current_tags)
450+
451+ def test_tags_added_in_test_are_reverted(self):
452+ # Tags added during a test run are then reverted once that test has
453+ # finished.
454+ result = self.makeResult()
455+ result.startTestRun()
456+ result.tags(set(['foo']), set())
457+ result.startTest(self)
458+ result.tags(set(['bar']), set())
459+ result.addSuccess(self)
460+ result.stopTest(self)
461+ self.assertEqual(set(['foo']), result.current_tags)
462+
463+ def test_tags_removed_in_test(self):
464+ # Tags can be removed during tests.
465+ result = self.makeResult()
466+ result.startTestRun()
467+ result.tags(set(['foo']), set())
468+ result.startTest(self)
469+ result.tags(set(), set(['foo']))
470+ self.assertEqual(set(), result.current_tags)
471+
472+ def test_tags_removed_in_test_are_restored(self):
473+ # Tags removed during tests are restored once that test has finished.
474+ result = self.makeResult()
475+ result.startTestRun()
476+ result.tags(set(['foo']), set())
477+ result.startTest(self)
478+ result.tags(set(), set(['foo']))
479+ result.addSuccess(self)
480+ result.stopTest(self)
481+ self.assertEqual(set(['foo']), result.current_tags)
482+
483+
484+class DetailsContract(TagsContract):
485 """Tests for the details API of TestResults."""
486
487 def test_addExpectedFailure_details(self):

Subscribers

People subscribed via source and target branches