Vibe Code Textbook

Tutorials · · 988 words · 4 min read

Claude Code on a legacy script: how do you add tests safely?

A step-by-step method for getting characterization tests onto an untested script with a coding agent: survey, seams, pin the actual behaviour, then talk about bugs.

tutorial characterization tests legacy code unittest plan mode

You get a legacy script, a test suite that pins exactly what it does today, and the five prompts I would use to get an agent from the first to the second without changing a byte of behaviour on the way. No agent ran in this session; what ran here was Python 3.13.12 on Windows, and every output block below is real. The prompts are prescriptive and labelled.

The subject is logreport, a 90-line log summariser I wrote to have the problems legacy scripts have: module-level state, printing from inside the parser, a date format done by string slicing, and one latent bug. It ships as add-tests-to-a-legacy-script-with-an-agent.py with its suite beside it.

What characterization tests are for

Michael Feathers' page puts it in one sentence: "The purpose of characterization testing is to document your system's actual behavior, not check for the behavior you wish your system had." His procedure is the one this article follows: write a test with a placeholder name, put a dummy value in the assertion, run it, copy the real output in as the expectation, rename the test after what you learned, then keep asking questions. He also names the hard part: "breaking dependencies around a piece of code well enough to be able to exercise it in a test harness."

That last sentence is why the agent's first job is not to write tests. It is to find the seams.

Step 1: a read-only survey

Start in plan mode. The permission-modes page describes plan as a mode where "Claude reads files and runs read-only shell commands to explore but doesn't edit your source files". That is the right shape for a survey: nothing can change while you are still learning what the thing does.

The prompt I would send:

Read logreport.py. Do not change anything. Tell me: (1) every piece of
module-level state and who writes it, (2) every print call and which
stream it uses, (3) the exact input format it expects, (4) anything that
looks like a bug or a quirk. Quote line numbers.

On this script a correct survey names five globals (COUNTS, FIRST, LAST, BAD, TOTAL), two print sites (a warning inside parse_line, the report in report), the YYYY-MM-DD HH:MM:SS LEVEL message line shape, and two quirks: dates are re-formatted to DD/MM/YY, and the first/last timestamps are picked by comparing those DD/MM/YY strings. The second quirk is the bug. I would not let the agent fix it yet.

Step 2: propose seams, change no behaviour

The seam is the smallest change that lets a test drive the code and read its output without touching the filesystem or sys.stdout. The prompt:

Propose the smallest change that lets a test pass in lines and capture
the output, without changing any output byte for the existing CLI. Show
the diff only. Do not apply it.

The diff I would accept is the one I applied by hand to the shipped file. The functions gained an out parameter defaulting to sys.stdout, the globals gained a reset(), and one new function composes them:

-def parse_line(line):
+def parse_line(line, out=sys.stdout):
     global FIRST, LAST, BAD, TOTAL
     ...
-        print("WARN skipping line %d: %r" % (TOTAL, line.rstrip("\n")))
+        print("WARN skipping line %d: %r" % (TOTAL, line.rstrip("\n")), file=out)

+def reset():
+    global COUNTS, FIRST, LAST, BAD, TOTAL
+    COUNTS = {}
+    FIRST = None
+    LAST = None
+    BAD = 0
+    TOTAL = 0
+
+def run(lines, out=sys.stdout):
+    """Seam: process `lines` and write the report to `out`. Resets the globals first."""
+    reset()
+    for line in lines:
+        parse_line(line, out)
+    report(out)

 def main(argv):
     ...
     with open(argv[1], encoding="utf-8") as fh:
-        for line in fh:
-            parse_line(line)
-    report()
+        run(fh)

Defaults keep the CLI path identical. The reset() is not cosmetic: without it a second test in the same process inherits the first test's counts, and the suite passes or fails depending on test order. There is a test for exactly that.

Step 3: pin the behaviour, quirks included

Now the tests, written the Feathers way. The prompt:

Write logreport_test.py using unittest. Drive logreport.run(lines, out)
with an io.StringIO. For each test, run it first with a wrong expected
value, then paste the ACTUAL output into the assertion and name the test
after what the output showed. Cover: a five-line sample with one garbage
line, empty input, level ordering, case sensitivity of levels, three
malformed timestamps (use subTest), the DD/MM/YY reformatting, and a
two-line log that crosses a month boundary. Do not fix anything you find.

The subTest instruction is there because the unittest docs describe subTest as a context manager that "executes the enclosed code block as a subtest" with parameters "displayed whenever a subtest fails, allowing you to identify them clearly", which is exactly what three malformed-line cases need.

The test that matters most is the one that pins the bug:

def test_last_seen_is_wrong_across_a_month_boundary_documented_bug(self):
    # 1 September sorts before 31 August once both are DD/MM/YY strings.
    out = run(["2026-08-31 23:59:59 INFO x\n", "2026-09-01 00:00:01 INFO y\n"])
    self.assertIn("first: 01/09/26 00:00:01\n", out)   # wrong, pinned on purpose
    self.assertIn("last:  31/08/26 23:59:59\n", out)   # wrong, pinned on purpose

This looks perverse and is the whole method. The test says: today the script reports the wrong "last". When somebody fixes it, this test fails, they read the name, and they flip the expectation in the same commit as the fix. Nothing changes by accident.

Step 4: run them

What ran here, against the shipped script:

$ python -m unittest add-tests-to-a-legacy-script-with-an-agent_test.py
........
----------------------------------------------------------------------
Ran 8 tests in 0.000s

OK

And the CLI path, untouched by the seam, on the same five-line sample the suite uses:

$ python add-tests-to-a-legacy-script-with-an-agent.py app.log
WARN skipping line 3: 'garbage line without a timestamp'
lines: 5 (skipped 1)
  INFO     2
  ERROR    1
  WARN     1
first: 05/09/26 14:03:11
last:  05/09/26 14:05:30
exit=0

The eight tests: the exact seven-line report for that sample, empty input, sort order (count descending then name), case-sensitive levels, three malformed stamps via subTest, state reset between runs, the DD/MM/YY table, and the month-boundary bug. Eight is not coverage; it is a description of the script that a machine will keep honest.

Step 5: only now, the bugs

With the suite green, the conversation about what the script should do can start. The prompt:

The suite is green. List the behaviours the tests pinned that you think
are bugs, one per line, with the test name that would need to change.
Do not change code or tests.

The best-practices page's advice for this stage is to "have Claude show evidence rather than asserting success: the test output, the command it ran and what it returned". A bug list with test names attached is that evidence in a form you can act on one item at a time. The fix itself belongs to the next article, Refactor with an agent without losing behaviour, which keeps this suite as the net and restructures the script underneath it.

Why the order matters

Every step above can be skipped and usually is. Skip the survey and the agent writes tests for the behaviour it assumes. Skip the seam and the tests shell out to the script and diff stdout, which works until the first test needs to inject a date. Skip pinning the bug and the fix arrives in the same diff as the tests, and now you cannot tell which line changed behaviour. The sequence is slow on purpose: on a script this size it is an hour, and the hour buys a suite that fails for a reason you can name.

What this does not cover

The script reads one file and prints; there is no database, clock, or network to break a dependency around, which is where Feathers says the difficulty actually lives. The tests also run against the shipped seam version, not the original, so the claim "the seam changed no output byte" rests on the CLI run above and on the tests in the refactor article that run the same assertions against both. And I chose the sample lines. A real legacy script gets its characterization sample from production logs, and the first surprise is usually in line one of those.

Code and data

Sources

  1. Michael Feathers, "Characterization Testing" (read 2026-09-05)
  2. Python Software Foundation, "unittest — Unit testing framework" (Python 3 docs, read 2026-09-05)
  3. Anthropic, "Best practices for Claude Code" (Claude Code docs, read 2026-09-05)
  4. Anthropic, "Choose a permission mode" (Claude Code docs, read 2026-09-05)