Testland
Browse all skills & agents

test-code-conventions

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.

Install with skills.sh (any agent)

npx skills add testland/qa --skill test-code-conventions
View source

test-code-conventions

Overview

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.

When to use

  • A new team member is onboarding to the codebase and needs to understand "what we mean by a good test."
  • A review flagged an issue and the reviewer needs the underlying rule's rationale.
  • The team is authoring its own per-team test conventions document and wants a starting point.

How to use

  1. Scope to test files. Apply these conventions to *.spec.* / *.test.* / tests/** only; production code is out of scope.
  2. Read the Act lines first (§1). One scan of the single Act per test tells you what each test exercises before you read the body.
  3. Check one logical assertion target (§2) and a self-documenting name (§3) - hide the body and see whether the name still explains it.
  4. Judge the assertions and doubles (§4 assertion specificity, §5 mocking) - loose matchers and behaviour-verification are the common misses.
  5. Trace fixture scope and magic literals (§6, §7): the smallest scope that holds, and named values across the cause-effect chain.
  6. Flag slow setup (§10); for E2E tests, apply the selector-priority and web-first rules in §8 / §9.
  7. Cite the section when flagging. Name the rule (§4) and spend the words on the specific edit, not on restating the rule.

§1 - AAA structure

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:

  • Reviewer can scan a 50-test file by reading only the Act lines - knows what each test actually exercises.
  • Failure debugging is faster: the failed assertion points to the specific Assert; the Arrange and Act are isolated.

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.

§2 - Single-responsibility per test

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', () => { /* ... */ });

§3 - Naming patterns

Two well-established conventions:

A - <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.

B - Nested describe + it

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.

§4 - Assertion specificity

Assertions should narrow the verification window to exactly what's being asserted. Vague matchers hide regressions:

VagueSpecificWhy 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.

§5 - Mocking

The full taxonomy per mocks-stubs (opens in new window):

"Dummy objects: passed around but never actually used. Usually they are just used to fill parameter lists." (mocks-stubs (opens in new window))

"Fake objects: actually have working implementations, but usually take some shortcut which makes them not suitable for production." (mocks-stubs (opens in new window))

"Stubs: provide canned answers to calls made during the test, usually not responding at all to anything outside what's programmed in." (mocks-stubs (opens in new window))

"Spies: stubs that also record some information based on how they were called." (mocks-stubs (opens in new window))

"Mocks: objects pre-programmed with expectations which form a specification of the calls they are expected to receive." (mocks-stubs (opens in new window))

Per mocks-stubs (opens in new window): "Only mocks insist upon behavior verification. The other doubles can, and usually do, use state verification."

Convention rules:

  1. Prefer state verification over behavior verification. Assert on the SUT's resulting state, not on which methods were called on a collaborator. Behavior verification couples the test to the implementation; refactors break tests that should pass.
  2. Don't mock what you don't own. Mocking third-party libraries makes tests pass against a fictional API; when the library updates, the mock drifts undetected. Prefer adapter patterns + contract tests against the real boundary.
  3. Prefer fakes over mocks for state-bearing collaborators. A fake DB / fake clock / fake feature-flag service is reusable across tests; mocks are bespoke per test.

§6 - Fixture coupling

Tests that share fixtures (parameters, builders, factory functions) should be coupled at the smallest scope:

ScopeWhen to use
Inline (per test)Default. The test owns the data; reviewer sees what's being tested.
describe-blockMultiple tests verify the same scenario differently.
File-level (beforeEach)Shared setup with no per-test variation.
Cross-file factoryShared 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.

§7 - Magic numbers and strings

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% tax

Name 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.

§8 - E2E selectors

Prefer user-facing, accessibility-first locators over DOM-structure selectors. Per tl-queries (opens in new window) the query priority is getByRole getByLabelText → text / placeholder → getByTestId (last resort), and per pw-best-practices (opens in new window) 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.

§9 - Web-first assertions (E2E)

