Interview question
Test a component through user behavior
Tests validation, focus, pending state, and the save contract through accessible user interactions rather than component internals.
TL;DR
Tests validation, focus, pending state, and the save contract through accessible user interactions rather than component internals.
Testing Library query priority, user-event, accessible validation, focus management, asynchronous state, duplicate-submit protection, and resilient assertions.
Practice the problem like a real interview: restate, reason, implement, and test.
A ProfileForm accepts name and email and calls an asynchronous onSave callback. Write a component test that submits an empty form, verifies that the validation summary receives focus and the fields expose their errors, corrects both values, submits again, and proves that only one normalized payload is saved while the request is pending.
Given a blank form, Save reveals Name is required and Enter a valid email, and focus moves to the error summary. After the user enters Ada Lovelace and ada@example.com, Save changes to Saving..., remains disabled until the promise settles, and onSave receives exactly { name: "Ada Lovelace", email: "ada@example.com" } once.
userEvent.setup() session and await interactions and asynchronous UI.onSave so the test decides when saving finishes.userEvent, submit, and assert the exact normalized callback payload.import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { expect, it, vi } from "vitest";
import { ProfileForm } from "./profile-form";
it("guides correction and saves once", async () => {
const user = userEvent.setup();
let finishSave!: () => void;
const onSave = vi.fn(
() => new Promise<void>((resolve) => { finishSave = resolve; }),
);
render(<ProfileForm onSave={onSave} />);
await user.click(screen.getByRole("button", { name: "Save profile" }));
const summary = await screen.findByRole("alert");
expect(summary).toHaveFocus();
expect(screen.getByText("Name is required")).toBeVisible();
expect(screen.getByText("Enter a valid email")).toBeVisible();
expect(screen.getByLabelText("Name")).toHaveAttribute("aria-invalid", "true");
await user.type(screen.getByLabelText("Name"), "Ada Lovelace");
await user.type(screen.getByLabelText("Email"), "ada@example.com");
await user.click(screen.getByRole("button", { name: "Save profile" }));
expect(onSave).toHaveBeenCalledTimes(1);
expect(onSave).toHaveBeenCalledWith({
name: "Ada Lovelace",
email: "ada@example.com",
});
const pendingButton = screen.getByRole("button", { name: "Saving..." });
expect(pendingButton).toBeDisabled();
await user.click(pendingButton);
expect(onSave).toHaveBeenCalledTimes(1);
finishSave();
await waitFor(() => expect(screen.getByText("Profile saved")).toBeVisible());
});
The component may use different markup, but it must preserve these accessible names and observable behaviors for the test contract to remain valid.
This test intentionally covers one meaningful component workflow, so its runtime is dominated by rendering and user-event scheduling rather than input size. Semantic queries make the test slightly stricter because inaccessible markup fails earlier, which is useful feedback. Detailed server error mapping, network retries, and persistence belong in focused component-integration or end-to-end tests instead of making this case responsible for every branch.
onSave rejects and the form returns to an actionable error state.onSave ran, not the normalized payload or call count.Move to the linked follow-up, next path step, prerequisite, or deeper variant.
Practice the next layer of the same subject.