Unit Testing Patterns skill for Codex

Tests that survive refactors and fail for one reason. Codex writes tests around the behaviors a unit promises rather than the methods it happens to have. It picks inputs at boundaries, mocks only at real boundaries, injects clocks and seeds, proves each test can fail, and flags design smells such as too many collaborators instead of burying them under mocks.

  • Testing & QA
  • $unit-testing-patterns
  • 215 lines
  • 2 files
  • ~2,150 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- unit-testing-patterns

Installs to ~/.agents/skills/unit-testing-patterns/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $unit-testing-patterns, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Designs unit tests that pin down behavior instead of implementation - arrange-act-assert structure, behavior-first naming, boundary and equivalence cases, test doubles chosen by role, and deterministic time, randomness and IO. Language-neutral with TypeScript, Python and Go examples. Use when writing tests for new or untested code, reviewing a test file, deciding what to mock, or when tests break on every refactor.

Use it when

  • Adding tests to code that has none
  • Reviewing a test file for brittleness or weak assertions
  • Deciding between a stub, a fake and a mock
  • Tests depend on the clock, random values or run order

Use something else when

How to check the result

Each new test fails when the production code is deliberately broken and passes again when reverted, and the file passes repeatedly under random ordering.

The mistake it prevents

Asserting that internal collaborators were called with exact arguments, which couples the test to the implementation and breaks on every refactor.

~/.agents/skills/unit-testing-patterns/SKILL.md

--- name: unit-testing-patterns description: Designs unit tests that pin down behavior instead of implementation - arrange-act-assert structure, behavior-first naming, boundary and equivalence… license: MIT ---

A good unit test fails for exactly one reason, names that reason in its title, and keeps passing through any refactor that preserves behavior. If a test breaks when you rename a private helper, it is testing the wrong thing.

Before writing anything

  1. Read AGENTS.md for the test command, framework and file layout. Match the existing suite’s style (file naming, assertion library, fixtures) before introducing your own.
  2. Open two or three existing test files near the code you are testing. Copy their helpers and setup patterns instead of inventing parallel ones.
  3. Run the existing tests once and confirm they pass, so any later failure is yours.
  4. Identify the unit’s public surface: exported functions, class methods callers use, HTTP handler, CLI entry. That surface is what you test.

Workflow

- [ ] 1. List behaviors (not methods) the unit promises
- [ ] 2. For each behavior, pick inputs: typical, boundary, invalid
- [ ] 3. Decide which collaborators need doubles, and which kind
- [ ] 4. Write each test as Arrange / Act / Assert
- [ ] 5. Make the test fail on purpose once to prove it can
- [ ] 6. Remove duplication that hides intent, keep duplication that shows it
- [ ] 7. Run the file, then the whole suite

1. List behaviors

Write one line per rule the code must uphold, in domain language:

  • “orders over 100 EUR ship free”
  • “an expired token is rejected with 401, not 500”
  • “parsing an empty file returns an empty list, not an error”

Each line becomes one or more tests. If you cannot phrase a behavior, read the callers before writing the test; the code may be doing several jobs.

2. Choose inputs deliberately

TechniqueApply it toExample for discount(total) with tiers at 100 and 500
Equivalence classesone representative per class50, 250, 900
Boundary valueseach edge and its neighbours99.99, 100, 100.01, 499.99, 500
Invalid inputeach way the input can be wrong-1, NaN, null, “100”
Empty / one / manycollections[], [x], [x, y, z]
Special valuesstrings, numbers, dates"", unicode, 0, max int, DST change, leap day

Off-by-one bugs live at boundaries. A tier test with only 50 and 900 proves almost nothing.

3. Choose test doubles by role

You need to…UseAssert on
Satisfy a parameter that is never useddummynothing
Feed canned answers into the unitstubthe unit’s output
Replace a slow or stateful dependency with a working light versionfake (in-memory repo, fake clock)the unit’s output and the fake’s state
Check that an outbound side effect happened (email sent, event published)spy or mockthe call, once, with key arguments

Rules:

  • Prefer real objects for values and pure logic. Mock only at boundaries: network, disk, clock, randomness, queues, third-party SDKs.
  • Do not mock types you do not own. Wrap the SDK in a thin adapter you own and double the adapter; cover the adapter with an integration test.
  • Assert on calls only for commands (side effects). Never assert that a query method was called; assert on the result it produced.
  • More than three doubles in one test means the unit has too many collaborators. Report that as a design smell rather than piling on mocks.

4. Arrange / Act / Assert

Keep the three phases visible. One Act per test.

