CtrlK
BlogDocsLog inGet started
Tessl Logo

testland/test-isolation-patterns

Pure reference catalog of test-isolation and fixture-lifecycle patterns - the four-phase test pattern (Meszaros), fixture scope (per-test / per-describe / shared / global), the Fresh-Fixture vs Shared-Fixture trade-off (Fowler), parallel-safety patterns, and cleanup discipline (afterEach / afterAll / tagged-cleanup), plus a pattern-selection guide and a worked leaking-state diagnosis. The database-isolation strategies (transaction-rollback / database-per-worker / template-database) and network / external-service stubbing live in references/. This is the architecture-tier reference, not a file-level fixture-coupling style rule. Use when designing fixture scope and isolation strategy, auditing fixture coupling or retry/wait policy, or moving a suite to parallel execution.

75

Quality

94%

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide

SecuritybySnyk

Passed

No findings from the security scan

Overview
Quality
Evals
Security
Files
name:
test-isolation-patterns
description:
Pure reference catalog of test-isolation and fixture-lifecycle patterns - the four-phase test pattern (Meszaros), fixture scope (per-test / per-describe / shared / global), the Fresh-Fixture vs Shared-Fixture trade-off (Fowler), parallel-safety patterns, and cleanup discipline (afterEach / afterAll / tagged-cleanup), plus a pattern-selection guide and a worked leaking-state diagnosis. The database-isolation strategies (transaction-rollback / database-per-worker / template-database) and network / external-service stubbing live in references/. This is the architecture-tier reference, not a file-level fixture-coupling style rule. Use when designing fixture scope and isolation strategy, auditing fixture coupling or retry/wait policy, or moving a suite to parallel execution.

test-isolation-patterns

Overview

A test that fails sometimes for non-obvious reasons is non-deterministic. Per Martin Fowler - Eradicating Non-Determinism in Tests: "A test is non-deterministic when it passes sometimes and fails sometimes, without any noticeable change in the code, tests, or environment… Once you start ignoring a regression test failure, then that test is useless and you might as well throw it away." The dominant cause is broken isolation - one test affecting another, the environment leaking, fixtures sharing state. This catalog is the canonical reference for the isolation patterns that prevent it.

This skill is a pure reference - no execution steps. It is the catalog cited when auditing fixture coupling, retry/wait policy consistency, and CI integration health. It complements test-code-conventions §6 (which is the file-level rule against global-fixture hubs) with the cross-cutting architecture patterns. It also complements flake-pattern-reference, which catalogs flake symptoms; this skill catalogs the prevention patterns.

When to use

  • Designing a new framework - pick the fixture scope and isolation strategy.
  • Auditing an existing framework where flake-rate is rising (broken isolation drives the concurrency and test-order-dependency flake categories, together about a third of flakes per Luo et al. 2014).
  • Migrating from sequential to parallel execution - the parallel-safety patterns become load-bearing.
  • Refactoring fixture inheritance chains - apply the cleanup-discipline patterns.

How to use

  1. Frame each test in the four phases (Pattern 1). Be explicit about where Setup and Teardown live, because Phases 1 and 4 are where isolation breaks.
  2. Pick the tightest fixture scope that holds (Pattern 2). Default to per-test; escalate to per-describe or shared only when setup is measurably expensive and the group never mutates the fixture.
  3. Choose Fresh Fixture (Pattern 3), or a Persistent Fresh Fixture (transaction-rollback) when a fresh rebuild is measured too slow.
  4. For DB-backed or network-backed tests, pick an external-dependency strategy from references/database-store-isolation.md and references/network-service-isolation.md.
  5. Before enabling parallel execution, apply the parallel-safety patterns (Pattern 4): worker-scoped state, unique IDs, ephemeral paths, no global singletons.
  6. Wire teardown through the runner's afterEach by default (Pattern 5, cleanup discipline).
  7. Cross-check against the pattern-selection guide and the cross-cutting anti-patterns before sign-off.

Pattern 1 - The four-phase test pattern

Canonical source: Gerard Meszaros - xUnit Test Patterns: Refactoring Test Code (2007). Referenced in the Wikipedia entry on test fixture.

Every test has four phases:

PhaseWhat
1. SetupEstablish the pre-conditions / fixture
2. ExerciseInteract with the System Under Test
3. VerifyDetermine whether the expected outcome was obtained
4. TeardownReturn to a clean state

Phases 1 and 4 together are fixture management. Patterns 2 through 5 below cover how to do them safely; isolating external stores and services is covered in the deep references.

Pattern 2 - Fixture scope

The framework's test runner offers three or four scopes; the team picks the tightest scope that meets the constraint.

