TestingGENERALFRAMEWORK-SPECIFICPLATFORM-SPECIFIC

Testing Pure Logic

The cheapest, fastest and most reliable tests you will ever write — and the reason to get logic out of components so that it can be tested this way at all.

The intent, the obvious build, and why it breaks

Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.

The question

Which parts of my frontend can be tested with no browser, no mounting and no waiting, and how do I get more of it there?

The user intent

A person expects the numbers to be right: the total, the remaining quota, the date in their own timezone, the page they land on after deleting the last item on the last page.

The obvious build

The rule lives where it is used. The discount calculation sits in the component that shows the price, so the test mounts that component, renders it, and reads the text of the total.

Why it breaks

A rule with six branches now needs six mounts, six sets of props and six DOM queries, and each assertion is separated from the logic by a rendering pass that can fail for its own reasons.

How it breaks in a real browser
  • A rule with six branches now needs six mounts, six sets of props and six DOM queries, and each assertion is separated from the logic by a rendering pass that can fail for its own reasons.
  • When the assertion fails you cannot tell whether the arithmetic is wrong or the rendering is: expected "£0.00" to be "£12.00" is one message for two entirely different bugs.
  • The same rule is needed somewhere else — an export, a summary row, a confirmation email preview — and it gets copied, because it is not extractable without dragging a component along.
  • Boundary cases are exactly the ones nobody writes, because setting up "an empty cart", "one item", "a quantity that rounds at the half penny" through props is tedious enough to skip.
  • The tests are slow enough that they run in CI rather than while typing, so the loop that should be under a second becomes a loop measured in coffee.
IntentEventStateUI LogicDOM WorkNetworkLayout / PaintPixelsFeedback

What is actually happening

In the browser, not in the framework.

  • A pure function is one whose output depends only on its arguments and which changes nothing outside itself. That property is what makes a test a two-line statement: given these inputs, this output.
  • No mounting means no document, no scheduler, no event loop turn to wait for, no cleanup between tests. The test is a function call and a comparison (The Event Loop, Precisely).
  • Determinism is the actual product. A pure test cannot be flaky, because there is nothing for it to race against — no timer, no network, no other test's leftover DOM.
  • Most of what looks like UI logic is not: validation rules, price and tax arithmetic, pagination maths, sorting and grouping, permission derivation, state reducers, URL parsing and serialising, formatting (Derived State).
  • The extraction is not a testing trick. It is the same boundary that lets the rule be reused on a server, in a worker, or in an export path, and it is what makes the component small enough to read (Drawing Component Boundaries).

What this makes the browser do

And which of it is avoidable.

  • Zero. This is the point: no parse, no style, no layout, no paint, no compositing, no accessibility tree computation.
  • By moving branch coverage here you also remove it from the levels that do cost browser work, which is usually where a slow suite is actually slow.
  • The same purity makes the code cheap to move off the main thread later. A function with no DOM dependency can be posted to a worker without rewriting it (When a Worker Is Actually the Answer).
  • It also makes the code cheap to memoise correctly, because a pure function's inputs are exactly its cache key (Memoization).

The same rule, in two places

The change here is not "add a test". It is moving one boundary so that the rule can be interrogated directly. Everything that made the first version awkward — the mount, the query, the props, the ambiguous failure message — is a consequence of the rule having no address of its own.

The second version is also the version that can be reused by an export, a summary row and a server-rendered receipt without any of them mounting a component.

Where the rule lives decides how it can be tested
Rule inside the component
function CartTotal({ items, coupon }) {
  let subtotal = 0
  for (const i of items) subtotal += i.price * i.qty
  const discount = coupon?.kind === 'percent'
    ? subtotal * coupon.value / 100
    : coupon?.value ?? 0
  const total = Math.max(0, subtotal - discount)
  return <p>{formatMoney(total)}</p>
}

// the test:
// render(<CartTotal items={...} coupon={...} />)
// expect(screen.getByText('£12.00')).toBeVisible()
Rule extracted, component displays
export function cartTotal(items, coupon) {
  const subtotal = items.reduce((s, i) => s + i.price * i.qty, 0)
  const discount = coupon?.kind === 'percent'
    ? subtotal * coupon.value / 100
    : coupon?.value ?? 0
  return Math.max(0, subtotal - discount)
}

