DEV Community

Dakota Huang
Dakota Huang

Posted on

Falsy Inputs Are Three Contracts, Not One Helper

Empty-case refactors fail for a clear mechanical reason. None, empty lists, and missing keys diverge in messy modules.

Pin those three contracts with characterization tests first. Extract one normalizer only after every pin stays green.

Why falsy collapse shows up in review

Messy modules often treat every falsy value as one branch. A later helper then writes labels or [].

That collapse changes downstream payloads without a compile error. Callers that sent None stop matching callers that omitted the key.

Characterization tests record current behavior with executable assertions. They do not bless that behavior as intended design. They block silent drift during a later helper extract.

The messy function under test

Treat the listing as an unexecuted teaching example. Do not read it as measured production telemetry.

The function mutates a payload dictionary in place. It logs a stable event name for each empty path.

# examples/messy_labels.py
from __future__ import annotations

import logging
from typing import Any

log = logging.getLogger("labels")


def apply_labels(
    payload: dict[str, Any], default_team: str = "ops"
) -> dict[str, Any]:
    if "labels" not in payload:
        payload["labels"] = [default_team]
        payload["labels_source"] = "default"
        log.info("labels.missing")
        return payload

    labels = payload["labels"]
    if labels is None:
        payload["labels"] = None
        payload["labels_source"] = "explicit_null"
        log.info("labels.null")
        return payload

    if labels == []:
        payload["labels"] = []
        payload["labels_source"] = "explicit_empty"
        log.info("labels.empty")
        return payload

    if not isinstance(labels, list):
        raise TypeError("labels must be a list, null, or omitted")

    cleaned = []
    for item in labels:
        if not isinstance(item, str):
            raise TypeError("label items must be str")
        text = item.strip()
        if text:
            cleaned.append(text.lower())
    payload["labels"] = cleaned
    payload["labels_source"] = "provided"
    log.info("labels.provided count=%s", len(cleaned))
    return payload
Enter fullscreen mode Exit fullscreen mode

Three empty-like inputs take three distinct code paths. A fourth path rejects wrong container types with TypeError. Do not merge those paths during the first cleanup.

Decision table: record before you extract

Build a table from the current module behavior. Fill every cell from live function calls. Do not fill cells from memory or review notes.

Input shape labels after call labels_source Log fragment Exception
missing key ["ops"] default labels.missing none
"labels": None None explicit_null labels.null none
"labels": [] [] explicit_empty labels.empty none
"labels": [" A "] ["a"] provided labels.provided none
"labels": "ops" n/a n/a n/a TypeError

This table is the reusable artifact for the extract. Keep it in the same commit as the tests. Update the table and tests together later.

What a collapsed helper looks like

Here is the extract that usually lands in review. It looks smaller, but it changes the public contract.

# unsafe extract — do not apply during cleanup
def apply_labels(payload, default_team="ops"):
    labels = payload.get("labels") or []
    cleaned = [item.strip().lower() for item in labels if item.strip()]
    payload["labels"] = cleaned or [default_team]
    payload["labels_source"] = "provided"
    return payload
Enter fullscreen mode Exit fullscreen mode

payload.get hides a missing key as None. The or operator then turns None into []. Missing, null, and empty now share one path.

JSON callers can still tell those states apart. A database null and an empty array are not aliases. Characterization tests exist to keep that distinction visible.

Step 1 — Snapshot return shape and logs

Create a focused test module before any extract. Do not open a helper-move patch yet. Capture payload keys, object identity, and log records.

# tests/test_apply_labels_char.py
from __future__ import annotations

import logging
import pytest

from examples.messy_labels import apply_labels


@pytest.mark.parametrize(
    "payload, labels, source, fragment",
    [
        ({"id": 1}, ["ops"], "default", "labels.missing"),
        ({"id": 1, "labels": None}, None, "explicit_null", "labels.null"),
        ({"id": 1, "labels": []}, [], "explicit_empty", "labels.empty"),
        (
            {"id": 1, "labels": [" A ", "b"]},
            ["a", "b"],
            "provided",
            "labels.provided",
        ),
    ],
)
def test_empty_and_present_contracts(payload, labels, source, fragment, caplog):
    caplog.set_level(logging.INFO, logger="labels")
    result = apply_labels(payload)
    assert result is payload
    assert result["labels"] == labels
    assert result["labels_source"] == source
    assert fragment in caplog.text


def test_non_list_labels_raise_typeerror():
    with pytest.raises(TypeError, match="labels must be a list"):
        apply_labels({"labels": "ops"})


def test_non_str_item_raises_typeerror():
    with pytest.raises(TypeError, match="label items must be str"):
        apply_labels({"labels": [1]})
Enter fullscreen mode Exit fullscreen mode

These tests freeze mutation and return identity together. They also freeze exception class and a message fragment.

Step 2 — Run the pins in isolation

