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
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.