← Back to DevBytes

Testing Fastify Components: From Unit to E2E Tests

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.

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

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.

— Ad —

Google AdSense will appear here after approval

← Back to all articles