Skip to content

Commit 2e080a8

Browse files
committed
Report subtests through the exception handlers
Subtest failures were buffered on the test case and replayed at the end of RunTest._run_core, bypassing testtools' own exception machinery. That lost several behaviours relative to a non-subtest failure: - a subclass overriding `skipException` had its skip reported as an error, since `subTest` caught the hardcoded `unittest.SkipTest` - `expectFailure` inside a subtest leaked the private `_ExpectedFailure` as an error instead of recording an expected failure - `MultipleExceptions` was reported verbatim rather than unpacked - details attached inside a failing subtest never reached the result - `AsynchronousDeferredRunTest._run_core` never drained the buffers, so a failing subtest reported as a pass Report each subtest as it completes instead, dispatching on the case's exception handlers and passing the details accumulated inside the block. Since reporting no longer lives in `_run_core`, the Twisted runner is covered too; it only needs to know not to also report success. Also align with unittest where the buffering had diverged. Follow-up to testing-cabal#630.
1 parent d4a6af3 commit 2e080a8

7 files changed

Lines changed: 440 additions & 87 deletions

File tree

‎NEWS‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ Changes and improvements to testtools_, grouped by release.
66
NEXT
77
~~~~
88

9+
Improvements
10+
------------
11+
* Add support for ``TestCase.subTest``, reporting each subtest with its own
12+
parameters. (Cyril Roelandt, #317)
13+
914
2.9.1
1015
~~~~~
1116

‎tests/test_testcase.py‎

Lines changed: 248 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@
5151
LogEvent,
5252
TestResult,
5353
)
54+
from testtools.testresult.real import (
55+
ExtendedToStreamDecorator,
56+
StreamSummary,
57+
)
5458

