|
| 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`. |
0 commit comments