Skip to content

Commit bc34fbb

Browse files
mavamclaude
andcommitted
Add pre-compare transforms for non-deterministic test output
Introduces a `pre-compare` frontmatter option that normalizes test output before comparison, allowing tests with non-deterministic ordering to pass reliably. Usage in TQL/shell/Python tests: --- pre-compare: [sort] --- Or in test.yaml for directory-level configuration: pre-compare: [sort] The initial transform is `sort`, which sorts output lines lexicographically. Transforms apply only at comparison time - baselines store original output. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent e859a76 commit bc34fbb

8 files changed

Lines changed: 78 additions & 26 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
title: Add pre-compare transforms for non-deterministic output
3+
type: feature
4+
authors:
5+
- mavam
6+
- claude
7+
created: 2026-01-30T20:46:00.000000Z
8+
---
9+
10+
The test framework now supports pre-compare transforms that normalize output before comparison with baselines. This helps handle tests with non-deterministic output like unordered result sets from hash-based aggregations or parallel operations.
11+
12+
Configure the `pre-compare` option in `test.yaml` or per-test frontmatter to apply transforms to both actual output and baselines before comparison:
13+
14+
```yaml
15+
# Sort output lines for comparison (baseline stays unchanged)
16+
pre-compare: sort
17+
```
18+
19+
The `sort` transform sorts output lines lexicographically, making it easy to handle unordered results. Transforms only affect comparison—baseline files remain untransformed on disk, and `--update` continues to store original output.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
#!/usr/bin/env bash
2+
# pre-compare: sort
3+
4+
# Demonstrate pre-compare transform for handling non-deterministic output.
5+
# This test produces lines in random order, but the sort transform ensures
6+
# comparison succeeds against a sorted baseline.
7+
8+
echo "zebra"
9+
echo "alpha"
10+
echo "charlie"
11+
echo "bravo"
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
alpha
2+
bravo
3+
charlie
4+
zebra

src/tenzir_test/runners/custom_python_fixture_runner.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -135,12 +135,17 @@ def run(self, test: Path, update: bool, coverage: bool = False) -> bool:
135135
return False
136136
run_mod.log_comparison(test, ref_path, mode="comparing")
137137
expected = ref_path.read_bytes()
138-
if expected != output:
138+
pre_compare = typing.cast(
139+
tuple[str, ...], test_config.get("pre_compare", tuple())
140+
)
141+
expected_transformed = run_mod.apply_pre_compare(expected, pre_compare)
142+
output_transformed = run_mod.apply_pre_compare(output, pre_compare)
143+
if expected_transformed != output_transformed:
139144
if run_mod.interrupt_requested():
140145
run_mod.report_interrupted_test(test)
141146
else:
142147
run_mod.report_failure(test, "")
143-
run_mod.print_diff(expected, output, ref_path)
148+
run_mod.print_diff(expected_transformed, output_transformed, ref_path)
144149
return False
145150
finally:
146151
fixture_api.pop_context(context_token)

src/tenzir_test/runners/diff_runner.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ def run(self, test: Path, update: bool, coverage: bool = False) -> bool | str:
6868
raise RuntimeError("TENZIR_BINARY must be configured for diff runners")
6969
base_cmd: list[str] = [*binary, *config_args]
7070

