Skip to content

An Original React Booking Capstone

Step 39 of 45

Test, Review, and Present the Capstone

Introduction

This is lesson 4 of chapter 8, the last lesson of the capstone (domain capstone). The Harbor Workshops application now has a catalog, a URL-based filter, a detail page, and a booking workflow. A project in this state is a prototype: it seems to work. This lesson turns it into evidence that it works and into something a reviewer can understand in five minutes.

You will write automated tests with Jest and React Testing Library (the tools introduced in chapter 6), perform an accessibility and performance self-review, fix what you find, and package the result with a README, a clean Git history, and a short presentation. The material is original OnLevelUp content. It does not reproduce the graded peer-review rubric of the official program, which is applied by reviewers on Coursera.

Learning objectives

By the end of this lesson you can:

  1. Choose what to test using the test pyramid and the acceptance criteria.
  2. Write unit tests for pure functions (validateBooking, seatsLeftFor, formatters).
  3. Write component and integration tests with React Testing Library that mimic user behavior.
  4. Render routed components in a test with a memory router and the providers they need.
  5. Query elements by accessible role and name, and use userEvent correctly.
  6. Run an accessibility self-review with keyboard testing, automated checks, and a manual checklist.
  7. Review performance and code quality, and refactor safely with tests as a net.
  8. Prepare a README, a demo script, and a self-assessment against the acceptance criteria.

1. What to test: the test pyramid, applied

Not every line deserves a test. Spend effort where bugs are likely and expensive.

Layer What it checks Speed Harbor Workshops examples
Unit One function in isolation Very fast validateBooking, seatsLeftFor, formatPrice
Component / integration A component or a few together, via the DOM Fast Level filter changes the list; booking form shows errors
End-to-end The whole app in a real browser Slower Browse, book, confirm (optional for a capstone)

Most tests should be unit and integration tests, with a few end-to-end checks for the main journey. Derive the tests from your acceptance criteria (lesson 1): each criterion should be covered by at least one test or a documented manual check.

Test setup

The pathway tooling uses Jest with React Testing Library. A Vite project can use either Jest (with a Babel or SWC transform) or Vitest, whose API for describe, test, expect, and mocks is intentionally Jest-compatible. Follow the current documentation of the runner you choose. The examples below use the shared API.

npm install -D @testing-library/react @testing-library/user-event @testing-library/jest-dom

Configure a jsdom test environment and import @testing-library/jest-dom once in a setup file so that matchers like toBeInTheDocument() exist.

2. Unit tests for pure functions

Pure functions are the cheapest tests to write and the most reliable.

// src/lib/validateBooking.test.js
import { validateBooking } from './validateBooking';

const valid = { name: 'Sam Rivera', email: 'sam@example.com', seats: '2' };

describe('validateBooking', () => {
  test('accepts valid values', () => {
    expect(validateBooking(valid, 5)).toEqual({});
  });

  test('requires a name that is not only spaces', () => {
    expect(validateBooking({ ...valid, name: '   ' }, 5).name).toMatch(/enter your name/i);
  });

  test.each(['sam', 'sam@', 'sam@example', 'sam @example.com'])(
    'rejects the email %s',
    (email) => {
      expect(validateBooking({ ...valid, email }, 5).email).toBeDefined();
    },
  );

  test('rejects seats above availability (boundary)', () => {
    expect(validateBooking({ ...valid, seats: '5' }, 5)).toEqual({});
    expect(validateBooking({ ...valid, seats: '6' }, 5).seats).toMatch(/only 5/i);
  });

  test('rejects zero, negative, and fractional seats', () => {
    for (const seats of ['0', '-1', '1.5', '']) {
      expect(validateBooking({ ...valid, seats }, 5).seats).toBeDefined();
    }
  });
});

Good habits visible here: boundary tests (exactly at the limit and just above), test.each for variations, and assertions about the message intent using a regular expression so that small wording changes do not break the test.

// src/lib/seats.test.js
import { seatsLeftFor } from './seats';

const workshop = { id: 'w-1', capacity: 10, seatsTaken: 7 };

test('subtracts bookings of the same workshop only', () => {
  const bookings = [
    { workshopId: 'w-1', seats: 2 },
    { workshopId: 'w-2', seats: 5 },
  ];
  expect(seatsLeftFor(workshop, bookings)).toBe(1);
});

test('never returns a negative number', () => {
  expect(seatsLeftFor(workshop, [{ workshopId: 'w-1', seats: 9 }])).toBe(0);
});