function CartTotal({ items, coupon }) {
  return <p>{formatMoney(cartTotal(items, coupon))}</p>
}

// the test:
// expect(cartTotal([], null)).toBe(0)
// expect(cartTotal(oneItem, percentOver100)).toBe(0)

The second form separates two failures that the first conflates: a wrong number and a number that never reached the screen. It also makes the cases nobody writes — empty cart, a coupon larger than the subtotal, a negative quantity — a one-line call instead of a fixture.

Purity is a property you have to defend

Almost every function that becomes flaky was pure when it was written. Impurity arrives quietly: a Date.now() for "days remaining", an Intl call that inherits the runner's locale, a module-level cache added for performance. Each is invisible in review and each turns a deterministic test into a test that fails in a CI container set to a different timezone.

The fix is uniform and boring: make the ambient value an argument. The signature then documents exactly what this function depends on, and the boundary cases — the last day of a trial, a leap day, a locale that puts the currency symbol after the number — become ordinary calls.

Ambient inputs belong in the signature
1// Impure: three hidden dependencies, none of them visible at the call site.
2export function trialBanner(user: User): string {
3 const daysLeft = Math.ceil((user.trialEndsAt - Date.now()) / 86_400_000)
4 return `${daysLeft} days left in your trial`
5}
6
7// Pure: the clock, the locale and the timezone are arguments.
8export interface TrialCtx { now: number; locale: string; timeZone: string }
9
10export function trialStatus(user: User, ctx: TrialCtx) {
11 const end = new Date(user.trialEndsAt)
12 const daysLeft = daysBetween(ctx.now, user.trialEndsAt, ctx.timeZone)
13 return {
14 daysLeft,
15 expired: daysLeft <= 0,
16 // written out rather than numeric, because this string is also read aloud
17 endsOn: new Intl.DateTimeFormat(ctx.locale, {
18 dateStyle: 'long', timeZone: ctx.timeZone,
19 }).format(end),
20 }
21}
22
23// The cases that matter are now one call each:
24// the last full day, the boundary instant, one second past it,
25// a user in a timezone west of the server, a locale that formats
26// the date the other way round.

The interesting assertion is not the happy path. It is that a user whose trial ends at midnight in Auckland and a user whose trial ends at the same instant in Los Angeles see different numbers of days left, and that both are correct.

What is worth extracting

Not everything should move. The test is whether the rule has a name a domain expert would recognise and an answer that can be wrong. "Which page do we land on after deleting the last row" is such a rule; "render a list" is not.

These are the categories that repay extraction in nearly every application, roughly in the order they cause production bugs.

  • Money and quantity arithmetic — subtotals, tax, discounts, proration, rounding. Floating point is a real source of off-by-a-penny bugs and the assertions are trivial to write.
  • Dates and durations — "days left", "expires today", scheduling windows, working days. Almost always wrong at a boundary, and always in a timezone you do not live in (Timezones and Locale Formatting).
  • Pagination and range maths — page counts, offsets, clamping, what happens when the last item on the last page is deleted. Boundary logic pretending to be trivial (Pagination From the Interface Backwards).
  • Sorting, grouping and filtering — including stability and tie-breaking, which users notice as rows jumping around between renders (Reconciliation and Keys).
  • Validation and parsing — field rules, cross-field rules, and the parsing of anything that arrived as a string (Native Validation and Its Limits).
  • State reducers(state, action) => state, including the "ignore a stale response" rule that is otherwise untestable (State Synchronization).
  • Permission derivation — turning a role or a claim set into what the UI offers, which is a decision function, not an authorization boundary (Authorization-Aware UI).
  • Formatting — currency, units, plurals, truncation, and anything a screen reader will read out loud (Internationalization).
