Testing¶
Your spec is code: rules, guards, budgets and policy that decide what your app does. Test it like code. Everything in HarnessWeaver runs offline against fakes, with no network and no model spend (RULES R14), and the test kit, harnessweaver.testing, is what the framework's own tests use.
Write tests¶
Add tests/test_desk.py:
"""The support desk's tests: offline, with no network and no model spend."""
from pathlib import Path
import pytest
from helpdesk.models import Ticket
import harnessweaver as hw
from harnessweaver.testing import (
assert_no_content,
assert_trace,
harness_for_test,
output,
scripted,
step,
tool_call,
)
pytestmark = pytest.mark.anyio
SPEC = Path(__file__).resolve().parents[1] / "harness.yaml"
BILLING = {"category": "billing", "priority": 2, "summary": "Charged twice; wants a refund."}
async def test_triage_returns_a_typed_answer() -> None:
async with harness_for_test(SPEC, model=scripted(output(BILLING))) as harness:
result = await harness.run("triage", Ticket(text="I was charged twice."))
assert result.ok
assert result.output == BILLING
async def test_a_rule_breaking_answer_is_repaired_once() -> None:
model = scripted(
output({"category": "bug", "priority": 3, "summary": "Crashes at login."}),
output({"category": "bug", "priority": 1, "summary": "Crashes at login."}),
)
async with harness_for_test(SPEC, model=model) as harness:
result = await harness.run("triage", Ticket(text="It crashes at login."))
assert result.ok
assert len(model.requests) == 2
assert_trace(result, step("output.before", source="validation.rules[bugs-are-urgent]"))
async def test_emails_never_reach_the_model() -> None:
model = scripted(output(BILLING))
async with harness_for_test(SPEC, model=model) as harness:
result = await harness.run("triage", Ticket(text="Refund me. ann@example.com"))
assert_no_content([model.requests, result], "ann@example.com")
async def test_refunds_over_the_cap_are_blocked() -> None:
model = scripted(tool_call("refund_payment", order_id="A-1001", amount_usd=900))
async with harness_for_test(SPEC, model=model) as harness:
result = await harness.run("orders", "Refund $900 on order A-1001.", principal="ann")
assert result.status is hw.RunStatus.BLOCKED
assert result.error is not None
assert result.error.details["code"] == "policy_denied"
async def test_big_refunds_wait_for_finance() -> None:
model = scripted(tool_call("refund_payment", order_id="A-1001", amount_usd=250))
async with harness_for_test(SPEC, model=model) as harness:
result = await harness.run("orders", "Refund $250 on order A-1001.", principal="ann")
assert result.status is hw.RunStatus.AWAITING_APPROVAL
assert result.approval is not None
assert result.approval.approver_role == "finance"
Each test builds a harness from your real harness.yaml, with a scripted model in place of the real one, and checks one control:
- Typed answer. The model's output comes back as your type.
- Repair. A first answer that breaks a rule is repaired once: two model requests, and the rule's outcome is in the trace.
- No leaks. The customer's e-mail address appears in neither the model's requests nor the result.
- Policy. A $900 refund is blocked with
policy_denied, before the tool runs. - Approval. A $250 refund waits for finance.
The tests import your helpdesk package, so let pytest find it, with a conftest.py in the project's root:
"""Make the helpdesk package importable in tests, as it is when the app runs."""
import sys
from pathlib import Path
HERE = str(Path(__file__).resolve().parent)
if HERE not in sys.path:
sys.path.insert(0, HERE)
(Or set pythonpath = ["."] under [tool.pytest.ini_options] in pyproject.toml.)
Run them¶
$ uv run harnessweaver test .
collected 5 items
tests/test_desk.py ..... [100%]
============================== 5 passed in 0.27s ===============================
harnessweaver test runs pytest in test mode (HARNESSWEAVER_TEST_MODE=1), where every live provider refuses to call out: model providers, http tools, webhook approval channels and the OTLP exporter. A test that reaches a real model by accident fails instead of spending money. It also applies the offline overlay. Plain uv run pytest works too, if you set test mode yourself.
Tip
Async tests use the anyio pytest plugin: pytestmark = pytest.mark.anyio. Time comes from an injected clock, so tests never sleep.
harness_for_test¶
| Argument | Meaning |
|---|---|
spec |
A harness.yaml path, a spec document as a dict, or a loaded spec (hw.load_spec(...)) |
model |
Replaces models.provider, usually a scripted(...) model |
tools |
FakeTools, or any tool backend |
deciders |
Fake routing answers, by router name |
clock |
A FakeClock by default |
telemetry |
Where spans and metrics go, for example an in-memory OpenTelemetry SDK |
A dict spec is handy for small tests. apiVersion, kind and metadata may be left out:
harness = harness_for_test(
{"tasks": {"echo": {"runner": "direct", "output": "str"}}},
model=scripted("hello"),
)
Use async with harness_for_test(...) as harness: when the spec has a durable state store, like this one, so the store is closed.
Scripted models¶
scripted(*replies, rules=(), repeat_last=True) answers from its replies in order, after checking its rules against the last message:
| Helper | Reply |
|---|---|
text("...") |
A text answer. A plain string works too |
output({...}) |
Structured output |
tool_call("name", **arguments) |
One tool call |
reply(**fields) |
Any of text, output, tool_calls, usage, cost_usd, model, chunks, finish_reason, fail |
model.requests keeps every request the model got, so a test can check what the model saw.
Fake tools and deciders¶
FakeTool(name, result=None, ...) is a tool with a manifest built from its fields, so the governor treats it like a real one. Every call is kept in .calls, and raises= makes it fail. FakeDecider(choice) is a routing stage that always gives the same answer: harness_for_test(..., deciders={"intent": FakeDecider("billing")}).
Checking traces¶
assert_trace(result, "run.start", "input.before", "model.before", "run.end")
assert_trace(result, step("input.before", source="guardrails.input[base-pii]", outcome="continue"))
assert_trace(result, step(kind="note", code="guard_fired"))
assert_trace(result, "run.start", "run.end", exact=True) # exactly these hooks fired
assert_trace checks that the expected events appear in that order, with others allowed in between unless exact=True. A string is a hook point that fired, and step(...) matches any event by its fields. A failure prints the whole trace. hooks_fired(trace) lists the hooks that fired, and render_trace(trace) prints the trace.
Checking for leaks¶
assert_no_content(where, *values) fails if any value appears anywhere in a result, a trace, model requests, log lines or span attributes, or any mix of them. It's the test form of RULES R9.
Recording a real model once¶
A Cassette records a real model's answers once, then replays them offline, matching requests by digest:
model = Cassette("tests/cassettes/triage.json", provider=real_provider, mode="auto")
harness = harness_for_test("harness.yaml", model=model)
replay never calls out, record always does, and auto replays when the file exists. scrub= cleans each answer before it's saved, so secrets and personal data never reach the file (RULES S7). Record deliberately, outside test mode. In CI, cassettes only replay.
What to test¶
| To check | Do |
|---|---|
| A guard redacts before the model | Assert the value isn't in model.requests (assert_no_content) |
| A control blocks | Assert result.status and result.error.details["code"], and that the model or tool was never called |
| A repair happens | Script an invalid answer then a valid one; assert two requests |
| A baseline lock holds | Load an overlay that changes a locked path, and expect LockViolation from hw.load_spec |
| The policy | harnessweaver policy test (Policy) |
| Quality | Eval suites with baselines (Evals) |
Recap¶
harness_for_testbuilds your real spec with fakes:scriptedmodels,FakeTools,FakeDeciders and a fake clock.- Test mode makes live providers refuse the network, so tests can never spend money.
assert_tracechecks what happened, andassert_no_contentchecks what must never leak.