Skip to content

Test application behavior with Vitest

You will add a repeatable automated test path to Delivery Board. Vitest will run TypeScript and TSX tests through the Vite module graph. React Testing Library will render components into a jsdom document, and user-event will operate controls through the same roles and accessible names that a user can find.

The final suite will check an empty collection, a filter restored from the URL, a work-item status transition, focus recovery after a filtered item disappears, an unknown work-item route, and route title and focus behavior.

TypeScript can reject an invalid relationship between types. A linter can reject a configured source pattern. A production build can prove that the current source creates output. None of those checks proves that activating Start work changes the intended record, reports the result, or leaves keyboard focus in a usable place.

An automated behavior test records selected application contracts as executable evidence. It can repeat those contracts after a refactor or dependency change. The test suite does not replace browser inspection, accessibility evaluation, or product judgment. It gives those checks a smaller and more reliable regression surface.

What you will practice

  • Distinguish type checking, linting, production building, automated behavior tests, and real-browser verification by the evidence each can provide.
  • Choose a test seam from a user-visible outcome and a plausible regression instead of testing every source line.
  • Configure Vitest through the existing Vite configuration with a jsdom environment and one setup file.
  • Use React Testing Library queries based on roles, accessible names, labels, text, and current values.
  • Choose getBy, queryBy, or findBy from the expected timing and presence of an element.
  • Use userEvent.setup and await interactions that can cause React updates.
  • Render the routed client with MemoryRouter and an explicit initial URL.
  • Check rendered state, absence, live feedback, focus, document title, and current navigation without reading React internals.
  • Read one focused failure from the assertion, accessible DOM output, and source location before changing code.
  • Explain why jsdom component tests do not prove responsive layout, CSS rendering, browser history integration, or complete accessibility.
  • New: Test case, test oracle, regression risk, test seam, Vitest, jsdom, React Testing Library, jest-dom matchers, user-event, describe, it, expect, vi.fn, render, screen, within, MemoryRouter, getBy, queryBy, findBy, test isolation, cleanup, focused test runs, watch mode, and a controlled sensitivity check.
  • Reused: The clean feature/react-components branch, Vite, TypeScript, React, React Router declarative mode, typed fixtures, status transitions, URL search state, empty results, live status feedback, heading focus, document titles, semantic HTML, accessible names, source checks, production preview, browser testing, and focused Git commits.

Starting point

Before you start

  • The completed Build routes and URL state with React Router lesson on a clean feature/react-components branch.
  • Delivery Board defines the board, status guide, work-item detail, and catch-all routes.
  • The board can filter through URL search state and can update the current in-memory work-item collection.
  • A filtered status update moves focus to live feedback when the active card leaves the rendered result.
  • The project passes typecheck, lint, build, and production-preview browser checks before test packages are installed.
  • No API request, server process, database, authentication rule, coverage percentage, end-to-end browser runner, or visual-regression service is required in this lesson.
Current state
The routed client has a documented manual test path, but no command can repeat selected component, state, route, URL, and focus contracts after a source change.
First action
Open the delivery-board repository, verify the clean routing commit on feature/react-components, run the baseline source checks, and write six named test outcomes before installing a test package.
First checkpoint
Vitest runs one passing WorkItemList test in jsdom, the test finds the empty result through visible semantics, and no application source changed to make the test pass.
Help trigger
Use the nearest recovery note or ask for help if no tests are discovered, document is undefined, a jest-dom matcher has no type, a router Hook lacks context, a query finds several elements, an awaited interaction still reports an act warning, the focused test fails for a different reason than its name describes, or test and browser results disagree.

