Build a confidence-focused frontend test strategy across pure logic, accessible UI behavior, network boundaries, integration flows, and resilient end-to-end checks.
Choose test levels based on risk and feedback speed
Test user-visible behavior through accessible interfaces
Control time, randomness, and network boundaries deliberately
Design integration and end-to-end tests that resist flakiness
Use coverage and CI results as evidence rather than goals
A test suite should reduce uncertainty about important user and business behavior. Prioritize failures with meaningful impact, then choose the cheapest test level that can observe the real contract.
Many small implementation-detail tests can still miss a broken workflow. Balance fast unit feedback with focused integration and a small number of critical end-to-end journeys.
Rank scenarios by impact and likelihood
Test contracts rather than private implementation
Keep a deliberate mix of unit, integration, and end-to-end coverage
Price calculation rules: focused unit tests
Course completion mutation and cache: integration test
Sign in, unlock, complete course: one critical end-to-end test
Purely decorative spacing: visual reviewPure functions are ideal unit-test targets because inputs and outputs define the complete behavior. Cover representative values, boundaries, and invalid states without involving a browser.
Avoid mirroring the implementation line by line in assertions. A good test communicates the rule that must survive refactoring.
Test observable inputs and outputs
Include boundary and invalid cases
Name tests after behavior rather than method internals
type Tier = "free" | "pro" | "interview_plus";
function canAccessPro(tier: Tier): boolean {
return tier === "pro" || tier === "interview_plus";
}
it.each([
["free", false],
["pro", true],
["interview_plus", true],
] as const)("resolves %s access", (tier, expected) => {
expect(canAccessPro(tier)).toBe(expected);
});UI tests should interact through labels, roles, names, and visible text. Those queries reflect the interface exposed to users and catch missing accessibility semantics.
Avoid reaching into component instances, private state, or fragile CSS selectors. Assert the outcome of an action, not the framework steps that produced it.
Prefer role and label queries
Drive interactions through realistic user events
Assert visible outcomes and accessible state
render(<CourseFooter status="in_progress" />);
await user.click(screen.getByRole("button", { name: "Complete course" }));
expect(await screen.findByText("Course completed")).toBeVisible();
expect(screen.getByRole("button", { name: "Completed" })).toBeDisabled();When a test cannot find a control by role and accessible name, users of assistive technology may not find it either. Fixing the markup is usually better than adding a test-only identifier.
Test focus, expanded state, selected state, error associations, and live feedback where those behaviors matter to the workflow.
Use failed accessible queries as design feedback
Assert programmatic states such as expanded and invalid
Reserve test IDs for elements with no semantic query
await user.click(screen.getByRole("button", { name: /react frontend roadmap/i }));
const dialog = screen.getByRole("dialog", { name: /react frontend roadmap/i });
expect(dialog).toBeVisible();
expect(within(dialog).getByText("Start")).toBeVisible();
expect(within(dialog).getByRole("button", { name: "Close" })).toHaveFocus();Frontend behavior often crosses promises, rendering, and network callbacks. Await the user interaction and then wait for the observable state that signals completion.
Arbitrary sleeps make suites slow and flaky. Use find queries or bounded polling tied to the expected UI instead.
Await user events and asynchronous queries
Wait for a specific observable outcome
Never use fixed sleeps to guess when work is done
await user.click(screen.getByRole("button", { name: "Complete course" }));
expect(screen.getByRole("button", { name: "Completing…" })).toBeDisabled();
expect(await screen.findByText("Course completed")).toBeVisible();
expect(screen.queryByText("Completing…")).not.toBeInTheDocument();Mocks are useful at slow, nondeterministic, destructive, or external boundaries. Mocking every internal collaborator couples tests to the current call graph and can make false behavior pass.
Prefer a small fake repository, an HTTP interception layer, or an injected clock over replacing private functions with spies. Keep the substitute faithful to the public contract.
Mock systems outside the behavior under test
Prefer contract-faithful fakes over brittle call assertions
Let internal refactors pass unchanged behavior tests
type Clock = { now(): Date };
function completedAt(clock: Clock): string {
return clock.now().toISOString();
}
const fixedClock: Clock = {
now: () => new Date("2026-09-10T12:00:00.000Z"),
};
expect(completedAt(fixedClock)).toBe("2026-09-10T12:00:00.000Z");Network interception can exercise the real client request and response mapping without reaching a live dependency. Model success, authorization failures, invalid payloads, delays, and transport errors.
Validate both sides of important contracts with shared schemas or provider tests. A mock that accepts impossible requests can hide production integration failures.
Intercept at the HTTP boundary
Exercise success and meaningful failure responses
Keep fixtures aligned with validated production contracts
server.use(
http.get("*/v1/learning/guides/typescript-foundations", () =>
HttpResponse.json(
{ error: { code: "FORBIDDEN", message: "PRO access required." } },
{ status: 403 },
),
),
);Integration tests connect several real modules around a controlled boundary, such as a page, query client, router, and intercepted service. They reveal cache-key, loading-state, and component-contract errors that isolated units miss.
Reset shared state between tests and construct the same providers production uses. Keep each test focused on one workflow even when the harness is realistic.
Use production-like providers and routing
Reset query caches and storage between tests
Assert the workflow across module boundaries
function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: Infinity },
mutations: { retry: false },
},
});
}End-to-end tests validate deployed-like behavior through a real browser, but they are slower and have more failure surfaces. Reserve them for high-value journeys and integration seams.
Control test data through supported APIs, use deterministic accounts, and assert stable user-visible checkpoints. Avoid depending on the order or residue of another test.
Automate the few journeys whose failure would matter most
Create and clean up isolated test data
Use semantic locators and observable checkpoints
1. Authenticate as an Interview+ learner.
2. Open the TypeScript course from the roadmap.
3. Verify protected content is visible without an upsell.
4. Complete the course once.
5. Reload and confirm completed progress persists.A flaky test has an uncontrolled dependency such as time, scheduling, network, shared data, or animation. Retrying it can hide the evidence without correcting the cause.
Freeze clocks, seed randomness, isolate data, wait on conditions, and disable nonessential motion. Record diagnostics such as traces only where they help explain failure.
Identify the uncontrolled input behind intermittent failures
Replace timing guesses with observable conditions
Use retries only after making the test deterministic
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-09-10T12:00:00.000Z"));
});
afterEach(() => {
vi.useRealTimers();
});Coverage reports show which code executed, not whether behavior was asserted well. Use uncovered branches to find missing risk scenarios rather than optimizing for a percentage alone.
A maintainable suite gives fast local feedback, runs deterministically in CI, and makes failures easy to diagnose. Delete redundant tests when stronger behavior tests make them unnecessary.
Interpret coverage in the context of risk
Keep fast checks early in the CI pipeline
Treat test code as production-quality maintained code
Pull request: lint → types → unit and integration → build
Protected branch: database contract tests → focused browser journeys
Nightly: broader browser matrix and performance budgets
Failure artifact: concise logs, screenshot, trace when actionable