3. Component tests the way users behave

React Testing Library's guiding principle is: the more your tests resemble the way your software is used, the more confidence they give. So you query by what a user perceives, such as role, label, and text, not by class names or component internals.

Query priority

Priority Query Use for
1 getByRole('button', { name: /confirm booking/i }) Anything with an accessible role
2 getByLabelText(/email/i) Form fields
3 getByText(...) Non-interactive text
Last resort getByTestId Cases where nothing semantic exists

Using roles has a bonus: if the query fails because a button is really a div, you have found an accessibility bug.

Variants matter:

  • getBy... throws if not found: use for things that must be present.
  • queryBy... returns null: use to assert absence (expect(queryByText(...)).not.toBeInTheDocument()).
  • findBy... returns a promise and waits: use for asynchronous results.

Testing the form

// src/components/BookingForm.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import BookingForm from './BookingForm.jsx';

function setup(props = {}) {
  const onSubmit = jest.fn();            // with Vitest: vi.fn()
  const user = userEvent.setup();
  render(<BookingForm seatsLeft={4} onSubmit={onSubmit} submitting={false} {...props} />);
  return { onSubmit, user };
}

test('shows errors and does not submit an empty form', async () => {
  const { onSubmit, user } = setup();
  await user.click(screen.getByRole('button', { name: /confirm booking/i }));

  expect(screen.getByLabelText(/full name/i)).toHaveAttribute('aria-invalid', 'true');
  expect(screen.getByText(/enter your name/i)).toBeInTheDocument();
  expect(onSubmit).not.toHaveBeenCalled();
  expect(screen.getByLabelText(/full name/i)).toHaveFocus();
});

test('submits trimmed values as numbers', async () => {
  const { onSubmit, user } = setup();
  await user.type(screen.getByLabelText(/full name/i), '  Sam Rivera ');
  await user.type(screen.getByLabelText(/email/i), 'sam@example.com');
  await user.clear(screen.getByLabelText(/seats/i));
  await user.type(screen.getByLabelText(/seats/i), '2');
  await user.click(screen.getByRole('button', { name: /confirm booking/i }));

  expect(onSubmit).toHaveBeenCalledWith({
    name: 'Sam Rivera',
    email: 'sam@example.com',
    seats: 2,
  });
});

test('disables the button while submitting', () => {
  setup({ submitting: true });
  expect(screen.getByRole('button', { name: /booking/i })).toBeDisabled();
});

Notes: userEvent simulates a real sequence of events (focus, keydown, input, keyup) and is preferred over fireEvent. Its methods return promises, so await them. Create the user with userEvent.setup() before rendering. The first test also verifies focus management, which is a real requirement from lesson 3.

Testing routed screens with providers

Components that use Link, useParams, or useBookings need their environment. Create a helper once:

// src/test/renderWithProviders.jsx
import { render } from '@testing-library/react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { BookingsProvider } from '../state/BookingsContext.jsx';
import App from '../App.jsx';

export function renderApp(route = '/') {
  localStorage.clear();                   // isolate tests from each other
  return render(
    <MemoryRouter initialEntries={[route]}>
      <BookingsProvider>
        <App />
      </BookingsProvider>
    </MemoryRouter>,
  );
}

MemoryRouter keeps the history in memory, so tests do not depend on the browser's address bar. Because the test renders the real App, the routes are exercised too.

// src/App.test.jsx
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderApp } from './test/renderWithProviders.jsx';

test('filters the catalog by level from the URL', () => {
  renderApp('/workshops?level=beginner');
  const items = screen.getAllByRole('listitem');
  expect(screen.getByRole('status')).toHaveTextContent(/workshops shown/i);
  expect(items.length).toBeGreaterThan(0);
  expect(screen.queryByText(/advanced/i)).not.toBeInTheDocument();
});

test('shows a not-found page for an unknown workshop', () => {
  renderApp('/workshops/does-not-exist');
  expect(screen.getByText(/could not find that workshop/i)).toBeInTheDocument();
});

test('completes a booking and shows the confirmation', async () => {
  const user = userEvent.setup();
  renderApp('/workshops/w-101/book');

  await user.type(screen.getByLabelText(/full name/i), 'Sam Rivera');
  await user.type(screen.getByLabelText(/email/i), 'sam@example.com');
  await user.click(screen.getByRole('button', { name: /confirm booking/i }));

  expect(await screen.findByRole('heading', { name: /booking confirmed/i })).toBeInTheDocument();
  expect(screen.getByText(/sam@example.com/i)).toBeInTheDocument();
});

