Verdict
Yes
Yes, you need unit tests next to E2E tests for every rule with branches, one test per branch and boundary, while the E2E tests keep covering the main user flows.
Why
- Blast radiususers
- Change frequencyregularly
- Detectabilityeventually
- Reversibilitywith-effort
- Test costmoderate
Yes, unit test the rules with branches and keep E2E tests for the flows. The typical case is a web application whose E2E suite drives each main flow once, through rules with more branches than that run reaches. Blast radius is users, and Change frequency is regularly, about once a month. Detectability is eventually, because a branch that no E2E test reaches returns a plausible wrong result and nothing crashes. Reversibility is with-effort, a hotfix and replies to support tickets, and Test cost is moderate, because each unit test needs updates when its rule changes, so rule R11 gives Test.
| When | Decision | Why |
|---|---|---|
| The rule computes a discount or a shipping fee | Test mandatory: unit tests for every branch and boundary amount, reviewed by a second person | Blast radius rises to money and Reversibility to costly, because refunds follow |
| The rule decides which records a user may see, and the E2E test logs in only as an allowed user | Test mandatory, including a test that denies a request for another account's record | Blast radius rises to safety-or-legal and Detectability to never, because a leaked record raises no error |
| The code forwards form fields to an API without branches, and an E2E test on every pull request puts a distinct value in each field | Do not unit test the forwarding code | Detectability falls to immediately and Reversibility to trivial, because a swapped field fails the E2E test before merge |
| The rule runs in an admin tool whose staff report a wrong result the same day | Test minimally: one unit test of the main branch | Blast radius falls to internal and Detectability to same-day |
| The rule changes a few times a year, such as a postal code format | Test minimally: one unit test of the branch most likely to break | Change frequency falls to rarely |
What breaks if you don't test
An E2E test for class booking books one class for one member, so the waitlist rule for a full class never runs. When that rule breaks, CI stays green, and the first sign comes days later from a member who joined a waitlist and never got the place that freed up. When an E2E test does fail, it reports a timeout on a page, not the function that broke.
What you lose if you over-test
A unit test for a controller that only forwards form data needs a mock for every collaborator and breaks when a method is renamed, while the E2E test already checks the same path. Mocked unit tests also say nothing about whether layers connect: the mock accepts a field name that the real API rejects, and only the E2E test catches it.
How to test
- Unit test each branch and boundary of the rules behind each E2E flow: validation, dates, state such as a waitlist. Pass the clock and time zone in as arguments, and cover inputs with Vitest's test.each.
- Keep one E2E test per main flow on every pull request, with a distinct value in each field.
- Skip unit tests for code without branches that an E2E test drives.
- When an E2E test catches a bug and no unit test fails, add the unit test, as The Practical Test Pyramid recommends.
Playwright can set a timezoneId, but each zone and hour then costs one more browser run.
When the answer changes
- The rules move money or decide who sees which records.
- The code has no branches, and the E2E suite runs on every pull request with a distinct value in each field.
- The app is an internal tool whose staff report every wrong result the same day.
Real incident + Code example
The Today tab that ran on UTC
On a booking app for fitness studios I worked on, the E2E suite booked a class from the Today tab on every run. A refactor made the tab take today's date from UTC instead of the studio's time zone. Our CI ran in UTC, where both dates agree, so the suite stayed green. In Tokyo before 09:00 the tab showed only classes before 09:00; in New York after 20:00 it added the next day's classes. Some members booked from the week view and others did not book, so nobody filed a ticket; a studio owner in Tokyo asked about a drop in same-day bookings three weeks later. The fix came with this unit test:
import { expect, test } from "vitest";
import { classesToday } from "./schedule";
test.each([
{
zone: "Asia/Tokyo",
now: "2026-06-09T22:00:00Z", // 07:00 on 10 June in Tokyo
classes: [
{ name: "Yoga", start: "2026-06-10T01:00:00Z" }, // 10:00 on 10 June
{ name: "Spin", start: "2026-06-10T16:00:00Z" }, // 01:00 on 11 June
],
},
{
zone: "America/New_York",
now: "2026-06-11T01:00:00Z", // 21:00 on 10 June in New York
classes: [
{ name: "Yoga", start: "2026-06-11T02:00:00Z" }, // 22:00 on 10 June
{ name: "Spin", start: "2026-06-11T13:00:00Z" }, // 09:00 on 11 June
],
},
])("lists today's classes in $zone", ({ zone, now, classes }) => {
const names = classesToday(classes, new Date(now), zone).map((c) => c.name);
expect(names).toEqual(["Yoga"]);
});
Each case holds a class on the next local day, so a filter that returns too many classes fails too.
Related questions
FAQ
- Why should I write unit tests if I already have E2E tests?
Write unit tests because an E2E suite runs each flow with one set of inputs, and rules with branches fail on inputs it never sends. A unit test of a pure function runs each branch in milliseconds and names the function that broke.
- Do I need a unit testing framework if I already use Cypress?
Yes, add a unit test runner such as Vitest or Jest once the app has rules with branches, such as validation or date logic. An app that only displays data from an API can rely on Cypress E2E tests alone.
- Can E2E tests replace unit tests?
E2E tests replace unit tests only for code without branches, such as a controller that forwards a form, and only when the E2E test puts a distinct value in each field. For rules with branches, E2E tests cannot replace unit tests, because reaching every branch through the browser takes one browser run per input.