test-shared-let
Flags a let reassigned in a beforeEach — mutable state shared across tests.
Deterministic checkBlocks by default
Why this rule
- Arrange in a
setup()factory. The factory wires mocks and builds fixtures, then returns the locals the test needs asconsts. Do not hold the subject under test in a sharedletreassigned acrossbeforeEachblocks — that is mutable test state. - Act and assert live in the
test, not inbeforeEach. (Component tests are the one accepted exception:render()lives in thesetup()factory by convention — see the component testing doc.) - One
setup()and one act per test. Two setups or two acts means two tests. Multipleexpects are fine only when they assert one behavior's result. - No nested method calls in the act. Assign each call's result to a named
const. Two exceptions: (1) the error case, where the act sits inside the matcher:expect(() => parse(bad)).toThrow(); (2) assertion-matcher composition (toEqual(expect.objectContaining(...))). - Blank line between arrange, act, and assert — and no
// arrange/// act/// assertcaptions; the spacing already shows the structure. - Test behavior, not internals. Assert the observable output a consumer sees. (Asserting an injected repository was called with the right args IS behavior — the persistence call is the unit's observable side effect at its boundary.)
- When asserting multiple properties of one result, prefer a single
expect. For a partial match usetoEqual(expect.objectContaining({ ... }))— nottoStrictEqual: with an asymmetric matcher argument, Jest only runs the matcher and the strict extra-property checks never fire, sotoStrictEqualthere is identical totoEqualbut misleadingly implies strictness. ReservetoStrictEqualfor whole-object assertions with a concrete expected object. - Cover all code paths — branches, error handling, boundary conditions. Each test exercises a unique code path; don't add tests that only vary input without varying behavior.
- Reaching defensive branches: when a branch guards against input the type system forbids (a
defaultarm, an early return on an impossible discriminant), a test may force the invalid input withas unknown as T— the one blessed double cast, and it lives only in test files, never in source. - Use
test.eachwhen multiple inputs exercise the same code path with different outputs; different code paths get separate tests.
Examples
The check flags the incorrect code and passes the correct code.
Incorrect
import { expect, describe, test, beforeEach } from '@jest/globals';let subject: string;describe('subject', () => {beforeEach(() => {subject = 'ready';});test('reads the subject', () => {expect(subject).toBe('ready');});});
Correct
import { expect, describe, test } from '@jest/globals';const setupSubject = () => ({ subject: 'ready' });describe('subject', () => {test('reads the subject', () => {const { subject } = setupSubject();expect(subject).toBe('ready');});});
Configure
- BlockDefault
"blocking"Stops a run when a file the run changed breaks the rule. - Advise
"advisory"Reports it and hands it to the refactor agent. Never stops a run. - Off
"off"Not checked. Use it when your own linter already enforces the rule.
Add this to your lightsout.config.json, then change the value.
{"standards-checks": {"test-shared-let": "blocking"}}