Pure-reference catalog of test-code conventions: AAA structure (Arrange / Act / Assert), per-test single-responsibility, descriptive naming (`{sut}_{scenario}_{expected}`), assertion specificity, mocking rationale (state vs behavior, fake vs mock), fixture-coupling rules, the magic-number / hard-coded-string anti-patterns, and step design (§11: FIRST principles, step granularity, the mechanical → page → business abstraction layers, the rule-of-three extraction heuristic, declarative phrasing); the E2E selector-priority, web-first-assertion, and step-grouping conventions live in references/. Use as the shared rule book a test-code review cites back to, or as onboarding for what makes a test code-reviewable; to score a test's quality on weighted axes use test-design-scorecard, and for setup/teardown isolation specifically use test-isolation-patterns.
88
88%
Does it follow best practices?
Impact
—
Average score across 10 eval scenarios
Passed
No findings from the security scan
This skill is a pure reference - no actions, no workflows. It catalogs the test-code conventions a test-code review enforces. When a review flags an issue, this reference gives the reviewer the underlying rule.
*.spec.* /
*.test.* / tests/** only; production code is out of scope.§4) and spend the
words on the specific edit, not on restating the rule.The canonical test shape - Arrange, Act, Assert - splits each test into three phases:
test('addItem increases cart count', () => {
// Arrange
const cart = new Cart();
// Act
cart.addItem({ sku: 'BOOK-001', qty: 1 });
// Assert
expect(cart.itemCount).toBe(1);
});The phases are visually separated (blank line; comment; or
// arrange / act / assert headers). The benefits:
Each test has exactly one Act. Two Acts = two tests. Splitting on multiple Acts is the canonical refactor when a single test grows too long.
A test that asserts cart.count === 1 AND cart.totalPrice === 10 AND cart.lastUpdated > now() is three tests in disguise. When one
assertion fails, the test stops; the other two failures are
masked.
The rule: one logical assertion per test. "Logical" means
related to the same observable property - multiple expect calls
that all verify "the cart has one item" (count = 1, items.length = 1,
contents[0].sku = '...') are one logical assertion.
Splitting:
// Bad - three logical assertions
test('addItem updates cart', () => {
cart.addItem(...);
expect(cart.itemCount).toBe(1); // assertion 1
expect(cart.totalPrice).toBe(10); // assertion 2 (different property)
expect(cart.lastUpdated).toBeGreaterThan(t0); // assertion 3 (different property)
});
// Good - three tests, each assertion isolated
test('addItem increments count', () => { /* ... */ });
test('addItem updates totalPrice', () => { /* ... */ });
test('addItem updates lastUpdated', () => { /* ... */ });Two well-established conventions:
<system_under_test>_<scenario>_<expected> (Roy Osherove)test('addItem_validQty_incrementsCount', () => { /* ... */ });
test('addItem_zeroQty_throwsValidationError', () => { /* ... */ });
test('addItem_negativeQty_throwsValidationError', () => { /* ... */ });The triple makes the test self-documenting; no need to read the body to understand what's verified.
describe('Cart', () => {
describe('addItem', () => {
describe('with valid qty', () => {
it('increments count', () => { /* ... */ });
});
describe('with zero qty', () => {
it('throws ValidationError', () => { /* ... */ });
});
});
});Both are valid; the team picks one and stays consistent. Mixing the two in one suite is the smell.
Avoid: test('it works'), test('test 1'),
test('addItem 1'), test('addItem 2'). Generic names are zero
debugging help when the failure surfaces.
Assertions should narrow the verification window to exactly what's being asserted. Vague matchers hide regressions:
| Vague | Specific | Why specific is better |
|---|---|---|
expect(x).toBeTruthy() | expect(x).toEqual({ id: 1, name: 'foo' }) | "truthy" passes for 1, 'a', {}, [] - many bug shapes pass. |
expect(arr).toBeDefined() | expect(arr).toEqual(['BOOK-001']) | "defined" passes for [], null-prototype, … |
expect(err).toBeInstanceOf(Error) | expect(err.code).toBe('VALIDATION_ERROR') | Catches "right type, wrong reason" regressions. |
expect(response.status).toBeGreaterThan(199) | expect(response.status).toBe(201) | 200, 204, 299 also pass - masks status-code regressions. |
expect(html).toContain('error') | expect(html).toMatch(/<div class="error">.*Invalid/) | "error" matches "no errors" too. |
The general principle: the assertion should fail on any change to the SUT's behavior that isn't intentional. If the assertion passes for behaviors that shouldn't, it's too loose.
The full taxonomy per mocks-stubs:
"Dummy objects: passed around but never actually used. Usually they are just used to fill parameter lists." (mocks-stubs)
"Fake objects: actually have working implementations, but usually take some shortcut which makes them not suitable for production." (mocks-stubs)
"Stubs: provide canned answers to calls made during the test, usually not responding at all to anything outside what's programmed in." (mocks-stubs)
"Spies: stubs that also record some information based on how they were called." (mocks-stubs)
"Mocks: objects pre-programmed with expectations which form a specification of the calls they are expected to receive." (mocks-stubs)
Per mocks-stubs: "Only mocks insist upon behavior verification. The other doubles can, and usually do, use state verification."
Convention rules:
Tests that share fixtures (parameters, builders, factory functions) should be coupled at the smallest scope:
| Scope | When to use |
|---|---|
| Inline (per test) | Default. The test owns the data; reviewer sees what's being tested. |
describe-block | Multiple tests verify the same scenario differently. |
File-level (beforeEach) | Shared setup with no per-test variation. |
| Cross-file factory | Shared shapes, not shared instances. Use builders / factories. |
Anti-pattern: a giant globalFixtures.ts that every test imports.
Tests now break in unrelated ways when the global is touched; the
test no longer "owns" what it verifies.
Test code is more tolerant of magic numbers than production code - the test's job is often to assert against specific values. But meaningful magic matters:
// Bad - unexplained value
expect(cart.totalPrice).toBe(43.21); // why 43.21?
// Also bad - expectation recomputes the formula under test, so it agrees
// with the implementation even when the implementation is wrong
const EXPECTED_TOTAL = PRICE_PER_BOOK * QTY * (1 + EXPECTED_TAX_RATE);
expect(cart.totalPrice).toBeCloseTo(EXPECTED_TOTAL, 2);
// Better - inputs named, expected value stated independently
const PRICE_PER_BOOK = 10.99;
const QTY = 4;
const TAX_RATE = 0.0825;
expect(cart.totalPrice).toBeCloseTo(47.59, 2); // 4 x 10.99, +8.25% taxName the inputs; never derive the expected value from the expression under test. The named inputs document where the number came from, and the literal expectation is what makes the assertion independent of the code it is checking.
Prefer user-facing, accessibility-first locators over DOM-structure
selectors. Per tl-queries the query priority is getByRole →
getByLabelText → text / placeholder → getByTestId (last resort), and
per pw-best-practices CSS-class and XPath selectors are brittle
because the DOM changes freely. The full priority table, the per-framework
mappings (Playwright / Cypress / Selenium), and the CSS/XPath rationale are
in references/e2e-selector-and-assertion-conventions.md.
Prefer auto-waiting web-first assertions
(await expect(locator).toBeVisible()) over synchronous .isVisible()
checks that race the render, per pw-best-practices. The before /
after example is in references/e2e-selector-and-assertion-conventions.md.
A test that takes >1s in setup (creating fixtures, seeding DB, warming caches) has a coupling problem. The remedies:
beforeAll if shared.db:reset.The whole-suite cost of slow setup compounds: 10 tests × 2s = +20s per CI run × 50 PRs/day = 1000s/day burned.
Within an "Act" phase, what is one step? The architecture-tier rules for step granularity, layering, and phrasing:
Per Robert C. Martin - Clean Code (2008), ch. 9 "Unit Tests": Fast (slow tests don't get run), Independent (each test sets up its own world - per Fowler on non-determinism, the prerequisite to parallel execution), Repeatable (any environment), Self-validating (no human reads logs to determine the outcome), Timely (written close to the production code). A step that violates FIRST is the smell; the rules below are the fix.
A "step" is the minimal unit a reader can name in business terms -
one meaningful operation, not one click. Each step does exactly
one of Arrange / Act / Assert / Annotate; steps that mix two are
split (await page.click('#submit'); expect(toast).toBeVisible();
is an Act and an Assert). Aim for 3-8 steps per test body; >15
signals the test does too much or sits at the wrong abstraction.
Three layers, named consistently: business (customer.signsIn(),
checkout.placesOrder()) → page / component (Page Objects,
Tasks: LoginPage.submit({...})) → mechanical (page.click(),
page.fill()). The test body lives at the business layer - a
body full of mechanical clicks reads as a script, not a
specification. Exceptions that legitimately stay mechanical: a11y
keyboard-order tests, visual regression, selector-resilience tests -
tag them (@a11y, @visual) so reviewers don't refactor them.
Per Fowler - Refactoring (2nd ed. 2018):
inline at first occurrence, note at the second, extract at the
third. Always extract a step that is 5+ mechanical lines, appears in
3+ tests, or needs an explanatory comment (the comment is the smell
that the abstraction is missing). Don't pre-extract on the first
test (YAGNI), and never hide Act + Arrange in one helper
(setupAndDoThing()).
Prefer declarative ("the customer signs in") over imperative ("enters email, clicks submit") - the test: would the wording change if the implementation changed? If yes, rewrite (per Cucumber - Better Gherkin, which applies beyond Gherkin). Step naming follows the same discipline as §3 test naming (per Osherove - The Art of Unit Testing): read the body aloud - it should sound like a specification, not "click, type, click, expect-truthy".
The AAA / Given-When-Then vocabulary mapping, the phase-separation
rule, and the full declarative-vs-imperative tables with
when-imperative-is-correct cases are in
references/step-grouping-and-phrasing.md.
Where extracted steps live (Page Object / Screenplay / App Actions)
is object-model-patterns; fixture lifecycle is
test-isolation-patterns.
The file under review (checkout.spec.ts):
let cart; // file-level, reused across tests
beforeAll(() => { cart = buildCart(); });
test('checkout 1', async () => {
cart.addItem({ sku: 'BOOK-001', qty: 2 });
const res = await checkout(cart);
expect(res).toBeTruthy();
expect(cart.total).toBe(43.21);
});Walking the conventions:
| Convention | Finding | Fix |
|---|---|---|
| §1 AAA | Phases not separated; two logical Acts (addItem, checkout). | Blank-line the phases; split the two Acts into two tests. |
| §2 Single-responsibility | Asserts the checkout result and the cart total - two targets. | One assertion target per test. |
| §3 Naming | checkout 1 names nothing. | checkout_validCart_confirmsOrder. |
| §4 Assertion specificity | expect(res).toBeTruthy() passes for 1, {}, []. | expect(res.status).toBe('confirmed'). |
| §6 Fixture coupling | cart is a file-level beforeAll fixture the test mutates. | Rebuild per test in beforeEach (inline ownership). |
| §7 Magic literals | 43.21 has no visible derivation. | Derive from named PRICE, QTY, TAX_RATE. |
After the fixes:
const PRICE = 19.99, QTY = 2, TAX_RATE = 0.0825;
let cart;
beforeEach(() => { cart = buildCart(); });
test('checkout_validCart_confirmsOrder', async () => {
// Arrange
cart.addItem({ sku: 'BOOK-001', qty: QTY });
// Act
const res = await checkout(cart);
// Assert
expect(res.status).toBe('confirmed');
});
test('addItem_appliesTaxedTotal', () => {
// Arrange
cart.addItem({ sku: 'BOOK-001', qty: QTY });
// Assert
expect(cart.total).toBeCloseTo(PRICE * QTY * (1 + TAX_RATE), 2);
});Each test now has one Act, one assertion target, a self-documenting name,
a specific matcher, an inline-owned fixture, and a derived expected value.
For the E2E selector and web-first conventions applied to a *.e2e.ts
file, see references/e2e-selector-and-assertion-conventions.md.