Prefer auto-waiting web-first assertions (await expect(locator).toBeVisible()) over synchronous .isVisible() checks that race the render, per pw-best-practices (opens in new window). The before / after example is in references/e2e-selector-and-assertion-conventions.md.

§10 - Slow setup is a smell

A test that takes >1s in setup (creating fixtures, seeding DB, warming caches) has a coupling problem. The remedies:

  • Move fixture creation to a per-suite beforeAll if shared.
  • Use a template-DB snapshot/restore pattern for DB tests instead of db:reset.
  • Move the unit test to an integration layer if it really needs the full stack - don't run integration tests under the unit-test budget.

The whole-suite cost of slow setup compounds: 10 tests × 2s = +20s per CI run × 50 PRs/day = 1000s/day burned.

§11 - Step design (architecture tier)

Within an "Act" phase, what is one step? The architecture-tier rules for step granularity, layering, and phrasing:

FIRST principles

Per Robert C. Martin - Clean Code (2008), ch. 9 "Unit Tests" (opens in new window): Fast (slow tests don't get run), Independent (each test sets up its own world - per Fowler on non-determinism (opens in new window), 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.

Step granularity

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.

Abstraction layers

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.

Extraction: the rule of three

Per Fowler - Refactoring (2nd ed. 2018) (opens in new window): 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()).

Phrasing and naming

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 (opens in new window), which applies beyond Gherkin). Step naming follows the same discipline as §3 test naming (per Osherove - The Art of Unit Testing (opens in new window)): 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.

Worked example - reviewing one test file

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:

ConventionFindingFix
§1 AAAPhases not separated; two logical Acts (addItem, checkout).Blank-line the phases; split the two Acts into two tests.
§2 Single-responsibilityAsserts the checkout result and the cart total - two targets.One assertion target per test.
§3 Namingcheckout 1 names nothing.checkout_validCart_confirmsOrder.
§4 Assertion specificityexpect(res).toBeTruthy() passes for 1, {}, [].expect(res.status).toBe('confirmed').
§6 Fixture couplingcart is a file-level beforeAll fixture the test mutates.Rebuild per test in beforeEach (inline ownership).
§7 Magic literals43.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.

References

E2E selectors and web-first assertions

View source (opens in new window)

E2E selectors and web-first assertions

Deep reference for the test-code-conventions SKILL.md (§8 and §9). Consult when reviewing end-to-end tests (Playwright / Cypress / Selenium / WebdriverIO) where locator fragility or synchronous assertions are the concern. The universal conventions (§1-§7, §10) stay in the SKILL; these two are E2E-specific.

§8 - E2E selectors

Per Playwright best practices (opens in new window): "Your DOM can easily change so having your tests depend on your DOM structure can lead to failing tests."

Per Testing Library query priority (opens in new window) (highest to lowest):

PriorityQueryWhen to use
1getByRole('button', { name: 'Submit' })Default. Tests via the accessibility tree - the same path users take.
2getByLabelText('Email')Form fields with an associated <label>.
3getByPlaceholderText, getByText, getByDisplayValueWhen labels aren't available.
4getByAltText, getByTitleImages, tooltips.
5getByTestId('submit-button')Last resort. Per Testing Library: "The user cannot see (or hear) these."

