Type-safe clients and testing
Use test helpers where they help
Build typed request inputs conveniently without letting helpers hide the contract under test.
Test helpers cut repetition when you build requests. They should not replace assertions on the HTTP response.
A helper creates a typed bookmark POST. The test still checks status and JSON independently:
function postBookmark(app, payload, headers = {}) {
return app.request('/bookmarks', {
method: 'POST',
headers: {
'content-type': 'application/json',
...headers
},
body: JSON.stringify(payload)
})
}
it('rejects invalid URLs', async () => {
const res = await postBookmark(app, {
title: 'Bad link',
url: 'not-a-url'
})
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error).toBeTruthy()
})
When validation works, Vitest prints:
✓ rejects invalid URLs
The helper sent the request; the test still asserted 400 and a truthy body.error. If the helper accidentally omitted content-type: application/json, the server might return 400 for the wrong reason or skip the JSON parser entirely.
Compare with curl:
curl -s -w '\n%{http_code}\n' -X POST http://localhost:3000/bookmarks \
-H 'content-type: application/json' \
-d '{"title":"Bad link","url":"not-a-url"}'
You want 400 and validator JSON, matching the test expectations.
A realistic failure: the helper hardcodes Cookie: session=test but forgets content-type. Vitest passes if you only check res.ok === false, while curl with JSON headers gets a different status. Fix the helper defaults and repeat one test with a manually built Request:
const res = await app.request(new Request('http://localhost/bookmarks', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ title: 'Hono docs', url: 'https://hono.dev' })
}))
If manual and helper paths diverge, the helper hid a wrong assumption.
Name helpers after the HTTP action (postBookmark), not the expected outcome (createBookmarkSuccessfully).
Keep helpers thin: defaults for content type and session cookie, not business expectations.
When ten tests share a session cookie, one helper function beats ten copy-pasted header objects. Still assert status in each test so a helper change cannot silently accept 500 responses.
Document helper defaults in a comment at the top of the test file. The next reader should know which headers are assumed without opening the helper body.
A helper that asserts status internally is a smell. Delete those assertions and keep expectations visible in each test case.
When a test fails, print await res.text() once in the debugger. HTML error pages mean you hit the wrong path or left off JSON headers.
Try this on your own project: extract one request builder and verify every test still asserts status and body explicitly.
Lesson completed