Why does unittest's assertWarns see a warning that a plain catch_warnings block misses?
answer
- Warnings are filtered, not raised
- The default action shows one per location
- A per-module registry remembers what was shown
- catch_warnings only saves and restores
- assertWarns clears registries and filters always
basics
~20 sBecause warnings deduplicates. The default filter shows a given warning once per source location and remembers that in each module's __warningregistry__. assertWarns clears those registries and installs an "always" filter, so the warning is seen on every run.
solid answer
~40 s`warnings.warn` consults the global filter list, and the default action shows a warning **once per (message, category, module, line)** — the fact that it has already been shown is cached in the emitting module's `__warningregistry__`, which lives as long as the process. So a hand-rolled `with warnings.catch_warnings(record=True) as seen:` around code that already warned earlier in the test run records nothing, and the test passes or fails depending on test order. `unittest.TestCase.assertWarns` avoids both traps: on entry it wipes the `__warningregistry__` of every loaded module and installs `simplefilter("always", category)` inside a `catch_warnings(record=True)`, then asserts on exit that a matching warning arrived. It also exposes the caught instance as `.warning`, plus `.filename` and `.lineno`; `assertWarnsRegex` additionally matches `str(warning)` against a pattern.
code
python · 23 linesimport unittest
import warnings
def encode(quality):
if quality > 92:
warnings.warn("quality above 92 blows the budget", DeprecationWarning, stacklevel=2)
return quality
class WarnTest(unittest.TestCase):
def test_assert_warns_exposes_the_instance(self):
with self.assertWarns(DeprecationWarning) as caught:
encode(93)
self.assertEqual(str(caught.warning), "quality above 92 blows the budget")
def test_regex_variant(self):
with self.assertWarnsRegex(DeprecationWarning, r"above 92"):
encode(93)
if __name__ == "__main__":
unittest.main()go deeper
Know that a warning is filtered rather than raised, and that the tested way to catch one is with self.assertWarns(DeprecationWarning): around the call rather than inspecting captured output by hand.
Explain the filter actions, the once-per-location default and the per-module registry behind it, and show the two-statement catch_warnings plus simplefilter("always") idiom — plus what assertWarns does that the hand-rolled version does not.
Diagnose the order-dependent failure: a warning assertion that passes alone and fails in the full suite because an earlier test already recorded that location. Reach for stacklevel correctness and for deprecations-as-errors in CI.
Set the policy: whether the build treats DeprecationWarning as an error, how deprecation notices are staged for downstream consumers, and why global warning state makes parallel test execution and thread-based tests fragile.
### The machinery under `warnings.warn` A warning is not an exception being raised — it is a message passed through a filter chain. `warnings.warn(message, category, stacklevel=...)` walks `warnings.filters`, a list of `(action, message, category, module, lineno)` tuples, and the first match decides the fate of the call: `"error"` turns it into a raised exception, `"ignore"` drops it, `"always"` shows it every time, `"once"` shows it once per process, `"module"` once per module, and `"default"` — the one that matters here — shows it **once per unique (message text, category, module, line number)**. That "once" has to be remembered somewhere, and it is remembered in the calling module's `__warningregistry__` dictionary. It is per-module, per-process, and nothing clears it between tests. This is the entire mechanism behind the interview question, and behind a whole family of order-dependent test failures: run `test_a` first and the warning is recorded; run `test_b`, which exercises the same line, and the warning is suppressed, so a `catch_warnings(record=True)` block sees an empty list and the assertion fails — but only when the tests run in that order, or only on the second run in a long-lived process. ### What `catch_warnings` actually does `warnings.catch_warnings()` is a save/restore context manager and nothing more: it snapshots `warnings.filters` and the `showwarning` hook on entry and puts them back on exit. It does **not** change any filter by itself. With `record=True` it additionally redirects warnings into a list, which it returns, of `WarningMessage` objects carrying `message` (the warning instance), `category`, `filename` and `lineno`. So a correct hand-rolled capture always has two statements, not one: ```python with warnings.catch_warnings(record=True) as seen: warnings.simplefilter("always") call_the_code() ``` Drop the `simplefilter` line and you inherit whatever filters the process happens to carry — including the interpreter's default suppression of `DeprecationWarning` outside `__main__`, and including the once-per-location registry above. Since **Python 3.11** you can fold the two into one call: `catch_warnings(record=True, action="always")` takes the same keyword arguments `simplefilter` does (`action`, `category`, `module`, `lineno`, `append`). One caveat worth stating out loud in an interview: `catch_warnings` mutates **process-global** state, so it is documented as not thread-safe. Two threads inside overlapping blocks will trample each other's filters. ### What `assertWarns` adds `unittest.TestCase.assertWarns(category)` wraps all of that and closes both holes. On entry it iterates `sys.modules` and empties every module's `__warningregistry__`, so no earlier test can hide the warning; it then enters `catch_warnings(record=True)` and calls `simplefilter("always", category)`. On exit it scans the captured list for an instance of the expected category and fails with `<Category> not triggered` if none matched. Unlike `assertLogs`, it has a callable form too — `self.assertWarns(DeprecationWarning, func, arg)` — though the context-manager form reads better. Because it stores the match, the block gives you the warning itself: ```python with self.assertWarns(DeprecationWarning) as caught: encode(93) self.assertEqual(str(caught.warning), "quality above 92 blows the budget") ``` `caught.warning` is the warning **instance** (so a custom warning subclass keeps its attributes), and `caught.filename` / `caught.lineno` are the source location the warning was attributed to — which is why the code under test should pass `stacklevel=2` when warning on behalf of its caller. `assertWarnsRegex(category, pattern)` is the same thing with a `re.search` over `str(warning)`; it is the right tool when the message embeds a value, and the wrong one when you would be re-testing your own format string. The symmetric negative does not exist as public API: there is no supported "assert no warnings" method on `TestCase`, so the honest way to express it is a `catch_warnings(record=True)` block with an `"always"` filter and `assertEqual(seen, [])`, or an `"error"` filter that turns any warning into a test failure. ### Running the suite `unittest`'s own runner sets the warning filters to `"default"` for the duration of a run unless you pass `-W` or set `PYTHONWARNINGS`, which is why `DeprecationWarning`s that are invisible in normal execution suddenly appear in test output. Treating deprecations as errors in CI (`-W error::DeprecationWarning`) is a common hardening step — and it interacts with everything above, since an `"error"` filter inside a plain `catch_warnings` block will raise rather than record. ### Reading the filter list Filters are consulted in order and the **first** match wins, which is why `simplefilter` — which inserts its rule at the front of the list — reliably overrides whatever sits behind it, while `warnings.filterwarnings(..., append=True)` puts a rule at the back where an earlier entry may shadow it. The `-W` command-line option and the `PYTHONWARNINGS` environment variable each prepend entries at startup in the same `action:message:category:module:lineno` form, so `-W error::DeprecationWarning` is simply an `"error"` rule at the head of the list. Knowing the ordering is what lets you explain why a filter set in one place appears to have no effect: something more specific, or something inserted later at the front, matched first. The other half of a good deprecation story is `stacklevel`. A warning raised with the default `stacklevel=1` is attributed to the line inside your own function, so both the user-visible location and the `.filename` / `.lineno` that `assertWarns` exposes point at library internals. Passing `stacklevel=2` blames the caller, which is what makes the notice actionable — and it is worth asserting on, because it silently regresses whenever the warning is moved into a helper.
- How would you assert that a block emits no warnings at all, given there is no assertNoWarns?Use `with warnings.catch_warnings(record=True) as seen:` with `warnings.simplefilter("always")` inside and assert the list is empty, or install an `"error"` filter so any warning raises and fails the test outright. Both are explicit; there is no supported negative assertion method on `unittest.TestCase`.
- Why does the code under test usually pass `stacklevel=2` to `warnings.warn`?Without it the warning is attributed to the line inside your own function, so the filename and line number a user sees — and the ones `assertWarns` exposes as `.filename` and `.lineno` — point at library internals rather than at the caller's offending call. `stacklevel=2` blames the caller, which is what makes the warning actionable.
The default filter behaves like a notice board that refuses to pin the same note twice; assertWarns takes the board down and puts a blank one up before your test starts.
saying these in an interview costs you the question
- Thinking warnings.warn raises an exception by default
- Using catch_warnings without setting any filter
- Believing record=True alone defeats deduplication
- Not knowing __warningregistry__ persists across tests
- Claiming assertNoWarns exists on TestCase
- Treating catch_warnings as thread-safe