About test isolation in utest¶
Test isolation determines how much one test can affect another. utest provides strong isolation between files and practical isolation within a file, but the two levels work differently and the distinction matters when you run into unexpected test interactions.
File-level isolation: separate subprocesses¶
Each test file runs in its own ucode subprocess — a worker process launched by the coordinator. The coordinator collects results over stdout as JSON lines, then aggregates them into the final report.
This architecture means a crash, an infinite loop, or an unhandled die() in one test file cannot corrupt the state of another. The worker process simply exits (or times out), the coordinator records a fatal error for that file, and the remaining files continue normally. This was a deliberate choice: in a firmware testing context, test files often exercise independent subsystems, and a panic in one subsystem should never silence unrelated tests.
Within-file isolation: mock state snapshots¶
Within a single file, all tests share the same ucode process and therefore the same global scope. They are not isolated in the operating system sense. What utest does instead is snapshot and restore the mock state around each test.
The sequence for each test is:
- Run all
beforeEachhooks in order (outermost describe first). - Take a snapshot of all current mock state (
mock.snapshot()). - Run the test body.
- If the test body threw, restore the snapshot immediately — before
afterEachruns. This guarantees thatafterEachhooks see clean mock state even when the test leaks amock.global.patch()without a matchingmock.global.unpatch(). - Run all
afterEachhooks in reverse order (innermost describe first). - Restore the full snapshot unconditionally — this cleans up any patches that
afterEachitself may have set.
sequenceDiagram
participant R as Runner
participant B as beforeEach hooks
participant T as Test body
participant A as afterEach hooks
R->>B: run (outermost → innermost)
R->>R: mock.snapshot()
R->>T: run test body
alt test body threw
R->>R: mock.restore(snap) — clean state for afterEach
end
R->>A: run (innermost → outermost)
R->>R: mock.restore(snap) — unconditional final cleanup
The result is that each test starts with the same mock state, regardless of what previous tests did. A test that calls mock.global.patch('fs', ...) and then crashes before calling mock.global.unpatch('fs') will not leave fs patched for the next test.
Why beforeEach and afterEach are scoped to their describe block¶
beforeEach and afterEach hooks are inherited from outer describe blocks but not from siblings. A hook registered in a nested describe does not run for tests in a different nested describe at the same level.
This reflects the mental model that a describe block represents a shared context — a set of preconditions that apply to everything inside it. Scoping hooks to their block makes it possible to read a test and understand exactly which hooks will run without scanning the entire file. It also means you can add setup to a subsuite without accidentally affecting sibling suites.
What isolation does not cover¶
Top-level let declarations in a test file persist across all tests in that file. If a test mutates a module-level variable, the next test will see the mutation. This is intentional: beforeEach exists precisely to reset that kind of shared state. The pattern is to declare the variable at module level and reset it inside beforeEach:
let counter = 0;
describe("my suite", () => {
beforeEach(() => { counter = 0; });
it("increments", () => { counter++; assert.match(1, counter); });
it("still starts at zero", () => { assert.match(0, counter); });
});
If you need true isolation from other tests in the same file, the only mechanism available is the mock snapshot/restore cycle described above. Shared ucode globals — functions, tables, imported module references — are not snapshotted.
Built-in patches are the one part of the mock system the snapshot/restore cycle does not cover. mock.global.patch_builtin() writes directly to global[name] and is not captured by mock.snapshot(), so a forgotten mock.global.unpatch_builtin() leaks the replacement into every later test in the worker. Always pair patch_builtin with unpatch_builtin (in an afterEach), or prefer the callback-scoped mock.inject_builtin(), which restores the original unconditionally — see How-to: Mock a built-in global.
Next steps¶
- Understand how mock.inject() scoping works: About mock.inject() vs mock.global.patch()
- Use strict mode to catch unintended mock access: About strict mode
- See the DSL reference for beforeEach and afterEach: Reference: DSL