5559
from .helpers import (
5660
AsText,
@@ -2353,16 +2357,36 @@ def _run_case(self, case):
23532357
case.run(result)
23542358
return result
23552359

2360+
def _outcomes(self, result, case):
2361+
"""The (event name, subtest label) pairs from a run.
2362+
2363+
Labels are relative to ``case``'s own id, so that tests can assert on
2364+
the subtest description without depending on the enclosing qualname.
2365+
"""
2366+
prefix = case.id()
2367+
outcomes = []
2368+
for event in result._events:
2369+
if event[0] in ("startTest", "stopTest"):
2370+
continue
2371+
reported = event[1].id()
2372+
if reported.startswith(prefix):
2373+
reported = reported[len(prefix) :].lstrip()
2374+
outcomes.append((event[0], reported))
2375+
return outcomes
2376+
23562377
def test_passing_subtests(self):
23572378
class Case(TestCase):
23582379
def test_it(self):
23592380
for i in (0, 2, 4):
23602381
with self.subTest(i=i):
23612382
self.assertEqual(i % 2, 0)
23622383

2363-
result = self._run_case(Case("test_it"))
2364-
self.assertIn("addSuccess", [e[0] for e in result._events])
2365-
self.assertEqual([], result.failures)
2384+
case = Case("test_it")
2385+
result = self._run_case(case)
2386+
self.assertEqual(
2387+
[("addSuccess", "")],
2388+
self._outcomes(result, case),
2389+
)
23662390

23672391
def test_single_failure(self):
23682392
class Case(TestCase):
@@ -2371,11 +2395,12 @@ def test_it(self):
23712395
with self.subTest(i=i):
23722396
self.assertEqual(i % 2, 0)
23732397

2374-
result = self._run_case(Case("test_it"))
2375-
self.assertNotIn("addSuccess", [e[0] for e in result._events])
2376-
self.assertEqual(1, len(result.failures))
2377-
subtest = result.failures[0][0]
2378-
self.assertIn("(i=1)", str(subtest))
2398+
case = Case("test_it")
2399+
result = self._run_case(case)
2400+
self.assertEqual(
2401+
[("addFailure", "(i=1)")],
2402+
self._outcomes(result, case),
2403+
)
23792404

23802405
def test_multiple_failures(self):
23812406
class Case(TestCase):
@@ -2384,16 +2409,14 @@ def test_it(self):
23842409
with self.subTest(i=i):
23852410
self.assertEqual(i % 2, 0)
23862411

2387-
result = self._run_case(Case("test_it"))
2388-
self.assertEqual(2, len(result.failures))
2389-
descriptions = [str(f[0]) for f in result.failures]
2390-
self.assertTrue(
2391-
any("(i=1)" in d for d in descriptions),
2392-
f"Expected a failure for (i=1), got {descriptions}",
2393-
)
2394-
self.assertTrue(
2395-
any("(i=3)" in d for d in descriptions),
2396-
f"Expected a failure for (i=3), got {descriptions}",
2412+
case = Case("test_it")
2413+
result = self._run_case(case)
2414+
self.assertEqual(
2415+
[
2416+
("addFailure", "(i=1)"),
2417+
("addFailure", "(i=3)"),
2418+
],
2419+
self._outcomes(result, case),
23972420
)
23982421

23992422
def test_failure_continues_loop(self):
@@ -2418,34 +2441,38 @@ def test_it(self):
24182441
with self.subTest(msg="my label", x=42):
24192442
self.fail("boom")
24202443

2421-
result = self._run_case(Case("test_it"))
2422-
self.assertEqual(1, len(result.failures))
2423-
description = str(result.failures[0][0])
2424-
self.assertIn("[my label]", description)
2425-
self.assertIn("(x=42)", description)
2444+
case = Case("test_it")
2445+
result = self._run_case(case)
2446+
self.assertEqual(
2447+
[("addFailure", "[my label] (x=42)")],
2448+
self._outcomes(result, case),
2449+
)
24262450

24272451
def test_multiple_params(self):
24282452
class Case(TestCase):
24292453
def test_it(self):
24302454
with self.subTest(a=1, b="two"):
24312455
self.fail("boom")
24322456

2433-
result = self._run_case(Case("test_it"))
2434-
self.assertEqual(1, len(result.failures))
2435-
description = str(result.failures[0][0])
2436-
self.assertIn("a=1", description)
2437-
self.assertIn("b='two'", description)
2457+
case = Case("test_it")
2458+
result = self._run_case(case)
2459+
self.assertEqual(
2460+
[("addFailure", "(a=1, b='two')")],
2461+
self._outcomes(result, case),
2462+
)
24382463

24392464
def test_no_params(self):
24402465
class Case(TestCase):
24412466
def test_it(self):
24422467
with self.subTest():
24432468
self.fail("boom")
24442469

2445-
result = self._run_case(Case("test_it"))
2446-
self.assertEqual(1, len(result.failures))
2447-
description = str(result.failures[0][0])
2448-
self.assertIn("(<subtest>)", description)
2470+
case = Case("test_it")
2471+
result = self._run_case(case)
2472+
self.assertEqual(
2473+
[("addFailure", "(<subtest>)")],
2474+
self._outcomes(result, case),
2475+
)
24492476

24502477
def test_nested_subtests(self):
24512478
class Case(TestCase):
@@ -2456,16 +2483,16 @@ def test_it(self):
24562483
with self.subTest(b=b):
24572484
self.assertEqual(a, b)
24582485

2459-
result = self._run_case(Case("test_it"))
2460-
self.assertEqual(4, len(result.failures))
2461-
descriptions = [str(f[0]) for f in result.failures]
2462-
self.assertTrue(
2463-
any("a=1" in d and "b=3" in d for d in descriptions),
2464-
f"Expected a failure with both a=1 and b=3, got {descriptions}",
2465-
)
2466-
self.assertTrue(
2467-
any("a=2" in d and "b=4" in d for d in descriptions),
2468-
f"Expected a failure with both a=2 and b=4, got {descriptions}",
2486+
case = Case("test_it")
2487+
result = self._run_case(case)
2488+
self.assertEqual(
2489+
[
2490+
("addFailure", "(a=1, b=3)"),
2491+
("addFailure", "(a=1, b=4)"),
2492+
("addFailure", "(a=2, b=3)"),
2493+
("addFailure", "(a=2, b=4)"),
2494+
],
2495+
self._outcomes(result, case),
24692496
)
24702497

24712498
def test_skip_inside_subtest(self):
@@ -2482,9 +2509,186 @@ def test_it(self):
24822509
self.reached.append(i)
24832510

24842511
case = Case("test_it")
2485-
self._run_case(case)
2512+
result = self._run_case(case)
24862513
self.assertEqual([0, 2], case.reached)
2487-
self.assertEqual(1, len(case._subtest_skips))
2514+
# The skipped subtest is reported on its own; the parent is not
2515+
# additionally reported as a success.
2516+
self.assertEqual(
2517+
[("addSkip", "(i=1)")],
2518+
self._outcomes(result, case),
2519+
)
2520+
2521+
def test_skip_uses_custom_skip_exception(self):
2522+
class CustomSkip(Exception):
2523+
pass
2524+
2525+
class Case(TestCase):
2526+
skipException = CustomSkip
2527+
2528+
def test_it(self):
2529+
with self.subTest(i=1):
2530+
self.skipTest("skip this one")
2531+
2532+
case = Case("test_it")
2533+
result = self._run_case(case)
2534+
self.assertEqual(
2535+
[("addSkip", "(i=1)")],
2536+
self._outcomes(result, case),
2537+
)
2538+
2539+
def test_error_is_reported_as_error(self):
2540+
class Case(TestCase):
2541+
def test_it(self):
2542+
with self.subTest(i=1):
2543+
raise ValueError("boom")
2544+
2545+
case = Case("test_it")
2546+
result = self._run_case(case)
2547+
self.assertEqual(
2548+
[("addError", "(i=1)")],
2549+
self._outcomes(result, case),
2550+
)
2551+
2552+
def test_expected_failure_inside_subtest(self):
2553+
class Case(TestCase):
2554+
def test_it(self):
2555+
with self.subTest(i=1):
2556+
self.expectFailure("known bug", self.assertEqual, 1, 2)
2557+
2558+
case = Case("test_it")
2559+
result = self._run_case(case)
2560+
self.assertEqual(
2561+
[("addExpectedFailure", "(i=1)")],
2562+
self._outcomes(result, case),
2563+
)
2564+
2565+
def test_details_are_reported_with_the_subtest(self):
2566+
class Case(TestCase):
2567+
def test_it(self):
2568+
with self.subTest(i=1):
2569+
self.addDetail("clue", text_content("important diagnostic"))
2570+
self.fail("boom")
2571+
2572+
case = Case("test_it")
2573+
result = self._run_case(case)
2574+
[(_, _, details)] = [e for e in result._events if e[0] == "addFailure"]
2575+
self.assertEqual("important diagnostic", details["clue"].as_text())
2576+
2577+
def test_details_do_not_leak_to_the_parent(self):
2578+
class Case(TestCase):
2579+
def test_it(self):
2580+
with self.subTest(i=1):
2581+
self.addDetail("clue", text_content("subtest only"))
2582+
self.fail("boom")
2583+
self.assertNotIn("clue", self.getDetails())
2584+
2585+
case = Case("test_it")
2586+
result = self._run_case(case)
2587+
self.assertEqual(
2588+
[("addFailure", "(i=1)")],
2589+
self._outcomes(result, case),
2590+
)
2591+
2592+
def test_multiple_exceptions_are_unpacked(self):
2593+
class Case(TestCase):
2594+
def test_it(self):
2595+
with self.subTest(i=1):
2596+
raise MultipleExceptions(
2597+
(ValueError, ValueError("one"), None),
2598+
(KeyError, KeyError("two"), None),
2599+
)
2600+
2601+
case = Case("test_it")
2602+
result = self._run_case(case)
2603+
self.assertEqual(
2604+
[
2605+
("addError", "(i=1)"),
2606+
("addError", "(i=1)"),
2607+
],
2608+
self._outcomes(result, case),
2609+
)
2610+
2611+
def test_failfast_stops_at_first_failing_subtest(self):
2612+
class Case(TestCase):
2613+
def __init__(self, *args, **kwargs):
2614+
super().__init__(*args, **kwargs)
2615+
self.iterations = []
2616+
2617+
def test_it(self):
2618+
for i in range(4):
2619+
with self.subTest(i=i):
2620+
self.iterations.append(i)
2621+
self.fail("boom")
2622+
2623+
case = Case("test_it")
2624+
result = ExtendedTestResult()
2625+
result.failfast = True
2626+
case.run(result)
2627+
self.assertEqual([0], case.iterations)
2628+
self.assertEqual(
2629+
[("addFailure", "(i=0)")],
2630+
self._outcomes(result, case),
2631+
)
2632+
2633+
def test_keyboard_interrupt_propagates(self):
2634+
class Case(TestCase):
2635+
def test_it(self):
2636+
with self.subTest(i=1):
2637+
raise KeyboardInterrupt
2638+
2639+
self.assertRaises(KeyboardInterrupt, Case("test_it").run, ExtendedTestResult())
2640+
2641+
def test_subtest_is_not_runnable(self):
2642+
class Case(TestCase):
2643+
def test_it(self):
2644+
with self.subTest(i=1):
2645+
self.fail("boom")
2646+
2647+
case = Case("test_it")
2648+
result = self._run_case(case)
2649+
[(_, subtest, _)] = [e for e in result._events if e[0] == "addFailure"]
2650+
self.assertRaises(NotImplementedError, subtest.runTest)
2651+
2652+
def test_stream_result_counts_one_test(self):
2653+
# A subtest outcome is attached to the parent test rather than
2654+
# opening a stream record of its own, as unittest does.
2655+
class Case(TestCase):
2656+
def test_it(self):
2657+
with self.subTest(i=1):
2658+
self.fail("boom")
2659+
2660+
summary = StreamSummary()
2661+
decorator = ExtendedToStreamDecorator(summary)
2662+
decorator.startTestRun()
2663+
Case("test_it").run(decorator)
2664+
decorator.stopTestRun()
2665+
self.assertEqual(1, summary.testsRun)
2666+
self.assertEqual(False, summary.wasSuccessful())
2667+
2668+
def test_stream_result_counts_one_test_for_skip(self):
2669+
class Case(TestCase):
2670+
def test_it(self):
2671+
with self.subTest(i=1):
2672+
self.skipTest("skip this one")
2673+
2674+
summary = StreamSummary()
2675+
decorator = ExtendedToStreamDecorator(summary)
2676+
decorator.startTestRun()
2677+
Case("test_it").run(decorator)
2678+
decorator.stopTestRun()
2679+
self.assertEqual(1, summary.testsRun)
2680+
self.assertEqual(True, summary.wasSuccessful())
2681+
2682+
def test_str_matches_unittest_format(self):
2683+
class Case(TestCase):
2684+
def test_it(self):
2685+
with self.subTest(i=1):
2686+
self.fail("boom")
2687+
2688+
case = Case("test_it")
2689+
result = self._run_case(case)
2690+
[(_, subtest, _)] = [e for e in result._events if e[0] == "addFailure"]
2691+
self.assertEqual(f"{case} (i=1)", str(subtest))
24882692

24892693

24902694
def test_suite():

0 commit comments

Comments
 (0)