KevinIsInCoding Claude Sonnet 4.6 commited on
Commit
b720671
Β·
1 Parent(s): b4e34e7

docs: add unit test design document

Browse files

Defines testing strategy, module coverage targets, and 7-stage
implementation order for Beacon's test suite.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Files changed (1) hide show
  1. design/unit-test-design.md +172 -0
design/unit-test-design.md ADDED
@@ -0,0 +1,172 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Beacon Unit Test Design
2
+
3
+ ## Why This Exists
4
+
5
+ Beacon is now stable. Unit tests are the guardrail against regressions as new features land. The core design challenge is that the system mixes two fundamentally different kinds of logic β€” deterministic computation and LLM orchestration β€” which require different testing strategies.
6
+
7
+ ---
8
+
9
+ ## Rule 1: Classify Every Code Path Before Testing It
10
+
11
+ Every function in Beacon falls into one of three categories:
12
+
13
+ ### Deterministic (test directly, no mocks)
14
+
15
+ These functions take inputs and return outputs with no external dependencies. They are always fast, always repeatable, and should have high coverage.
16
+
17
+ | Function | Location | What makes it deterministic |
18
+ |----------|----------|----------------------------|
19
+ | `haversine_miles` | `models.py` | Pure trigonometry |
20
+ | `PatientProfile.summary` | `models.py` | String formatting from a dataclass |
21
+ | `_evaluate_deterministic` | `agents/eligibility.py` | Operator comparison (<=, >=, ==, !=, in, not_in, between) |
22
+ | `_resolve_patient_value` | `agents/eligibility.py` | Dict/field lookup via static key map |
23
+ | `_compute_overall` | `agents/eligibility.py` | Precedence rule: FAIL > UNKNOWN > PASS |
24
+ | `_flatten_and_rank` | `trials_api.py` | API response reshaping + haversine sort |
25
+ | `_months_from_date` | `agents/intake.py` | Date arithmetic (freeze `datetime.today`) |
26
+ | `_resolve_months` | `agents/intake.py` | Dict key fallback logic |
27
+ | `lookup_disease_profile` | `prompts.py` | File read + string matching (uses real data files on disk) |
28
+
29
+ > **Rule:** `_evaluate_deterministic` is the single highest-value test target in the codebase. It is the only place where the system makes an automated pass/fail judgment about a patient's eligibility. Every operator Γ— pass/fail combination must be covered.
30
+
31
+ ### I/O-Bound (mock the network, test the logic around it)
32
+
33
+ These functions call external services. The external call is mocked; the logic that wraps it (retry, pagination, parameter construction) is what we test.
34
+
35
+ | Function | External dependency | Mock tool |
36
+ |----------|-------------------|-----------|
37
+ | `geocode_zip` | OpenStreetMap Nominatim (httpx) | `pytest-httpx` |
38
+ | `search_trials_api` | ClinicalTrials.gov API (httpx) | `pytest-httpx` |
39
+
40
+ > **Rule:** Use `pytest-httpx` (not `responses`, not `httpretty`). The codebase uses `httpx` directly. Other HTTP mock libraries intercept at a different layer and will silently miss these calls.
41
+
42
+ ### LLM-Orchestrated (mock the client, test the loop logic)
43
+
44
+ These functions call `client.messages.create()` or `client.messages.stream()`. The LLM response is unpredictable; the orchestration logic around it is not. What we test: does the function correctly handle a `tool_use` stop reason, route to the right tool, append messages in the right format, and loop until `end_turn`?
45
+
46
+ | Function | Location | What the test verifies |
47
+ |----------|----------|----------------------|
48
+ | `run_intake_agent` | `agents/intake.py` | identify_disease and submit_profile tool routing |
49
+ | `stream_intake_turn` | `agents/intake.py` | token/text/profile/reset_stream event sequence |
50
+ | `run_research_agent` | `agents/research.py` | search tool loop: 1 call, 2 calls, API error path |
51
+ | `stream_research_agent` | `agents/research.py` | status/token/done event sequence |
52
+ | `run_eligibility_check` | `agents/eligibility.py` | parse β†’ deterministic split β†’ LLM assess orchestration |
53
+ | `bulk_parse_and_strip` | `agents/eligibility.py` | bulk parse + deterministic filter, strip fields |
54
+ | `_parse_criteria` | `agents/eligibility.py` | tool-forced call, struct β†’ dataclass conversion |
55
+ | `_assess_llm` | `agents/eligibility.py` | tool-forced call, output β†’ CriterionAssessment mapping |
56
+
57
+ > **Rule:** Never call the live Anthropic API from tests. Mock `client.messages.create` using `side_effect` lists to simulate multi-turn tool loops. For streaming, mock `client.messages.stream` with a context-manager-compatible fake that yields tokens and returns a final message.
58
+
59
+ ---
60
+
61
+ ## Rule 2: What Not to Test
62
+
63
+ Some parts of the codebase have no unit-testable logic and should be explicitly excluded from coverage requirements.
64
+
65
+ | File | Reason to skip |
66
+ |------|---------------|
67
+ | `app.py` | Gradio requires a running server; UI event wiring is not unit-testable |
68
+ | `prompts.py` system prompt strings | Static text constants β€” content changes should not break tests |
69
+ | `config.py` | Two string constants, no logic |
70
+ | `translations.py` | String constant tables, no logic |
71
+ | `tools.py` | Data definitions (dicts loaded from JSON), validated by Anthropic at runtime |
72
+ | `beacon_logging.py` | Logger wiring, pure side-effects |
73
+ | `clinical_trials_guru.py` | LangGraph graph construction β€” integration concern, not unit concern |
74
+ | `main.py` | CLI entry point wrapper |
75
+
76
+ ---
77
+
78
+ ## Rule 3: Test Structure Mirrors Module Structure
79
+
80
+ ```
81
+ tests/
82
+ β”œβ”€β”€ conftest.py # shared fixtures + Anthropic mock builders
83
+ β”œβ”€β”€ test_models.py
84
+ β”œβ”€β”€ test_prompts.py
85
+ β”œβ”€β”€ test_trials_api.py
86
+ └── agents/
87
+ β”œβ”€β”€ __init__.py
88
+ β”œβ”€β”€ test_eligibility.py
89
+ β”œβ”€β”€ test_intake.py
90
+ └── test_research.py
91
+ ```
92
+
93
+ `conftest.py` is the foundation. It must define:
94
+ - A `PatientProfile` fixture covering the common ALS patient case
95
+ - Module-level factory functions (not fixtures) for constructing Anthropic mock objects: text blocks, tool-use blocks, complete message responses
96
+ - A `FakeStream` class that implements the `client.messages.stream()` context manager interface (has `text_stream` iterator and `get_final_message()`)
97
+ - A `mock_client` fixture returning a bare `MagicMock` Anthropic client
98
+
99
+ ---
100
+
101
+ ## Rule 4: The LLM Loop Testing Pattern
102
+
103
+ The while-True tool-use loops in intake, research, and eligibility are the most structurally complex code in the project. The test pattern is:
104
+
105
+ 1. Configure `mock_client.messages.create.side_effect` as an ordered list of mock responses
106
+ 2. First response(s) return `stop_reason="tool_use"` with tool-use blocks
107
+ 3. Final response returns `stop_reason="end_turn"` with a text block
108
+ 4. Assert: the function returned the expected value AND `mock_client.messages.create.call_count` equals the expected number of loop iterations
109
+
110
+ This verifies both the happy path and that the loop terminates correctly.
111
+
112
+ ---
113
+
114
+ ## Rule 5: Coverage Targets
115
+
116
+ | Layer | Target | Rationale |
117
+ |-------|--------|-----------|
118
+ | `models.py` | 95%+ | Pure functions, no excuse for gaps |
119
+ | `agents/eligibility.py` | 85%+ | Highest business-criticality |
120
+ | `agents/intake.py` | 75%+ | Date helpers are pure; streaming has irreducible mock complexity |
121
+ | `trials_api.py` | 75%+ | Flatten logic is pure; HTTP retries covered by httpx mock |
122
+ | `prompts.py` | 80%+ | Lookup function covered; prompt strings excluded |
123
+ | `agents/research.py` | 65%+ | Loop logic covered; streaming harder to fully exercise |
124
+ | **Overall** | **~75%** | Reasonable first-pass guardrail |
125
+
126
+ ---
127
+
128
+ ## Implementation Stages
129
+
130
+ Stages are ordered by dependency and confidence-building. Each stage is independently verifiable.
131
+
132
+ ### Stage 1 β€” Foundation
133
+ **Deliverable:** `tests/conftest.py` with all shared fixtures and mock builders.
134
+ **Why first:** Every other test file imports from here. Nothing else can be built without it.
135
+
136
+ ### Stage 2 β€” Pure Functions (no mocks)
137
+ **Deliverable:** `tests/test_models.py`, `tests/test_prompts.py`
138
+ **Why second:** No mocking required. Establishes that pytest is wired correctly. Disease profile lookup (`lookup_disease_profile`) reads real data files and also validates that the `data/diseases/` JSON registry is structurally intact.
139
+
140
+ ### Stage 3 β€” Deterministic Eligibility Logic
141
+ **Deliverable:** `tests/agents/test_eligibility.py` covering `_evaluate_deterministic`, `_resolve_patient_value`, and `_compute_overall`
142
+ **Why third:** Highest-value tests in the project. Fully deterministic. No mocks needed. Establishes the criterion/assessment fixture patterns used by later stages.
143
+
144
+ ### Stage 4 β€” HTTP Layer
145
+ **Deliverable:** `tests/test_trials_api.py`
146
+ **Why fourth:** Introduces `pytest-httpx`. `_flatten_and_rank` is pure dict logic (no mock). `search_trials_api` uses `httpx_mock` to verify aggFilters param construction, pagination, and retry behavior.
147
+
148
+ ### Stage 5 β€” LLM Orchestration (eligibility)
149
+ **Deliverable:** `tests/agents/test_eligibility.py` extended with `run_eligibility_check` and `bulk_parse_and_strip`
150
+ **Why fifth:** First introduction of LLM mocking. Eligibility is the clearest to mock because the tool choice is forced and the input/output shapes are structured.
151
+
152
+ ### Stage 6 β€” LLM Orchestration (intake)
153
+ **Deliverable:** `tests/agents/test_intake.py`
154
+ **Why sixth:** Covers date helpers with `freezegun`, then introduces the `FakeStream` pattern for `stream_intake_turn`. Tests all three event types: token, text, profile.
155
+
156
+ ### Stage 7 β€” LLM Orchestration (research)
157
+ **Deliverable:** `tests/agents/test_research.py`
158
+ **Why last:** Most complex mock setup (patches three collaborators: `search_trials_api`, `_flatten_and_rank`, `bulk_parse_and_strip`). Benefits from patterns established in Stages 5–6.
159
+
160
+ ---
161
+
162
+ ## Dependencies
163
+
164
+ ```
165
+ pytest>=8.0
166
+ pytest-mock>=3.14
167
+ pytest-httpx>=0.35
168
+ freezegun>=1.5
169
+ pytest-cov
170
+ ```
171
+
172
+ `pyproject.toml` should exclude the files listed in Rule 2 from coverage reporting.