Topic #47Core7 min read

Testing (Vitest + React Testing Library + Playwright)

The frontend test pyramid: many fast component tests with Vitest + RTL, a few end-to-end flows with Playwright.

#testing#vitest#react-testing-library#playwright#e2e

The frontend test pyramid mirrors the backend one: write many fast, cheap tests at the bottom and few slow, expensive ones at the top. At the base sit unit and component tests run with Vitest + React Testing Library (RTL) — they render a component in a simulated DOM (jsdom) and run in milliseconds. At the top sit a handful of end-to-end (e2e) tests run with Playwright, which drives a real browser through a whole user flow.

RTL's core philosophy is to test behavior, not implementation: it tests components the way a user uses them — finding text on screen, typing into a labelled field, clicking a button — rather than inspecting internal state, props, or component instances. This means you query the DOM by accessible roles and labels (getByRole('button', { name: /add/i }), getByLabelText(/amount/i)), which both reflects real usage and nudges you toward accessible markup. Tests stay green when you refactor internals, and only break when actual user-visible behavior changes.

Unit vs integration vs e2e: a unit test exercises one function or component in isolation (often with mocks, e.g. vi.fn() for callbacks). An integration test renders several components together and checks they cooperate (a form submitting and a list updating). An e2e test runs the deployed app in a browser and validates a full journey — navigating, filling the form, asserting the result appears, and checking that an unauthenticated visit to /admin redirects to /login.

The two layers use different query styles for the same idea. RTL exposes screen.getByRole / getByLabelText / getByText and the userEvent library for realistic interactions; mocks like vi.fn() let you assert a callback was (or wasn't) called. Playwright exposes page.getByLabel, page.getByRole, and assertions like expect(page).toHaveURL(...) and expect(locator).toBeVisible(). The table contrasts the levels:

LevelToolScopeSpeedUse for
Unit / ComponentVitest + RTLOne component in jsdomVery fast (ms)Validation, rendering, callbacks
IntegrationVitest + RTLSeveral components togetherFastComponents cooperating
End-to-endPlaywrightFull app in a real browserSlowCritical user journeys
Backend Analogy

It is the same pyramid you build in Java: many JUnit unit tests at the base (Vitest + RTL), fewer Spring/Vert.x integration tests in the middle, and a small set of full system/e2e tests at the top (Playwright). And just as you'd assert observable outputs from a service rather than poking its private fields, RTL asserts on what the user sees instead of internal component state — vi.fn() is your Mockito mock for verifying interactions.

Key Insights
  • Test pyramid: lots of fast Vitest + RTL component tests, a few slow Playwright e2e tests. Cost and speed scale up the pyramid.
  • RTL philosophy: test behavior, not implementation. Query by accessible role/label/text and interact via userEvent — never inspect internal state.
  • Querying by role and label both mirrors real user behavior and pushes you toward accessible markup; tests survive refactors and break only on real behavior changes.
  • Use mocks (vi.fn()) to assert callbacks were called with the right data — or asserts they were NOT called, e.g. when validation should block submit.

Worked Code

Component test: Vitest + React Testing Library
TSX
// Component test: Vitest + React Testing Library
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, test, expect, vi } from 'vitest';
import { ExpenseForm } from './ExpenseForm';

describe('ExpenseForm', () => {
  test('shows validation error for empty amount', async () => {
    const onAdd = vi.fn();
    render(<ExpenseForm onAdd={onAdd} />);
    await userEvent.click(screen.getByRole('button', { name: /add/i }));
    expect(screen.getByText(/amount is required/i)).toBeInTheDocument();
    expect(onAdd).not.toHaveBeenCalled();
  });

  test('submits valid data and resets form', async () => {
    const onAdd = vi.fn();
    render(<ExpenseForm onAdd={onAdd} />);
    await userEvent.type(screen.getByLabelText(/amount/i), '250');
    await userEvent.selectOptions(screen.getByLabelText(/category/i), 'travel');
    await userEvent.click(screen.getByRole('button', { name: /add/i }));
    expect(onAdd).toHaveBeenCalledWith(
      expect.objectContaining({ amount: 250, category: 'travel' })
    );
  });

  test('renders expense list correctly', () => {
    const items = [{ id: 1, category: 'meals', amount: 120, approved: true }];
    render(<ExpenseList items={items} />);
    expect(screen.getByText(/meals/)).toBeInTheDocument();
    expect(screen.getByText(/120/)).toBeInTheDocument();
  });
});
End-to-end test: Playwright
TypeScript
// End-to-end test: Playwright
import { test, expect } from '@playwright/test';

test('user can add an expense end to end', async ({ page }) => {
  await page.goto('/expenses');
  // Fill the form
  await page.getByLabel('Amount').fill('250');
  await page.getByLabel('Category').selectOption('travel');
  await page.getByLabel('Description').fill('Client meeting');
  // Submit
  await page.getByRole('button', { name: 'Add Expense' }).click();
  // Verify it appears in the list
  await expect(page.getByText('250')).toBeVisible();
  await expect(page.getByText('travel')).toBeVisible();
});

test('login redirects unauthenticated users', async ({ page }) => {
  await page.goto('/admin');
  await expect(page).toHaveURL('/login');
});

Interview-Ready Q&A

The pyramid has many fast, cheap tests at the base and few slow, expensive ones at the top. Unit/component tests (Vitest + RTL) render one component in jsdom and run in milliseconds — they form the wide base. Integration tests render several components together to verify they cooperate. End-to-end tests (Playwright) drive a real browser through a full user journey and are the slow, narrow tip. You write the most tests at the bottom because they're fast and stable, and only a handful of e2e tests covering critical flows because they're slow and brittle.

Things to Remember
  • 1Test pyramid: many Vitest + RTL component tests, few Playwright e2e tests.
  • 2RTL = test behavior not implementation: query by role/label/text, interact with userEvent.
  • 3Use vi.fn() mocks to assert callbacks were called (or not); Playwright runs the real app for full flows.

References & Further Reading