Jadpo is experimental and not ready for production. These docs follow the compiler's main branch as of 30 Oct 2026. What's built · Install 0.1.0
jadpo
Reference: Testing

Testing

Kinds of test

On this page

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:

example
test "a shelf counts its visits" {    var shelf: Shelf = attempt Shelf.update("0192f0c4-3b5e-7c1a-9d2e-4f6a8b0c1d2e") {        set: {            visits: 3        }    }    assert shelf.visits == 3}
text
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:

example
// 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, or none on success;
  • output, the route's typed output, or none unless 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):

example
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:

example
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:

example
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:

example
// 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:

example
/// Tidies up at once.action tidy_now() -> Unit {    tidy_up.run()}
text
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.