Articles
Refactoring Interview Practice: Preserve Behavior While Requirements Change
Practice refactoring interviews with behavior-preserving steps, differential tests, changing requirements, and clear decisions about abstractions.
A refactoring interview can go wrong even when the rewritten code looks cleaner. Changing discount order, exception timing, or the number of notifications sent can break a caller while every function name improves. The challenge is to make change easier without quietly changing the existing contract.
Use two separate checkpoints: first preserve the agreed behavior while restructuring; then implement the new requirement and identify exactly which outcomes should differ. Practice that distinction with Fix failing tests and refactor code.
Evidence boundary: Primary refactoring references establish the terminology below. The code, requirements, and interview prompts are original exercises, not reported questions from a particular employer. Suggested decisions are engineering judgment; no candidate report or universal interview scoring rule is implied.
What must stay the same during refactoring?
Primary-source definition: Martin Fowler describes refactoring as changing internal structure while preserving observable behavior. His refactoring overview emphasizes small transformations that keep the program working. That is a more precise objective than making code shorter or introducing more classes.
Preparation advice: Name the observations relevant to the task. They may include the return value, output ordering, exceptions, mutations to arguments, and calls to external dependencies. Two functions that return the same total can still differ if one also modifies the customer's cart.
Do not assume every accidental implementation detail must become a permanent contract. Ask what callers rely on. If the task specifies only successful results for valid inputs, avoid inventing a broad malformed-input policy during cleanup. If clients depend on an exception type, preserve it unless changing it is explicitly part of the requirement.
A useful opening is: “I will preserve totals, error behavior for the stated inputs, and side effects. I will first extract the calculation without changing the order of operations, then add the requested shipping policy.” This tells the interviewer what your tests must establish.
Start with a behavior inventory, not a rewrite
Consider this original exercise. Inputs are nonnegative integer subtotals in cents and a Boolean membership flag. Members receive a ten-percent discount, rounded down to whole cents. Standard shipping costs 500 cents unless the discounted subtotal is at least 5,000 cents. The function returns the payable total.
def quote_before(subtotal, member):
amount = subtotal
if member:
amount = amount * 90 // 100
if amount < 5000:
amount += 500
return amount
These rules are invented for practice. They do not represent a payment provider's policy. The important detail is that shipping eligibility is evaluated after the discount. Moving that comparison earlier changes behavior.
Before editing, write a few expected results independently. A nonmember subtotal of 4,999 costs 5,499. A nonmember subtotal of 5,000 costs 5,000. A member subtotal of 5,000 becomes 4,500 and then incurs shipping, returning 5,000. That last equality can hide a mistake: the final total matches the original subtotal for a reason you must explain.
Now choose a more revealing boundary. For a member subtotal of 5,555, the discounted amount is 4,999, so the total is 5,499. At 5,556, the discounted amount is 5,000 and the total drops to 5,000. The policy produces a discontinuity. Do not smooth it away while claiming to preserve behavior.
Extract the decision that the next requirement will change
Primary-source technique: Extract Function names a computation behind a function boundary. In this exercise, the valuable boundary is between discount calculation and shipping policy, because the announced requirement concerns shipping.
def discounted_subtotal(subtotal, member):
return subtotal * 90 // 100 if member else subtotal
def standard_shipping(amount):
return 500 if amount < 5000 else 0
def quote_after(subtotal, member):
amount = discounted_subtotal(subtotal, member)
return amount + standard_shipping(amount)
No rule has changed here. The intermediate amount still uses integer arithmetic, and shipping still receives the discounted value. The names expose the decision points so the next modification can be localized.
Preparation advice: Make one structural change and run the existing checks. If the result changes now, the new requirement cannot explain it because you have not implemented that requirement yet. This separation makes failures easier to diagnose than a combined rewrite with several behavioral changes.
An extraction is not automatically useful. A helper that merely renames one arithmetic operator may increase navigation without clarifying a decision. Explain what changes independently and why a named boundary helps the actual follow-up task.
Compare old and new behavior without trusting the wrong oracle
For the preservation phase, the old implementation can serve as a differential reference on valid inputs. Keep independently specified boundary cases as well: agreement between two implementations cannot establish that the original behavior was desirable.
expected_cases = [
(0, False, 500),
(4999, False, 5499),
(5000, False, 5000),
(5000, True, 5000),
(5555, True, 5499),
(5556, True, 5000),
]
for subtotal, member, expected in expected_cases:
assert quote_before(subtotal, member) == expected
assert quote_after(subtotal, member) == expected
for subtotal in range(10001):
for member in (False, True):
assert quote_after(subtotal, member) == quote_before(subtotal, member)
For this article, these checks were executed: six boundary examples and 20,002 old-versus-new comparisons passed. That covers a bounded range, not all possible inputs or future policies.
Original follow-up: What if the original code applies shipping before discount, contrary to the written specification? Report the disagreement. Fixing that defect is a behavior change, even if it produces the intended result. Separate its expected-result update from the structural refactoring so the reviewer can see which difference is deliberate.
Differential tests are especially useful for complicated existing behavior, but they can preserve bugs faithfully. They answer “Did these observations change?” rather than “Is this the right business policy?”
Add the requirement as an explicit behavior change
Now introduce a new requirement: express shipping always costs 900 cents, even when standard shipping would be free. Existing callers must continue to receive standard shipping unless they opt in. Keep the same nonnegative integer input contract.
def quote_with_delivery(subtotal, member, delivery="standard"):
amount = discounted_subtotal(subtotal, member)
if delivery == "standard":
shipping = standard_shipping(amount)
elif delivery == "express":
shipping = 900
else:
raise ValueError("unsupported delivery")
return amount + shipping
This is a feature addition, not just another refactoring. It introduces a new parameter, new express outcomes, and an error for unsupported delivery values. The default preserves the behavior of existing two-argument callers.
| Case | Preserved or new? | Expected outcome |
|---|---|---|
| Nonmember, 4,999 cents, default delivery | Preserved | 5,499 cents |
| Member, 5,556 cents, standard | Preserved | 5,000 cents |
| Member, 5,556 cents, express | New | 5,900 cents |
| Nonmember, 0 cents, express | New | 900 cents under the exercise's stated rule |
| Unsupported delivery name | New | ValueError |
Preparation advice: Test standard delivery against the preserved implementation, but calculate express expectations from the new requirement. Using the old function as the oracle for express delivery would reject the very feature you were asked to add.
The zero-subtotal express case may look strange. That is a reason to clarify a real specification, not permission to invent free shipping inside a refactor. For this exercise, the stated express rule applies regardless of subtotal.
When should you introduce a policy object or strategy?
For two small delivery choices, the explicit branch above is easy to inspect. A strategy interface becomes useful when delivery options have independently changing behavior, external dependencies, or enough complexity to justify separate implementations. Do not add a plugin system merely because the interviewer might eventually request another option.
Primary-source technique: Introduce Parameter Object groups related parameters. That can help when the same group travels through several functions. It does not mean every three-argument function needs a configuration class.
Engineering inference: Let the observed pressure choose the abstraction. If region, service level, and package weight form one delivery request used consistently across calculations, a named request type may clarify the contract. If only one Boolean exists today, an elaborate hierarchy may create more concepts than the task needs.
Explain the trade-off directly: “The branch is enough for the current requirement. If policies gain separate dependencies, I would move those implementations behind a stable pricing interface.” That demonstrates a next step without forcing speculative architecture into the current solution.
Also preserve the public entry point when callers cannot all migrate together. An adapter can accept the old arguments and construct the new representation internally. Changing every caller at once is a migration choice, not a necessary consequence of extracting a calculation.
What changes when the function performs side effects?
Suppose the original quote function also sends a notification. Returning the same number no longer establishes equivalence. A rewrite might send twice, send before validation, or suppress an exception after a send failure.
Primary-source technique: Separate Query from Modifier distinguishes obtaining information from changing state. For this exercise, a pure quote calculation and an explicit notification operation make the boundary easier to test. Existing callers may still need a wrapper that preserves their combined behavior.
Official tool behavior: Python's unittest.mock supports assertions about dependency calls and can simulate exceptions. Use such tools to check the relevant interaction contract, not to assert every internal helper call.
Original test design: Record which notification is sent, how often, and whether invalid input sends anything. Simulate the notification failure and confirm the agreed exception behavior. If the wrapper retries, ask whether duplicate delivery is permitted or independently prevented. A passing numeric assertion says nothing about those effects.
Avoid making the mock the architecture. Tests that require a particular sequence of private helper calls can reject a legitimate refactoring even when every external observation is preserved. Assert interactions that a real caller or external system can observe.
How should you handle a late interview requirement?
A late requirement is a chance to show controlled change. Restate the difference, locate the smallest decision it affects, and identify which existing tests should remain unchanged. Then add a test for the new outcome before changing that decision.
If little time remains, prefer a small explicit extension over a broad redesign. Keep a runnable checkpoint. If the new requirement cannot be finished, show the preserved working implementation and name the incomplete branch rather than leaving all old behavior broken behind unfinished abstractions.
Suggested explanation: “The discount and standard shipping contract is unchanged. Express adds a separate fee rule. I will preserve the existing two-argument call, add the optional delivery choice, and rerun the standard cases alongside the express cases.”
If the interviewer asks for performance improvement instead, state the observable constraint being added. A faster implementation must preserve output and relevant side effects while meeting the new budget. Measure representative workloads when possible; shorter code is not evidence of faster execution.