Skip to content

Commit 59c19ea

Browse files
committed
test(recorded): add replay harness
1 parent 8082e74 commit 59c19ea

21 files changed

Lines changed: 2252 additions & 3 deletions

Makefile

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
.PHONY: all test tests test_watch test_coverage test_profile docs docs-strict docs-serve docs-update-cards docs-check-cards docs-watch-cards pre_commit help
1+
.PHONY: all test tests test_watch test_coverage test_profile record-tests docs docs-strict docs-serve docs-update-cards docs-check-cards docs-watch-cards pre_commit help
22

33
# Default target executed when no specific target is provided to make.
44
all: help
@@ -21,6 +21,15 @@ test_coverage:
2121
test_profile:
2222
poetry run pytest -vv tests/ --profile-svg
2323

24+
# Refresh recorded-test cassettes against live providers, fill snapshots, then verify
25+
# the offline replay. Requires real provider credentials in the environment
26+
# (e.g. OPENAI_API_KEY, NVIDIA_API_KEY). Fake cassettes are excluded from recording.
27+
RECORDED_TESTS ?= tests/recorded
28+
record-tests:
29+
poetry run pytest $(RECORDED_TESTS) --record-mode=all -m "not fake_cassette"
30+
poetry run pytest $(RECORDED_TESTS) --block-network --inline-snapshot=create
31+
poetry run pytest $(RECORDED_TESTS) --block-network
32+
2433
docs:
2534
poetry run sphinx-build -b html docs _build/docs
2635

poetry.lock

Lines changed: 105 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@ pytest = ">=7.2.2,<9.0.0"
160160
pytest-asyncio = ">=0.21.0, <1.0.0"
161161
pytest-cov = ">=4.1.0"
162162
pytest-httpx = ">=0.22.0"
163+
pytest-recording = "^0.13.4"
163164
streamlit = ">=1.37.0"
164165
tox = "^4.23.2"
165166
pytest-profiling = "^1.7.0"
@@ -184,6 +185,8 @@ langchain-core = ">=0.2.14,<2.0.0"
184185
langchain-community = ">=0.2.5,<2.0.0"
185186
langchain-openai = ">=0.1.0"
186187
langchain-nvidia-ai-endpoints = ">=0.2.0"
188+
inline-snapshot = "^0.33.0"
189+
dirty-equals = "^0.11"
187190

188191
# Directories in which to run Pyright type-checking
189192
[tool.pyright]
@@ -232,6 +235,9 @@ log-level = "DEBUG"
232235
# phase, which will cause tests to fail or "magically" ignored.
233236
log_cli = "False"
234237

238+
[tool.inline-snapshot]
239+
format-command = "ruff format --stdin-filename {filename} -"
240+
235241
[build-system]
236242
requires = ["poetry-core>=1.0.0,<2.0.0"]
237243
build-backend = "poetry.core.masonry.api"

pytest.ini

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@ log_cli = False
1010

1111
asyncio_default_fixture_loop_scope = function
1212

13+
markers =
14+
recorded: deterministic cassette replay tests
15+
live: tests that intentionally hit external services
16+
vcr: marker used by pytest-recording
17+
fake_cassette: cassette is hand-authored; refresh workflow must skip these tests
18+
1319
testpaths =
1420
tests
1521
docs/colang-2/examples

