Testing Business Rules Without the UI

The most expensive tests in most suites are the ones verifying arithmetic through a browser. A pricing rule with eight branches gets eight browser tests, each costing seconds and each failing for any of a dozen unrelated reasons, when the same eight cases as pure functions cost four milliseconds and fail with the exact expected and received values. The obstacle is rarely conviction; it is that the rule lives inside a component or a route handler and cannot be called on its own. This guide covers recognising an embedded rule, extracting it without a risky refactor, covering it properly, and keeping it out of the interface tests afterwards. It sits under unit vs integration vs E2E mapping.

Root Cause Analysis

Rules end up embedded because that is where they are first needed. A component renders a price, so the discount is computed in the component; a route returns a decision, so the permission check is written in the handler. Nothing is wrong at that moment, and nothing forces a change later — the code works, and the test that covers it works too, just expensively.

The cost shows up in three ways. Each case needs a render or a request, so the per-case cost is two to four orders of magnitude higher than a function call. The failure message describes a rendered output — “expected the text £75.00” — rather than the value that was wrong. And because setting up each case is laborious, the boundary and error cases quietly do not get written, which is where the defects are.

There is a design signal here worth taking seriously. A rule that is hard to test without rendering is a rule that cannot be reused without rendering either — by a background job, a report, an API, or the next interface. The extraction that makes it testable is usually the extraction the codebase needed anyway.

The cost of one rule case at each tier A pure function case costs well under a millisecond, a component render costs tens of milliseconds, and a browser journey costs seconds — so the tier decides how many cases get written at all. Cost of verifying one case pure function 0.05 ms — write all eight cases component render 25 ms — write the important ones browser journey 3 s the tier does not only decide the cost — it decides how many cases exist
At three seconds a case, nobody writes the eighth one; at fifty microseconds, nobody thinks about it.

Reproducible Setup

Start from a rule embedded in a component, which is the most common shape.

// src/features/pricing/PriceSummary.tsx — the rule is inside the render
export function PriceSummary({ basket, customer }: Props) {
  let discountPence = 0;
  if (basket.totalPence > 10_000) discountPence = Math.round(basket.totalPence * 0.1);
  if (customer.tier === 'gold') discountPence = Math.max(discountPence, Math.round(basket.totalPence * 0.15));
  if (basket.hasSaleItems) discountPence = 0;
  const cap = customer.tier === 'gold' ? 5_000 : 2_500;
  discountPence = Math.min(discountPence, cap);

  return <dl><dt>Discount</dt><dd>{formatMoney(discountPence)}</dd></dl>;
}
// the test this forces you to write — one render per case
test('gives a gold customer 15% up to £50', () => {
  render(<PriceSummary basket={basket} customer={gold} />);
  expect(screen.getByText('£50.00')).toBeInTheDocument();
});

Five branches are visible in that component, and there are more combinations than branches. Covering them through renders is possible and nobody will do it.

Implementation

Step 1 — Extract the rule with its inputs as plain values. Take the smallest step that gives you a callable function: no new abstraction, no interface, just a move.

// src/domain/pricing.ts
export type DiscountInput = {
  totalPence: number;
  hasSaleItems: boolean;
  tier: 'standard' | 'gold';
};

export function calculateDiscount({ totalPence, hasSaleItems, tier }: DiscountInput): number {
  if (hasSaleItems) return 0;

  const base = totalPence > 10_000 ? Math.round(totalPence * 0.1) : 0;
  const tiered = tier === 'gold' ? Math.round(totalPence * 0.15) : 0;
  const cap = tier === 'gold' ? 5_000 : 2_500;

  return Math.min(Math.max(base, tiered), cap);
}

Step 2 — Make the component a caller. It now does what a component should: turn values into markup.

// src/features/pricing/PriceSummary.tsx
import { calculateDiscount } from '../../domain/pricing';

export function PriceSummary({ basket, customer }: Props) {
  const discountPence = calculateDiscount({
    totalPence: basket.totalPence,
    hasSaleItems: basket.hasSaleItems,
    tier: customer.tier,
  });

  return <dl><dt>Discount</dt><dd>{formatMoney(discountPence)}</dd></dl>;
}

Step 3 — Cover the rule exhaustively, including the combinations. This is now cheap enough that the boundary cases and the interactions between branches all get written.

// src/domain/pricing.test.ts
import { test, expect, describe } from 'vitest';
import { calculateDiscount } from './pricing';

const input = (o: Partial<DiscountInput> = {}): DiscountInput =>
  ({ totalPence: 20_000, hasSaleItems: false, tier: 'standard', ...o });

