The round-trip property is the most productive property-based test most codebases can add. Anything that converts data between representations — JSON and msgpack encoders, custom binary protocols, CSV writers, URL builders, configuration parsers, ORM field converters — should satisfy decode(encode(x)) == x for every value it claims to support. The property needs no oracle, because the original input is the expected output, and it tends to find real bugs within seconds: characters that are not escaped, numbers that lose precision, nested structures that flatten, empty values that disappear.
The simple version is one line. The value comes from being careful about three things around it: generating the full value space rather than a comfortable subset, deciding explicitly what happens to values the format cannot represent exactly, and testing the reverse direction, where parsing real-world text meets the canonical output of the writer.
Prerequisites
hypothesis >= 6.100,pytest >= 8.0.- Strategy composition from Advanced property-based testing.
Solution
# test_codec.py
from datetime import datetime, timezone
from decimal import Decimal
from hypothesis import given, strategies as st
from myapp.codec import dumps, loads
scalars = (
st.none()
| st.booleans()
| st.integers(-(2**63), 2**63 - 1)
| st.text()
| st.decimals(allow_nan=False, allow_infinity=False, places=4)
| st.datetimes(timezones=st.just(timezone.utc))
)
values = st.recursive(
scalars,
lambda inner: st.lists(inner, max_size=5) | st.dictionaries(st.text(), inner, max_size=5),
max_leaves=25,
)
@given(values)
def test_round_trip(value):
assert loads(dumps(value)) == value
@given(values)
def test_encoding_is_deterministic(value):
assert dumps(value) == dumps(loads(dumps(value)))
Why this works
Serialisation bugs live at the edges of the value space, which is exactly where Hypothesis concentrates its effort. st.text() produces empty strings, strings of control characters, lone surrogates, right-to-left marks and characters outside the Basic Multilingual Plane. st.integers with bounds produces both bounds. st.recursive produces empty containers, deeply nested ones, and dictionaries with keys that look like the format's own syntax. A handwritten encoder that works on {"name": "Ada", "age": 36} often fails on {"": [None, {"\x00": ""}]}, and Hypothesis finds that shape quickly and shrinks the failure to its minimal form.
The second property — re-encoding produces identical bytes — catches a different class of bug. An encoder that iterates a set, or that formats floats differently depending on history, can round-trip correctly while producing different bytes each time, which breaks caching, content hashing and signature verification downstream. Testing byte-stability after one round trip is the standard way to detect it.
Deciding what the format represents
Most real formats are lossy for some values, and the round-trip property forces an explicit decision about each one. That decision is valuable documentation in its own right. Common cases:
- Floats in JSON.
NaNand infinities are not valid JSON;-0.0may come back as0. Either exclude them from the strategy withallow_nan=False, allow_infinity=False, or decide the encoder should reject them and test that it raises. - Tuples versus lists. JSON has no tuple, so
(1, 2)returns as[1, 2]. Generate lists only, or normalise before comparing. - Dictionary keys. JSON keys are strings, so
{1: "a"}returns as{"1": "a"}. Generate string keys, or test that non-string keys raise. - Timezones and precision. Datetimes may lose microseconds or collapse timezones to UTC. Restrict the strategy to what the format stores and document the restriction next to it.
- Decimals. Arbitrary-precision decimals through a float field lose digits. Bound
placesto what the field stores.
When normalising, keep the normaliser trivially simple — json.loads(json.dumps(x)) as the normaliser for a JSON-compatible codec is circular and hides bugs. A small function that converts tuples to lists, and nothing else, keeps the property meaningful.
The reverse direction: parse, then render
decode(encode(x)) == x checks that the writer's output is readable. It does not check that the reader handles text the writer would never produce — extra whitespace, different key order, alternative escapes, comments in a config format. Those arrive from other systems, from humans editing files, and from older versions of your own writer.
The reverse property is encode(decode(s)) == canonical(s), and it needs a strategy for valid encoded text. For simple formats, build text from generated values plus deliberate variation: render a generated value, then inject random whitespace between tokens and permute dictionary keys. For formats with a grammar, hypothesis.extra.lark.from_lark generates strings directly from a Lark grammar, and st.from_regex covers simple token formats.
from hypothesis.extra.lark import from_lark
from myapp.grammar import CONFIG_GRAMMAR
@given(from_lark(CONFIG_GRAMMAR))
def test_parse_then_render_is_stable(text):
rendered = render(parse(text))
assert render(parse(rendered)) == rendered
The comparison there is deliberately weaker than equality with the input: rendering normalises whitespace and ordering, so the property checks that the canonical form is a fixed point, which catches parsers that drop or misplace information.
What a typical first run finds
Adding a round-trip property to an existing codec almost always fails on the first run, and the shape of the failures is predictable enough to be worth knowing in advance. Treat each one as a question about the contract rather than an immediate code change.
The first failure is usually a string. Hypothesis tries the empty string, a string containing the format's delimiter, a string containing a backslash or quote, and a string with characters the encoder assumed would never appear — a null byte, a lone surrogate, a newline in a field that the format treats as a record separator. A CSV writer that forgets to quote fields containing commas, or a hand-rolled escaping routine that handles " but not \, fails within the first few dozen examples. The shrunk counterexample is typically a single character, which makes the fix obvious.
The second failure is often numeric. Large integers pass through a float somewhere and lose their low digits; 2**53 + 1 comes back as 2**53. Decimals are formatted with a fixed number of places and quietly rounded. Negative zero loses its sign. Each of these is either a bug or an undocumented limitation, and the round-trip test forces the choice between fixing the encoder and narrowing the strategy with a comment that says why.
The third failure is structural: an empty list encoded as nothing and decoded as None, a dictionary with a single key flattened into its value, nested containers where the inner one is lost. These are the most serious, because they tend to affect real data silently, and they are the ones example tests are least likely to catch because nobody thinks to write an example with an empty nested list.
Once the first round of failures is fixed or documented, the property usually stays green for a long time, and then fails exactly when someone changes the encoder in a way that breaks a case nobody remembered. That is the long-term value: it guards the whole value space, not the handful of cases someone once thought of. It also makes codec refactors much less frightening: swapping a hand-written encoder for a faster library, or changing the wire format, is safe to attempt when a single property checks every value shape the old code supported, and any difference in behaviour arrives as a shrunk, readable counterexample rather than a production incident.
Edge cases and failure modes
- Testing only the comfortable subset. A strategy of ASCII strings and small integers passes where real data fails. Start from
st.text()and full integer bounds, then narrow only with a reason. - Equality that is too loose.
1 == 1.0 == Truein Python, so a codec turning integers into booleans passes a naive round trip. Comparetype(a) is type(b)too, or comparerepr. - Unbounded recursion.
st.recursivewithoutmax_leavescan generate huge values and slow the suite. Keep the bound small; shrinking finds minimal failures anyway. - Version skew. Round-tripping within one version does not prove old payloads still decode. Keep a corpus of stored payloads from earlier versions and test decoding them separately.
- Performance cliffs. Deeply nested inputs can hit recursion limits in the decoder. Decide the supported depth and test that exceeding it raises a clear error.
Frequently Asked Questions
What is a round-trip property?
A property stating that converting a value to another representation and back yields the original value, such as json.loads(json.dumps(x)) == x. It needs no oracle, because the original input is the expected output.
What if my format is lossy?
Test the round trip on the subset of values the format represents exactly, or compare through a normalising function, or test that a second round trip is stable: encode(decode(encode(x))) == encode(x).
Should I test parse(render(x)) or render(parse(s))? Both, if you can generate valid text. The first checks that every value can be written and read back. The second checks that parsing does not lose information present in real input, and usually needs a canonical-form comparison.
Related
- Advanced Property-Based Testing — property patterns and strategy design.
- Writing Metamorphic Properties — oracle-free relations beyond round-trips.
- Structure-Aware Fuzzing with Atheris and Protobuf — fuzzing parsers with coverage guidance.
- Testing Numeric Code with Floats and NaN Edge Cases — the float values most codecs trip on.
← Back to Advanced Property-Based Testing