Yashveer Singh
Connect
<- All posts
Web App and Frontend Development12 min read

The Frontend Testing Strategy That Works

A frontend testing strategy that works is one that tests user-visible behavior rather than implementation details, uses the right tool for each type of test, and produces a test suite that can be maintained without constant rewriting as the implementation changes. The three layers that deliver the most value: unit tests for pure functions and custom hooks, integration tests for components that render real behavior (React Testing Library, not Enzyme), and end-to-end tests for the critical user paths that must work for the business to function.

Written by Yashveer Singh, founder of Yashveer Labs.

What you actually need to know

  • Test user behavior, not implementation details. Tests that assert function calls or internal state are tests that break on refactors.
  • React Testing Library is the right tool for component testing. Enzyme tests implementation details; Testing Library tests rendered output.
  • Playwright is the right tool for end-to-end tests in 2025. It is faster and more reliable than Cypress for most use cases.
  • Mock Service Worker (MSW) is the right tool for mocking API calls in tests. It intercepts at the network level and allows the same data fetching code to be tested.
  • The critical paths -- login, signup, checkout, the core user action -- need end-to-end test coverage. Everything else is optional.
Test TypeToolWhat It TestsMaintenance CostConfidence Level
Pure functionsVitest or JestLogic correctnessVery lowHigh for the function
Custom hooksTesting Library (renderHook)Hook behaviorLowHigh for the hook
Component integrationReact Testing LibraryRendered behaviorLow to mediumHigh for the feature
End-to-endPlaywrightFull user flowsMediumHighest for critical paths
Visual regressionStorybook + ChromaticUI appearanceMediumHigh for visual accuracy

The core argument

Most frontend test suites fail not because the tests are wrong but because they are testing the wrong things. A test suite full of tests that verify component internal state, function call counts, and CSS class names is a test suite that breaks constantly during refactors -- not because the behavior changed, but because the implementation changed. Engineers who spend more time updating tests than writing features quickly learn that the test suite is a burden, not an asset.

The testing strategy that works is organized around the principle that tests should describe what the user can see and do, not how the code accomplishes it. A test that says "when the user submits a form with an invalid email, an error message appears below the input" is a test that survives any refactor that preserves that behavior. A test that says "when the form is submitted, the validate function is called with the email value" is a test that breaks every time the validation implementation changes.

I have maintained frontend codebases with both types of test suites. The behavior-based suites were maintained with low overhead -- they rarely broke on refactors and caught real regressions. The implementation-based suites required constant maintenance and were frequently disabled or skipped in CI because they broke too often to be useful.

Unit tests for pure functions and hooks

Pure functions -- functions that take inputs and return outputs without side effects -- are the easiest things to test. The test is straightforward: provide inputs, assert the expected output. These tests are low maintenance, run fast, and provide high confidence for the specific function.

```ts // Pure function function formatCurrency(cents: number, currency: string): string { ... }

// Test it('formats cents as currency string', () => { expect(formatCurrency(1099, 'USD')).toBe('$10.99'); }); ```

Custom hooks are tested with React Testing Library's renderHook utility. This renders the hook in a minimal React component wrapper and allows the hook's return values to be asserted without building a full component:

``ts it('returns sorted invoices', () => { const { result } = renderHook(() => useInvoiceList()); // assert on result.current }); ``

The unit test investment is most valuable for hooks with complex logic: multi-step state machines, hooks that derive computed values from multiple inputs, hooks with complex conditional behavior. Simple hooks and trivial functions do not need unit tests -- the integration test at the component level covers them.

Component integration tests with React Testing Library

Component integration tests are the highest-value tests in a frontend suite. They render a component with its real dependencies (real hooks, real context, mocked network) and assert on the rendered output from the user's perspective.

The Testing Library query hierarchy, ordered by preference: getByRole (tests semantic HTML, most resilient), getByLabelText (tests form labels, important for accessibility), getByText (tests visible content), getByTestId (last resort, implementation detail). Tests that use getByRole and getByLabelText are testing the accessibility of the UI simultaneously with the functionality.

The test pattern that works:

```tsx it('shows error message when form is submitted with empty email', async () => { render(<SignupForm />);

await userEvent.click(screen.getByRole('button', { name: /sign up/i }));

expect(screen.getByRole('alert')).toHaveTextContent('Email is required'); }); ```

This test does not know how the validation is implemented. It does not know whether the error state is in React state or React Hook Form state. It tests that the user who clicks the submit button without entering an email sees an error message -- which is the behavior that matters.

The pattern that fails: asserting on component props, internal state, or specific CSS classes. These assertions break on every refactor even when the behavior is preserved.

End-to-end tests with Playwright

Playwright end-to-end tests run against the full application -- the real frontend against the real backend (or a controlled backend environment) -- and test complete user flows. They are the most expensive tests to write and maintain but provide the highest confidence that the critical paths work.

The critical paths that always need end-to-end coverage: authentication (login and signup), the checkout or payment flow, and the core user action that defines the product's value. For a project management tool, the core action might be creating and assigning a task. For an invoicing tool, it is creating and sending an invoice. These flows must work for the business to function.

Playwright's API is designed for resilience to timing issues -- the most common cause of flaky end-to-end tests. page.getByRole, page.getByLabel, and page.getByText wait for the element to appear before interacting with it. await page.click() waits for the element to be interactive before clicking. The built-in waiting eliminates the most common source of flakiness.

``ts test('user can complete signup and reach onboarding', async ({ page }) => { await page.goto('/signup'); await page.getByLabel('Email').fill('test@example.com'); await page.getByLabel('Password').fill('securepassword123'); await page.getByRole('button', { name: 'Create account' }).click(); await expect(page).toHaveURL('/onboarding'); }); ``

Mock Service Worker for network mocking

Mock Service Worker (MSW) intercepts fetch and XHR requests at the network level, before they leave the browser. This means the component being tested uses the same data fetching code it uses in production -- only the response is replaced with a controlled test response.

``ts // In test setup server.use( http.get('/api/invoices', () => { return HttpResponse.json([{ id: 1, amount: 1099, status: 'paid' }]); }) ); ``

The component renders, calls its real data fetching hook, which calls the real API function, which is intercepted by MSW and returns the test response. No mocking at the import level, no spy on fetch -- the real code path is tested with controlled data.

This approach is more reliable than mocking at the import level because it tests the full data fetching path. It also works for error states: returning a 500 response from MSW tests how the component renders when the API fails.

Common mistakes teams make with frontend testing

  1. Writing tests before the feature is stable. Tests written during active development break constantly because the implementation is changing. Write tests when the feature is substantially complete.
  2. Testing that a function was called rather than that the UI updated. Function call assertions are implementation details. UI update assertions are behavior.
  3. Using getByTestId as the primary query method. data-testid attributes are not visible to users and break when renamed. Use semantic queries first.
  4. Not running tests in CI. A test suite that is not enforced in CI is a test suite that is not maintained. Failing tests accumulate because there is no consequence to introducing them.
  5. Adding Playwright end-to-end tests for every feature. End-to-end tests are expensive to maintain and should be reserved for critical paths. Integration tests cover most features adequately.

Where to start: a 3-step testing setup

Step 1: Install Vitest (or Jest), React Testing Library, and MSW. Configure them to run in CI on every PR. Write one integration test for the most critical form in the application. This test establishes the pattern for every subsequent integration test.

Step 2: Install Playwright and write end-to-end tests for the three critical paths. Authentication, the core user action, and the most important data entry flow. Run these tests in CI on every merge to main (not on every PR -- they are slow enough that PR gating should be reserved for the integration tests).

Step 3: Add a rule in the contributing guide: every bug fix requires a test that reproduces the bug. A test that was failing before the fix and passing after is the proof that the fix is correct. This practice builds test coverage organically over time without requiring a dedicated coverage improvement sprint.

The Tests That Earn Their Weight

Yashveer Singh. Founder of Yashveer Labs. The testing strategy I follow on client projects is the one I have described: React Testing Library for component behavior, Playwright for critical path end-to-end coverage, and MSW for network mocking. On the Nyxera platform, the integration tests caught three regressions during a refactor that would have been invisible without them -- the refactor changed implementation details while preserving user-visible behavior, and the tests confirmed the behavior was preserved. That confidence is the return on the testing investment.

Related reading

FAQ

Frequently asked

Author

The person who wrote this

Yashveer Singh wrote this. Class 12, Commerce track, full stack developer. The categories do not align, which is the point. The work runs in production. Everything else is paperwork. If the project on your plate is the one this article describes, you can reach me through the contact page or through Instagram. I will read it. I will reply. That is the standard.

Related reading