You have completed the lesson when:

  • the work continues from the clean routing commit on feature/react-components;
  • the test plan names six user-visible contracts and one plausible wrong implementation for each contract;
  • vitest, jsdom, @testing-library/react, @testing-library/jest-dom, and @testing-library/user-event are recorded as development dependencies;
  • package.json provides test for one complete deterministic run and test:watch for an optional local watch session;
  • the existing vite.config.ts keeps the React plugin and adds a jsdom test environment plus one setup file;
  • src/test/setup.ts adds Vitest-compatible jest-dom matchers and resets the rendered document after every test;
  • WorkItemList.test.tsx checks the empty result and confirms that an empty list is not rendered;
  • App.test.tsx renders the real App inside MemoryRouter with an explicit initial entry;
  • one test proves that /?status=planned selects Planned, renders the planned record, excludes an active record, and reports the correct counts;
  • one test activates Start work, then checks the changed visible status, next accessible action, retained button focus, and live result message;
  • one test proves that changing the only planned item removes its card, renders the empty result, and moves focus to the status message;
  • one test proves that /work-items/WI-999 renders the specific missing-record result and recovery link;
  • one test navigates to the status guide and checks the page heading, heading focus, document title, and current navigation state;
  • tests use roles, accessible names, labels, visible text, and current values before any lower-level DOM selector;
  • the suite does not inspect Hook state, component instances, CSS class names, private functions, or implementation call order;
  • the controlled wrong status transition makes the focused behavior test fail for the intended reason, and the correct transition is restored before the final run;
  • six tests pass in two files through npm test;
  • typecheck, lint, build, and the routed production-preview browser checks still pass;
  • README.md documents the two test commands, six automated scenarios, test-file responsibilities, jsdom limits, required browser checks, and latest verified result; and
  • the final diff contains test setup, test source, dependency records, scripts, and matching README guidance in one focused commit.

Run these commands from the folder that contains Delivery Board’s package.json:

Verify the routing baseline
git switch feature/react-components
git status
git log -1 --oneline
npm ci
npm run typecheck
npm run lint
npm run build

Confirm that the latest commit is the routing lesson result and that git status is clean. Open the production preview once and repeat the route checks if the branch has changed since the routing lesson.

Stop before installing packages if a baseline check fails. A new test runner cannot repair an existing source, dependency, or production-build failure.

A useful test starts from a contract, not from a source file count. State the outcome, the plausible regression, and the smallest boundary that can expose it.

Use this plan for the required suite:

Contract Plausible regression Smallest useful test seam
An empty collection gives a specific result and no empty list The component renders an empty list or hides the supplied message WorkItemList with an empty array
A shared filtered URL reproduces the planned view The select and cards ignore or disagree with the search parameter App with MemoryRouter at /?status=planned
Start work changes the selected record and reports Active The status cycle skips a value or updates the wrong item App plus one accessible button interaction
A removed filtered card does not lose keyboard position The focused DOM node disappears and focus returns to the document body App at the planned URL plus one interaction
An unknown item ID gives a recovery result The detail route crashes or renders unrelated data App at /work-items/WI-999
Client navigation updates page context The new view renders but title, heading focus, or current navigation stays stale App plus the Status guide link

Do not add one test for every prop, line, component, route, or CSS selector. These six tests cover boundaries with meaningful regression risk. More tests are useful only when another contract or failure justifies them.

Check Evidence it can provide Evidence it cannot provide alone
npm run typecheck Type relationships satisfy the configured TypeScript project A button produces the intended runtime result
npm run lint Source satisfies configured static rules The interface is usable or correct
npm test Selected behavior matches executable expectations in the configured test environment Complete browser, layout, network, or accessibility behavior
npm run build Current source produces production assets Every route and interaction works after load
Production-preview browser check The built client works in the named browser and viewport Future changes keep working without repeating the check

All five checks answer different questions. A green test suite does not cancel a failed production build or a clipped 320-pixel layout.

PHP projects can use a runner such as PHPUnit to execute server-side units and integrations. This client uses Vitest because Vitest shares Vite’s TypeScript, TSX, module-resolution, and plugin configuration.

The transferable model is the same:

  1. Arrange a known input and starting state.
  2. Act through a public boundary.
  3. Assert the observable result.
  4. Reset shared state before the next independent case.

