Testing
HTTP Tests
On this page
Introduction
An HTTP test sends a request to your app and checks the response. The request runs through the whole kernel, middleware and session included, and the assertions chain onto it:
import { createTestApp, type TestApp } from '@marmeon/testing';
import { beforeEach, it } from 'vitest';
import application from '../../bootstrap/app.ts';
import { UserFactory } from '#modules/auth';
let app: TestApp;
beforeEach(async () => {
app = await createTestApp(application, { database: 'refresh' });
});
it('creates a note and shows it', async () => {
const ada = await app.factory(UserFactory).create();
await app
.actingAs(ada)
.post('/notes', { form: { title: 'Groceries', body: 'Milk' } })
.assertRedirect()
.assertFlashed('toast', { message: 'Note saved.' });
await app.assertDatabaseHas('notes', { user_id: ada.id, title: 'Groceries' });
});The getting started page explains createTestApp() and the test environment.
Making requests
app.get(), app.post(), app.put(), app.patch() and app.delete() take a path and options. app.request(method, path, options) sends any other method. The options say what the request carries:
| Option | What it sends |
|---|---|
headers | Request headers: { 'accept-language': 'de' }. |
form | A body as an HTML form sends it, application/x-www-form-urlencoded. An array repeats the field. |
multipart | A multipart/form-data body, with File and Blob values. |
json | A JSON body, with Content-Type: application/json. |
body | A body exactly as given: bytes, a string or a stream. |
A request without an Accept header is answered like a browser's page load: a page answers with its HTML document. An error,
though, gets the error page only when the request accepts text/html or is a client-side visit, and JSON otherwise. Send
headers: { accept: 'text/html' } to get exactly what a browser gets. The testing pages page
shows how to ask for the page object instead.
A request does not run until you await it. The assertions chained onto it run then, in order, and the awaited value is the
response. Every request also waits for the work the app does after its response, such as terminate hooks and
afterResponse.defer(), so a test sees what that work wrote:
const response = await app.actingAs(ada).post('/notes', { json: { title: 'Groceries', body: '' } }).assertRedirect();
const location = response.headers.get('location');Reading the response
The awaited response holds:
| Member | What it is |
|---|---|
status, headers, body | The status, the Headers, and the body as text. |
json() | The body parsed as JSON. |
page() | The page object of a page answer, from a client visit's JSON or the document's page script. |
cookie(name) | A cookie the response set, decrypted as the app reads it. |
session() | The session after the request, read from the store through the session cookie. |
flash(), flashed() | The flash messages this response delivers, and those waiting in the session. |
Request defaults
The methods below return a new view of the same app, with defaults for every request sent through it. The view shares the app, its database and its fakes, so you can keep one per user:
| Method | What it adds |
|---|---|
withHeaders(headers) | Headers on every request. |
expectingJson() | Accept: application/json, as an API client sends it. |
withCookie(name, value) | A cookie, encrypted as the app encrypts its own. |
withUnencryptedCookie(name, value) | A cookie exactly as given, to prove that a forged value is ignored. |
withSession(data) | A session that holds data before the request. |
actingAs(user) | A session signed in as user, as a sign-in leaves it. |
actingAs(user, { guard: 'api', abilities }) | A real API token of user in the Authorization header. |
expectingJson() changes what a failure looks like: a guest gets 401 instead of a redirect to the sign-in page, and invalid
input a 422 with the errors instead of a redirect back.
Sessions and signing in
actingAs(user) writes a session as a sign-in leaves it and sends its cookie. withSession(data) writes a session that holds
data. Both need a session driver on the server, which .env.test sets with SESSION_DRIVER=memory. With the cookie driver
they stop the test:
Session helpers in tests need a server-side session driver — memory, file, database or redis (set SESSION_DRIVER=memory in .env.test).A view made by actingAs() or withSession() keeps one session id across its requests, like one browser. The session is stored
as the app stores its own: under the hash of its id, and encrypted when the app encrypts its sessions. To go on with the session
a response started, such as a real sign-in, send its cookie back:
import { createTestApp } from '@marmeon/testing';
import { it } from 'vitest';
import application from '../../bootstrap/app.ts';
import { UserFactory } from '#modules/auth';
it('signs in with the password and keeps the session', async () => {
const app = await createTestApp(application, { database: 'refresh' });
const ada = await app.factory(UserFactory).create();
const login = await app
.post('/login', { form: { email: ada.email, password: 'password' } })
.assertRedirect('/dashboard')
.assertAuthenticated(ada);
const browser = app.withCookie('marmeon_session', login.cookie('marmeon_session')!);
await browser.get('/dashboard').assertOk();
await browser.post('/logout').assertRedirect('/login').assertGuest();
});UserFactory gives every user the password password. actingAs(user, { guard: 'api', abilities: ['notes:read'] }) creates a
real API token with those abilities and sends it as Authorization: Bearer …, without a session. Without abilities the token
may do everything. The API tokens page shows a test.
CSRF protection in tests
A test app does not check requests for CSRF, so a test can post without a token. createTestApp(application, { csrf: true })
turns the check on. A test request sends neither Sec-Fetch-Site nor Origin by itself, so send what a browser sends, such as
headers: { 'sec-fetch-site': 'same-origin' }, or the session's token. The CSRF protection page shows a test.
Requests of the browser's client
In the browser, the app's client sends more than page loads: client-side visits, partial reloads, islands, layers, actions and live validation, each marked by its own headers. A view sends a request the way the client would:
| Method | The request it sends |
|---|---|
navigating() | A client-side visit, answered with the page object as JSON. |
prefetching() | A prefetch of a link, marked Purpose: prefetch. |
reloading<Controller>(component, { only, except }) | A partial reload of the page on screen. |
islanding(), island(name, params, query) | The request of an island. island() takes the route's name. |
layering(), layer(name, params, query) | The request of a layer. layer() opens one by the route's name. |
acting() | An action, as useAction() sends it. |
validating(...fields) | A live validation of those fields. The controller never runs. |
A client-side visit across the sign-in boundary gets the protocol's full reload, a 409 with the target in
x-marmeon-location:
import { createTestApp } from '@marmeon/testing';
import { it } from 'vitest';
import application from '../../bootstrap/app.ts';
import { UserFactory } from '#modules/auth';
it('loads the sign-in page fully after signing out', async () => {
const app = await createTestApp(application, { database: 'refresh' });
const ada = await app.factory(UserFactory).create();
await app.actingAs(ada).navigating().post('/logout').assertStatus(409).assertHeader('x-marmeon-location', '/login').assertGuest();
});The testing pages page covers pages, partial reloads, islands, layers and actions.
Live validation
validating(...fields) sends the form's request the way live validation does. The app validates the named fields and answers
204, or 422 with their errors. The controller never runs:
await app.actingAs(ada).validating('title').post('/notes', { json: { title: '' } }).assertInvalid(['title']);
await app.actingAs(ada).validating('title').post('/notes', { json: { title: 'Groceries' } }).assertLiveValid('title');assertLiveValid(...fields) checks the 204 and that the app checked those fields. The
validation page explains live validation.
Uploads
app.upload(routeName, field, file, params) sends a file the way the browser does: it asks the route's upload target, sends the
bytes, and resolves with the token the form then sends in the field. The upload belongs to the view's session, so send the form
through the same view:
import { createTestApp } from '@marmeon/testing';
import { it } from 'vitest';
import application from '../../bootstrap/app.ts';
import { UserFactory } from '#modules/auth';
import { NoteRepository } from './NoteRepository.ts';
const PNG = Uint8Array.fromBase64('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYPj/HwADAgH/ehBVmQAAAABJRU5ErkJggg==');
it('stores a cover for the note', async () => {
const app = await createTestApp(application, { database: 'refresh' });
const ada = await app.factory(UserFactory).create();
const note = await app.make(NoteRepository).insert({ user_id: ada.id, title: 'Groceries', body: '' });
const browser = app.actingAs(ada);
const token = await browser.upload('notes.cover.update', 'cover', new File([PNG], 'cover.png', { type: 'image/png' }), { note: note.id });
await browser.put(`/notes/${note.id}/cover`, { json: { cover: token } }).assertRedirect();
app.storage.disk('public').assertCount(1, 'covers');
});The route and the field are checked by the compiler. A field without an upload() rule does not compile:
Argument of type '"title"' is not assignable to parameter of type '"'title' is no upload field of this form — its upload fields: cover"'.A target the route refuses fails the call with the response's status: 404 without an upload() rule, 403 when authorization
fails, 422 for a file that is too large. Uploads land on the storage fake. The forms page
explains uploads.
Assertions
Status
| Assertion | Passes for |
|---|---|
assertStatus(status) | That status. |
assertOk(), assertCreated(), assertNoContent() | 200, 201, 204. |
assertSuccessful() | Any 2xx. |
assertUnauthorized(), assertForbidden(), assertNotFound(), assertUnprocessable() | 401, 403, 404, 422. |
assertRedirect(location) | Any 3xx, and with location that exact Location header. |
Validation
assertInvalid(fields) finds the errors in a 422 answer, or in the session when a form request was redirected back. It takes
the names of the fields, or the messages they must have, each a string or a regular expression:
await app.actingAs(ada).post('/notes', { form: { title: '' } }).assertRedirect().assertInvalid(['title']);
await app.actingAs(ada).expectingJson().post('/notes', { json: { title: '' } }).assertInvalid<StoreNoteController>({ title: /required/ });With a controller as the type argument, a field the controller's form does not have is a compile error. assertValid() passes
when there are no errors in either place.
Flash messages
A flash message travels in two steps. A redirect leaves it waiting in the session, and the next answer that is shown delivers it:
| Assertion | Passes when |
|---|---|
assertFlashed(key, data) | The message waits in the session after this response, such as after a redirect. |
assertFlash(key, data) | This response delivers it: a page, a partial reload, a layer or an action. |
assertNoFlash(key) | This response delivers no message, or none of that key. |
data is a part of the message's data, compared deeply. Keys and data are typed by the app's FlashData, so a misspelt key does
not compile. The pages page explains flash messages.
Session and sign-in
| Assertion | Passes when |
|---|---|
assertSessionHas(key, value) | The session holds key after the request, with value when given, compared deeply. |
assertSessionMissing(key) | The session lacks key, or the request kept no session at all. |
assertAuthenticated(user) | The session is signed in, as user when given, compared by id. |
assertGuest() | The session is not signed in. |
These read the session the response's cookie points to, so they need a session driver on the server. When the response sets
no session cookie at all, such as on a route outside the web group, assertSessionHas(), assertAuthenticated() and
assertGuest() fail with Expected the response to carry a session (is StartSession on this route?).
Cookies
assertCookie(name, value) passes when the response sets the cookie, with that value when given. Values are compared decrypted.
assertCookieMissing(name) passes when the response does not touch the cookie, and assertCookieExpired(name) when it deletes
it.
Headers and body
| Assertion | Passes when |
|---|---|
assertHeader(name, value) | The header is there, with that exact value when given. |
assertJson(subset) | The JSON body contains subset: other keys of an object may exist, but an array must match element by element. |
assertExactJson(value) | The JSON body equals value. |
assertSee(text), assertDontSee(text) | The body contains the text, or does not. |
When an assertion fails
A failed assertion says what it expected, and shows the request, the status and the start of the body:
AssertionError: Expected status 200, got 404
GET /notes/9999 → 404
{"message":"Not Found"}The body helps when a request fails with a status you did not expect. With debug: true the body of a 500 carries the exception,
as the getting started page explains.