1. Vitest Setup
Vitest is a test runner built for Vite projects — same configuration, same fast dev-server-style re-runs, and a Jest-compatible API if you've used Jest before.
npm install -D vitest @testing-library/react @testing-library/user-event @testing-library/jest-dom jsdom
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom', // simulates a browser DOM in Node
setupFiles: './src/test-setup.ts',
globals: true,
},
});
import '@testing-library/jest-dom'; // adds matchers like .toBeInTheDocument()
import { describe, it, expect } from 'vitest';
function sum(a: number, b: number) {
return a + b;
}
describe('sum', () => {
it('adds two numbers', () => {
expect(sum(2, 3)).toBe(5);
});
});
jsdom is what makes browser-like testing possible in Node at all — it
simulates a DOM well enough that React can render into it and your test can query the
result, without ever opening a real browser.
2. React Testing Library's Philosophy
React Testing Library (RTL) is built around one guiding principle, printed on its own homepage: "the more your tests resemble the way your software is used, the more confidence they can give you." In practice, that means testing what a user sees and does — visible text, buttons, form fields — rather than a component's internal state or implementation details.
// Fragile: breaks if you rename the internal state variable,
// even if the component's actual behavior is unchanged.
expect(wrapper.state('isOpen')).toBe(true);
expect(wrapper.find('.internal-class-name')).toHaveLength(1);
// Resilient: this passes as long as the user-visible outcome is correct,
// regardless of how the component achieves it internally.
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(screen.getByText('Settings saved')).toBeInTheDocument();
This philosophy is why RTL deliberately has no API for reaching into a component's
props or internal state — it's a constraint, not a missing feature. A test written
this way keeps passing through a component being refactored from
useReducer to useState, or from a class to a function,
because none of that changes what the user actually sees or can click.
3. Queries & User Events
RTL's queries (getByRole, getByLabelText,
getByText...) find elements the way an assistive technology or a real
user would identify them — which is also why writing tests this way tends to surface
accessibility problems (Week 18) for free.
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect } from 'vitest';
import Counter from './Counter';
describe('Counter', () => {
it('increments when the button is clicked', async () => {
const user = userEvent.setup();
render(<Counter />);
expect(screen.getByText('Count: 0')).toBeInTheDocument();
const button = screen.getByRole('button', { name: /increment/i });
await user.click(button);
expect(screen.getByText('Count: 1')).toBeInTheDocument();
});
});
getByRole('button', {'{ name: /increment/i }'}) finds the button the
same way a screen reader would announce it — by its accessible role and name — not
by a CSS class or test-specific attribute. @testing-library/user-event
(not the lower-level fireEvent) simulates a real click as a full sequence
of browser events (pointer down, focus, pointer up, click), catching bugs a
simplified synthetic event would miss.
it('shows a validation error for an invalid email', async () => {
const user = userEvent.setup();
render(<SignupForm />);
await user.type(screen.getByLabelText('Email'), 'not-an-email');
await user.click(screen.getByRole('button', { name: /sign up/i }));
expect(await screen.findByText(/enter a valid email/i)).toBeInTheDocument();
});
findByText (rather than getByText) returns a promise and
retries until the element appears or a timeout elapses — necessary here because
React Hook Form's validation (Week 5) runs asynchronously after submission, so the
error message isn't in the DOM the instant click resolves.
4. Mocking Network Requests with MSW
A component test shouldn't depend on a real backend being available and returning
consistent data. Mock Service Worker (MSW) intercepts actual
fetch calls at the network level and returns data you control — your
component code doesn't know it's being tested at all.
npm install -D msw
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('/api/users/:id', ({ params }) => {
return HttpResponse.json({ id: Number(params.id), name: 'Ada Lovelace' });
}),
];
import '@testing-library/jest-dom';
import { setupServer } from 'msw/node';
import { handlers } from './mocks/handlers';
const server = setupServer(...handlers);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
it('shows the fetched user name', async () => {
render(<UserProfile userId={5} />);
expect(await screen.findByText('Ada Lovelace')).toBeInTheDocument();
});
This is a meaningfully better approach than mocking your fetchUser
function directly — UserProfile runs its actual, real fetch call, and
only the network layer underneath it is faked. The test verifies your real request
logic and real response handling, not just that a mocked function was called with
the right arguments.
5. End-to-End Tests with Playwright
Component tests (this week, so far) run in a simulated DOM and mock network layer — fast, but they never prove the whole app actually works together in a real browser. An end-to-end (e2e) test drives an actual browser against your actual running app.
npm init playwright@latest
import { test, expect } from '@playwright/test';
test('a user can sign up successfully', async ({ page }) => {
await page.goto('/signup');
await page.getByLabel('Name').fill('Grace Hopper');
await page.getByLabel('Email').fill('grace@example.com');
await page.getByLabel('Password').fill('supersecure123');
await page.getByLabel('Confirm password').fill('supersecure123');
await page.getByRole('button', { name: /sign up/i }).click();
await expect(page.getByText(/welcome, grace/i)).toBeVisible();
});
Notice the same accessible-query philosophy as RTL (getByLabel,
getByRole) — Playwright's API was deliberately designed to feel
consistent with Testing Library's. The key difference is scope: this test exercises
your real router (Weeks 6–7), your real form validation (Week 5), and a real network
request to whatever backend the app is actually pointed at — proving the whole
system works together, not just one component in isolation.
Write many fast component tests (this week's RTL work) covering individual pieces of behavior, and a small number of e2e tests covering only your most critical user flows (signup, checkout, login) end to end. E2e tests are slower and more brittle to run at scale — reserve them for the paths where "does this actually work in a real browser" matters most.
6. Hands-on Exercise
Write a full test suite for Week 5's signup form
Cover the form with component tests, then add one end-to-end smoke test.
Requirements:
- Set up Vitest + React Testing Library +
jest-domin a project containing Week 5's signup form. - Write a test confirming the form renders all expected fields with correct labels (
getByLabelTextfor each). - Write a test confirming a validation error appears when submitting with a mismatched
password/confirmPassword, usinguserEvent.typeandfindByText. - Write a test confirming a successful submission (valid data) shows a success message — mock the "submit" network call with MSW rather than testing against a real endpoint.
- Add one Playwright e2e test that fills out and submits the real form in a real browser, confirming the success message appears.
If getByLabelText can't find a field, it's almost always because the <label>'s htmlFor doesn't match the input's id — the same accessibility wiring from Week 5's exercise. A failing test here is often catching a real a11y bug, not just a test-configuration problem.
7. Knowledge Check
Four quick questions. Expand each to check your answer.
Q1
Why does React Testing Library deliberately not provide a way to read a component's internal state directly?
Why does React Testing Library deliberately not provide a way to read a component's internal state directly?
Its guiding principle is testing behavior the way a real user experiences it, not implementation details. Tests that check internal state break whenever that implementation changes — even if the user-visible behavior is identical — while tests written against visible text and roles keep passing through refactors, because they verify the same thing a user would actually notice.
Q2
Why prefer getByRole('button', {'{ name: ... }'}) over something like getByClassName or a test ID?
Why prefer getByRole('button', {'{ name: ... }'}) over something like getByClassName or a test ID?
It finds the element the same way an assistive technology would identify it — by its accessible role and name — which both survives CSS/styling refactors that would break a class-name query, and doubles as an accessibility check: if getByRole can't find your button, a screen reader user likely can't either.
Q3
What does MSW actually intercept, and why is that better than mocking your fetchUser function directly?
What does MSW actually intercept, and why is that better than mocking your fetchUser function directly?
It intercepts the actual network request at the network layer, letting your component's real fetchUser code run exactly as it would in production — only the response is faked. Mocking fetchUser itself skips testing that function's real logic entirely, so a bug inside it (a wrong URL, a wrong header) would go completely undetected.
Q4
What can a Playwright e2e test verify that an RTL component test cannot?
What can a Playwright e2e test verify that an RTL component test cannot?
That the whole system genuinely works together in a real browser — real routing, a real (or real-staging) backend, real network requests, real browser rendering and CSS. An RTL test runs one component in isolation inside a simulated DOM with mocked network calls; it can't catch an integration problem between pieces that only shows up when the full app runs together for real.