AI coding

Characterization Tests for Untested Code

Antonio Evans9 min read

A developer at a desk with a laptop, looking at a function on screen.
Key points

Key points

  1. 1A characterization test records what a function does today, bugs included, instead of guessing what it should do, as defined in Michael Feathers' work on legacy code.
  2. 2The mechanics are simple: call the unit with a chosen input, let the test fail, copy the real output into the assertion, then rename the test to describe the behavior.
  3. 3Edge cases (nulls, empty collections, boundary values, error paths) are where the value is, because those paths are the ones agents most often rewrite incorrectly.
  4. 4Run the recorded suite before and after every agent change, so any difference is a visible decision rather than a silent regression.

What a characterization test actually is

Michael Feathers defines legacy code as code without tests, and that definition is the one that matters here: a module checked in last week with no coverage is legacy in exactly the same sense as a module from years ago (DaedTech's write-up). Feathers' book lays out a change algorithm that puts tests before modification: identify the change point, find where behavior can be sensed, break dependencies to get the code into a harness, cover it with characterization tests, then add new behavior, then refactor (BSSw summary). The ordering is not stylistic. It exists because the code's current behavior is the only specification you have.

A characterization test is therefore a test that "characterizes the actual behavior of a piece of code" rather than asserting intended behavior (understandlegacycode.com). The practical consequence: if a function called addTwoNumbers(1, 1) returns 12, the test asserts 12. You do not correct it. Once code is in production, users have built processes around its exact outputs, however odd, so "different is dangerous" and the job is to detect difference, not to judge it (DaedTech).

This is why the technique suits agent-assisted change so well. An agent writing a test from a description will invent plausible expectations. An agent writing a test from observed output can only record what happened. Feathers describes characterization testing as a genuinely powerful use of AI test generation, precisely because the recording step removes the model's need to guess (Tech Lead Journal interview).

Step 1: pick one unit and one seam

Choosing the unit is a scoping decision, not a technical one. Pick the function or module your change touches, and pick the narrowest boundary that still produces an observable result. The guidance from a worked modernization tutorial is to choose one capability before writing any tests, then find the smallest test boundary that exercises it (freeCodeCamp tutorial).

The obstacle is almost always a dependency: a database connection, an HTTP client, a clock, a global. Feathers calls the escape hatch a seam, "a place to alter program behavior, without changing the code" (understandlegacycode.com). In object-oriented code the usual seam is a class you can subclass in the test to neutralize the problematic call. For example:

class FakePaymentGateway(PaymentGateway):
    def charge(self, amount, currency):
        return {"status": "ok", "id": "test-1"}

That fake is not a characterization of anything. It is the scaffolding that lets your characterization test run offline. Keep scaffolding and recorded behavior in clearly separate files so nobody later mistakes the fake's numbers for real ones.

Two practical tests for whether you picked a good unit:

SignalGood unitBad unit
RuntimeOne test under 100 msSeconds per test, hits a network
InputsPrimitives, small DTOsLarge object graphs you cannot construct
OutputReturn value, or one observable side effectNine printed log lines nobody reads
Change scopeThe exact function being modifiedHalf the application

Feathers' working definition of a unit test is blunt: it runs fast (under 100 ms) and it does not talk to infrastructure such as a database, the network, the file system or environment variables (understandlegacycode.com). If your characterization test cannot meet that bar, you are writing an integration characterization test, which is fine, but mark it and expect it to be slower.

Step 2: the record, fail, assert loop

The mechanical procedure is identical across languages and frameworks. Daniele Scillia's version is six steps: determine the piece of code, write a test with an assertion you know is wrong, run it and read the error to learn the real behavior, change the assertion to match, repeat until coverage is reasonable, then give each test a name that expresses the behavior it verifies (Medium, Dan the Dev).

The Honeybadger walkthrough shows this concretely on a Ruby mask method with no tests. The author first writes expect(mask('simple text')).to eq(nil), runs it, and reads the failure: expected: nil, got: "*******text". That string becomes the assertion. The same loop on '[email protected]' produces "***************.com", and on the digit string '635914526' produces "*****4526" (Honeybadger). Three inputs, three recorded facts, zero guessing.

In Python with pytest the workflow looks like this:

def test_masks_number_as_string():
    # Step A: deliberately wrong assertion
    assert mask("635914526") == "PLACEHOLDER"

Run it, read the assertion diff, and replace the placeholder with the observed value:

def test_masks_number_as_string():
    assert mask("635914526") == "*****4526"

Two rules keep this honest. First, never type an expected value from reading the code; always copy it from a real run, because the code path you think is taken and the one actually taken often differ. Second, rename the test immediately after it passes. x and test_1 are the most common failure mode of this technique: six months later nobody knows what the recorded string means.

Step 3: hunt the edges, where agents break things

The happy path is the least interesting part of a legacy function, because it is the part the agent will probably reproduce correctly. The edge paths are where behavior diverges. Production failures cluster at boundaries: the value exactly at a limit, the value just past it, empty collections, unusual encodings, and the interaction of two conditions such as a maximum-length name submitted during a session timeout (Virtuoso's edge case guide).

The systematic method is boundary value analysis. Split the input domain into equivalence classes where the function should behave uniformly, then test at, just below and just above each boundary, plus degenerate inputs such as empty collections and missing values (Varsity Tutors on edge case testing). Applied to a single function, that gives a checklist you can work through in twenty minutes:

CategoryInputs to tryWhat you are recording
Null and missingNone, null, undefined, empty string, whitespace onlyDoes it raise, return None, or crash? Exact exception type and message
Empty collections[], {}, empty DataFrame, zero-row result setReturn value, or the specific error
BoundariesLength 0, 1, max, max+1; 0, -1, 1; empty vs one elementExact off-by-one behavior
Type confusionNumeric passed as string, string passed as number, unexpected enum memberSilent coercion or raised error, whichever happens
Error pathsInvalid ID, missing record, downstream 500, timeoutThe mapped error object, not just "it fails"
Ordering and duplicationDuplicate keys, unsorted input, repeated callsWhether output order or state depends on input order

The last row matters more than it looks. A legacy function that returns results in database order will pass today and silently reorder after an agent adds a GROUP BY. Recording the actual order turns that into a test failure instead of a support ticket.

Error paths deserve their own discipline: assert the exact exception type and, where practical, the message or error code. A test that only asserts "raises" will pass when the agent introduces a KeyError where a ValidationError used to be, and callers that catch the specific type will break in production.

Step 4: decide what not to characterize

Not everything deserves a recorded test, and freezing too much is its own failure mode. The freeCodeCamp guide draws the line at behavior versus implementation detail: test what a caller can observe, and avoid locking in private helpers, internal call sequences or log formatting that nobody consumes (freeCodeCamp). A test that asserts a private method was called three times will fail on every legitimate refactor and teach the team to ignore red builds.

Use this decision rule for each candidate behavior:

  • Characterize it if a caller, a downstream service, a report, or a support process depends on the exact result.
  • Characterize it if the behavior looks like a bug but changing it would alter stored data or output someone reads.
  • Do not characterize it if it is a private implementation detail, a log line format, or a timing artifact that varies run to run.
  • Do not characterize it if the output is genuinely non-deterministic (timestamps, random IDs). Inject a fixed clock or seed, or record only the deterministic parts.

The bug question needs a policy, because characterization tests will surface real defects. The rule that works: record the buggy behavior, mark the test with a comment and a ticket reference, and fix it in a separate, deliberate change where the test is updated on purpose. That way the fix shows up in review as an intentional edit to a named test rather than an invisible side effect. Feathers' point is that once you have the recorded suite, you can see when your changes make the system behave differently, which is exactly the notification system the suite exists to provide (DaedTech). A deliberate diff to a test named test_truncates_negative_amounts_to_zero is a conversation. A silent one is an incident.

Step 5: put the suite in front of the agent

This is the payoff step. A documented local setup is the prerequisite for letting an agent self-correct: if the agent knows how to run the compiler, the linter and the tests, it can try dozens of changes, catch failures and fix them without you umpiring each one (Google Cloud Tech walkthrough). The characterization suite is the part of that feedback loop that knows what "correct" means.

A workable order of operations for one function:

  1. Write and record the characterization tests. Confirm they pass on untouched code.
  2. Commit the tests on their own, before any source change. This commit is your baseline.
  3. Give the agent the task plus the test command. Ask it to keep all current tests green and to state explicitly if it believes a recorded behavior is wrong.
  4. If the agent wants to change an expected value, treat that as a decision for a human, not a fix.
  5. After the change, run the suite and read every diff. A green suite with a modified expected value is not a pass.

The wider case for context before change is well documented: a 10-million-line enterprise project adopted agent tooling without a context layer, the agents were productive but created additional debt, and the engagement stalled for over nine months before pre-computed context changed the trajectory (Driver.ai). Characterization tests are a cheap, high-signal slice of that context, tied to behavior rather than prose.

If you are running agents across several models to compare implementations of the same function, Quanta Inference puts text, image, video, audio and embedding models behind one OpenAI-compatible API key with prepaid dollar pricing, which makes A/B runs on the same characterization suite straightforward (https://quantaintelligence.ai/inference).

A worked example with numbers

Take a 40-line function that computes a customer's invoice adjustment, called from three places, with no tests. A reasonable budget is one focused block of work rather than an open-ended exercise.

ActivityTests writtenTime
Harness: fake the pricing client, construct the DTO025 min
Happy path inputs315 min
Nulls, empty line items, whitespace-only codes620 min
Boundaries: quantity 0, 1, and the configured cap415 min
Error paths: unknown SKU, negative price, downstream failure520 min
Naming pass and a second read of each assertion010 min
Total18about 1h 45m

Eighteen tests covering the function is roughly the scale most teams find sufficient for a unit of this size. The coverage number itself is secondary; what matters is that the error paths and boundaries are represented, because those are the branches an agent rewriting the function is most likely to reinterpret. Once the suite is green on untouched code, the refactor or migration proceeds against a recorded baseline, and the strangler-style approach of replacing one piece at a time keeps each change small enough to verify (Martin Fowler on strangler fig, Microsoft's Azure guidance).

One further caution: do not let the agent write the assertions from a description of the function. The failure mode is subtle and expensive. A team that had four developers independently generate documentation with an AI, cross-check against each other and validate with the client still found errors (Driver.ai). Independent agreement is not the same as ground truth. Assertions copied from a real execution are.

FAQ

FAQ

What is the difference between a characterization test and a normal unit test?

A normal unit test asserts what the code should do according to a specification. A characterization test asserts what the code does now, including behavior that looks wrong, because that behavior is what callers currently depend on. You write it to detect difference, not to judge correctness, and you fix genuine bugs later as a separate, deliberate change.

How do I write a characterization test when the function calls a database?

Introduce a seam so the test can replace the database call with a fake, then record the behavior of your function around that fake. Feathers' definition of a unit test excludes infrastructure such as databases and the network, so a test that still hits a real database is an integration test. Mark it as such and expect it to be slower.

Can an AI agent generate these tests for me?

Yes, but only for the mechanical part. Let the agent run the function with a chosen input and copy the observed output into the assertion, which is exactly the loop the technique describes. Do not let it invent expected values from a description, because invented expectations reproduce the assumptions you are trying to replace with evidence.

What do I do when a characterization test reveals a real bug?

Record the buggy behavior, comment the test with a ticket reference, and keep it green. Fix the bug in a separate change where you update that specific test on purpose, so the behavior change appears in review as an explicit edit rather than an invisible side effect of a refactor.

How many tests are enough for one function?

Enough that every branch you might change can be sensed: the happy path, null and empty inputs, each boundary, and each error path. For a typical small function that lands somewhere around 15 to 20 tests. Coverage percentage is a weaker signal than whether error paths and boundary values are represented.

Sources

Sources

  1. Characterization Tests - DaedTech
  2. Working Effectively with Legacy Code - Better Scientific Software (BSSw)
  3. The key points of Working Effectively with Legacy Code
  4. Working Effectively with Legacy Code and AI Coding Assistant - YouTube
  5. How to Build Characterization Tests Before Refactoring ...
  6. Characterization tests: a solution for creating a test on ...
  7. Taming Legacy Code With Characterization Tests
  8. Edge Case Testing Explained, What to Test & How to Do It
  9. Testing with Edge Cases, Design tests using edge ...
  10. How to modernize legacy codebases using AI coding agents ... - YouTube
  11. AI Coding Agents Should Make Migrations Easier. Without Context ...
  12. Strangler Fig - Martin Fowler
  13. Strangler Fig Pattern - Azure Architecture Center | Microsoft Learn
About the author

About the author

Antonio Evans
Antonio Evans

Antonio Evans is the founder of Quanta Intelligence and a fractional CTO who builds HIPAA-ready AI systems for health and life-science companies. Before that he was technical co-founder of FriendsEAT for twenty years, CEO of Socialdraft, and a data engineer at IBM. Antonio lives with his dog Santino in Lisbon, Portugal.

All articles