Shared State / Order Dependence¶
| Slug | Severity | Detection Scope | Protects |
|---|---|---|---|
shared-state |
Medium | per-file | Atomic, Repeatable |
Summary¶
Tests leak state across one another via module-level mutables, fixture side effects retained between tests, before(:suite) blocks that install globals without restoration, or lazy-initialized module variables populated by the first test to run. Results depend on execution order.
Aliases¶
- "shared state"
- "order dependence"
- "order-dependent"
- "leaked state"
- "module-level mutables"
- "test isolation"
- "state leaking between tests"
Description¶
The semantic judgment is reachability analysis over the file's mutable bindings: trace writes and reads of each shared container across test bodies and setup hooks. A reader with the imports plus the hook blocks can build this graph quickly; a linter has to do scope-aware data flow.
Includes unused shared setup (dead @temp_dir, unused let(:doc)) — syntactically not a bug, but a signal that the author lost track of what's shared.
Signals¶
- File-level
let/constbound to a mutable object (new SUT(),new Engine(),createClient()) used by many tests without abeforeEachfactory. let css = ''written in onedescribe, read in another, with lazy-init (if (!css) rebuild).before(:suite)/beforeAllcreates a resource; individual tests mutate it.sys.path.insert(0, ...)/NODE_PATHmutation inconftestthat a sibling test file also performs.- Global mutable stubs (
console.warn = ...,Date.now = ...) installed without restore. - Temp directories created at suite-level and never cleaned.
- Test passes in isolation but fails under randomized ordering (
pytest-randomly,1jest --testSequencer=random,--shuffle).
False-positive guards¶
Module-level binding signals over-trigger in three classes the audit must not flag:
- Read-only shared bindings. Module-level constants (
const FIXTURES_DIR = ...,const FROZEN = Object.freeze({...}),FIXTURE_PATH = pathlib.Path(...)) andlets bound to immutable values are not shared state — they're shared data. There is no order-coupling risk because no test can mutate them. The smell fires on mutable bindings only; immutable shared bindings are a normal optimization. beforeAll/before(:suite)for genuinely expensive immutable setup. A parsed grammar, a compiled regex, an HTTP-client connection pool, a once-loaded ML model — created once and never mutated by tests — is legitimate cross-test sharing. Per-test recreation would burn CI time with zero isolation gain. The smell fires when (a) tests mutate the suite-level resource, or (b) the setup installs globals (monkey-patches, env-var changes, DOM patches) without a paired restore inafterAll/after(:suite).- Genuinely-unused shared setup is
rotten-green, not shared-state. Abefore(:suite)block whose product is read by no test (a@temp_dircreated and never referenced, alet(:doc)bound and never used) is dead scaffolding, not state leakage. Distinguish: shared-state requires both write and read across tests with order coupling; orphan setup with zero reads belongs to a different smell entirely. Routing such cases to rotten-green is the correct disposition.
Prescribed Fix¶
- Move setup into
beforeEachor a per-test factory; accept the small perf cost. - Delete genuinely unused shared setup (dead
@temp_dir, unusedlet(:doc)). - Use install-and-restore patterns (
jest.spyOn(console, 'warn')withafterEach) instead of permanent global mutation. - Consolidate duplicate
sys.path/ env-var mutations into one conftest. - Optional: enable order randomization in CI to prevent regression.
- Gate: preservation of regression-detection power plus the file must pass shuffled runs N times.
Example¶
Before¶
# tests/test_engine.py
engine = Engine() # module-level
def test_adds_rule():
engine.add('rule-a')
assert 'rule-a' in engine.rules
def test_empty_on_init():
assert engine.rules == [] # fails if test_adds_rule ran first
After¶
@pytest.fixture
def engine():
return Engine()
def test_adds_rule(engine):
engine.add('rule-a')
assert 'rule-a' in engine.rules
def test_empty_on_init(engine):
assert engine.rules == []
The module-level engine leaked state. Both tests now get a fresh instance via fixture. Passes under randomized ordering.
Related modes¶
wrong-level— integration-tier tests often legitimately share expensive state; the fix is to mark the tier, not eliminate sharing.mystery-guest— shared fixtures with undocumented shapes.rotten-green— dead shared setup.
Polyglot notes¶
Universal. Every runner has a per-test lifecycle hook (beforeEach, @BeforeEach, Setup, fixture). Detection of reachability is a per-language AST walk; the fix recipe is language-agnostic.
-
pytest-randomly— randomizes test order per session; failures under randomization are reproducible via the printed seed. https://github.com/pytest-dev/pytest-randomly. ↩