Skip to content

Consolidate test fixtures: one field added to WatcherLifecycle broke 19 unrelated tests #119

Description

@HammerMei

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.

Activity

  1. HammerMei commented on Aug 16, 2026

    @HammerMei
    OwnerAuthor

    Done, and the count in this issue was low

    tests/helpers.py now carries make_lifecycle, make_manager, make_watcher, make_core_config and one MockAgentBackend. All ten hand-built WatcherLifecycle.__new__ sites, three make_manager copies, four agent doubles and three make_watcher copies are gone.

    Proof rather than assertion. The refactor's whole claim is "adding a field to a shared structure no longer breaks unrelated tests", so I did exactly what caused the original damage: added a new attribute to WatcherLifecycle.__init__ and ran the suite.

    before (adding _started):  19 failed
    after  (adding a field):   3426 passed, 0 failed
    

    Injected after committing, removed with the inverse edit.

    Two things only the migration revealed

    The list was incomplete. test_integration.py had its own copy of IsolatedTestCase — a fourth kind of duplication that was not in this issue, and that I only found because removing the agent double left it undefined. Enumerating duplicates finds the ones you thought to grep for; migrating finds the rest.

    Keyword-only caught a real hazard. watcher_configs is keyword-only on the shared builder. Two of the three copies took it positionally in the third slot and the third had timeout there — three signatures already drifting toward binding an argument to the wrong parameter in silence. Making it keyword-only turned that into an error at migration time.

    What the fixtures look like now

    The four bookkeeping initialisations every fixture used to repeat (_processors, _states, _watcher_locks, _blocked_agents) are gone from all of them — that is the constructor's job. What is left at each site is only the collaborator that test actually substitutes, which reads shorter and says more.

    Related: the coverage half is measured in #114. The rule is in CLAUDE.md (Test Fixtures Are Shared By Default), including the case where not sharing is correct.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions