TL;DR
supertest (~5.1M weekly downloads) is the Express-era standard for API integration testing — wraps your Express or any Node.js HTTP server and lets you make HTTP assertions against it without binding a real port. fastify.inject is Fastify's built-in zero-overhead request injection — ships with Fastify, no extra dependency, tests routes in-process with no TCP socket. hono/testing is Hono's testing helper — ships with the Hono package, works in any runtime (Node, Bun, Deno, Cloudflare Workers), passes a standard Request object directly to your app's fetch handler. In 2026: use the framework's native testing approach (fastify.inject for Fastify, hono/testing for Hono) over supertest for faster, more accurate tests.
Key Takeaways
- supertest: ~5.1M weekly downloads — Express standard, wraps any
http.Server, rich assertion chain - fastify.inject: bundled with Fastify (~6M weekly downloads) — zero-overhead in-process injection, no real HTTP
- hono/testing: bundled with Hono (~1.8M weekly downloads) — framework-agnostic
testClient, works in all runtimes - supertest starts a real TCP listener (or uses in-memory socket) — slower, closer to production behavior
- fastify.inject / hono testing bypass the network entirely — pure in-process function calls
- Both approaches test the full route handler, middleware, and validation pipeline
Quick Comparison
| Feature | supertest | fastify.inject | hono/testing |
|---|---|---|---|
| Weekly downloads | ~5.1M | bundled w/ Fastify | bundled w/ Hono |
| Framework requirement | Any Node.js http.Server | Fastify only | Hono only |
| Transport | In-memory HTTP socket | Pure in-process | Pure in-process (Web Fetch API) |
| Runtime support | Node.js | Node.js | Node, Bun, Deno, CF Workers |
| Cookie handling | ✅ request.agent() | ✅ manual | Manual |
| Assertion chain | ✅ fluent .expect() | ❌ manual | ❌ manual |
| TypeScript types | ✅ | ✅ | ✅ RPC-style via testClient |
| WebSocket testing | ❌ | ✅ via inject | Limited |
| Speed vs real HTTP | ~2x faster | ~5x faster | ~5x faster |
Why Integration Test APIs?
Unit tests: test individual functions in isolation
Integration tests: test the full request/response pipeline
What integration tests catch that unit tests miss:
- Route registration bugs (wrong HTTP method, wrong path)
- Middleware chain errors (auth middleware not applied)
- Input validation failures (Zod/Fastify schema rejections)
- Serialization errors (response doesn't match schema)
- Error handler behavior (500 vs 422 vs 404)
- Database integration with test data
Goal: test your API as close to how it's actually used as possible,
without spinning up external services or a real HTTP server port.
supertest
supertest — HTTP integration testing:
Basic usage with Express
import request from "supertest"
import { describe, it, expect } from "vitest"
import { app } from "../src/app" // Express app (not .listen()-ed)
describe("GET /api/packages/:name", () => {
it("returns package data", async () => {
const response = await request(app)
.get("/api/packages/react")
.set("Authorization", "Bearer test-token")
.expect(200)
.expect("Content-Type", /json/)
expect(response.body.name).toBe("react")
expect(response.body.healthScore).toBeGreaterThan(0)
})
it("returns 404 for unknown package", async () => {
await request(app)
.get("/api/packages/nonexistent-package-xyz")
.expect(404)
})
it("returns 401 without auth", async () => {
await request(app)
.get("/api/packages/react")
.expect(401)
})
})
POST with body
import request from "supertest"
import { app } from "../src/app"
import { db } from "../src/db"
describe("POST /api/packages", () => {
afterEach(async () => {
await db.package.deleteMany({ where: { name: "test-package" } })
})
it("creates a package", async () => {
const response = await request(app)
.post("/api/packages")
.set("Authorization", "Bearer admin-token")
.set("Content-Type", "application/json")
.send({ name: "test-package", version: "1.0.0" })
.expect(201)
expect(response.body.id).toBeDefined()
expect(response.body.name).toBe("test-package")
})
it("returns 400 for invalid input", async () => {
const response = await request(app)
.post("/api/packages")
.set("Authorization", "Bearer admin-token")
.send({ name: "" }) // Invalid: empty name
.expect(400)
expect(response.body.errors).toBeDefined()
})
})
Persistent agent (reuse sessions/cookies)
import request from "supertest"
import { app } from "../src/app"
describe("authenticated user flow", () => {
const agent = request.agent(app) // Persists cookies between requests
it("logs in", async () => {
await agent
.post("/auth/login")
.send({ email: "test@example.com", password: "password" })
.expect(200)
// Cookie is now stored in agent
})
it("accesses protected route after login", async () => {
await agent
.get("/api/profile")
.expect(200) // Session cookie sent automatically
})
})
fastify.inject
Fastify — built-in injection testing:
Basic usage
import Fastify from "fastify"
import { describe, it, expect, beforeAll, afterAll } from "vitest"
import { packageRoutes } from "../src/routes/packages"
let fastify: ReturnType<typeof Fastify>
beforeAll(async () => {
fastify = Fastify({ logger: false })
await fastify.register(packageRoutes, { prefix: "/api" })
await fastify.ready()
})
afterAll(async () => {
await fastify.close()
})
describe("GET /api/packages/:name", () => {
it("returns package data", async () => {
const response = await fastify.inject({
method: "GET",
url: "/api/packages/react",
headers: {
Authorization: "Bearer test-token",
},
})
expect(response.statusCode).toBe(200)
expect(response.json().name).toBe("react")
})
it("validates route parameters", async () => {
const response = await fastify.inject({
method: "GET",
url: "/api/packages/", // Missing name
})
expect(response.statusCode).toBe(404)
})
})
POST with body
describe("POST /api/packages", () => {
it("creates a package with valid schema", async () => {
const response = await fastify.inject({
method: "POST",
url: "/api/packages",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer admin-token",
},
payload: {
name: "my-package",
version: "1.0.0",
description: "A test package",
},
})
expect(response.statusCode).toBe(201)
const body = response.json()
expect(body.id).toBeDefined()
})
it("rejects invalid schema", async () => {
const response = await fastify.inject({
method: "POST",
url: "/api/packages",
payload: { name: 123 }, // name should be a string
})
// Fastify's built-in schema validation returns 400:
expect(response.statusCode).toBe(400)
expect(response.json().message).toMatch(/body\/name/)
})
})
App factory pattern (recommended)
// src/app.ts — export a factory function:
import Fastify, { FastifyInstance } from "fastify"
export async function buildApp(opts = {}): Promise<FastifyInstance> {
const app = Fastify({ logger: false, ...opts })
await app.register(import("./plugins/auth"))
await app.register(import("./routes/packages"), { prefix: "/api" })
return app
}
// test/packages.test.ts:
import { buildApp } from "../src/app"
describe("Package API", () => {
let app: Awaited<ReturnType<typeof buildApp>>
beforeAll(async () => {
app = await buildApp()
})
afterAll(async () => {
await app.close()
})
it("responds to health check", async () => {
const res = await app.inject({ method: "GET", url: "/api/health" })
expect(res.statusCode).toBe(200)
})
})
hono/testing
Hono — testClient for all runtimes:
Basic testing
import { describe, it, expect } from "vitest"
import { testClient } from "hono/testing"
import { app } from "../src/app" // Hono app
describe("Hono API", () => {
const client = testClient(app)
it("GET /api/packages/:name", async () => {
const response = await client.api.packages[":name"].$get({
param: { name: "react" },
header: { Authorization: "Bearer test-token" },
})
expect(response.status).toBe(200)
const data = await response.json()
expect(data.name).toBe("react")
})
it("POST /api/packages", async () => {
const response = await client.api.packages.$post({
json: { name: "my-package", version: "1.0.0" },
header: { Authorization: "Bearer admin-token" },
})
expect(response.status).toBe(201)
})
})
Direct fetch approach (works without testClient)
import { app } from "../src/app"
describe("Hono fetch testing", () => {
it("returns package data", async () => {
const req = new Request("http://localhost/api/packages/react", {
headers: { Authorization: "Bearer test-token" },
})
const res = await app.fetch(req)
expect(res.status).toBe(200)
const data = await res.json()
expect(data.name).toBe("react")
})
})
Works in all runtimes
// Same test file works in Node.js (Vitest), Bun, Deno:
// Hono's testClient passes Request objects directly to the app
// No TCP socket, no HTTP server, no runtime-specific code
// For Cloudflare Workers tests (Wrangler + Miniflare):
const env = getMiniflareBindings()
const res = await app.fetch(req, env)
Test Database Strategies
The most expensive part of API integration testing is usually the database layer. You have three practical options, each with different tradeoffs: in-memory SQLite for speed, containerized test databases for accuracy, and transaction rollback for isolation.
In-memory SQLite with better-sqlite3 or sql.js is the fastest option. Your schema gets applied to a fresh in-memory database at test startup, tests run queries against real SQL, and the database disappears when the process exits. The limitation is dialect differences — if you use PostgreSQL-specific features (advisory locks, array operators, ON CONFLICT DO UPDATE), queries that work in production will fail against SQLite. This approach is excellent for CRUD-heavy APIs that stay close to standard SQL.
// vitest.setup.ts — in-memory SQLite for tests:
import Database from "better-sqlite3"
import { drizzle } from "drizzle-orm/better-sqlite3"
const sqlite = new Database(":memory:")
export const testDb = drizzle(sqlite)
// Apply migrations:
sqlite.exec(fs.readFileSync("drizzle/schema.sql", "utf-8"))
Transaction rollback is the cleanest isolation strategy when your test database is a real PostgreSQL instance. Each test begins a transaction, runs its setup and assertions, then rolls back — leaving the database in pristine state for the next test with zero cleanup code.
// fastify.inject with transaction rollback:
let tx: Transaction
beforeEach(async () => {
tx = await db.transaction()
app = await buildApp({ db: tx }) // Inject tx as the db dependency
})
afterEach(async () => {
await tx.rollback()
})
Test containers (the testcontainers npm package) spin up real Docker containers for your database, giving you an exact replica of your production environment. Startup takes 5-15 seconds per suite, but every feature — stored procedures, triggers, extensions, exact type behavior — works identically to production. This is the right choice for complex schemas or when SQLite dialect differences cause too many false passes.
The recommended approach in 2026 is layered: use in-memory SQLite for fast unit-level route tests (the majority), use transaction rollback against a shared PostgreSQL test instance for integration tests that touch complex SQL, and reserve test containers for CI pipelines running the full suite before merges.
Testing Authentication Flows
supertest's request.agent() persists cookies across requests, making session-based auth testing straightforward: log in once, and subsequent requests in the agent carry the session cookie automatically. This mirrors how a real browser session works and is the most convenient option for testing multi-step authenticated flows, OAuth redirects, or CSRF token handling.
For JWT-based APIs — which is most APIs in 2026 — the difference between supertest, fastify.inject, and hono/testing is minimal. You extract the token from a login response and pass it as a header on subsequent calls. The pattern is identical across all three tools.
Mock tokens vs real auth: the choice depends on what you're testing. If you're testing route authorization (does this endpoint return 403 for a non-admin user?), mock tokens are appropriate — create a signed JWT with known claims and inject it directly, avoiding the overhead of a real auth flow. If you're testing the authentication mechanism itself (does the login endpoint set the right cookies, rotate refresh tokens correctly, handle expired tokens?), use real tokens from a real auth call within your test.
// fastify.inject auth helper:
async function authedInject(app, method, url, payload?) {
const loginRes = await app.inject({ method: "POST", url: "/auth/login",
payload: { email: "test@example.com", password: "testpass" } })
const { token } = loginRes.json()
return app.inject({ method, url, payload,
headers: { Authorization: `Bearer ${token}` } })
}
// Mock token for authorization tests (faster, no DB lookup):
function signTestToken(claims: Partial<JWTPayload>) {
return jwt.sign({ sub: "test-user-id", role: "user", ...claims }, TEST_SECRET)
}
One important anti-pattern to avoid: mocking the entire auth middleware in integration tests. If your tests bypass the auth layer, they miss bugs where middleware is applied to the wrong routes, where the token parsing fails silently, or where role checks have off-by-one errors in complex permission systems. The integration test value comes precisely from exercising the full middleware chain.
Testing Error Handling and Edge Cases
Error handler behavior is one of the most undertested areas of API codebases. The happy path gets thorough coverage; error paths often get one test per error type at best. A robust integration test suite covers the full error surface: missing required fields, invalid types, out-of-range values, missing auth, insufficient permissions, resource not found, and server errors with proper status codes.
describe("Error handling", () => {
it("returns 422 with field-level errors for invalid input", async () => {
const res = await fastify.inject({
method: "POST",
url: "/api/packages",
payload: { name: "", version: "not-semver" },
})
expect(res.statusCode).toBe(422)
const body = res.json()
expect(body.errors).toHaveLength(2)
expect(body.errors[0].field).toBe("name")
})
it("returns 429 when rate limit exceeded", async () => {
// Make requests until rate limited:
for (let i = 0; i < 100; i++) {
await request(app).get("/api/packages/react")
}
const res = await request(app).get("/api/packages/react").expect(429)
expect(res.headers["retry-after"]).toBeDefined()
})
it("returns 503 when upstream service is unavailable", async () => {
// Inject a failing dependency:
const app = await buildApp({ registry: new FailingRegistryClient() })
const res = await app.inject({ method: "GET", url: "/api/packages/react" })
expect(res.statusCode).toBe(503)
})
})
Edge cases worth testing explicitly: concurrent requests that might create race conditions, requests with extremely large payloads that should be rejected at the body parser level, requests with unusual content-type headers, and requests where database constraints would be violated. These cases are difficult to test with unit tests because they depend on middleware interaction and database behavior — but they're easy to exercise with inject-style testing.
CI/CD Integration Considerations
Integration tests that touch databases need careful configuration in CI. GitHub Actions provides a services block that spins up Docker containers alongside your job, making database availability automatic without test containers overhead.
# .github/workflows/test.yml
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: testpass
POSTGRES_DB: testdb
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
- run: npm ci
- run: npm run db:migrate
env:
DATABASE_URL: postgresql://postgres:testpass@localhost:5432/testdb
- run: npm test
env:
DATABASE_URL: postgresql://postgres:testpass@localhost:5432/testdb
Parallelizing test suites is the most impactful CI optimization once your suite grows beyond 2-3 minutes. Vitest's --pool=threads option runs test files in parallel threads. For integration tests that share a database, use a separate schema per worker to avoid conflicts:
// vitest.config.ts:
export default defineConfig({
test: {
pool: "threads",
poolOptions: {
threads: {
singleThread: false,
maxThreads: 4,
},
},
// Each worker gets its own DB schema via env var:
env: {
DB_SCHEMA: `test_${process.env.VITEST_WORKER_ID ?? "0"}`,
},
},
})
For very large test suites, Vitest's --shard flag splits the suite across multiple CI jobs: vitest --shard=1/3, vitest --shard=2/3, vitest --shard=3/3 run one-third of test files each, then merge coverage reports. This can cut a 10-minute suite to under 4 minutes with 3 parallel jobs.
Performance Testing: When Integration Tests Are Too Slow
Integration tests exercise the full middleware stack, compile Fastify schemas, and often touch a database — each test call takes anywhere from 5ms (in-process inject, no DB) to 100ms+ (real DB round-trips). A suite of 500 integration tests at 50ms average is 25 seconds — acceptable. At 200ms average (complex queries, slow disk), it's 100 seconds. At that point, suite feedback becomes slow enough to disrupt development flow.
The practical solution is stratifying your test suite. Keep the bulk of tests as fast unit tests and route-level inject tests that mock database calls. Promote a smaller set of true integration tests — the ones that validate SQL correctness, migration compatibility, and data integrity — to a separate suite that runs on push but not on every save. Use Vitest's --project or describe.skip to separate fast and slow suites.
When integration tests themselves become the performance bottleneck, the problem is usually one of: too many tests sharing a single slow database, no connection pooling in test setup, or rebuilding the app instance for each test file instead of sharing it. A shared app instance with transaction-per-test isolation is typically 3-5x faster than creating a fresh app instance per test file.
Testing WebSocket and SSE Routes
supertest does not support WebSocket testing — it's an HTTP library. For WebSocket testing with Express, you need a separate utility like ws connecting to a bound test server port. fastify.inject has limited support: Fastify's inject can simulate upgrade requests but full WebSocket message exchange requires a real server. The recommended pattern for all three frameworks is binding to a random port for WebSocket-specific tests:
// Fastify WebSocket testing (fastify-websocket plugin):
describe("WebSocket routes", () => {
let server: FastifyInstance
let address: string
beforeAll(async () => {
server = await buildApp()
await server.listen({ port: 0 }) // Random available port
address = `ws://localhost:${server.server.address().port}`
})
afterAll(async () => { await server.close() })
it("broadcasts messages to connected clients", async () => {
const ws = new WebSocket(`${address}/api/ws/updates`)
await new Promise<void>((resolve) => ws.on("open", resolve))
const message = await new Promise<string>((resolve) => {
ws.on("message", (data) => resolve(data.toString()))
})
expect(JSON.parse(message)).toMatchObject({ type: "connected" })
ws.close()
})
})
For SSE (Server-Sent Events), Hono's direct fetch approach works cleanly — app.fetch(req) returns a real Response with a ReadableStream body that you can consume in tests without a real server.
Migration from supertest to Framework-Native Testing
Migrating an Express + supertest codebase to Fastify + fastify.inject is incremental: the test structure is nearly identical, with request(app).get(url) becoming app.inject({ method: "GET", url }). The main adjustment is replacing supertest's fluent assertion chain with manual assertions against the inject response object, and replacing response.body with response.json().
The migration order that minimizes disruption: first add fastify.inject tests alongside existing supertest tests for new routes, then migrate existing test files route-by-route during normal feature development, and finally remove the supertest dependency once all tests are migrated. Do not attempt a big-bang migration — the gradual approach lets you catch differences in behavior (Fastify's schema validation error format differs from Express's) and fix them incrementally.
For teams on Hono, the migration is slightly more involved if tests relied on supertest's cookie persistence. Map each request.agent() pattern to a stateless equivalent using explicit token extraction, since Hono's testClient does not have built-in session persistence.
Common Pitfalls
Forgetting to close the server: supertest without request.agent() opens and closes an HTTP server for each test call. But if you create a persistent server (e.g., with request.agent() or by calling app.listen() manually), you must close it in afterAll. An unclosed server prevents the test process from exiting and causes Jest/Vitest to hang. The solution is always pairing beforeAll/afterAll with server setup/teardown.
Test isolation failures: if one test mutates global state — a module-level cache, a singleton counter, or shared test data in the database — subsequent tests may pass or fail depending on execution order. This is the most common source of "flaky" integration tests. Solutions: reset caches in beforeEach, use transaction rollback for database state, and avoid module-level mutable singletons in application code.
Calling .listen() before tests: supertest works best with an Express app that has not been .listen()-ed — it manages the server lifecycle internally. If you call app.listen(3000) in your app module and then import that module in tests, supertest tries to create a second server. Export an unlaunched app from your module and only call .listen() in your entry point.
Schema compilation on every test file with Fastify: Fastify compiles JSON schemas to validators at fastify.ready(). Creating a new Fastify instance per test file means recompiling all schemas for every file — expensive for large apps. Share a single Fastify instance across test files in the same suite using a global setup file, or use the app factory pattern with a test-scoped shared instance.
Integration Testing Philosophy: Test the Real Stack
The distinction between unit tests and integration tests is particularly important for HTTP APIs. Unit tests that mock the router, middleware, and database miss the most common production bugs — wrong HTTP method on a route, middleware applying to the wrong paths, schema validation rejecting valid input, error handling returning 500 instead of 400. Integration tests using supertest, fastify.inject, or hono/testing exercise the full request pipeline from HTTP parsing through middleware, validation, routing, handlers, and response serialization.
The right architecture separates business logic into pure functions that are unit-testable in isolation, then tests the HTTP layer with integration tests using request injection. Database calls should be injectable so integration tests exercise real SQL against a test database rather than mocking the database client. This gives you the highest-confidence test suite: fast unit tests for logic, integration tests for the HTTP contract, and end-to-end tests only for the most critical user flows.
The Move to Framework-Native Testing
supertest was born in the Express era when every Node.js API used Express. It works by creating an HTTP server, binding it to a random port (or using an in-memory connection), and making real HTTP requests. Framework-native testing (fastify.inject, hono's testClient) bypasses the HTTP layer entirely: requests are JavaScript objects passed directly to the route handler, and responses are returned as JavaScript objects. No socket, no TCP, no port management. This approach is 2-5x faster for test suite execution and eliminates waiting for server startup — particularly valuable in CI environments where test run time compounds across hundreds of test files.
The shift is similar to how database integration tests moved from spinning up real databases to using in-memory SQLite or containerized test databases — you gain speed while still testing the real logic. fastify.inject's approach is especially thorough: it exercises Fastify's full plugin lifecycle, schema compilation, serialization, and error handling, just without a real socket. The inject call goes through exactly the same code path as a real HTTP request — the only difference is the transport layer.
For teams already on Fastify or Hono, the decision is straightforward: use the framework's native testing tool. For Express teams, supertest remains the pragmatic choice — one consistent testing interface regardless of the underlying framework.
Integration Testing Philosophy: Test the Real Stack
The distinction between unit tests and integration tests is particularly important for HTTP APIs. Unit tests that mock the router, middleware, and database miss the most common production bugs — wrong HTTP method on a route, middleware applying to the wrong paths, schema validation rejecting valid input, error handling returning 500 instead of 400. Integration tests using supertest, fastify.inject, or hono/testing exercise the full request pipeline from HTTP parsing through middleware, validation, routing, handlers, and response serialization. The goal is testing your API the way a real client uses it, with zero mocking of the HTTP layer.
The trade-off is real: integration tests are slower than unit tests because they exercise more code. They touch route registration logic, plugin initialization, schema compilation, and sometimes database connections. But this breadth is precisely why they're valuable — the bugs that slip past unit tests are almost always the bugs that live in the gaps between components. A unit test can prove that your validation function correctly rejects an empty name field, yet the integration test can reveal that your validation middleware was never registered for the POST route. The integration test catches it; the unit test doesn't.
The right architecture separates business logic into pure functions that are unit-testable in isolation, then tests the HTTP layer with integration tests using request injection. Database calls should be injectable (via dependency injection or test database seeding) so integration tests exercise real SQL against a test database rather than mocking the database client. This gives you the highest-confidence test suite: fast unit tests for logic, integration tests for the HTTP contract, and end-to-end tests only for the most critical user flows.
The Move to Framework-Native Testing
supertest was born in the Express era when every Node.js API used Express. It works by creating an HTTP server, binding it to a random port (or using an in-memory connection), and making real HTTP requests. This is "real" but has overhead — port binding, TCP stack, HTTP parsing — all of which add latency to every test run.
Framework-native testing (fastify.inject, hono's testClient) bypasses the HTTP layer entirely: requests are JavaScript objects passed directly to the route handler, and responses are returned as JavaScript objects. No socket, no TCP, no port management. This approach is 2-5x faster for test suite execution and eliminates the need to wait for server startup — particularly valuable in CI environments where test run time compounds across hundreds of test files.
The shift is similar to how database integration tests moved from spinning up real databases to using in-memory SQLite or containerized test databases — you gain speed while still testing the real logic. fastify.inject's approach is especially thorough: it exercises Fastify's full plugin lifecycle, schema compilation, serialization, and error handling, just without a real socket. The inject call goes through exactly the same code path as a real HTTP request — the only difference is the transport layer.
For teams already on Fastify or Hono, the decision is straightforward: use the framework's native testing tool. The framework authors designed it specifically for testing their framework's features. For Express teams or those with polyglot server environments (some Express, some Fastify), supertest remains the pragmatic choice — one consistent testing interface regardless of the underlying framework.
Authentication Testing Patterns
One area where testing approaches differ significantly is authentication. supertest's request.agent() persists cookies across requests, making session-based auth testing straightforward: log in once, and subsequent requests in the agent carry the session cookie automatically. This mirrors how a real browser session works, making it natural to test multi-step authenticated flows.
fastify.inject requires manually threading auth headers between requests — you'd extract the JWT from a login response and pass it to subsequent inject calls. For stateless JWT-based APIs, this is a minor inconvenience. For session-based auth with complex cookie management (rotating session tokens, SameSite policies, secure flags), supertest's agent abstraction is meaningfully more convenient.
For most modern APIs using JWT bearer tokens, the difference is minimal: inject the Authorization header on each request. Both approaches benefit from test factories that create authenticated request helpers, keeping test code clean:
// fastify.inject auth helper:
async function authedInject(app, method, url, payload?) {
const loginRes = await app.inject({ method: "POST", url: "/auth/login",
payload: { email: "test@example.com", password: "testpass" } })
const { token } = loginRes.json()
return app.inject({ method, url, payload,
headers: { Authorization: `Bearer ${token}` } })
}
The factory pattern normalizes the difference: whether you're using supertest, fastify.inject, or hono/testing, auth setup happens once in a shared helper, and test cases stay focused on the route behavior they're actually testing.
Methodology
Download data from npm registry (weekly average, February 2026). Feature comparison based on supertest v7.x, fastify v5.x, and hono v4.x.
Compare testing and API framework packages on PkgPulse →
See also: Fastify vs Hono and Fastify vs Koa, better-sqlite3 vs libsql vs sql.js, h3 vs polka vs koa lightweight http frameworks.