The Problem
Noa's team has a React application with 200+ components and growing, but only 30 hand-written tests — mostly snapshot tests that break on every CSS change and catch nothing. The team skips writing tests because the existing suite is slow (jsdom takes 40 seconds) and the tests don't catch real bugs. Last month, a pricing calculation bug shipped to production because nobody thought to test negative quantities. The quarter before that, a form refactor broke accessibility for screen reader users — no test caught it because they were all testing CSS classes.
The Solution
Build a three-layer testing strategy: Testing Library for user-centric component tests (catches accessibility issues by default), fast-check for property-based testing of business logic (finds edge cases like negative quantities automatically), and Happy DOM as the test environment (3-5x faster than jsdom). Vitest orchestrates everything with parallel execution and watch mode.
Step-by-Step Walkthrough
Step 1: Test Infrastructure
npm install -D vitest happy-dom @testing-library/react @testing-library/jest-dom \
@testing-library/user-event fast-check @vitejs/plugin-react msw
// vitest.config.ts — Test configuration optimized for speed
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
test: {
environment: "happy-dom", // 3-5x faster than jsdom
globals: true, // No need to import describe/it/expect
setupFiles: ["./tests/setup.ts"],
css: false, // Don't process CSS in tests
coverage: {
provider: "v8",
include: ["src/**/*.{ts,tsx}"],
exclude: ["src/**/*.test.*", "src/**/*.stories.*"],
thresholds: { branches: 80, functions: 80, lines: 80 },
},
},
resolve: {
alias: { "@": "./src" },
},
});
// tests/setup.ts — Global test setup
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach } from "vitest";
// Auto-cleanup after each test
afterEach(() => cleanup());
Step 2: Component Tests with Testing Library
Test components the way users interact with them — by role, text, and label.
// src/components/PricingCalculator.test.tsx
/**
* Tests the pricing calculator from the user's perspective.
* Finds elements by accessible roles — if a screen reader can't find
* the button, neither can this test.
*/
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { PricingCalculator } from "./PricingCalculator";
describe("PricingCalculator", () => {
it("calculates total for a standard order", async () => {
const user = userEvent.setup();
render(<PricingCalculator />);
// Find by accessible label — tests accessibility for free
await user.type(screen.getByLabelText("Quantity"), "5");
await user.selectOptions(screen.getByLabelText("Plan"), "pro");
// Verify the calculated total is displayed
const summary = screen.getByRole("region", { name: "Order Summary" });
expect(within(summary).getByText("$245.00")).toBeInTheDocument();
});
it("applies discount code and shows savings", async () => {
const user = userEvent.setup();
render(<PricingCalculator />);
await user.type(screen.getByLabelText("Quantity"), "10");
await user.type(screen.getByLabelText("Discount Code"), "SAVE20");
await user.click(screen.getByRole("button", { name: "Apply" }));
// Discount applied
expect(screen.getByText(/20% off/i)).toBeInTheDocument();
expect(screen.getByText("$392.00")).toBeInTheDocument(); // 490 * 0.8
expect(screen.getByText("You save $98.00")).toBeInTheDocument();
});
it("shows validation error for zero quantity", async () => {
const user = userEvent.setup();
render(<PricingCalculator />);
await user.clear(screen.getByLabelText("Quantity"));
await user.type(screen.getByLabelText("Quantity"), "0");
await user.click(screen.getByRole("button", { name: "Calculate" }));
expect(screen.getByRole("alert")).toHaveTextContent("Quantity must be at least 1");
});
it("is keyboard navigable", async () => {
const user = userEvent.setup();
render(<PricingCalculator />);
// Tab through the form
await user.tab();
expect(screen.getByLabelText("Quantity")).toHaveFocus();
await user.tab();
expect(screen.getByLabelText("Plan")).toHaveFocus();
await user.tab();
expect(screen.getByLabelText("Discount Code")).toHaveFocus();
// Submit with Enter
await user.tab();
expect(screen.getByRole("button", { name: "Calculate" })).toHaveFocus();
await user.keyboard("{Enter}");
});
});
Step 3: Property-Based Testing for Business Logic
Hand-written tests only cover cases you think of. fast-check generates thousands of random inputs to find the ones you didn't.
// src/lib/pricing.test.ts — Property-based pricing logic tests
/**
* These tests found 3 bugs in the first run:
* 1. Negative quantity produced negative prices
* 2. Discount > 100% produced negative totals
* 3. Floating point rounding gave $49.999999 instead of $50.00
*/
import fc from "fast-check";
import { calculatePrice, applyDiscount, formatCurrency } from "./pricing";
describe("calculatePrice — property-based", () => {
it("price is always non-negative", () => {
fc.assert(
fc.property(
fc.integer({ min: -1000, max: 1000 }), // Include negative to test validation
fc.constantFrom("free", "starter", "pro", "enterprise"),
(quantity, plan) => {
const price = calculatePrice(quantity, plan);
expect(price).toBeGreaterThanOrEqual(0);
}
)
);
});
it("price increases with quantity (for positive quantities)", () => {
fc.assert(
fc.property(
fc.integer({ min: 1, max: 10000 }),
fc.integer({ min: 1, max: 10000 }),
fc.constantFrom("starter", "pro", "enterprise"),
(q1, q2, plan) => {
if (q1 < q2) {
expect(calculatePrice(q1, plan)).toBeLessThanOrEqual(calculatePrice(q2, plan));
}
}
)
);
});
it("discount never makes price higher", () => {
fc.assert(
fc.property(
fc.integer({ min: 1, max: 1000 }),
fc.constantFrom("starter", "pro", "enterprise"),
fc.integer({ min: 0, max: 200 }), // Include >100% to test validation
(quantity, plan, discountPercent) => {
const original = calculatePrice(quantity, plan);
const discounted = applyDiscount(original, discountPercent);
expect(discounted).toBeLessThanOrEqual(original);
expect(discounted).toBeGreaterThanOrEqual(0);
}
)
);
});
it("formatCurrency roundtrips correctly", () => {
fc.assert(
fc.property(
fc.integer({ min: 0, max: 99999999 }), // Cents
(cents) => {
const formatted = formatCurrency(cents); // "$123.45"
const parsed = parseCurrency(formatted); // 12345
expect(parsed).toBe(cents);
}
)
);
});
});
Step 4: API Mocking with MSW
// tests/mocks/handlers.ts — Mock API responses
import { http, HttpResponse } from "msw";
export const handlers = [
http.get("/api/plans", () =>
HttpResponse.json([
{ id: "free", name: "Free", price: 0, limit: 1000 },
{ id: "pro", name: "Pro", price: 4900, limit: 100000 },
])
),
http.post("/api/checkout", async ({ request }) => {
const body = await request.json();
if (body.quantity <= 0) {
return HttpResponse.json({ error: "Invalid quantity" }, { status: 400 });
}
return HttpResponse.json({ sessionId: "cs_test_123", url: "https://checkout..." });
}),
];
// tests/mocks/server.ts
import { setupServer } from "msw/node";
import { handlers } from "./handlers";
export const server = setupServer(...handlers);
// tests/setup.ts — Add MSW to test setup
import { server } from "./mocks/server";
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
The Outcome
Noa's test suite goes from 30 brittle snapshot tests to 180 meaningful tests across three layers. Component tests with Testing Library catch accessibility regressions automatically — the keyboard navigation test would have caught last quarter's screen reader bug. Property-based tests with fast-check found 3 pricing bugs on the first run: negative quantity prices, discounts over 100%, and floating-point rounding errors. The full suite runs in 3.2 seconds on Happy DOM (down from 40 seconds on jsdom). Watch mode re-runs affected tests in 200ms. Coverage sits at 87%, with the property-based tests covering thousands of input combinations that hand-written tests never would.