Use a narrow pytest invocation for the first pass. Avoid the full suite until the pins are green.

python -m pytest tests/test_apply_labels_char.py -q --tb=short
Enter fullscreen mode Exit fullscreen mode

The example suite should report six passing tests. Four rows come from parametrized empty and present cases.

Two tests cover TypeError on wrong shapes. If a row fails, the decision table is wrong. Fix the table before any helper extract.

Step 3 — Add one negative pin for collapse

The dangerous extract collapses None into an empty list. Guard that collapse with two extra characterization tests now.

def test_none_is_not_coerced_to_empty_list():
    result = apply_labels({"labels": None})
    assert result["labels"] is None
    assert result["labels"] != []
    assert result["labels_source"] == "explicit_null"


def test_missing_key_is_not_none():
    result = apply_labels({"id": 9})
    assert result["labels"] == ["ops"]
    assert result["labels_source"] != "explicit_null"
    assert "labels" in result
Enter fullscreen mode Exit fullscreen mode

These tests encode the decision table as assertions. They fail if a helper uses Python truthiness. They also fail if dict.get hides a missing key.

Add one more pin for in-place mutation. Reviewers miss this when they only check equality.

def test_returns_the_same_dict_object():
    payload = {"labels": ["X"]}
    result = apply_labels(payload)
    assert result is payload
    assert payload["labels"] == ["x"]
Enter fullscreen mode Exit fullscreen mode

Step 4 — Make the smallest safe change

Extract only the list-cleaning loop in this change. Leave the three empty branches in the original function. Do not move logging calls in the same patch.

def _clean_label_items(labels: list[object]) -> list[str]:
    cleaned: list[str] = []
    for item in labels:
        if not isinstance(item, str):
            raise TypeError("label items must be str")
        text = item.strip()
        if text:
            cleaned.append(text.lower())
    return cleaned
Enter fullscreen mode Exit fullscreen mode

Wire it in one call site. Keep the empty-case branches untouched.

    payload["labels"] = _clean_label_items(labels)
    payload["labels_source"] = "provided"
    log.info("labels.provided count=%s", len(payload["labels"]))
    return payload
Enter fullscreen mode Exit fullscreen mode

Re-run the characterization file after the single extract. Every pin must remain green on the same assertions.

python -m pytest tests/test_apply_labels_char.py -q --tb=short
Enter fullscreen mode Exit fullscreen mode

One helper is enough for this commit. Keep None behavior identical to the recorded table. Keep empty-list behavior identical to the recorded table.

Step 5 — Keep table edits and extracts in separate commits

Do not mix table edits with helper extracts. Bisection needs one behavior change per commit.

  1. Add the decision table and characterization tests.
  2. Commit that snapshot with no production diff.
  3. Extract _clean_label_items only.
  4. Re-run tests/test_apply_labels_char.py.
  5. Commit the helper only after the pins stay green.

Sometimes the recorded None path is an old defect. Do not fix it inside the extract commit.

Open a second change with a new specification. Update the table, tests, and code in that order.

Where a free coding model can enter

A coding model may draft the helper after pins exist. It should not invent a new empty-case policy.

Paste the decision table into the prompt body. Paste failing tests if a draft collapses branches.

MonkeyCode offers free model access and a free server option. Disclosure: This article was prepared as part of MonkeyCode's product outreach.

Run the same pytest file on your own machine. Treat model output as a patch candidate, not a spec.

Keep this pytest file as the merge gate after any drafted extract.

Limitations

Characterization tests freeze today's quirks. They will protect a bug you later want gone.

When policy must change, rewrite the table first. Then change tests to the new spec. Then change production code.

Log-fragment pins are brittle under wording edits. Prefer stable event names over full sentences. Message substrings in pytest.raises have the same fragility.

This workflow does not prove thread safety. Shared payload mutation remains a residual risk. It also does not prove JSON encoding across languages.

None and [] can still diverge after serialization. Add transport tests if the payload leaves Python. Do not assume one runtime's truthiness is the contract.

Model drafts may still collapse falsy branches. The tests remain the control surface. Without them, the extract is guesswork.

Who should not use this approach

Skip this when you have no test runner. Skip this during a production incident hotfix.

Skip this when None and [] must become one public contract today. In that case, write specification tests first. Do not characterize a policy you intend to delete.

Skip this for generated protobuf defaults without reading the schema. Missing fields and explicit empties follow the schema. They do not follow Python truthiness.

Merge checklist

  1. Decision table committed beside the tests.
  2. Parametrized empty-case pins are green.
  3. TypeError class and message fragment are pinned.
  4. Extract covers only the non-empty list path.
  5. Characterization file is green on the extract commit.

If any item is false, do not merge the helper. Restore the function body and re-read the table. Repeat from Step 1 with a smaller diff.

Empty-case bugs are cheap to pin before a helper ships. Record the three contracts, then extract one loop.

Top comments (0)