What you'll build: A full test suite covering utility functions, individual components, and the integrated filtering behaviour — all running without the API server.
Time: 75 minutes
Reset command: npm run section:5:start (same as section-4/complete)
Tests exist to catch regressions — they verify that the code still does what you intended after you change it. Without tests, a refactor that breaks filtering or a typo that breaks a Badge might ship unnoticed.
The testing pyramid:
/\
/e2e\ few, slow, test full journeys
/------\
/component\ medium, test rendered output
/------------\
/ unit tests \ many, fast, test pure logic
/________________\
Today's tests are from the bottom two layers:
| Layer | Tool | What it tests |
|---|---|---|
| Unit | Vitest | Pure functions (helpers, data transforms) |
| Component | React Testing Library | Rendered HTML and user interactions |
| API mocking | MSW | Intercepts real fetch() calls in tests |
The key principle: test behaviour, not implementation.
Bad test (tests implementation):
expect(component.state.search).toBe('golden')Good test (tests behaviour):
await userEvent.type(searchInput, 'golden')
expect(screen.getByText('Max')).toBeInTheDocument()
expect(screen.queryByText('Whiskers')).not.toBeInTheDocument()The good test survives a complete refactor of how filtering works internally. The bad test breaks the moment you rename a state variable.
npm testVitest starts in watch mode. You should see 0 tests and no failures. Leave it running — it re-runs affected tests on every save.
Before testing components, test a pure function. Add a helper to format animal ages in src/utils/formatAge.ts:
export function formatAge(age: number): string {
return age === 1 ? '1 year' : `${age} years`
}Create src/utils/formatAge.test.ts:
import { describe, it, expect } from 'vitest'
import { formatAge } from './formatAge'
describe('formatAge', () => {
it('returns "1 year" for age 1', () => {
expect(formatAge(1)).toBe('1 year')
})
it('returns plural for any other age', () => {
expect(formatAge(3)).toBe('3 years')
expect(formatAge(10)).toBe('10 years')
})
it('handles age 0', () => {
expect(formatAge(0)).toBe('0 years')
})
})Save it. Vitest should show 3 passing tests immediately.
Now use formatAge in PetCard (replace the inline ternary). The tests you just wrote verify it still works correctly after you do that refactor.
Create src/components/atoms/Badge.test.tsx:
import { render, screen } from '@testing-library/react'
import { Badge } from './Badge'
describe('Badge', () => {
it('renders the label text', () => {
render(<Badge label="available" variant="available" />)
expect(screen.getByText('available')).toBeInTheDocument()
})
it('applies green styling for available variant', () => {
render(<Badge label="available" variant="available" />)
const badge = screen.getByText('available')
expect(badge).toHaveClass('bg-green-100', 'text-green-800')
})
it('applies yellow styling for reserved variant', () => {
render(<Badge label="reserved" variant="reserved" />)
expect(screen.getByText('reserved')).toHaveClass('bg-yellow-100')
})
it('applies grey styling for adopted variant', () => {
render(<Badge label="adopted" variant="adopted" />)
expect(screen.getByText('adopted')).toHaveClass('bg-gray-100')
})
})
renderandscreen—rendermounts the component into a virtual DOM.screengives you queries to find elements in that DOM —getByText,getByRole,queryByTextetc. Thescreenqueries are deliberately similar to how a user perceives the UI.
toBeInTheDocumentandtoHaveClass— These come from@testing-library/jest-domand are set up automatically insrc/test/setup.ts. They extend Vitest'sexpectwith DOM-aware matchers.
Create src/components/molecules/PetCard.test.tsx. You'll need a mock animal — import it from the test helpers:
import { render, screen } from '@testing-library/react'
import { PetCard } from './PetCard'
import { mockAnimals } from '@/test/msw-handlers'
const max = mockAnimals[0] // Max, available Golden Retriever
describe('PetCard', () => {
it('renders the animal name', () => {
render(<PetCard animal={max} />)
expect(screen.getByRole('heading', { name: 'Max' })).toBeInTheDocument()
})
it('renders the breed and age', () => {
render(<PetCard animal={max} />)
expect(screen.getByText('Golden Retriever')).toBeInTheDocument()
expect(screen.getByText(/3 years/)).toBeInTheDocument()
})
it('shows the available badge for an available animal', () => {
render(<PetCard animal={max} />)
expect(screen.getByText('available')).toBeInTheDocument()
})
it('shows a reserved badge for a reserved animal', () => {
const luna = mockAnimals.find(a => a.status === 'reserved')!
render(<PetCard animal={luna} />)
expect(screen.getByText('reserved')).toBeInTheDocument()
})
it('shows the adoption fee', () => {
render(<PetCard animal={max} />)
expect(screen.getByText('£250')).toBeInTheDocument()
})
it('shows "Free" when adoption fee is 0', () => {
render(<PetCard animal={{ ...max, adoptionFee: 0 }} />)
expect(screen.getByText('Free')).toBeInTheDocument()
})
})
getByRole('heading', { name: 'Max' })— Querying by role is the preferred approach because it's how screen readers navigate the page. It's also more resilient to markup changes thangetByTestId.
/3 years/— A regex matcher.screen.getByText(/3 years/)matches any element whose text content includes "3 years", not just elements whose entire text is "3 years". Useful when the text is part of a longer string.
Create src/components/molecules/SearchBar.test.tsx:
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { vi } from 'vitest'
import { SearchBar } from './SearchBar'
describe('SearchBar', () => {
it('renders the search input', () => {
render(<SearchBar value="" onChange={() => {}} />)
expect(screen.getByRole('searchbox')).toBeInTheDocument()
})
it('calls onChange with the typed value', async () => {
const user = userEvent.setup()
const handleChange = vi.fn()
render(<SearchBar value="" onChange={handleChange} />)
await user.type(screen.getByRole('searchbox'), 'golden')
expect(handleChange).toHaveBeenLastCalledWith('g')
// userEvent.type fires onChange for each character
expect(handleChange).toHaveBeenCalledTimes(6)
})
it('displays the current value', () => {
render(<SearchBar value="max" onChange={() => {}} />)
expect(screen.getByRole('searchbox')).toHaveValue('max')
})
})
userEventvsfireEvent—userEventsimulates real user interactions more accurately (it fires keydown, keypress, input, and keyup events).fireEventfires a single synthetic event. PreferuserEventfor interaction tests.
vi.fn()— Creates a Vitest mock function. You can assert it was called, how many times, and with what arguments.
This is the most powerful test — it renders the HomePage, intercepts the API call with MSW, and verifies the full filter behaviour from the user's perspective.
Create src/pages/HomePage.test.tsx:
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { BrowserRouter } from 'react-router'
import { HomePage } from './HomePage'
function renderWithRouter() {
return render(
<BrowserRouter>
<HomePage />
</BrowserRouter>
)
}
describe('HomePage', () => {
it('shows a loading skeleton initially', () => {
renderWithRouter()
// The skeleton cards are present before the API responds
expect(screen.getAllByRole('article').length).toBe(0)
// Look for skeleton divs using the animate-pulse class
})
it('renders animals after loading', async () => {
renderWithRouter()
// Wait for the MSW handler to respond and data to render
await waitFor(() => {
expect(screen.getByText('Max')).toBeInTheDocument()
})
expect(screen.getByText('Whiskers')).toBeInTheDocument()
})
it('filters animals when a species button is clicked', async () => {
const user = userEvent.setup()
renderWithRouter()
await waitFor(() => screen.getByText('Max'))
await user.click(screen.getByRole('button', { name: 'Cats' }))
await waitFor(() => {
expect(screen.getByText('Whiskers')).toBeInTheDocument()
expect(screen.queryByText('Max')).not.toBeInTheDocument()
})
})
it('filters animals when searching', async () => {
const user = userEvent.setup()
renderWithRouter()
await waitFor(() => screen.getByText('Max'))
await user.type(screen.getByRole('searchbox'), 'golden')
await waitFor(() => {
expect(screen.getByText('Max')).toBeInTheDocument()
expect(screen.queryByText('Whiskers')).not.toBeInTheDocument()
})
})
})
waitFor— Because data fetching is async, elements don't appear instantly.waitForrepeatedly runs the assertion until it passes or times out. It's the right tool whenever you're waiting for async state to resolve.
MSW is already configured — The
src/test/setup.tsfile starts MSW before every test and resets handlers after each one. The handlers insrc/test/msw-handlers.tsreturn a realistic subset of animals. Your tests never touch the real API server.
Run:
npm run test:coverageThis generates a coverage report showing which lines are tested. Look for any obvious gaps — an if branch that's never exercised, or a utility function with no test.
You don't need 100% coverage — but any component you plan to refactor should be tested first.
npm test should show all tests passing with no failures. A typical final count is around 20–25 tests across all files.
Try deliberately breaking something:
- Change
'bg-green-100'to'bg-blue-100'in Badge — the test for available badge styling should fail immediately - Fix it and watch the tests go green again
This is the fast feedback loop that makes tests valuable.
- Test the AnimalDetailPage — render it with a mock
:idparam and verify the animal name, story, and rehomingAdvice all appear - Test PetList loading state — render
<PetList isLoading animals={[]} />and assert that skeleton elements are present - Test the FilterBar — write a test that asserts the active button has the correct ARIA attribute (
aria-pressed="true") - Snapshot testing — add a snapshot test to Badge with
expect(container).toMatchSnapshot(). Run the tests, then change the Badge markup — the snapshot will fail, prompting you to review the change - Test error states — use MSW's
server.use()to override a handler for a single test and return a 500 error. Verify the error message appears in HomePage
Unit and component tests verify individual pieces work. Section 6 (stretch) uses Playwright to verify that a complete user journey works in a real browser — end to end.
Reference solution:
npm run section:5:complete(coming soon) — or ask your facilitator for the reference tests.