Back to Guides
GuideIntermediate

Testing Strategies for Modern Web Apps

A comprehensive guide to testing including unit tests, integration tests, and end-to-end testing with real-world examples.

March 5, 202440 min read
TestingVitestPlaywrightTDD
Tests don't slow you down — lack of tests does. A well-structured test suite lets you refactor confidently, catch regressions automatically, and ship with less anxiety. This guide covers every level of the testing pyramid with concrete examples using the tools I reach for: Vitest, Testing Library, and Playwright. THE TESTING PYRAMID The testing pyramid describes the ideal distribution of test types across your project. At the base are unit tests — cheap to write, fast to run, and you should have lots of them. In the middle are integration tests that verify multiple units working together. At the top are end-to-end tests that drive a real browser against a running application. Unit tests are your best investment for pure functions, custom hooks, and business logic. They run in milliseconds, give pinpoint error messages, and are easy to write as you build each piece. A good rule of thumb is to shoot for 70 percent of your test suite being unit tests. Integration tests verify that the seams between your units work correctly — a form component talking to an API hook, or a route handler reading from a database. They are slower than unit tests but faster than E2E tests. Aim for about 20 percent of your suite here. End-to-end tests drive a real browser against a running application. They are slower and more expensive to maintain, but they give the highest confidence that the whole system works from a user's perspective. Keep these at about 10 percent, focused on your most critical user flows. UNIT TESTING WITH VITEST Vitest is the modern choice for unit testing in Vite-based projects. It is Jest-compatible, meaning it uses the same API and same matchers you may already know, but it is faster because it shares your Vite config rather than requiring a separate transform pipeline. Install it with npm install -D vitest @vitest/coverage-v8. Add a test configuration block to your vite.config.ts, setting the environment to jsdom for browser-like globals, enabling globals so you do not need to import describe and it in every file, and pointing to a setup file where you can import testing utilities. Add test scripts to your package.json: one for watch mode (vitest), one for a single run (vitest run), and one for coverage (vitest run --coverage). For pure functions, the test pattern is straightforward. Import the function, describe a group of related cases with describe, and assert expected outputs with expect and matchers like toBe, toEqual, or toBeCloseTo. Keep each test focused on one behavior so failure messages are immediately clear. For custom hooks, use renderHook from Testing Library to render the hook in isolation, and wrap any state-updating calls in act to flush React's update queue before asserting. COMPONENT TESTING WITH TESTING LIBRARY Testing Library's core philosophy is to test components the way users interact with them. Query elements by role, label, or visible text rather than by CSS class or implementation detail. This makes tests resilient to internal refactoring and more meaningful as documentation. Install @testing-library/react, @testing-library/user-event, and @testing-library/jest-dom. Import jest-dom in your setup file to enable matchers like toBeInTheDocument, toBeDisabled, and toHaveTextContent. Use userEvent.setup() rather than fireEvent for user interactions. userEvent simulates real browser events including pointer and keyboard events, making tests closer to actual user behavior. Query elements with screen.getByRole for interactive elements, screen.getByLabelText for form inputs, and screen.getByText for static content. Avoid getByTestId — it is a last resort that adds implementation coupling without testing anything meaningful. For async behavior, use waitFor to wrap assertions that depend on state updates from async operations. This retries the assertion until it passes or times out, which is more reliable than using arbitrary delays. INTEGRATION TESTING Integration tests verify multiple units working together. Use real implementations for code you own and mock only external boundaries — network calls, third-party APIs, email services. The best tool for mocking network requests is msw, which stands for Mock Service Worker. It intercepts HTTP requests at the network level using the same fetch API your application uses, without patching fetch or Axios globally. This means your data fetching code runs exactly as it would in production, just with controlled responses. Install msw and create a server instance using setupServer from msw/node. In your test setup file, call server.listen before tests run, server.resetHandlers after each test to clear per-test overrides, and server.close after all tests finish. In individual tests, call server.use to register request handlers for that specific test. Use http.get, http.post, and other methods from msw to match routes, and return HttpResponse.json with your test data. This lets you test both the happy path and error states in a controlled way. END-TO-END TESTING WITH PLAYWRIGHT Playwright drives a real browser — Chromium, Firefox, or WebKit — against a running application. Use it for your most critical user flows: signup, login, checkout, core feature interactions. Install @playwright/test and run npx playwright install to download browser binaries. Configure it in playwright.config.ts with your base URL, parallelism settings, retry counts (set to 2 on CI to handle flakiness), and optionally a webServer block that starts your dev server automatically before tests run. Write tests using Playwright's page object. Navigate with page.goto, fill inputs with page.getByLabel.fill, click buttons with page.getByRole.click, and assert with expect(page).toHaveURL or expect(locator).toBeVisible. Playwright's locator API is auto-retrying — assertions wait for elements to appear rather than requiring you to add explicit waits. For larger test suites, the Page Object Model pattern pays off. Create a class for each page with methods for common actions like login or navigate. Tests become readable prose that describes user behavior rather than low-level DOM interactions. Configure Playwright to run in headless mode on CI and to save a trace on first retry. Traces record video, network activity, and snapshots that you can view in the Playwright Trace Viewer to understand exactly what happened when a test failed. TEST-DRIVEN DEVELOPMENT WORKFLOW TDD means writing a failing test before writing the implementation. The cycle is red, green, refactor — write a failing test, write the minimum code to make it pass, then clean up. The practical benefit is not the tests themselves but the design pressure they create. Code that is hard to test is usually code with too many dependencies, unclear boundaries, or mixed concerns. Writing the test first forces you to think about the interface before the implementation, which tends to produce simpler, more composable code. Start by writing a describe block with the behavior you want to implement and one or two it blocks describing specific cases. Run the tests — they fail because the code does not exist yet. Write the simplest implementation that makes them pass, then refactor knowing the tests will catch regressions. MOCKING STRATEGIES Vitest provides vi.fn() for creating mock functions, vi.mock to replace entire modules, and vi.stubGlobal to replace global values like fetch or Date. For mock functions, you can chain .mockReturnValue, .mockResolvedValue, or .mockRejectedValue to control what they return. After calling code that uses the mock, assert on calls with .toHaveBeenCalledWith or .toHaveBeenCalledTimes. Module mocking with vi.mock replaces an entire module's exports with mock implementations. You can do a full replacement by returning new implementations from the factory, or a partial replacement using importOriginal to get the real module and spread it with your overrides. The most important rule about mocking: avoid mocking code you own. If you mock your own database layer, you are only testing that the mock works, not the real implementation. Mock only third-party APIs and network calls, timers and Date.now when testing time-dependent logic, non-deterministic values like random IDs, and side effects like email or SMS sending that should not run in tests. CI INTEGRATION Run tests in CI on every push and pull request. Structure your pipeline with unit tests first — they are fast and cheap, and if they fail there is no point running E2E tests. Run E2E tests in a separate job after the build step. For Playwright, install browser dependencies with npx playwright install --with-deps. Run your production build and serve it (or let the webServer config in playwright.config.ts handle that). Upload the playwright-report directory as an artifact on failure so you can inspect traces and screenshots without re-running the suite locally. Track coverage with @vitest/coverage-v8 and upload reports to a service like Codecov to track trends over time. A sudden coverage drop on a PR is often a signal that new code was added without tests. WRAPPING UP The tools are straightforward: Vitest for units and component tests, msw for network mocking, Playwright for end-to-end tests. The harder part is discipline — writing tests as you build, keeping them fast, and resisting the urge to mock everything. The payoff is a codebase you can change without fear, and that confidence compounds over time.

Related Content