Refactoring & Testing

Refactoring Practices: Behavior-Preserving Techniques for Leaner Code

Refactoring changes a program’s internal structure without changing what callers, users, or connected systems can observe. The safest way to do it is deliberately small: capture existing behavior, make one structural change, then run the checks that show whether the contract still holds.

Published October 14, 2026

The constraint that makes refactoring useful

A refactor is not simply a rewrite that produces cleaner-looking code. It is a change to the arrangement of code—its names, boundaries, or relationships—while preserving externally observable behavior. That constraint matters most when a function has accumulated business rules that are only partly documented. A tidy replacement that changes a rounding edge case, error message, or call order may be a defect, even if the new implementation seems more reasonable.

Consider an order function that validates items, calculates a discount, applies tax, and formats a receipt. A single block may currently perform every step. Extracting those steps into named methods can make the flow easier to inspect without changing the order of operations. The goal is not to make the code look modern; it is to make the existing decisions easier to see and safer to change later.

That distinction gives a refactoring task a practical boundary. If the requirement is to change which orders qualify for a discount, that is a behavior change. If the requirement is to isolate the discount calculation while keeping the same qualifying rules, that is a refactor. The two kinds of work can happen together, but separating them makes review and diagnosis much easier.

Start with behavior, not an idealized specification

In a well-covered codebase, existing tests may already describe the relevant contract. In older systems, they may not. A method’s actual behavior can include undocumented details: whether a missing address produces an empty string or an exception, whether a percentage is rounded before or after tax, or whether a discount applies to shipping. Users and downstream services can depend on these details whether or not anyone intended them.

When documentation is incomplete, characterization tests record what the current program does. They do not certify that the behavior is correct or desirable. They create a reference point that lets a developer distinguish a structural cleanup from an accidental behavior change.

Suppose an order total is calculated by applying a 10% discount before tax. For a $100 eligible subtotal and 8% tax, the result is $97.20: the discounted subtotal is $90, then tax adds $7.20. If tax is calculated first and the discount is applied to the taxed amount, the result is $97.20 in this particular example too. That coincidence makes it a weak test. To expose the order of operations, include rules that distinguish the paths—such as a discount that excludes shipping, tax rounding to cents, or a threshold based on the pre-tax subtotal. A useful test is designed around a behavior that could actually change, not merely a plausible-looking input.

For example, if the legacy policy is that the discount applies only to merchandise and tax is rounded to two decimal places after applying the discount, a test might assert the exact total for an order with shipping and a fraction-of-a-cent tax result. The assertion should encode the observed result and identify the policy being protected. Avoid copying a large output blindly: a test with a meaningful name and carefully selected values explains why the boundary matters.

A working example: Extract Method

Extract Method moves a coherent group of statements into a new method, then replaces the original statements with a call to it. It is especially useful when a block has a recognizable purpose, appears more than once, or interrupts the reader’s understanding of the surrounding sequence. A successful extraction gives the block a name that says what it does, rather than merely describing its mechanics.

Here is a deliberately compressed example in Java-like syntax. The function mixes validation, pricing, and presentation:

String receipt(Order order) {
    if (order.items().isEmpty()) {
        throw new IllegalArgumentException("Order must contain an item");
    }

    BigDecimal subtotal = sum(order.items());
    BigDecimal discount = order.isMember()
        ? subtotal.multiply(new BigDecimal("0.10"))
        : BigDecimal.ZERO;
    BigDecimal taxable = subtotal.subtract(discount)
        .add(order.shipping());
    BigDecimal tax = taxable.multiply(order.taxRate())
        .setScale(2, RoundingMode.HALF_UP);
    BigDecimal total = taxable.add(tax);

    return "Total: " + total;
}

The first extraction can isolate validation without changing its timing or exception:

String receipt(Order order) {
    validateOrder(order);

    BigDecimal subtotal = sum(order.items());
    BigDecimal discount = order.isMember()
        ? subtotal.multiply(new BigDecimal("0.10"))
        : BigDecimal.ZERO;
    BigDecimal taxable = subtotal.subtract(discount)
        .add(order.shipping());
    BigDecimal tax = taxable.multiply(order.taxRate())
        .setScale(2, RoundingMode.HALF_UP);
    BigDecimal total = taxable.add(tax);

    return "Total: " + total;
}

void validateOrder(Order order) {
    if (order.items().isEmpty()) {
        throw new IllegalArgumentException("Order must contain an item");
    }
}

This change is small, but it gives the validation rule a stable name and a possible home for related checks. The next extraction might be calculateDiscount, followed by calculateTax. Each method should have a clear input and result. If an extraction requires many parameters, reads hidden mutable state, or returns several values through side effects, pause: the code may need a better boundary than a quick method split.

The order of statements is part of the example’s behavior. Moving shipping into the discountable subtotal, changing when rounding occurs, or validating only after calculations would not be a behavior-preserving extraction. Keep the original sequence unless the change is explicitly intended and reviewed as a behavior change.

Tests that protect a refactor

A practical test strategy starts with the narrowest reliable checks and expands outward. Unit tests can cover a pricing calculation; integration tests can verify that the order service uses the right tax configuration; an end-to-end test can establish that a customer sees the expected receipt. No single layer proves everything. The right set depends on where the observable contract lives.

  • Test the boundary cases. Include an empty order, a non-member order, a qualifying member order, shipping, and a value near a rounding boundary when those cases are relevant to the existing rules.
  • Assert meaningful outcomes. Prefer checking the computed total or specified error over asserting private helper calls. Internal call sequences are fragile unless call order itself is part of the contract.
  • Use a regression test for a discovered bug. If investigation shows the current behavior is wrong, write a test for the desired behavior and make that correction a distinct change from structural refactoring.
  • Run the suite after each meaningful step. A failing test immediately after one extraction is easier to diagnose than a failure after a dozen edits.

Tests can also overstate certainty. A characterization test that freezes every incidental detail may make future correction harder. For instance, asserting the exact wording of an internal log line is usually noise unless another system parses it or support workflows rely on it. Record externally relevant behavior and known legacy constraints; do not turn every implementation accident into a permanent contract.

Reduce duplication without inventing a framework

Repeated code is a signal to investigate, not an automatic instruction to create an abstraction. Two blocks may look similar but encode different policies. Conversely, the same rule may be duplicated in methods that have drifted just enough to hide the shared intent. Before extracting a common method, compare the rules, inputs, outputs, and likely reasons for change.

If two services both calculate a member discount, a shared method may be appropriate when they must follow the same policy. A helper that accepts six flags to support unrelated variations is a warning sign: it centralizes syntax while making behavior harder to understand. In that case, separate methods with explicit names may be leaner than a single configurable utility.

A useful sequence is to extract the repeated behavior in one location, run tests, then replace the next copy. This preserves a clear checkpoint. If the copies differ, name that difference and decide whether it represents a genuine business rule or accidental drift before consolidating them.

Another common opportunity is a conditional block that obscures a domain decision. Extracting isEligibleForDiscount(order) can make a caller read like a short policy statement. But the method should not simply rename an unreadable expression while hiding new complexity inside. Its value comes from giving a stable concept a precise name and a focused implementation.

A safe sequence for a working codebase

Refactoring is easier to review when the change has a narrow scope and a visible stopping point. A useful workflow looks like this:

  1. Choose one pain point. Identify the specific difficulty: duplicated policy, a mixed-responsibility function, unclear branching, or a hard-to-test dependency. Avoid combining unrelated cleanup with feature work.
  2. Find the observable contract. Read callers, tests, API responses, persistence behavior, and relevant operational logs. Ask which outputs or failure modes other code can observe.
  3. Add or improve characterization tests. Capture important behavior before moving code. Include the boundary case most likely to expose a mistaken assumption.
  4. Make one structural change. Extract a method, rename a local variable, or move a responsibility. Keep the diff small enough to review against the original behavior.
  5. Run targeted and broader checks. Start with the relevant tests, then run the project’s full suite and static checks as appropriate.
  6. Review the diff as a behavior audit. Look for changed operators, altered rounding, reordered calls, swallowed exceptions, new default values, and modified error messages.
  7. Stop when the problem is solved. A refactor does not need to redesign nearby modules just because the opportunity is visible.