How an extraction actually goes
  1. 1
    Name the rule

    Give it the name the business uses: cartTotal, daysLeftInTrial, pageAfterDelete.

    fails by A name like getData or helper, which means the boundary has not actually been found.

  2. 2
    Move it, unchanged

    Cut the body out with no rewrite, so the commit is provably behaviour-preserving.

    fails by Improving it during the move, which makes any resulting bug impossible to attribute.

  3. 3
    Lift the ambient inputs

    Clock, locale, timezone, flags and randomness become parameters.

    fails by Leaving one behind — usually the clock — so the test still fails on a machine in another timezone.

  4. 4
    Write the boundaries

    Empty, one, exactly at the limit, one past, negative, absent, malformed.

    fails by Writing the happy path only, which is the case that was already working.

  5. 5
    Delete what it replaced

    Remove the mounted tests that only existed to reach this arithmetic.

    fails by Keeping both, so the same rule is now maintained at two levels and one of them will drift.

Steps two and three are separate on purpose: a move and a signature change in one commit is a bug that nobody can bisect.

How to build it

Most important first.

  • Separate decide from display. The component asks a function what to show; the function does not know it is in a component.
  • Put ambient inputs in the signature. now, timeZone, locale, currency, randomSeed and feature flags are arguments, not globals, and passing them is what makes the boundary cases reachable (Timezones and Locale Formatting).
  • Test the boundaries first: empty, one, exactly at the limit, one past it, negative, absent, malformed. Interior cases rarely find anything the boundary cases missed.
  • Test invariants as well as examples: a sort is stable, a total equals the sum of the lines, a reducer applied to the same action twice does not double-count (Idempotency in API Design covers the same idea on the wire).
  • Keep reducers pure so that state transitions can be tested as (state, action) => state, which is the single highest-value extraction in most applications (Who Owns This State?).
  • When a rule is shared with the server, test it from the same definition rather than testing two copies and hoping (How API Shape Drives UI Complexity).

Keyboard, focus, semantics, announcement

A required field on every lesson in this domain, not a section added when there is room.

  • Pure logic has no direct accessibility surface, and it must not acquire one: a function that returns markup strings, ARIA attributes or focus instructions has smuggled presentation into a place no accessibility test can reach (Semantics Before ARIA).
  • It does own the *content* of accessible names and messages. Error text, status messages and unit labels are usually produced by a pure formatter, and testing that a quantity error says which field and what to do is a genuine accessibility test at the cheapest level (Errors People Can Actually Perceive).
  • Formatting functions decide what a screen reader will read out loud. 12/07 is ambiguous when read aloud in a way that a fully written date is not, and that is a pure-function assertion (Internationalization).
  • Keep pluralisation and number formatting in this layer and test them per locale. A string built by concatenation is both a translation bug and an announcement bug, and neither shows up in a rendering test (Live Regions and Announcement).

What can go wrong

Failure modes
  • Hidden impurity: Date.now(), Math.random(), Intl with an implicit locale, navigator.language, or a module-level cache. The test passes locally and fails in a runner with a different timezone or a different Node build.
  • A "pure" function that reaches for the DOM once — reading document.body.clientWidth for a breakpoint — which makes it untestable without a document and unusable in a worker.
  • Testing the implementation: asserting on the shape of an intermediate array rather than on the answer, so any rewrite is a test rewrite.
  • The mitigation failing: extracting everything into a utils module with no boundary of its own, which trades a fat component for a fat bag of unrelated functions (Over-Componentization).
  • Mocking the function under test's dependencies so thoroughly that the test asserts the mock configuration.
  • Believing the extraction removed the need for a component test. It removed the need to test the *arithmetic* there; whether the number reaches the screen is still unproven (Component Testing).
What can arrive out of order
  • Reducers are where out-of-order results get resolved. A pure (state, response) => state that ignores a response older than the current request is testable deterministically, which is the only way most people ever get that logic right (Out-of-Order Responses).
  • Module-level mutable state shared between pure-looking functions makes tests order-dependent: the suite passes in file order and fails when the runner shards it across workers.
