Testing Fastify Components: From Unit to E2E Tests
Fastify is a high-performance Node.js web framework known for its low overhead and developer-friendly plugin architecture. But speed means little without reliability — and reliability comes from a solid testing strategy. In this tutorial, you'll learn how to test Fastify applications across the full spectrum: isolated unit tests, integration tests for plugins and routes, and end-to-end (E2E) tests that exercise your entire HTTP surface.
Why Testing Fastify Matters
Fastify's modular design — built around plugins, decorators, hooks, and schemas — makes it easy to compose complex applications from small pieces. That same modularity is what makes testing approachable. Each layer can be validated independently before being wired into the whole. A robust test suite gives you confidence to refactor, upgrade dependencies, and ship features without fear of regressions.
- Unit tests validate individual functions, decorators, and business logic in isolation.
- Integration tests verify that plugins, routes, and hooks work together correctly.
- E2E tests exercise the real HTTP interface as an external client would.
Project Setup
Start by creating a new project and installing the necessary dependencies. We'll use tap (Fastify's recommended test runner), but the patterns apply equally to Jest or Vitest.
mkdir fastify-testing-demo
cd fastify-testing-demo
npm init -y
npm install fastify
npm install -D tap pino-pretty
Create a basic application structure:
// src/app.js
const buildApp = async (options = {}) => {
const fastify = require('fastify')(options);
fastify.decorate('formatGreeting', (name) => {
return `Hello, ${name}!`;
});
fastify.get('/health', async () => {
return { status: 'ok' };
});
fastify.get('/greet/:name', async (request, reply) => {
const { name } = request.params;
return { message: fastify.formatGreeting(name) };
});
await fastify.register(require('./plugins/userPlugin'));
return fastify;
};
module.exports = { buildApp };
Notice that we export a buildApp factory rather than a singleton. This is a critical pattern for testing — each test gets a fresh instance.
Unit Testing Decorators and Utilities
Unit tests focus on the smallest building blocks. Decorators and pure utility functions are ideal candidates. Because formatGreeting is attached to the Fastify instance, we can test it by building a minimal app or by extracting the logic into a separate module.
// test/unit/formatGreeting.test.js
const { test } = require('tap');
const { buildApp } = require('../../src/app');
test('formatGreeting returns a personalized message', async (t) => {
const app = await buildApp({ logger: false });
t.equal(app.formatGreeting('World'), 'Hello, World!');
t.equal(app.formatGreeting('Fastify'), 'Hello, Fastify!');
await app.close();
t.end();
});
test('formatGreeting handles empty string', async (t) => {
const app = await buildApp({ logger: false });
t.equal(app.formatGreeting(''), 'Hello, !');
await app.close();
t.end();
});
For pure business logic, prefer extracting it into its own module so you can test without spinning up a Fastify instance at all:
// src/utils/greeting.js
const formatGreeting = (name) => `Hello, ${name}!`;
module.exports = { formatGreeting };
// test/unit/greeting.test.js
const { test } = require('tap');
const { formatGreeting } = require('../../src/utils/greeting');
test('formatGreeting produces correct output', (t) => {
t.equal(formatGreeting('World'), 'Hello, World!');
t.end();
});
Testing Plugins
Fastify plugins encapsulate functionality and can be tested in isolation by registering them into a fresh instance. Let's create a user plugin that adds a decorator and a route.
// src/plugins/userPlugin.js
module.exports = async function userPlugin(fastify, options) {
const users = options.users || [];
fastify.decorate('findUser', (id) => {
return users.find((u) => u.id === id);
});
fastify.get('/users/:id', async (request, reply) => {
const user = fastify.findUser(Number(request.params.id));
if (!user) {
return reply.code(404).send({ error: 'User not found' });
}
return user;
});
};
Now test the plugin by registering it into a throwaway Fastify instance with controlled inputs:
// test/integration/userPlugin.test.js
const { test } = require('tap');
const Fastify = require('fastify');
const userPlugin = require('../../src/plugins/userPlugin');
const mockUsers = [
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
];
async function buildTestApp() {
const app = Fastify({ logger: false });
await app.register(userPlugin, { users: mockUsers });
return app;
}
test('findUser decorator returns matching user', async (t) => {
const app = await buildTestApp();
t.same(app.findUser(1), { id: 1, name: 'Alice' });
t.equal(app.findUser(999), undefined);
await app.close();
t.end();
});
test('GET /users/:id returns 200 for existing user', async (t) => {
const app = await buildTestApp();
const response = await app.inject({
method: 'GET',
url: '/users/1',
});
t.equal(response.statusCode, 200);
t.same(JSON.parse(response.payload), { id: 1, name: 'Alice' });
await app.close();
t.end();
});
test('GET /users/:id returns 404 for missing user', async (t) => {
const app = await buildTestApp();
const response = await app.inject({
method: 'GET',
url: '/users/999',
});
t.equal(response.statusCode, 404);
t.same(JSON.parse(response.payload), { error: 'User not found' });
await app.close();
t.end();
});
The app.inject() method is Fastify's secret weapon for testing. It dispatches requests through the full request lifecycle — including hooks, serializers, and error handlers — without binding to a network port. This makes tests fast and deterministic.
Testing Routes and Schemas
Fastify's schema validation is a first-class feature. You should test both the happy path and validation failures. Let's add a POST route with a JSON schema.
// src/routes/createUser.js
module.exports = async function createUserRoute(fastify) {
fastify.post('/users', {
schema: {
body: {
type: 'object',
required: ['name', 'email'],
properties: {
name: { type: 'string', minLength: 1 },
email: { type: 'string', format: 'email' },
},
},
},
handler: async (request, reply) => {
const { name, email } = request.body;
const user = { id: Date.now(), name, email };
return reply.code(201).send(user);
},
});
};
// test/integration/createUser.test.js
const { test } = require('tap');
const Fastify = require('fastify');
const createUserRoute = require('../../src/routes/createUser');
async function buildApp() {
const app = Fastify({ logger: false });
await app.register(createUserRoute);
return app;
}
test('POST /users creates a user with valid input', async (t) => {
const app = await buildApp();
const response = await app.inject({
method: 'POST',
url: '/users',
payload: { name: 'Charlie', email: 'charlie@example.com' },
});
t.equal(response.statusCode, 201);
const body = JSON.parse(response.payload);
t.equal(body.name, 'Charlie');
t.equal(body.email, 'charlie@example.com');
t.ok(body.id, 'should have an id');
await app.close();
t.end();
});
test('POST /users rejects missing required fields', async (t) => {
const app = await buildApp();
const response = await app.inject({
method: 'POST',
url: '/users',
payload: { name: 'Charlie' },
});
t.equal(response.statusCode, 400);
await app.close();
t.end();
});
test('POST /users rejects invalid email format', async (t) => {
const app = await buildApp();
const response = await app.inject({
method: 'POST',
url: '/users',
payload: { name: 'Charlie', email: 'not-an-email' },
});
t.equal(response.statusCode, 400);
await app.close();
t.end();
});
Testing Hooks
Hooks like onRequest, preHandler, and onResponse are often used for authentication, logging, or request transformation. Test them by observing their side effects or by checking the response.
// src/plugins/authPlugin.js
module.exports = async function authPlugin(fastify) {
fastify.decorate('verifyToken', (token) => {
return token === 'valid-secret-token';
});
fastify.addHook('onRequest', async (request, reply) => {
if (request.url === '/public') return;
const auth = request.headers.authorization;
if (!auth || !fastify.verifyToken(auth.replace('Bearer ', ''))) {
return reply.code(401).send({ error: 'Unauthorized' });
}
});
fastify.get('/protected', async () => ({ data: 'secret' }));
fastify.get('/public', async () => ({ data: 'open' }));
};
// test/integration/authPlugin.test.js
const { test } = require('tap');
const Fastify = require('fastify');
const authPlugin = require('../../src/plugins/authPlugin');
async function buildApp() {
const app = Fastify({ logger: false });
await app.register(authPlugin);
return app;
}
test('protected route rejects requests without token', async (t) => {
const app = await buildApp();
const response = await app.inject({ method: 'GET', url: '/protected' });
t.equal(response.statusCode, 401);
await app.close();
t.end();
});
test('protected route accepts valid token', async (t) => {
const app = await buildApp();
const response = await app.inject({
method: 'GET',
url: '/protected',
headers: { authorization: 'Bearer valid-secret-token' },
});
t.equal(response.statusCode, 200);
t.same(JSON.parse(response.payload), { data: 'secret' });
await app.close();
t.end();
});
test('public route is accessible without token', async (t) => {
const app = await buildApp();
const response = await app.inject({ method: 'GET', url: '/public' });
t.equal(response.statusCode, 200);
await app.close();
t.end();
});
End-to-End Testing
E2E tests treat the application as a black box. You build the full app, start it on a real port, and make HTTP requests using a client like undici or Node's built-in fetch. This catches issues that inject might miss, such as port binding, middleware ordering across plugins, and real serialization.
npm install -D undici
// test/e2e/app.e2e.test.js
const { test } = require('tap');
const { fetch } = require('undici');
const { buildApp } = require('../../src/app');
let app;
let baseUrl;
test('setup', async (t) => {
app = await buildApp({ logger: false });
await app.listen({ port: 0, host: '127.0.0.1' });
const address = app.server.address();
baseUrl = `http://127.0.0.1:${address.port}`;
t.end();
});
test('GET /health returns ok status', async (t) => {
const response = await fetch(`${baseUrl}/health`);
t.equal(response.status, 200);
const body = await response.json();
t.same(body, { status: 'ok' });
});
test('GET /greet/:name returns greeting', async (t) => {
const response = await fetch(`${baseUrl}/greet/Fastify`);
t.equal(response.status, 200);
const body = await response.json();
t.equal(body.message, 'Hello, Fastify!');
});
test('teardown', async () => {
await app.close();
});
Using port: 0 lets the operating system assign an available port, preventing conflicts when tests run in parallel.
Testing with a Database
Most real applications interact with a database. The key principle is to use a test database that is reset between test runs. Here's an example using an in-memory data store pattern that can be swapped for a real database connection.
// src/plugins/dataPlugin.js
module.exports = async function dataPlugin(fastify, options) {
const store = options.store || new Map();
fastify.decorate('db', {
async save(key, value) {
store.set(key, value);
return value;
},
async get(key) {
return store.get(key);
},
async clear() {
store.clear();
},
});
};
// test/integration/dataRoute.test.js
const { test } = require('tap');
const Fastify = require('fastify');
const dataPlugin = require('../../src/plugins/dataPlugin');
async function buildApp() {
const app = Fastify({ logger: false });
await app.register(dataPlugin, { store: new Map() });
app.post('/items/:key', async (request) => {
const saved = await app.db.save(request.params.key, request.body.value);
return { saved };
});
app.get('/items/:key', async (request, reply) => {
const value = await app.db.get(request.params.key);
if (!value) return reply.code(404).send({ error: 'Not found' });
return { value };
});
return app;
}
test('save and retrieve an item', async (t) => {
const app = await buildApp();
await app.inject({
method: 'POST',
url: '/items/foo',
payload: { value: 'bar' },
});
const response = await app.inject({ method: 'GET', url: '/items/foo' });
t.equal(response.statusCode, 200);
t.same(JSON.parse(response.payload), { value: 'bar' });
await app.close();
t.end();
});
Best Practices
- Use a factory function to build your app. Never export a singleton — it makes isolation between tests impossible.
- Always close the app after each test with
app.close()to release resources and prevent memory leaks. - Prefer
injectover real HTTP for route and integration tests. Reserve real port binding for true E2E tests. - Test the contract, not the implementation. Assert on status codes, payloads, and observable side effects rather than internal function calls.
- Reset state between tests. Clear databases, caches, and mocks so tests remain independent and deterministic.
- Test validation explicitly. Fastify's schema validation is powerful — verify both valid and invalid inputs to catch schema regressions.
- Disable logging in tests with
logger: falseto keep output clean, or use a custom logger that captures logs for assertions. - Organize tests by layer. Keep
unit/,integration/, ande2e/directories so you can run them selectively. - Mock external services at the boundary. Don't hit real third-party APIs in automated tests — use libraries like
nockor inject fake plugins.
Running Tests
Add scripts to your package.json to run tests by category:
{
"scripts": {
"test": "tap",
"test:unit": "tap test/unit/**/*.test.js",
"test:integration": "tap test/integration/**/*.test.js",
"test:e2e": "tap test/e2e/**/*.test.js"
}
}
Run the full suite with npm test, or target a specific layer during development for faster feedback loops.
Conclusion
Testing Fastify applications is straightforward once you adopt the factory pattern and leverage inject for fast, in-process request simulation. By layering your tests — unit tests for pure logic, integration tests for plugins and routes, and E2E tests for the full HTTP surface — you build a safety net that catches regressions at the right level of granularity. Start with unit tests for your business logic, add integration tests as you compose plugins, and introduce E2E tests for critical user flows. With these patterns in place, you can ship Fastify features with confidence and speed.