Testing

Radiant provides request builders and fluent assertions to test handlers without a running server. The same helpers are available from radiant/testing.

Request builders

radiant.test_get("/users/42")
radiant.test_post("/users", "{\"name\":\"alice\"}")
radiant.test_put("/users/1", "{\"name\":\"bob\"}")
radiant.test_patch("/users/1", "{\"active\":true}")
radiant.test_delete("/users/42")
radiant.test_head("/users/42")
radiant.test_options("/users")
radiant.test_request(radiant.query_method, "/search")

// Query strings
radiant.test_get("/search?q=gleam&page=2")

// Custom method or headers
radiant.test_request(http.Get, "/users")

For requests built from several pieces, use the composable builder. Query values are percent-encoded when the request is built:

let req =
  radiant.request(http.Post, "/search")
  |> radiant.with_query("q", "gleam router")
  |> radiant.with_request_header("x-trace", "test")
  |> radiant.with_request_body("{\"page\":2}")
  |> radiant.build()

Assertion helpers

Status, body, and header assertions return the response for chaining. They panic with a descriptive message on failure. should_have_json_body is a terminal helper: it decodes the body and returns the decoded value.

router
|> radiant.handle(radiant.test_get("/users/42"))
|> radiant.should_have_status(200)
|> radiant.should_have_header("content-type", "application/json; charset=utf-8")
|> radiant.should_have_body("{\"id\":42}")

// Parse and return the decoded JSON value
let user =
  router
  |> radiant.handle(radiant.test_get("/users/42"))
  |> radiant.should_have_status(200)
  |> radiant.should_have_json_body(user_decoder)

user.name |> should.equal("alice")

Full example

import gleeunit/should
import radiant
import my_app

pub fn get_user_test() {
  my_app.router()
  |> radiant.handle(radiant.test_get("/users/1"))
  |> radiant.should_have_status(200)
  |> radiant.should_have_json_body(user_decoder)
  |> fn(u) { u.id |> should.equal(1) }
}

pub fn user_not_found_test() {
  my_app.router()
  |> radiant.handle(radiant.test_get("/users/999"))
  |> radiant.should_have_status(404)
}

pub fn wrong_method_test() {
  my_app.router()
  |> radiant.handle(radiant.test_post("/users/1", ""))
  |> radiant.should_have_status(405)
  |> radiant.should_have_header("allow", "GET")
}

pub fn non_integer_param_test() {
  // <id:int> pattern rejects non-integer segments → 404
  my_app.router()
  |> radiant.handle(radiant.test_get("/users/abc"))
  |> radiant.should_have_status(404)
}

pub fn json_body_middleware_test() {
  let body = "{\"name\":\"alice\",\"email\":\"alice@example.com\"}"

  my_app.router()
  |> radiant.handle(radiant.test_post("/users", body))
  |> radiant.should_have_status(201)
}

pub fn bad_json_body_test() {
  my_app.router()
  |> radiant.handle(radiant.test_post("/users", "not-json"))
  |> radiant.should_have_status(400)
}

In 2.0, request accessors return RadiantError values. Test the error branch directly when the distinction matters:

case radiant.query_int(req, "page") {
  Error(radiant.MissingQuery("page")) -> radiant.bad_request()
  Error(radiant.InvalidIntQuery("page", _)) -> radiant.bad_request()
  Ok(page) -> handle_page(page)
  Error(_) -> radiant.bad_request()
}

Use error_message for a stable human-readable message, or json_error_from when returning an accessor error directly:

case radiant.query_int(req, "page") {
  Error(error) -> radiant.json_error_from(400, error)
  Ok(page) -> handle_page(page)
}

Testing middleware in isolation

pub fn cors_test() {
  let router =
    radiant.new()
    |> radiant.middleware(radiant.cors(radiant.default_cors()))
    |> radiant.get("/", fn(_) { radiant.ok("ok") })

  radiant.test_request(http.Options, "/")
  |> fn(req) {
    // Add Origin header manually
    let req = request.set_header(req, "origin", "https://example.com")
    request.set_header(req, "access-control-request-method", "GET")
  }
  |> fn(req) { radiant.handle(router, req) }
  |> radiant.should_have_status(204)
  |> radiant.should_have_header("access-control-allow-origin", "https://example.com")
}
Search Document