Pin the Behaviour Before You Change It
Before you change code that has no tests, you write tests that record what it does today, right or wrong. Michael Feathers called these characterization tests, in Working Effectively with Legacy Code. With an agent they are fast to write and easy to get subtly wrong, because the agent's instinct is to test what the code should do, and on legacy code that instinct quietly rewrites the behaviour you meant to preserve.
Cragmoor's pricing module computes every price the gyms charge: single classes, class packs, the member discount, the off-peak rate. It has no tests and changed twice last year. Ines wants it restructured. Nobody can say for certain what it does, and until somebody can, nobody may change it.
Record, Don't Judge
A characterization test calls the code with an input, observes the output, and asserts exactly that output. Feathers' recipe is mechanical: put the code in a test harness, write an assertion you know will fail, let the failure tell you what the behaviour is, change the test to expect what the code actually produces, and repeat. The expected value comes from the run. Nobody's opinion of what the price ought to be enters the test.
The first test is for the ten-class pack bought by a member. The placeholder expected value is zero, which is certainly wrong, and the runner reports the real one. Prices are in whole cents throughout the booking service, so the answer reads 16,160 cents, which is $161.60.
from pricing import pack_price
def test_ten_class_pack_for_a_member():
assert pack_price(10, member=True) == 0 # placeholder: the run reports the real value> assert pack_price(10, member=True) == 0 # placeholder: the run reports the real value
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
E assert 16160 == 0
E + where 16160 = pack_price(10, member=True)
test_pricing.py:5: AssertionError
=========================== short test summary info ============================
FAILED test_pricing.py::test_ten_class_pack_for_a_member - assert 16160 == 0
============================== 1 failed in 0.09s ===============================import { expect, test } from "vitest";
import { packPrice } from "./pricing";
test("ten-class pack for a member", () => {
expect(packPrice(10, true)).toBe(0); // placeholder: the run reports the real value
}); FAIL pricing.test.ts > ten-class pack for a member
AssertionError: expected 16160 to be +0 // Object.is equality
- Expected
+ Received
- 0
+ 16160
❯ pricing.test.ts:5:31
3|
4| test("ten-class pack for a member", () => {
5| expect(packPrice(10, true)).toBe(0); // placeholder: the run re…
| ^
6| });import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.api.Test;
class PricingTest {
@Test
void tenClassPackForAMember() {
assertEquals(0, Pricing.packPrice(10, true)); // placeholder: the run reports the real value
}
}PricingTest > tenClassPackForAMember() FAILED
org.opentest4j.AssertionFailedError: expected: <0> but was: <16160>
at app//org.junit.jupiter.api.Assertions.assertEquals(Assertions.java:673)
at app//PricingTest.tenClassPackForAMember(PricingTest.java:8)
1 test completed, 1 failed
BUILD FAILED in 1s
3 actionable tasks: 3 executedThat value then goes into the test, and the test is green. It records a fact: today, ten classes at $17.95 for a member cost $161.60. Whether $161.60 is right is a separate question, and it gets its own section below. A test that records a bug is doing its job, because its job is to make any change in behaviour visible, including a change that fixes something.
The Input Grid
One test pins one input. The agent is good at the tedious part, enumerating inputs that cover the module's branches, so ask it for the grid as a table before it writes a single test, and read the table. Chapter 5's rule applies: a plan costs a minute to correct, and here the plan is the list of behaviours you will have pinned. An input missing from the grid is a behaviour nobody will notice changing.
For pricing the grid crosses the things the code branches on: member and non-member, a single class and the 5-class and 10-class packs, classes starting at 15:59, 16:00 and 16:01, at a gym in each of Cragmoor's two time zones. Packs are sold without a class time, so they vary only by size and membership. That gives 16 inputs.
| Product | Gym | Starts (gym time) | Non-member | Member |
|---|---|---|---|---|
| Single class | Server's zone | 15:59 | 1495 | 1346 |
| Single class | Server's zone | 16:00 | 1795 | 1616 |
| Single class | Server's zone | 16:01 | 1795 | 1616 |
| Single class | Other zone | 15:59 | 1795 | 1616 |
| Single class | Other zone | 16:00 | 1795 | 1616 |
| Single class | Other zone | 16:01 | 1795 | 1616 |
| 5-class pack | any | none | 8975 | 8080 |
| 10-class pack | any | none | 17950 | 16160 |
The table shows two prices per row, so its eight rows hold the 16 inputs, all in cents, every value taken from a run. The two rows in bold are the ones that look wrong, and the agent was asked to flag them rather than fix them.
The Agent Wants to Fix It
Given "write tests for pricing", an agent writes the expected value it believes is correct. Ten classes at $17.95 with 10% off is $161.55, so it asserts $161.55, runs the test, sees it fail against $161.60, and changes pricing to make it pass. The result looks like a test-writing session and is in fact a behaviour change, the quiet kind from Chapter 7, arriving inside a commit titled "add pricing tests". The instinct is reasonable on new code and destructive on old code.
The instruction that prevents it has to be explicit, because the agent's default is to fix: record current outputs; do not modify pricing; list any value that looks wrong instead of changing it.
1. A member's 10-class pack is 16160, but 10% off 17950 is 16155; the discount is rounded per class.
2. At the gym in the other time zone, a 15:59 class gets the peak price; the hour seems to be read in the server's time zone.
Two Surprises, Pinned
The list contains this chapter's two facts about pricing. First, the member discount is applied to each class and rounded each time. Ninety percent of 1,795 cents is 1,615.5, rounded half-up to 1,616, and ten of those make 16,160 cents, five cents more than 10% off the pack total of 17,950, which would be 16,155. Second, the 16:00 off-peak cut-off is decided on the server's clock. The server runs in the zone of the first gyms, and the other gyms are one hour behind, so a class starting at 15:59 on their wall clock is 16:59 on the server's. Every class there that starts between the two zones' 16:00s, from 15:00 to 15:59 local time, is charged the peak price when it should get the off-peak one.
Both get tests that assert today's values, and the tests are named so that a reader knows they pin a suspected bug rather than a requirement. The 15:59 test checks the member and the non-member price together, so fifteen tests pin the grid's 16 inputs.
from datetime import datetime
from zoneinfo import ZoneInfo
import pytest
from pricing import class_price, pack_price
CHICAGO, DENVER = ZoneInfo("America/Chicago"), ZoneInfo("America/Denver")
def at(zone, hour, minute):
return datetime(2026, 11, 10, hour, minute, tzinfo=zone)
@pytest.mark.parametrize("starts_at, member, expected", [
(at(CHICAGO, 15, 59), False, 1495), (at(CHICAGO, 15, 59), True, 1346),
(at(CHICAGO, 16, 0), False, 1795), (at(CHICAGO, 16, 0), True, 1616),
(at(CHICAGO, 16, 1), False, 1795), (at(CHICAGO, 16, 1), True, 1616),
(at(DENVER, 16, 0), False, 1795), (at(DENVER, 16, 0), True, 1616),
(at(DENVER, 16, 1), False, 1795), (at(DENVER, 16, 1), True, 1616),
])
def test_single_class(starts_at, member, expected):
assert class_price(starts_at, member) == expected
@pytest.mark.parametrize("size, member, expected", [
(5, False, 8975), (5, True, 8080), (10, False, 17950),
])
def test_pack(size, member, expected):
assert pack_price(size, member) == expected
def test_off_peak_follows_server_clock__current_behaviour_under_review():
# 15:59 at the gym is 16:59 on the server's clock, so it prices as peak
assert class_price(at(DENVER, 15, 59), member=False) == 1795
assert class_price(at(DENVER, 15, 59), member=True) == 1616
def test_member_pack_rounds_each_class__current_behaviour_under_review():
# 10 x round(1795 x 0.90) = 16160, where 0.90 x 17950 would be 16155
assert pack_price(10, member=True) == 16160import { expect, test } from "vitest";
import { classPrice, packPrice } from "./pricing";
// 10 November 2026: Chicago is UTC-6, Denver is UTC-7
const chicago = (h: number, m: number) => new Date(Date.UTC(2026, 10, 10, h + 6, m));
const denver = (h: number, m: number) => new Date(Date.UTC(2026, 10, 10, h + 7, m));
test.each([
[chicago(15, 59), false, 1495], [chicago(15, 59), true, 1346],
[chicago(16, 0), false, 1795], [chicago(16, 0), true, 1616],
[chicago(16, 1), false, 1795], [chicago(16, 1), true, 1616],
[denver(16, 0), false, 1795], [denver(16, 0), true, 1616],
[denver(16, 1), false, 1795], [denver(16, 1), true, 1616],
])("single class at %s, member %s", (startsAt, member, expected) => {
expect(classPrice(startsAt, member)).toBe(expected);
});
test.each([[5, false, 8975], [5, true, 8080], [10, false, 17950]])(
"pack of %i, member %s", (size, member, expected) => {
expect(packPrice(size, member)).toBe(expected);
});
test("off-peak follows the server clock (current behaviour, under review)", () => {
// 15:59 at the gym is 16:59 on the server's clock, so it prices as peak
expect(classPrice(denver(15, 59), false)).toBe(1795);
expect(classPrice(denver(15, 59), true)).toBe(1616);
});
test("member pack rounds each class (current behaviour, under review)", () => {
// 10 x round(1795 x 0.90) = 16160, where 0.90 x 17950 would be 16155
expect(packPrice(10, true)).toBe(16160);
});import static org.junit.jupiter.api.Assertions.assertEquals;
import java.time.*;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
class PricingTest {
static Instant at(String zone, int hour, int minute) {
return ZonedDateTime.of(2026, 11, 10, hour, minute, 0, 0, ZoneId.of(zone)).toInstant();
}
@ParameterizedTest
@CsvSource({
"America/Chicago, 15, 59, false, 1495", "America/Chicago, 15, 59, true, 1346",
"America/Chicago, 16, 0, false, 1795", "America/Chicago, 16, 0, true, 1616",
"America/Chicago, 16, 1, false, 1795", "America/Chicago, 16, 1, true, 1616",
"America/Denver, 16, 0, false, 1795", "America/Denver, 16, 0, true, 1616",
"America/Denver, 16, 1, false, 1795", "America/Denver, 16, 1, true, 1616",
})
void singleClass(String zone, int hour, int minute, boolean member, long expected) {
assertEquals(expected, Pricing.classPrice(at(zone, hour, minute), member));
}
@ParameterizedTest
@CsvSource({"5, false, 8975", "5, true, 8080", "10, false, 17950"})
void pack(int size, boolean member, long expected) {
assertEquals(expected, Pricing.packPrice(size, member));
}
@Test
void offPeakFollowsServerClock_currentBehaviourUnderReview() {
// 15:59 at the gym is 16:59 on the server's clock, so it prices as peak
assertEquals(1795, Pricing.classPrice(at("America/Denver", 15, 59), false));
assertEquals(1616, Pricing.classPrice(at("America/Denver", 15, 59), true));
}
@Test
void memberPackRoundsEachClass_currentBehaviourUnderReview() {
// 10 x round(1795 x 0.90) = 16160, where 0.90 x 17950 would be 16155
assertEquals(16160, Pricing.packPrice(10, true));
}
}Whether to fix either one is not an engineering decision. Five cents on a pack and the wrong rate for an hour a day at two gyms are prices members have been paying for three years. Ines takes both to whoever owns the pricing rule. If a fix comes, it is its own commit, with its own changed assertion and a message that says the price changes, reviewed as a behaviour change.
Proving the Net Holds
A characterization suite is only as good as the branches it executes and the results it checks. Two checks tell you whether it does. Run the coverage tool and read which lines of pricing no test reached. With only the pack test, the whole class-price function, with both the off-peak branch and the zone conversion, is unreached; after the grid, nothing is.
Name Stmts Miss Branch BrPart Cover Missing -------------------------------------------------------- pricing.py 20 5 6 1 69% 11-15, 22->24 -------------------------------------------------------- TOTAL 20 5 6 1 69%
Name Stmts Miss Branch BrPart Cover Missing -------------------------------------------------------- pricing.py 20 0 6 0 100% -------------------------------------------------------- TOTAL 20 0 6 0 100%
$ npx vitest run --coverage.enabled --coverage.include=pricing.ts --coverage.reporter=text % Coverage report from v8 ------------|---------|----------|---------|---------|------------------- File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s ------------|---------|----------|---------|---------|------------------- All files | 72.22 | 16.66 | 50 | 70.58 | pricing.ts | 72.22 | 16.66 | 50 | 70.58 | 10-15 ------------|---------|----------|---------|---------|------------------- =============================== Coverage summary =============================== Statements : 72.22% ( 13/18 ) Branches : 16.66% ( 1/6 ) Functions : 50% ( 1/2 ) Lines : 70.58% ( 12/17 ) ================================================================================
$ npx vitest run --coverage.enabled --coverage.include=pricing.ts --coverage.reporter=text % Coverage report from v8 No files with missing coverage. 1 file fully covered. =============================== Coverage summary =============================== Statements : 100% ( 18/18 ) Branches : 100% ( 6/6 ) Functions : 100% ( 2/2 ) Lines : 100% ( 17/17 ) ================================================================================
$ gradle test jacocoTestReport $ cut -d, -f3,6-9 build/reports/jacoco/test/jacocoTestReport.csv CLASS,BRANCH_MISSED,BRANCH_COVERED,LINE_MISSED,LINE_COVERED Pricing,5,3,6,10
$ gradle test jacocoTestReport $ cut -d, -f3,6-9 build/reports/jacoco/test/jacocoTestReport.csv CLASS,BRANCH_MISSED,BRANCH_COVERED,LINE_MISSED,LINE_COVERED Pricing,0,8,0,16
Then break the code on purpose, once, and watch the suite catch it. Make the member discount round down instead of half-up, and eight of the fifteen tests go red. Change the cut-off comparison from "hour before 16" to "hour up to 16", which makes the whole 16:00 hour off-peak, and five go red. A suite that stays green when you break the code was written to pass, not to pin, which is the failure Chapter 7 called bending the test and Chapter 8 taught you to look for in tests the agent wrote. Coverage says a line ran. Only a planted bug shows that its result was checked.
Snapshot Tests and Their Trap
For a large grid it is tempting to record every output into one snapshot file that the test runner compares against. Snapshots pin a great deal cheaply, and for wide outputs, such as a generated invoice, they are the right tool. They come with a regenerate command, and that command is the trap. When a snapshot fails, an agent will run it, because it turns red into green in one step, and the safety net becomes a rubber stamp.
Regenerating a snapshot is a behaviour change and gets reviewed as one: the snapshot's diff is read line by line, and every changed line needs the reason it changed. Put that rule in the project instructions file, so the next session knows it before the first snapshot fails.
A characterization test asserts what the code does now, including its bugs. Write one before you change code whose behaviour you do not trust your understanding of.
A specification test asserts what the code should do, taken from the business rule. Write one when you change behaviour on purpose, as in Chapter 8's test-first loop. Mixing the two in one step is how a refactoring becomes an unreviewed behaviour change.
- "A test that asserts a wrong value is a wrong test." On legacy code the test's job is to detect change. The 16,160-cent assertion is correct as a record, and it will be updated deliberately in the commit that fixes the rounding, if that commit ever comes.
- "The agent can write characterization tests from reading the code." Reading produces what the code appears to do; running produces what it does. Expected values come from a run, never from the agent's reading of the function.
- "High coverage means the behaviour is pinned." Coverage says a line ran, not that its result was asserted. A test that calls
pricingand asserts nothing reaches every line and pins nothing. - "Fixing the obvious bug while writing the tests saves a step." It removes the only moment when old and new behaviour could be compared, and it hides a business decision inside a commit that claims to add tests.
- Take every expected value from a real run, never from the agent's reading of the code. Reading predicts; running observes.
- Tell the agent in so many words not to modify the code under test, and to list suspicious values instead. Its default on a failing test is to fix.
- Break the code on purpose once and watch a test fail. A net that cannot catch a planted bug cannot catch a real one.
- Name a test that pins a suspected bug so that it says so. The next reader, human or agent, must not mistake a record for a requirement.
Knowledge Check
A characterization test for a member's 10-class pack asserts 16,160 cents, although 10% off 17,950 is 16,155. Is the test correct?
- Yes, as a record of today's behaviour, and the name should say that it pins a suspected bug
- No, because a test must always assert the business rule, so it should expect 16,155
- No, because the rounding is clearly a bug, so the test should be skipped until it is fixed
- Yes, but only if the module also gets a comment explaining that its rounding was intended
You ask the agent to "write tests for pricing" on the untested module, with no other instruction. What is the likely result?
- Tests that assert today's outputs exactly, with a list of any of the values that look suspicious
- Tests that assert the values it believes are correct, and changes to pricing to make them pass
- A refusal to write any tests until someone documents the intended behaviour of the pricing module
- Tests that call each function once and assert nothing, since the expected values are unknown
Why must the expected values in characterization tests come from running the code?
- A run is faster than reading the code, so it saves the agent time on large modules
- The test runner refuses to accept expected values that were not produced by a run
- Reading predicts what the code does; on unusual lines like per-class rounding, the prediction is wrong
- Running the code also produces coverage data, which the tests need in order to pass
The grid suite reaches 100% line coverage of pricing. What does that prove?
- That every behaviour of pricing is now pinned, so any change will turn a test red
- That the module has no remaining bugs, because every line has run at least once
- Nothing useful, so coverage reports should be ignored when writing characterization tests
- That every line ran, which is necessary; a planted bug that turns a test red shows results are checked
A snapshot of the full price grid fails after an agent's refactoring, and the agent proposes running the regenerate command. When is that acceptable?
- Whenever the refactoring was meant to preserve behaviour, since the snapshot must then be stale
- Only after reading the snapshot's diff and accepting each changed value as an intended change
- Whenever the other tests in the suite are still green, which shows that the change was harmless
- Never, since snapshot files must not be regenerated once a test suite is committed
You got correct