ScopeLifecycleUse when
Per-test (function-scoped)Setup before each test; teardown after eachDefault. Maximally isolated. Slowest. Always parallel-safe.
Per-describe (class / module-scoped)Setup before the first test in the group; teardown after the lastSetup is expensive and the group of tests genuinely shares it (read-only)
Shared (session / worker-scoped)Setup once for the whole run; teardown at endSetup is unaffordable per-describe (e.g., spinning up a Docker stack) and the tests don't mutate it
Global (module-loading)Setup at module-import time; no teardownAnti-pattern in nearly all cases. Use only for truly immutable language-level fixtures (constants, configuration).

The single rule that prevents most flake: never share mutable fixtures across tests. If a fixture is mutated by any test, it must be per-test scoped.

Framework-specific scope syntax (illustrative; cite the per-framework skill for tool-specific details)

  • Jest / Vitest: beforeEach / beforeAll (per-describe by default within a describe block).
  • Playwright Test: test.beforeEach / test.beforeAll; test.use({}) for per-test config; fixtures via test.extend().
  • pytest: @pytest.fixture(scope="function" | "class" | "module" | "session").
  • JUnit 5: @BeforeEach / @BeforeAll; @TestInstance(Lifecycle.PER_CLASS).
  • RSpec: before(:each) / before(:all).

Anti-patterns

Anti-patternWhy it fails
Per-describe fixture that any test in the describe mutatesOne test fails; the next "starts" from the mutated state
Shared fixture mutated through a leaky abstraction (e.g., factory returns a shared object)Cross-test mutation without an obvious culprit; flake follows
Per-test scope for genuinely expensive setup (a 30s Docker spin-up per test)Suite time explodes; team skips tests
Global fixture for anything that has stateCannot reset between test runs; CI run pollutes the next run
Inheritance hierarchy of fixtures (BaseTestAppTestDomainTestSpecificTest)Depth-3+ chains break unpredictably (§A2)

Pattern 3 - Fresh Fixture vs Shared Fixture trade-off

Canonical source: Martin Fowler - Eradicating Non-Determinism in Tests.

Fowler's framing: "I prefer the former [Fresh Fixture], as it's often easier - and in particular easier to find the source of a problem… [but] rebuilding the database each time can add a lot of time to test runs, so that argues for switching to a clean-up strategy."

ApproachSetup costIsolationWhen
Fresh Fixture (rebuild from scratch every test)HighMaximumDefault; use unless measured slow
Cleanup strategy (preserve the fixture, undo changes at teardown)LowStrong if cleanup is comprehensiveWhen Fresh Fixture's cost is prohibitive
Persistent Fresh Fixture (fresh per test, persisted via transaction-rollback)LowMaximumThe pragmatic middle for DB-backed tests

The transaction-rollback pattern (Persistent Fresh Fixture): Begin a transaction at test start; do all the test's DB work inside it; rollback at test end. The database is materially unchanged across tests. The pattern works for any DB that supports transactions; integration-test frameworks like DatabaseCleaner (Ruby), pytest-django's db fixture, Spring's @Transactional test annotation all implement it. The five DB-level strategies are catalogued in references/database-store-isolation.md.

Anti-patterns

Anti-patternWhy it fails
Fresh Fixture that takes 60+ seconds per testSuite time becomes prohibitive; team skips tests
Cleanup strategy that misses one mutation surface (cache; queue; file system)Cross-test coupling through the missed surface
Transaction-rollback that doesn't actually rollback (autocommit, DDL changes)Silent state leakage
Shared Fixture documented as "immutable" but tests mutate it anywayThe documentation is unverified; flake follows

Pattern 4 - Parallel safety

Canonical source: Fowler - Eradicating Non-Determinism in Tests on isolation as the parallel-safety prerequisite, plus Luo et al. FSE 2014, which attributes 20% of flakes to concurrency problems (race conditions and deadlocks).

Parallel execution magnifies every isolation bug. The patterns that make parallel safe:

PatternWhat it does
Worker-scoped fixturesEach parallel worker has its own state (DB, file system path, port range)
Unique identifiers per testTest names, file paths, generated IDs include the worker ID (worker_${WORKER_ID}_user_${TEST_ID})
Ephemeral output pathsTests write to tmp/${WORKER_ID}/${TEST_ID}/ and clean up at teardown
Port range allocationEach worker gets a port range (30000 + WORKER_ID * 100) to avoid binding conflicts
No global singletonsNo process.env writes, no global config mutation, no static state
Idempotent setupRe-running the setup produces the same state (so a flaky-and-retried test isn't tainted)

Anti-patterns

Anti-patternWhy it fails
process.env.X = "..." in a test (writes to a shared global)Worker N's env-write affects worker M's reads
Hard-coded port 3000 in tests (port collisions)First worker binds; others fail
Tests writing to /tmp/test.log (path collision)Workers stomp each other's files
Test-name-based DB seeding (collides across workers if names overlap)Cross-worker state pollution
Per-test setup that does setTimeout / sleep to "let things settle"Flake source: async-wait is the largest flake category at 45% per Luo et al. 2014; use proper event-based synchronisation

Pattern 5 - Cleanup discipline

Canonical source: Meszaros's xUnit Test Patterns (2007) - the Garbage-Collected Teardown vs In-line Teardown vs Implicit Teardown vs Setup Decorator patterns.

The four canonical cleanup approaches:

PatternMechanism
In-line TeardownEach test explicitly cleans up at end (last line of the test body)
Implicit TeardownafterEach / afterAll hooks the runner calls automatically
Garbage-Collected TeardownCleanup happens when the language's GC reclaims the fixture (typed in C# / Java with IDisposable / AutoCloseable)
Tagged CleanupFixture registers itself with a "cleanup queue" at setup; queue drains at suite end

Rule: Implicit Teardown via the runner's afterEach hook is the default. In-line Teardown is acceptable when the cleanup is specific to one test. Tagged Cleanup is for fixtures whose lifetime is variable (held across multiple tests, then released).

Anti-patterns

Anti-patternWhy it fails
No teardown ("the next test will clean up")Failing test orphans state; the next test fails too
Teardown that swallows errors silentlyReal cleanup failures are invisible; flake follows
Teardown that depends on test-pass state (if (test.passed) cleanup())Failing tests don't clean up; cascading flake
Teardown order-dependent on setup orderRefactoring setup breaks teardown

Worked example - diagnosing a leaking-state test

Symptom. updates a member's role passes when its file runs alone but fails intermittently in the full suite - the classic order-dependent flake (Fowler).

The suite (before):

describe("member admin", () => {
  let user;
  beforeAll(() => { user = createUser({ role: "member" }); }); // per-describe scope

  it("promotes to admin", () => {
    promote(user);
    expect(user.role).toBe("admin"); // mutates the shared fixture
  });

  it("updates a member's role", () => {
    expect(user.role).toBe("member"); // fails: user was mutated above
  });
});

Diagnosis. Walk Pattern 2: user is a per-describe (beforeAll) fixture that the first test mutates. The second test inherits the mutated state. This is the Pattern 2 anti-pattern row "Per-describe fixture that any test in the describe mutates", and it violates the single rule that prevents most flake: never share mutable fixtures across tests.

Fix. Move the fixture to per-test (beforeEach) scope so each test gets a Fresh Fixture (Pattern 3):

describe("member admin", () => {
  let user;
  beforeEach(() => { user = createUser({ role: "member" }); }); // per-test scope

  it("promotes to admin", () => {
    promote(user);
    expect(user.role).toBe("admin");
  });

  it("updates a member's role", () => {
    expect(user.role).toBe("member"); // always fresh; order-independent
  });
});

Each test now starts from a clean object - maximally isolated and parallel-safe. If createUser were genuinely expensive, the middle path is Pattern 3's Persistent Fresh Fixture (transaction-rollback), not a shared mutable object.

Deep references

Once fixture scope is chosen, the two external-dependency concerns have their own deep catalogs:

Cross-cutting anti-patterns

Anti-patternWhy it fails
Implicit ordering (test B depends on test A's side effects)Per Fowler: "isolation… gives you more flexibility in running subsets of tests and parallelizing tests." Ordering breaks both.
Tests that "sleep until it works"Timing-fragile; async-wait is 45% of all flakes per Luo et al. 2014
Tests that read system time without overridesTests fail at midnight / DST / leap year
Tests that read random data without seedingNon-reproducible failures
Tests that depend on file-system layoutOS / CI-runner-specific failures
Tests that depend on locale / timezone of the runnerInternationalisation-dependent flake

Pattern-selection guide

ScenarioRecommended pattern
Default (unit / integration test)Per-test fixture scope + Fresh Fixture
DB-backed integration testPer-test fixture + transaction-rollback (Persistent Fresh Fixture)
Slow expensive E2E setupPer-describe Shared Fixture documented as immutable + transactional teardown
Parallel executionWorker-scoped DB + unique IDs per worker + ephemeral output paths
External service interactionStubs by default; contract tests at API surface; real-network only in smoke / canary
Multi-worker DB-heavy suiteDatabase-per-worker + template-database cloning
Mutation-heavy unit testsPer-test fixture + in-memory mock

Hand-off targets

  • Per-file fixture coupling ruletest-code-conventions §6.
  • Flake symptoms / pattern catalogflake-pattern-reference (qa-flake-triage) - symptoms; this skill is the prevention reference.
  • Quarantine a chronically flaky testflaky-test-quarantine (qa-flake-triage).
  • Stub / mock external servicesmsw-handlers, wiremock-stubs, mountebank-imposters (qa-test-data).
  • Test data construction patternstest-data-patterns (qa-test-data, sister catalog).
  • Object-model architecture patternsobject-model-patterns (sister catalog).
  • Test step granularitytest-step-design-patterns (sister catalog).

References

Workspace
testland
Visibility
Public
Created
Last updated
Publish Source
GitHub
Badge
testland/test-isolation-patterns badge