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.
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');
});
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
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.
Related
- Back to Unit vs Integration vs E2E Mapping
- Mapping user journeys to test layers — where the decisions come from.
- Choosing a tier for API route tests — the same extraction on the server.
- Finding weak assertions with mutation scores — checking the new cases are as strong as they look.