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:
- Choose what to test using the test pyramid and the acceptance criteria.
- Write unit tests for pure functions (
validateBooking,seatsLeftFor, formatters). - Write component and integration tests with React Testing Library that mimic user behavior.
- Render routed components in a test with a memory router and the providers they need.
- Query elements by accessible role and name, and use
userEventcorrectly. - Run an accessibility self-review with keyboard testing, automated checks, and a manual checklist.
- Review performance and code quality, and refactor safely with tests as a net.
- 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...returnsnull: 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
useStateworks, 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.
- 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.
- Automated scan. Run a tool such as axe (through a browser extension, or the
jest-axelibrary inside a test) and Lighthouse's accessibility audit. Fix every reported violation and understand each, rather than silencing it. - 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 buildthennpm run preview), not the dev server. - Bundle size. Check what the build reports. Split rarely visited routes with
React.lazyandSuspenseonly when the bundle or an audit warrants it. - Images. Set
widthandheight(or an aspect ratio) to avoid layout shift, serve appropriately sized images, and useloading="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, notcalc2)? - 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
- Title and one-sentence pitch.
- Screenshot or short screen recording of the main flow.
- Features mapped to the user stories.
- Tech stack and why.
- How to run (
npm install,npm run dev), how to test (npm test), and how to build. - Decisions and trade-offs: URL for filters, integer cents,
localStorageas a stand-in for a back end. - Accessibility and performance notes with the audit results.
- Known limitations and next steps.
- 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
awaitonuserEventcalls. The assertion runs before the interaction finishes. - Using
getByto assert absence. It throws; usequeryByfor "not present". - Shared state between tests. Clear
localStorageand 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.
- High: no README. A reviewer cannot run the project. Add what it is, how to run it, how to test it, and a screenshot.
- High: a
divused 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. - High: missing
alttext. Add meaningful alternatives, oralt=""for decorative images. - Medium: a 4 MB hero image. It harms Largest Contentful Paint on mobile. Resize it, compress it, use a modern format, and set dimensions.
- Medium: tests tied to class names. They break with any styling change. Query by role and accessible name, which also verifies accessibility.
- 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
awaituserEvent. - 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
- Testing Library, guiding principles and queries: https://testing-library.com/docs/queries/about (opens in a new tab)
- Testing Library, user-event: https://testing-library.com/docs/user-event/intro (opens in a new tab)
- Jest documentation: https://jestjs.io/docs/getting-started (opens in a new tab)
- Vitest documentation: https://vitest.dev/guide/ (opens in a new tab)
- web.dev, Core Web Vitals: https://web.dev/articles/vitals (opens in a new tab)
- Deque, axe-core: https://www.deque.com/axe/ (opens in a new tab)
- WCAG 2.2: https://www.w3.org/TR/WCAG22/ (opens in a new tab)
- Meta Front-End Developer Professional Certificate (Coursera): https://www.coursera.org/professional-certificates/meta-front-end-developer (opens in a new tab)
