DEV Community

Dakota Huang
Dakota Huang

Posted on

Your Cache Header Is a Contract. Characterize It Before You Refactor

Your Last-Modified header is a contract. Browsers and proxies cache against it. A refactor that changes one byte can serve stale content for days. Characterization tests catch that before deploy. Here is a reproducible workflow.

Here is a minimal legacy parser. It takes a raw HTTP date and returns a header value. It also has a quiet bug.

import re
from datetime import datetime, timezone

def legacy_last_modified(raw: str) -> str:
    if not raw:
        return '0'
    match = re.search(r'[0-9]{2} [A-Z][a-z]{2} [0-9]{4} [0-9]{2}:[0-9]{2}:[0-9]{2}', raw)
    if not match:
        return '0'
    dt = datetime.strptime(match.group(0), '%d %b %Y %H:%M:%S')
    dt = dt.replace(tzinfo=timezone.utc)
    return dt.strftime('%a, %d %b %Y %H:%M:%S GMT')
Enter fullscreen mode Exit fullscreen mode

The regex pulls out the first date-like substring. It drops everything after seconds, including timezone offsets. The function treats all times as UTC. That hidden assumption will surface during a refactor.

Step 1: Write a characterization seed

A characterization test locks the current behavior. It does not say what should happen. It says what does happen.

cases = [
    ('Wed, 21 Oct 2026 07:28:00 GMT', 'Wed, 21 Oct 2026 07:28:00 GMT'),
    ('', '0'),
    ('invalid', '0'),
    ('Wed, 21 Oct 2026 09:28:00 +0200', 'Wed, 21 Oct 2026 09:28:00 GMT'),
]

import pytest

@pytest.mark.parametrize('raw,expected', cases)
def test_legacy_last_modified(raw, expected):
    assert legacy_last_modified(raw) == expected
Enter fullscreen mode Exit fullscreen mode

Run it against the old function. Every case passes. You now have a reproducible baseline.

Step 2: Write the modern implementation

The standard library gives you email.utils. It parses HTTP dates correctly, including offsets.

from email.utils import parsedate_to_datetime, format_datetime

def modern_last_modified(raw: str) -> str:
    if not raw:
        return '0'
    try:
        dt = parsedate_to_datetime(raw)
    except (TypeError, ValueError):
        return '0'
    return format_datetime(dt, usegmt=True)
Enter fullscreen mode Exit fullscreen mode

In your test file, replace the import of legacy_last_modified with modern_last_modified. Rerun the same parametrized test. One case fails.

FAILED test_legacy.py::test_legacy_last_modified[Wed, 21 Oct 2026 09:28:00 +0200]
AssertionError: assert 'Wed, 21 Oct 2026 07:28:00 GMT' == 'Wed, 21 Oct 2026 09:28:00 GMT'
Enter fullscreen mode Exit fullscreen mode

The modern version returns 07:28:00 GMT. The legacy version returned 09:28:00 GMT. The old code ignored +0200. Now you have a visible behavior change.

Step 3: Decide, don't drift

This is the key moment. Most refactors fail here by moving the invisible bug forward.

You have three options.

  1. Keep legacy behavior exactly.
  2. Accept the fix and update the test.
  3. Reject the modern implementation.

Any option is fine as long as it is intentional. Write the decision into a comment or commit message. Do not let the test failure slip into a fix it later pile.

For this example, option two is correct. The legacy behavior was wrong. But the decision must be explicit.

cases = [
    ('Wed, 21 Oct 2026 07:28:00 GMT', 'Wed, 21 Oct 2026 07:28:00 GMT'),
    ('', '0'),
    ('invalid', '0'),
    ('Wed, 21 Oct 2026 09:28:00 +0200', 'Wed, 21 Oct 2026 07:28:00 GMT'),
]
Enter fullscreen mode Exit fullscreen mode

The change is only the last expectation. Now the test suite asserts the corrected behavior. Reviewers can see the exact semantic change in a one-line diff.

Step 4: Split the change into two commits

Commit one adds the characterization tests against the legacy function. Commit two swaps in the modern implementation and updates expectations. Reviewers can see exactly what changed.

This follows the smallest safe refactor rule: lock first, then change. Each stage can be reverted independently. If commit two breaks production, you can restore the old function without rewriting tests.

Why characterization beats guessing

You cannot safely review a refactor you do not understand. Characterization tests convert hidden assumptions into executable assertions. The diff becomes the explanation.

This technique scales beyond cache headers. Use it on any pure function that parses, formats, or transforms input. Start with five cases. Add more when you find a weird string in the logs.

Where a free model fits (honestly)

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

MonkeyCode's free model access is relevant to this workflow in one narrow way: generating a first draft of the cases list. The model suggests candidates like empty strings and malformed headers. You then run every candidate against the legacy function. The test suite, not the model, defines the baseline.

MonkeyCode's free server option is available as an alternative to running this workflow locally. That is all you need to reproduce the pattern.

Notice what the model should not do. It should not choose the correct behavior. It should not decide whether +0200 is a bug. Those are human decisions. Tools widen your options; they do not replace your judgment.

Limitations

Characterization tests document present behavior, not ideal behavior. If the legacy function is deeply wrong, the tests enshrine that wrongness. You still need a human to review each expectation.

This technique also works best with pure functions. When the legacy code touches files, databases, or clocks, isolate those side effects first. Otherwise your baseline is timing dependent.

Who should not use this? If you are about to delete the code, skip the tests. If you are writing a greenfield service, write behavior tests instead. Characterization is for code you must keep.

Conclusion

Your cache header is a contract. Treat it that way. Before you refactor, write one characterization test. Run it. Then change behavior knowingly. That is the smallest safe refactor.

Next time you open a legacy function, start there. Write one test. Lock the current behavior. Then decide what the next behavior should be.

Top comments (0)