The third test is an integration test: it crosses the form, the async submit, the reducer, the router, and the confirmation page. findByRole waits for the mocked 600 ms delay. If the wait is slow in your suite, mock the data function or use fake timers, but keep the user-level assertions.

What not to test

  • Implementation details (a state variable's name, the number of renders).
  • Library behavior (that useState works, that the router matches a path).
  • Styling, except where it carries meaning (use visual review for that).

If a refactor that keeps behavior forces you to rewrite many tests, the tests were too coupled to implementation.

4. Accessibility self-review

Automated checks find perhaps a third of accessibility problems; the rest need a human. Combine three techniques.

  1. Keyboard-only run. Unplug the mouse mentally. Starting at the top, press Tab through the whole booking journey. Check: the skip link appears; focus order follows the visual order; focus is always visible; the filter buttons work with Enter and Space; the form can be completed and submitted; nothing traps focus.
  2. Automated scan. Run a tool such as axe (through a browser extension, or the jest-axe library inside a test) and Lighthouse's accessibility audit. Fix every reported violation and understand each, rather than silencing it.
  3. Manual checklist based on POUR (chapter 7):
Check Pass condition
Text contrast Normal text at least 4.5:1, large text and UI components at least 3:1
Zoom At 200% zoom and 320 px width, no content is lost or needs two-axis scrolling
Images Informative images have text alternatives; decorative images use alt=""
Headings One h1; levels descend without skipping
Forms Every field has a label; errors are specific and associated
Status State changes (result counts, errors) are announced
Motion Animations respect prefers-reduced-motion
Target size Pointer targets are at least 24 by 24 CSS pixels (WCAG 2.2 AA minimum)

An automated accessibility test can sit in your suite:

import { axe } from 'jest-axe';
test('the catalog has no detectable accessibility violations', async () => {
  const { container } = renderApp('/workshops');
  expect(await axe(container)).toHaveNoViolations();
});

A passing axe test does not prove the page is accessible; it proves that it has no violations of the rules axe can check.

5. Performance and code-quality review

Performance

  • Measure first. Run a Lighthouse audit on a production build (npm run build then npm run preview), not the dev server.
  • Bundle size. Check what the build reports. Split rarely visited routes with React.lazy and Suspense only when the bundle or an audit warrants it.
  • Images. Set width and height (or an aspect ratio) to avoid layout shift, serve appropriately sized images, and use loading="lazy" for below-the-fold images.
  • Re-renders. Use the React DevTools Profiler before reaching for memo. With a catalog of a dozen cards, optimizing is unnecessary; with thousands of rows, virtualizing might be.
  • Core Web Vitals. Largest Contentful Paint, Interaction to Next Paint, and Cumulative Layout Shift are the current user-centered metrics.

Code quality

Run ESLint and fix the warnings. Then review your own diff the way a colleague would, with questions such as:

  • Is there duplicated markup that should be a component?
  • Are names intention-revealing (seatsLeftFor, not calc2)?
  • Is derived data computed rather than stored?
  • Are effects necessary, and do they clean up?
  • Are all props used, and are the error states handled?
  • Are secrets or API keys committed? (Never commit them.)
  • Is dead code or commented-out code removed?

Refactor in small steps with the tests green after each one. Tests are the safety net that makes cleanup low-risk.

6. Package and present the work

README structure

  1. Title and one-sentence pitch.
  2. Screenshot or short screen recording of the main flow.
  3. Features mapped to the user stories.
  4. Tech stack and why.
  5. How to run (npm install, npm run dev), how to test (npm test), and how to build.
  6. Decisions and trade-offs: URL for filters, integer cents, localStorage as a stand-in for a back end.
  7. Accessibility and performance notes with the audit results.
  8. Known limitations and next steps.
  9. Credits and licenses for fonts and images.

Git history

A reviewer reads the history to see how you work. Use meaningful commit messages in the imperative ("Add booking form validation"), keep commits focused, and do not commit node_modules or build output (.gitignore). Tag a release (v1.0.0) when the acceptance criteria pass.

The five-minute demo

Prepare a script: (1) the problem and user, 30 seconds; (2) the main flow live, 2 minutes: browse, filter, detail, book with an error, fix it, confirm; (3) one technical decision you are proud of, 1 minute; (4) test and accessibility results, 1 minute; (5) what you would do next, 30 seconds. Rehearse, and prepare for questions like "why a reducer here?" or "what breaks if the network fails?".

Self-assessment against criteria

Create a table with each acceptance criterion, its evidence (test name, manual check), and its status. Be honest. Naming a known limitation scores better than hiding it.

Worked example: a failing test reveals a real bug

Problem. Your integration test "completes a booking" passes. You add one more test: booking 3 seats on a workshop with 3 seats left, then opening the detail page. The expected text is "Fully booked", but the page shows "3 seats left".

Step 1: reproduce and read the failure. The confirmation appears, so the booking was stored. The detail page still shows the old count.

Step 2: form a hypothesis. The detail page uses seatsLeft(workshop) from the data module (base data only), while the booking page uses seatsLeftFor(workshop, bookings). Two functions encode two rules.

Step 3: confirm with a smaller test. Render /workshops/w-3 after storing a booking in the provider's initial localStorage. The same wrong count appears, so the cause is not timing or navigation.

Step 4: fix at the source. Replace the direct call with the hook useSeatsLeft(workshop) in the detail page and the card, and delete the old function so the wrong rule cannot be reused.

Step 5: keep the test. The new test now protects against reintroducing the divergent rule.

Why this is better than patching the detail page only? A patch leaves two sources of truth, and the catalog card would remain wrong. Why not skip the failing test because "it works when I click"? Manual testing happens once; a test runs forever. The bug appears only when sequences of actions combine, which is exactly what integration tests catch.

Common pitfalls and exam tips

  • Testing implementation details. Prefer queries by role, label, and text.
  • Forgetting await on userEvent calls. The assertion runs before the interaction finishes.
  • Using getBy to assert absence. It throws; use queryBy for "not present".
  • Shared state between tests. Clear localStorage and recreate providers per test.
  • Snapshot tests everywhere. Large snapshots are approved without being read; use targeted assertions.
  • Mocking everything. Over-mocking tests the mocks. Mock only the network and time.
  • Relying on automated accessibility checks alone.
  • Auditing the dev server. Measure a production build.
  • A README with no run instructions. The reviewer cannot evaluate what they cannot start.

Exam tip: choose getByRole when a question asks for the preferred query; findBy for elements that appear asynchronously; queryBy to assert something is absent; userEvent over fireEvent for realistic interactions.

Mini case study: reviewing a classmate's capstone

You receive a classmate's to-do list capstone to review. You find: no README; tests that select elements with container.querySelector('.btn-3'); a clicked div acting as a button; images without alt; a 4 MB hero image; and console.log statements left in the code.

Task. Write the review feedback as prioritized, constructive items.

Solution.

  1. High: no README. A reviewer cannot run the project. Add what it is, how to run it, how to test it, and a screenshot.
  2. High: a div used as a button. It is not focusable or operable by keyboard. Replace it with <button type="button">, which also gives the Enter and Space behavior for free.
  3. High: missing alt text. Add meaningful alternatives, or alt="" for decorative images.
  4. Medium: a 4 MB hero image. It harms Largest Contentful Paint on mobile. Resize it, compress it, use a modern format, and set dimensions.
  5. Medium: tests tied to class names. They break with any styling change. Query by role and accessible name, which also verifies accessibility.
  6. Low: leftover console.log. Remove debugging output.

Each item states the problem, the user impact, and the fix, and they are ordered by impact. Good feedback is specific and kind.

Key terms

  • Test pyramid: many unit tests, fewer integration tests, few end-to-end tests.
  • React Testing Library: a library that tests components through the DOM the way users use them.
  • userEvent: simulates realistic user interactions.
  • MemoryRouter: a router keeping history in memory, for tests.
  • Integration test: a test crossing several units.
  • axe: an automated accessibility rules engine.
  • Core Web Vitals: user-centered performance metrics (LCP, INP, CLS).
  • Acceptance criteria: verifiable conditions that define a story as done.

Summary checklist

  • Pure functions have unit tests, including boundaries.
  • The form, filter, and booking journey have user-centered tests.
  • Tests query by role and label, and await userEvent.
  • A keyboard-only run, an automated scan, and the manual checklist are complete.
  • A production build was audited, and issues were fixed.
  • Lint is clean, and dead code and secrets are removed.
  • The README, screenshot, run, test, and decision notes exist.
  • A self-assessment maps each acceptance criterion to evidence.
  • The five-minute demo is rehearsed.

Official sources