Merge lp:~jml/testtools/tsfr-fixup into lp:~testtools-committers/testtools/trunk

Proposed by Jonathan Lange on 2012-04-13
Status: Merged
Merged at revision: 252
Proposed branch: lp:~jml/testtools/tsfr-fixup
Merge into: lp:~testtools-committers/testtools/trunk
Diff against target: 465 lines (+271/-115)
2 files modified
testtools/testresult/real.py (+38/-33)
testtools/tests/test_testresult.py (+233/-82)
To merge this branch: bzr merge lp:~jml/testtools/tsfr-fixup
Reviewer Review Type Date Requested Status
testtools committers 2012-04-13 Pending
Review via email: mp+101898@code.launchpad.net

Description of the Change

After sitting staring confused at ThreadsafeForwardingResult and its tests, I decided to have a go at understanding them. Accordingly, I have built my understanding into the code.

Most of the changes are in the tests. I hope they make sense and more clearly show what TSFR actually does.

Implementation-wise, I moved _merge_tags out to function level, and I renamed _not_empty_tags to _any_tags, which is clearer to me.

There are two main things that I am unsure of in TSFR as is:

 1. Why not forward on stopTest(), rather than on addFoo()?

 2. Why are global tags specifically forwarded as global tags? Why not instead send them within the test context?

Correct me if I'm wrong, but...

  tags(A, B)
  startTest(t)
  tags(C, D)
  addSuccess(t)
  stopTest(t)
  tags(B, A)

Is equivalent to:

  startTest(t)
  tags(A, B)
  tags(C, D)
  addSuccess(t)
  stopTest(t)

And also to:

  startTest(t)
  tags(_merge_tags((A, B), (C, D)))
  addSuccess(t)
  stopTest(t)

So why have the extra complexity of maintaining global & local state for tags?

To post a comment you must log in.
lp:~jml/testtools/tsfr-fixup updated on 2012-04-13
263. By Jonathan Lange on 2012-04-13

