Skip to content

Commit 454a836

Browse files
committed
Add support for unittest.TestCase.subTest
This has been supported since Python 3.4 [1]. Like the stdlib implementation, we report failures individually but treat the entire thing as a single "test" (so multiple failures in a single test method will still result in a `Failed: 1` summary). [1] https://docs.python.org/3/library/unittest.html#distinguishing-test-iterations-using-subtests Signed-off-by: Stephen Finucane <stephen@that.guru>
1 parent 0ba2f4d commit 454a836

File tree

2 files changed

+204
-0
lines changed

2 files changed

+204
-0
lines changed

tests/test_testresult.py

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1314,6 +1314,149 @@ def test_empty_detail_status_correct(self):
13141314
log._events,
13151315
)
13161316

1317+
def test_subtest_failure(self):
1318+
# Test that addSubTest collects failures and reports them in stopTest
1319+
1320+
# Create a mock subtest that mimics unittest's _SubTest
1321+
class MockSubTest:
1322+
def __init__(self, parent, description):
1323+
self._parent = parent
1324+
self._description = description
1325+
1326+
def id(self):
1327+
return f"{self._parent.id()} {self._description}"
1328+
1329+
def _subDescription(self):
1330+
return self._description
1331+
1332+
log = LoggingStreamResult()
1333+
result = ExtendedToStreamDecorator(log)
1334+
result.startTestRun()
1335+
now = datetime.datetime.now(utc)
1336+
result.time(now)
1337+
result.startTest(self)
1338+
1339+
# Simulate a failing subtest
1340+
subtest = MockSubTest(self, "(i=1)")
1341+
try:
1342+
raise AssertionError("subtest failed")
1343+
except AssertionError:
1344+
err = sys.exc_info()
1345+
result.addSubTest(self, subtest, err)
1346+
1347+
result.stopTest(self)
1348+
result.stopTestRun()
1349+
1350+
# Filter events to check structure
1351+
test_id = self.id()
1352+
events = log._events
1353+
1354+
# Should have: startTestRun, inprogress, traceback attachment, fail, stopTestRun
1355+
self.assertEqual(events[0], ("startTestRun",))
1356+
self.assertEqual(events[1].test_id, test_id)
1357+
self.assertEqual(events[1].test_status, "inprogress")
1358+
1359+
# The traceback attachment for the subtest
1360+
self.assertEqual(events[2].test_id, test_id)
1361+
self.assertEqual(events[2].file_name, "traceback (i=1)")
1362+
self.assertIn(b"AssertionError: subtest failed", events[2].file_bytes)
1363+
1364+
# The final fail status
1365+
self.assertEqual(events[3].test_id, test_id)
1366+
self.assertEqual(events[3].test_status, "fail")
1367+
1368+
self.assertEqual(events[4], ("stopTestRun",))
1369+
1370+
def test_subtest_success_no_events(self):
1371+
# Test that successful subtests don't generate events
1372+
class MockSubTest:
1373+
def __init__(self, parent, description):
1374+
self._parent = parent
1375+
self._description = description
1376+
1377+
def id(self):
1378+
return f"{self._parent.id()} {self._description}"
1379+
1380+
def _subDescription(self):
1381+
return self._description
1382+
1383+
log = LoggingStreamResult()
1384+
result = ExtendedToStreamDecorator(log)
1385+
result.startTestRun()
1386+
now = datetime.datetime.now(utc)
1387+
result.time(now)
1388+
result.startTest(self)
1389+
1390+
# Simulate a passing subtest (err=None)
1391+
subtest = MockSubTest(self, "(i=0)")
1392+
result.addSubTest(self, subtest, None)
1393+
1394+
# Simulate the success callback that unittest sends when all subtests pass
1395+
result.addSuccess(self)
1396+
result.stopTest(self)
1397+
result.stopTestRun()
1398+
1399+
test_id = self.id()
1400+
events = log._events
1401+
1402+
# Should have: startTestRun, inprogress, success, stopTestRun
1403+
# No subtest-specific events since it passed
1404+
self.assertEqual(events[0], ("startTestRun",))
1405+
self.assertEqual(events[1].test_id, test_id)
1406+
self.assertEqual(events[1].test_status, "inprogress")
1407+
self.assertEqual(events[2].test_id, test_id)
1408+
self.assertEqual(events[2].test_status, "success")
1409+
self.assertEqual(events[3], ("stopTestRun",))
1410+
1411+
def test_multiple_subtest_failures(self):
1412+
# Test that multiple subtest failures are all reported
1413+
class MockSubTest:
1414+
def __init__(self, parent, description):
1415+
self._parent = parent
1416+
self._description = description
1417+
1418+
def id(self):
1419+
return f"{self._parent.id()} {self._description}"
1420+
1421+
def _subDescription(self):
1422+
return self._description
1423+
1424+
log = LoggingStreamResult()
1425+
result = ExtendedToStreamDecorator(log)
1426+
result.startTestRun()
1427+
now = datetime.datetime.now(utc)
1428+
result.time(now)
1429+
result.startTest(self)
1430+
1431+
# Simulate two failing subtests
1432+
for i in [1, 2]:
1433+
subtest = MockSubTest(self, f"(i={i})")
1434+
try:
1435+
raise AssertionError(f"subtest {i} failed")
1436+
except AssertionError:
1437+
err = sys.exc_info()
1438+
result.addSubTest(self, subtest, err)
1439+
1440+
result.stopTest(self)
1441+
result.stopTestRun()
1442+
1443+
test_id = self.id()
1444+
events = log._events
1445+
1446+
# Should have: startTestRun, inprogress, 2x traceback, fail, stopTestRun
1447+
self.assertEqual(events[0], ("startTestRun",))
1448+
self.assertEqual(events[1].test_status, "inprogress")
1449+
1450+
# Two traceback attachments
1451+
self.assertEqual(events[2].file_name, "traceback (i=1)")
1452+
self.assertIn(b"subtest 1 failed", events[2].file_bytes)
1453+
self.assertEqual(events[3].file_name, "traceback (i=2)")
1454+
self.assertIn(b"subtest 2 failed", events[3].file_bytes)
1455+
1456+
# Final fail status
1457+
self.assertEqual(events[4].test_status, "fail")
1458+
self.assertEqual(events[5], ("stopTestRun",))
1459+
13171460