describe('calculateDiscount', () => {
  test.each([
    ['below the threshold', input({ totalPence: 10_000 }), 0],
    ['just above the threshold', input({ totalPence: 10_001 }), 1_000],
    ['capped for a standard customer', input({ totalPence: 100_000 }), 2_500],
    ['gold beats the base rate', input({ tier: 'gold', totalPence: 20_000 }), 3_000],
    ['gold is capped higher', input({ tier: 'gold', totalPence: 100_000 }), 5_000],
    ['sale items disqualify entirely', input({ hasSaleItems: true, tier: 'gold' }), 0],
    ['sale items beat the gold tier', input({ hasSaleItems: true, totalPence: 100_000, tier: 'gold' }), 0],
  ])('%s', (_label, given, expected) => {
    expect(calculateDiscount(given)).toBe(expected);
  });
});

Step 4 — Reduce the component test to presentation. One test that the component displays what the rule returned, and nothing about what the rule should return.

// src/features/pricing/PriceSummary.test.tsx
import { render, screen } from '@testing-library/react';
import { PriceSummary } from './PriceSummary';

test('formats the calculated discount as currency', () => {
  render(<PriceSummary basket={{ totalPence: 20_000, hasSaleItems: false }} customer={{ tier: 'gold' }} />);
  expect(screen.getByRole('definition')).toHaveTextContent('£30.00');
});
Before and after extraction, in tests Before, three component renders cover three of seven cases; after, seven pure-function cases cover all of them in under a millisecond and one component test covers formatting. before 3 component tests 3 of 7 cases covered 75 ms failure says "expected £50.00" after 7 unit cases + 1 component test 7 of 7 cases covered 26 ms failure says "expected 3000, got 2000"
More cases, less time, and a failure message that names the value rather than the pixels.

Step 5 — Extract without a risky refactor by moving before changing. Copy the logic verbatim into the function first and make the component call it, with no behavioural edit at all. Tidy the function in a second commit, once the tests exist.

Step 6 — Keep the boundary visible. A short note in the component file naming where the rules live stops the next person adding a condition back into the render.

// Pricing rules live in src/domain/pricing.ts and are tested there.
// This component formats and displays; it does not decide.

Verification

Verify the rule is genuinely pure, which is what makes the unit tier viable and what a future contributor is most likely to break.

grep -nE "import .*(react|\.\./components|fetch|node:fs)" src/domain/pricing.ts
# (no output)

Then verify the extraction did not change behaviour. The safest check is to run the old component tests unchanged against the new implementation before simplifying them.

git stash -- src/features/pricing/PriceSummary.test.tsx   # keep the original tests
npx vitest run src/features/pricing
# ✓ 3 passed   ← behaviour preserved; now simplify the tests

Finally, verify the interface tests have actually shrunk. If the component file still contains assertions about amounts for each tier, the extraction has moved the code without moving the tests, and the expensive coverage is still being paid for.

grep -cE "£[0-9]" src/features/pricing/PriceSummary.test.tsx
# 1     ← one formatting assertion, not seven pricing assertions
Signs a rule is embedded where it should not be Arithmetic inside a render, a test that mounts a component to check a number, a condition repeated in two components, and a rule that a background job cannot reuse all point at the same extraction. arithmetic in a render Math.round inside the component body mounting for a number render() to assert on a total duplicated condition the same check in two components not reusable a report cannot apply the same rule
The last two are design problems that the testing difficulty merely made visible.

Troubleshooting

Symptom: the rule needs data from several sources and extraction feels impossible. Diagnosis: the function is being asked to fetch as well as decide. Fix: pass the data in. The caller gathers, the function decides — which is also what makes it callable from a job, an API and a component alike.

Symptom: the extracted function takes ten parameters. Diagnosis: it is doing several things, or the input wants a name. Fix: give the input a type and pass one object, as in the setup; if it is genuinely several rules, split the function, which the parameter count is telling you.

Symptom: the component test still fails when a rule changes. Diagnosis: the test asserts on a computed amount rather than on formatting. Fix: choose an input whose expected output is obvious and unlikely to move, or assert on the shape — that a currency string is rendered — rather than on the specific value.

Symptom: extraction is blocked because the rule uses a React hook. Diagnosis: the rule is entangled with state or context. Fix: separate the two — a hook that reads state and calls a pure function is testable at both levels, and the function is then reusable outside React entirely.

FAQ

Is this just moving code to make tests easier?

It makes tests easier because it makes the code better factored; the two are the same property seen from different sides. A rule that can be called with plain values can be reused, reasoned about and changed with confidence. If the extraction produced no other benefit it would still be worth it for the case coverage, but it usually does.

What about rules that genuinely depend on rendering?

Some do — layout decisions, what fits on a screen, focus behaviour. Those belong at the component tier and should stay there. The test is whether the rule is about values or about presentation; discount arithmetic is the first, a truncation that depends on available width is the second.

How exhaustive should the unit cases be?

Cover every branch and every boundary, plus the combinations where branches interact — the sale-items-and-gold-tier case in the example is exactly the one nobody writes at the component tier. Since each case costs microseconds, the limit is your imagination rather than your budget, and mutation testing will tell you what you missed.

Does this leave the interface untested?

No — it leaves it tested for what it does. The component still has tests for rendering, interaction and accessibility, which is its actual responsibility. What it no longer has is seven slow tests re-verifying arithmetic that a faster tier now covers better.