A logbook for curious waters
← Software Engineering Field Guide

Behavior-Preserving Change

Before you begin: Functions, conditionals, comparisons, and basic tests.

A behavior-preserving change leaves the results and effects that callers rely on unchanged. The code may look different; its promises do not. This is the condition that makes a structural edit a refactoring rather than a change in functionality. 1

It is easy to agree with that sentence and still miss one character that breaks it.

The purchase right on the boundary

Suppose a fictional shop charges five tokens for shipping, except on purchases of 50 tokens or more, which ship free. Subtotals in this example are nonnegative whole numbers.

Original version

public static class Shipping
{
    public static int Fee(int subtotal)
    {
        int fee = 5;
        if (subtotal >= 50)
        {
            fee = 0;
        }
        return fee;
    }
}
Console.WriteLine(Shipping.Fee(50)); // 0

Fee begins with a charge of 5 and changes it to zero when subtotal >= 50.

def shipping_fee(subtotal):
    fee = 5
    if subtotal >= 50:
        fee = 0
    return fee
print(shipping_fee(50))  # 0

shipping_fee begins with a charge of 5 and changes it to zero when subtotal >= 50.

A developer removes the temporary variable and returns each answer immediately. But the comparison changes accidentally.

A shorter version with a bug

public static class Shipping
{
    public static int Fee(int subtotal)
    {
        if (subtotal > 50)
        {
            return 0;
        }
        return 5;
    }
}
Console.WriteLine(Shipping.Fee(50)); // 5: wrong at the boundary
def shipping_fee(subtotal):
    if subtotal > 50:
        return 0
    return 5
print(shipping_fee(50))  # 5: wrong at the boundary

Purchases of 20 and 100 still work. A test suite containing only those examples would miss the regression. The original promise included a purchase of exactly 50; the shorter version quietly removed it.

Check the boundary, not just a convenient example

SubtotalRequired shipping feeWhy test it?
495Immediately below the threshold
500Exactly at the threshold
510Immediately above the threshold

The correction restores >=. It keeps the early returns, which were not the problem.

public static class Shipping
{
    public static int Fee(int subtotal)
    {
        if (subtotal >= 50)
        {
            return 0;
        }
        return 5;
    }
}
Console.WriteLine(Shipping.Fee(49)); // 5
Console.WriteLine(Shipping.Fee(50)); // 0
Console.WriteLine(Shipping.Fee(51)); // 0
def shipping_fee(subtotal):
    if subtotal >= 50:
        return 0
    return 5
print(shipping_fee(49))  # 5
print(shipping_fee(50))  # 0
print(shipping_fee(51))  # 0

“Behavior” includes more than return values

The example returns a number, so its promise is small. Other functions also write files, send messages, update balances, or throw particular exceptions. Returning the same value after sending two emails instead of one would not preserve behavior.

Decide which observations matter before editing. For an email operation, a useful test could check the recipient, message, and number of sends. For a file reader, it could check what happens when the file does not exist. A function that reads an input twice may also behave differently when the input changes between reads.

Tests protect the observations they actually make. They do not make every untested detail safe to change. Small edits and a clear account of the original behavior still matter.

There is no rule against intentionally changing behavior. A new free-shipping threshold might be a perfectly reasonable requirement. Just name that as a rule change, update its tests deliberately, and keep it separate from the edit that merely reorganizes the code.

Sources

  1. Martin Fowler — Refactoring, on preserving observable behavior. The threshold example is an original demonstration of a regression.
  2. Microsoft — Unit-testing best practices, on tests that clearly express expected behavior.