This sequence is not ceremony for its own sake. It limits the number of possible causes when something fails. If a test breaks after one extraction, the likely defect is close to the change. If a wide cleanup and a feature land together, reviewers have to disentangle intent from incidental movement.

When tests are missing or expensive

Some legacy code is difficult to test because it reads the system clock, accesses a database directly, or calls an external service from inside a large function. Rewriting the whole method to make it testable can create more risk than the original cleanup. Instead, introduce a narrow seam: a parameter, interface, or small wrapper that lets a test control one dependency while leaving the rest of the behavior intact.

For example, if a receipt includes today’s date by calling the system clock directly, passing a clock dependency can make date-sensitive tests deterministic. That is a structural change with a limited purpose. Avoid building a generalized time framework if the application has one date-sensitive path. The smallest useful seam is usually the best first move.

When automated tests remain impractical, use other evidence: compare representative outputs before and after, exercise a staging workflow, inspect database writes, and have a domain owner confirm critical cases. These checks do not replace tests, but they can reduce risk while the team improves testability incrementally. Document what was and was not verified rather than implying that a green build covers untested behavior.

Common refactoring traps

Changing behavior under the label of cleanup. Replacing an odd rule with a sensible one may be a good product change, but it should be explicit. Separate it from a structural refactor or clearly identify both intentions in the change review.

Extracting methods that do not clarify anything. A method named processData or handleResult may merely move complexity out of sight. Prefer a name that states a domain decision or concrete operation. If the name needs a paragraph of explanation, the boundary may be wrong.

Refactoring without a stopping criterion. One cleanup can lead to a redesign of the entire module. Set a local objective—such as isolating discount policy—and stop when the code is easier to change for that reason.

Making a large mechanical change by hand. Rename and move operations can be safer with reliable IDE refactoring support, but automation does not validate behavior. Review the diff and run tests even when the tool performs the edit.

Trusting tests that mirror the implementation. A test that repeats the same calculation formula as production code can reproduce the same mistake. Use independently chosen examples and expected results derived from the observed contract or domain rule.

Keep the diff understandable

A reviewer needs to tell what changed structurally and whether behavior moved. Avoid mixing a method extraction with wholesale formatting, unrelated renaming, dependency upgrades, and policy changes. If formatting is necessary, keep it separate where practical. A focused pull request lets the reviewer compare old and new control flow instead of searching through noise.

Describe the refactor in terms of the problem and its invariant: “Extracted tax calculation so the receipt flow exposes discount-before-tax order; output unchanged.” If a test captures a surprising legacy rule, say so. That small explanation helps the next engineer understand why a seemingly awkward expression remains.

For a code review, ask three direct questions: Can the original behavior be traced through the new structure? Do method names reveal decisions rather than conceal them? Do tests cover the boundary most likely to change accidentally? Those questions are more useful than counting lines removed. A shorter diff is not inherently a better design if it compresses policy into an opaque expression.

Measure the result by future change

Leaner code is not code with the fewest characters or methods. It is code that makes the next relevant change cheaper and less risky. After extracting validation and pricing, can a developer locate the discount rule without rereading receipt formatting? Can a test exercise tax rounding without constructing an entire application? Can a reviewer see whether the order of calculations changed?

These are concrete measures of improvement. A refactor earns its keep when it reduces the mental work required to understand a behavior, isolates a reason to change, or gives tests a reliable point of control. If it adds layers without making any of those tasks easier, the abstraction may be surplus structure.

Behavior-preserving refactoring is therefore a discipline of restraint. Characterize the behavior that matters, change one structural detail at a time, and verify the result at the boundaries the system actually exposes. Extract Method is a useful tool, but its value comes from a clear boundary, a precise name, and evidence that the code still does what its callers rely on.

Theme