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)); // 0Fee 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 feeprint(shipping_fee(50)) # 0shipping_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 boundarydef shipping_fee(subtotal):
if subtotal > 50:
return 0
return 5print(shipping_fee(50)) # 5: wrong at the boundaryPurchases 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
| Subtotal | Required shipping fee | Why test it? |
|---|---|---|
| 49 | 5 | Immediately below the threshold |
| 50 | 0 | Exactly at the threshold |
| 51 | 0 | Immediately 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)); // 0def shipping_fee(subtotal):
if subtotal >= 50:
return 0
return 5print(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
- Martin Fowler — Refactoring, on preserving observable behavior. The threshold example is an original demonstration of a regression.
- Microsoft — Unit-testing best practices, on tests that clearly express expected behavior.