CSS class selectors (.button-primary) and XPath (//div[@class='cart']//button[1]) are not on the priority list - per Playwright best practices they are explicitly identified as brittle.

The same convention holds across runners: Playwright (page.getByRole(...)), Cypress (cy.findByRole(...) via cypress-testing-library), and Selenium (when the test framework supports role-based queries via extensions).

§9 - Web-first assertions (E2E)

Per Playwright best practices: avoid "manual assertions without waiting. Using isVisible() checks immediately without awaiting, rather than web-first assertions like toBeVisible() that wait for conditions to be met."

// Bad - race condition
expect(page.locator('.toast').isVisible()).toBe(true);

// Good - auto-waits
await expect(page.locator('.toast')).toBeVisible();

The web-first form auto-waits for the assertion to become true within the test's timeout, eliminating the wait-N-seconds-and-hope pattern. A synchronous .isVisible() snapshot races the render and flakes on any latency the test author did not anticipate.

Worked example - reviewing one E2E test file

// Before - brittle selector, synchronous assertion
await page.locator('.cart-panel .btn-primary').click();
expect(page.locator('.toast-success').isVisible()).toBe(true);

// After - accessibility-first selector, web-first assertion
await page.getByRole('button', { name: 'Checkout' }).click();
await expect(page.getByRole('status')).toHaveText('Order confirmed');

The rewrite resolves the button through the accessibility tree (survives DOM refactors) and awaits the outcome (no render race).

References

  • Playwright best practices - user-facing locators, web-first assertions, "automated tests should verify that the application code works for the end users": https://playwright.dev/docs/best-practices
  • Testing Library query priority - role-based → label → placeholder / text → testId; testid is the documented last resort ("The user cannot see (or hear) these"): https://testing-library.com/docs/queries/about/

Step grouping and phrasing

View source (opens in new window)

Step grouping and phrasing

Reference detail for test-code-conventions §11 (step design): how test steps are grouped into phases and how they are phrased. The SKILL.md spine summarizes and links here; this file holds the full tables, guidance, and code.

AAA / Given-When-Then mapping (Pattern 5)

Two equivalent step-grouping vocabularies. Same content, different name conventions.

AAA (xUnit tradition)Given-When-Then (BDD tradition)
ArrangeGiven
ActWhen
AssertThen
(Annotate)(no equivalent; goes in step comments)

Both express the same three phases. The team picks one and uses it consistently. Don't mix: if some tests use AAA comments and others use Given-When-Then helpers, the cognitive cost grows.

When AAA is the right choice

  • xUnit-family frameworks (Jest, pytest, JUnit, NUnit).
  • Tests that aren't user-facing scenarios (data-quality, security, perf).
  • The team prefers comment-anchored phase separation.

When Given-When-Then is the right choice

  • BDD frameworks (Cucumber, SpecFlow, Reqnroll, Behave) where the syntax is enforced.
  • Tests that are user-facing scenarios (E2E, integration).
  • The team wants product stakeholders to read the tests.

The phase-separation rule

Whatever vocabulary the team uses, the phases must be visually separable. The pattern from test-code-conventions §1:

test('places an order', async () => {
  // Arrange
  const customer = await aCustomer().withCartItem('sku-001').build();

  // Act
  const order = await customer.placesOrder();

  // Assert
  expect(order.status).toBe('confirmed');
});

Or, with blank-line separation (no comments needed):

test('places an order', async () => {
  const customer = await aCustomer().withCartItem('sku-001').build();

  const order = await customer.placesOrder();

  expect(order.status).toBe('confirmed');
});

Either works; lacking either fails the §1 AAA review.

Declarative vs imperative step phrasing (Pattern 6)

Even at the business layer, two phrasings exist:

ImperativeDeclarative
"The customer enters the email and password and clicks submit""The customer signs in"
"The customer adds 5 items to the cart and proceeds to checkout""The customer initiates checkout with a 5-item cart"
"The customer is created with role=admin and org_id=42""An admin customer in org 42"

The declarative form is preferred per Cucumber's better-Gherkin guidance (opens in new window) (which applies broadly, not just to Gherkin): "scenarios should describe the intended behaviour of the system, not the implementation."

The test: would the wording need to change if the implementation changed? If yes, the step is imperative; rewrite declaratively.

When imperative is correct

  • Tests that test the imperative mechanic (e.g., "the form submits on Enter key press" - Enter is the mechanic under test).
  • Accessibility tests where the keyboard sequence IS the test.

Anti-patterns

Anti-patternWhy it fails
Declarative step that hides a critical mechanic (customer.signsIn() when the test is about SSO redirect)The test no longer tests what it claims to test
Imperative step that re-describes the system internalsBrittle to refactors; the test breaks when the implementation changes for unrelated reasons
Mixing imperative and declarative within one testReader can't tell what abstraction level they're operating at

Related skills

object-model-patterns

Pure reference catalog of the canonical object-model architecture patterns for test automation frameworks - Page Object Model (Fowler), Screenplay (Marcano/Palmer/Hill), Component Object, App Actions (Cypress idiom), Service Object, Repository, and Screen Object (the desktop/mobile sibling of Page Object covering Windows UIA, macOS XCTest, Linux AT-SPI, Appium / Espresso) - each with its canonical citation, when-to-use rules, refuse-to-mix anti-patterns, and a worked example. This is the architecture-tier reference - what each pattern *is* - not file-level style rules and not tool-specific configuration. Use when designing, reviewing, or migrating a test framework's object-model architecture.

test-design-scorecard

Scores test files 1 to 5 on six design axes (AAA phase separation, single-responsibility, naming, fixture coupling, magic literals, setup time) using explicit per-level anchors that settle what separates a 2 from a 4, then turns the scores into growth-framed feedback and a per-author trend report; the per-PR and per-author rollup examples and trend-reporting conventions live in references/. Owns the scoring and the write-up only: the conventions being scored live in a separate conventions catalog such as `test-code-conventions`, and block-or-approve gating belongs to an adversarial review. Use when a test diff needs a graded coaching read rather than a merge verdict: onboarding a new engineer, a team deliberately ramping up test discipline, or a quarterly per-author trend where the output is a conversation, not a gate.

test-framework-architecture-audit

Audits an existing test automation framework across eight architecture-tier axes and bands each one PASS, WARN, or FAIL: page-object coverage and purity, base-class inheritance depth, fixture scope and coupling, helper sprawl, naming-convention drift, retry and wait consistency, documented-versus-actual convention drift, and CI integration health. Carries the numeric cut behind every band and labels which cuts are practitioner conventions rather than published standards. Measures the framework's own structure (page objects, base classes, fixtures, helpers, conventions), not the suite's tier mix or flake rate, and not the design of a framework that does not exist yet. Use when a test framework has grown for a release or more without structural review, before a major refactor, or when a team suspects its written test conventions no longer match what the code actually does.

test-framework-blueprint

Build-an-X workflow that takes an SDET from no test suite to a complete framework design in seven steps - inventory the SUT, choose runner + language, directory layout + fixture architecture, object-model decision, test data + mocking wiring, reporting + CI integration, conventions doc + review gates - producing a written framework blueprint (directory tree, fixture list, chosen patterns, CI matrix) plus an implementation order. This is the whole-framework design workflow - not the Step 2 runner-choice decision on its own, not the Step 4 object-model pattern catalog it defers to, and not the scaffolder that generates the harness skeleton once the blueprint exists. Use when designing a test automation framework from scratch or re-architecting one that grew organically.

test-isolation-patterns

Pure reference catalog of test-isolation and fixture-lifecycle patterns - the four-phase test pattern (Meszaros), fixture scope (per-test / per-describe / shared / global), the Fresh-Fixture vs Shared-Fixture trade-off (Fowler), parallel-safety patterns, and cleanup discipline (afterEach / afterAll / tagged-cleanup), plus a pattern-selection guide and a worked leaking-state diagnosis. The database-isolation strategies (transaction-rollback / database-per-worker / template-database) and network / external-service stubbing live in references/. This is the architecture-tier reference, not a file-level fixture-coupling style rule. Use when designing fixture scope and isolation strategy, auditing fixture coupling or retry/wait policy, or moving a suite to parallel execution.

test-suite-health-audit

Measures an existing test suite's current state on four axes: per-file tier classification (unit / integration / E2E, first match wins), pyramid ratio against whatever target the team already committed to, per-layer flake rate, and defects-caught-per-run-minute ROI per tier, then reduces them to one categorical verdict (Healthy, Needs pruning, Needs refactor, Cannot assess). Reports severity against a target ratio but never prescribes one: choosing the target unit:integration:E2E mix and the rebalancing plan belongs to a pyramid-balancing capability such as `test-pyramid-balancer`. Use when a suite has grown for a year or more without review and someone needs a defensible read on whether it is healthy, over-grown, or structurally inverted before deciding what to delete or rewrite.