The test patches myapp.cache.cached and the function still caches. Nothing is wrong with the patch — it is simply too late. @cached ran when myapp.services was imported, which happened when the test module imported the function under test, which happened before any patch call in the test body. The name now points at a wrapper that closed over the original implementation, and rebinding the decorator changes nothing about the wrapper that already exists.
Prerequisites
- Python 3.8+;
functools.wrapssets__wrapped__, which several of the strategies below rely on. - The binding model from where to patch.
Solution
Four strategies, ordered by how much they change. The first two are test-only; the last two change the code, and are the ones worth arguing for.
1. Patch what the wrapper delegates to. Most decorators call something at runtime — a cache backend, a metrics client, a retry policy. That call happens per invocation, so it is patchable long after decoration:
# myapp/cache.py
import functools
def cached(fn):
@functools.wraps(fn)
def wrapper(*args, **kwargs):
key = (fn.__name__, args, tuple(sorted(kwargs.items())))
if key in BACKEND: # read at CALL time, not at decoration
return BACKEND[key]
BACKEND[key] = result = fn(*args, **kwargs)
return result
return wrapper
# The test never touches the decorator; it swaps the backend the wrapper reads.
def test_cache_miss_calls_through(monkeypatch):
monkeypatch.setattr("myapp.cache.BACKEND", {}) # empty backend, per test
assert services.expensive(2) == 4
2. Reach through __wrapped__. functools.wraps copies the original onto the wrapper, so the undecorated function is still available for a direct test:
from myapp.services import expensive
def test_pure_logic_without_the_decorator():
raw = expensive.__wrapped__ # the undecorated function
assert raw(2) == 4 # no caching, no retries, no metrics
This tests the logic honestly and skips the decorator entirely, which is exactly right when the decorator is infrastructure and the logic is the subject.
3. Patch, then reload. When the decorator's behaviour at decoration time is the thing under test — a registry decorator, a route decorator — the module has to be re-imported with the patch in place:
import importlib
from unittest.mock import patch
import myapp.services
def test_registration_uses_the_patched_registry():
with patch("myapp.registry.REGISTRY", new={}) as registry:
importlib.reload(myapp.services) # decoration runs again, patched
assert "expensive" in registry
importlib.reload(myapp.services) # restore the real registration
Treat this as a last resort. Reloading rebinds every object in the module while other modules keep references to the old ones, so isinstance checks across the boundary start failing and any module-level state is rebuilt.
4. Move the decision inside the wrapper. The durable fix: a decorator that reads its configuration at call time needs no patching at all.
def retry(attempts=None):
def decorate(fn):
@functools.wraps(fn)
def wrapper(*args, **kwargs):
# Resolved per call, so a test can change settings without re-decorating.
n = attempts if attempts is not None else settings.get("retry_attempts", 3)
...
return wrapper
return decorate
Why this works
@decorator is syntax for fn = decorator(fn) executed at module import. The result is a new function object bound to the module namespace, holding the original in a closure cell. Nothing re-evaluates that expression later, so the decorator name is irrelevant after import — which is why strategies one and four work: both target something the wrapper consults while running, rather than something the decorator consulted while building it. __wrapped__ works for the opposite reason: functools.wraps deliberately keeps a reference to the pre-decoration function so tooling can reach it.
Testing the decorator itself
A decorator is a function that returns a function, and it can be tested directly without importing anything it decorates. That test is usually clearer than any test of decorated code, and it belongs next to the decorator rather than next to its users.
import functools
import pytest
from myapp.cache import cached
def test_cached_calls_through_once():
calls = []
@cached # applied here, inside the test, on our terms
def add(a, b):
calls.append((a, b))
return a + b
assert add(1, 2) == 3
assert add(1, 2) == 3
assert calls == [(1, 2)] # the second call was served from cache
def test_cached_preserves_metadata():
@cached
def documented(x):
"""Adds nothing."""
return x
assert documented.__name__ == "documented" # functools.wraps applied
assert documented.__doc__ == "Adds nothing."
assert documented.__wrapped__(5) == 5 # original reachable
Applying the decorator inside the test sidesteps the entire import-time problem, because decoration happens when the test runs. Combined with strategy one for the integration path, this covers both halves: the decorator's own semantics here, and the decorated function's behaviour where it is used.
The metadata assertions deserve a place in that suite. A decorator that forgets functools.wraps breaks __name__, __doc__, __module__ and __wrapped__, which in turn breaks pytest's own introspection, inspect.signature, and every other test strategy on this page. One assertion catches it permanently.
Edge cases and failure modes
- Class decorators and metaclasses have the same timing but no
__wrapped__convention, so strategy two is unavailable; prefer strategies one and four. @pytest.fixtureis itself a decorator applied at import. Patching something a fixture's decorator consulted at definition time has the same problem, and the answer is the same: read it inside the fixture body.- Stacked decorators build nested wrappers, so
__wrapped__reaches one level down, not to the original. Walk the chain withinspect.unwrapwhen several are applied. - Reloading a module that defines exception classes creates new class objects, so
except OldErrorin an unreloaded module no longer catches the new one — a particularly confusing symptom of strategy three. lru_cacheon a method holds instances alive for the process lifetime; clearing it between tests is required for isolation and is a leak source in production, as covered in finding memory leaks with tracemalloc snapshots.- Import order decides whether a patch is early enough. A
conftest.pythat imports the application at module scope makes every later patch too late; import inside fixtures instead.
A checklist for decorator-heavy codebases
When several decorators stack on every handler — auth, caching, metrics, retries — testing gets harder in proportion. Four conventions keep it tractable.
Keep decorators thin and their dependencies late. A decorator that reads settings or a client at call time is testable without ceremony; one that captures them at decoration time is not. This single rule removes most of the difficulty on this page.
Always apply functools.wraps. It costs one line and preserves __name__, __doc__, the signature and __wrapped__, which every debugging and introspection tool depends on — including pytest's own fixture resolution when the decorator is applied to a fixture.
Expose a bypass for tests. A module-level DISABLED = False that each wrapper consults is crude but effective, and far safer than reloading modules. It also documents that the decorator has an off switch, which is useful in production incidents as well as in tests.
Test the decorator and the decorated function separately. The decorator's semantics get a focused unit test; the business logic gets tested through __wrapped__ or through an undecorated inner function. Very little needs to test both at once, and the tests that do belong at the integration level where the whole stack is exercised deliberately.
Frequently Asked Questions
Why does patching a decorator have no effect? Because decoration happens once, at import. By the time the test patches the decorator name, the decorated function already exists as a wrapper built from the original, and the wrapper holds a direct reference to it.
Is importlib.reload a safe way to re-apply a patched decorator?
It works but it is invasive: reloading rebinds the module's objects while other modules keep references to the old ones, so classes fail isinstance checks across the boundary. Use it only for leaf modules with no importers.
Can I patch the function the decorator wraps instead?
Often yes. functools.wraps preserves the original as __wrapped__, so patching what the wrapper delegates to changes behaviour without re-running the decorator.
What is the most maintainable fix?
Make the decorator's behaviour configurable at call time rather than at decoration time — read the flag, the clock or the client inside the wrapper, so a test can change it without re-decorating anything.
Can I stop a decorator from being applied during tests entirely?
Yes, by making the decorator a no-op under a flag it reads at import: if os.environ.get("DISABLE_CACHE"): return fn. It works, and it changes what the tests exercise — the decorated path in production is then never covered. Prefer it only for decorators whose behaviour is genuinely orthogonal to the logic, such as metrics, and cover the decorator itself with its own unit test.
Related
- Where to patch: understanding mock.patch targets — the binding rules that make timing matter.
- Patching strategies for complex codebases — the wider set of patching failure modes.
- Replacing singletons and module globals in tests — the same import-time problem, in data rather than in behaviour.
- Patching builtins and sys.modules safely — when the reload strategy needs its state restored.