Summary
Test fixtures are copied per suite rather than shared, and the cost surfaces whenever a shared structure gains a field.
Concrete: adding one attribute to WatcherLifecycle broke nineteen tests at once, none of which had anything to do with the change. Every one was a hand-built WatcherLifecycle.__new__(...) object with attributes assigned by hand, missing a field no real instance can lack.
Current duplication
| Thing |
Copies |
WatcherLifecycle.__new__ hand-built objects |
13 |
make_manager helper |
3 (test_integration.py, test_watcher_lifecycle.py, test_list_watchers.py) |
Agent-backend doubles (MockAgentBackend / _RecordingAgent) |
5 |
tests/helpers.py |
18 lines, one class |
Why __new__ fixtures are the worst of it
Bypassing __init__ produces objects in states no code path can create — a record in _states with no corresponding processor, _rooms populated with an empty refcount, and so on. Those do not fail where they are wrong; they fail several layers away, looking like a product bug. One such fixture was found to be passing its assertion for the wrong reason (it asserted a watermark was unchanged, in a fixture where nothing could have changed it).
Shape of the fix
- A
make_lifecycle(**overrides) / make_manager(**overrides) in tests/helpers.py that runs the real constructor and takes keyword overrides for the collaborators a test needs to substitute.
- One shared agent-backend double, with the recording variants built on it.
- Migrate the 13
__new__ sites.
The rule this comes from is now written down in CLAUDE.md (Test Fixtures Are Shared By Default), including the case where not sharing is correct: reuse must not fuse two independent layers, and a helper that grows if on who called it should be split instead.
Companion work
Pairs with #114 (measure coverage). Both are about the same thing from opposite ends — #114 finds what is untested, this finds what is tested in a way that resists change. Worth doing together, and worth doing before the remaining dynamic-watcher increments rather than after, since those add fields to exactly these structures.
Summary
Test fixtures are copied per suite rather than shared, and the cost surfaces whenever a shared structure gains a field.
Concrete: adding one attribute to
WatcherLifecyclebroke nineteen tests at once, none of which had anything to do with the change. Every one was a hand-builtWatcherLifecycle.__new__(...)object with attributes assigned by hand, missing a field no real instance can lack.Current duplication
WatcherLifecycle.__new__hand-built objectsmake_managerhelpertest_integration.py,test_watcher_lifecycle.py,test_list_watchers.py)MockAgentBackend/_RecordingAgent)tests/helpers.pyWhy
__new__fixtures are the worst of itBypassing
__init__produces objects in states no code path can create — a record in_stateswith no corresponding processor,_roomspopulated with an empty refcount, and so on. Those do not fail where they are wrong; they fail several layers away, looking like a product bug. One such fixture was found to be passing its assertion for the wrong reason (it asserted a watermark was unchanged, in a fixture where nothing could have changed it).Shape of the fix
make_lifecycle(**overrides)/make_manager(**overrides)intests/helpers.pythat runs the real constructor and takes keyword overrides for the collaborators a test needs to substitute.__new__sites.The rule this comes from is now written down in
CLAUDE.md(Test Fixtures Are Shared By Default), including the case where not sharing is correct: reuse must not fuse two independent layers, and a helper that growsifon who called it should be split instead.Companion work
Pairs with #114 (measure coverage). Both are about the same thing from opposite ends — #114 finds what is untested, this finds what is tested in a way that resists change. Worth doing together, and worth doing before the remaining dynamic-watcher increments rather than after, since those add fields to exactly these structures.