Why can a failure inside unittest's TestCase.subTest still abort the loop?
answer
- the guard is narrower than it looks
- runner flags can override per-case reporting
- only what is inside the block
- reporting depends on the result object
- addSubTest missing means silent pass-through
basics
~20 sThree escapes exist: code outside the with block is unguarded, fail-fast mode stops the method at the first bad case, and a result object without an addSubTest method makes subTest degrade to a plain pass-through so the first failure propagates.
solid answer
~40 s`subTest` only guards what is lexically inside its block, so an exception raised in the loop header, in the code that builds the next row, or in a helper called between cases still unwinds the method and is reported once. Fail-fast — `python -m unittest -f`, or `failfast=True` on a runner — stops the method the moment a subtest fails, by design, so a loop reports one bad case instead of all of them. And per-case reporting depends on the result object implementing `addSubTest`: with a custom result class that does not, `subTest` yields with no guard at all and the first failure ends the method exactly as if the context manager were absent, with no warning. `KeyboardInterrupt` is also deliberately re-raised so Ctrl-C still works.
code
python · 16 linesimport io
import unittest
class OutsideGuardTest(unittest.TestCase):
def test_rows(self):
for text in ("1.5", "1,5", "2.5"):
value = float(text)
with self.subTest(text=text):
self.assertGreater(value, 0)
result = unittest.TextTestRunner(stream=io.StringIO()).run(
unittest.defaultTestLoader.loadTestsFromTestCase(OutsideGuardTest)
)
print(len(result.errors), len(result.failures))go deeper
Remember that only the indented code inside the with block is protected. Work done just above it, in the same loop, still ends the test method when it raises.
Be able to name the three escapes — unguarded code, fail-fast mode, a result object without addSubTest — and say which of them is silent. That last one is the detail interviewers are listening for.
Turn it into a diagnosis order when a table under-reports: check the block's boundary, then the runner flags, then the result class. Two of the three are answerable without opening the test.
Own the runner configuration as a reporting contract: fail-fast buys a fast red signal and costs per-case detail, and that tradeoff should be a deliberate decision per pipeline stage rather than an inherited flag.
## The guard is smaller than the loop `unittest.TestCase.subTest` protects the statements inside its `with` block and nothing else. The distinction is easy to lose when the block is the body of a loop, because the loop *looks* fully wrapped: ```python for text in rows: value = float(text) # unguarded with self.subTest(text=text): self.assertGreater(value, 0) ``` If a row makes `float(text)` raise, the exception escapes the method: one error is reported against the method, later rows never run, and the report names no row at all. The fix is to pull the work inside the block — which is also the right shape for a different reason, since a case that cannot even build its input is a case that failed. Any helper called between cases, and the loop's own iterator, live in the same unguarded space; a generator that raises while producing the next row aborts the method just as surely. ## Fail-fast turns the isolation off on purpose Fail-fast mode — `python -m unittest -f` / `--failfast`, `unittest.main(failfast=True)`, or the `failfast` argument to a runner — stops the run at the first failure. It applies to subtests too: after a case fails, `subTest` sees the fail-fast flag on the result and stops the enclosing method rather than continuing the loop. This is not a bug and not an interaction to work around; it is what fail-fast means. It does explain a confusing report, though: the same table yields three failing rows locally and one in a CI job configured with `-f`, and nothing about the code changed. When someone says "subtest reporting is broken on CI", the runner flags are the first thing to read. ## A result object that does not support subtests Per-case reporting is negotiated, not assumed. `subTest` checks whether the active result object provides an `addSubTest` method; if it does not — a hand-rolled `TestResult`-like class, an old integration shim between unittest and another reporting tool — then `subTest` simply yields with no executor around the body. The `with` block then does nothing whatsoever: the first failing assertion propagates and the method ends as if you had written a plain loop. The important part is that this degradation is **silent**. No warning is emitted, no attribute error is raised, and the code reads exactly like a working per-case loop. `unittest.TestResult` and the standard runners implement `addSubTest`, so the plain path is safe; the trap belongs to bespoke reporting integrations. The same pass-through applies when there is no active outcome at all — `subTest` used outside a running test does nothing rather than failing loudly. ## KeyboardInterrupt is deliberately not caught The executor around a subtest body catches everything except `KeyboardInterrupt`, which is re-raised. That keeps Ctrl-C working on a long-running table: without the exemption, an interrupt would be recorded as one more failing case and the loop would carry on to the next row, which is precisely the behaviour that makes people reach for `kill -9`. ## Widening the guard When the unguarded work is per-row — building the input, normalising a value, opening the fixture that row needs — the fix is not a wider `try` around the loop but moving the work inside the block: ```python for row in rows: with self.subTest(row=row.id): value = parse(row.text) # a row that cannot be built has failed self.assertGreater(value, 0) ``` A row whose input cannot even be constructed *is* a failing row, and reporting it as one is more honest than aborting the method and blaming nothing in particular. The work that legitimately stays outside the block is work shared by every row — and if that shared work is fragile enough to raise, it belongs in `setUp`, where the failure is reported as a fixture error against the method instead of being mistaken for a data problem in whichever row happened to be next. ## What to take from this The mental model to carry is that `subTest` is a *guard around a block* whose effect is negotiated with the result object and can be overridden by the runner — not a property of the loop. So when a table reports fewer cases than it has rows, work down three questions in order: is the failing work actually inside the block; is the run in fail-fast mode; and is the result object a standard one. Two of those three are visible without touching the test file at all, which is why this is a cheap diagnosis to run first.
- Why does the same case table report three failures locally and one on CI?Almost always fail-fast. A CI job invoking `python -m unittest -f`, or a runner constructed with `failfast=True`, stops the method at the first failing subtest by design, so the table reports one bad row and stops. Check the runner flags before you suspect the test: nothing in the code needs to differ for the two reports to differ.
- What happens if a custom result class has no addSubTest method?`subTest` detects that the result does not support subtests and yields with no guard, so the block behaves like plain inline code: the first failure propagates and ends the method. Nothing warns you — the code still reads like per-case reporting. It is the one failure mode of this feature that is invisible in the test file itself.
- Why is KeyboardInterrupt excluded from what a subtest block catches?So Ctrl-C still stops the run. If an interrupt were captured like any other exception it would be recorded as one failing case and the loop would proceed to the next row, making a long table effectively uninterruptible. Every other exception is captured and attributed to its case.
saying these in an interview costs you the question
- Assumes the whole loop body is guarded, not just the block
- Says fail-fast has no effect on subtests
- Expects a warning when the result lacks addSubTest
- Thinks Ctrl-C is captured as a failing case
- Blames the test when CI reports fewer cases than rows