The runtime boundary differs. PHPUnit normally executes PHP on the server. This lesson executes client TypeScript and React behavior through Node and a simulated DOM. Later Express lessons will apply the same test-case model to HTTP requests and JSON responses.

From the project root, install the five development tools:

Install the client test tools
npm install --save-dev vitest jsdom @testing-library/react @testing-library/jest-dom @testing-library/user-event

Then inspect the resolved versions:

Confirm the installed test packages
npm ls vitest jsdom @testing-library/react @testing-library/jest-dom @testing-library/user-event

The command records direct ranges in package.json and exact resolved packages in package-lock.json. Keep the lockfile. Do not replace the installed versions with numbers copied from this lesson or another project.

Each package has one responsibility:

Package Responsibility in this lesson
vitest Discover test files, run cases, provide assertions and test doubles, and report failures
jsdom Supply a DOM-like document and browser APIs inside Node
@testing-library/react Render React elements and query the resulting document
@testing-library/jest-dom Add readable DOM assertions such as toBeInTheDocument and toHaveFocus
@testing-library/user-event Simulate user interaction sequences such as clicking and keyboard input

These are development dependencies because the production browser bundle does not run the test suite.

Open package.json. Add these entries inside the existing scripts object:

package.json — add inside scripts
"test": "vitest run",
"test:watch": "vitest"

Keep the existing dev, typecheck, build, lint, and preview scripts.

  • npm test runs the complete suite once and exits with success or failure. Use this command in the final quality gate.
  • npm run test:watch stays open, observes source changes, and reruns related tests. Use it during a focused edit when continuous feedback helps.

The explicit run mode prevents a required check from waiting indefinitely for another file change.

Replace vite.config.ts with this configuration:

vite.config.ts
import react from "@vitejs/plugin-react";
import { defineConfig } from "vitest/config";
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
setupFiles: ["./src/test/setup.ts"],
},
});

defineConfig from vitest/config accepts the existing Vite options and the test property. The React plugin remains active, so test files use the same TSX transformation as application modules.

The jsdom environment provides document, HTMLElement, accessible DOM properties, focus state, and other browser-like APIs while the tests run in Node. It does not render pixels or run a complete browser engine.

Create src/test/setup.ts:

src/test/setup.ts
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach } from "vitest";
afterEach(() => {
cleanup();
document.title = "";
});

The setup file runs before every test file:

  • the Vitest-specific jest-dom import extends expect with DOM matchers;
  • cleanup() removes the rendered React tree after every test; and
  • resetting document.title prevents one route test from supplying the next test’s starting title.

Vitest globals remain disabled. Each test file will import describe, it, expect, and vi explicitly. The explicit afterEach import also makes cleanup independent of a global test API.

The project now has this test structure:

  • Directorysrc
    • Directorycomponents
      • WorkItemCard.tsx
      • WorkItemList.tsx
      • WorkItemList.test.tsx — Focused empty-result component test
    • Directorytest
      • setup.ts — DOM matchers and per-test reset
    • App.test.tsx — Routed client behavior tests
    • App.tsx
  • package.json — Test scripts and development dependencies
  • package-lock.json — Exact dependency graph
  • vite.config.ts — Shared Vite and Vitest configuration

Testing Library queries describe how an element can be found in the rendered interface.

Prefer this order when the interface supports it:

  1. getByRole with an accessible name for buttons, links, headings, navigation, lists, and status regions.
  2. getByLabelText or getByRole with a name for form controls.
  3. Visible text or current display value for non-interactive content.
  4. A test ID only when no semantic or visible contract can identify the element.

Do not select .work-item-action, inspect a React state variable, or call a component handler directly when the contract concerns a user activating a named button.

Choose the query family from the expected result

