
Testing Strategies
Design tests around confidence per maintenance cost, not raw coverage count.
Quick Navigation: Mental Model • Layers • Behavior Tests • Boundaries • Tools • Reliability • Interview Framing
Testing Is Risk Management
A test suite is not a scoreboard. It is a risk model for the product. Every test buys confidence at a cost: runtime, setup, debugging, data management, and future maintenance.
Confidence
How likely is this test to catch a real user-facing regression?
Cost
How expensive is it to write, run, debug, and keep stable?
Signal
Does a failure point to a real product problem or just a brittle test?
Good engineers do not ask, “Do we have enough tests?” They ask, “Which failures would hurt users, and what is the cheapest reliable test that would catch them?”
Test Layers and What They Prove
The pyramid, trophy, and diamond are useful metaphors, but they are not laws. The right shape depends on product risk, team velocity, architecture, and how expensive failures are in production.
Static checks
Types, lint rules, format, dependency rules
Proves
Invalid code is rejected before runtime.
Blind spot
It cannot prove user behavior.
Unit logic
Pure functions, reducers, parsers, state machines
Proves
A small decision works for known inputs.
Blind spot
Integration bugs can still pass.
Component behavior
Rendered UI, interactions, accessibility queries
Proves
A user-visible feature behaves correctly in isolation.
Blind spot
Browser, routing, network, and backend assumptions may be mocked.
End-to-end flows
Real browser journeys across pages and services
Proves
Critical paths survive realistic wiring.
Blind spot
Slow suites can become flaky and expensive.
Use the smallest layer that can observe the risk honestly. Unit tests are ideal for pure decisions. Component tests are ideal for interactive UI behavior. End-to-end tests are for journeys where wiring matters more than isolated logic.
Test Behavior, Not Implementation
Frontend tests should usually interact with the page the way users do: by text, role, label, keyboard, pointer, visible state, and accessible output. A test coupled to internal component state can pass while the product is broken, or fail during a harmless refactor.
Strong behavior test
await user.click(screen.getByRole("button", { name: "Save" }))
expect(screen.getByText("Saved")).toBeVisible()This verifies an action and a user-visible result. The implementation can change.
Brittle implementation test
expect(wrapper.state("isSaving")).toBe(false)
expect(button.className).toContain("primary")This tests private wiring. It may fail for a refactor users never notice.
Selector hierarchy
- Prefer role, label, text, placeholder, and accessible name queries.
- Use test ids for stable product contracts when user-facing selectors are not enough.
- Avoid CSS class, DOM depth, and implementation selectors for behavior tests.
- Treat selectors as public test contracts, not incidental markup details.
Mock Boundaries Deliberately
Mocking is not a moral failure. It is a boundary decision. The mistake is mocking the thing you are trying to gain confidence in.
| Boundary | Usually mock | Usually keep real | Reasoning |
|---|---|---|---|
| Network | Third-party APIs, unstable services | Contract shape and error states | Control data while preserving realistic request outcomes. |
| Time | Timers, clocks, dates | Visible timeout behavior | Flaky time makes tests nondeterministic. |
| Browser APIs | Unavailable APIs in test runtime | User-facing permission states | Simulate browser capability, not component internals. |
| Design system | Rarely | Rendered semantics and interactions | Mocking shared UI often hides accessibility and composition bugs. |
What Tool to Use When
Tool choice should follow the risk you are trying to observe. Jest, Vitest, Testing Library, Playwright, and visual tools are not interchangeable layers with different branding. They answer different questions.
| Tool | Use for | Best when | Avoid when |
|---|---|---|---|
| TypeScript + ESLint | Static checks | You want to catch invalid types, unsafe patterns, accessibility lint issues, and dead-simple mistakes before runtime. | You need proof that UI behavior works after rendering. |
| Vitest or Jest | Unit tests | You are testing pure logic, reducers, validators, parsers, state machines, and framework-light utilities. | The behavior depends on real browser layout, navigation, storage, or user interaction semantics. |
| Testing Library | Component and integration tests | You need to render UI and verify user-visible behavior through roles, labels, text, keyboard, pointer, and async states. | You are tempted to inspect component instances, private state, CSS classes, or implementation-only props. |
| Mock Service Worker | Network boundary mocking | You want realistic request/response behavior without depending on live backend or third-party services. | The test goal is to validate the actual backend integration in a staging-like environment. |
| Playwright | End-to-end and browser checks | You need real browser confidence for routing, auth, checkout, upgrade, publishing, accessibility smoke checks, or critical journeys. | A faster component test can honestly prove the same behavior. |
| Storybook + visual regression | UI state coverage | You need to review many component states, responsive variants, themes, or visual regressions. | You need to prove data flow across the full application. |
Default frontend stack
Use TypeScript and linting for static feedback, Vitest or Jest for pure logic, Testing Library for component behavior, Mock Service Worker for API boundaries, and Playwright for a small set of critical browser journeys.
Jest vs Vitest
Choose Vitest when the app already uses Vite-style tooling or you want fast modern unit tests. Choose Jest when the codebase already has mature Jest setup, custom transformers, or legacy mocks. The strategy matters more than the runner.
Reliability and Flake Control
A flaky test is worse than a missing test when the team learns to ignore it. Reliable suites make failures actionable: either the product regressed, the environment broke, or the test needs a clearer contract.
- Make each test independent: no hidden order dependency, shared session, or leaked storage.
- Control network boundaries with route mocks, contract fixtures, or test-owned environments.
- Wait for user-observable states, not arbitrary timers.
- Keep end-to-end tests narrow: one user journey, one reason to fail.
- Collect traces, screenshots, console logs, and network logs for failed browser tests.
Retries are a diagnostic tool, not a fix. If retries hide real nondeterminism, the suite becomes quieter and less trustworthy at the same time.
Coverage Is a Map, Not Proof
Coverage tells you which code executed. It does not tell you whether assertions were meaningful, whether accessibility worked, whether the right user journey was covered, or whether the test would fail for the bug you care about.
Useful coverage signal
Finds untested branches in complex logic, reducers, parsers, and edge-case-heavy utilities.
Misleading coverage signal
Rewards shallow render tests that execute code without checking meaningful outcomes.
Interview Framing
A strong interview answer starts with risk. Identify the product path, the failure mode, and the cheapest test layer that can catch it honestly.
Senior answer pattern
I would unit test deterministic logic, component test user-visible UI behavior, and reserve browser end-to-end tests for revenue, auth, onboarding, and other critical journeys. I would avoid testing implementation details, mock external systems at stable boundaries, and track flaky tests as reliability bugs because ignored tests destroy trust in the suite.
When to use unit tests
Branchy logic, pure transforms, date/math rules, reducers, validators.
When to use component tests
Forms, modals, filters, async UI states, keyboard and accessibility behavior.
When to use end-to-end tests
Login, checkout, publishing, upgrade, critical navigation, production smoke flows.