What you'll build: Browser-level tests that launch a real Chromium instance, navigate the PawFinder UI, and assert that complete user journeys work correctly.
Time: 60 minutes (optional — for groups who finish Section 5 early)
Prerequisites: Section 4 complete (both the web app AND the animals API must be running)
Unit and component tests run fast and in isolation — but they can't catch problems that only appear when everything runs together: the API, the routing, the browser, and the full component tree.
End-to-end (E2e) tests fill that gap. They run against the real running app in a real browser.
The trade-off:
| Unit / Component | E2e | |
|---|---|---|
| Speed | Very fast (milliseconds) | Slow (seconds per test) |
| Reliability | High (isolated) | Lower (real network, real browser) |
| What it catches | Logic bugs, rendering bugs | Integration bugs, browser quirks |
| When to write | Always | For critical user journeys |
The rule of thumb: few, focused E2e tests for the journeys that absolutely must work — searching, filtering, viewing a pet, submitting an enquiry. Don't test every variant; that's what component tests are for.
Install Playwright's browser binaries (one-time setup):
npx playwright install chromiumMake sure both servers are running:
# Terminal 1:
cd services/animals-api && npm start
# Terminal 2:
npm run devThen run the tests in UI mode to see what's happening:
npm run test:e2e:uiThis opens Playwright's visual test runner. You'll see it's empty — let's fix that.
Create e2e/home.spec.ts:
import { test, expect } from '@playwright/test'
test('home page shows the PawFinder heading', async ({ page }) => {
await page.goto('/')
await expect(page.getByRole('heading', { name: 'Find Your Perfect Pet' })).toBeVisible()
})
test('home page loads animals from the API', async ({ page }) => {
await page.goto('/')
// Wait for at least one animal card to appear
await expect(page.locator('article').first()).toBeVisible()
// The API has 28 available animals
await expect(page.getByText('28 animals found')).toBeVisible()
})Run this test — you should see a real Chromium window flash open, load your app, and the tests pass.
page.goto('/')— Playwright'sbaseURLis configured tohttp://localhost:5173, so/resolves to your dev server. Theplaywright.config.tsalso starts the dev server automatically if it's not already running.
page.locatorvspage.getByRole— Both query the DOM.getByRoleis preferred because it tests accessibility-compatible markup, just like Testing Library.locator('article')is a CSS selector — useful when there's no accessible role to query by.
test('searching for an animal name filters the list', async ({ page }) => {
await page.goto('/')
// Wait for animals to load first
await expect(page.locator('article').first()).toBeVisible()
// Type in the search box
await page.getByRole('searchbox').fill('golden')
// Only Max should be visible (Golden Retriever)
await expect(page.getByRole('heading', { name: 'Max' })).toBeVisible()
await expect(page.getByText('1 animal found')).toBeVisible()
})
test('clearing the search restores all animals', async ({ page }) => {
await page.goto('/')
await expect(page.locator('article').first()).toBeVisible()
await page.getByRole('searchbox').fill('golden')
await expect(page.getByText('1 animal found')).toBeVisible()
await page.getByRole('searchbox').fill('')
await expect(page.getByText('28 animals found')).toBeVisible()
})
fillvstype—page.fill()sets the input value directly (fast).page.type()simulates keystrokes one at a time (slow but more realistic for testing autocomplete behaviour). Usefillunless you specifically need character-by-character simulation.
test('clicking Cats filter shows only cats', async ({ page }) => {
await page.goto('/')
await expect(page.locator('article').first()).toBeVisible()
await page.getByRole('button', { name: 'Cats' }).click()
// Wait for the filter to apply
await expect(page.getByText(/animals found/)).toBeVisible()
// All visible cards should be cats — check the first one
// Cats have breeds like "Domestic Shorthair", "Siamese", "Maine Coon" etc.
// The simplest check: no dog breeds should be visible
await expect(page.getByText('Golden Retriever')).not.toBeVisible()
})
test('filter button shows as active when selected', async ({ page }) => {
await page.goto('/')
await page.getByRole('button', { name: 'Cats' }).click()
const catsButton = page.getByRole('button', { name: 'Cats' })
// The active button has aria-pressed="true"
await expect(catsButton).toHaveAttribute('aria-pressed', 'true')
})test('clicking an animal card navigates to the detail page', async ({ page }) => {
await page.goto('/')
await expect(page.locator('article').first()).toBeVisible()
// Click Max's card
await page.getByRole('heading', { name: 'Max' }).click()
// We should be on the detail page
await expect(page).toHaveURL(/\/animals\/max-001/)
await expect(page.getByRole('heading', { name: 'Max' })).toBeVisible()
})
test('animal detail page shows the story and rehoming advice', async ({ page }) => {
await page.goto('/animals/max-001')
await expect(page.getByText('Their Story')).toBeVisible()
await expect(page.getByText('Rehoming Advice')).toBeVisible()
// Max's story contains this phrase
await expect(page.getByText(/relocated abroad/)).toBeVisible()
})
test('back link returns to the listing', async ({ page }) => {
await page.goto('/animals/max-001')
await page.getByRole('link', { name: 'Back to all animals' }).click()
await expect(page).toHaveURL('/')
await expect(page.getByText('Find Your Perfect Pet')).toBeVisible()
})If you built the enquiry form in Section 4's "Going further":
test('submitting an enquiry shows a success message', async ({ page }) => {
await page.goto('/animals/max-001')
await page.getByLabel('Your name').fill('Test User')
await page.getByLabel('Email address').fill('test@example.com')
await page.getByLabel('Message').fill('I would love to adopt Max!')
await page.getByRole('button', { name: 'Submit enquiry' }).click()
await expect(page.getByText(/enquiry submitted/i)).toBeVisible()
})Run all E2e tests headlessly (faster, good for CI):
npm run test:e2eView the HTML report after a run:
npx playwright show-reportRun with a visible browser (great for debugging):
npx playwright test --headedRecord a new test by interacting with the browser:
npx playwright codegen http://localhost:5173All tests should pass with:
npm run test:e2eTry making a test fail deliberately — comment out the fill('') in the "clearing the search" test. The 28 animals found assertion will fail and Playwright will show you exactly which line failed and what the page looked like at that moment.
- Visual regression — Playwright can take screenshots and compare them to a baseline. Add
await expect(page).toHaveScreenshot('home-page.png')and see what happens on the second run - API mocking in E2e — Playwright can intercept network requests with
page.route(). Try mocking the API to return a specific set of animals so your E2e tests don't depend on the dev database - Test multiple viewports — add a mobile viewport test:
page.setViewportSize({ width: 375, height: 667 }). Does the filter bar still work? - Trace viewer — run tests with
--trace onand then opennpx playwright show-traceto see a timeline of every action, network request, and DOM snapshot
You've built:
- A component library following Atomic Design (Badge, Avatar, PetCard, PetList, SearchPanel)
- A Storybook that documents every atom and molecule
- A real-data app with loading states, error handling, and routing
- Unit tests, component tests, and end-to-end tests
What comes next (future hackday): Adding a local LLM to answer questions about animals and rehoming. The story, healthNotes, and rehomingAdvice fields in the API data were designed with this in mind — they contain rich enough context for an LLM to answer questions like "which dog would suit a flat?" or "tell me about Max's history."
Well done — go adopt a rescue animal.