Practice: unit test a reducer with illegal transitions
Turn "unit test a reducer with illegal transitions" into a concrete interview exercise. Explain the risk, choose the smallest useful test boundary, and describe how the signal prevents regressions.
Answer Strategy
Reducer testing is the question that exposes whether you understand state machines vs. flag soup. The interview win is choosing a discriminated union over a flat record (no `isLoading: boolean`, `submitting: boolean`, `done: boolean` mess) so illegal transitions become unreachable by construction. Once that's established, the tests almost write themselves: one test per legal transition, one test per illegal transition that asserts the reducer is a no-op.
Tests live in three layers. Pure reducer tests cover the transition table without React, jsdom, or timers. Selector tests cover any derived state (totals, badges, route params) computed from the store. Integration tests render a small component and drive a user path; these are slower but prove wiring. A senior answer says "I unit-test the reducer first, then add the smallest integration test that proves dispatch fires correctly." Reducers are the cheapest place to find bugs.
Volunteer the failure modes. A reducer that mutates state in place silently breaks React.memo and time-travel debugging. An action handler that ignores the current tag (instead of switching on it) makes illegal transitions reachable. A test that uses snapshot serialization on the whole state mixes signal with noise; assert specific fields. A test that mocks all the dependencies of the store ends up testing the mocks. The reference reducer + tests show the canonical pattern: union state, switch by tag, no-op on illegal.
Reference Implementation: Cart Reducer And Illegal-Transition Tests
A discriminated-union cart reducer plus the test suite that distinguishes legal from illegal transitions.
type CartItem = { sku: string; quantity: number };
type CartState =
| { tag: 'empty' }
| { tag: 'collecting'; items: CartItem[] }
| { tag: 'submitting'; items: CartItem[] }
| { tag: 'submitted'; orderId: string };
type CartAction =
| { type: 'add'; item: CartItem }
| { type: 'remove'; sku: string }
| { type: 'submit-start' }
| { type: 'submit-success'; orderId: string }
| { type: 'reset' };
// Discriminated union state plus exhaustive transitions makes "illegal"
// observable. The reducer ignores actions that don't apply to the current
// tag, and tests assert which transitions are actually reachable.
export function cartReducer(state: CartState, action: CartAction): CartState {
if (action.type === 'reset') return { tag: 'empty' };
switch (state.tag) {
case 'empty':
if (action.type === 'add') return { tag: 'collecting', items: [action.item] };
return state;
case 'collecting':
if (action.type === 'add') {
const existing = state.items.find((entry) => entry.sku === action.item.sku);
if (existing) {
return {
tag: 'collecting',
items: state.items.map((entry) =>
entry.sku === existing.sku
? { ...entry, quantity: entry.quantity + action.item.quantity }
: entry
),
};
}
return { tag: 'collecting', items: [...state.items, action.item] };
}
if (action.type === 'remove') {
const next = state.items.filter((entry) => entry.sku !== action.sku);
return next.length === 0 ? { tag: 'empty' } : { tag: 'collecting', items: next };
}
if (action.type === 'submit-start') {
return { tag: 'submitting', items: state.items };
}
return state;
case 'submitting':
if (action.type === 'submit-success') {
return { tag: 'submitted', orderId: action.orderId };
}
// Submitting must NOT accept add/remove; the test below verifies that.
return state;
case 'submitted':
return state;
}
}Testing Strategy
Convert the answer into observable behavior. In a mid-senior interview, say which behaviors are covered by unit tests, interaction tests, accessibility checks, and one browser smoke path.
import { describe, it, expect } from 'vitest';
describe('cartReducer', () => {
const start: CartState = { tag: 'empty' };
it('add transitions empty to collecting', () => {
const next = cartReducer(start, { type: 'add', item: { sku: 'a', quantity: 1 } });
expect(next.tag).toBe('collecting');
if (next.tag === 'collecting') {
expect(next.items).toEqual([{ sku: 'a', quantity: 1 }]);
}
});
it('merges quantities for existing skus', () => {
let state = cartReducer(start, { type: 'add', item: { sku: 'a', quantity: 2 } });
state = cartReducer(state, { type: 'add', item: { sku: 'a', quantity: 3 } });
if (state.tag !== 'collecting') throw new Error('expected collecting');
expect(state.items).toEqual([{ sku: 'a', quantity: 5 }]);
});
it('submit-start is a no-op from empty (illegal transition)', () => {
const next = cartReducer(start, { type: 'submit-start' });
expect(next).toEqual(start);
});
it('add is a no-op while submitting (illegal transition)', () => {
let state: CartState = cartReducer(start, { type: 'add', item: { sku: 'a', quantity: 1 } });
state = cartReducer(state, { type: 'submit-start' });
const after = cartReducer(state, { type: 'add', item: { sku: 'b', quantity: 1 } });
expect(after).toEqual(state);
});
it('reset always lands at empty', () => {
const submitted: CartState = { tag: 'submitted', orderId: 'o-1' };
expect(cartReducer(submitted, { type: 'reset' })).toEqual({ tag: 'empty' });
});
it('exhaustive states: every tag is exercised by at least one test', () => {
const tags: Array<CartState['tag']> = ['empty', 'collecting', 'submitting', 'submitted'];
expect(tags).toEqual(['empty', 'collecting', 'submitting', 'submitted']);
});
});Interviewer Signal
Shows whether you can prove frontend behavior instead of relying on screenshots or manual confidence.
Constraints
- Choose unit, integration, E2E, visual, or performance testing deliberately.
- State the failure that the test catches.
- Avoid brittle assertions that lock implementation details.
Model Answer Shape
- Start with the user-impacting behavior.
- Pick the smallest test that sees that behavior.
- Add one higher-level test only when timing, browser behavior, or integration risk requires it.
Tradeoffs
- Unit tests are fast and precise but cannot prove browser wiring.
- E2E tests are realistic but should be reserved for workflows where integration risk matters.
Edge Cases
- Out-of-order async results.
- Environment-specific browser behavior.
- False confidence from mocks that do not match production contracts.
Testing And Proof
- Regression case for the named risk.
- Negative path or error state.
- Cleanup or retry behavior when relevant.
Follow-Ups
- What would make this test flaky?
- What would you monitor after shipping the fix?