Security
  • Validation logic is testable here, and testing it here is worthwhile — but a passing test does not make it a control. Client-side validation is a usability feature; the server validates independently (Parse, Validate, Authorize, Process).
  • Parsers and normalisers for untrusted input — query strings, postMessage payloads, deep-link parameters — are pure functions and deserve hostile inputs in their tests, not just plausible ones (The URL Is Application State).
  • Anything that builds markup or a URL from user data is a sink. Test that a payload containing markup or a javascript: scheme comes back inert (Sanitization and Trusted HTML).
  • Test fixtures for these functions are a common home for real tokens and real customer records. Use synthetic data; a fixture file is a permanent, greppable copy.
Misreads
  • "Unit tests catch most bugs." They catch most *logic* bugs. The failures users report in a frontend are more often about reachability, timing and appearance, and none of those live here.
  • "If the reducer is tested, the feature is tested." A perfect reducer wired to the wrong action, or rendered by a component that reads the wrong field, is a broken feature with a green test.
  • "Extracting logic is a testing concern." It is a design concern that testing exposes. The reuse, the readability and the ability to run it in a worker are all the same boundary.
  • "Pure means no side effects anywhere in the file." It means this function's output depends only on its inputs. A module can hold pure functions next to code that is not.
  • "Mocking the clock is the same as passing it in." Mocking is a runner capability that varies between tools; a parameter works everywhere and documents the dependency in the signature.

Measuring it, and what changes in the field

How you would see this
  • Wall-clock time for the pure suite. If it is not fast enough to run on save, something in it is not pure.
  • The ratio of assertions that need a mount to assertions that do not — the number that moves when an extraction actually pays off.
  • Branch coverage is more meaningful here than anywhere else in the suite, because at this level a branch really is a behaviour.
  • When a production bug turns out to be arithmetic, note whether the function existed. Bugs in code that was never extracted are the argument for extracting it (Frontend Error Tracking).
Slow device, slow network, large data, old tab
  • On a large dataset the rule may be correct and still too slow. Purity makes it easy to benchmark in isolation, and easy to get wrong: a microbenchmark of a function is not a statement about the page (List Virtualization).
  • Across locales and timezones the same function has different correct answers. A CI machine fixed to UTC will pass tests that fail for half the user base (Timezones and Locale Formatting).
  • In an offline-capable client, pure reducers are what make a queued mutation replayable, so their determinism stops being a testing convenience and becomes a correctness requirement (The Offline Mutation Queue).
  • When the same rule runs on the server for rendering, any divergence appears as a hydration mismatch rather than as a wrong number (Hydration Mismatch).
What this costs
  • Extraction adds a file, a name and an import. On genuinely trivial logic that indirection costs more than it returns, and "extract everything" is its own anti-pattern.
  • A fast, fully green pure suite is seductive: it is the level most likely to create false confidence, because it proves the most and looks like it proves everything.
  • Injecting ambient values — clock, locale, flags — makes call sites noisier. It is worth it, and it is still a cost that shows up in every call.
  • Sharing a rule with the server means a shared build boundary and a shared release cadence, which is a real coupling in exchange for a real guarantee.

Where this applies

Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.

  • GENERALPurity, determinism and the extract-to-test argument are properties of the code, not of any runner, and hold identically whether the tests are executed by Vitest, Jest, node:test or a bespoke harness.
  • FRAMEWORK-SPECIFICHow much extraction is needed depends on the framework: reducer-shaped state in React or a Redux-style store is already close to pure, Vue and Svelte stores usually need the rule lifted out of reactive wrappers, and Angular services are typically injectable and testable without any move at all.
  • PLATFORM-SPECIFICLocale, timezone and number formatting come from the host: an ICU-enabled Node build, a Node build without full ICU, and a browser can produce different formatted output for the same input, so a formatting test must pin the locale and timezone explicitly rather than inherit the runner's.

Where the depth lives

This domain teaches the browser-side mechanism and hands the rest off.

API Designidempotency
Concurrencydeterminism
Domains that do not exist yet
  • Testing & Reliability Engineering — property-based testing belongs exactly here: pure functions are the only place where generating a thousand inputs and asserting an invariant is cheap enough to be routine.
  • Software Design — the extract-to-test move is a coupling argument. That domain owns why the boundary is right independently of whether anyone tests it.