---
title: Testing Glacier Applications
description: Writing tests in .glacier; the per-test database and the browser-like client; factories, assertions and running the tests.
category: Going Further
order: 1
---
Glacier apps are tested from the start. The model, controller, scaffold
and authentication generators write tests next to the code they make, and
the tests are written in `.glacier` (or in Rust, with `--dialect rust`), so
they read like the controllers and models they check. Each test runs against the real app with a fresh database and
a client that behaves like a browser.
After reading this guide, you will know:
- Where tests live and how they're registered.
- How to write tests, group them and share setup and helpers.
- How the test client sends requests, keeps cookies and handles CSRF.
- How to make records with factories and sequences, and check validation
errors.
- How to run tests and read their failures.
The examples come from `accounts`, an app made with `glacier new`,
`glacier generate authentication` and a `Note` scaffold whose notes belong
to the user who wrote them. The [Authentication](authentication.html)
guide builds the same app.
## Test Files
Tests live in the app's `tests/` folder, in the same layout as `src/`:
| Path | What |
| --- | --- |
| `tests/controllers/notes_controller.test.glacier` | Requests to `/notes` |
| `tests/models/note.test.glacier` | The `Note` model |
| `tests/factories/note.glacier` | Valid fields for making a `Note` in tests |
| `tests/glacier.rs` | The list of all of them |
A file ending `.test.glacier` holds tests; other `.glacier` files under
`tests/`, such as factories, hold support code. The app's `build.rs`
compiles each of them to Rust, as it does the files in `src/`.
Cargo only looks for tests in `tests/*.rs`, so one file,
`tests/glacier.rs`, lists the others with `glacier::tests!`. The
generators add each file they write to it:
```rust
//! The app's `.glacier` tests and factories (`tests/**/*.glacier`),
//! compiled into one test binary; every `.glacier` file under tests/ is found.
//! Run them with `glacier test` or `cargo test`.
glacier::tests!();
/// A test in plain Rust, run against the app like the `.glacier` ones.
#[glacier::test(app)]
async fn shows_the_sign_up_page() {
let res = glacier::testing::get("/registration/new").await;
assert_eq!(res.status, 200u16);
assert!(res.body.contains("Sign up"));
}
```
Each path names a file without its extension, so
`controllers::notes_controller` is
`tests/controllers/notes_controller.test.glacier`. All the tests build
into one test binary, which links much faster than one binary per file,
and each test is named after its folder and file:
`controllers::notes_controller::lists_only_the_user_s_own_notes`.
NOTE: A new test file must be added to `glacier::tests!` before it runs.
A file listed there that doesn't exist is a build error.
## Writing Tests
### Tests and Assertions
A test is a `test "…" do` block. Its name becomes the Rust function name,
so two tests in the same file, or the same `describe`, can't share a
name. Inside, a test makes requests
and asserts on what comes back:
```glacier
test "lists only the user's own notes" do
user = sign_in()
insert(Note, title: "Mine", user_id: user.id)
other = insert(User)
insert(Note, title: "Theirs", user_id: other.id)
res = get "/notes"
assert res.status == :ok
assert res.body =~ "Mine"
refute res.body =~ "Theirs"
end
```
There are four kinds of assertion, and `refute` turns each one around:
| Assertion | Passes when |
| --- | --- |
| `assert a == b` | `a` equals `b` |
| `assert a != b` | `a` doesn't equal `b` |
| `assert text =~ part` | `text` contains `part` |
| `assert condition` | `condition` is `true` |
`=~` checks that the text contains the other string; it isn't a regular
expression match. `refute a == b` is the same check as `assert a != b`.
In a test, `!` after a call waits for it and fails the test, at that
line, if it returns an error, so `Note.count()!` is a number.
`?` does the same for a value that's already there, without waiting: on
an `Err` or a `None`, the test fails at that line. In an action, `?`
returns the error; a test has nothing to return it to, so it fails
instead:
```glacier
test "redirects to the note it creates" do
user = sign_in()
res = post "/notes", params_for(Note, user_id: user.id), title: "Mine"
id = res.location.trim_start_matches("/notes/").parse::<i64>()?
assert Note.find(id)!.title == "Mine"
end
```
Changed to strip `"/note/"` instead, so that the id doesn't parse, the
test fails with the error:
```text
thread 'controllers::notes_controller::redirects_to_the_note_it_creates' (12891996) panicked at tests/controllers/notes_controller.test.glacier:73:64:
called `Result::unwrap()` on an `Err` value: ParseIntError { kind: InvalidDigit }
```
Inside a closure (`fn x -> … end`) in a test, `?` keeps its usual
meaning, returning from the closure.
### Pages by Selector (new)
`=~` checks a page's text, markup and all. To check what the page shows,
select its elements with CSS, as Rails' `assert_select` does:
```glacier
test "lists a page of notes, by selector" do
for i in 1..=25 do
insert(Note, title: format!("Note {i}"))
end
res = get "/notes"
assert_select(res, "table tbody tr", count: 20)
assert_select(res, "a[rel=next]", text: "Next")
refute_select(res, "a[rel=prev]")
end
```
| Assertion | Passes when |
| --- | --- |
| `assert_select(res, "css")` | at least one element matches |
| `assert_select(res, "css", count: 20)` | exactly 20 match |
| `assert_select(res, "css", text: "Next")` | one that matches has `Next` in its text |
| `refute_select(res, "css")` | none matches |
They take a response or any HTML text, and fail showing the start of the
page. `glacier::testing::select_text(res, "li")` returns each match's
text, for checks of your own.
### Snapshots (new)
A snapshot checks a whole value, such as a JSON response, against a copy
saved the first time the test ran:
```glacier
test "exports a note as JSON, as it was" do
note = insert(Note, title: "Groceries", body: "Milk, eggs")
res = get note_json_path(note.id)
assert_snapshot("note", res.json(), redact: [:id, :inserted_at, :updated_at])
end
```
The first run writes the value to `tests/snapshots/`, named after the
test and the snapshot, and passes:
```json
{
"id": "[id]",
"title": "Groceries",
"body": "Milk, eggs",
"inserted_at": "[inserted_at]",
"updated_at": "[updated_at]"
}
```
Commit the file: it's the expected value. A later run whose value differs
fails, showing the lines that changed (`-` saved, `+` now). When the
change is right, run the tests with `GLACIER_UPDATE_SNAPSHOTS=1` to save
the new values, and review the files' diff before committing. `redact:`
names fields, at any depth, whose values change from run to run, such as
ids and timestamps: they're saved as `"[id]"`. Text works too:
`assert_snapshot("page", res.body)`.
### Setup
A `setup do` block runs before each test in its file. It must come before
the tests, and a file has at most one. The generated controller tests use
one to load a form first, for the CSRF token (see below):
```glacier
setup do
get "/registration/new"
end
test "signs up and signs in" do
res = post "/registration", [email_address: "new@example.com", password: "password"]
assert res.status == :see_other
assert User.count()! == 1
assert Session.count()! == 1
end
test "rejects a short password" do
res = post "/registration", [email_address: "new@example.com", password: "short"]
assert res.status == :unprocessable_entity
assert res.body =~ "Password is too short"
assert User.count()! == 0
end
test "refuses a sign-up with the hidden field filled in, as bots do" do
get "/registration/new"
res = post "/registration", [email_address: "bot@example.com", password: "password", website: "http://spam.example"]
assert res.status == :unprocessable_entity
assert User.count()! == 0
end
```
### Grouping Tests with `describe`
`describe "…" do` groups tests, and may have its own `setup`, which runs
after the file's. Describes don't nest. The group's name is part of each
test's name: `controllers::notes_controller::someone_else_s_note::can_t_be_seen`.
```glacier
describe "someone else's note" do
setup do
sign_in()
end
test "can't be seen" do
other = insert(User)
note = insert(Note, user_id: other.id)
# Not the user's: as missing as a note that doesn't exist.
assert get(note).status == :not_found
end
test "can't be deleted" do
other = insert(User)
note = insert(Note, user_id: other.id)
assert delete(note).status == :not_found
assert Note.count()! == 1
end
end
```
### Helpers
`def` defines a helper at the top of a test file or inside a `describe`.
Helpers can make requests and records as tests do, and calls to them are
awaited for you, so `user = sign_in()` reads like any other call:
```glacier
# Signs in as a new user, as a browser would, and returns them.
def sign_in -> User do
user = insert(User)
get "/session/new"
post "/session", [email_address: &user.email_address, password: "password"]
user
end
```
In a helper, `?` fails the test as it does in a test, unless the helper
returns a `Result` or an `Option`, in which case `?` returns the error or
`None` to the caller as usual:
```glacier
# The id at the end of a path: 1 for /notes/1.
def id_in(path: &str) -> i64 do
path.rsplit("/").next()?.parse()?
end
test "finds the new note from its redirect" do
user = sign_in()
res = post "/notes", params_for(Note, user_id: user.id)
assert Note.find(id_in(&res.location))!.user_id == user.id
end
```
### Skipping a Test
`skip:` with a reason keeps a test from running. The test still has to
compile, and `glacier test` lists it as ignored, with the reason:
```glacier
test "links to the notes", skip: "the home page has no menu yet" do
res = get "/"
assert res.body =~ "href=\"/notes\""
end
```
## The Database and the Client
Each test gets a fresh, migrated database and its own instance of the app,
so tests don't see each other's records and can run in parallel. With the
default test configuration, `config/test.toml`, the database is an
in-memory SQLite database:
```toml
[database]
url = "sqlite::memory:"
```
Model calls such as `Note.count()` and `insert(User)` use the test's
database, as do the requests the test sends.
### Requests (updated)
`get`, `post`, `patch`, `put` and `delete` send a request to the app,
without opening a socket, and return its response:
- `get` and `delete` take only a path.
- `post`, `patch` and `put` take a path and a form: a keyword list such as
`[email_address: "user@example.com", password: "password"]`, or a form
such as `params_for(Note)` followed by fields to set on it.
A keyword list inside another nests its fields under its key:
`[post: [title: "Hi"]]` sends `post[title]=Hi`. A list sends each value
under the key with `[]`, as a form's checkboxes do, which
`params.list("scopes")` reads back:
```glacier
res = post "/api_tokens", [name: "ci", scopes: ["notes:read", "notes:write"]]
```
Forms are sent as
`application/x-www-form-urlencoded`, as a browser sends them. A form can
also hold a file, made with
`glacier::testing::file(filename, content_type, bytes)`; it's then
sent as `multipart/form-data`, as a browser uploads it ([File
Uploads](file_uploads.html) shows it in use).
`post_json`, `patch_json`, `put_json` and `delete_json` take a path and JSON text, or a
form, which is sent as the JSON object it describes, and send it as
`application/json`, as an API client does; [JSON
APIs](json_apis.html#sending-json-bodies) shows them in use.
A path can be a string or a record, which goes to the record's own page,
as `redirect note` does in a controller:
```glacier
test "updates a note" do
user = sign_in()
note = insert(Note, user_id: user.id)
res = patch note, params_for(Note, user_id: user.id), title: "Updated"
assert res.status == :see_other
assert get(note).body =~ "Updated"
end
```
The response has four fields:
| Field | What |
| --- | --- |
| `res.status` | The status, compared by name (`:see_other`) or number (`303`) |
| `res.body` | The body, as text |
| `res.location` | The `Location` header, or `""` when there isn't one |
| `res.headers` | All the headers |
Status names are the HTTP reason phrases in snake case: `:ok`,
`:created`, `:see_other`, `:unprocessable_entity`, `:too_many_requests`.
A name that no status has, such as `:se_other`, fails the test rather
than never matching.
```glacier
test "sends security headers with every page" do
res = get "/"
assert res.headers["x-frame-options"] == "SAMEORIGIN"
assert res.headers["x-content-type-options"] == "nosniff"
end
```
### Cookies, Sessions and CSRF (updated)
The client keeps cookies between requests, as a browser does, so signing
in once signs in the rest of the test:
```glacier
test "sends a signed-out visitor to sign in, and back afterwards" do
res = get "/notes"
assert res.status == :see_other
assert res.location == "/session/new"
user = insert(User)
get res.location
res = post "/session", [email_address: &user.email_address, password: "password"]
assert res.location == "/notes"
end
```
Here `get res.location` follows the redirect, and the second
`res.location` is the page the visitor first asked for.
CSRF protection works in tests as it does in a browser. When a response
contains a form with a CSRF token, the client remembers the token and
sends it with later `post`, `patch`, `put` and `delete` requests. A test
that submits a form without loading a page that has one first is refused,
as a forged request would be:
```glacier
test "refuses a form sent before any page with its CSRF token" do
res = post "/session", [email_address: "user@example.com", password: "password"]
assert res.status == :forbidden
assert res.body == "Invalid CSRF token"
end
```
That's why the generated controller tests start with a `setup` that loads
a form.
`glacier::testing::set_cookie(name, value)` sends a cookie of your own
with the requests that follow, as a browser that already had it would,
beside the ones the app set:
```glacier
test "a remembered locale is a cookie" do
glacier::testing::set_cookie("locale", "fr")
assert get("/").body =~ "Boutique"
end
```
### Request Headers
`glacier::testing::header(name, value)` sends a header with every later
request in the test, replacing any earlier value for it.
`glacier::testing::bearer(token)` sends `Authorization: Bearer <token>`,
for testing an API (see [Building JSON APIs](json_apis.html)). The `api`
app's generated tests start a session directly and send its token:
```glacier
test "signs in, returning a token" do
insert(User, email_address: "user@example.com")
res = post "/session", [email_address: "user@example.com", password: "password"]
assert res.status == :created
assert res.body =~ "\"token\":"
assert Session.count()! == 1
end
test "rejects a wrong password" do
insert(User, email_address: "user@example.com")
res = post "/session", [email_address: "user@example.com", password: "wrong"]
assert res.status == :unauthorized
assert Session.count()! == 0
end
test "signs out the session whose token is sent" do
user = insert(User, email_address: "user@example.com")
token = Session.start(user.id, None)!
glacier::testing::bearer(&token)
res = delete "/session"
assert res.status == :no_content
assert Session.count()! == 0
end
```
`header` sends any other header. Here a test asks for JSON from a page
that needs signing in, which answers a JSON client with `401
Unauthorized` rather than a redirect:
```glacier
test "answers a JSON client that isn't signed in with 401" do
glacier::testing::header("accept", "application/json")
res = get "/notes"
assert res.status == :unauthorized
assert res.status != :see_other
end
```
### Background Jobs
A test starts no job workers, so a job the app enqueues during a test
waits in the test's database. `enqueued_jobs()!` lists the waiting jobs,
with their names and arguments, and `perform_jobs()!` runs the jobs that
are due, until none is left, and returns how many ran. Test files see the
app's jobs without an `alias`. [Background Jobs](background_jobs.html)
shows both in use.
## Time Travel
Code that depends on the time, such as a loan that becomes overdue, can be
tested by moving the test's clock. `travel` moves it forward and stops it
there:
```glacier
loan = insert(Loan, due_at: 14.days().from_now())
refute loan.overdue()
travel(15.days())
assert loan.overdue()
```
`travel_to` sets it to a moment, given as text or a `Timestamp`:
```glacier
travel_to("2026-10-19T09:00:00Z")
loan = insert(Loan, due_at: 2.hours().from_now())
assert loan.due_on_clock("Europe/London") == "19 Oct 2026, 12:00"
```
| Helper | What it does |
| --- | --- |
| `travel(15.days())` | Moves the clock forward (or back, with a negative length) and stops it |
| `travel_to("2026-10-19T09:00:00Z")` | Moves the clock to that moment and stops it |
| `freeze_time()` | Stops the clock now, so times compare exactly |
| `travel_back()` | Lets the clock run with the real time again |
The clock is the one Glacier reads everywhere: `Timestamp.now()`, the
times records are stamped with, and when jobs are due. Each test starts
with the real time, and moving the clock in one test never affects
another. Code that reads the system clock directly, rather than
`Timestamp.now()`, doesn't see the change.
## Factories
A factory gives a model valid fields to start from, so a test only spells
out what matters to it. The generators write one per model in
`tests/factories/`:
```glacier
# Valid fields for a Note, used by `insert(Note)` and
# `params_for(Note)` in tests. Override any of them per call:
# `insert(Note, title: …)`.
factory Note do
title: "Example title"
body: "Example body"
user_id: insert(User).id
end
```
```glacier
# A user who can sign in with "password", each with their own email
# address (`insert(User, email_address: "ada@example.com")` to choose it).
factory User do
email_address: format!("user{}@example.com", glacier::testing::sequence())
password: "password"
end
```
Two calls use them:
- `insert(Note)` builds a `Note` from the factory's fields and saves it,
as `Note.build(…)` and `save()` would, so its validations and callbacks
run, and returns the saved record. If the record is invalid, the test
fails with its errors.
- `params_for(Note)` returns the factory's fields as a form, for a
request or for `Note.build`/`Note.create`.
Either takes fields to override: `insert(Note, title: "Mine", user_id:
user.id)`.
### Sequences
Some fields must be different in every record, such as a user's email
address, which must be unique. `glacier::testing::sequence()` returns a
number that no earlier call in this test binary returned, so a factory can build a different
value each time. The generated `User` factory above uses it, so every
user it makes has their own address, `user1@example.com`,
`user2@example.com` and so on, and a test can insert as many users as it
needs.
Numbers start at 1 and go up by one with each call. The count is shared
by every test in the test binary, not kept per test, and the tests run in
parallel, so which numbers a test gets depends on the others: check the
value the record got, such as `user.email_address`, rather than expecting
a particular one. Each test binary starts counting at 1 again, so two
test runs at once against the same shared database (see [Testing Against
Postgres](#testing-against-postgres)) can make the same values.
To choose the value, set the field. A field a test sets isn't computed by
the factory (see below), so it takes no number:
```glacier
test "gives each user their own email address, unless the test sets one" do
ada = insert(User)
grace = insert(User)
assert ada.email_address != grace.email_address
assert ada.email_address.starts_with("user")
user = insert(User, email_address: "ada@example.com")
assert user.email_address == "ada@example.com"
end
```
Tests that sign in with an address they know set it, as the generated
ones do with `insert(User, email_address: "user@example.com")`, or use
the user's own, as the `sign_in` helper in [Helpers](#helpers) does with
`email_address: &user.email_address`.
`glacier::testing::sequence()` is an ordinary function, so a test can call
it anywhere, not only in a factory; in a test file, or a factory,
`sequence()` is enough.
### Values in Turn (new)
`cycle([...])` gives the next of a few values each time it runs, from
the first, as FactoryBot's cycling sequences do: `status: cycle([:draft,
:published])` in a factory makes records of each status in turn. Each
place `cycle` is written keeps its own turn, starting again in each
test, so the order is the same however tests run:
```glacier
test "cycle gives each record the next value in turn" do
statuses = (0..4).map(fn _ -> cycle([:draft, :published]) end).collect::<Array<_>>()
assert statuses == ["draft", "published", "draft", "published"]
end
```
### Fields a Test Sets Aren't Computed (updated)
A field given to `insert` or `params_for` isn't computed by the
factory. The `Note` factory inserts a user for `user_id`, but
`insert(Note, user_id: user.id)` inserts no extra user.
This applies only to fields inside the parentheses. Fields after the form
are set on the result, after the factory has run:
```glacier
res = post "/notes", params_for(Note, user_id: user.id), title: ""
```
So `params_for(Note), user_id: user.id` still inserts the factory's own
user first. The extra user is easy to miss, but it changes counts, and
with a factory whose unique fields have fixed values it fails
validation. This test checks that `params_for(Note, user_id: user.id)`
inserts no user:
```glacier
test "makes no extra user for a user_id the test gives" do
user = sign_in()
post "/notes", params_for(Note, user_id: user.id)
assert User.count()! == 1
end
```
and written with `user_id` after the form, it fails:
```text
---- controllers::notes_controller::makes_no_extra_user_for_a_user_id_the_test_gives stdout ----
thread 'controllers::notes_controller::makes_no_extra_user_for_a_user_id_the_test_gives' (12893120) panicked at tests/controllers/notes_controller.test.glacier:65:3:
assertion failed: User.count()! == 1
left: 2
right: 1
```
### Traits (new)
A trait is a named variant of a factory's record: a few fields on top of
the factory's own. A loan that is overdue or already returned is set up
this way in the library example:
```glacier
factory Loan do
member_id: insert(Member).id
book_id: insert(Book).id
due_at: 14.days().from_now()
trait :overdue do
due_at: 2.days().ago()
end
trait :returned do
returned_at: 1.days().ago()
end
# One field or two: a trait on one line.
trait :long, due_at: 28.days().from_now()
end
```
A trait of a field or two fits on one line, as `:long` does:
`trait :long, due_at: 28.days().from_now()`.
A call names traits as atoms, after the model and before any fields:
```glacier
late = insert(Loan, :overdue)
assert late.overdue()
back = insert(Loan, :overdue, :returned)
refute back.overdue()
# A field given in the call still wins over the trait's.
soon = insert(Loan, :overdue, due_at: 1.days().from_now())
refute soon.overdue()
```
The factory's fields go in first, then each named trait's, in the
factory's order, so a trait's field wins over the factory's. A field
given in the call wins over both, and as above, isn't computed. Naming a
trait the factory doesn't have fails the test with `this factory has no
trait :name`. `params_for(Loan, :overdue)` and `insert_for(access, Loan,
:overdue)` take traits the same way.
### After Insert (new)
`after_insert do … end`, after a factory's fields, runs on each record
`insert` makes, with the record as `self`, before the test gets it: to
add records it needs, or load what it points at. The library's comment
factory loads each comment's book:
```glacier
factory Comment do
body: "A fine book."
commentable_type: "Book"
commentable_id: insert(Book).id
# On each comment `insert` makes, as `self`, before the test gets it.
after_insert do
self.load(:commentable)!
end
end
```
```glacier
test "the factory's after_insert loads the comment's book" do
comment = insert(Comment)
assert comment.commentable.type_name() == "Book"
assert insert_list(Comment, 2).all(fn c -> c.commentable.type_name() == "Book" end)
end
```
It runs for `insert`, `insert_list` and `insert_for`, not for
`params_for`, which saves nothing.
### Several Records at Once (new)
`insert_list(Loan, 3, …)` inserts three, and returns them in a `Vec`.
After the count it takes what `insert` takes, traits and fields, and
each record's factory fields are computed for it, so each loan here has
its own book:
```glacier
member = insert(Member)
loans = insert_list(Loan, 3, :overdue, member_id: member.id)
assert loans.len() == 3
assert loans.iter().all(fn loan -> loan.overdue() end)
assert Loan.where(member_id: member.id).count()! == 3
```
### Reusing a Record with `recycle` (new)
A factory that needs a parent inserts one, so `insert_list(Loan, 3)`
makes three members too. `recycle(member)` hands the factories a record
to use instead, for the rest of the test, as Laravel's `recycle` does:
wherever a factory would `insert(Member)`, it takes that member:
```glacier
ada = insert(Member, name: "Ada")
recycle(ada)
loans = insert_list(Loan, 3)
assert loans.iter().all(fn loan -> loan.member_id == ada.id end)
assert Member.count()! == 1
```
Only the factories' own `insert` calls are affected: a test that writes
`insert(Member)` itself still gets a new member. Recycle a record of each
model you want shared; the last one given for a model wins.
### Made-Up Values with `Fake` (new)
`Fake` makes values that look real, for pages, screenshots and seeds, as
Faker does for Rails and Laravel factories:
```glacier
factory Author do
name: Fake.name()
bio: Fake.sentence()
end
```
It has `name`, `first_name`, `last_name`, `email` and `username` (both
unique, the email at `example.com`), `company`, `city`,
`street_address`, `phone` (in a range kept for drama, never a real
number), `url`, `word`, `words(n)`, `sentence`, `paragraph`, `title`,
`number(min, max)`, `boolean` and `one_of([...])`. The values come from
small built-in English lists, in the same order on every run, so a
failing test fails the same way again; assert on what a test sets, not
on what `Fake` made.
### Records for an Access
A model `owned_by :user` or `:org` belongs to a tenant, and a test about
tenants wants records that are someone's. `insert_for(access, Note)` is
`insert` for an [Access](authentication.md#who-a-request-is-for-access): the record is built as
`Note.for(access)` builds one, so its owner column comes from the access,
whatever the factory or the test set, and it's saved under the access, so
its owned parents are checked to be the tenant's too:
```glacier
test "insert_for gives a note to the access's user" do
ada = UserAccess { user: insert(User) }
bob = insert(User)
# The factory's user, and even one given here, give way to Ada's.
note = insert_for(ada, Note, user_id: bob.id)
assert note.user_id == ada.user.id
assert Note.for(ada).count()! == 1
end
```
The factory's own fields are still computed, so the `Note` factory still
inserts a user for `user_id` that the note then doesn't use.
## Model Tests
Model tests use the model as the app does. `errors_on(saved)` takes the
result of `Model.create(…)` that failed validation and gives its error
messages joined with `; `, for an `=~` check:
```glacier
test "saves a valid note" do
note = insert(Note)
assert Note.count()! == 1
assert note.title == "Example title"
end
test "requires title" do
saved = Note.create(params_for(Note, title: ""))!
assert errors_on(saved) =~ "title can't be blank"
end
```
A record that failed to `save` has its errors too: `user.errors_on("password")`
lists the messages for one field, as the generated `User` tests check:
```glacier
test "stores only a hash of the password" do
user = insert(User, email_address: "user@example.com")
assert user.password_digest.starts_with("$argon2id$")
assert user.password.is_none()
end
test "signs in with the right password only" do
insert(User, email_address: "user@example.com")
assert User.authenticate_by("user@example.com", "password")!.is_some()
assert User.authenticate_by(" USER@example.com ", "password")!.is_some()
assert User.authenticate_by("user@example.com", "wrong")!.is_none()
assert User.authenticate_by("nobody@example.com", "password")!.is_none()
end
test "needs a password of 8 characters or more" do
user = User.build(params_for(User, password: "short"))
refute user.save()!
assert user.errors_on("password") == ["is too short (minimum is 8 characters)"]
end
test "email addresses are unique, whatever their case" do
insert(User, email_address: "user@example.com")
user = User.build(params_for(User, email_address: "User@Example.com"))
refute user.save()!
assert user.errors_on("email_address") == ["has already been taken"]
end
```
`errors_on(saved)` fails the test if the record was saved.
## Unit Tests in a Module
A plain `module` of functions can hold its own tests, beside the code
they test, as Rust's `#[cfg(test)]` tests sit in the file they test:
```glacier
# Run by `glacier test` with the rest, without the app or a database.
test "has a label for every length" do
assert label(400) == "long"
assert label(120) == "medium"
assert label(10) == "short"
assert first_or(&[3, 4], 0) == 3
assert first_or(&Array.new(), "none") == "none"
end
```
They call the module's functions by name, take `assert` and `refute` as
other tests do, and `!` fails the test with its line. They run with the
rest under `glacier test`, but without the app: no requests, factories or
database, so they suit functions of plain values. A test that needs any
of those goes in `tests/`.
## Running Tests
`glacier test` compiles the `.glacier` files, builds the app and runs every
test. Anything after it goes to `cargo test`, so a word picks out the
tests whose names contain it:
```sh
$ glacier test own_notes
```
`cargo test` works as well; `glacier test` adds one thing, which is that
failures point at your `.glacier` files.
### Reading a Failure (updated)
Each assertion reports its source and what it found. Here the test was
changed to look for `"Mime"` instead of `"Mine"`:
```text
---- controllers::notes_controller::lists_only_the_user_s_own_notes stdout ----
thread 'controllers::notes_controller::lists_only_the_user_s_own_notes' (12894171) panicked at tests/controllers/notes_controller.test.glacier:36:3:
assertion failed: res.body =~ "Mime"
expected to contain: "Mime"
but was: "<!doctype html>\n<html lang=\"en\">\n <head>\n <meta charset=\"utf-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n <title>Notes · accounts</title>\n <link rel=\"stylesheet\" href=\"/app.css\">\n </head>\n <body>\n <p class=\"flash\">Signed in.</p>\n <h1>Notes</h1>\n\n<table>\n <thead>\n <tr><th>Title</th><th>Body</th><th>User</th><th></th></tr>\n </thead>\n <tbody>\n <tr>\n <td>Mine</td>\n <td>Example body</td>\n <td>1</td>\n <td>\n <a href=\"/notes/1\">Show</a>\n <a href=\"/notes/1/edit\">Edit</a>\n </td>\n </tr>\n </tbody>\n</tabl"… (64 more bytes)
```
Long values are cut after 600 characters. The test's name is in the line
above the failure, and is the thread's name: each test runs on a thread
of its own, named after it, with a 16 MB stack, as an app's nested async
calls can need more than a test thread's default in a debug build.
Under `cargo test`, the same failure points at the Rust that `build.rs`
generated, on the same line:
```text
thread 'controllers::notes_controller::lists_only_the_user_s_own_notes' (12894267) panicked at /Users/georgebuckley/framework/guides/examples/accounts/.glacier/tests/controllers/notes_controller.test.rs:36:3:
assertion failed: res.body =~ "Mime"
```
Each `.glacier` line compiles to the same line of Rust, so the line
number is right either way.
When a request in a test answers `500`, the response says only
"Internal Server Error", so the test prints the error itself, with what
caused it, in the test's output, which `cargo test` shows when the test
fails:
```text
PATCH /books/1/pages/2 answered 500 Internal Server Error: error returned from database: (code: 1) no such table: main.leafs
caused by: (code: 1) no such table: main.leafs
```
## Before a Commit
`glacier precommit` runs what a commit should pass, stopping at the first
step that fails: `glacier fmt --check`, `cargo fmt --check` for the Rust
files, clippy with warnings as errors, the tests, then `glacier audit`.
The generators format what they write, so a new app passes as it is:
```console
$ glacier precommit
== glacier fmt --check
22 file(s) checked; all formatted
== cargo fmt --check
== clippy
== tests
== audit
src/views/docs/show.html:2: raw-html: `raw(…)` skips escaping: make sure nothing a user wrote reaches it
1 finding to look at (not a failure: each is a place to look)
```
### `glacier audit` (updated)
`glacier audit` reads the app's source for places that often hide a
security problem, as Sobelow does for Phoenix, and prints each with its
file and line. It exits with an error when it finds any, so CI can run
it; in `precommit` its findings are printed but don't fail the run. Each
is a place to look, not proof of a bug:
| Finding | What it looks for |
| --- | --- |
| `raw-html` | `raw(…)`, which skips escaping, in code and templates |
| `sql-string` | `Db.sql` with `format!` or `#{…}`: bind values with `.bind(…)` instead; `order_raw`, `where_sql` or `Expr.raw` built from params or `#{…}` |
| `cache-key` | `cache::fetch` in a controller that reads `access`: if the value is someone's, `cache::fetch_for(access, …)` |
| `castable-key` | `Post.build(params)` (or `update`, `assign`) when `Post` `belongs_to :user`, unless the action sets `user_id` itself just after or takes it out with `params.without("user_id")` |
| `cors-any` | `Cors.any_origin()` |
| `get-changes` | a `get` route to an action like `destroy`, `create`, `update` or `publish` |
| `no-csrf` | a `post`, `put`, `patch` or `delete` route outside any pipeline, in an app that has a `browser` pipeline (the webhook route excepted, as it's signed) |
## Testing Against Postgres
The in-memory SQLite database is new for every test. Any other test
database, such as Postgres, is shared by all the tests, so each test runs
inside a transaction that's rolled back when it ends. Everything the test writes, including through its
requests, is gone afterwards.
Point the tests at Postgres in `config/test.toml`, or with the
`GLACIER__DATABASE__URL` environment variable, which overrides it. Migrate
the database before the first run:
```sh
$ GLACIER__DATABASE__URL=postgres://$USER@localhost/glacier_guides_accounts_test glacier db migrate
migrated m20261004100420_create_users
migrated m20261004100421_create_sessions
migrated m20261004100733_create_notes
$ GLACIER__DATABASE__URL=postgres://$USER@localhost/glacier_guides_accounts_test glacier test
…
test result: ok. 33 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; finished in 5.76s
```
Every test also runs any migrations that are missing before it starts.
On an empty database, many tests start migrating at once and get in each
other's way, so most of the first run fails with errors from the
database; migrating first avoids that.
Some things behave differently on a shared database:
- Ids aren't reused. A rolled-back insert still uses up its id, so a test
can't expect the first record it makes to have id 1.
- Tests run in parallel, each in its own transaction, so a test that
inserts a row with the same unique value as another running test waits
for that test to finish. The tests pass, but more slowly.
The same works for an app that shares a database with an existing Rails
app: load that app's `db/structure.sql` into the test database, point
`config/test.toml` at it, and give the Glacier models the fields of the
existing tables (see [Models](models.html)). Glacier doesn't
load `structure.sql` itself.
## Tests in Rust
Tests can be plain Rust too. In the test binary that `glacier::tests!`
declares, `#[glacier::test(app)]` runs an async test against the app with
its own database and client, and the functions in `glacier::testing` are
the ones `.glacier` tests call:
```rust
/// A test in plain Rust, run against the app like the `.glacier` ones.
#[glacier::test(app)]
async fn shows_the_sign_up_page() {
let res = glacier::testing::get("/registration/new").await;
assert_eq!(res.status, 200u16);
assert!(res.body.contains("Sign up"));
}
```