71+
coverage_dir = ""
7172
if coverage:
7273
coverage_dir = env.get(
7374
"CMAKE_COVERAGE_OUTPUT_DIRECTORY",
@@ -111,6 +112,7 @@ def run(self, test: Path, update: bool, coverage: bool = False) -> bool | str:
111112
root_bytes = str(run_mod.ROOT).encode() + b"/"
112113
unoptimized_stdout = unoptimized.stdout.replace(root_bytes, b"")
113114
optimized_stdout = optimized.stdout.replace(root_bytes, b"")
115+
# Generate diff without transforms first
114116
diff_chunks = list(
115117
difflib.diff_bytes(
116118
difflib.unified_diff,
@@ -130,12 +132,15 @@ def run(self, test: Path, update: bool, coverage: bool = False) -> bool | str:
130132
ref_path.write_bytes(diff_bytes)
131133
else:
132134
expected = ref_path.read_bytes()
133-
if diff_bytes != expected:
135+
pre_compare = typing.cast(tuple[str, ...], test_config.get("pre_compare", tuple()))
136+
expected_transformed = run_mod.apply_pre_compare(expected, pre_compare)
137+
actual_transformed = run_mod.apply_pre_compare(diff_bytes, pre_compare)
138+
if actual_transformed != expected_transformed:
134139
if run_mod.interrupt_requested():
135140
run_mod.report_interrupted_test(test)
136141
else:
137142
run_mod.report_failure(test, "")
138-
run_mod.print_diff(expected, diff_bytes, ref_path)
143+
run_mod.print_diff(expected_transformed, actual_transformed, ref_path)
139144
return False
140145
run_mod.success(test)
141146
return True

src/tenzir_test/runners/shell_runner.py

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ def run(self, test: Path, update: bool, coverage: bool = False) -> bool:
125125
run_mod.success(test)
126126
return True
127127

128+
pre_compare = typing.cast(tuple[str, ...], test_config.get("pre_compare", tuple()))
128129
if combined_bytes:
129130
if not stdout_path.exists():
130131
run_mod.report_failure(
@@ -134,22 +135,28 @@ def run(self, test: Path, update: bool, coverage: bool = False) -> bool:
134135
return False
135136
run_mod.log_comparison(test, stdout_path, mode="comparing")
136137
expected_stdout = stdout_path.read_bytes()
137-
if expected_stdout != combined_bytes:
138+
expected_transformed = run_mod.apply_pre_compare(expected_stdout, pre_compare)
139+
actual_transformed = run_mod.apply_pre_compare(combined_bytes, pre_compare)
140+
if expected_transformed != actual_transformed:
138141
if run_mod.interrupt_requested():
139142
run_mod.report_interrupted_test(test)
140143
else:
141144
run_mod.report_failure(test, "")
142-
run_mod.print_diff(expected_stdout, combined_bytes, stdout_path)
145+
run_mod.print_diff(expected_transformed, actual_transformed, stdout_path)
143146
return False
144147
elif stdout_path.exists():
145148
expected_stdout = stdout_path.read_bytes()
149+
# Check if original baseline is empty before transformation
146150
if expected_stdout not in {b"", b"\n"}:
147-
if run_mod.interrupt_requested():
148-
run_mod.report_interrupted_test(test)
149-
else:
150-
run_mod.report_failure(test, "")
151-
run_mod.print_diff(expected_stdout, b"", stdout_path)
152-
return False
151+
expected_transformed = run_mod.apply_pre_compare(expected_stdout, pre_compare)
152+
actual_transformed = run_mod.apply_pre_compare(b"", pre_compare)
153+
if expected_transformed != actual_transformed:
154+
if run_mod.interrupt_requested():
155+
run_mod.report_interrupted_test(test)
156+
else:
157+
run_mod.report_failure(test, "")
158+
run_mod.print_diff(expected_transformed, actual_transformed, stdout_path)
159+
return False
153160

154161
run_mod.success(test)
155162
return True

tests/test_run.py

Lines changed: 12 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1583,20 +1583,18 @@ def test_directory_with_test_yaml_inside_root_is_selector(tmp_path, monkeypatch)
15831583

15841584

15851585
class TestTransformSort:
1586-
def test_empty_input_returns_empty(self):
1587-
assert run._transform_sort(b"") == b""
1588-
1589-
def test_single_line_without_newline(self):
1590-
assert run._transform_sort(b"hello") == b"hello"
1591-
1592-
def test_single_line_with_newline(self):
1593-
assert run._transform_sort(b"hello\n") == b"hello\n"
1594-
1595-
def test_multiple_lines_get_sorted(self):
1596-
assert run._transform_sort(b"zebra\napple\nmango\n") == b"apple\nmango\nzebra\n"
1597-
1598-
def test_duplicate_lines_preserved(self):
1599-
assert run._transform_sort(b"b\na\nb\na\n") == b"a\na\nb\nb\n"
1586+
@pytest.mark.parametrize(
1587+
"input_data,expected_output",
1588+
[
1589+
(b"", b""),
1590+
(b"hello", b"hello"),
1591+
(b"hello\n", b"hello\n"),
1592+
(b"zebra\napple\nmango\n", b"apple\nmango\nzebra\n"),
1593+
(b"b\na\nb\na\n", b"a\na\nb\nb\n"),
1594+
],
1595+
)
1596+
def test_sort_transform(self, input_data, expected_output):
1597+
assert run._transform_sort(input_data) == expected_output
16001598

16011599
def test_trailing_newline_preserved(self):
16021600
result = run._transform_sort(b"b\na\n")

tests/test_run_config.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ def test_parse_test_config_override(tmp_path: Path, configured_root: Path) -> No
7272
"inputs": None,
7373
"retry": 1,
7474
"package_dirs": tuple(),
75+
"pre_compare": tuple(),
7576
}
7677

7778

@@ -102,6 +103,7 @@ def test_parse_test_config_yaml_frontmatter(tmp_path: Path, configured_root: Pat
102103
"inputs": None,
103104
"retry": 1,
104105
"package_dirs": tuple(),
106+
"pre_compare": tuple(),
105107
}
106108

107109

@@ -341,6 +343,7 @@ def test_parse_python_comment_frontmatter(tmp_path: Path, configured_root: Path)
341343
"inputs": None,
342344
"retry": 1,
343345
"package_dirs": tuple(),
346+
"pre_compare": tuple(),
344347
}
345348

346349

0 commit comments

Comments
 (0)