The Testing Pyramid in Practice
The testing pyramid suggests writing many unit tests, fewer integration tests, and even fewer end-to-end tests. While this model is a useful starting point, modern web applications often benefit from a slightly different balance. Integration tests that verify how components work together frequently catch bugs that unit tests miss, making them worth the additional cost.
The goal is not to achieve a specific ratio of test types but to build confidence that your application works correctly with the least amount of maintenance overhead.
Unit Testing with Vitest
Vitest has become the preferred test runner for modern JavaScript projects. It is compatible with the Jest API, so migration is straightforward, but it runs significantly faster thanks to native ES module support and integration with Vite.
import { describe, it, expect } from "vitest";
import { formatDate } from "./utils";
describe("formatDate", () => {
it("formats ISO dates to readable strings", () => {
expect(formatDate("2025-09-12")).toBe("September 12, 2025");
});
it("returns an empty string for invalid dates", () => {
expect(formatDate("not-a-date")).toBe("");
});
});
Focus your unit tests on pure functions and business logic. These are the tests that run fastest and provide the most reliable results because they have no external dependencies.
Component Testing
Component tests verify that your UI components render correctly and respond to user interaction. Tools like Testing Library encourage testing components the way users interact with them, rather than testing implementation details.
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { SearchBar } from "./SearchBar";
it("calls onSearch when the form is submitted", async () => {
const onSearch = vi.fn();
render(<SearchBar onSearch={onSearch} />);
await userEvent.type(screen.getByRole("searchbox"), "astro");
await userEvent.click(screen.getByRole("button", { name: /search/i }));
expect(onSearch).toHaveBeenCalledWith("astro");
});
Querying by role, label text, and other accessible attributes ensures your tests remain resilient to markup changes while also verifying accessibility.
End-to-End Testing with Playwright
End-to-end tests verify complete user workflows through the actual browser. Playwright provides a reliable, fast, and well-documented framework for writing these tests.
import { test, expect } from "@playwright/test";
test("user can publish a blog post", async ({ page }) => {
await page.goto("/admin");
await page.getByRole("link", { name: "New Post" }).click();
await page.getByLabel("Title").fill("My Test Post");
await page.getByRole("button", { name: "Publish" }).click();
await expect(page.getByText("Post published successfully")).toBeVisible();
});
Keep your end-to-end test suite focused on critical user paths. These tests are slower and more brittle than unit tests, so reserve them for workflows where a failure would have significant impact.
What to Test
Deciding what to test is as important as knowing how to test. Prioritize:
- Business logic and data transformations that your application depends on
- User flows that generate revenue or are critical to your product's value
- Edge cases and error handling where bugs are most likely to hide
- Integrations with external services where contract changes could break your application
Avoid testing framework internals, trivial code, or implementation details that change frequently. Every test has a maintenance cost, and tests that break without indicating a real bug erode confidence in your test suite.