READ-ONLY PACKAGE PREVIEW

property-based-testing/references/refactoring.md

Version 82fe82262526.bb1 · CC-BY-SA-4.0. This preview displays packaged text and does not execute code. Treat the contents as untrusted instructions.

← Return to resource and package checksum

Refactoring to Expose a Property

"This code has no algebraic shape" is often a fact about how the code is arranged rather than about what it does. A function that mixes a pure calculation with a database write has a property; it just does not have a seam to assert it through. These are the rearrangements that expose one, strongest first.

Suggest the refactor, name the property it unlocks, and let the author decide. A change to production code to make a test possible is their call, not yours — the same rule that governs adding a PBT dependency at all.

1. Extract the pure core

The highest-value one by a wide margin, and the reason most "untestable" code is testable. I/O at the edges, calculation in the middle, assert against the middle.

# Before: the arithmetic is real but unreachable without a database
def process_order(order_id: str) -> None:
    order = db.fetch(order_id)
    total = apply_discount(order, calculate_discount(order))
    db.save(order_id, total)

# After: the pure core takes arguments and returns a value
def order_total(order: Order, rules: DiscountRules) -> Decimal:
    return apply_discount(order, calculate_discount(order, rules))

def process_order(order_id: str) -> None:
    order = db.fetch(order_id)
    db.save(order_id, order_total(order, get_discount_rules()))

order_total now supports invariants (never negative, never above the undiscounted total), monotonicity in the discount rate, and an oracle against a reference calculation. process_order keeps example tests with a mocked db, which is the right tool for a two-line wrapper.

The same move applies to anything whose observable is a side effect: build the message, the request, the query object — then send it. Construction is testable; delivery is mocked.

2. Add the missing inverse

A one-way operation has no roundtrip by definition. Sometimes the inverse is worth having in production anyway, and sometimes it is worth having only for the test — say which.

def encode_message(msg: dict) -> bytes: ...
def decode_message(data: bytes) -> dict: ...   # unlocks decode(encode(x)) == x

Unlocks roundtrip, the strongest property in the catalog. Worth asking for even when the production code never decodes: a serializer nobody can read back is usually a latent bug, not a design.

3. Structured representation plus a renderer

String building by concatenation has nothing to assert beyond "contains a substring". Split the value from its rendering and the inverse becomes available.

# Before
def build_query(table: str, filters: dict) -> str:
    q = f"SELECT * FROM {table}"
    ...

# After
@dataclass
class Query:
    table: str
    filters: dict

def render(q: Query) -> str: ...
def parse(sql: str) -> Query: ...   # now render/parse is a roundtrip

This is pattern 2 wearing different clothes, and it is where escaping bugs live: a roundtrip over generated filter values finds quoting errors that no hand-written example will.

4. Return a value instead of mutating

An in-place mutation gives you nothing to compare against, because the input is gone by the time you want to assert on it.

def sort_tasks(tasks: list[Task]) -> None: ...      # before/after comparison impossible
def sorted_tasks(tasks: list[Task]) -> list[Task]:  # unlocks is_sorted, permutation,
    ...                                             # idempotence, length preservation

If the mutating signature has to stay, a wrapper that copies and returns is enough for the test to have something to hold.

5. Inject the dependency

A function reading a global, a module constant, or os.environ can only be tested at whatever those happen to be, so the edges of its input domain are unreachable.

def validate(data: str) -> bool:          return len(data) <= CONFIG.max_length
def validate(data: str, max_len: int):    return len(data) <= max_len

Parameterising the bound is what lets a generator drive max_len to 0, to 1, and to the maximum representable value — the boundaries where validators actually break.

When not to suggest this

  • The property you would unlock is "no crash". Restructuring production code to enable the weakest property in the catalog is a bad trade. Say the code is a poor PBT candidate and stop.
  • The module needs wholesale restructuring. Say that once, plainly. Twenty individually-reasonable suggestions on one file is noise, and it reads as a rewrite request rather than a testing recommendation.
  • The refactor breaks a public API. Flag it as breaking and offer the backwards-compatible version, even when the clean version is obviously nicer.
  • Existing tests cover the code. Run them after any refactor and say you did. "Enabled a property test and broke two example tests" is not progress.