Requests
app.get(), post(), put(), patch(), delete(), head() and options() start a request. Nothing is sent until it is awaited, so the builder methods can come in any order. A relative path resolves against http://localhost, or the baseUrl option.
const res = await app
.post('/users')
.bearer(token)
.query({ notify: true, tags: ['admin', 'beta'] })
.json({ name: 'Ada' })
.expect(201)
.expect('content-type', /json/);
expect(await res.json()).toMatchObject({ name: 'Ada' });
Bodies
| Method | Sends | Content type |
|---|---|---|
.json(value) | JSON.stringify(value) | application/json |
.text(body, type?) | the string | text/plain, or type |
.form(fields) | multipart/form-data, a Blob becomes a file | set by Request, with the boundary |
.urlencoded(fields) | application/x-www-form-urlencoded | set by Request |
.body(init) | anything Request accepts | none, set it with .header() |
A content type set with .header() wins over the one a body method sets.
await app
.post('/avatars')
.form({ userId: '1', file: new Blob([png], { type: 'image/png' }) })
.expect(201);
Expectations
.expect(status), .expect(header, value) and .expect(check) run in order once the response arrives. A header value is a string compared exactly or a RegExp. A check is any function, and it fails the request by throwing, so your runner's expect works inside it.
When one fails, the error shows the whole exchange, with credential-bearing headers redacted:
Expected status 201, got 422
POST http://localhost/users
request authorization: <redacted>
content-type: application/json
{"name":"A"}
response content-type: application/json
{"errors":[{"path":"name","message":"Too short"}]}
Bodies are cut at 2 KB, and a binary body is shown by its size. authorization, cookie, set-cookie and headers whose name contains token, secret, password or api-key are redacted. Bodies are not, so keep real secrets out of request bodies in tests. The error also carries request and response as properties, for runners that print them.
Body assertions stay with your runner: expect(await res.json()).toEqual(...) produces a far better diff than a message could.
Responses
The body is read once and kept, so res.json(), res.text() and res.bytes() can be called any number of times. res.json() on a body that is not JSON throws with the start of the body and its content type. res.cookies() returns the cookies the response sets, by name. res.raw is the Response the app returned.
Server-sent events
res.events() reads a text/event-stream body as it arrives, without buffering it. Each event has data and, when the server sent them, event, id and retry.
const res = await app.get('/orders/1/events').expect(200);
for await (const event of res.events()) {
expect(event.event).toBe('status');
expect(JSON.parse(event.data)).toEqual({ status: 'paid' });
break;
}
Leaving the loop cancels the stream, which aborts the request signal of the handler the way a disconnecting client would. Because the stream is not kept, events() cannot be combined with json() or text() on the same response.
Cookies and sessions
.cookie(name, value) sends a cookie with one request. app.agent() returns a client that keeps the cookies responses set and sends them back, like a browser:
const agent = app.agent();
await agent
.post('/login')
.json({ email: '[email protected]', password: 'secret' })
.expect(204);
await agent.get('/me').expect(200);
await agent.post('/logout').expect(204);
await agent.get('/me').expect(401);
A cookie expired with Max-Age=0 or a past Expires is dropped from the jar. Agents do not share cookies with each other or with app.
Typed calls
app.client is a @vercube/client that calls the app in memory. Give createTestApp the generated route map and every call is typed by your controllers, so a test breaks at compile time when a handler changes:
import type { VercubeRoutes } from '../.vercube/routes';
await using app = await createTestApp<VercubeRoutes>({
controllers: [UserController],
});
const user = await app.client.get('/users/:id', { params: { id: '1' } });
expect(user.name).toBe('Ada');
It resolves to the parsed body and throws a VercubeClientError for an error status, which suits tests of the happy path. To assert on a status or headers, use the request builder above.
Anything else
app.fetch() sends a Request, or a path with RequestInit, through the app as is. app.app is the App itself.
const res = await app.fetch('/health', { method: 'HEAD' });
WebSockets need a real server and are not covered yet.