Skip to content

Latest commit

 

History

History
343 lines (246 loc) · 11.7 KB

File metadata and controls

343 lines (246 loc) · 11.7 KB

Section 5: Unit & Component Testing

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)


The big idea

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.


Step 0 — Run the tests

npm test

Vitest starts in watch mode. You should see 0 tests and no failures. Leave it running — it re-runs affected tests on every save.


Step 1 — Write a utility function and test it

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.


Step 2 — Write component tests for Badge

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')
  })
})

render and screenrender mounts the component into a virtual DOM. screen gives you queries to find elements in that DOM — getByText, getByRole, queryByText etc. The screen queries are deliberately similar to how a user perceives the UI.

toBeInTheDocument and toHaveClass — These come from @testing-library/jest-dom and are set up automatically in src/test/setup.ts. They extend Vitest's expect with DOM-aware matchers.


Step 3 — Write component tests for PetCard

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 than getByTestId.

/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.


Step 4 — Write component tests for SearchBar

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')
  })
})

userEvent vs fireEventuserEvent simulates real user interactions more accurately (it fires keydown, keypress, input, and keyup events). fireEvent fires a single synthetic event. Prefer userEvent for interaction tests.

vi.fn() — Creates a Vitest mock function. You can assert it was called, how many times, and with what arguments.


Step 5 — Write an integration test with MSW

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. waitFor repeatedly 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.ts file starts MSW before every test and resets handlers after each one. The handlers in src/test/msw-handlers.ts return a realistic subset of animals. Your tests never touch the real API server.


Step 6 — Check your test coverage

Run:

npm run test:coverage

This 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.


Check your work

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.


Going further

  1. Test the AnimalDetailPage — render it with a mock :id param and verify the animal name, story, and rehomingAdvice all appear
  2. Test PetList loading state — render <PetList isLoading animals={[]} /> and assert that skeleton elements are present
  3. Test the FilterBar — write a test that asserts the active button has the correct ARIA attribute (aria-pressed="true")
  4. 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
  5. 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

What's next

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.