About this course
<p>The first thing many engineers want to do when they inherit legacy code is improve it.</p>
<p>You find a function that's difficult to understand. Or you see duplicated logic, deeply nested conditions, database calls mixed with business rules, and dependencies that make testing almost impossible.</p>
<p>You know the code could be better, so you start cleaning it up.</p>
<p>Then something breaks. Not because the new implementation is obviously wrong. It breaks because the old implementation was doing something nobody knew it was doing.</p>
<p>That's one of the most common risks in legacy modernization.</p>
<p>Before changing code, you need a way to answer a simple question:</p>
<blockquote>
<p>Did I preserve the behavior that already mattered?</p>
</blockquote>
<p>That is where characterization tests become useful.</p>
<p>A characterization test doesn't begin by asking what the software <strong>should</strong> do. It begins by documenting what the software <strong>does today</strong>.</p>
<p>That distinction matters.</p>
<p>In a greenfield application, tests usually express intended behavior. But in a legacy application, you may first need tests that capture existing behavior so you can change the implementation without accidentally changing its observable results.</p>
<p>In this tutorial, I'll show you how to use characterization tests as a safety net before refactoring legacy code.</p>
<p>We'll look at how to:</p>
<ul>
<li><p>identify behavior worth protecting,</p>
</li>
<li><p>choose useful test boundaries,</p>
</li>
<li><p>capture current outputs,</p>
</li>
<li><p>deal with side effects,</p>
</li>
<li><p>handle databases and external systems,</p>
</li>
<li><p>use AI to accelerate test discovery,</p>
</li>
<li><p>avoid freezing implementation details,</p>
</li>
<li><p>decide what not to characterize,</p>
</li>
<li><p>and turn characterization tests into a foundation for safer refactoring.</p>
</li>
</ul>
<p>The examples use TypeScript and Vitest, but the approach applies to most languages and testing frameworks.</p>
<p>The goal isn't to preserve every line of legacy behavior forever. The goal is to make behavior visible before you start changing the code that produces it.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>You should be comfortable with:</p>
<ul>
<li><p>TypeScript or a similar programming language</p>
</li>
<li><p>unit and integration testing</p>
</li>
<li><p>dependency injection</p>
</li>
<li><p>mocks and test doubles</p>
</li>
<li><p>basic refactoring techniques</p>
</li>
<li><p>reading an unfamiliar codebase</p>
</li>
</ul>
<p>It also helps if you've already mapped the capability you want to change.</p>
<p>Before writing characterization tests, you should have some idea of:</p>
<ul>
<li><p>where the behavior starts</p>
</li>
<li><p>what state it changes</p>
</li>
<li><p>which external systems it touches</p>
</li>
<li><p>which outputs may be consumed elsewhere</p>
</li>
</ul>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-what-characterization-tests-actually-protect">What Characterization Tests Actually Protect</a></p>
</li>
<li><p><a href="#heading-start-with-behavior-not-implementation">Start with Behavior, Not Implementation</a></p>
</li>
<li><p><a href="#heading-choose-one-capability-before-writing-tests">Choose One Capability Before Writing Tests</a></p>
</li>
<li><p><a href="#heading-find-the-smallest-useful-test-boundary">Find the Smallest Useful Test Boundary</a></p>
</li>
<li><p><a href="#heading-capture-existing-behavior-before-improving-it">Capture Existing Behavior Before Improving It</a></p>
</li>
<li><p><a href="#heading-characterize-edge-cases-you-dont-yet-understand">Characterize Edge Cases You Don't Yet Understand</a></p>
</li>
<li><p><a href="#heading-test-side-effects-not-just-return-values">Test Side Effects, Not Just Return Values</a></p>
</li>
<li><p><a href="#heading-how-to-characterize-code-that-depends-on-a-database">How to Characterize Code That Depends on a Database</a></p>
</li>
<li><p><a href="#heading-how-to-handle-external-services">How to Handle External Services</a></p>
</li>
<li><p><a href="#heading-how-to-use-ai-to-discover-characterization-tests">How to Use AI to Discover Characterization Tests</a></p>
</li>
<li><p><a href="#heading-dont-let-ai-invent-expected-behavior">Don't Let AI Invent Expected Behavior</a></p>
</li>
<li><p><a href="#heading-avoid-testing-implementation-details">Avoid Testing Implementation Details</a></p>
</li>
<li><p><a href="#heading-when-a-characterization-test-reveals-a-bug">When a Characterization Test Reveals a Bug</a></p>
</li>
<li><p><a href="#heading-how-much-behavior-should-you-characterize">How Much Behavior Should You Characterize</a></p>
</li>
<li><p><a href="#heading-use-characterization-tests-during-the-refactor">Use Characterization Tests During the Refactor</a></p>
</li>
<li><p><a href="#heading-a-practical-characterization-testing-workflow">A Practical Characterization Testing Workflow</a></p>
</li>
<li><p><a href="#heading-what-characterization-tests-cant-tell-you">What Characterization Tests Can't Tell You</a></p>
</li>
<li><p><a href="#heading-characterization-tests-are-temporary-knowledge-infrastructure">Characterization Tests Are Temporary Knowledge Infrastructure</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-what-characterization-tests-actually-protect">What Characterization Tests Actually Protect</h2>
<p>Suppose you inherit this function:</p>
<pre><code class="language-typescript">type Customer = {
id: string;
type: "STANDARD" | "PREMIUM";
};
type Order = {
id: string;
customer: Customer;
subtotal: number;
country: string;
paymentMethod: "CARD" | "TRANSFER";
};
async function processOrder(order: Order) {
let total = order.subtotal;
if (order.customer.type === "PREMIUM") {
total = total * 0.9;
}
if (order.country === "AR" && order.paymentMethod === "TRANSFER") {
total = total - 500;
}
if (total < 0) {
total = 0;
}
await ordersRepository.save({
...order,
total,
status: "PROCESSED",
});
await eventBus.publish("order.processed", {
orderId: order.id,
total,
});
return total;
}
</code></pre>
<p>There are several things you may want to refactor here.</p>
<p>For example, the pricing rules could move to another module. Persistence could be isolated. The event publisher could sit behind an interface. And the function could return an object rather than a primitive.</p>
<p>Those may all be good decisions, but before making them, you should ask: What behavior currently matters?</p>
<p>For this function, observable behavior includes at least:</p>
<ul>
<li><p>premium customers receive a 10% discount</p>
</li>
<li><p>Argentine transfers receive another adjustment</p>
</li>
<li><p>totals can't become negative</p>
</li>
<li><p>the order is persisted with a specific status</p>
</li>
<li><p>an event is published</p>
</li>
<li><p>the event contains the calculated total</p>
</li>
<li><p>the function returns that total</p>
</li>
</ul>
<p>A characterization test gives you a baseline for those behaviors.</p>
<p>For example:</p>
<pre><code class="language-typescript">import { describe, expect, it, vi } from "vitest";
describe("processOrder", () => {
it("applies the existing premium customer behavior", async () => {
const save = vi.spyOn(ordersRepository, "save");
const publish = vi.spyOn(eventBus, "publish");
const order: Order = {
id: "order-1",
customer: {
id: "customer-1",
type: "PREMIUM",
},
subtotal: 10000,
country: "US",
paymentMethod: "CARD",
};
const result = await processOrder(order);
expect(result).toBe(9000);
expect(save).toHaveBeenCalledWith(
expect.objectContaining({
id: "order-1",
total: 9000,
status: "PROCESSED",
})
);
expect(publish).toHaveBeenCalledWith("order.processed", {
orderId: "order-1",
total: 9000,
});
});
});
</code></pre>
<p>This test isn't saying that a 10% discount is the best pricing model.</p>
<p>It's saying:</p>
<blockquote>
<p>This is what the system currently does.</p>
</blockquote>
<p>That's the contract you need to understand before changing it.</p>
<h2 id="heading-start-with-behavior-not-implementation">Start with Behavior, Not Implementation</h2>
<p>A common mistake is to write tests around the structure you're planning to create.</p>
<p>Suppose you want to refactor the previous code into:</p>
<pre><code class="language-text">OrderProcessor
PricingPolicy
OrdersRepository
OrderEventPublisher
</code></pre>
<p>You may be tempted to write tests for those future classes first.</p>
<p>But those classes don't describe the existing system. They describe your proposed design.</p>
<p>Characterization tests should begin at the current observable boundary.</p>
<p>Instead of asking:</p>
<blockquote>
<p>How should <code>PricingPolicy</code> work?</p>
</blockquote>
<p>ask:</p>
<blockquote>
<p>Given this input, what does <code>processOrder()</code> currently produce?</p>
</blockquote>
<p>That difference helps prevent your new architecture from redefining behavior accidentally.</p>
<p>The sequence should be:</p>
<pre><code class="language-text">Observe existing behavior
↓
Capture it
↓
Refactor implementation
↓
Run characterization tests
↓
Verify behavior remains stable
</code></pre>
<p>Not:</p>
<pre><code class="language-text">Design new architecture
↓
Write tests for new architecture
↓
Assume it matches the old system
</code></pre>
<p>The second workflow tests your design. The first protects the migration.</p>
<h2 id="heading-choose-one-capability-before-writing-tests">Choose One Capability Before Writing Tests</h2>
<p>Don't start by trying to characterize an entire legacy application. Instead, pick one business capability.</p>
<p>For example:</p>
<pre><code class="language-text">Approve Order
Generate Invoice
Renew Subscription
Register Customer
Calculate Commission
Cancel Reservation
</code></pre>
<p>Then trace that capability through the system.</p>
<p>Suppose you choose:</p>
<blockquote>
<p>Generate Invoice</p>
</blockquote>
<p>You discover this path:</p>
<pre><code class="language-text">POST /orders/:id/invoice
↓
InvoiceController.generate()
↓
InvoiceService.generate()
↓
TaxCalculator.calculate()
↓
InvoiceRepository.save()
↓
PdfGenerator.create()
↓
EmailService.send()
</code></pre>
<p>That becomes the scope of your investigation.</p>
<p>Now ask: Which behaviors matter if I refactor this capability?</p>
<p>Perhaps:</p>
<pre><code class="language-text">tax calculation
invoice numbering
database state
PDF fields
email recipient
email attachment
error behavior
</code></pre>
<p>Those are candidates for characterization.</p>
<p>This is more useful than trying to increase test coverage across the repository indiscriminately.</p>
<p>Coverage isn't the goal. Behavioral confidence is.</p>
<h2 id="heading-find-the-smallest-useful-test-boundary">Find the Smallest Useful Test Boundary</h2>
<p>Characterization tests can exist at different levels.</p>
<p>You might test:</p>
<pre><code class="language-text">function
service
module
API endpoint
background job
complete workflow
</code></pre>
<p>The right boundary is usually the smallest one that still captures meaningful behavior.</p>
<p>Suppose the logic you want to refactor lives inside:</p>
<pre><code class="language-typescript">class InvoiceService {
async generate(orderId: string) {
// 300 lines of legacy behavior
}
}
</code></pre>
<p>If <code>generate()</code> coordinates tax calculation, persistence, numbering, and external calls, testing a small internal helper may not protect enough behavior.</p>
<p>Testing the whole production stack may be too slow and difficult.</p>
<p>A service-level characterization test may be the useful compromise.</p>
<p>For example:</p>
<pre><code class="language-typescript">describe("InvoiceService.generate", () => {
it("preserves the existing invoice calculation", async () => {
const service = createInvoiceService();
const invoice = await service.generate("order-123");
expect(invoice.subtotal).toBe(10000);
expect(invoice.tax).toBe(2100);
expect(invoice.total).toBe(12100);
});
});
</code></pre>
<p>You don't want to ask what's the smallest unit you can test. You want to ask what's the smallest boundary that gives you confidence during this refactor.</p>
<p>Those aren't always the same thing.</p>
<h2 id="heading-capture-existing-behavior-before-improving-it">Capture Existing Behavior Before Improving It</h2>
<p>Legacy code often contains behavior that looks suspicious.</p>
<p>Consider:</p>
<pre><code class="language-typescript">function calculateDiscount(amount: number) {
if (amount > 10000) {
return amount * 0.15;
}
if (amount > 5000) {
return amount * 0.1;
}
return 0;
}
</code></pre>
<p>You run a few examples and discover:</p>
<pre><code class="language-text">5000 -> 0
5001 -> 500.1
10000 -> 1000
10001 -> 1500.15
</code></pre>
<p>You might think:</p>
<blockquote>
<p><code>5000</code> should probably receive the 10% discount.</p>
</blockquote>
<p>Maybe. But that's not what the current code does.</p>
<p>A characterization test could record:</p>
<pre><code class="language-typescript">describe("calculateDiscount", () => {
it.each([
[5000, 0],
[5001, 500.1],
[10000, 1000],
[10001, 1500.15],
])(
"returns the existing discount for amount %d",
(amount, expected) => {
expect(calculateDiscount(amount)).toBe(expected);
}
);
});
</code></pre>
<p>This creates a behavioral boundary around the existing implementation.</p>
<p>Later, if the business confirms that <code>5000</code> should receive a discount, you can intentionally change:</p>
<pre><code class="language-typescript">if (amount > 5000)
</code></pre>
<p>to:</p>
<pre><code class="language-typescript">if (amount >= 5000)
</code></pre>
<p>and update the relevant test.</p>
<p>The important part is that the change becomes explicit.</p>
<p>Without the test, it could happen accidentally during an unrelated refactor.</p>
<h2 id="heading-characterize-edge-cases-you-dont-yet-understand">Characterize Edge Cases You Don't Yet Understand</h2>
<p>The obvious cases aren't always the risky ones. Legacy systems often fail at boundaries.</p>
<p>Look for values such as:</p>
<pre><code class="language-text">0
-1
null
empty string
maximum value
minimum value
exact threshold values
unknown status
duplicate identifiers
missing related records
</code></pre>
<p>Suppose you find:</p>
<pre><code class="language-typescript">function normalizeBalance(balance?: number) {
if (!balance) {
return 0;
}
return Math.round(balance * 100) / 100;
}
</code></pre>
<p>That means:</p>
<pre><code class="language-text">undefined -> 0
0 -> 0
</code></pre>
<p>But also potentially:</p>
<pre><code class="language-text">NaN -> 0
</code></pre>
<p>because <code>NaN</code> is falsy.</p>
<p>Is that intentional? You may not know yet.</p>
<p>You can characterize it:</p>
<pre><code class="language-typescript">describe("normalizeBalance", () => {
it("returns zero for undefined", () => {
expect(normalizeBalance(undefined)).toBe(0);
});
it("returns zero for zero", () => {
expect(normalizeBalance(0)).toBe(0);
});
it("returns zero for NaN in the current implementation", () => {
expect(normalizeBalance(Number.NaN)).toBe(0);
});
});
</code></pre>
<p>The name matters.</p>
<p>Notice that I wrote:</p>
<blockquote>
<p>in the current implementation</p>
</blockquote>
<p>I'm not pretending that behavior is correct. I'm just documenting it.</p>
<p>That distinction becomes important when a test describes questionable behavior.</p>
<h2 id="heading-test-side-effects-not-just-return-values">Test Side Effects, Not Just Return Values</h2>
<p>A return value is only one kind of behavior.</p>
<p>Legacy functions frequently produce side effects.</p>
<p>Consider:</p>
<pre><code class="language-typescript">async function cancelOrder(order: Order) {
order.status = "CANCELLED";
await orders.save(order);
await inventory.release(order.id);
await audit.log("ORDER_CANCELLED", order.id);
return order;
}
</code></pre>
<p>A weak characterization test might only check:</p>
<pre><code class="language-typescript">expect(result.status).toBe("CANCELLED");
</code></pre>
<p>But a refactor could still accidentally remove:</p>
<pre><code class="language-text">inventory.release()
audit.log()
</code></pre>
<p>and the test would continue passing.</p>
<p>A stronger characterization test captures observable side effects:</p>
<pre><code class="language-typescript">it("preserves cancellation side effects", async () => {
const save = vi.spyOn(orders, "save");
const release = vi.spyOn(inventory, "release");
const log = vi.spyOn(audit, "log");
const order = {
id: "order-1",
status: "APPROVED",
} as Order;
await cancelOrder(order);
expect(save).toHaveBeenCalled();
expect(release).toHaveBeenCalledWith("order-1");
expect(log).toHaveBeenCalledWith(
"ORDER_CANCELLED",
"order-1"
);
});
</code></pre>
<p>This doesn't mean every internal call deserves an assertion.</p>
<p>The question is whether the call produces observable behavior that matters outside the implementation.</p>
<h2 id="heading-how-to-characterize-code-that-depends-on-a-database">How to Characterize Code That Depends on a Database</h2>
<p>Database-heavy legacy code can be difficult to test.</p>
<p>Suppose you have:</p>
<pre><code class="language-typescript">async function activateCustomer(customerId: string) {
const customer = await db.customers.findById(customerId);
if (!customer) {
throw new Error("Customer not found");
}
await db.customers.update(customerId, {
status: "ACTIVE",
activatedAt: new Date(),
});
return db.customers.findById(customerId);
}
</code></pre>
<p>You have several options.</p>
<h3 id="heading-use-an-integration-test">Use an Integration Test</h3>
<p>If the database behavior itself matters, run against a disposable test database.</p>
<p>For example:</p>
<pre><code class="language-typescript">it("activates an existing customer", async () => {
await seedCustomer({
id: "customer-1",
status: "PENDING",
});
const result = await activateCustomer("customer-1");
expect(result?.status).toBe("ACTIVE");
expect(result?.activatedAt).toBeTruthy();
});
</code></pre>
<p>This gives high confidence, but the test may be slower.</p>
<h3 id="heading-introduce-a-seam">Introduce a Seam</h3>
<p>If database access makes testing impractical, you may need a very small structural change before characterization.</p>
<p>For example:</p>
<pre><code class="language-typescript">type CustomerRepository = {
findById(id: string): Promise<Customer | null>;
update(
id: string,
data: Partial<Customer>
): Promise<void>;
};
</code></pre>
<p>Then:</p>
<pre><code class="language-typescript">async function activateCustomer(
customerId: string,
customers: CustomerRepository
) {
// existing behavior
}
</code></pre>
<p>This is a useful concept from legacy-code work: create a <strong>seam</strong>, a place where behavior can be observed or replaced without rewriting the system.</p>
<p>The key is to keep this preparatory change mechanical.</p>
<p>Don't redesign the business logic while creating the test boundary.</p>
<p>First make it testable. Then characterize it. Then refactor.</p>
<h2 id="heading-how-to-handle-external-services">How to Handle External Services</h2>
<p>Legacy code frequently talks directly to:</p>
<pre><code class="language-text">payment providers
email services
ERPs
CRMs
message brokers
cloud storage
third-party APIs
</code></pre>
<p>You usually don't want characterization tests repeatedly calling those systems.</p>
<p>Instead, capture the interaction at the boundary.</p>
<p>Suppose:</p>
<pre><code class="language-typescript">async function chargeOrder(order: Order) {
const response = await stripe.charge({
amount: order.total,
currency: "usd",
customerId: order.customerId,
});
await orders.markPaid(order.id, response.id);
return response.id;
}
</code></pre>
<p>You can characterize the request:</p>
<pre><code class="language-typescript">it("sends the existing payment payload", async () => {
const charge = vi
.spyOn(stripe, "charge")
.mockResolvedValue({
id: "payment-123",
});
const markPaid = vi.spyOn(orders, "markPaid");
const order = {
id: "order-1",
total: 5000,
customerId: "customer-1",
} as Order;
await chargeOrder(order);
expect(charge).toHaveBeenCalledWith({
amount: 5000,
currency: "usd",
customerId: "customer-1",
});
expect(markPaid).toHaveBeenCalledWith(
"order-1",
"payment-123"
);
});
</code></pre>
<p>That protects the external contract without hitting the external system.</p>
<p>But be careful. If the provider behavior itself matters, mocks alone may not be enough.</p>
<p>You might also need:</p>
<ul>
<li><p>provider sandbox tests</p>
</li>
<li><p>contract tests</p>
</li>
<li><p>integration tests</p>
</li>
<li><p>schema validation</p>
</li>
</ul>
<p>Characterization testing doesn't eliminate the need for those layers.</p>
<h2 id="heading-how-to-use-ai-to-discover-characterization-tests">How to Use AI to Discover Characterization Tests</h2>
<p>AI is particularly useful when you are staring at a large legacy function and trying to understand what deserves a test.</p>
<p>Suppose you have a 400-line service.</p>
<p>Instead of asking:</p>
<pre><code class="language-text">Write unit tests for this class.
</code></pre>
<p>use a more investigative prompt:</p>
<pre><code class="language-text">Analyze this class without changing it.
Identify observable behaviors that could change during refactoring.
Group them into:
1. returned values,
2. state changes,
3. persistence effects,
4. external calls,
5. emitted events,
6. exceptions,
7. boundary conditions.
For every proposed characterization test:
- reference the relevant source code,
- explain what behavior the test would protect,
- distinguish observed behavior from inferred behavior.
Do not invent expected values.
</code></pre>
<p>That final instruction matters: you want AI to help identify <strong>what to observe</strong>. You don't want it inventing what the software should do.</p>
<p>Another useful prompt is:</p>
<pre><code class="language-text">Review the existing test suite for this capability.
Compare the behaviors covered by tests with the
observable behaviors in the implementation.
List behavior that appears unprotected.
Do not generate tests yet.
</code></pre>
<p>This is often more valuable than immediately asking for test code.</p>
<p>First identify the gaps, and then decide which gaps matter.</p>
<h2 id="heading-dont-let-ai-invent-expected-behavior">Don't Let AI Invent Expected Behavior</h2>
<p>This is probably the most important rule when combining AI with characterization testing, and it's worth talking a bit more about.</p>
<p>Suppose AI reads:</p>
<pre><code class="language-typescript">if (customer.age > 65) {
discount = 0.2;
}
</code></pre>
<p>It may generate:</p>
<pre><code class="language-typescript">expect(calculateDiscount(65)).toBe(0.2);
</code></pre>
<p>because it assumes the intended business rule is:</p>
<blockquote>
<p>Customers aged 65 or older receive a discount.</p>
</blockquote>
<p>But that's not what the code says.</p>
<p>The existing behavior is:</p>
<pre><code class="language-text">65 -> no discount
66 -> discount
</code></pre>
<p>The expected values in characterization tests should come from evidence.</p>
<p>Useful evidence includes:</p>
<ul>
<li><p>running the current system</p>
</li>
<li><p>existing tests</p>
</li>
<li><p>fixtures</p>
</li>
<li><p>production-safe observations</p>
</li>
<li><p>documented examples</p>
</li>
<li><p>database state</p>
</li>
<li><p>historical behavior</p>
</li>
</ul>
<p>Don't derive expectations solely from what seems reasonable.</p>
<p>A better AI instruction is:</p>
<pre><code class="language-text">For each candidate test, tell me how I can obtain
the expected result from the current implementation.
Do not propose the expected result yourself unless it
can be directly derived from executable behavior
or an existing test.
</code></pre>
<p>This turns AI into an assistant for experiment design rather than an authority on business rules.</p>
<h2 id="heading-avoid-testing-implementation-details">Avoid Testing Implementation Details</h2>
<p>Characterization tests can become harmful if they freeze the current code structure.</p>
<p>Suppose the implementation is:</p>
<pre><code class="language-typescript">async function processOrder(order: Order) {
validateOrder(order);
calculatePrice(order);
reserveInventory(order);
saveOrder(order);
}
</code></pre>
<p>A brittle test might assert:</p>
<pre><code class="language-typescript">expect(validateOrder).toHaveBeenCalledBefore(calculatePrice);
expect(calculatePrice).toHaveBeenCalledBefore(reserveInventory);
expect(reserveInventory).toHaveBeenCalledBefore(saveOrder);
</code></pre>
<p>Maybe that order matters. Maybe it doesn't.</p>
<p>If consumers only care about:</p>
<pre><code class="language-text">correct total
inventory reserved
order persisted
</code></pre>
<p>then asserting the exact sequence unnecessarily constrains the refactor.</p>
<p>Prefer protecting externally meaningful behavior.</p>
<p>For example:</p>
<pre><code class="language-typescript">expect(savedOrder.total).toBe(9000);
expect(inventory.reserve).toHaveBeenCalledWith(
"product-1",
2
);
expect(repository.save).toHaveBeenCalled();
</code></pre>
<p>Characterization tests should create a safety net. They shouldn't turn the legacy implementation into a specification of every internal decision.</p>
<h2 id="heading-when-a-characterization-test-reveals-a-bug">When a Characterization Test Reveals a Bug</h2>
<p>Eventually you'll encounter behavior that appears clearly wrong.</p>
<p>For example:</p>
<pre><code class="language-typescript">function calculateFee(amount: number) {
if (amount === 0) {
return 100;
}
return amount * 0.02;
}
</code></pre>
<p>You confirm that zero-value transactions are charged a fixed fee. Everyone agrees this looks suspicious.</p>
<p>What should the characterization test do?</p>
<p>First, separate two questions:</p>
<ol>
<li><p>What does the system do today?</p>
</li>
<li><p>What should the system do?</p>
</li>
</ol>
<p>The characterization test answers the first.</p>
<pre><code class="language-typescript">it("currently charges 100 for a zero-value transaction", () => {
expect(calculateFee(0)).toBe(100);
});
</code></pre>
<p>Then investigate whether this is intentional business behavior, a historical workaround, or an actual defect.</p>
<p>If the business confirms it is a bug, create a separate change.</p>
<p>For example:</p>
<pre><code class="language-typescript">it("does not charge a fee for a zero-value transaction", () => {
expect(calculateFee(0)).toBe(0);
});
</code></pre>
<p>Then modify the production code.</p>
<p>This may sound overly formal for a small condition. But it creates a clean distinction between:</p>
<pre><code class="language-text">behavior discovered during refactoring
</code></pre>
<p>and:</p>
<pre><code class="language-text">behavior intentionally changed
</code></pre>
<p>That distinction becomes extremely valuable in large migrations.</p>
<h2 id="heading-how-much-behavior-should-you-characterize">How Much Behavior Should You Characterize</h2>
<p>You don't need to characterize everything. Trying to preserve every observed detail can create another form of paralysis.</p>
<p>Prioritize behavior with high change risk or high business impact.</p>
<p>I usually look first at:</p>
<ul>
<li><p>financial calculations</p>
</li>
<li><p>state transitions</p>
</li>
<li><p>authentication and authorization</p>
</li>
<li><p>external contracts</p>
</li>
<li><p>queue and event payloads</p>
</li>
<li><p>data transformations</p>
</li>
<li><p>retry behavior</p>
</li>
<li><p>idempotency</p>
</li>
<li><p>regulatory rules</p>
</li>
<li><p>critical error handling</p>
</li>
</ul>
<p>You may care less about:</p>
<ul>
<li><p>internal helper naming</p>
</li>
<li><p>private method structure</p>
</li>
<li><p>log wording that nobody consumes</p>
</li>
<li><p>temporary object shapes</p>
</li>
<li><p>implementation-specific call sequences</p>
</li>
</ul>
<p>A useful question is: If this behavior changed during refactoring, could somebody outside this function notice?</p>
<p>If the answer is yes, it's probably worth considering.</p>
<h2 id="heading-use-characterization-tests-during-the-refactor">Use Characterization Tests During the Refactor</h2>
<p>Once the characterization suite exists, keep the refactor small.</p>
<p>Suppose you begin with:</p>
<pre><code class="language-typescript">async function processOrder(order: Order) {
// validation
// pricing
// inventory
// persistence
// event publishing
}
</code></pre>
<p>You might first extract pricing:</p>
<pre><code class="language-typescript">function calculateOrderTotal(order: Order) {
let total = order.subtotal;
if (order.customer.type === "PREMIUM") {
total *= 0.9;
}
if (
order.country === "AR" &&
order.paymentMethod === "TRANSFER"
) {
total -= 500;
}
return Math.max(total, 0);
}
</code></pre>
<p>Run the characterization suite. If everything still passes, continue.</p>
<p>Next isolate inventory and run it again.</p>
<p>Then persistence. Run it again.</p>
<p>This gives you a migration rhythm:</p>
<pre><code class="language-text">small structural change
↓
run tests
↓
observe
↓
continue
</code></pre>
<p>If something fails, the search space is small.</p>
<p>Compare that with rewriting 2,000 lines and then discovering 47 broken tests.</p>
<p>Small changes turn failures into useful feedback, while Large changes turn failures into archaeology.</p>
<p>Again.</p>
<h2 id="heading-a-practical-characterization-testing-workflow">A Practical Characterization Testing Workflow</h2>
<p>Here is the workflow I would use on an unfamiliar legacy capability.</p>
<h3 id="heading-1-map-the-capability">1. Map the Capability</h3>
<p>Identify:</p>
<pre><code class="language-text">entry point
business logic
state changes
side effects
external contracts
outputs
</code></pre>
<p>Don't refactor yet.</p>
<h3 id="heading-2-find-existing-tests">2. Find Existing Tests</h3>
<p>Search for tests that already describe the capability.</p>
<p>Look for:</p>
<pre><code class="language-text">happy paths
boundary cases
errors
historical bugs
integration behavior
</code></pre>
<p>Don't duplicate useful tests unnecessarily.</p>
<h3 id="heading-3-list-observable-behaviors">3. List Observable Behaviors</h3>
<p>Create a table such as:</p>
<table>
<thead>
<tr>
<th>Behavior</th>
<th>Evidence</th>
<th>Protected?</th>
</tr>
</thead>
<tbody><tr>
<td>Premium discount</td>
<td>Code + production example</td>
<td>No</td>
</tr>
<tr>
<td>Order event</td>
<td>Code</td>
<td>Yes</td>
</tr>
<tr>
<td>Transfer adjustment</td>
<td>Code</td>
<td>No</td>
</tr>
<tr>
<td>Negative total clamp</td>
<td>Code</td>
<td>No</td>
</tr>
<tr>
<td>Save status</td>
<td>Existing integration test</td>
<td>Yes</td>
</tr>
</tbody></table>
<p>Now you know where the risk is.</p>
<h3 id="heading-4-pick-the-test-boundary">4. Pick the Test Boundary</h3>
<p>Decide whether the useful boundary is:</p>
<pre><code class="language-text">function
service
module
endpoint
job
workflow
</code></pre>
<p>Choose based on confidence, not test ideology.</p>
<h3 id="heading-5-capture-current-behavior">5. Capture Current Behavior</h3>
<p>Run the existing system.</p>
<p>Use real observable outputs when possible.</p>
<p>Don't guess expectations.</p>
<h3 id="heading-6-add-critical-edge-cases">6. Add Critical Edge Cases</h3>
<p>Test:</p>
<pre><code class="language-text">thresholds
empty values
nulls
errors
duplicate operations
retry scenarios
</code></pre>
<p>especially around logic you intend to change.</p>
<h3 id="heading-7-capture-side-effects">7. Capture Side Effects</h3>
<p>Protect meaningful:</p>
<pre><code class="language-text">writes
events
messages
external calls
state transitions
</code></pre>
<p>not only function return values.</p>
<h3 id="heading-8-mark-uncertain-behavior">8. Mark Uncertain Behavior</h3>
<p>Use test names or documentation that clearly distinguishes:</p>
<pre><code class="language-text">confirmed business rule
</code></pre>
<p>from:</p>
<pre><code class="language-text">current observed behavior
</code></pre>
<h3 id="heading-9-refactor-incrementally">9. Refactor Incrementally</h3>
<p>Make one structural change.</p>
<p>Run the suite.</p>
<p>Repeat.</p>
<h3 id="heading-10-replace-characterization-with-intent-where-appropriate">10. Replace Characterization with Intent Where Appropriate</h3>
<p>As understanding improves, some characterization tests can evolve into true specification tests.</p>
<p>Instead of:</p>
<pre><code class="language-text">currently returns 0 for this input
</code></pre>
<p>you may eventually be able to say:</p>
<pre><code class="language-text">does not apply a discount below the premium threshold
</code></pre>
<p>That transition is useful. It means the system is becoming understood rather than merely preserved.</p>
<h2 id="heading-what-characterization-tests-cant-tell-you">What Characterization Tests Can't Tell You</h2>
<p>Characterization tests are powerful, but they have an important limitation.</p>
<p>They tell you what happened for the cases you observed. They don't automatically tell you why.</p>
<p>Suppose the test says:</p>
<pre><code class="language-text">Argentine transfer orders receive a 500-unit adjustment.
</code></pre>
<p>The test can protect that behavior.</p>
<p>It can't tell you whether the adjustment exists because of:</p>
<ul>
<li><p>a tax rule</p>
</li>
<li><p>a banking fee</p>
</li>
<li><p>an old promotion</p>
</li>
<li><p>a customer-specific workaround</p>
</li>
<li><p>a bug nobody removed</p>
</li>
</ul>
<p>For that, you still need other evidence:</p>
<ul>
<li><p>documentation</p>
</li>
<li><p>Git history</p>
</li>
<li><p>production telemetry</p>
</li>
<li><p>domain experts</p>
</li>
<li><p>incident records</p>
</li>
<li><p>external system contracts</p>
</li>
</ul>
<p>This is why characterization testing belongs after codebase understanding, not instead of it.</p>
<p>You first discover the behavior. Then you protect it. Then you continue investigating what it means.</p>
<h2 id="heading-characterization-tests-are-temporary-knowledge-infrastructure">Characterization Tests Are Temporary Knowledge Infrastructure</h2>
<p>There's another way I think about these tests.</p>
<p>Legacy systems contain knowledge that's often trapped inside implementation details. A characterization test moves some of that knowledge into an executable form.</p>
<p>Before:</p>
<pre><code class="language-text">Nobody knows what changing this condition will break.
</code></pre>
<p>After:</p>
<pre><code class="language-text">Changing this condition causes these four observable behaviors to change.
</code></pre>
<p>That's already progress.</p>
<p>The test suite becomes part of your understanding of the system. It creates a bridge between:</p>
<pre><code class="language-text">what the code currently does
</code></pre>
<p>and:</p>
<pre><code class="language-text">what we eventually want the system to do
</code></pre>
<p>You don't have to keep every characterization test forever.</p>
<p>Some will become proper specification tests.</p>
<p>Some will disappear when obsolete behavior is intentionally removed.</p>
<p>Some will remain as regression tests.</p>
<p>Their first job is simpler: <strong>make change safer while understanding is still incomplete.</strong></p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>AI makes refactoring legacy code faster.</p>
<p>It can explain functions, generate candidate abstractions, extract interfaces, suggest module boundaries, and rewrite large sections of code in seconds.</p>
<p>That makes characterization testing more important, not less.</p>
<p>When the cost of producing a new implementation decreases, the risk shifts toward verifying that the new implementation still preserves the behavior that matters.</p>
<p>Before asking:</p>
<pre><code class="language-text">How should I refactor this?
</code></pre>
<p>ask:</p>
<pre><code class="language-text">What does this do today?
</code></pre>
<p>Then:</p>
<pre><code class="language-text">Which of those behaviors matter?
</code></pre>
<p>Then:</p>
<pre><code class="language-text">How can I prove they still work after the change?
</code></pre>
<p>That is what characterization tests give you.</p>
<p>They don't tell you that legacy behavior is correct. They give you evidence that it exists.</p>
<p>And once that evidence is executable, you can refactor with much more confidence.</p>
<p>The sequence becomes:</p>
<pre><code class="language-text">Understand
↓
Characterize
↓
Refactor
↓
Verify
</code></pre>
<p>AI can accelerate every step in that workflow. But the engineering judgment remains in deciding what behavior deserves to survive, what behavior should change, and when you have enough evidence to safely make that distinction.</p>