Unit Testing Pure Logic
Extract and test pure functions hard — money, parsing, permissions — without rendering React when the DOM adds no signal.
- testing
- unit-testing
Not everything needs render(). Pure logic — deterministic functions with no DOM, network, or module side effects — is the cheapest place to get thorough branch coverage and clear failures.
What counts as pure (enough)
export function canEdit(role: 'admin' | 'editor' | 'viewer', ownerId: string, userId: string) {
if (role === 'admin') return true;
if (role === 'editor' && ownerId === userId) return true;
return false;
}
import { describe, expect, test } from 'vitest';
describe('canEdit', () => {
test('admin always', () => {
expect(canEdit('admin', 'a', 'b')).toBe(true);
});
test('editor only for own', () => {
expect(canEdit('editor', 'a', 'a')).toBe(true);
expect(canEdit('editor', 'a', 'b')).toBe(false);
});
test('viewer never', () => {
expect(canEdit('viewer', 'a', 'a')).toBe(false);
});
});
Table-driven tests keep cases dense:
test.each([
['admin', 'a', 'b', true],
['editor', 'a', 'a', true],
['editor', 'a', 'b', false],
['viewer', 'a', 'a', false],
] as const)('canEdit(%s, %s, %s) → %s', (role, owner, user, expected) => {
expect(canEdit(role, owner, user)).toBe(expected);
});
Extract from components
// Hard to unit without RTL
function Price({ cents, currency }: Props) {
const label = new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(cents / 100);
return <span>{label}</span>;
}
// Better
export function formatPrice(cents: number, currency: string, locale = 'en-US') {
return new Intl.NumberFormat(locale, { style: 'currency', currency }).format(cents / 100);
}
Component test checks rendering/a11y; unit tests cover locales and edge cents.
Good unit targets in frontend apps
| Domain | Examples |
|---|---|
| Money / tax | rounding, discounts |
| Dates | format, relative ranges (with fake timers/TZ) |
| Parsing | query strings, feature flags |
| Permissions | RBAC helpers |
| Reducers | state transitions |
| Schema mappers | DTO → view model |
Keep tests behavior-focused
// Brittle
expect(fn.toString()).toContain('admin');
// Strong
expect(canEdit('admin', 'x', 'y')).toBe(true);
Side effects: still unit-testable with seams
export async function loadUser(id: string, getJson: typeof fetch = fetch) {
const res = await getJson(`/api/users/${id}`);
// …
}
Inject getJson in tests — or cover via MSW at integration layer. Don’t mock half the world for a pure mapper.
Footguns
- Unit-testing hooks that need full React tree — use
renderHookor extract pure core. - Asserting on private internals.
- Snapshots of huge objects instead of field checks.
- Timezone-dependent tests without fixed locale/TZ.
Interview out-loud answer
“I pull pure helpers out of components and table-test branches. Unit tests own math, permissions, and parsing; RTL owns interaction. If a test needs heavy mocking of React, it’s probably in the wrong layer.”
Property-inspired cases
You don’t need a full property-testing framework to think in properties: “sorting never drops items,” “permissions for admin are a superset of editor.” Encode those as a few parametric tests so refactors can’t silently weaken invariants.
Extra practice
Write a minimal demo in a scratch file or the playground: one happy path, one failure path, and one boundary input. If you cannot exhibit a bug that the pattern prevents, you do not own the concept yet — re-read the primary docs linked below and tighten the example until the failure is obvious.
Related on this site
- Testing pyramid for frontend
- Fake timers for debounce
- Coverage metrics pitfalls
- Runtime validation with schemas
- Integration testing UI
Further reading
Related guides
- Accessibility Testing in CIWire axe and lint rules into CI without false confidence — what automation catches, what it misses, and a practical pipeline.
- Component Testing StorybookUse Storybook as a component workshop and optional test runner — stories as living specs, interaction tests, and a11y checks.
- Contract Testing APIsKeep frontend and backend agreements honest — schema contracts, Pact-style consumer tests, and OpenAPI-driven checks without brittle E2E.
- Coverage Metrics PitfallsWhy 100% line coverage can still ship bugs — gaming metrics, useless tests, and what coverage is actually good for.
- End-to-End Testing TradeoffsWhen E2E tests earn their keep, why they flake, and how to keep a small critical-path suite instead of a slow second frontend.