You open a class to make a small change. The method is longer than it should be, several conditions overlap, and a comment refers to a requirement that nobody can find. There are tests, but none cover the part you need to touch.
Cleaning it up seems straightforward. Understanding which details you can safely change is harder.
That awkward condition might be redundant. It might also be the reason an important customer gets the right invoice. Reading the code can tell you what it does, but it won't always tell you which behaviours other parts of the system depend on.
This is where I use pinning tests. Before changing the implementation, I capture its existing behaviour for a useful set of inputs. Those tests give me a baseline against which to check the refactor.
Capture what the code does today
Pinning tests are commonly called characterisation tests. Their starting point is the implementation that already exists.
A requirement-based test might assert that an order qualifies for free delivery when its value reaches €100. A pinning test records that the current code charges delivery at exactly €100 and only waives it above that amount.
Both tests can use the same framework and assertion methods. The difference is where the expected result comes from.
That distinction deserves to be explicit. A passing pinning test establishes that a particular observed behaviour has been preserved. Deciding whether that behaviour meets the business requirement is a separate piece of work.
I use these tests alongside functional and acceptance tests. They answer a narrower question: did this change alter something the existing implementation did for the cases we've captured?
Start at a boundary you can keep
Consider this delivery calculator:
public sealed class DeliveryCalculator
{
public decimal Calculate(
decimal subtotal,
string countryCode,
bool preferredCustomer)
{
decimal charge = 5m;
if (subtotal > 100m)
{
charge = 0m;
}
else
{
if (countryCode != "IE")
{
charge += 7.50m;
}
if (preferredCustomer)
{
charge /= 2m;
}
}
return decimal.Round(charge, 2);
}
}
It's small enough to understand, but it contains several decisions. Free delivery starts above €100. The country code comparison is case-sensitive. Preferred customers receive a discount on the entire delivery charge. Orders above the threshold get free delivery regardless of destination.
In a real system, those decisions might be spread over a much larger method. The public operation is still a useful place to capture them. Tests against that boundary can survive changes to the private methods and classes behind it.
Before refactoring, I run the existing implementation with selected inputs and inspect the results:
| Subtotal | Country | Preferred customer | Observed charge |
| €0 | IE | No | €5.00 |
| €100 | IE | No | €5.00 |
| €100.01 | IE | No | €0.00 |
| €50 | IE | Yes | €2.50 |
| €50 | GB | No | €12.50 |
| €50 | GB | Yes | €6.25 |
| €50 | ie | No | €12.50 |
| €150 | GB | No | €0.00 |
The lowercase country code is particularly useful. A developer tidying this method might naturally add case-insensitive comparison. That could be a sensible improvement, but it changes the result. The baseline makes that change visible.
Turn those observations into tests
Here's an xUnit theory containing those captured cases. The snippets assume System and Xunit are imported.
public sealed class DeliveryCalculatorPinningTests
{
public static TheoryData<decimal, string, bool, decimal> CapturedCases =>
new()
{
{ 0m, "IE", false, 5m },
{ 100m, "IE", false, 5m },
{ 100.01m, "IE", false, 0m },
{ 50m, "IE", true, 2.50m },
{ 50m, "GB", false, 12.50m },
{ 50m, "GB", true, 6.25m },
{ 50m, "ie", false, 12.50m },
{ 150m, "GB", false, 0m }
};
[Theory]
[MemberData(nameof(CapturedCases))]
public void Calculate_preserves_captured_delivery_charges(
decimal subtotal,
string countryCode,
bool preferredCustomer,
decimal capturedCharge)
{
var calculator = new DeliveryCalculator();
var actual = calculator.Calculate(
subtotal,
countryCode,
preferredCustomer);
Assert.Equal(capturedCharge, actual);
}
}
The expected values are fixed observations. The test doesn't reproduce the delivery calculation to work out its own answer.
That would weaken the baseline. If the test repeated the same conditions as the production code, the same mistake could be introduced in both places. If it called the implementation under test to generate its expected value on every run, it would simply compare the code with itself.
Run these tests against the original implementation before changing it. Otherwise, you're capturing the behaviour of the refactor and losing the comparison you wanted.
Refactor with the baseline in place
With the captured cases passing, the implementation can be simplified:
public sealed class DeliveryCalculator
{
public decimal Calculate(
decimal subtotal,
string countryCode,
bool preferredCustomer)
{
if (subtotal > 100m)
{
return 0m;
}
var charge = countryCode == "IE" ? 5m : 12.50m;
if (preferredCustomer)
{
charge /= 2m;
}
return decimal.Round(charge, 2);
}
}
The structure has changed, while the captured results remain the same. The tests don't care that the nested condition disappeared or that the surcharge became a choice between two amounts.
If I change > to >=, the €100 case fails. If I make the country comparison case-insensitive, the lowercase case fails. Those failures identify observable changes that might otherwise disappear inside a cleanup diff.
Passing these eight cases doesn't prove that every possible input behaves identically. Confidence depends on the cases chosen and the scope of the change. For a larger calculation, I might compare both implementations over a broader, deterministic set of generated inputs as well.
Choose cases around decisions
The most useful inputs tend to sit near branches and boundaries. For a threshold, capture values below it, at it and above it. For interacting conditions, capture combinations that reveal their order.
Here, the overseas order above €100 shows that the free-delivery decision takes precedence over the destination charge. The preferred overseas customer shows that the discount applies after the surcharge.
Historical support cases and unusual production inputs can also provide valuable examples, provided any captured data is sanitised. They often expose behaviour that a handful of ordinary inputs would miss.
Be selective about invalid inputs. If a boundary already rejects negative order totals before this calculator runs, a large collection of negative-value tests may add little protection to the change you're making. If this calculator owns that validation, its handling belongs in the baseline.
Capture effects as well as return values
Many operations do more than return a number. A handler might update a record, publish a message and then acknowledge the input. A report generator might return a file whose column order is consumed by another system.
The baseline needs to include the observable effects relevant to the change. A matching return value won't reveal that an outgoing message lost a field or a database update stopped happening.
Choose assertions at the boundary where the effect can be observed. That might mean reading the resulting database state in an integration test, examining messages collected by a test publisher, or checking the generated file.
Avoid pinning every internal method call merely because a mocking framework makes it easy. You want enough freedom to restructure the code while preserving its externally relevant behaviour.
Larger outputs need a reviewable baseline
For a sizeable JSON response or generated document, individual assertions can become cumbersome. A reviewed snapshot or checked-in expected file can capture more of the output with less test code.
Keep that baseline small enough to inspect. A huge snapshot that gets approved without being read provides weak protection, even if the test technically compares every byte.
Variable values need attention too. Use fixed clocks and deterministic identifiers where possible. Normalise fields only when their exact values are outside the behaviour being protected, and keep meaningful relationships intact. Removing every timestamp, for example, could hide a broken expiry calculation.
Also consider ordering. Sorting a collection before comparison is appropriate only when its order has no contractual significance. Otherwise, the normalisation removes evidence of a change consumers may notice.
A surprising result deserves investigation
Suppose the lowercase country code behaviour turns out to be a bug. The pinning test has helped uncover it; preserving it temporarily doesn't make it an approved requirement.
I would keep the refactor focused, then change the comparison deliberately with a test expressing the agreed behaviour. Update the old expectation as part of that fix and explain why it changed.
That separation makes review easier. A reviewer can assess whether the refactor preserves behaviour, then assess whether the proposed behaviour change is correct.
An urgent defect may need fixing immediately. The same principle still applies: identify the intentional difference, test it explicitly and retain the useful surrounding baseline. Don't blindly preserve a known defect just to keep a characterisation test green.
Keep the tests that remain useful
Pinning tests often start as preparation for one change, but the strongest cases can become lasting regression tests. Boundary values, historical failures and externally consumed output formats usually remain worth protecting.
As the code becomes better understood, give those tests names that explain the behaviour. Record uncertain quirks clearly so a future developer doesn't mistake an observed result for a confirmed business rule.
I don't need to understand every historical decision before making unfamiliar code easier to maintain. I do need evidence about the behaviour I'm changing. Pinning tests let me gather that evidence before the first refactor, while the original implementation is still there to tell me what it does.
Arun PottiPosted Nov 14, 2026, 8:38 PM
Great Article.