Test components the way users use them with Vitest and React Testing Library, then cover critical flows end-to-end in a real browser with Playwright.
Why: component tests render the component and interact with it the way a user would — find by role and text, click, assert on what is visible. Testing implementation details (state, CSS selectors) makes tests break on every refactor; testing behavior does not.
$ pnpm add -D vitest jsdom @testing-library/react @testing-library/user-eventimport { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { expect, test } from 'vitest'
// The Counter component from the useState lesson,
// saved as Counter.tsx in the same folder as this test
import Counter from './Counter'
test('increments when clicked', async () => {
render(<Counter step={2} />)
// Query like a user would: by role and visible text
const button = screen.getByRole('button', { name: /count: 0/i })
await userEvent.click(button)
// After one click with step={2}, the label should read "Count: 2"
expect(screen.getByRole('button', { name: /count: 2/i })).toBeDefined()
})Why: end-to-end tests drive the real app in a real browser — routing, server, database, everything wired together. A handful of E2E tests over your critical flows catch what unit tests cannot.
$ pnpm create playwrightimport { test, expect } from '@playwright/test'
// Drives the Signup form from the Forms lesson (app/signup/page.tsx) —
// start the app first with pnpm dev (or npm run dev), then run the test
// page is a real browser tab that the test drives
test('user can sign up', async ({ page }) => {
await page.goto('http://localhost:3000/signup')
// Find elements the way a user would, then act on them
await page.getByPlaceholder('you@example.com').fill('ada@example.com')
await page.getByRole('button', { name: 'Sign up' }).click()
// Passes once the welcome message appears
await expect(page.getByText('Welcome, ada')).toBeVisible()
})