ESC

Type to search the knowledge base.

Unit Testing Pure Logic

Extract and test pure functions hard — money, parsing, permissions — without rendering React when the DOM adds no signal.

beginner3 min read
  • 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.

Docs: Vitest, Jest.

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

  1. Unit-testing hooks that need full React tree — use renderHook or extract pure core.
  2. Asserting on private internals.
  3. Snapshots of huge objects instead of field checks.
  4. 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.

Further reading

Related guides