Skip to content

Test Naming & Structure

FieldValue
TypeSkill Resource
Source~/.copilot/skills/quality/references/naming-structure.md
DescriptionNot specified

Source Content

Test Naming & Structure

The convention exists so any engineer can read a failing test name and know what broke without opening the file. Consistency here is worth more than cleverness.

Table of contents

Naming

A test name describes observable behavior, not the method called. The pattern is does X when Y — present tense, no “should”, no implementation words.

AvoidPrefer
it("test submit")it("shows a confirmation when the email is valid")
it("should call onSave")it("saves the draft when the user clicks Save")
it("renders correctly")it("disables the button while the form is submitting")
it("works")it("rejects an amount above the account limit")

Rules:

  • describe the unit, it the behavior. describe("proratedAmount") groups; each it is one behavior of it.
  • No “should”. It adds a word and no meaning. it("returns zero when …"), not it("should return zero when …").
  • No implementation words — no method names, no “calls”, no “state” in the behavior clause. Name what the user or caller observes.
  • One behavior per it. A name containing “and” is two tests.

Structure — Arrange-Act-Assert

Every test has three visually separated phases, in order, with a blank line between them:

it("rejects an amount above the account limit", () => {
// Arrange
const account = makeAccount({ limit: 5000 });
// Act
const result = withdraw(account, 6000);
// Assert
expect(result.ok).toBe(false);
expect(result.error).toBe("LIMIT_EXCEEDED");
});
  • Arrange — set up data, render, install handlers. Push repeated setup into a beforeEach or a small factory (makeAccount), not copy-paste.
  • Act — the single action under test. If a test has two distinct actions, it is two tests.
  • Assert — what changed. Assert the behavior, not the mechanism.

The comment markers are optional once the blank-line rhythm is habitual, but the three-phase order is not.

File and directory layout

  • Co-locate the test with its subject: billing.tsbilling.test.ts; SubscribeForm.tsxSubscribeForm.test.tsx.
  • Use .test.ts(x) (the house suffix) consistently; do not mix .spec and .test in one repo.
  • Shared MSW handlers, render helpers, and factories live in a test/ or src/test-utils/ folder and are imported — never redefined per file.
  • E2E specs live in their own top-level directory (e.g. e2e/), separate from unit/component tests, because they run on a different command and schedule.

Worked example

A component test that exercises all the house layers at once — behavior, network, and a11y:

import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { axe } from "jest-axe";
import { http, HttpResponse } from "msw";
import { server } from "../test/server";
import { InvoiceList } from "./InvoiceList";
describe("InvoiceList", () => {
it("renders each invoice returned by the API", async () => {
// Arrange
server.use(
http.get("/api/invoices", () =>
HttpResponse.json([{ id: "inv_1", total: 14500 }]),
),
);
// Act
render(<InvoiceList />);
// Assert
expect(await screen.findByText(/\$145\.00/)).toBeInTheDocument();
});
it("shows an error message when the request fails", async () => {
// Arrange
server.use(
http.get("/api/invoices", () => new HttpResponse(null, { status: 500 })),
);
// Act
render(<InvoiceList />);
// Assert
expect(await screen.findByText(/couldn’t load invoices/i)).toBeInTheDocument();
});
it("has no accessibility violations", async () => {
// Arrange / Act
const { container } = render(<InvoiceList />);
// Assert
expect(await axe(container)).toHaveNoViolations();
});
});

Note: the amount is asserted as a locale-formatted string ($145.00), matching the house rule that all currency renders through formatCurrency (ADR-013). Tests assert the formatted output the user sees, not the raw 14500 integer.