13181461
class TestResourcedToStreamDecorator(TestCase):
13191462
def setUp(self):

testtools/testresult/real.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2226,6 +2226,7 @@ def __init__(self, decorated: StreamResult) -> None:
22262226
self._started = False
22272227
self._tags: TagContext | None = None
22282228
self.__now: datetime.datetime | None = None
2229+
self._subtest_failures: list[tuple[unittest.TestCase, ExcInfo]] = []
22292230

22302231
def _get_failfast(self) -> bool:
22312232
return len(self.targets) == 2
@@ -2245,11 +2246,43 @@ def startTest(self, test: unittest.TestCase) -> None:
22452246
self.startTestRun()
22462247
self.status(test_id=test.id(), test_status="inprogress", timestamp=self._now())
22472248
self._tags = TagContext(self._tags)
2249+
self._subtest_failures = []
22482250

22492251
def stopTest(self, test: unittest.TestCase) -> None:
22502252
# NOTE: In Python 3.12.1 skipped tests may not call startTest()
22512253
if self._tags is not None:
22522254
self._tags = self._tags.parent
2255+
# If any subtests failed, emit the failure details and a fail status
2256+
# for the parent test. When all subtests pass, unittest calls
2257+
# addSuccess for the parent, so we don't need to emit a status here.
2258+
if self._subtest_failures:
2259+
test_id = test.id()
2260+
now = self._now()
2261+
# Emit traceback for each failed subtest as a file attachment
2262+
for subtest, err in self._subtest_failures:
2263+
# Use subtest description to create unique attachment name
2264+
subtest_desc = subtest._subDescription()
2265+
attachment_name = f"traceback {subtest_desc}"
2266+
content = TracebackContent(err, subtest)
2267+
mime_type = repr(content.content_type)
2268+
file_bytes = b"".join(content.iter_bytes())
2269+
self.status(
2270+
file_name=attachment_name,
2271+
file_bytes=file_bytes,
2272+
eof=True,
2273+
mime_type=mime_type,
2274+
test_id=test_id,
2275+
timestamp=now,
2276+
)
2277+
# Emit final fail status for the parent test
2278+
self.status(
2279+
test_id=test_id,
2280+
test_status="fail",
2281+
test_tags=self.current_tags,
2282+
timestamp=now,
2283+
)
2284+
# Clear subtest tracking
2285+
self._subtest_failures = []
22532286

22542287
def addError(
22552288
self,
@@ -2345,6 +2378,26 @@ def addSuccess(
23452378
) -> None:
23462379
self._convert(test, None, details, "success")
23472380

2381+
def addSubTest(
2382+
self,
2383+
test: unittest.TestCase,
2384+
subtest: unittest.TestCase,
2385+
err: ExcInfo | None,
2386+
) -> None:
2387+
"""Handle a subtest result.
2388+
2389+
This is called by unittest when a subtest completes. Subtest failures
2390+
are collected and reported as attachments to the parent test, so the
2391+
test count reflects only the parent test (matching unittest behavior).
2392+
2393+
:param test: The original test case.
2394+
:param subtest: The subtest instance (has its own id() method).
2395+
:param err: None if successful, exc_info tuple if failed.
2396+
"""
2397+
# Collect failures to report when the parent test completes
2398+
if err is not None:
2399+
self._subtest_failures.append((subtest, err))
2400+
23482401
def _check_args(self, err: ExcInfo | None, details: DetailsDict | None) -> None:
23492402
param_count = 0
23502403
if err is not None:
@@ -2700,6 +2753,14 @@ def addUnexpectedSuccess(
27002753
) -> None:
27012754
self.decorated.addUnexpectedSuccess(test, details=details)
27022755

2756+
def addSubTest(
2757+
self,
2758+
test: unittest.TestCase,
2759+
subtest: unittest.TestCase,
2760+
err: ExcInfo | None,
2761+
) -> None:
2762+
self.decorated.addSubTest(test, subtest, err)
2763+
27032764
def addDuration(self, test: unittest.TestCase, duration: float) -> None:
27042765
self.decorated.addDuration(test, duration)
27052766

0 commit comments

Comments
 (0)