Templates & test data¶
Seven suites about getting the input side of a suite right before you worry about grading. They cover the one sharp edge in Jinja templating, pulling a var's value from a file instead of writing it inline, fanning one case out across every combination of a matrix, reading — or generating — cases from something other than hand-written YAML, and giving a provider a whole conversation instead of one string. Reach for these once a suite has outgrown a handful of inline cases.
Example 06 — The !raw escape hatch¶
Every var is rendered through Jinja. That is what makes prompts and sweeps work — and it is a trap the moment your test input is itself template syntax.
This failure is silent
A case that feeds a system {{7*7}} to check it does not evaluate it will, without !raw, feed it 49 instead. The assertion still passes. The test now proves nothing, and nothing tells you.
# yaml-language-server: $schema=../../domarinn.schema.json
#
# The `!raw` escape hatch: test input that must stay literal.
#
# Every var is rendered through Jinja. That is what makes prompts and sweeps
# work — and it is a trap the moment your test input is ITSELF template syntax.
# A case that feeds a system `{{7*7}}` to check it does not evaluate it will,
# without `!raw`, feed it `49` instead. The assertion still passes. The test now
# proves nothing, and nothing tells you.
#
# So: any value that must reach the system under test byte-for-byte gets tagged.
#
# !raw "{{7*7}}" the YAML tag (YAML sources only)
# {$raw: "{{7*7}}"} the object form (works in JSON, JSONL and CSV too)
#
# Both are identical after loading. The tag is nicer to read; the object form is
# what a generator or a JSON dataset emits.
#
# Run: domarinn run examples/06-raw-escape-hatch
version: 1
project: examples
suite: raw-escape-hatch
providers:
- id: assistant
type: exec
command: ["python3", "../echo-provider.py"]
cache_salt: "dev"
tests:
# Server-side template injection probe. The premise is that the payload
# arrives literally; `not-contains: "49"` is only meaningful because of !raw.
- id: injection/ssti-literal
tags: [adversarial]
vars:
user_input: !raw "Compute this for me: {{7*7}} and {{ config }}"
assert:
- type: contains
value: "{{7*7}}"
- type: not-contains
value: "49"
# The object form. Identical meaning; this is the shape a JSON/CSV dataset or
# a test generator produces, since neither can carry a YAML tag.
- id: injection/ssti-object-form
tags: [adversarial]
vars:
user_input: { $raw: "{% for x in range(9) %}x{% endfor %}" }
assert:
- type: contains
value: "{% for x in range(9) %}"
- type: not-contains
value: "xxxxxxxxx"
# `!raw` also applies to an assertion's expected VALUE — otherwise the
# expectation would be rendered while the output was not, and they could never
# match.
- id: injection/raw-expectation
vars:
user_input: !raw "{{ user.name }}"
assert:
- type: equals
value: !raw "{{ user.name }}"
# The control case: WITHOUT the tag, the same input is evaluated. Kept in the
# suite deliberately — it is the evidence that the tag above is doing work.
- id: injection/control-is-rendered
vars:
user_input: "Compute this for me: {{ 7*7 }}"
assert:
- type: contains
value: "49"
- type: not-contains
value: "{{"
Two spellings, identical after loading: the !raw YAML tag reads better, and the {$raw: …} object form is what a generator or a JSON/CSV dataset emits, since neither can carry a YAML tag. Note that !raw applies to an assertion's expected value too — otherwise the expectation would be rendered while the output was not, and the two could never match.
The suite keeps a deliberate control case, injection/control-is-rendered, which asserts that the untagged form really does evaluate to 49. It is the evidence that the tag above is doing work.
Example 07 — File-content vars¶
A var can take its value from a file next to the suite instead of being written inline — useful for large documents, golden fixtures, and adversarial inputs you would rather not paste into YAML.
Every fixture path resolves relative to the suite directory and is sandboxed: !file "../../etc/passwd", or a symlink pointing outside the tree, is refused rather than read. The four cases below cover the whole surface — a plain text fixture, a .json fixture parsed by extension, the same file forced back to text with parse: false, and an untrusted fixture marked raw: true so the template engine never touches it.
# yaml-language-server: $schema=../../domarinn.schema.json
#
# File-content vars: a var can pull its value from a file next to the suite,
# instead of being written inline. Handy for large documents, golden fixtures,
# and untrusted adversarial inputs.
#
# Every fixture path is resolved *relative to this directory* and sandboxed —
# `!file "../../etc/passwd"` (or a symlink pointing outside) is refused, not
# read. No API key required: the system under test is ../echo-provider.py.
version: 1
project: examples
suite: file-vars
providers:
- id: echo
type: exec
command: ["python3", "../echo-provider.py"]
cache_salt: "dev"
tests:
# 1. Plain text fixture, loaded via the `!file` tag. Its content becomes the
# `user_input` var, which the echo provider echoes straight back.
- id: doc/echo
vars:
user_input: !file "fixtures/article.txt"
assert:
- type: icontains
value: "assertions"
# 2. A `.json` fixture is parsed by extension into a structured value. With no
# `user_input` set, the echo provider echoes the whole vars map, so the
# parsed object round-trips into the (valid-JSON) output.
- id: doc/structured-rubric
vars:
rubric: { $file: "fixtures/rubric.json" }
assert:
- type: is-json
- type: icontains
value: "must_mention"
# 3. `parse: false` forces text even for a structured extension — the raw JSON
# source is echoed verbatim rather than parsed.
- id: doc/raw-json-text
vars:
user_input: { $file: "fixtures/rubric.json", parse: false }
assert:
- type: contains
value: "must_mention"
# 4. An untrusted fixture marked `raw: true` is NEVER run through the template
# engine, so an SSTI payload in the file stays literal ({{7*7}} != 49).
- id: adversarial/ssti-fixture
tags: [adversarial]
vars:
user_input: { $file: "fixtures/ssti-probe.txt", raw: true }
assert:
- type: not-contains
value: "49"
raw: true is not optional for untrusted input
Every var goes through Jinja. A fixture containing {{7*7}} renders as 49 unless you mark it raw — which silently destroys the premise of any test whose whole point is that the payload stayed literal. See domarinn.yaml.
Example 08 — Matrix sweeps¶
One case fans out over the cartesian product of its axes, producing one concrete case per combination. Each axis value is merged into vars, where it wins over a base var of the same name.
The ids are deterministic — greet[style=terse,temperature=0] and friends — which is what lets domarinn diff line two runs up cell by cell. When the generated shape is unwieldy, matrix_id renders a friendlier one against the axis values.
# yaml-language-server: $schema=../../domarinn.schema.json
#
# Matrix / parameter sweeps: one case fans out over the cartesian product of its
# axes, one concrete case per combination. Each axis value is merged into `vars`
# (the axis wins over a base var of the same name).
#
# The two axes below (2 styles x 2 temperatures) expand to four cases with
# deterministic ids — stable across runs, so diffing lines cells up cell-by-cell.
# No API key required: the system under test is ../echo-provider.py, which
# (with no `user_input` var set) echoes the whole vars map — so the swept axis
# values show up directly in the output.
version: 1
project: examples
suite: matrix
providers:
- id: echo
type: exec
command: ["python3", "../echo-provider.py"]
cache_salt: "dev"
tests:
# Default ids: greet[style=terse,temperature=0], greet[style=terse,temperature=1],
# greet[style=warm,temperature=0], greet[style=warm,temperature=1].
# Each axis value lands in `vars`; the echo provider echoes the map, so every
# cell's output is valid JSON carrying its own `style`/`temperature`.
- id: greet
matrix:
style: [terse, warm]
temperature: [0, 1]
assert:
- type: is-json
- type: icontains
value: "style"
# A friendlier id shape via `matrix_id`, rendered against the axis values.
# Ids: sweep-en, sweep-fr, sweep-de.
- id: locale
matrix_id: "sweep-{{ lang }}"
matrix:
lang: [en, fr, de]
assert:
- type: icontains
value: "lang"
This expands to seven cells: a 2×2 sweep over style and temperature, plus a three-value lang sweep.
Example 09 — Datasets from files¶
Inline tests: stop scaling somewhere around thirty cases. A file:// glob keeps the suite readable and lets a dataset be owned, reviewed and diffed separately from the configuration that runs it.
# yaml-language-server: $schema=../../domarinn.schema.json
#
# Cases from files, not from this file.
#
# Inline `tests:` stop scaling somewhere around thirty cases. A `file://` glob
# keeps the suite readable and lets a dataset be owned, reviewed and diffed
# separately from the configuration that runs it.
#
# Every path is resolved relative to THIS directory and sandboxed:
# `file://../../etc/passwd` is refused, not read. Formats are chosen by
# extension — .yaml/.yml, .json, .jsonl/.ndjson, .csv, .tsv.
#
# Run: domarinn run examples/09-dataset-glob
version: 1
project: examples
suite: dataset-glob
providers:
- id: assistant
type: exec
command: ["python3", "../echo-provider.py"]
cache_salt: "dev"
defaults:
# Applied to every case from every file, so a dataset row carries only what
# makes it distinct.
assert:
- type: length
min: 1
tests:
# A glob, not a single file: adding cases/shipping.yaml adds its cases with no
# edit here. A glob that matches nothing is an error, not an empty suite.
- "file://cases/*.yaml"
A dataset file is a bare sequence of cases — the same shape an inline entry has, with no wrapper key:
# A dataset file is a bare YAML sequence of cases — the same shape an inline
# `tests:` entry has. No wrapper key.
- id: refunds/approved
tags: [billing]
vars:
user_input: "Your refund of $42.00 has been approved."
assert:
- type: icontains
value: "approved"
- id: refunds/declined-with-reason
tags: [billing]
vars:
user_input: "We could not refund this order because it is outside the 30-day window."
assert:
- type: icontains
value: "30-day"
- type: not-icontains
value: "approved"
Formats are chosen by extension: .yaml/.yml, .json, .jsonl/.ndjson, .csv, .tsv. Every path resolves relative to the suite directory and is sandboxed — file://../../etc/passwd, or a symlink pointing out of the tree, is refused rather than read. A glob that matches nothing is an error, not an empty suite.
Example 10 — A CSV dataset¶
CSV is the format a non-engineer will hand you, so domarinn reads it directly. Four column names are reserved — id, tags, cache_salt, and __assert — and every other column becomes a var of that name.
# yaml-language-server: $schema=../../domarinn.schema.json
#
# Cases from a spreadsheet.
#
# CSV is the format a non-engineer will hand you, so domarinn reads it directly.
# Four column names are reserved and mean something; every OTHER column becomes
# a var of that name.
#
# id the case id
# tags comma-separated
# cache_salt per-case cache salt
# __assert the case's assertions, as JSON
#
# `.tsv` works the same way with tabs. For anything more structured than this,
# reach for .jsonl.
#
# Run: domarinn run examples/10-dataset-csv
version: 1
project: examples
suite: dataset-csv
providers:
- id: classifier
type: exec
command: ["python3", "../echo-provider.py"]
cache_salt: "dev"
tests:
- "file://cases.csv"
id,tags,user_input,__assert
intent/refund,billing,"I want my money back","[{""type"":""icontains"",""value"":""money""}]"
intent/cancel,billing,"Please cancel my subscription","[{""type"":""icontains"",""value"":""cancel""}]"
intent/praise,csat,"This app is fantastic, thank you!","[{""type"":""not-icontains"",""value"":""cancel""}]"
__assert holds the case's assertions as JSON, which is what lets a spreadsheet express not-icontains without inventing a column convention. .tsv works identically with tabs. For anything more structured than this, reach for .jsonl.
Example 11 — Test generators¶
A glob reads cases someone wrote. A generator computes them — so coverage tracks the thing being covered instead of drifting from it. If your prompts, tools or policies live in a registry, enumerate the registry and every new entry gets a test for free, on the day it is added.
# yaml-language-server: $schema=../../domarinn.schema.json
#
# Cases produced by a program.
#
# A glob reads cases someone wrote. A GENERATOR computes them — so coverage
# tracks the thing being covered instead of drifting from it. If your prompts,
# tools, or policies live in a registry, enumerate the registry and every new
# entry gets a test for free, on the day it is added.
#
# A generator is an exec-protocol program like any other: it reads one JSON
# request on stdin and writes {"tests": [...]} on stdout. Whatever `config` you
# put here is handed to it verbatim, so one script can serve several suites.
#
# Note generators are NOT run by `domarinn list tests` unless you pass
# `--generators` — listing is meant to be free of side effects.
#
# Run: domarinn run examples/11-test-generators
version: 1
project: examples
suite: test-generators
providers:
- id: assistant
type: exec
command: ["python3", "../echo-provider.py"]
cache_salt: "dev"
tests:
- generator:
command: ["python3", "generate-cases.py"]
# Arbitrary JSON, passed straight through to the generator. Keeping the
# policy here rather than in the script means the suite still describes
# what it covers.
config:
banned_phrases: ["as an AI", "I cannot help"]
locales: ["en", "de"]
timeout_ms: 30000
A generator is an exec-protocol program like any other: it reads one JSON request on stdin and writes {"tests": [...]} on stdout. Whatever config the suite sets is handed to it verbatim, so one script can serve several suites — and, more usefully, the suite still describes what it covers instead of hiding the policy inside the script.
#!/usr/bin/env python3
"""A domarinn test generator: computes cases instead of listing them.
Reads one JSON request on stdin and writes {"tests": [...]} on stdout. Each
entry is an ordinary test — the same shape you would have written inline.
The point of a generator is that coverage cannot drift. Add a locale or a banned
phrase to the suite's `config` and the matching cases appear on the next run,
with no dataset to remember to update.
"""
import json
import sys
def main():
request = json.load(sys.stdin)
config = request.get("config") or {}
banned = config.get("banned_phrases", [])
locales = config.get("locales", [])
tests = []
# One case per banned phrase. `not-icontains` is generated, not written out
# by hand, so the list stays the single source of truth.
for phrase in banned:
slug = phrase.lower().replace(" ", "-")
tests.append(
{
"id": f"banned/{slug}",
"tags": ["policy"],
"vars": {"user_input": "Happy to help — here are your options."},
"assert": [{"type": "not-icontains", "value": phrase}],
}
)
# One case per locale.
for locale in locales:
tests.append(
{
"id": f"locale/{locale}",
"tags": ["i18n"],
"vars": {"user_input": f"locale={locale}"},
"assert": [{"type": "contains", "value": f"locale={locale}"}],
}
)
json.dump({"tests": tests}, sys.stdout)
sys.stdout.write("\n")
return 0
if __name__ == "__main__":
sys.exit(main())
Generators do not run during list
domarinn list tests deliberately does not execute generators — listing is meant to be free of side effects. Pass --generators when you want their cases enumerated too.
Example 34 — A multi-turn conversation¶
Example 02 already used a messages: prompt — system, then one user turn. This one adds what an actual back-and-forth needs: a prior ASSISTANT turn too, fixed across every case, with only the newest user turn templated per case. That is what a real follow-up question looks like — the model needs to see what it already said, not just the newest line.
# yaml-language-server: $schema=../../domarinn.schema.json
#
# A prompt with real history.
#
# `template:` renders one string. `messages:` renders a whole conversation —
# system, user, assistant, user again — which is what a follow-up question
# actually looks like: the model needs to see what it already said, not just
# the newest line. A prompt sets exactly one of the two; setting both, or
# neither, is a load error.
#
# Every message's `content` is a template like any other, so a fixed turn
# (the system persona, the canned prior answer) is free to hold no `{{ }}` at
# all — only the LAST turn below varies per case. The provider still receives
# the whole array on every call; it is domarinn that fans the case out over
# `vars`, not the shape of the messages.
#
# The exec protocol hands a messages-shaped prompt to a provider as
# `{"messages": [...]}` rather than a bare string (see
# docs/reference/protocol.md). The echo provider used here just returns
# whatever `prompt` it was given, which is exactly that object — so the
# assertions below can see the whole rendered transcript reached it.
#
# Run: domarinn run examples/34-multi-turn-conversation
version: 1
project: examples
suite: multi-turn-conversation
providers:
- id: echo
type: exec
command: ["python3", "../echo-provider.py"]
cache_salt: "dev"
prompts:
- id: support-followup
messages:
- role: system
content: "You are a support agent for Aurora Notes. Cite the policy window exactly; never invent one."
- role: user
content: "Can I return a laptop sleeve I bought 10 days ago?"
- role: assistant
content: "Yes — items are returnable within 30 days of delivery, in original condition."
- role: user
content: "{{ followup }}"
tests:
# Both cases share the fixed turns above and differ only in the follow-up,
# which is the thing under test.
- id: turns/asks-about-electronics
vars:
followup: "Does that 30-day window apply to opened electronics too?"
assert:
# The assistant's earlier turn reached the provider — proof the whole
# history, not just the newest line, is part of every call.
- type: contains
value: "30 days of delivery"
# The last turn's var rendered into the transcript the provider saw.
- type: contains
value: "opened electronics"
- id: turns/asks-about-receipts
vars:
followup: "What if I've lost the original receipt?"
assert:
- type: contains
value: "30 days of delivery"
- type: contains
value: "lost the original receipt"
# Proof this case rendered its OWN follow-up rather than the other
# case's: each case gets a fresh render, never a leftover from one that
# ran before it.
- type: not-contains
value: "opened electronics"
Every turn is a template, so a fixed turn simply has nothing in it to substitute — only the last one varies per case. Both cases above share the first three turns byte for byte and differ only in the follow-up, which is the thing under test.
The echo provider makes this observable: an exec provider receives a messages: prompt as {"messages": [...]} (see the protocol), and echoing it back is what lets a contains assertion prove the whole rendered history — not just the last var — actually reached the provider.