Skip to main content
Back to Blog
Guide
2026-08-08

Test Strategy Legacy Code Characterization: Refactor with Confidence

Use test strategy legacy code characterization to capture real behavior, expose risky seams, and refactor aging systems with evidence instead of guesswork.

Test Strategy Legacy Code Characterization: Refactor with Confidence

A test strategy for legacy code characterization records what a system does today before a team changes it. The immediate goal is not to prove that every observed behavior is desirable. It is to create fast, reviewable evidence around the behaviors that users, integrations, data stores, and operations currently depend on. That evidence turns an uncertain rewrite into a series of bounded changes.

Start at the change boundary, not at the top of the codebase. Identify the request, event, batch job, or function that will be modified, observe representative inputs and outputs, and add tests at the narrowest stable seams available. Use unit-level characterization for deterministic logic, component tests for persistence and messaging, and a small number of browser or API journeys for business-critical behavior. Treat every captured surprise as a decision for the team, not as an automatic requirement to preserve a bug forever.

Characterization Tests Preserve Evidence, Not Design Approval

A conventional specification test begins with intended behavior. A characterization test often begins with uncertainty: the implementation has become the most complete description of the rules, documentation is incomplete, and changing one branch may affect a distant caller. The test asks, "For this controlled input and state, what observable result does the current system produce?"

That distinction changes how failures are interpreted. If a new characterization test fails on its first run, the test probably modeled the environment incorrectly. If it passes and later fails during refactoring, the change may have altered a relied-upon behavior. The team then decides whether to restore that behavior, update the test because the change is intentional, or add a migration path.

Characterization should focus on externally meaningful observations. Private method calls, incidental object shapes, timestamps generated by the runtime, and log wording are weak anchors unless an external consumer actually relies on them. Account balances, response status, file layout, emitted event fields, ordering guarantees, and retry behavior are stronger anchors.

Evidence targetGood observationFragile observationWhy the distinction matters
Domain calculationFinal invoice total by line and tax classNumber of helper callsHelpers can change without changing the contract
HTTP endpointStatus, selected headers, normalized bodyFramework response object internalsConsumers see the protocol, not the framework
Database mutationRows committed and invariant preservedExact SQL stringQuery plans and SQL formatting can evolve
Background jobDurable state and emitted messageConsole textOperational text is rarely a business contract
UI workflowUser-visible state and accessible controlCSS class or DOM depthStyling refactors should not invalidate behavior

This is also where test naming matters. A name such as applies the observed zero-rate fallback when region is missing communicates both behavior and uncertainty. A name such as calculateTax works hides the risky condition and gives a future reviewer little help.

Map the Change Surface Before Writing Assertions

Legacy code is dangerous less because of age than because its dependencies are poorly visible. Before generating tests, create a change-surface map. Begin with the planned edit and trace callers, state, outgoing effects, scheduled execution, feature switches, and known operator procedures. A one-page map is more useful than a repository-wide coverage target.

The following shell commands provide a reproducible first pass for a Node.js repository. Replace calculateInvoice with the symbol under change. They only inspect files and history.

rg -n "calculateInvoice" src test tests
rg -n "invoice|billing|tax" package.json src .github
git log --oneline --all -- src/billing
git blame -L 1,220 src/billing/calculate-invoice.ts

Search results are leads, not proof. Dynamic imports, reflection, queue consumers, database triggers, and external schedulers may not appear in a symbol search. Ask operations how the path is invoked. Inspect production-safe telemetry for input categories and error classes. Read recent incidents and support cases. If an AI coding agent proposes a map, require it to cite file paths and call sites so a reviewer can distinguish repository evidence from an inference.

Classify seams by stability and observability:

SeamTypical techniqueUse it whenMain risk
Pure functionTable-driven unit testInputs fully determine outputsMissing implicit locale or clock dependency
Module boundaryStub a narrow adapterLegacy logic calls network, clock, or filesystemMock duplicates an undocumented contract
HTTP boundaryIn-process server plus requestClients depend on status and JSONTest may omit gateway behavior
Persistence boundaryDisposable real databaseConstraints, transactions, or queries matterFixture drift and slow setup
Browser boundaryPlaywright user journeyRendering and interaction are part of riskBroad failure diagnosis
File or message snapshotNormalized golden masterOutput is large but structurally stableReviewers approve noisy diffs blindly

The map should lead to an explicit scope statement. For example: "Characterize invoice recalculation for domestic orders, missing region, discount stacking, and duplicate retry. Preserve response and ledger effects. Do not freeze log wording or current SQL." That sentence gives an agent a far safer mandate than "add tests for billing."

Capture Deterministic Rules With Input Partitions

The fastest characterization layer is a table of meaningful input partitions. Look for boundary values, null and missing distinctions, type coercion, ordering, rounding, duplicate identifiers, and state transitions. Use production-shaped examples that contain only the fields needed to express the rule.

Here is a self-contained Vitest test for a legacy discount function. The implementation is included so the example can be copied into a test file and run after installing Vitest.

import { describe, expect, it } from 'vitest';

type Line = { quantity: number; unitPriceCents: number };

function legacyTotal(lines: Line[], coupon?: string): number {
  const subtotal = lines.reduce(
    (sum, line) => sum + line.quantity * line.unitPriceCents,
    0,
  );
  if (coupon === 'SAVE10' && subtotal > 5000) {
    return Math.floor(subtotal * 0.9);
  }
  return subtotal;
}

describe('legacyTotal characterization', () => {
  it.each([
    { subtotal: 5000, expected: 5000 },
    { subtotal: 5001, expected: 4500 },
    { subtotal: 10001, expected: 9000 },
  ])('maps $subtotal cents to $expected cents', ({ subtotal, expected }) => {
    const lines = [{ quantity: 1, unitPriceCents: subtotal }];
    expect(legacyTotal(lines, 'SAVE10')).toBe(expected);
  });
});

This test reveals two important facts without claiming they are ideal: the threshold is strictly greater than 5,000 cents, and fractional cents are rounded down. A product owner may later decide that the threshold should be inclusive or that money should use a decimal representation. Until then, the test protects a deliberate investigation from accidental drift.

Do not create hundreds of cases by permuting every field. Select partitions that could drive different branches or expose distinct failure consequences. Pairwise generation can help when flags interact, but domain knowledge should select the final set. A small table whose rows each have a reason is easier to maintain than a large generated matrix with redundant signals.

Control Time, Randomness, Locale, and Process State

Legacy behavior often looks deterministic only on the original developer's machine. Tests become trustworthy when nondeterministic inputs are made explicit. The important sources are clock time, time zone, random values, environment variables, global caches, process order, and locale-sensitive formatting.

A low-risk refactoring technique is to add an optional dependency at the boundary while preserving the existing default. The production caller behaves as before, while the test supplies a fixed clock.

import { expect, test } from 'vitest';

type Clock = { now(): Date };

function trialStatus(
  startedAt: Date,
  trialDays: number,
  clock: Clock = { now: () => new Date() },
): 'active' | 'expired' {
  const expiresAt = startedAt.getTime() + trialDays * 24 * 60 * 60 * 1000;
  return clock.now().getTime() < expiresAt ? 'active' : 'expired';
}

test('expires at the observed millisecond boundary', () => {
  const fixedClock: Clock = {
    now: () => new Date('2026-08-08T00:00:00.000Z'),
  };
  expect(trialStatus(new Date('2026-08-01T00:00:00.000Z'), 7, fixedClock))
    .toBe('expired');
});

This seam is preferable to globally changing fake timers when only one dependency needs control. Fake timers are still useful when the behavior under test schedules callbacks, but they can unintentionally affect libraries in the same process. Regardless of technique, restore global state after each test and run the suite in a non-default time zone at least once if date logic is in scope.

Build Golden Masters That Humans Can Review

Golden master testing captures a rich output and compares future runs to an approved baseline. It works well for reports, serializers, generated commands, migration previews, and large calculations. It works badly when the output contains unstable identifiers or when reviewers cannot tell which differences are consequential.

Normalize only fields that are known noise. Never replace every number or date indiscriminately, because that could hide the regression you need to see. The following example creates a stable representation of an audit record while preserving the business fields.

import { expect, test } from 'vitest';

type AuditRecord = {
  eventId: string;
  occurredAt: string;
  actorId: string;
  action: string;
  amountCents: number;
};

function stableAudit(record: AuditRecord) {
  return {
    ...record,
    eventId: '<event-id>',
    occurredAt: '<timestamp>',
  };
}

test('records the observed refund audit shape', () => {
  const actual: AuditRecord = {
    eventId: 'evt-8f21',
    occurredAt: '2026-08-08T10:30:00.000Z',
    actorId: 'agent-42',
    action: 'refund.created',
    amountCents: 1299,
  };

  expect(stableAudit(actual)).toEqual({
    eventId: '<event-id>',
    occurredAt: '<timestamp>',
    actorId: 'agent-42',
    action: 'refund.created',
    amountCents: 1299,
  });
});

Baseline approval is a testing activity, not a clerical update. The reviewer should ask where the example came from, which values were normalized, whether confidential data was removed, and whether ordering is contractual. Store a short rationale beside the test or in its name. When a baseline changes, review the semantic diff and link it to the intended behavior change.

Characterize Persistence Through Invariants and Transactions

Mocking a repository can establish how a class calls an adapter, but it cannot reveal database defaults, constraints, collation, transaction isolation, cascades, or trigger effects. If the change touches persistence semantics, run a focused suite against the same database engine used in production, ideally in a disposable environment.

Express durable outcomes and invariants rather than exact SQL. For a transfer operation, the essential evidence might be: both ledger entries exist, their sum is zero, account balances agree with the ledger, and a retry with the same key does not duplicate the transfer. Each assertion corresponds to a failure consequence.

The next example demonstrates the invariant with an in-memory model. It is self-contained and intentionally avoids pretending that a memory collection validates database-specific behavior.

import { expect, test } from 'vitest';

type Entry = { account: string; delta: number; transferId: string };

function recordTransfer(
  entries: Entry[],
  transferId: string,
  from: string,
  to: string,
  amount: number,
): Entry[] {
  if (entries.some((entry) => entry.transferId === transferId)) return entries;
  return [
    ...entries,
    { account: from, delta: -amount, transferId },
    { account: to, delta: amount, transferId },
  ];
}

test('a repeated transfer remains balanced and idempotent', () => {
  const once = recordTransfer([], 'tr-100', 'cash', 'vendor', 4500);
  const twice = recordTransfer(once, 'tr-100', 'cash', 'vendor', 4500);

  expect(twice).toHaveLength(2);
  expect(twice.reduce((sum, entry) => sum + entry.delta, 0)).toBe(0);
  expect(twice.map((entry) => entry.account).sort()).toEqual(['cash', 'vendor']);
});

In the real component test, replace the in-memory model with a test database and execute concurrent attempts if race behavior matters. Seed through public setup helpers or documented SQL, keep fixtures minimal, and verify cleanup. A test that passes only because records leak from the previous case is worse than no characterization because it creates false confidence.

Use Protocol Tests at Integration Edges

An endpoint's contract is more than a happy-path body. Characterize status codes, required headers, error shape, field omission versus null, pagination, authorization distinctions, and idempotency. Avoid snapshotting every header because dates, tracing identifiers, and server details are usually unstable.

This runnable example uses the built-in Fetch API types and a pure handler, so it needs no network port or third-party HTTP package.

import { expect, test } from 'vitest';

async function legacyLookup(request: Request): Promise<Response> {
  const url = new URL(request.url);
  const id = url.searchParams.get('id');
  if (!id) {
    return Response.json(
      { error: 'missing_id' },
      { status: 400, headers: { 'cache-control': 'no-store' } },
    );
  }
  return Response.json({ id, state: 'active' }, { status: 200 });
}

test('missing id preserves status, code, and cache policy', async () => {
  const response = await legacyLookup(new Request('https://example.test/users'));

  expect(response.status).toBe(400);
  expect(response.headers.get('cache-control')).toBe('no-store');
  await expect(response.json()).resolves.toEqual({ error: 'missing_id' });
});

For a real service, add consumer contract tests where ownership crosses a team boundary. Characterization in the provider repository protects what has been observed, while a consumer-owned contract documents what a known consumer actually needs. These are complementary signals, not interchangeable labels.

Reserve Browser Coverage for Critical User Outcomes

Browser tests are valuable when risk lives in routing, rendering, browser storage, accessibility, or JavaScript integration. They are too slow and broad to characterize every branch. Select a few journeys that prove the legacy feature remains usable, then push detailed partitions down to faster layers.

Use user-facing locators and web-first assertions. The Playwright locator practices guide explains how roles, labels, and stable contracts reduce selector churn. The following test is complete for a page that is created entirely in the test, which makes the example runnable without an application server.

import { expect, test } from '@playwright/test';

test('legacy confirmation disables duplicate submission', async ({ page }) => {
  await page.setContent(`
    <button type="button" id="submit">Submit order</button>
    <p role="status"></p>
    <script>
      const button = document.querySelector('#submit');
      const status = document.querySelector('[role="status"]');
      button.addEventListener('click', () => {
        button.disabled = true;
        status.textContent = 'Order submitted';
      });
    <\/script>
  `);

  await page.getByRole('button', { name: 'Submit order' }).click();

  await expect(page.getByRole('button', { name: 'Submit order' })).toBeDisabled();
  await expect(page.getByRole('status')).toHaveText('Order submitted');
});

The most common error is making a browser test assert every intermediate implementation detail. Assert the outcome and any interaction contract that matters to the user. If a failure could be diagnosed more precisely at an API or unit layer, add coverage there instead of expanding the browser script indefinitely. For runner selection and test-layer tradeoffs, see the JavaScript testing frameworks guide.

Give AI Coding Agents an Evidence-Bounded Assignment

AI agents can accelerate repository discovery, fixture reduction, partition enumeration, and test scaffolding. They also tend to overfit to the first code path they read or manufacture an intended rule from a suggestive function name. The prompt should separate discovery, capture, and change.

A strong assignment includes:

  1. The exact entry point and planned production change.
  2. Files and systems that may be inspected.
  3. Observable behaviors to preserve, plus observations explicitly out of scope.
  4. Commands that must pass.
  5. A prohibition on changing production behavior during the capture phase.
  6. A requirement to label assumptions and cite repository evidence.

Ready-made QA skills can be installed from qaskills.sh with the qaskills CLI when a team wants a repeatable agent workflow. Whether using a packaged skill or a custom prompt, inspect the diff. A passing generated test can still be empty of value if it mocks the subject, repeats its implementation inside the assertion, or never exercises the planned change.

Use mutation as a quick quality question: if the risky condition is reversed or removed, does at least one test fail for the expected reason? You do not need mutation tooling to perform this check. Make a temporary local edit, run the focused test, and revert the temporary edit through your normal version-control workflow.

Diagnose the Failure That Appears Only in CI

Consider a characterization suite for daily settlement files. It passes locally but fails in CI on the first day of some months. The diff shows a date one day earlier than expected. Re-recording the golden file makes the immediate build green, then the failure returns for another developer.

The likely cause is environment-dependent date construction, not legitimate baseline drift. Inspect whether input such as 2026-08-01 is parsed as UTC while formatting uses the local time zone. Compare the CI and local TZ settings, log ISO instants at the failing boundary, and run the focused test under two explicit zones. The repair is to define the business time zone or use an unambiguous instant, then keep the baseline fixed.