it("ships free when the subtotal reaches exactly 100 EUR", () => {
  // Arrange
  const cart = cartWith({ price: 60 }, { price: 40 });

  // Act
  const quote = quoteShipping(cart, "DE");

  // Assert
  expect(quote.cost).toBe(0);
  expect(quote.reason).toBe("free-threshold");
});
def test_rejects_expired_token_with_401(fake_clock, token_factory):
    token = token_factory(expires_at=fake_clock.now() - timedelta(seconds=1))

    response = authenticate(token, clock=fake_clock)

    assert response.status == 401
    assert response.error == "token_expired"

Multiple asserts are fine when they describe one outcome (status and body of one response). Two Acts in one test means two tests.

5. Prove the test can fail

Break the production code briefly (flip a comparison, return early) or change the expected value, run the test, and watch it fail with a readable message. A test that cannot fail is worse than no test because it reports false safety. Revert the break.

6. DAMP over DRY

  • Keep the values that matter to the behavior inside the test body. A reader should not jump to a fixture to learn why 100 is special.
  • Hide only incidental setup behind builders or factories (cartWith(...), token_factory(...)). For larger factory design, hand off to $test-data-factories.
  • Avoid loops and conditionals inside a test. Use the framework’s parametrization so each case reports separately.

Naming

A test name is a sentence a product owner could read in a failure report.

WeakStrong
test_discount_2test_discount_applies_10_percent_from_100_eur
it("works")it("returns an empty list for an empty file")
TestParseTestParse/rejects_trailing_comma
should call repo.savepersists the order with status PENDING

Pattern: <unit> <expected outcome> when <condition>; drop the unit name if the describe block already states it.

Determinism

A unit test must produce the same result on every machine, in any order, in parallel.

Source of nondeterminismControl it by
Current timeinject a clock, or framework fake timers; never call Date.now()/time.time() deep in logic
Random values, UUIDsinject a seeded generator or an id factory
Timezone, localeset TZ=UTC in the test command and pass locale explicitly
Filesystemuse the framework temp dir (tmp_path, t.TempDir(), os.tmpdir() + cleanup)
Networknever in unit tests; stub at the adapter
Shared mutable statebuild fresh state per test; reset singletons in teardown
Test orderrun with random ordering where supported (pytest-randomly, go test -shuffle=on, RSpec --order random)
type Clock interface{ Now() time.Time }

func TestInvoiceIsOverdueAfter30Days(t *testing.T) {
	issued := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
	clock := fixedClock(issued.AddDate(0, 0, 31))

	inv := Invoice{IssuedAt: issued}

	if !inv.Overdue(clock) {
		t.Fatalf("invoice issued %v should be overdue at %v", issued, clock.Now())
	}
}

What to test, what to skip

Test:

  • Branching business rules, calculations, parsing, validation, state transitions.
  • Error paths: each distinct error the caller can observe.
  • Bugs you fixed: one regression test per bug, named after the behavior, not the ticket.

Skip or test elsewhere:

  • Trivial getters, framework wiring, generated code: covered by integration tests.
  • Private helpers directly: test them through the public function that uses them. If a helper is complex enough to need its own tests, it wants to be its own unit.
  • Exact log strings and exact SQL text, unless the format is the contract.

Test smells to fix during review

  • Mirror test: re-implements the production algorithm to compute the expected value. Use a literal expected value worked out by hand.
  • Over-specified mock: verifies every call and argument; breaks on harmless refactors.
  • Sleep: sleep(100) to wait for async work. Await the promise or use fake timers.
  • Mystery guest: depends on a file, env var or database row the test does not create.
  • Assertion-free test: runs code and checks nothing, or only not.toThrow() where a real outcome exists.
  • Catch-all try/except inside the test that turns failures into passes.
  • Snapshot of a large object standing in for three real assertions.

Verification

  1. Run the new tests alone, then the full unit suite, using the command from AGENTS.md or the package manifest.
  2. Run the new file three times, and with random order if the runner supports it, to catch hidden order dependence.
  3. Confirm each new test failed at least once during step 5.
  4. If a coverage tool is configured, check that the branches you targeted are now covered; do not chase the percentage for its own sake.

Output

When reporting back, list:

**Tests added:** tests/pricing/test_discount.py (7 cases)
**Behaviors covered:** tier boundaries at 100 and 500, negative totals rejected,
currency rounding to cents
**Doubles used:** FakeClock (time), no mocks
**Not covered:** tax rules in tax.py (needs a design change; collaborators are global)
**Command:** uv run pytest tests/pricing -q  -> 7 passed

What the zip contains

SKILL.md plus the interface file below. Nothing runs on install.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "Unit Testing Patterns"
  short_description: "Tests that survive refactors and fail for one reason"
  default_prompt: "$unit-testing-patterns Write unit tests for the discount calculator in pricing.ts"

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc