Topic 02

Test-First With an Agent

Testing

Test-first with an agent divides the work along the line of authority. You write or approve the failing test, and the agent writes the code that makes it pass. Done carefully, it is the tightest form of the supervised loop from Chapter 2. Done carelessly, it hands the agent the question and the answer key together.

The refund change is the case. Run test-first, it would have caught the boundary flip before anyone read a line of the diff, because the test that would have failed is three lines long and was written from the rule, not from the code.

The Loop

Write the test from the rule, using the examples Chapter 5 turned into acceptance criteria. Run it and watch it fail. Commit it, so it becomes a fixed point the agent's work is measured against. Ask the agent to make it pass without changing it. Read the diff. Run the whole suite, not only the new tests, before you accept anything.

The request itself is short, and every clause in it does work. It names the tests, forbids editing them, and tells the agent what to do when it disagrees, so a disagreement arrives as a question rather than as a changed assertion. The reply is a claim like any other, and the runner's own output, not the sentence "all tests pass", is what you check.

The request that hands over a committed test
You
"Make the three refund-window tests I just committed pass. Do not edit any test. Change only the refund code. If a test looks wrong to you, stop and tell me which one and why."
The agent
"Changed the window from 24 to 12 hours in the refunds module and nothing else. The three new tests pass. Two older tests now fail: the 23-hour case still expects no refund, and the second-zone line still expects 09:00. Both state the 24-hour rule. I have not edited them. Should they follow the new rule?"

That reply is the loop working. Both tests encoded the old rule, and the agent stopped instead of changing them. The answer is yes, a refund of $24.00 and a deadline of 21:00, and you make those changes yourself, in their own commit, with a message that says the rule changed. Chapter 7 showed what happens when that decision is left to the agent: the second-zone line moved to 20:00.

Test-first with an agent: you own the test, it owns the code
Write the test, watch it failyou
→
Commit ityou
→
Make it passthe agent
→
Read the diff, run the suiteyou

The Refund Tests

The rule says a cancellation up to 12 hours before the class is refunded in full, and Ines ruled that "up to" includes the twelfth hour. Three cases come straight from that wording: 12 hours and 1 minute ahead is refunded, exactly 12 hours ahead is refunded, and 11 hours and 59 minutes ahead is not. Two older tests already state the old rule: the 23-hour case expects no refund, and the second-zone line expects 09:00. They stay as they are for now; what happens to them is the point of the request further down.

tests/test_refund_window.py (yours, committed before you ask)
"""The 12-hour rule, straight from its wording. Written before the code."""

from datetime import datetime, timedelta

from cragmoor import refunds, schedule
from cragmoor.booking import Booking
from cragmoor.gyms import Gym

GYM = Gym("north", "America/Chicago")
CLASS = Booking(gym=GYM, starts_at=datetime(2026, 11, 14, 9, 0), paid_cents=2400)


def cancelled(hours: int, minutes: int = 0) -> datetime:
    """A fixed moment before the class: the test sets the clock, not the machine."""
    start = schedule.localize(CLASS.starts_at, GYM.time_zone)
    return start - timedelta(hours=hours, minutes=minutes)


def test_12_hours_1_minute_ahead_is_refunded():
    assert refunds.refund_cents(CLASS, cancelled(12, 1)) == 2400


def test_exactly_12_hours_ahead_is_refunded():
    assert refunds.refund_cents(CLASS, cancelled(12)) == 2400


def test_11_hours_59_minutes_ahead_is_not_refunded():
    assert refunds.refund_cents(CLASS, cancelled(11, 59)) == 0
tests/refund-window.test.ts (yours, committed before you ask)
// The 12-hour rule, straight from its wording. Written before the code.
import { expect, test } from "vitest";
import type { Booking } from "../src/booking.ts";
import * as refunds from "../src/refunds.ts";
import * as schedule from "../src/schedule.ts";

const CLASS: Booking = {
  gym: { code: "north", timeZone: "America/Chicago" },
  startsAt: "2026-11-14T09:00", paidCents: 2400, classes: 1,
};

/** A fixed moment before the class: the test sets the clock, not the machine. */
function cancelled(hours: number, minutes = 0): Date {
  const start = schedule.localize(CLASS.startsAt, CLASS.gym.timeZone);
  return new Date(start.getTime() - (hours * 60 + minutes) * 60_000);
}

test("12 hours 1 minute ahead is refunded", () => {
  expect(refunds.refundCents(CLASS, cancelled(12, 1))).toBe(2400);
});

test("exactly 12 hours ahead is refunded", () => {
  expect(refunds.refundCents(CLASS, cancelled(12))).toBe(2400);
});

test("11 hours 59 minutes ahead is not refunded", () => {
  expect(refunds.refundCents(CLASS, cancelled(11, 59))).toBe(0);
});
RefundWindowTest.java (yours, committed before you ask)
import static org.junit.jupiter.api.Assertions.assertEquals;

import java.time.LocalDateTime;
import java.time.ZonedDateTime;
import org.junit.jupiter.api.Test;

/** The 12-hour rule, straight from its wording. Written before the code. */
class RefundWindowTest {
  static final Gym GYM = new Gym("north", "America/Chicago");
  static final Booking CLASS = new Booking(GYM, LocalDateTime.of(2026, 11, 14, 9, 0), 2400, 1);

  /** A fixed moment before the class: the test sets the clock, not the machine. */
  static ZonedDateTime cancelled(int hours, int minutes) {
    return Schedule.localize(CLASS.startsAt(), GYM.timeZone()).minusHours(hours).minusMinutes(minutes);
  }

  @Test
  void twelveHoursOneMinuteAheadIsRefunded() {
    assertEquals(2400, Refunds.refundCents(CLASS, cancelled(12, 1)));
  }

  @Test
  void exactlyTwelveHoursAheadIsRefunded() {
    assertEquals(2400, Refunds.refundCents(CLASS, cancelled(12, 0)));
  }

  @Test
  void elevenHoursFiftyNineMinutesAheadIsNotRefunded() {
    assertEquals(0, Refunds.refundCents(CLASS, cancelled(11, 59)));
  }
}

Run against the code as it stood before the change, with the 24-hour window, two of the three fail, and they fail on their assertions: the code refunded nothing where the new rule refunds $24.00. That is red for the rule's reason. Run against the refund diff from Chapter 6, the exactly-12-hours test fails, which is the boundary flip caught in seconds. Run against the narrowed change, all three pass.

pytest -q --tb=line, against the 24-hour code: red for the rule's reason
tests/test_refund_window.py:24: AssertionError: assert 0 == 2400
FAILED tests/test_refund_window.py::test_12_hours_1_minute_ahead_is_refunded
FAILED tests/test_refund_window.py::test_exactly_12_hours_ahead_is_refunded
2 failed, 1 passed in 0.02s
against the agent's refund diff from Chapter 6
FAILED tests/test_refund_window.py::test_exactly_12_hours_ahead_is_refunded
1 failed, 2 passed in 0.02s
against the narrowed change
3 passed in 0.02s
vitest run, against the 24-hour code: red for the rule's reason
   × 12 hours 1 minute ahead is refunded 17ms
   × exactly 12 hours ahead is refunded 1ms
AssertionError: expected +0 to be 2400 // Object.is equality
      Tests  2 failed | 1 passed (3)
against the agent's refund diff from Chapter 6
   × exactly 12 hours ahead is refunded 3ms
AssertionError: expected +0 to be 2400 // Object.is equality
      Tests  1 failed | 2 passed (3)
against the narrowed change
      Tests  3 passed (3)
JUnit, against the 24-hour code: red for the rule's reason
│  └─ RefundWindowTest ✔
│     ├─ elevenHoursFiftyNineMinutesAheadIsNotRefunded() ✔
│     ├─ twelveHoursOneMinuteAheadIsRefunded() ✘ expected: <2400> but was: <0>
│     └─ exactlyTwelveHoursAheadIsRefunded() ✘ expected: <2400> but was: <0>
against the agent's refund diff from Chapter 6
│     ├─ elevenHoursFiftyNineMinutesAheadIsNotRefunded() ✔
│     ├─ twelveHoursOneMinuteAheadIsRefunded() ✔
│     └─ exactlyTwelveHoursAheadIsRefunded() ✘ expected: <2400> but was: <0>
against the narrowed change
[         3 tests successful      ]
[         0 tests failed          ]

Failing for the Right Reason

A new test must fail on its assertion, not on an import error, a misspelt name or a missing fixture. A test that fails for the wrong reason can pass for the wrong reason too: fix the import, and the test may pass at once because the assertion never checked what you meant. Read the failure message before handing the test over, and confirm it says what the rule says, in this case "expected 2400, got 0".

Fixed Clocks and Fixed Inputs