Diagnostic clueInterpretationNext check
Failure changes with TZTime-zone dependencyParsing and formatting zones
Failure changes with suite orderShared process or database stateIsolation and cleanup
Full suite fails, focused test passesGlobal mutation or resource conflictTimers, env, ports, caches
Snapshot changes every runUnnormalized nondeterminismIDs, clocks, unordered collections
Retry passes without code changeRace or readiness issueEvent completion and polling boundary

Do not classify all intermittent failures as flaky tests. A test may be faithfully exposing nondeterministic product behavior. Diagnose which side owns the variability before adding retries, wider timeouts, or normalization.

What Teams Get Wrong About Coverage and Bugs

The first misconception is that characterization means preserving every bug. It means preserving knowledge. When a test captures a suspected defect, name it clearly and create a decision point. If the team fixes the defect, add a specification test for the desired result and update or remove the old characterization in the same reviewed change.

The second misconception is that a high line-coverage percentage makes a legacy change safe. Coverage reports where execution occurred, not whether meaningful consequences were asserted. A single broad test can execute many lines while missing a rounding boundary, duplicate event, or partial commit. Risk-to-test traceability is a better steering mechanism: list each material failure mode and the evidence that detects it.

The third misconception is that mocks make characterization more isolated and therefore more accurate. Excessive mocks can encode the developer's guess about collaborators and prevent the actual legacy interaction from running. Mock only across a boundary you understand, and preserve realistic protocol values. For unknown behavior, prefer observation through a disposable real dependency.

Run Characterization as a Controlled Refactoring Sequence

Use a sequence that keeps behavioral changes visible:

  1. Define the proposed edit and enumerate its failure consequences.
  2. Map callers, state, external effects, and runtime variability.
  3. Add the smallest characterization tests that fail when those consequences occur.
  4. Run them repeatedly and in CI to establish stability.
  5. Commit the tests separately from structural refactoring when practical.
  6. Refactor in small steps while keeping observations unchanged.
  7. Introduce desired behavior with explicit specification tests.
  8. Retire redundant golden masters after stronger, narrower contracts exist.

The exit criterion is not "the old code has tests." It is that the intended change can be reviewed with clear evidence: which behaviors remained stable, which changed intentionally, and which risks remain untested. Record the remaining gaps, such as an unavailable mainframe sandbox or an unrepeatable month-end job, so release decisions account for them.

Frequently Asked Questions

What is the first characterization test I should add?

Choose the narrowest stable boundary that crosses the code you plan to change and proves a valuable outcome. For a calculation, that is often a table-driven function test around boundary inputs. For a transaction, it may be a component test that checks committed records and idempotency. Avoid beginning with a browser journey unless the primary uncertainty is in browser behavior. The first test should reduce a named risk and run reliably enough to guide repeated edits.

Should characterization tests assert behavior that looks incorrect?

Yes, temporarily, when the behavior is real, relevant, and needed to make change visible. Name the test so the questionable rule is obvious, and create a product decision rather than silently legitimizing it. If the behavior is confirmed as a defect, write a new specification for the desired result, change the implementation, and remove or update the old expectation in one reviewed change. The value is preserving evidence during the decision, not granting permanent approval.

How much production data should be used for legacy characterization?

Use the smallest sanitized examples that preserve the relevant shape and boundary conditions. Production telemetry can reveal input categories, frequency, and failure patterns, but copying raw customer records creates privacy, security, and maintenance risks. Derive synthetic fixtures, document which observed category each represents, and remove irrelevant fields. For rare interactions, combine sanitized examples with deliberate boundary cases so common traffic does not crowd out severe but infrequent failures.

When can a characterization test be deleted?

Delete or replace it when a stronger test protects the same risk at a clearer boundary, the underlying behavior has been intentionally retired, or the test observes only an implementation detail that no longer matters. Confirm that removing it does not leave a failure consequence uncovered. Snapshot-heavy tests are common candidates after the team extracts explicit domain contracts. Treat deletion as suite design work, with the same review attention as adding coverage, rather than as routine cleanup.