Section titled “Choose the query family from the expected result”
Query family Use when Failure behavior
getBy... One matching element must exist now Throws for zero or several matches
queryBy... One matching element must be absent Returns null for zero; throws for several
findBy... One matching element should appear after asynchronous work Retries until it finds the element or times out

Use an All variant when several matches are the expected contract. Do not change to getAllBy... only to silence an unexpected duplicate. First decide whether several elements are correct.

Create src/components/WorkItemList.test.tsx:

src/components/WorkItemList.test.tsx
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { WorkItemList } from "./WorkItemList";
describe("WorkItemList", () => {
it("shows the supplied empty result without an empty list", () => {
render(
<WorkItemList
items={[]}
emptyMessage="No planned work items match this filter."
onStatusChange={vi.fn()}
/>,
);
expect(
screen.getByRole("heading", { name: "Current work" }),
).toBeInTheDocument();
expect(
screen.getByText("No planned work items match this filter."),
).toBeInTheDocument();
expect(screen.queryByRole("list")).not.toBeInTheDocument();
});
});

The test has three visible phases:

  • Arrange: render WorkItemList with no items, a specific message, and a valid callback prop.
  • Act: no interaction is required because the empty result exists on the initial render.
  • Assert: check the section heading, supplied message, and absence of a list.

vi.fn() supplies the required callback boundary. This test does not claim that the callback ran. Its evidence concerns only the empty render result.

Run the file alone:

Run the first component test
npm test -- src/components/WorkItemList.test.tsx

The expected summary reports one passed file and one passed test.

  • No test file found: confirm the filename contains .test.tsx and remains under the project root.
  • document is not defined: confirm environment: "jsdom" is inside the test property that Vitest reads.
  • toBeInTheDocument has no type: confirm the setup file is TypeScript, its path is correct, and it imports @testing-library/jest-dom/vitest.
  • The list query finds a list: inspect the accessible DOM in the failure output. Confirm that items={[]} reaches the intended component and that the application component has not been rendered around it.

Checkpoint: One isolated render contract is executable

What now works
Vitest discovers the TSX file, jsdom supplies a document, WorkItemList renders the supplied empty result, and the test confirms that no empty list exists.
Files changed
package.json, package-lock.json, vite.config.ts, src/test/setup.ts, src/components/WorkItemList.test.tsx
What remains
Render the complete routed client and test interactions, URL state, recovery, title, and focus through public behavior.
Next action
Create src/App.test.tsx and add the shared MemoryRouter render helper plus the five application tests.
If it does not work
Run only WorkItemList.test.tsx, read the first configuration or query error, and repair that boundary before adding the routed test environment.

The production entry uses BrowserRouter, but App receives router context from its parent. A test can provide MemoryRouter instead:

The routed test boundary
function renderApp(initialEntry = "/") {
return render(
<MemoryRouter initialEntries={[initialEntry]}>
<App />
</MemoryRouter>,
);
}

MemoryRouter stores location entries in memory. initialEntries gives each test a specific route and search string without changing the real browser history. The test still renders the real App, route table, pages, state owner, and router-aware links.

Do not wrap App in BrowserRouter inside the test. Tests should not compete over one global browser URL when an isolated in-memory route can express the same component contract.

Create src/App.test.tsx:

src/App.test.tsx
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router";
import { describe, expect, it } from "vitest";
import App from "./App";
function renderApp(initialEntry = "/") {
return render(
<MemoryRouter initialEntries={[initialEntry]}>
<App />
</MemoryRouter>,
);
}
describe("Delivery Board", () => {
it("reproduces a valid status filter from the URL", () => {
renderApp("/?status=planned");
expect(
screen.getByRole("combobox", { name: "Show work items" }),
).toHaveValue("planned");
expect(
screen.getByRole("heading", {
name: "Prepare the accessible navigation review",
}),
).toBeInTheDocument();
expect(
screen.queryByRole("heading", {
name: "Document the component responsibilities",
}),
).not.toBeInTheDocument();
expect(
screen.getByText("1 work item shown. 1 of 3 done."),
).toBeInTheDocument();
});
it("updates the selected work item and reports the result", async () => {
const user = userEvent.setup();
renderApp();
const itemHeading = screen.getByRole("heading", {
name: "Prepare the accessible navigation review",
});
const item = itemHeading.closest("article");
if (!(item instanceof HTMLElement)) {
throw new Error("Expected the work-item heading inside an article.");
}
await user.click(
within(item).getByRole("button", {
name: "Start work: Prepare the accessible navigation review",
}),
);
expect(
within(item).getByText("Active"),
).toBeInTheDocument();
expect(
within(item).getByRole("button", {
name: "Mark done: Prepare the accessible navigation review",
}),
).toHaveFocus();
expect(screen.getByRole("status")).toHaveTextContent(
"Prepare the accessible navigation review changed to Active.",
);
});
it("moves focus to feedback when a filtered item leaves the result", async () => {
const user = userEvent.setup();
renderApp("/?status=planned");
await user.click(
screen.getByRole("button", {
name: "Start work: Prepare the accessible navigation review",
}),
);
const status = screen.getByRole("status");
expect(status).toHaveTextContent(
"Prepare the accessible navigation review changed to Active.",
);
expect(status).toHaveFocus();
expect(
screen.queryByRole("heading", {
name: "Prepare the accessible navigation review",
}),
).not.toBeInTheDocument();
expect(
screen.getByText("No planned work items match this filter."),
).toBeInTheDocument();
});
it("shows a recovery page for an unknown work-item ID", () => {
renderApp("/work-items/WI-999");
expect(
screen.getByRole("heading", { name: "Work item not found" }),
).toBeInTheDocument();
expect(screen.getByText("WI-999")).toBeInTheDocument();
expect(
screen.getByRole("link", { name: "Return to the board" }),
).toBeInTheDocument();
});
it("updates the title and heading focus after client navigation", async () => {
const user = userEvent.setup();
renderApp();
await user.click(
screen.getByRole("link", { name: "Status guide" }),
);
const heading = screen.getByRole("heading", { name: "Status guide" });
await waitFor(() => {
expect(heading).toHaveFocus();
});
expect(document.title).toBe("Status guide | Delivery Board");
expect(
screen.getByRole("link", { name: "Status guide" }),
).toHaveAttribute("aria-current", "page");
});
});

Run the application file:

Run the routed-client tests
npm test -- src/App.test.tsx

The expected summary reports one passed file and five passed tests.

The page contains three buttons and three links with related visible text. The status-transition test first finds the intended work-item heading, then moves to its containing article, and then uses within(item).

This scope expresses the contract: activate the named button inside the card for Prepare the accessible navigation review. It does not depend on array position or a CSS class.

The instanceof HTMLElement guard validates the result of closest at runtime and narrows its TypeScript type. Do not replace the guard with a non-null assertion. A changed semantic structure should produce a clear test failure rather than an invalid assumption.

Create a user-event session inside each interactive test:

Create and await one interaction session
const user = userEvent.setup();
await user.click(
screen.getByRole("link", { name: "Status guide" }),
);

user-event dispatches the related pointer, mouse, focus, and click events for the requested action. Its methods can trigger asynchronous React work, so await them.

Do not place one shared user session in beforeEach. Each test must make its starting input-device state visible.

Two tests check different focus contracts:

  • When a card remains in the DOM after its status changes, the same button keeps focus and receives a new accessible name.
  • When a filtered card leaves the DOM, the live status message receives focus so keyboard position does not fall back to an unexplained document location.

The route test uses waitFor because the focus change happens in an effect after client navigation. waitFor retries the assertion until it passes or reaches its timeout. It does not add an arbitrary delay.

The suite never asks for the current items state, selected filter Hook, or effect call count. It checks the results those mechanisms exist to produce:

  • the selected value;
  • visible and absent records;
  • updated status and action;
  • live feedback;
  • focus;
  • route recovery;
  • document title; and
  • aria-current on current navigation.

A future refactor can change component boundaries or Hook arrangement while preserving these contracts.

Checkpoint: The routed client has selected regression coverage

What now works
Five App tests reproduce URL state, operate the real status flow, verify both focus outcomes, recover from an unknown item ID, and check route title and current-page behavior through MemoryRouter.
Files changed
src/App.test.tsx, src/components/WorkItemList.test.tsx, src/test/setup.ts, vite.config.ts
What remains
Confirm that the tests detect a plausible defect, restore the source, run the complete quality gate, and repeat the checks that require a real browser.
Next action
Run npm test, then perform the controlled wrong-transition sensitivity check against the named status test.
If it does not work
Run one failing test by file and exact name, read its assertion and accessible DOM output, and repair the earliest setup, query, action, or expectation boundary.

Confirm that a test can detect the wrong behavior

Section titled “Confirm that a test can detect the wrong behavior”

A passing test can still be too weak. Perform one controlled mutation to prove that the status test rejects a plausible wrong implementation.

In src/components/WorkItemCard.tsx, temporarily change only this mapping:

Temporary wrong transition — do not keep
const nextStatusByStatus: Record<WorkStatus, WorkStatus> = {
planned: "done",
active: "done",
done: "planned",
};

Run only the matching test:

Prove that the status test detects the wrong transition
npm test -- src/App.test.tsx -t "updates the selected work item"

The focused run must fail because the card shows Done rather than Active. The failure should point to the expected visible status or next action. A configuration error, missing document, or router-context error would not prove this test’s sensitivity.

Restore the correct mapping immediately:

Restore the required transition
const nextStatusByStatus: Record<WorkStatus, WorkStatus> = {
planned: "active",
active: "done",
done: "planned",
};

Run the complete suite again:

Confirm the restored suite
npm test

The final result must report two passed files and six passed tests. Confirm that git diff does not contain the temporary wrong transition.

Start watch mode when repeated local feedback helps:

Start the optional test watch session
npm run test:watch

Vitest reports the active file and key commands. Edit one test description without changing behavior, save, and observe the related rerun. Press q to exit after the experiment.

Do not use watch mode as the final evidence. Run npm test once after watch mode exits so the complete suite starts from one clean process and returns a final exit code.

Read the test name, assertion message, accessible DOM output, and first source location before editing.

Symptom Likely boundary Focused check
No test files found Discovery Confirm .test.tsx filename and project path
document is not defined Environment Confirm test.environment is jsdom in the loaded Vite config
DOM matcher is missing Setup Confirm the setup path and /vitest jest-dom import
Router Hook lacks context Provider Render App inside one MemoryRouter
Query finds several elements Test scope or interface name Use the intended region or card with within; do not select the first match by index without a contract
Query finds no named control Accessible interface or stale expectation Inspect the rendered roles and names before changing the query
act warning after interaction Async boundary Create user-event inside the test and await the action or resulting async query
Test passes alone but fails in the suite Isolation Check cleanup, mutable module data, mocks, title, timers, and shared globals
Focus assertion fails DOM lifetime or effect timing Confirm the intended element remains or use waitFor for effect-driven navigation focus
Test says Active is missing and DOM shows Done Application transition Compare the status map with the expected planned → active contract
Test is green but the browser is wrong Environment limit or missing case State which browser behavior is outside jsdom and add the smallest suitable browser or end-to-end check

Repair the earliest boundary that explains the evidence. Do not weaken an expectation only because the current implementation disagrees with the product contract.

Treat an accessible query failure as interface evidence

Section titled “Treat an accessible query failure as interface evidence”

If getByRole("button", { name: ... }) cannot find a control that users must identify, inspect the rendered roles and names. The source may have an inaccessible name, the test may contain stale product wording, or the control may not exist in the current state.