Documentation tweaks.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'testtools/testresult/real.py'
2--- testtools/testresult/real.py 2012-04-13 09:42:40 +0000
3+++ testtools/testresult/real.py 2012-04-13 12:49:17 +0000
4@@ -356,26 +356,30 @@
5 class ThreadsafeForwardingResult(TestResult):
6 """A TestResult which ensures the target does not receive mixed up calls.
7
8- This is used when receiving test results from multiple sources, and batches
9- up all the activity for a single test into a thread-safe batch where all
10- other ThreadsafeForwardingResult objects sharing the same semaphore will be
11- locked out.
12-
13- Typical use of ThreadsafeForwardingResult involves creating one
14- ThreadsafeForwardingResult per thread in a ConcurrentTestSuite. These
15- forward to the TestResult that the ConcurrentTestSuite run method was
16- called with.
17-
18- target.done() is called once for each ThreadsafeForwardingResult that
19- forwards to the same target. If the target's done() takes special action,
20- care should be taken to accommodate this.
21+ Multiple ``ThreadsafeForwardingResult``s can forward to the same target
22+ result, and that target result will only ever receive the complete set of
23+ events for one test at a time.
24+
25+ This is enforced using a semaphore, which further guarantees that tests
26+ will be sent atomically even if the ``ThreadsafeForwardingResult``s are in
27+ different threads.
28+
29+ ``ThreadsafeForwardingResult`` is typically used by
30+ ``ConcurrentTestSuite``, which creates one ``ThreadsafeForwardingResult``
31+ per thread, each of which wraps of the TestResult that
32+ ``ConcurrentTestSuite.run()`` is called with.
33+
34+ target.startTestRun() and target.stopTestRun() are called once for each
35+ ThreadsafeForwardingResult that forwards to the same target. If the target
36+ takes special action on these events, it should take care to accommodate
37+ this.
38 """
39
40 def __init__(self, target, semaphore):
41 """Create a ThreadsafeForwardingResult forwarding to target.
42
43- :param target: A TestResult.
44- :param semaphore: A threading.Semaphore with limit 1.
45+ :param target: A ``TestResult``.
46+ :param semaphore: A ``threading.Semaphore`` with limit 1.
47 """
48 TestResult.__init__(self)
49 self.result = ExtendedToOriginalDecorator(target)
50@@ -387,27 +391,27 @@
51 def __repr__(self):
52 return '<%s %r>' % (self.__class__.__name__, self.result)
53
54- def _not_empty_tags(self, tags):
55- return bool(tags[0] and tags[1])
56+ def _any_tags(self, tags):
57+ return bool(tags[0] or tags[1])
58
59 def _add_result_with_semaphore(self, method, test, *args, **kwargs):
60 now = self._now()
61 self.semaphore.acquire()
62 try:
63 self.result.time(self._test_start)
64- if self._not_empty_tags(self._global_tags):
65+ if self._any_tags(self._global_tags):
66 self.result.tags(*self._global_tags)
67 try:
68 self.result.startTest(test)
69 self.result.time(now)
70- if self._not_empty_tags(self._test_tags):
71+ if self._any_tags(self._test_tags):
72 self.result.tags(*self._test_tags)
73 try:
74 method(test, *args, **kwargs)
75 finally:
76 self.result.stopTest(test)
77 finally:
78- if self._not_empty_tags(self._global_tags):
79+ if self._any_tags(self._global_tags):
80 self.result.tags(*reversed(self._global_tags))
81 finally:
82 self.semaphore.release()
83@@ -470,20 +474,21 @@
84 """See `TestResult`."""
85 super(ThreadsafeForwardingResult, self).tags(new_tags, gone_tags)
86 if self._test_start is not None:
87- self._test_tags = self._merge_tags(
88- self._test_tags, new_tags, gone_tags)
89+ self._test_tags = _merge_tags(
90+ self._test_tags, (new_tags, gone_tags))
91 else:
92- self._global_tags = self._merge_tags(
93- self._global_tags, new_tags, gone_tags)
94-
95- def _merge_tags(self, existing, new_tags, gone_tags):
96- result_new = set(existing[0])
97- result_gone = set(existing[1])
98- result_new.update(new_tags)
99- result_new.difference_update(gone_tags)
100- result_gone.update(gone_tags)
101- result_gone.difference_update(new_tags)
102- return result_new, result_gone
103+ self._global_tags = _merge_tags(
104+ self._global_tags, (new_tags, gone_tags))
105+
106+
107+def _merge_tags(existing, (new_tags, gone_tags)):
108+ result_new = set(existing[0])
109+ result_gone = set(existing[1])
110+ result_new.update(new_tags)
111+ result_new.difference_update(gone_tags)
112+ result_gone.update(gone_tags)
113+ result_gone.difference_update(new_tags)
114+ return result_new, result_gone
115
116
117 class ExtendedToOriginalDecorator(object):
118
119=== modified file 'testtools/tests/test_testresult.py'
120--- testtools/tests/test_testresult.py 2012-04-13 09:42:40 +0000
121+++ testtools/tests/test_testresult.py 2012-04-13 12:49:17 +0000
122@@ -59,6 +59,7 @@
123 )
124 from testtools.testresult.real import (
125 _details_to_str,
126+ _merge_tags,
127 utc,
128 )
129
130@@ -769,103 +770,253 @@
131 class TestThreadSafeForwardingResult(TestCase):
132 """Tests for `TestThreadSafeForwardingResult`."""
133
134- def setUp(self):
135- super(TestThreadSafeForwardingResult, self).setUp()
136- self.result_semaphore = threading.Semaphore(1)
137- self.target = LoggingResult([])
138- self.result1 = ThreadsafeForwardingResult(self.target,
139- self.result_semaphore)
140+ def make_results(self, n):
141+ events = []
142+ target = LoggingResult(events)
143+ semaphore = threading.Semaphore(1)
144+ return [
145+ ThreadsafeForwardingResult(target, semaphore)
146+ for i in range(n)], events
147
148 def test_nonforwarding_methods(self):
149 # startTest and stopTest are not forwarded because they need to be
150 # batched.
151- self.result1.startTest(self)
152- self.result1.stopTest(self)
153- self.assertEqual([], self.target._events)
154+ [result], events = self.make_results(1)
155+ result.startTest(self)
156+ result.stopTest(self)
157+ self.assertEqual([], events)
158+
159+ def test_tags_not_forwarded(self):
160+ # Tags need to be batched for each test, so they aren't forwarded
161+ # until a test runs.
162+ [result], events = self.make_results(1)
163+ result.tags(set(['foo']), set(['bar']))
164+ self.assertEqual([], events)
165+
166+ def test_global_tags_simple(self):
167+ # Tags specified outside of a test result are global. When a test's
168+ # results are finally forwarded, we send through these global tags
169+ # *as* global tags, and also send a tags command at the end of the
170+ # test that undoes it.
171+ [result], events = self.make_results(1)
172+ result.tags(set(['foo']), set())
173+ result.time(1)
174+ result.startTest(self)
175+ result.time(2)
176+ result.addSuccess(self)
177+ self.assertEqual(
178+ [('time', 1),
179+ ('tags', set(['foo']), set()),
180+ ('startTest', self),
181+ ('time', 2),
182+ ('addSuccess', self),
183+ ('stopTest', self),
184+ ('tags', set(), set(['foo'])),
185+ ], events)
186+
187+ def test_global_tags_complex(self):
188+ # Multiple calls to tags() in a global context are merged together.
189+ # Strictly speaking they could also be forwarded as multiple tags()
190+ # calls. The key thing is that they are not sent until the test is
191+ # done.
192+ [result], events = self.make_results(1)
193+ result.tags(set(['foo', 'bar']), set(['baz', 'qux']))
194+ result.tags(set(['cat', 'qux']), set(['bar', 'dog']))
195+ result.time(1)
196+ result.startTest(self)
197+ result.time(2)
198+ result.addSuccess(self)
199+ self.assertEqual(
200+ [('time', 1),
201+ ('tags', set(['cat', 'foo', 'qux']), set(['dog', 'bar', 'baz'])),
202+ ('startTest', self),
203+ ('time', 2),
204+ ('addSuccess', self),
205+ ('stopTest', self),
206+ ('tags', set(['dog', 'bar', 'baz']), set(['cat', 'foo', 'qux'])),
207+ ], events)
208+
209+ def test_local_tags(self):
210+ # Any tags set within a test context are forwarded in that test
211+ # context when the result is finally forwarded. This means that the
212+ # tags for the test are part of the atomic message communicating
213+ # everything about that test.
214+ [result], events = self.make_results(1)
215+ result.time(1)
216+ result.startTest(self)
217+ result.tags(set(['foo']), set([]))
218+ result.tags(set(), set(['bar']))
219+ result.time(2)
220+ result.addSuccess(self)
221+ self.assertEqual(
222+ [('time', 1),
223+ ('startTest', self),
224+ ('time', 2),
225+ ('tags', set(['foo']), set(['bar'])),
226+ ('addSuccess', self),
227+ ('stopTest', self),
228+ ], events)
229
230 def test_startTestRun(self):
231- self.result1.startTestRun()
232- self.result2 = ThreadsafeForwardingResult(self.target,
233- self.result_semaphore)
234- self.result2.startTestRun()
235- self.assertEqual(["startTestRun", "startTestRun"], self.target._events)
236+ # Calls to startTestRun are not batched, because we are only
237+ # interested in sending tests atomically, not the whole run.
238+ [result1, result2], events = self.make_results(2)
239+ result1.startTestRun()
240+ result2.startTestRun()
241+ self.assertEqual(["startTestRun", "startTestRun"], events)
242
243 def test_stopTestRun(self):
244- self.result1.stopTestRun()
245- self.result2 = ThreadsafeForwardingResult(self.target,
246- self.result_semaphore)
247- self.result2.stopTestRun()
248- self.assertEqual(["stopTestRun", "stopTestRun"], self.target._events)
249-
250- def test_forwarding_methods(self):
251- # error, failure, skip and success are forwarded in batches.
252- exc_info1 = make_exception_info(RuntimeError, 'error')
253- starttime1 = datetime.datetime.utcfromtimestamp(1.489)
254- endtime1 = datetime.datetime.utcfromtimestamp(51.476)
255- self.result1.time(starttime1)
256- self.result1.startTest(self)
257- self.result1.time(endtime1)
258- self.result1.addError(self, exc_info1)
259- exc_info2 = make_exception_info(AssertionError, 'failure')
260- starttime2 = datetime.datetime.utcfromtimestamp(2.489)
261- endtime2 = datetime.datetime.utcfromtimestamp(3.476)
262- self.result1.time(starttime2)
263- self.result1.startTest(self)
264- self.result1.time(endtime2)
265- self.result1.addFailure(self, exc_info2)
266+ # Calls to stopTestRun are not batched, because we are only
267+ # interested in sending tests atomically, not the whole run.
268+ [result1, result2], events = self.make_results(2)
269+ result1.stopTestRun()
270+ result2.stopTestRun()
271+ self.assertEqual(["stopTestRun", "stopTestRun"], events)
272+
273+ def test_forward_addError(self):
274+ # Once we receive an addError event, we forward all of the events for
275+ # that test, as we now know that test is complete.
276+ [result], events = self.make_results(1)
277+ exc_info = make_exception_info(RuntimeError, 'error')
278+ start_time = datetime.datetime.utcfromtimestamp(1.489)
279+ end_time = datetime.datetime.utcfromtimestamp(51.476)
280+ result.time(start_time)
281+ result.startTest(self)
282+ result.time(end_time)
283+ result.addError(self, exc_info)
284+ self.assertEqual([
285+ ('time', start_time),
286+ ('startTest', self),
287+ ('time', end_time),
288+ ('addError', self, exc_info),
289+ ('stopTest', self),
290+ ], events)
291+
292+ def test_forward_addFailure(self):
293+ # Once we receive an addFailure event, we forward all of the events
294+ # for that test, as we now know that test is complete.
295+ [result], events = self.make_results(1)
296+ exc_info = make_exception_info(AssertionError, 'failure')
297+ start_time = datetime.datetime.utcfromtimestamp(2.489)
298+ end_time = datetime.datetime.utcfromtimestamp(3.476)
299+ result.time(start_time)
300+ result.startTest(self)
301+ result.time(end_time)
302+ result.addFailure(self, exc_info)
303+ self.assertEqual([
304+ ('time', start_time),
305+ ('startTest', self),
306+ ('time', end_time),
307+ ('addFailure', self, exc_info),
308+ ('stopTest', self),
309+ ], events)
310+
311+ def test_forward_addSkip(self):
312+ # Once we receive an addSkip event, we forward all of the events for
313+ # that test, as we now know that test is complete.
314+ [result], events = self.make_results(1)
315 reason = _u("Skipped for some reason")
316- starttime3 = datetime.datetime.utcfromtimestamp(4.489)
317- endtime3 = datetime.datetime.utcfromtimestamp(5.476)
318- self.result1.time(starttime3)
319- self.result1.startTest(self)
320- self.result1.time(endtime3)
321- self.result1.addSkip(self, reason)
322- starttime4 = datetime.datetime.utcfromtimestamp(6.489)
323- endtime4 = datetime.datetime.utcfromtimestamp(7.476)
324- self.result1.time(starttime4)
325- self.result1.startTest(self)
326- self.result1.time(endtime4)
327- self.result1.addSuccess(self)
328+ start_time = datetime.datetime.utcfromtimestamp(4.489)
329+ end_time = datetime.datetime.utcfromtimestamp(5.476)
330+ result.time(start_time)
331+ result.startTest(self)
332+ result.time(end_time)
333+ result.addSkip(self, reason)
334 self.assertEqual([
335- ('time', starttime1),
336- ('startTest', self),
337- ('time', endtime1),
338- ('addError', self, exc_info1),
339- ('stopTest', self),
340- ('time', starttime2),
341- ('startTest', self),
342- ('time', endtime2),
343- ('addFailure', self, exc_info2),
344- ('stopTest', self),
345- ('time', starttime3),
346- ('startTest', self),
347- ('time', endtime3),
348+ ('time', start_time),
349+ ('startTest', self),
350+ ('time', end_time),
351 ('addSkip', self, reason),
352 ('stopTest', self),
353- ('time', starttime4),
354+ ], events)
355+
356+ def test_forward_addSuccess(self):
357+ # Once we receive an addSuccess event, we forward all of the events
358+ # for that test, as we now know that test is complete.
359+ [result], events = self.make_results(1)
360+ start_time = datetime.datetime.utcfromtimestamp(6.489)
361+ end_time = datetime.datetime.utcfromtimestamp(7.476)
362+ result.time(start_time)
363+ result.startTest(self)
364+ result.time(end_time)
365+ result.addSuccess(self)
366+ self.assertEqual([
367+ ('time', start_time),
368 ('startTest', self),
369- ('time', endtime4),
370+ ('time', end_time),
371 ('addSuccess', self),
372 ('stopTest', self),
373- ], self.target._events)
374-
375- def test_tags_helper(self):
376- expected = set(['present']), set(['missing', 'going'])
377- input = set(['present']), set(['missing'])
378- self.assertEqual(
379- expected, self.result1._merge_tags(input, set(), set(['going'])))
380- expected = set(['present']), set(['missing', 'going'])
381- input = set(['present', 'going']), set(['missing'])
382- self.assertEqual(
383- expected, self.result1._merge_tags(input, set(), set(['going'])))
384- expected = set(['coming', 'present']), set(['missing'])
385- input = set(['present']), set(['missing'])
386- self.assertEqual(
387- expected, self.result1._merge_tags(input, set(['coming']), set()))
388- expected = set(['coming', 'present']), set(['missing'])
389- input = set(['present']), set(['coming', 'missing'])
390- self.assertEqual(
391- expected, self.result1._merge_tags(input, set(['coming']), set()))
392+ ], events)
393+
394+ def test_only_one_test_at_a_time(self):
395+ # Even if there are multiple ThreadsafeForwardingResults forwarding to
396+ # the same target result, the target result only receives the complete
397+ # events for one test at a time.
398+ [result1, result2], events = self.make_results(2)
399+ test1, test2 = self, make_test()
400+ start_time1 = datetime.datetime.utcfromtimestamp(1.489)
401+ end_time1 = datetime.datetime.utcfromtimestamp(2.476)
402+ start_time2 = datetime.datetime.utcfromtimestamp(3.489)
403+ end_time2 = datetime.datetime.utcfromtimestamp(4.489)
404+ result1.time(start_time1)
405+ result2.time(start_time2)
406+ result1.startTest(test1)
407+ result2.startTest(test2)
408+ result1.time(end_time1)
409+ result2.time(end_time2)
410+ result2.addSuccess(test2)
411+ result1.addSuccess(test1)
412+ self.assertEqual([
413+ # test2 finishes first, and so is flushed first.
414+ ('time', start_time2),
415+ ('startTest', test2),
416+ ('time', end_time2),
417+ ('addSuccess', test2),
418+ ('stopTest', test2),
419+ # test1 finishes next, and thus follows.
420+ ('time', start_time1),
421+ ('startTest', test1),
422+ ('time', end_time1),
423+ ('addSuccess', test1),
424+ ('stopTest', test1),
425+ ], events)
426+
427+
428+class TestMergeTags(TestCase):
429+
430+ def test_merge_unseen_gone_tag(self):
431+ # If an incoming "gone" tag isn't currently tagged one way or the
432+ # other, add it to the "gone" tags.
433+ current_tags = set(['present']), set(['missing'])
434+ changing_tags = set(), set(['going'])
435+ expected = set(['present']), set(['missing', 'going'])
436+ self.assertEqual(
437+ expected, _merge_tags(current_tags, changing_tags))
438+
439+ def test_merge_incoming_gone_tag_with_current_new_tag(self):
440+ # If one of the incoming "gone" tags is one of the existing "new"
441+ # tags, then it overrides the "new" tag, leaving it marked as "gone".
442+ current_tags = set(['present', 'going']), set(['missing'])
443+ changing_tags = set(), set(['going'])
444+ expected = set(['present']), set(['missing', 'going'])
445+ self.assertEqual(
446+ expected, _merge_tags(current_tags, changing_tags))
447+
448+ def test_merge_unseen_new_tag(self):
449+ current_tags = set(['present']), set(['missing'])
450+ changing_tags = set(['coming']), set()
451+ expected = set(['coming', 'present']), set(['missing'])
452+ self.assertEqual(
453+ expected, _merge_tags(current_tags, changing_tags))
454+
455+ def test_merge_incoming_new_tag_with_current_gone_tag(self):
456+ # If one of the incoming "new" tags is currently marked as "gone",
457+ # then it overrides the "gone" tag, leaving it marked as "new".
458+ current_tags = set(['present']), set(['coming', 'missing'])
459+ changing_tags = set(['coming']), set()
460+ expected = set(['coming', 'present']), set(['missing'])
461+ self.assertEqual(
462+ expected, _merge_tags(current_tags, changing_tags))
463
464
465 class TestExtendedToOriginalResultDecoratorBase(TestCase):

Subscribers

People subscribed via source and target branches