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
Learn Jadpo: all 14 pages

Tests

Page 8 of 14 in Learn Jadpo

By the end you can prove each route and action works, with a database of its own per test.

On this page

You've been checking the app by hand with curl. On this page you write tests that do it for you: some call actions directly, and some send real HTTP requests through the routes. Each test gets a fresh database of its own, so tests never see each other's data.

A test sits beside what it tests

A test file is named after the file it tests, with .test before .jadpo, and sits next to it. It can use what that file declares, such as add_todo, without a use line; everything else it imports as any file does. Tests for add_todo go in actions/add_todo.test.jadpo:

domains/todos/actions/add_todo.test.jadpo:

jadpo
use todos/types { NewTodo, TodoStatus, TodoView }use todos/failures { DueDateInPast }test "a new todo is open" {    var todo: TodoView = attempt add_todo(        NewTodo {            title: "Buy milk"        }    )    assert todo.status == TodoStatus.open    assert todo.due_at == none}test "a due date that has passed is refused" {    clock: "2026-11-01T12:00:00Z"    assert add_todo(        NewTodo {            title: "Buy milk"            due_at: "2026-11-01T11:59:59Z"        }    ) fails DueDateInPast}
  • test "..." names the test with a sentence, which is what a failure report shows.
  • assert checks that a condition holds. The test fails at the first that doesn't.
  • attempt add_todo(...) calls the action, which may fail. In a test, an attempt that fails ends the test as a failure, naming it.
  • assert add_todo(...) fails DueDateInPast checks that the call fails with exactly that failure.
  • clock: sets the time the test runs at, so clock.now is 2026-11-01T12:00:00Z and the test passes on any day. A test that doesn't set it runs at 2000-01-01T00:00:00Z.

A test that calls an action directly has no caller and no HTTP request, so it tests the action's own logic. Policy, which is about who is calling, doesn't apply there.

Data for tests: fixtures

Tests often need a todo that already exists. A fixture makes one. It's like an action that only tests can call, and it lives in fixtures/. Like an action test, it runs with no caller, so policy doesn't apply:

domains/todos/fixtures/milk.jadpo:

jadpo
use todos/entities { Todo }use todos/types { TodoStatus }/// An open todo to buy milk.fixture milk() -> Todo {    return Todo.create {        set: {            title: "Buy milk"            status: TodoStatus.open            due_at: none        }    }}

Tests through HTTP

A route test sends a request through the app's real HTTP handling: the input is checked, policy applies and statuses are set, just as for curl. Tests for the Todos routes go beside them, in routes/todos.test.jadpo:

domains/todos/routes/todos.test.jadpo:

jadpo
use todos/entities { Todo }use todos/types { NewTodo, TodoChanges, TodoFilter, TodoStatus }use todos/fixtures { milk }test "adding a todo answers 201 with the todo" {    var response: Response = request {        method: HttpMethod.post        path: "/todos"        input: NewTodo {            title: "Buy milk"        }    }    assert response.status == 201}test "a finished todo leaves the open list" {    var todo: Todo = milk()    var changed: Response = request {        method: HttpMethod.patch        path: "/todos/{todo_id}"        params: {            todo_id: todo.id        }        input: TodoChanges {            status: TodoStatus.done        }    }    assert changed.status == 200    var open: Response = request {        method: HttpMethod.get        path: "/todos"        input: TodoFilter {            status: TodoStatus.open        }    }    assert open.status == 200    assert open.output == []}test "a deleted todo is gone" {    var todo: Todo = milk()    var deleted: Response = request {        method: HttpMethod.delete        path: "/todos/{todo_id}"        params: {            todo_id: todo.id        }    }    assert deleted.status == 204    var fetched: Response = request {        method: HttpMethod.get        path: "/todos/{todo_id}"        params: {            todo_id: todo.id        }    }    assert fetched.status == 404    assert fetched.code == "todo_not_found"}
  • request { } sends one request and gives back its Response. A Response has the status, the code of a failure, and the output the route sent.
  • path: is the route's path as you wrote it, with {todo_id} filled from params:.
  • input: is the body for a POST or PATCH, and the query string for a GET or DELETE, built as a value of the route's input type.
  • open.output == [] checks the list came back empty.

Run them

jadpo test runs every test. It starts a PostgreSQL server of its own, builds a database from your migrations, and gives each test a copy:

shell
jadpo test
text
tested: 5 passed, 2 action tests, 3 route tests in 1.0 s

The tests run in a random order each time, to catch tests that depend on each other. To run one file's tests, name it:

shell
jadpo test domains/todos/actions/add_todo.test.jadpo
text
tested: 2 passed, 2 of 5 tests in 0.9 s

When a test fails

Break one: in todos.test.jadpo, change assert response.status == 201 to == 200, and run the tests:

text
FAIL  adding a todo answers 201 with the todo --> domains/todos/routes/todos.test.jadpo:13  assert response.status == 200         |        |         |        201         Response { status: 201, code: none, output: TodoView { id: "00dc6acf-ac00-7c23-aed1-cba688488f78", title: "Buy milk", status: TodoStatus.open, due_at: none, created_at: "2000-01-01T00:00:00Z" } }  expected: 200  actual: 201  rule: test.assertion_failed  rerun: jadpo test domains/todos/routes/todos.test.jadpo:13 --seed 1test failed: 1 failed, 4 passed, 2 action tests, 3 route tests in 1.0 s

It shows each part of the condition with its value, and a rerun: line that runs just this test again in the same order: --seed fixes the order the tests ran in. Put the 201 back.

jadpo test --watch keeps running, and reruns the tests a change affects each time you save.

What you have

Five tests that cover adding, refusing, listing, changing and deleting todos, each on a fresh database. Later pages add tests as they go. Next, on page 9, the app reads a setting from its environment.

Read more in the reference: Writing a test, Kinds of test and Shared test data: fixtures.

Markdown for agents: /learn/learn-tests.md.Built from Jadpo main at a707efb. Every file on this page is compiled and tested by Jadpo's CI, on the app as the pages before it built it.