DEV Community

Jordan Li
Jordan Li

Posted on

Property-Based Testing Without the Property: Generating Invariants with a Free Model

You inherit a function that classifies network errors, and the only test is a fixture with three old examples. You refactor it to use an enum, all tests pass, then a production alert fires for a status code you never mapped. The failure is not a bug in your code. The failure is that you had no definition of the system's contract beyond a few hand-picked samples.

Property-based testing fixes that by checking rules that must hold for every input, not just the ones you guessed. But there is a catch most tutorials skip: writing those properties is the hard part. You need to know what the function is supposed to guarantee. For legacy code, nobody wrote that down. A free hosted LLM can draft candidate invariants directly from your source, and a free server can run them long enough to be meaningful. That combination is what I have been using for the past week, and this article walks through the complete workflow.

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

Step 1: Generate Candidate Invariants from Source

My target function translates an HTTP status code into a user-facing severity string: "critical", "retryable", or "ok". The legacy implementation had a long chain of if statements with a few suspicious fallthroughs. I wanted to refactor it to a lookup table, but I needed to prove the mapping stayed identical for all 100+ status codes.

Writing that proof by hand means listing every code, which is exactly the kind of exhaustive enumeration that property tests are supposed to avoid. Instead, I asked MonkeyCode's free model to read the function and propose invariants:

Here is a Python function that takes an HTTP status code (int) and
returns a severity string. Read the source and propose 8-10 properties
that any correct implementation should satisfy. For each property,
write it as an assertion that can be used with Hypothesis.

Source:
<PASTE THE FUNCTION>
Enter fullscreen mode Exit fullscreen mode

The output was surprisingly sharp. It suggested, among others:

  • The result is always one of the three known strings.
  • Every status code in the 500-599 range maps to "critical".
  • Redirect codes (300-399) map to "ok" only when allow_redirects is true (the function also took a boolean argument).
  • Inputs outside 100-599 raise ValueError.

That last property was the gold. The original tests never checked invalid inputs, and the refactor I had prepared would have allowed 999 to fall through to "ok" silently.

Step 2: Encode the Properties with Hypothesis

I converted the model's suggestions into a Hypothesis test module. The key is to treat the generated properties as a starting point, not gospel. I kept the ones that were clearly semantic and dropped the ones that merely restated the implementation.

Here is the resulting test_properties.py:

import hypothesis.strategies as st
from hypothesis import given, assume

from severity import severity_from_code

VALID_CODES = st.integers(min_value=100, max_value=599)

@given(st.integers())
def test_always_returns_known_string(code):
    result = severity_from_code(code, allow_redirects=False)
    assert result in {"critical", "retryable", "ok"}

@given(st.integers(min_value=100, max_value=599))
def test_5xx_always_critical(code):
    assume(500 <= code <= 599)
    assert severity_from_code(code, allow_redirects=False) == "critical"

@given(st.integers(min_value=100, max_value=599))
def test_4xx_never_ok(code):
    assume(400 <= code <= 499)
    assert severity_from_code(code, allow_redirects=False) != "ok"

@given(st.integers())
def test_invalid_code_raises(code):
    assume(code < 100 or code > 599)
    try:
        severity_from_code(code, allow_redirects=False)
    except ValueError:
        pass
    else:
        raise AssertionError(f"{code} should be rejected")
Enter fullscreen mode Exit fullscreen mode

The first property looks weak, but it catches a function that accidentally returns None. The invalid-code property catches silent fallthroughs, which was the exact regression I was about to introduce.

Step 3: Run Long Batches on a Free Server

Hypothesis is only as powerful as the number of examples it can explore. Running five minutes locally is a start, but a realistic confidence boost comes from letting it run for an hour against a large database of edge cases. That is why I moved the suite to MonkeyCode's free server.

The free server option gives you a persistent environment where you can install dependencies, define a cron job, and collect results in the morning. My setup is deliberately minimal: a shell script that runs pytest and appends a status line to a log file, scheduled every two hours.

#!/usr/bin/env bash
cd /home/user/severity-project
source .venv/bin/activate
pytest test_properties.py --quiet --maxfail=1 >> property_run.log 2>&1
echo "run completed at $(date --iso-8601=seconds)" >> property_run.log
Enter fullscreen mode Exit fullscreen mode

I used the free models on the same platform to also generate a larger corpus of status-code strings that mix whitespace, leading zeros, and negative signs, because the property tests accept integers directly but my real API receives strings. That led to another small property: the parsing layer should reject "0080" even though 80 is a valid code. The model noticed that int("0080") == 80 and the original parser allowed it, which violated the HTTP spec.

Decision Table: When to Trust Generated Properties

Generated property Trust level Why
Output belongs to a known enum High Usually visible from type hints or docstrings
Specific ranges map to specific outputs Medium May reflect intended behavior, but verify against spec
Invalid inputs raise exceptions High Best signal for catching silent fallthroughs
A property exactly mirrors the implementation's branches Low Tautological, gives no safety to the refactor
Cross-kind relationship (e.g., redirect + allow_redirects) Medium Needs human review to confirm business logic

Limitations and Who Should Not Use This

Generated invariants are only as good as the source you paste. If the original function is so tangled that even a human cannot articulate the contract, the model will produce either tautologies or hallucinations. Review each property before committing; I deleted two that would have failed on the old implementation simply because they described the new one.

This technique also does not replace integration testing. A property test proves the function behaves consistently, but it does not prove the system handles a downstream database timeout. Keep separate smoke tests for that.

Finally, free-tier quotas are real. Generating a few hundred prompt tokens per function is trivial, but if you try to generate properties for a monorepo with thousands of functions, you will exhaust the daily allowance quickly. Scope the technique to the functions you are about to refactor, not the entire codebase.

Putting It Together

The full loop looks like this: paste a legacy function into MonkeyCode's free model, get a list of candidate invariants, filter them, encode them as Hypothesis properties, push the test module to a repository, let the free server run the suite on a schedule, and review the log for failures. The whole effort takes about an hour for one function and pays off every time an AI assistant or a human refactor touches that code later.

I now start every refactor with this step, and it has caught two regressions that unit tests with hand-picked inputs never would have seen. If you want to try the same pattern without spending money on cloud time, MonkeyCode's free models and free server are a reasonable place to begin. The method matters more than the platform, but having both in one place does remove a few excuses.

Top comments (0)