Why can a unittest self.assertRaises block pass for the wrong reason, and how do you tighten it?
answer
- It asserts less than people think
- How much code sits inside the block
- Subclasses match, and almost everything is one
- The class alone is not the contract
- Assert the aftermath, not only the raise
basics
~20 sA self.assertRaises block asserts only that some statement inside it raised that class or a subclass — not which line or which call. Setup code inside the block can keep the test green while the code under test never runs.
solid answer
~40 s`with self.assertRaises(ValueError):` asserts one thing: some statement in the block raised a `ValueError` or a subclass. It does not say which statement, which call, or why. So a block containing setup lines can pass because the fixture construction raised, while the function under test is never reached; and `assertRaises(Exception)` matches a `TypeError` from a changed signature or an `AttributeError` from a typo in the test itself, so it cannot tell a rejected input from a broken test. Tighten it by putting exactly one call inside the block and everything else above it, naming the narrowest exception class in the contract, pinning the message with `assertRaisesRegex` on a short stable fragment, and asserting the side effects after the block. The callable form, `self.assertRaises(ValueError, fn, arg)`, is structurally incapable of covering extra statements.
code
python · 20 linesimport unittest
def retention_days(config):
return int(config["retention"])
class RetentionTest(unittest.TestCase):
def test_too_broad(self):
with self.assertRaises(Exception):
config = {"retention_days": "30"}
retention_days(config)
def test_narrow(self):
config = {"retention": "thirty"}
with self.assertRaisesRegex(ValueError, "invalid literal"):
retention_days(config)
unittest.main(argv=["tests"], exit=False, verbosity=2)go deeper
Recall the with-statement form, that the assertion fails when nothing is raised, and that the block should contain the call you expect to fail and nothing else.
Explain that matching is by isinstance, so subclasses count, and that a wide block or a broad class makes the assertion pass for reasons unrelated to the behaviour under test.
Show how you would find this in an existing suite — a guard whose test can no longer reach it stays green for months — and how you tighten scope, class and message without making the assertion brittle on wording.
Own the review rule that a broad exception class in a test is a defect, and the practice that a rejection test also asserts the absence of side effects, so a safety guard cannot be silently removed.
### What the context manager actually promises `with self.assertRaises(ValueError):` asserts one thing and one thing only: *somewhere inside this block, an exception that is an instance of `ValueError` (or a subclass) was raised, and the block did not finish normally.* It says nothing about which statement raised it, which call produced it, or what the message was. If the block completes without raising, the assertion fails with `ValueError not raised`. That looseness is where false-green tests come from. ### The three ways it passes for the wrong reason **1. The block is too wide.** Setup lines inside the block can raise the expected class themselves. ```python with self.assertRaises(ValueError): config = load_config(path) # this line raises ValueError on a typo'd path archiver = Archiver(config) archiver.retain(batch) # the call under test never runs ``` The test is green, the function under test was never called, and it stays green after somebody deletes the guard you were testing. **2. The exception class is too broad.** `assertRaises(Exception)` matches subclasses, and almost everything is a subclass — including `TypeError` from a signature you just changed, `KeyError` from a renamed dictionary key, `AttributeError` from a typo in the test itself, and `NameError` from an import you removed. A test asserting `Exception` cannot distinguish "the validation rejected the input" from "the test is broken". **3. Nobody checks the message.** Two different code paths often raise the same class for very different reasons. Matching the class alone lets a test that was written for "batch exceeds the size cap" keep passing when the real cause has become "batch is not a list". Put together, this is how a guard disappears in production. A chat-transcript archiver caps how many messages one batch may hold, precisely so that a runaway producer cannot drive unbounded memory growth. The test wraps six lines in `assertRaises(Exception)`. A four-person team refactors the config loader; the loader now raises on the fixture the test builds, the block still raises, the suite is still green — and the cap has been unreachable for a month by the time RSS climbs in production. ### Tightening it * **One statement in the block.** Everything else — building the input, constructing the object — goes *above* the `with`. The block should contain only the call whose failure you are asserting. * **The narrowest class that is part of the contract.** If the code raises a custom exception, name it; `assertRaises(Exception)` and `assertRaises(BaseException)` should not survive review. * **Pin the message with `assertRaisesRegex(cls, pattern)`.** The pattern is searched, not fullmatched, so match a short stable fragment or an error code — never a whole sentence containing values, which turns the test brittle in the opposite direction. * **Assert the aftermath.** The interesting part of a rejection is usually its side effects: nothing was written, the queue length is unchanged, the connection was returned. Those assertions go *after* the `with` block, where they are ordinary assertions. * **Consider the callable form** — `self.assertRaises(ValueError, archiver.retain, oversized)` — when the call is a one-liner. It is structurally incapable of covering extra statements, which is exactly the defect the block form invites. The context-manager form earns its keep when the call needs a statement, spans lines, or has follow-up assertions. ### Two edges that surprise people `assertRaises` matches with `isinstance`, so **subclasses match**: asserting `OSError` will happily accept a `FileNotFoundError`, which is usually what you want but is not a precise assertion. And since exception groups arrived in Python 3.11, a `ValueError` raised *inside* an `ExceptionGroup` is **not** matched by `assertRaises(ValueError)` — the group is not an instance of the member's class, so it propagates and the test reports an error rather than a failure. Assert against `ExceptionGroup` (or the group class the code raises) and inspect the members. Finally, a passing `assertRaises` is the weakest possible evidence about behaviour, so treat it as a starting point: the strong test is "this input is rejected, with this message, and nothing was mutated". ### How to check a rejection test is real There is a cheap manual test for all of this, and it takes a minute: delete the guard in the code under test — the raise, the validation, the size check — and run the test. If it still passes, the test was never testing the guard, and you have found the defect before it costs you anything. Put the guard back. The same trick catches the too-wide block, the too-broad class and the assertion that is satisfied by a fixture error, without needing any tooling.
- What does assertRaises do if the block completes without raising anything?It fails, with a message naming the class that was expected — `ValueError not raised`. That is a failure rather than an error, because the context manager raises `TestCase.failureException` on exit. It is the one thing the assertion checks reliably, which is why a test whose block cannot reach the call under test is so dangerous: the raise it observes is real, just not the one you meant.
- When is the callable form, self.assertRaises(exc, fn, *args), preferable to the with block?When the call is a one-liner. The callable form can only ever execute that one call, so no setup statement can sneak into the assertion's scope. The block form earns its place when the failing operation is a statement rather than a call, spans several lines, or is followed by assertions about what did not happen — and then you keep the block to a single line anyway.
- How do you keep assertRaisesRegex from becoming brittle in the other direction?Match a short, stable fragment — an error code, a keyword, the name of the rejected field — rather than a whole sentence containing formatted values. The pattern is searched, not fullmatched, so a fragment suffices. Matching a full message written by a library couples your suite to that library's wording, and it will change.
- What should the test assert after the assertRaises block?The side effects the rejection promises: nothing was written, the counter did not move, the buffer is unchanged, the connection went back to the pool. A guard that raises but has already mutated state is still a bug, and a test that stops at the raise never sees it. Those are ordinary assertions placed after the block.
A wide assertRaises block is a burglar alarm wired to the whole street: it goes off reliably, and it never tells you which house was entered.
saying these in an interview costs you the question
- Wraps half the test body in one assertRaises block
- Asserts Exception or BaseException instead of a specific class
- Believes the block proves which line raised
- Thinks the assertion passes when nothing is raised
- Assumes assertRaisesRegex must match the whole message
- Stops at the raise and never asserts the side effects