Fastify Testing: A Complete Guide with Code Examples

Fastify Testing: A Complete Guide with Code Examples

Fastify is one of the fastest Node.js web frameworks, and it has excellent built-in testing support through fastify.inject(). Most Fastify teams never get past console.log debugging.

Here's how to build a proper Fastify test suite.

Fastify's Testing Advantage

Fastify was designed to be testable. The inject() method fires requests directly through the application without starting an HTTP server. It's fast, accurate, and requires no port management.

// Basic structure
const fastify = require('fastify')({ logger: false });

fastify.get('/health', async (request, reply) => {
  return { status: 'ok' };
});

// Test
const response = await fastify.inject({
  method: 'GET',
  url: '/health',
});

console.assert(response.statusCode === 200);

Setting Up Tests

npm install --save-dev tap @tap/snapshot
# or with vitest:
npm install --save-dev vitest

This guide uses tap (Fastify's default) and vitest (popular alternative). The concepts apply to both.

// test/app.test.js (using tap)
const { test } = require('tap');
const build = require('../src/app');

test('health check', async (t) => {
  const app = build();
  
  const response = await app.inject({
    method: 'GET',
    url: '/health',
  });
  
  t.equal(response.statusCode, 200);
  t.same(response.json(), { status: 'ok' });
  
  await app.close();
});

Structuring Testable Fastify Apps

The key to testable Fastify apps is the builder pattern — a function that creates and returns the app:

// src/app.js
const fastify = require('fastify');

function build(opts = {}) {
  const app = fastify(opts);
  
  // Register plugins
  app.register(require('./plugins/database'));
  app.register(require('./plugins/auth'));
  
  // Register routes
  app.register(require('./routes/users'), { prefix: '/users' });
  app.register(require('./routes/products'), { prefix: '/products' });
  
  return app;
}

module.exports = build;
// test/helpers.js
const build = require('../src/app');

function buildTest() {
  const app = build({
    logger: false,  // Silence logs in tests
    // Override DB connection with test DB
    db: { url: process.env.TEST_DATABASE_URL || 'sqlite::memory:' },
  });
  
  return app;
}

module.exports = { buildTest };

Testing Routes

// test/routes/users.test.js
const { test } = require('tap');
const { buildTest } = require('../helpers');

test('GET /users', async (t) => {
  const app = buildTest();
  t.teardown(() => app.close());
  
  const response = await app.inject({
    method: 'GET',
    url: '/users',
  });
  
  t.equal(response.statusCode, 200);
  const body = response.json();
  t.ok(Array.isArray(body), 'response is an array');
});

test('GET /users/:id — found', async (t) => {
  const app = buildTest();
  t.teardown(() => app.close());
  
  // Insert test data
  await app.db.user.create({ data: { email: 'alice@test.com', id: 1 } });
  
  const response = await app.inject({
    method: 'GET',
    url: '/users/1',
  });
  
  t.equal(response.statusCode, 200);
  t.equal(response.json().email, 'alice@test.com');
});

test('GET /users/:id — not found', async (t) => {
  const app = buildTest();
  t.teardown(() => app.close());
  
  const response = await app.inject({
    method: 'GET',
    url: '/users/99999',
  });
  
  t.equal(response.statusCode, 404);
});

test('POST /users — valid data', async (t) => {
  const app = buildTest();
  t.teardown(() => app.close());
  
  const response = await app.inject({
    method: 'POST',
    url: '/users',
    payload: { email: 'new@test.com', name: 'Alice' },
  });
  
  t.equal(response.statusCode, 201);
  const body = response.json();
  t.equal(body.email, 'new@test.com');
  t.ok(body.id, 'id is present');
});

test('POST /users — missing email', async (t) => {
  const app = buildTest();
  t.teardown(() => app.close());
  
  const response = await app.inject({
    method: 'POST',
    url: '/users',
    payload: { name: 'Alice' },  // email missing
  });
  
  t.equal(response.statusCode, 400);
  const body = response.json();
  t.ok(body.message.includes('email'));
});

Testing JSON Schema Validation

Fastify validates request and response schemas automatically. Test both valid and invalid cases:

// src/routes/products.js
const productSchema = {
  body: {
    type: 'object',
    required: ['name', 'price'],
    properties: {
      name: { type: 'string', minLength: 1 },
      price: { type: 'number', minimum: 0.01 },
      stock: { type: 'integer', minimum: 0, default: 0 },
    },
  },
};

async function productRoutes(fastify) {
  fastify.post('/', { schema: productSchema }, async (request, reply) => {
    const product = await fastify.db.product.create({ data: request.body });
    return reply.code(201).send(product);
  });
}
// test/routes/products.test.js
test('POST /products — rejects invalid price type', async (t) => {
  const app = buildTest();
  t.teardown(() => app.close());
  
  const response = await app.inject({
    method: 'POST',
    url: '/products',
    payload: { name: 'Widget', price: 'not-a-number' },
  });
  
  t.equal(response.statusCode, 400);
  // Fastify's error message for schema validation
  t.ok(response.json().message);
});

test('POST /products — rejects price below minimum', async (t) => {
  const app = buildTest();
  t.teardown(() => app.close());
  
  const response = await app.inject({
    method: 'POST',
    url: '/products',
    payload: { name: 'Widget', price: 0 },
  });
  
  t.equal(response.statusCode, 400);
});

test('POST /products — stock defaults to 0 when not provided', async (t) => {
  const app = buildTest();
  t.teardown(() => app.close());
  
  const response = await app.inject({
    method: 'POST',
    url: '/products',
    payload: { name: 'Widget', price: 29.99 },
  });
  
  t.equal(response.statusCode, 201);
  t.equal(response.json().stock, 0);
});

Testing Authentication

Test auth at both the middleware level and the route level:

// test/auth.test.js
const { test } = require('tap');
const { buildTest } = require('./helpers');
const { sign } = require('jsonwebtoken');

test('protected routes block unauthenticated requests', async (t) => {
  const app = buildTest();
  t.teardown(() => app.close());
  
  const response = await app.inject({
    method: 'GET',
    url: '/profile',
  });
  
  t.equal(response.statusCode, 401);
});

test('protected routes allow valid JWT', async (t) => {
  const app = buildTest();
  t.teardown(() => app.close());
  
  const token = sign({ sub: '1', email: 'user@test.com' }, 'test-secret');
  
  const response = await app.inject({
    method: 'GET',
    url: '/profile',
    headers: { Authorization: `Bearer ${token}` },
  });
  
  t.equal(response.statusCode, 200);
});

test('admin routes block non-admin users', async (t) => {
  const app = buildTest();
  t.teardown(() => app.close());
  
  const token = sign({ sub: '1', role: 'user' }, 'test-secret');
  
  const response = await app.inject({
    method: 'GET',
    url: '/admin/users',
    headers: { Authorization: `Bearer ${token}` },
  });
  
  t.equal(response.statusCode, 403);
});

Testing Plugins

Fastify plugins (decorators, hooks) need their own tests:

// test/plugins/rate-limit.test.js
test('rate limiter blocks excessive requests', async (t) => {
  const app = buildTest({ rateLimit: { max: 5, timeWindow: '1 minute' } });
  t.teardown(() => app.close());
  
  // Make 5 requests (within limit)
  for (let i = 0; i < 5; i++) {
    const res = await app.inject({ method: 'GET', url: '/api/data' });
    t.equal(res.statusCode, 200, `Request ${i + 1} should succeed`);
  }
  
  // 6th request should be blocked
  const blockedRes = await app.inject({ method: 'GET', url: '/api/data' });
  t.equal(blockedRes.statusCode, 429, 'Request 6 should be rate limited');
  t.ok(blockedRes.headers['retry-after'], 'Retry-After header should be present');
});

What inject() Misses

fastify.inject() is excellent for logic testing. It doesn't test:

  • Real TCP behavior — connection pooling, keep-alive, timeouts under network conditions
  • SSL/TLS — your HTTPS configuration in production may differ from development
  • Load behavior — Fastify performs differently under concurrent connections than sequential injections
  • File uploads — multipart form handling sometimes behaves differently with real HTTP multipart boundaries

Continuous Monitoring for Fastify APIs

Once deployed, use HelpMeTest to run behavioral tests against your live Fastify service:

Test: API health and user creation
GET /health → 200, { status: "ok" }
POST /users → 201, body has id
GET /users/:id → 200, returns created user
All responses under 100ms (Fastify should be fast)

Set these tests to run every 5 minutes. If your database connection pool exhausts after a deploy, your auth plugin stops working, or a dependency update silently breaks a route, you catch it before users do.

Free tier: 10 tests, unlimited health checks. Try HelpMeTest →

Fastify Testing Checklist

  • App built with a builder function — testable in isolation
  • inject() tests for every route: 200, 201, 400, 404, 401, 403
  • JSON schema validation tested: valid data passes, invalid data returns 400
  • Auth tested: unauthenticated returns 401, unauthorized role returns 403
  • Plugin behavior tested: hooks, decorators, preHandlers
  • Rate limiting tested if applicable
  • Error serialization: errors return correct JSON format
  • app.close() called in teardown — prevents port conflicts
  • Production monitoring: behavioral tests running continuously

fastify.inject() makes testing easy. There's no reason not to use it.

Read more