Choose the correction from that evidence. A CSS selector can make the test green while preserving a missing accessible name.

jsdom models many document APIs in Node. It does not provide a complete visual or browser environment.

The required tests can check:

  • semantic roles and accessible names derived from the DOM;
  • control values and visible text;
  • event-driven React updates;
  • presence and absence of elements;
  • focus state assigned through DOM APIs;
  • document title; and
  • declarative routing with an in-memory history.

The required tests cannot prove:

  • CSS layout at 320 pixels or 200% zoom;
  • text clipping, horizontal overflow, paint order, or color contrast;
  • the complete browser pointer, keyboard, focus, and History API implementation;
  • direct server handling of /work-items/WI-001;
  • screen-reader announcements or usability with assistive technology;
  • production network, performance, security, or device behavior; or
  • that every important product outcome has a test.

Passing getByRole queries is useful accessibility evidence because the DOM exposes usable semantics. It is not a complete accessibility audit.

From the project root, run each required command:

Run the complete automated gate
npm test
npm run typecheck
npm run lint
npm run build

Expected evidence:

  • two test files and six tests pass;
  • TypeScript reports no error;
  • the linter reports no unresolved error; and
  • Vite creates the production dist output.

If a later command fails, the change is not ready because the earlier command passed. Read the first failing command’s evidence and repair that boundary.

Start the production preview:

Inspect the built routed client
npm run preview

Use the exact URL Vite reports. Repeat these checks in a current browser:

  1. Open /, /?status=planned, /guide, /work-items/WI-001, /work-items/WI-999, and /does-not-exist directly.
  2. Reload each path and confirm the correct route result.
  3. Change the planned item at / and confirm its visible status, live message, and retained action-button focus.
  4. Change the only item at /?status=planned and confirm the empty result plus status-message focus.
  5. Use Board, Status guide, Back, and Forward. Confirm current navigation, titles, headings, and focus.
  6. Check keyboard operation, visible focus, 320 CSS pixels, 200% zoom, long content, and horizontal overflow.
  7. Confirm that the Console contains no unresolved React, router, or application error.

Stop the preview with Ctrl+C after the checks.

Record these browser results beside the automated result in the project documentation. Keep the environment and date visible. Do not report jsdom tests as browser evidence.

Update the existing README.md while the evidence is current. Keep these details in its test procedure or quality section:

  1. Commands: npm test runs the complete suite once; npm run test:watch starts an optional local watch session.
  2. Test files: src/components/WorkItemList.test.tsx checks the isolated empty result; src/App.test.tsx checks routed application behavior; src/test/setup.ts owns shared DOM setup and reset.
  3. Automated scenarios: list the empty collection, planned URL, status transition, filtered focus recovery, unknown item ID, and route title-and-focus cases.
  4. Environment: state that these tests run in jsdom through Node and do not run a full browser engine.
  5. Required browser evidence: retain the direct-route, keyboard, focus, narrow-width, 200%-zoom, overflow, and Console checks.
  6. Latest verified result: record the test count, automated command results, named browser, browser version, operating system, viewport checks, test date, and any evidence-backed limit.

Do not paste terminal logs into README. Record the result and conditions another contributor needs to repeat the checks. Do not include a username, local absolute path, token, or private student data.

Inspect the complete project diff:

Review the client test diff
git status --short
git diff

The expected change contains:

  • package.json and package-lock.json for test tools and scripts;
  • vite.config.ts for the test environment and setup path;
  • new src/test/setup.ts;
  • new src/components/WorkItemList.test.tsx; and
  • new src/App.test.tsx; and
  • updated README.md test commands, coverage boundary, browser procedure, and latest evidence.

No production application source must remain changed. The temporary wrong status transition must not appear in the diff. Generated dist, coverage output, and node_modules remain outside Git.

Stage the exact files:

Commit the verified client tests
git add package.json package-lock.json vite.config.ts
git add src/test/setup.ts src/App.test.tsx
git add src/components/WorkItemList.test.tsx
git add README.md
git diff --cached
git commit -m "test: add React behavior coverage"
git status

The final status must be clean on feature/react-components. Keep all four React lesson commits available for Project stage 2 review.

Self-check

Complete these checks against the required result.

  1. Confirm that testing started from the clean routing commit and that the baseline source and production checks passed.
  2. Point to the six planned contracts and name one plausible regression that each test can detect.
  3. Confirm that all five test tools are development dependencies and that package-lock.json records the resolved graph.
  4. Explain why test runs once and exits while test:watch remains open for local feedback.
  5. Confirm that vite.config.ts keeps the React plugin and adds only the jsdom environment and setup path needed now.
  6. Explain why the setup imports the Vitest-specific jest-dom entry and resets both the React tree and document title.
  7. Run WorkItemList.test.tsx alone and confirm its heading, visible message, and absent-list assertions.
  8. Explain why vi.fn supplies a prop in the empty test but does not prove a user-visible callback result.
  9. Point to one role-and-name query, one visible-text query, one value assertion, and one absence query.
  10. Explain when getBy, queryBy, and findBy are appropriate.
  11. Confirm that renderApp supplies an explicit MemoryRouter entry without changing the real browser history.
  12. Open the planned URL test and trace its arranged route, immediate assertions, and plausible failure.
  13. Run the status interaction and confirm Active, the next named action, retained focus, and live feedback.
  14. Run the filtered status interaction and confirm that the card leaves, the empty result appears, and feedback receives focus.
  15. Run the unknown-item test and confirm the requested ID plus recovery link.
  16. Navigate to Status guide and confirm heading focus, document title, and aria-current.
  17. Confirm that every user-event interaction is awaited and that each user session starts inside its test.
  18. Confirm that the suite does not inspect React state, component instances, CSS classes, or private call order.
  19. Perform the controlled wrong transition, confirm the focused test fails for visible Done instead of Active, then restore the correct source.
  20. Run the complete suite and confirm two files and six tests pass in one clean process.
  21. Run typecheck, lint, and build after the test suite.
  22. Repeat the direct-route, interaction, focus, keyboard, narrow, zoom, overflow, and Console checks in the production preview.
  23. State at least four results that jsdom cannot prove and identify the required real-browser evidence for them.
  24. Confirm that README names both test commands, all six scenarios, test-file responsibilities, jsdom limits, browser checks, and the latest evidence conditions.
  25. Inspect the final diff, confirm no deliberate defect or generated output remains, create the test commit, and confirm a clean branch.

Explanation: test the contract at the narrowest realistic boundary

Section titled “Explanation: test the contract at the narrowest realistic boundary”

The final suite uses two test seams:

  • WorkItemList is a focused component boundary for a collection with no items.
  • App is an integration boundary for state, pages, routes, URL search state, effects, and navigation.

The complete application tests are larger, but each test still names one scenario and one outcome. They use real local components and router behavior. No test mocks the component whose behavior it claims to prove.

Accessible queries connect testability and interface quality. A test that finds Start work: Prepare the accessible navigation review uses the same computed name exposed to assistive technology. If that name disappears, the test reports a meaningful interface change.

The suite remains selective. It does not duplicate every manual route case or prove CSS behavior through DOM strings. A useful test portfolio combines static checks, focused unit or component tests, integration tests, and real-browser checks according to the risk and runtime boundary.

The six required tests and real-browser checks complete this lesson. Keep each selected extension outside the required commit or identify it in a separate commit.

The required result is safely paused when both test files pass, the controlled defect has been restored, typecheck, lint, and build pass, the production-preview browser path is recorded, the test commit exists, and git status is clean on feature/react-components.

Continue to Project stage 2: Build the React client. That assignment will ask you to apply the component, state, routing, accessibility, responsive, test-design, browser-verification, documentation, and Git practices to the project client as one reviewable product stage.