BrandGhost
Characterization Tests Before Refactoring C# Code
All articles

Characterization Tests Before Refactoring C# Code

Learn characterization tests for C# refactoring: choose observable behavior, capture outputs and exceptions, control unstable data, and avoid false confidence.

Characterization tests record what existing code does at a selected boundary before its internals change. Michael Feathers explains that their purpose is to document actual behavior rather than desired behavior. They answer a preservation question: for these inputs and observations, does the restructured implementation still behave like the original? They do not automatically answer whether that behavior meets the product's requirements.

That distinction matters when maintaining C# code with surprising defaults, incomplete tests, or undocumented error handling. We need to learn the current behavior without accidentally approving every oddity as the permanent design. A useful baseline separates observed facts, desired behavior, and unknown behavior instead of merging them into one green check.

This article uses complete standard-library console examples and explicit failing assertions rather than a testing framework. The documentation baseline is .NET runtime 10.0.12 and SDK 10.0.401, and C# 14, checked October 12, 2026. These examples have been reviewed against documentation, not compiled or executed here.

Characterization Tests Start With the Caller

Begin with the operation you intend to restructure and the caller that consumes it. A parser's caller may observe a number, an exception, and whether an input object was modified. A batch processor's caller may also observe which items were attempted before failure and which effects happened in which order.

Do not begin by testing every private method. Private structure is precisely what the refactoring may change. A baseline tied to that structure can force unnecessary test rewrites while missing the public behavior that matters. Prefer an existing callable boundary whose observations survive the planned transformation.

This is consistent with Fowler's observable-behavior definition of refactoring, but a definition does not supply your observation boundary. You still need to state which returned values, effects, and errors count in this system. “Externally visible” is a question about consumers, not a synonym for public access modifiers.

For foundational context, keep the existing refactoring definition guide separate from this tactical job. Characterization tests earn their place by making a selected baseline concrete, not by repeating that refactoring should preserve behavior.

Separate Observed Behavior From Desired Behavior

Suppose a parser accepts leading spaces. That is an observation. Suppose the specification requires rejecting them. That is a requirement. The baseline should not silently rewrite the observed result to match the requirement, because it would then fail before any structural change.

Record both facts and give them different purposes. The characterization case documents the current parser for preservation work. A separate requirements test can expose the discrepancy and drive a later fix. If the existing behavior is dangerous, the team may prioritize that fix first; calling it a refactoring does not remove the behavior change.

Avoid names that imply approval, such as “CorrectlyAcceptsWhitespace,” when you only observed acceptance. A name like “CurrentParser_LeadingWhitespace_ReturnsValue” is more honest. Names shape how future maintainers interpret the baseline, particularly after the original investigation has been forgotten.

Capture Exact Values Before Broad Snapshots

Start with a small observation you can explain. The following complete example records an invariant-culture integer parser and uses explicit comparisons. It covers a normal value, surrounding whitespace, a sign, malformed input, and an out-of-range number.

using System;
using System.Globalization;

CheckValue("12", 12);
CheckValue(" 12 ", 12);
CheckValue("-12", -12);
ExpectException<FormatException>("twelve");
ExpectException<OverflowException>("2147483648");

static int CurrentParser(string text)
{
    return int.Parse(text, NumberStyles.Integer, CultureInfo.InvariantCulture);
}

static void CheckValue(string text, int expected)
{
    if (CurrentParser(text) != expected)
    {
        throw new InvalidOperationException("Value baseline changed.");
    }
}

static void ExpectException<TException>(string text)
    where TException : Exception
{
    try
    {
        CurrentParser(text);
    }
    catch (TException)
    {
        return;
    }

    throw new InvalidOperationException(
        "Expected exception: " + typeof(TException).Name);
}

The catch is specific to the expected exception family. A different exception propagates and fails the example. If the parser returns instead of throwing, the explicit failure after the try block detects it. These assertions do real comparisons; they are not comments claiming that values were checked.

Microsoft documents the string parsing overload's exceptions, including malformed and out-of-range input. That source supports the API behavior used here, not a claim that these five cases fully characterize every application parser.

The null-string case belongs in the real baseline too if null can reach the operation. This illustration's helper takes a non-nullable string and does not claim to characterize null handling. Nullable annotations are not a reason to ignore values that can arrive through deserialization or callers that do not follow the annotations.

Characterization tests should preserve the original culture choice. Here the parser already uses invariant culture. Changing a current-culture parser to invariant culture while adding the harness would change the system being characterized. Control the environment or record multiple cultures instead of “stabilizing” production code without acknowledging the change.

Choose Cases That Distinguish Plausible Mistakes

A case is useful when a plausible wrong transformation produces a different observation. For the parser, a successful integer cannot distinguish Parse from a replacement that returns zero for invalid input. The malformed case can. An out-of-range value can distinguish overflow rejection from an unchecked narrowing conversion.

Think in partitions tied to actual branches and contracts. Empty input, minimum and maximum values, null, whitespace, duplicates, and failure after partial work are candidates, not a mandatory universal checklist. The right choices depend on what the operation accepts and what the proposed diff could change.

Include interactions when they matter. A processor that stops after a failing item needs a case with valid work before and after the failure. A single failing item cannot establish whether later items were skipped. Similarly, checking an unordered set of results cannot detect changed ordering.

The existing guide to working effectively with legacy code already covers adding tests and choosing appropriate boundaries. Here the distinguishing step is connecting each selected input to a specific preservation risk.

Explain Why Each Baseline Case Exists

Attach a short reason to the case in the research record or test naming. “Duplicate inputs remain duplicated” identifies a contract. “Boundary case” alone does not. Another developer should be able to tell which accidental simplification the test is meant to catch.

This also prevents collecting large numbers of redundant cases. Ten ordinary positive integers may all follow the same path. A negative integer, leading whitespace, and an overflow case can expose different behaviors. More examples are not automatically more discriminating examples.

When the source has unfamiliar behavior, exploratory calls can help discover it in an approved environment. Record actual observations rather than filling expected results from intuition. This article presents documented examples, not a report of exploratory execution on a production system.

Record Side Effects as an Ordered Observation

Return values are insufficient when callers depend on effects. The next example models a batch operation through a supplied recording callback. It records both the count and the sequence of labeled notifications, including the effect immediately before a deliberate failure.

using System;
using System.Collections.Generic;

var events = new List<string>();
var count = Process(new[] { "A", "B" }, events.Add);
if (count != 2 || string.Join("|", events) != "seen:A|seen:B")
{
    throw new InvalidOperationException("Success trace changed.");
}

events.Clear();
try
{
    Process(new[] { "A", "STOP", "B" }, events.Add);
    throw new InvalidOperationException("Expected stop failure.");
}
catch (InvalidOperationException exception)
    when (exception.Message == "stop")
{
    if (string.Join("|", events) != "seen:A|seen:STOP")
    {
        throw new InvalidOperationException("Failure trace changed.");
    }
}

static int Process(string[] items, Action<string> record)
{
    var count = 0;
    foreach (var item in items)
    {
        record("seen:" + item);
        if (item == "STOP")
        {
            throw new InvalidOperationException("stop");
        }

        count++;
    }

    return count;
}

The example checks that B is not observed after STOP and that STOP itself is observed before failure. A refactoring that checks the stop condition first would change the trace even though both versions throw. The label seen: and event order are part of this model's observation boundary.

The failure assertion uses a controlled exception message from our own example. Do not extend that into a recommendation to snapshot all platform exception messages. Messages can have localization or implementation details, whereas a consumer may only rely on a type or parameter name.

The callback already exists in this model. Introducing such a callback into a real untestable operation is a production change that needs its own scrutiny. The existing article on interfaces around hard dependencies owns that preparation topic; characterization tests should not quietly invent a seam and assume it is behavior-neutral.

Control Instability Without Erasing Meaning

Time, generated identifiers, environment paths, and unordered observations can make a baseline vary. First determine whether the variation is part of the consumer contract. A timestamp displayed to a user is not irrelevant merely because it makes a snapshot inconvenient.

If an existing boundary accepts a clock or identifier provider, use controlled values there. If it does not, consider a higher-level invariant or a bounded observation. Keep production semantics unchanged while building the baseline whenever possible. A new provider abstraction is separate preparation work, not a free harness detail.

Normalize only fields whose exact values are outside the selected contract, and document the rule. Removing an incidental identifier may be defensible. Sorting events is not defensible if order matters. Stripping all whitespace is not defensible if the operation formats a document that callers compare exactly.

Characterization tests are especially vulnerable to overly broad normalization because the comparison can look rigorous while throwing away the very difference it should detect. The normalization function deserves review as part of the assertion, not as a trusted convenience.

Prefer Focused Assertions When the Contract Is Small

An approved snapshot can preserve a large structured result, but it requires deliberate review of what enters the baseline. Blindly accepting a new file whenever a test fails converts the baseline into an output recorder that approves whatever the current code happens to emit.

Focused assertions make small contracts easier to understand. If the relevant promise is that no event follows STOP, assert that directly and retain the meaningful sequence. A whole-object snapshot may contain useful context, but it should not replace the specific invariant merely because it is quicker to generate.

For a large report, combine a snapshot with targeted checks around boundaries that matter. Explain which fields are intentionally ignored and why. The choice is between different observation shapes, not between a “modern” snapshot and an “old-fashioned” assertion.

Compare Versions Without Sharing Mutable State

Sometimes a baseline comparison needs both implementations in the same harness. Give each path an independent starting state. Passing the same mutable list to both allows the first path to change what the second receives, invalidating the comparison.

using System;
using System.Collections.Generic;

var first = new List<int> { 1, 2, 3 };
var second = new List<int> { 1, 2, 3 };
var before = SumBefore(first);
var after = SumAfter(second);

if (before != after)
{
    throw new InvalidOperationException("Sum changed.");
}

if (string.Join(",", first) != "1,2,3" ||
    string.Join(",", second) != "1,2,3")
{
    throw new InvalidOperationException("Input mutation detected.");
}

static int SumBefore(List<int> values)
{
    var result = 0;
    foreach (var value in values)
    {
        result = checked(result + value);
    }

    return result;
}

static int SumAfter(List<int> values)
{
    var result = 0;
    foreach (var value in values)
    {
        result = Add(result, value);
    }

    return result;
}

static int Add(int left, int right)
{
    return checked(left + right);
}

Both paths retain checked addition at each iteration. The example intentionally does not replace the loop with an algebraic formula or move the checked context out of the helper. It asserts results and input contents for one case, while the review identifies the preserved overflow policy.

Add an overflow case to a real harness if large inputs are in scope. Agreement on a small sum cannot prove overflow equivalence. Characterization tests and code review work together: one supplies observations for chosen inputs, while the other identifies meaningful paths the current observations have not exercised.

State What Characterization Tests Cannot Establish

A finite sample does not prove equivalence for every possible input. A recording callback does not reproduce a real database transaction. A pure console parser does not prove behavior under cancellation, concurrent callers, or a serializer's interpretation of an exception.

These limits should appear in the completion record. “Selected input/output and effect traces agree” is a supportable statement. “The refactoring cannot break anything” is not. The value of characterization tests increases when their boundary is explicit because future changes can reuse that boundary knowingly.

The existing overview for developers learning refactoring already recommends a suite that captures current behavior. The additional lesson here is to treat that suite as a scoped record, not a universal safety certificate.

Keep the baseline stable during structural work. If a mismatch appears, investigate before changing expected values. A genuinely incorrect expectation can be corrected with evidence. An intentional product change needs a new requirement and separate explanation. An unexplained mismatch should block the preservation claim.

Frequently Asked Questions About Characterization Tests

Are characterization tests the same as requirements tests?

No. Characterization tests describe observed existing behavior at a chosen boundary. Requirements tests describe behavior the system is meant to provide. They can agree, but an existing bug can make them differ. Keeping their purposes separate prevents a baseline from silently approving a known discrepancy.

Should I preserve a behavior that looks like a bug?

For a strictly structural change, document it and avoid changing it accidentally. That does not mean leaving it forever. If the bug needs immediate correction, treat that correction as intentional behavior work with its own acceptance criteria rather than hiding it in the refactoring.

Is a snapshot enough for characterization tests?

Only for the observations it actually retains and the cases it exercises. Snapshot review must consider irrelevant variability, meaningful ordering, and accidental normalization. A snapshot accepted without examining the differences is not strong evidence, even if the resulting test is green.

Do characterization tests require xUnit?

No. The concept is independent of a framework. These examples use standard-library console assertions so their comparisons are visible without additional packages. A real repository can express the same observations through its existing framework, using the version and APIs that repository actually supports.

How do I handle an operation that cannot be isolated?

Look for a broader existing boundary where behavior is observable, such as an HTTP response or a generated file. If dependency isolation is necessary, make that preparatory change explicit. Do not pretend a new seam has been characterized by a baseline that only became possible after the seam changed production code.

When is the baseline ready for a refactoring?

When its observations, case selection, actual execution results, and exclusions are understandable. It should discriminate the plausible mistakes in the planned transformation and explain the remaining uncertainty. Characterization tests are ready when they support a specific preservation claim, not when an arbitrary coverage percentage is reached.

Get the Dev Leader Newsletter

C#, .NET, and software engineering content every Saturday.

Subscribe to the newsletter
An error has occurred. This application may no longer respond until reloaded. Reload