tests/recorded/README.md

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
# Recorded Tests
2+
3+
Recorded tests replay provider traffic through pytest-recording cassettes and must run without live network access by default.
4+
5+
## Adding a test
6+
7+
Markers are applied once per module via `pytestmark`; do not stack `@pytest.mark.recorded` / `vcr` / `asyncio` on each test. Use a module-level list, and fold in `vcr`/`asyncio` only when every test in the module needs them:
8+
9+
```python
10+
pytestmark = [pytest.mark.recorded, pytest.mark.vcr, pytest.mark.asyncio]
11+
12+
13+
async def test_my_case(openai_api_key):
14+
rails = LLMRails(load_config(OPENAI_BASELINE_CONFIG), verbose=False)
15+
result = await rails.generate_async(prompt="...")
16+
assert result == snapshot()
17+
```
18+
19+
Request credentials as fixture parameters (`openai_api_key`, `nvidia_api_key`) rather than calling `request.getfixturevalue(...)`. In modules that mix sync/async tests or vcr/non-vcr tests, keep only `recorded` in `pytestmark` and apply `@pytest.mark.vcr` / `@pytest.mark.asyncio` per test. Then record once with credentials, fill the snapshot offline, and verify the replay:
20+
21+
```bash
22+
OPENAI_API_KEY=... poetry run pytest path::test_my_case --record-mode=all
23+
poetry run pytest path::test_my_case --block-network --inline-snapshot=create
24+
poetry run pytest path::test_my_case --block-network
25+
```
26+
27+
## Negative paths
28+
29+
This suite owns **pipeline-level** failures (how `LLMRails` behaves when a model call
30+
fails) and **public-API input validation**. Client/wire-level conditions (status code to
31+
exception mapping, retries, SSE, malformed bodies) belong in `tests/llm/clients/`, which
32+
covers them with `httpx.MockTransport` + JSON fixtures; do not duplicate them here.
33+
34+
Prefer mechanisms in this order:
35+
36+
1. **Recordable real error** (refreshable cassette). A nonexistent model name yields a real,
37+
deterministic 404, so error paths record and refresh like any happy path. Use a config
38+
whose model is invalid (see `OPENAI_INVALID_MODEL_CONFIG`,
39+
`CONTENT_SAFETY_INVALID_MODEL_CONFIG`).
40+
2. **Pure runtime** `pytest.raises` for input validation (no cassette, no transport).
41+
3. **Fake cassette** (`@pytest.mark.fake_cassette`) only as a last resort, for a synthetic
42+
response that must flow through the full pipeline and cannot be reproduced by 1 or 2.
43+
44+
Observed behavior these tests pin: a failing model call (main *or* a rail's own model)
45+
propagates as `LLMCallException` — a safety-model failure does not let content through
46+
silently. Name negative tests `test_<surface>_<failure>_<behavior>` with the suffixes
47+
`_raises` / `_fails_closed` / `_invalid_*` so they are greppable
48+
(`pytest -k "raises or invalid"`), and co-locate each with its happy-path sibling module.
49+
50+
## Replay
51+
52+
```bash
53+
poetry run pytest tests/recorded --block-network -v --durations=10
54+
```
55+
56+
Focused rails replay:
57+
58+
```bash
59+
poetry run pytest tests/recorded/rails/public_api --block-network -v
60+
poetry run pytest tests/recorded/rails/library --block-network -v
61+
```
62+
63+
Replay mode installs dummy API keys from `tests/recorded/utils.py`. A cassette miss with `--block-network` is a test failure.
64+
65+
## Refresh
66+
67+
Refresh only in a trusted environment with real provider credentials. The full
68+
record -> fill-snapshots -> verify loop is wrapped in a make target:
69+
70+
```bash
71+
OPENAI_API_KEY=... NVIDIA_API_KEY=... make record-tests
72+
```
73+
74+
Or run the recording step alone:
75+
76+
```bash
77+
poetry run pytest tests/recorded --record-mode=all -m "not fake_cassette" -v
78+
```
79+
80+
For a focused rewrite:
81+
82+
```bash
83+
poetry run pytest tests/recorded/rails/public_api/test_generate.py::test_openai_generate_async_public_contract --record-mode=rewrite -v
84+
```
85+
86+
The refresh workflow uploads cassettes as artifacts for review and does not commit them.
87+
88+
## Cassettes
89+
90+
Rails tests use pytest-recording's default names:
91+
92+
```text
93+
tests/recorded/rails/<suite>/cassettes/<test_module>/<test_name>.yaml
94+
```
95+
96+
Parameterized tests include the parameter id in the cassette filename. Every test (rails and clients) uses this default naming; do not add `@pytest.mark.default_cassette(...)`.
97+
98+
JSON request and response bodies are stored as `parsed_body` and rehydrated by `ReadableYamlSerializer` during replay. SSE responses also use parseable `parsed_body` events.
99+
100+
At serialize time the bodies are normalized to ASCII (smart quotes, en/em dashes, the hyphen family, and ellipsis are folded, then NFKC) so cassettes and inline snapshots stay stable and portable. Response headers are dropped by exact name and by prefix (`x-`, `cf-`, `openai-`); `tests/recorded/sanitization.py` holds the `ALLOWED_HEADERS` exceptions that must survive the prefix sweep (currently `content-type`).
101+
102+
Inspect a cassette:
103+
104+
```bash
105+
poetry run python -m tests.recorded.inspect_cassette tests/recorded/rails/public_api/cassettes/test_stream/test_openai_stream_async_public_contract.yaml
106+
```
107+
108+
## Snapshots
109+
110+
Rails replay outputs are pinned with inline snapshots after normalization. Create or fix snapshots with:
111+
112+
```bash
113+
poetry run pytest tests/recorded/rails --block-network --inline-snapshot=create
114+
poetry run pytest tests/recorded/rails --block-network --inline-snapshot=fix
115+
poetry run pytest tests/recorded/rails --block-network --inline-snapshot=review
116+
```
117+
118+
Snapshot formatting uses `ruff format` through `[tool.inline-snapshot]` in `pyproject.toml`.
119+
120+
Volatile response fields (ids, timestamps, fingerprints) are scrubbed to fixed sentinels in the cassette, so snapshots assert them directly without needing loose matchers.
121+
122+
## Fake Outputs
123+
124+
Prefer `FakeLLMModel` when a test needs the main model to emit a specific output and provider-backed rail/task calls can still replay from VCR. This keeps the test refreshable.
125+
126+
Use a fake cassette only when runtime injection cannot model the behavior clearly, such as a provider stream/error path. Fake cassettes must:
127+
128+
- live under a `cassettes/**/fake/` directory,
129+
- use `@pytest.mark.fake_cassette`,
130+
- be excluded from refresh with `-m "not fake_cassette"`,
131+
- include YAML header metadata with `reason`, `frozen_fields`, and `fake_llm_model_considered`.
132+
133+
The fake-cassette metadata validator is in `tests/recorded/fake_cassettes.py`.

tests/recorded/__init__.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.

0 commit comments

Comments
 (0)