howdy/testing

Exercise an app without starting a server.

Build a request, send it through the app, and read the response. The whole pipeline runs: routing, middleware, guards, versioning, body decoding and validation. Nothing touches a socket, so tests are fast and need no ports.

import howdy/testing

fn app() {
  howdy.new() |> howdy.controller(user_controller())
}

pub fn create_user_test() {
  let res =
    testing.post("/user", json.object([#("name", json.string("Ada"))]))
    |> testing.header("x-api-key", "secret")
    |> testing.send(app())

  assert res.status == 201
  assert testing.json(res, user.decoder()) == Ok(User(id: 1, name: "Ada"))
}

pub fn rejects_blank_name_test() {
  let res =
    testing.post("/user", json.object([#("name", json.string(""))]))
    |> testing.send(app())

  assert res.status == 422
  assert testing.field_errors(res)
    == Ok([service.FieldError("name", problem.Blank)])
}

Requests are ordinary gleam/http/request values, so anything that module offers, such as request.set_header, works on them too.

Types

An event stream from howdy/sse, running in a test without a socket.

pub opaque type EventStream

A file to send in a form with multipart_body.

pub type FormFile {
  FormFile(
    field: String,
    file_name: String,
    content_type: String,
    content: BitArray,
  )
}

Constructors

  • FormFile(
      field: String,
      file_name: String,
      content_type: String,
      content: BitArray,
    )

Values

pub fn bytes(res: response.Response(content.Content)) -> BitArray

The response body as bytes. Empty is <<>>, and a body from howdy/stream is written out in full. Panics for bodies that cannot be read in a test, such as a file or an event stream.

pub fn bytes_body(
  req: request.Request(context.Body),
  body: BitArray,
) -> request.Request(context.Body)

Replace the body with raw bytes. No content type is set.

pub fn cleanup(after cleanup: fn() -> b, run run: fn() -> a) -> a

Run run, then after, even when run crashes, as a test does when an assertion fails. A crash still fails the test once after has run. Use it to release what a test opened, such as a database:

pub fn with_db(run_test: fn(Repo) -> a) -> a {
  let assert Ok(db) = sqlite.start(sqlite.memory())
  use <- testing.cleanup(fn() { repo.close(db) })
  let assert Ok(_) = database.migrate(db)
  run_test(db)
}
pub fn close_events(stream: EventStream) -> Nil

End the stream as a client going away would, and wait for its on_close to run.

pub fn cookie(
  req: request.Request(context.Body),
  name: String,
  value: String,
) -> request.Request(context.Body)

Add a cookie. The value is percent-encoded, matching what cookie.set writes, so anything cookie.string can read round-trips.

pub fn cookies(
  res: response.Response(content.Content),
) -> List(#(String, String))

Every cookie the response sets, as name and decoded value pairs in the order they were set. A deleted cookie appears with an empty value. Attributes such as Path and Max-Age are dropped; read the raw set-cookie headers to check those.

pub fn delete(path: String) -> request.Request(context.Body)
pub fn error(
  res: response.Response(content.Content),
) -> Result(String, Nil)

The message from a standard error response, {"error": "..."}, as produced by service.error_response. Error(Nil) when the body is not one.

pub fn field_errors(
  res: response.Response(content.Content),
) -> Result(List(service.FieldError), Nil)

The field errors from a 422 validation response. Error(Nil) when the body is not one.

pub fn form_body(
  req: request.Request(context.Body),
  pairs: List(#(String, String)),
) -> request.Request(context.Body)

Replace the body with urlencoded form fields and set the content type. Repeat a name for multi-value fields such as multi-selects.

pub fn from_ip(
  req: request.Request(context.Body),
  ip: String,
) -> request.Request(context.Body)

Make the request appear to come from ip. Without this the client address is unknown and rate_limit.by_ip does not count the request.

pub fn get(path: String) -> request.Request(context.Body)
pub fn header(
  req: request.Request(context.Body),
  name: String,
  value: String,
) -> request.Request(context.Body)

Set a header, replacing any existing value.

pub fn json(
  res: response.Response(content.Content),
  decoder: decode.Decoder(a),
) -> Result(a, json.DecodeError)

Decode the response body as JSON with decoder.

pub fn json_body(
  req: request.Request(context.Body),
  body: json.Json,
) -> request.Request(context.Body)

Replace the body with JSON and set the content type.

pub fn multipart_body(
  req: request.Request(context.Body),
  fields: List(#(String, String)),
  files: List(FormFile),
) -> request.Request(context.Body)

Replace the body with a multipart/form-data form, the fields and then the files, and set the content type. Read it with form.read_with and limits that accept files.

pub fn next_event(stream: EventStream) -> Result(sse.Event, Nil)

The next event the stream sends, waiting up to a second for it. Error(Nil) if none arrives in time.

pub fn open_events(
  res: response.Response(content.Content),
) -> EventStream

Open the event stream a response describes. Its on_open has run by the time this returns, so it is already on its topics and anything the test broadcasts reaches it. Heartbeats are not sent. Panics if the response is not an event stream; check the status first.

let stream = testing.get("/orders/events") |> testing.send(app())
  |> testing.open_events
sse.broadcast("orders", sse.event("hello"))
assert testing.next_event(stream) == Ok(sse.event("hello"))
testing.close_events(stream)
pub fn patch(
  path: String,
  body: json.Json,
) -> request.Request(context.Body)

A PATCH with a JSON body and matching content type.

pub fn post(
  path: String,
  body: json.Json,
) -> request.Request(context.Body)

A POST with a JSON body and matching content type.

pub fn post_form(
  path: String,
  pairs: List(#(String, String)),
) -> request.Request(context.Body)

A POST of an HTML form: the pairs are urlencoded and the content type set, as a browser would submit them.

pub fn post_multipart(
  path: String,
  fields: List(#(String, String)),
  files: List(FormFile),
) -> request.Request(context.Body)

A POST of an HTML form with files, encoded as multipart/form-data as a browser would submit it.

pub fn put(
  path: String,
  body: json.Json,
) -> request.Request(context.Body)

A PUT with a JSON body and matching content type.

pub fn query(
  req: request.Request(context.Body),
  pairs: List(#(String, String)),
) -> request.Request(context.Body)

Set the query string from key and value pairs, replacing any existing query. Values are encoded for you.

pub fn request(
  method: http.Method,
  path: String,
) -> request.Request(context.Body)

A request with the given method and no body. A query string in path, such as "/users?page=2", is split off and set as the query.

pub fn send(
  req: request.Request(context.Body),
  app: howdy.App,
) -> response.Response(content.Content)

Run the request through the app and return the response, exactly as the server would produce it.

pub fn sent_events(stream: EventStream) -> List(sse.Event)

Every event the stream has sent that the test has not yet read, without waiting for more.

pub fn text(res: response.Response(content.Content)) -> String

The response body as text. Empty is "", and a body from howdy/stream is written out in full. Panics for bodies that are not UTF-8 or cannot be read in a test, such as a file or an event stream.

pub fn text_body(
  req: request.Request(context.Body),
  body: String,
) -> request.Request(context.Body)

Replace the body with text and set the content type.

✨ Search Document