Skip to content

Commit 8863bbe

Browse files
authored
Add property-based tests for JSON serialization, records, and state (#11)
* chore(release): v0.1.0a4 * test(properties): add property-based tests for JSON, records, and state Add comprehensive property-based tests using Hypothesis to verify: - JSON serialization roundtrip (serialize -> deserialize preserves data) - JSON serialization determinism (consistent output for identical data) - Record validation with generated valid/invalid records - Key extraction correctness for scalar and compound keys - Tombstone detection and construction roundtrip - State computation with random sequences of put/delete operations - State invariants (no tombstones in final state, keys match records) Changes: - Create shared strategies.py module with reusable Hypothesis strategies - Rename test_key_comparison.py to test_key_properties.py for consistency - Add test_json_properties.py for serialization tests - Add test_record_properties.py for record validation tests - Add test_state_properties.py for state computation tests
1 parent 7373632 commit 8863bbe

5 files changed

Lines changed: 381 additions & 12 deletions

File tree

tests/properties/strategies.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
"""Hypothesis strategies for JSONLT property-based testing."""
2+
3+
from hypothesis import strategies as st
4+
5+
from jsonlt._constants import MAX_INTEGER_KEY, MAX_TUPLE_ELEMENTS, MIN_INTEGER_KEY
6+
7+
# Key-related strategies (migrated from test_key_comparison.py)
8+
key_element_strategy = st.one_of(
9+
st.text(),
10+
st.integers(min_value=MIN_INTEGER_KEY, max_value=MAX_INTEGER_KEY),
11+
)
12+
13+
key_strategy = st.one_of(
14+
st.text(),
15+
st.integers(min_value=MIN_INTEGER_KEY, max_value=MAX_INTEGER_KEY),
16+
st.tuples(*[key_element_strategy] * 1),
17+
st.tuples(*[key_element_strategy] * 2),
18+
st.lists(key_element_strategy, min_size=1, max_size=MAX_TUPLE_ELEMENTS).map(tuple),
19+
)
20+
21+
# JSON primitive strategy
22+
json_primitive_strategy = st.one_of(
23+
st.none(),
24+
st.booleans(),
25+
st.integers(),
26+
st.floats(allow_nan=False, allow_infinity=False),
27+
st.text(),
28+
)
29+
30+
# JSON value strategy (recursive, bounded depth)
31+
# Use st.recursive to generate nested structures
32+
json_value_strategy = st.recursive(
33+
json_primitive_strategy,
34+
lambda children: st.one_of(
35+
st.lists(children, max_size=5),
36+
st.dictionaries(st.text(max_size=20), children, max_size=5),
37+
),
38+
max_leaves=50,
39+
)
40+
41+
# JSON object strategy (for records)
42+
json_object_strategy = st.dictionaries(
43+
st.text(max_size=20).filter(
44+
lambda s: not s.startswith("$")
45+
), # No $-prefixed fields
46+
json_value_strategy,
47+
max_size=10,
48+
)
49+
50+
# Field name strategy (no $-prefix for valid records)
51+
field_name_strategy = st.text(min_size=1, max_size=20).filter(
52+
lambda s: not s.startswith("$")
53+
)
54+
55+
# Key specifier strategy
56+
scalar_key_specifier_strategy = field_name_strategy
57+
tuple_key_specifier_strategy = (
58+
st.lists(field_name_strategy, min_size=2, max_size=5)
59+
.filter(lambda fields: len(fields) == len(set(fields)))
60+
.map(tuple)
61+
)
62+
key_specifier_strategy = st.one_of(
63+
scalar_key_specifier_strategy,
64+
tuple_key_specifier_strategy,
65+
)
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
"""Property-based tests for JSON serialization and parsing."""
2+
3+
import json
4+
from typing import TYPE_CHECKING
5+
6+
from hypothesis import given
7+
8+
from jsonlt._json import parse_json_line, serialize_json
9+
10+
from .strategies import json_object_strategy
11+
12+
if TYPE_CHECKING:
13+
from jsonlt._json import JSONObject
14+
15+
16+
class TestSerializationRoundtrip:
17+
"""Serialize then parse produces equivalent data."""
18+
19+
@given(json_object_strategy)
20+
def test_roundtrip_preserves_data(self, obj: "JSONObject") -> None:
21+
"""parse(serialize(obj)) == obj for any valid JSON object."""
22+
serialized = serialize_json(obj)
23+
parsed = parse_json_line(serialized)
24+
assert parsed == obj
25+
26+
@given(json_object_strategy)
27+
def test_serialize_is_deterministic(self, obj: "JSONObject") -> None:
28+
"""serialize(obj) == serialize(obj) always."""
29+
result1 = serialize_json(obj)
30+
result2 = serialize_json(obj)
31+
assert result1 == result2
32+
33+
34+
class TestSerializationProperties:
35+
"""Serialization output format invariants."""
36+
37+
@given(json_object_strategy)
38+
def test_no_extraneous_whitespace(self, obj: "JSONObject") -> None:
39+
"""Output contains no space/newline/tab outside strings."""
40+
serialized = serialize_json(obj)
41+
# Parse to check it's valid JSON
42+
parsed = parse_json_line(serialized)
43+
# Re-serialize and check for equality (no whitespace variation)
44+
reserialized = serialize_json(parsed)
45+
assert serialized == reserialized
46+
47+
@given(json_object_strategy)
48+
def test_valid_json_output(self, obj: "JSONObject") -> None:
49+
"""Output is parseable by standard json.loads."""
50+
serialized = serialize_json(obj)
51+
# Should not raise
52+
json.loads(serialized)
Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,11 @@
1+
"""Property-based tests for key comparison operations."""
2+
13
from hypothesis import given, strategies as st
24

35
from jsonlt._constants import MAX_INTEGER_KEY, MAX_TUPLE_ELEMENTS, MIN_INTEGER_KEY
46
from jsonlt._keys import compare_keys
57

6-
key_element_strategy = st.one_of(
7-
st.text(),
8-
st.integers(min_value=MIN_INTEGER_KEY, max_value=MAX_INTEGER_KEY),
9-
)
10-
11-
key_strategy = st.one_of(
12-
st.text(),
13-
st.integers(min_value=MIN_INTEGER_KEY, max_value=MAX_INTEGER_KEY),
14-
st.tuples(*[key_element_strategy] * 1),
15-
st.tuples(*[key_element_strategy] * 2),
16-
st.lists(key_element_strategy, min_size=1, max_size=MAX_TUPLE_ELEMENTS).map(tuple),
17-
)
8+
from .strategies import key_element_strategy, key_strategy
189

1910

2011
class TestTotalOrderProperties:
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
"""Property-based tests for record validation."""
2+
3+
from typing import TYPE_CHECKING
4+
5+
from hypothesis import given, strategies as st
6+
7+
from jsonlt._records import build_tombstone, extract_key, is_tombstone, validate_record
8+
9+
from .strategies import (
10+
field_name_strategy,
11+
json_value_strategy,
12+
key_element_strategy,
13+
key_specifier_strategy,
14+
scalar_key_specifier_strategy,
15+
tuple_key_specifier_strategy,
16+
)
17+
18+
if TYPE_CHECKING:
19+
from jsonlt._json import JSONObject
20+
21+
22+
class TestValidRecordProperties:
23+
"""Valid records pass validation without exception."""
24+
25+
@given(
26+
scalar_key_specifier_strategy,
27+
key_element_strategy,
28+
st.dictionaries(field_name_strategy, json_value_strategy, max_size=5),
29+
)
30+
def test_valid_scalar_key_record(
31+
self, key_field: str, key_value: str | int, extra_fields: "JSONObject"
32+
) -> None:
33+
"""Records with valid scalar keys pass validation."""
34+
# Build record with key field and extra data
35+
record: JSONObject = {
36+
key_field: key_value,
37+
**{k: v for k, v in extra_fields.items() if k != key_field},
38+
}
39+
validate_record(record, key_field) # Should not raise
40+
41+
@given(tuple_key_specifier_strategy, st.data())
42+
def test_valid_compound_key_record(
43+
self, key_specifier: tuple[str, ...], data: st.DataObject
44+
) -> None:
45+
"""Records with valid compound keys pass validation."""
46+
# Generate a key value for each field in the specifier
47+
record: JSONObject = {}
48+
for field in key_specifier:
49+
record[field] = data.draw(key_element_strategy)
50+
validate_record(record, key_specifier) # Should not raise
51+
52+
53+
class TestExtractKeyProperties:
54+
"""Key extraction invariants."""
55+
56+
@given(scalar_key_specifier_strategy, key_element_strategy)
57+
def test_extracted_scalar_key_matches_field(
58+
self, key_field: str, key_value: str | int
59+
) -> None:
60+
"""Extracted key equals the key field value."""
61+
record: JSONObject = {key_field: key_value}
62+
extracted = extract_key(record, key_field)
63+
assert extracted == key_value
64+
65+
@given(tuple_key_specifier_strategy, st.data())
66+
def test_extracted_compound_key_matches_fields(
67+
self, key_specifier: tuple[str, ...], data: st.DataObject
68+
) -> None:
69+
"""Extracted compound key is tuple of field values."""
70+
record: JSONObject = {}
71+
expected_elements: list[str | int] = []
72+
for field in key_specifier:
73+
value: str | int = data.draw(key_element_strategy)
74+
record[field] = value
75+
expected_elements.append(value)
76+
77+
extracted = extract_key(record, key_specifier)
78+
assert extracted == tuple(expected_elements)
79+
80+
81+
class TestTombstoneProperties:
82+
"""Tombstone detection and construction."""
83+
84+
@given(key_specifier_strategy, st.data())
85+
def test_tombstone_detected(
86+
self, key_specifier: str | tuple[str, ...], data: st.DataObject
87+
) -> None:
88+
"""is_tombstone returns True for tombstones."""
89+
# Build a valid key
90+
if isinstance(key_specifier, str):
91+
key: str | int | tuple[str | int, ...] = data.draw(key_element_strategy)
92+
else:
93+
key = tuple(data.draw(key_element_strategy) for _ in key_specifier)
94+
95+
tombstone = build_tombstone(key, key_specifier)
96+
assert is_tombstone(tombstone) is True
97+
98+
@given(key_specifier_strategy, st.data())
99+
def test_build_tombstone_roundtrip(
100+
self, key_specifier: str | tuple[str, ...], data: st.DataObject
101+
) -> None:
102+
"""extract_key(build_tombstone(key, specifier), specifier) == key."""
103+
# Build a valid key
104+
if isinstance(key_specifier, str):
105+
key: str | int | tuple[str | int, ...] = data.draw(key_element_strategy)
106+
else:
107+
key = tuple(data.draw(key_element_strategy) for _ in key_specifier)
108+
109+
tombstone = build_tombstone(key, key_specifier)
110+
extracted = extract_key(tombstone, key_specifier)
111+
assert extracted == key

0 commit comments

Comments
 (0)