supertest vs fastify.inject vs hono/testing: API Integration Testing (2026)
TL;DR
supertest 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 starting a real server. fastify.inject is Fastify's built-in request injection — tests Fastify routes in-process with zero overhead, no HTTP port needed. hono/testing is Hono's testing helper — works in any runtime (Node, Bun, Deno, Cloudflare Workers), passes a Request object directly to your app. 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: ~5M weekly downloads — Express standard, wraps any
http.Server, rich assertion chain - fastify.inject: bundled with Fastify — zero-overhead in-process injection, no real HTTP
- hono/testing: bundled with Hono — 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
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)
Feature Comparison
| Feature | supertest | fastify.inject | hono/testing |
|---|---|---|---|
| Framework | Any Node.js server | Fastify only | Hono only |
| Transport | In-memory HTTP | Pure in-process | Pure in-process |
| Runtime support | Node.js | Node.js | Any (Node, Bun, Deno, CF Workers) |
| Cookie handling | ✅ (agent) | ✅ | Manual |
| Assertion chain | ✅ (fluent) | ❌ (manual) | ❌ (manual) |
| TypeScript types | ✅ | ✅ | ✅ (RPC-style) |
| Weekly downloads | ~5M | bundled w/ Fastify | bundled w/ Hono |
When to Use Each
Use supertest if:
- Testing Express, Koa, or any Node.js HTTP server
- Team is already familiar with supertest's fluent assertion chain
- Need cookie/session testing with
request.agent() - Migrating existing Express tests
Use fastify.inject if:
- Building a Fastify application — it's zero-dependency, built in
- Testing Fastify's schema validation behavior
- Need full plugin/decorator lifecycle in tests
Use hono/testing (testClient) if:
- Building a Hono application
- Need tests that run in multiple runtimes (Node, Bun, Cloudflare Workers)
- Want type-safe route testing with RPC-style client
Tip: Always test the real app, not mocks
// ❌ Too isolated — misses middleware, validation, error handlers:
const handler = require("./routes/packages")
const result = handler({ params: { name: "react" } }, mockRes)
// ✅ Tests the full stack:
const response = await app.inject({ method: "GET", url: "/api/packages/react" })
Methodology
Download data from npm registry (weekly average, February 2026). Feature comparison based on supertest v7.x, fastify v5.x, and hono v4.x.