Kinds of test
Jadpo works out a test's kind from what it calls, so you don't declare it.
The report counts each kind separately, because each proves something
different: an action test doesn't prove that the route decodes input or
checks who is signed in. jadpo test --kind route runs one kind.
| Kind | The test calls | It proves |
|---|---|---|
| pure | functions only | the calculation |
| action | actions and queries directly | the behaviour and the data it writes |
| route | a route, through request |
the whole HTTP boundary: input, sign-in, policy, failures, output |
| schedule | a schedule, through .run() |
what one run of the schedule does |
A test that calls actions to set up its data and then makes a request is a
route test. A fifth kind, recording tests, has no test declaration; it
is described at the end of this topic.
Action tests
An action test calls actions and queries directly. Calling an action is an
ordinary call, with no call word.
Only actions write records
(The operations every entity gets).
So a test changes data through the action that does it, which is what an
action test proves, and a fixture builds the data it starts from
(Shared test data: fixtures). A test may
read straight from an entity with get, as it may call a query, and
either makes it an action test with its own database. A write straight to
an entity is semantic.not_callable:
test "a shelf counts its visits" { var shelf: Shelf = attempt Shelf.update("0192f0c4-3b5e-7c1a-9d2e-4f6a8b0c1d2e") { set: { visits: 3 } } assert shelf.visits == 3}
error: A test can't call `Shelf.update` --> domains/shop/entities/shelf.test.jadpo:2:32 |2 | var shelf: Shelf = attempt Shelf.update("0192f0c4-3b5e-7c1a-9d2e-4f6a8b0c1d2e") { | ^^^^^^^^^^^^ writes a record reason: Only actions write records (area 6), so a test changes data through the action that does it, which is what it proves; a fixture, a test-only action, builds the data a test starts from. A test may still read, with `Shelf.get` and queries. next: Call the action that writes `Shelf`, or move the write into a fixture (agent) rule: semantic.not_callable help: https://jadpo.dev/docs/errors/semantic.not_callablecheck failed: 1 error, 4 files
Route tests
A route test sends a typed request with request { }. The request goes
through the real HTTP layer Jadpo generates for your routes, without a
network:
// domains/planning/routes/todos.test.jadpotest "a due date in the past is rejected" { clock: "2026-01-15T12:00:00Z" var alice: Principal.user = alice_with_todos() var response: Response = request { method: HttpMethod.post path: "/todos" as: alice input: CreateTodo { title: "Late" due_at: "2026-01-15T11:59:59Z" } } assert response.status == 422 assert response.code == "due_date_in_past"}
The request names its route by method: and by its path as
jadpo routes prints it. Each placeholder in the path gets its value in
params:, as in path: "/todos/{todo_id}" with
params: { todo_id: todo.id }. input: is the route's Input. A
generated magic-link route takes the built-in input the API record names
(Magic links in tests). The
request goes through the runtime's HTTP component exactly as a client's
would, and carries your app's own Origin, as a browser's does.
as: signs someone in for the request, through the application's real
authentication: a browser session, an API token or, for
Principal.service(service), a service key
(Signing in, end to end). Leave
it out and the request is sent with no one signed in.
request gives a Response, which has:
status, the HTTP status;code, the failure's code, ornoneon success;output, the route's typed output, ornoneunless it succeeded.
A route that streams a model's answer gives pieces, the text of each
event in order, in place of output. Its code is that of the error
event that ended the stream, if one did
(Streaming a model's answer). A
defect behind the response, which is a bug such as an overflow, ends the
test as test.fault, with its frames.
In this example the route lets anyone call it, so the test signs no one
in. add_memo reaches no entity, so the root policy { } says who may
call it (Actions):
type Memo = Text { max_length: 200}type AddMemo = Input { text: Memo}type MemoView = Output { text: Memo}action add_memo(input: AddMemo) -> MemoView { return MemoView { text: input.text }}routes Memos { prefix: /memos POST / { authentication: RouteAuthentication.none input: AddMemo output: MemoView status: SuccessStatus.created run: add_memo(input) }}policy { actions: { add_memo: [Access.public] }}test "a memo comes back as it was sent" { var response: Response = request { method: HttpMethod.post path: "/memos" input: AddMemo { text: "Call the plumber" } } assert response.status == 201 assert response.code == none assert response.output == MemoView { text: "Call the plumber" }}
Idempotency keys
An idempotency key is a value a client sends with a write so that, if the
request is sent again, the write happens once. A route test sends a fresh
Idempotency-Key with every request but a GET, as a real client would.
So a route on an API with idempotency: Idempotency.required, which
refuses a write without a key, is tested through its success like any
other (Idempotent writes).
To test what a repeat gets back, or how reusing a key for another request
is refused, give the key yourself with idempotency_key:. The request
sends that key instead of a fresh one.
This API refuses a write without a key:
application { apis: { main: { prefix: / idempotency: Idempotency.required } }}
The first test sends no key of its own and is answered as any client
would be. The other two send memo-1 twice. A repeat gets the first
answer back and leaves no second memo. The same key with another memo is
a 422 with the code idempotency_key_reused:
use notes/actions { AddMemo }test "a memo is left through its route, with a fresh key" { var response: Response = request { method: HttpMethod.post path: "/memos" input: AddMemo { text: "Call the plumber" } } assert response.status == 201}test "a memo sent again with its key is left once" { var first: Response = request { method: HttpMethod.post path: "/memos" idempotency_key: "memo-1" input: AddMemo { text: "Call the plumber" } } var resent: Response = request { method: HttpMethod.post path: "/memos" idempotency_key: "memo-1" input: AddMemo { text: "Call the plumber" } } assert resent.status == 201 assert resent.output == first.output var on_file: Response = request { method: HttpMethod.get path: "/memos" } match first.output { some(memo) -> { assert on_file.output == [memo] } none -> { assert first.output != none } }}test "a key sent again with another memo is refused" { var first: Response = request { method: HttpMethod.post path: "/memos" idempotency_key: "memo-1" input: AddMemo { text: "Call the plumber" } } assert first.status == 201 var other: Response = request { method: HttpMethod.post path: "/memos" idempotency_key: "memo-1" input: AddMemo { text: "Fix the tap" } } assert other.status == 422 assert other.code == "idempotency_key_reused"}
idempotency_key: takes text. The runtime checks it as it checks a
client's, so a key that isn't 1 to 255 printable characters is a 400 with
the code invalid_request. A GET reads no key, so a request to one that
gives idempotency_key: is semantic.request_invalid.
Schedule tests
A schedule is work Jadpo runs on a timetable, such as sending reminders
every hour (Schedules). A schedule test
runs one with .run(), once, at the test's clock:
// domains/planning/schedules/overdue_reminders.test.jadpotest "an overdue todo gets one reminder" { clock: "2026-01-15T12:00:00Z" fakes: { ReminderMail.send_overdue_reminder: [ReminderReceipt { accepted_at: "2026-01-15T12:00:00Z" }] } one_overdue_todo() overdue_reminders.run() assert list.count(ReminderMail.send_overdue_reminder.requests) == 1}
The run calls the schedule's action as the tick does. The tick is the runtime's regular pass that runs each schedule when it is due (How a schedule runs). So the action gets no arguments, and runs as the system, with no signed-in caller, reaching every tenant (each customer organisation whose data the app keeps apart). It runs through the runtime's action boundary at the test's clock, which stays fixed through the run.
.run() gives Unit whatever the action returns, since a tick keeps no
value. It fails with the action's failures, so a bare call of a schedule
that can fail takes attempt, and
assert overdue_reminders.run() fails ReminderFailed checks a failing
run.
.run() takes no arguments (type.argument_count). It writes no run
record and takes no lease, as the tick does: a schedule test proves what
one run does, and the compiler's own live tests prove the tick's records.
What the run emits is delivered once it comes back, as any step's is
(Caches and events).
A test that runs a schedule is a schedule test, whatever it calls to set
up its data, a request included. --kind schedule runs only those.
An entity's built-in purge schedule has no name a test can write, so no test runs it. Purge is the compiler's own operation, proven by the compiler's live tests rather than each application's (The purge schedule).
Only a test runs a schedule by hand. In the application the tick runs it
when it is due, and an operator runs it with jadpo schedules run. A test
runs a schedule it can see, as it calls an action, and anything else that
runs one is semantic.not_callable:
/// Tidies up at once.action tidy_now() -> Unit { tidy_up.run()}
error: An action can't run schedule `tidy_up` --> domains/planning/actions/tidy_now.jadpo:3:5 |3 | tidy_up.run() | ^^^^^^^^^^^ runs a schedule reason: Only a test runs a schedule by hand, with `.run()`, to prove what one run does; in the application the tick runs it when it is due, and an operator runs it now with `jadpo schedules run`. next: Call the action `tidy_up` runs, or move this into the schedule's test (agent) rule: semantic.not_callable help: https://jadpo.dev/docs/errors/semantic.not_callablecheck failed: 1 error, 4 files
Recording tests
A recording is a saved answer from an outside service, kept so the app's
code can be checked against what the service really sends
(Recordings). Each recording of the app's
own integrations,
shared/providers/<name>/<version>/recordings/<resource>.<operation>.json,
is a test of kind recording. It is named
<resource>.<operation>'s recording decodes, and it reads the recording
as its operation's output through the package's decoders. You don't write
a test declaration for it.
jadpo test --kind recording runs only these, and giving a recording's
file runs it alone. A recording that doesn't decode is
test.recording_undecodable or test.round_trip_failed.
Each recording of a prompt's answer,
prompts/recordings/<prompt>/<name>.json, is a test of the same kind,
named <prompt>'s <name> recording decodes. It is read through each type
the prompt is asked for as (Testing AI calls). A recording made
from an older version of its prompt passes, with an alert on the report.
Markdown for agents: /docs/kinds-of-test.md.Built from Jadpo main at a707efb.