Rules about time need a clock the test controls. An agent asked to test "12 hours before" against the real clock writes a test whose result depends on when it runs, which passes on most days and fails on the night before a class. The tests above never ask the machine for the time; they pass the moment of cancellation in as an argument.

That has a consequence for the code. If the test supplies the time, the code the agent writes must accept it rather than reading the clock itself. Say so in the task. Code that reads the clock deep inside cannot be tested at a boundary, and an agent will happily write it that way unless the test makes the other shape the only one that passes.

The refund code already has the right shape: the function that decides a refund takes the moment of cancellation as an argument. The places that call it, the cancellation screen and the cancellation request, read the clock once and pass the time in. That one read is the only line that touches the real clock, and it is too simple to be wrong in an interesting way.

Where It Goes Wrong

The agent edits the test instead of the code, the bend Chapter 7 described; the commit you made before asking makes that visible. The agent special-cases the three inputs because the tests were narrow, a branch on exactly 12 hours that makes all three pass; add a value it has not seen, or check the rule across a range, as the topic on edges does. A new test passes at once because the code already did that, so it was never red and proves nothing. Or the agent writes code and tests together, and the tests mirror the code, which the next topic takes apart.

When the Agent Drafts the Test

Letting the agent draft the test is acceptable, on one condition: you review it as a specification before any code exists. Does it state the rule, in literal values a member would recognise? Would it fail if the rule were broken? The mutation check in the next topic answers the second question in a minute. Commit the reviewed test, then ask for the code in a separate request, so the test is fixed while the code is written.

A drafted test fails that review in predictable ways. It computes the expected deadline by calling the deadline function. It checks that a refund is "greater than zero" instead of $24.00. It tests 24 hours and 6 hours, far from the boundary, because those are the cases the agent's code obviously handles. Each of those is a sign the test was written from the code the agent was about to write, not from the rule.

Test-First vs Tests-After, With an Agent

Tests-after: the agent writes the code, then tests for it. The tests are derived from the code and agree with it, mistakes included. Use it only to pin behaviour that already exists, the method of Chapter 11.

Test-first: the test is derived from the rule and committed before the code. The code has to meet it. Use it for every change to a rule.

Misconceptions
  • "Asking for code and tests together is test-driven." Both come from one generation, so the tests agree with the code by construction, boundary flip included.
  • "A new test that passes at once is a good sign." A test that has never failed has never been shown able to fail, and it may be testing nothing.
  • "Three examples pin the rule." Three exact inputs invite three special cases. Add a value between them, or check a range.
  • "The agent may fix a test it believes is wrong." Sometimes your test is wrong. Then the agent says so and stops, and you change the test yourself, in its own commit.
  • "Test-first is too slow for a small change." The refund tests are about 20 lines, and they catch the boundary flip that cost a review round in Chapter 6.
Why It Matters
  • Commit the failing test before the agent starts. Any edit to it then shows as a diff against your commit.
  • Run every new test against the old code and read the failure. It must fail on the assertion, for the rule's reason.
  • Inject the clock, and any randomness, in the tests you write. A rule about time cannot be tested against the real clock.
  • Tell the agent explicitly: make these tests pass, do not edit them, and stop and report if one looks wrong.

Knowledge Check

You ask an agent for the 12-hour change and its tests in one request. Why are those tests weak evidence that the change is right?

  • Agents cannot write tests that actually run under the test runner
  • They come from one generation and agree with the code by construction
  • One request produces too few tests to cover the 12-hour rule properly
  • The test runner skips tests that arrive in the same commit as the code

Your new boundary test fails, and the message reports that a name could not be imported. What should you do before giving it to the agent?

  • Hand it over as it is, since the agent will fix whatever makes it fail
  • Delete it and rely on the existing suite to cover the boundary instead
  • Fix the import, rerun it, and check that it now fails on its assertion
  • Mark it as skipped until the agent has finished writing the new code

Your test-first tests check 12 hours 1 minute, exactly 12 hours and 11 hours 59 minutes before a class. What can the agent do that passes all three without implementing the rule?

  • Change the machine's clock so the tests run at a convenient time
  • Mark the three tests as skipped, so that the run reports nothing
  • Refund every cancellation, since two of the three expect a refund
  • Special-case the three exact inputs so each takes its own branch

Partway through, the agent reports that your exactly-12-hours test looks wrong to it. What should happen next?

  • The agent corrects the test itself and carries on with the code
  • It stops; you decide, and change the test yourself if it is wrong
  • The agent skips that test and finishes the other two of the three
  • The agent writes a longer explanation of why the test is wrong

You got correct