CtrlK
BlogDocsLog inGet started
Tessl Logo

mcollina/fastify-best-practices

Guides development of Fastify Node.js backend servers and REST APIs using TypeScript or JavaScript. Use when building, configuring, or debugging a Fastify application — including defining routes, implementing plugins, setting up JSON Schema validation, handling errors, optimising performance, managing authentication, configuring CORS and security headers, integrating databases, working with WebSockets, and deploying to production. Covers the full Fastify request lifecycle (hooks, serialization, logging with Pino) and TypeScript integration via strip types. Trigger terms: Fastify, Node.js server, REST API, API routes, backend framework, fastify.config, server.ts, app.ts.

95

Quality

95%

Does it follow best practices?

Impact

Pending

No eval scenarios have been run

SecuritybySnyk

Passed

No known issues

Overview
Quality
Evals
Security
Files

testing.mdrules/

name:
testing
description:
Testing Fastify applications with inject()
metadata:
{"tags":"testing, inject, node-test, integration, unit"}

Testing Fastify Applications

Using inject() for Request Testing

Fastify's inject() method simulates HTTP requests without network overhead:

import { describe, it, before, after } from 'node:test';
import Fastify from 'fastify';
import { buildApp } from './app.js';

describe('User API', () => {
  let app;

  before(async () => {
    app = await buildApp();
    await app.ready();
  });

  after(async () => {
    await app.close();
  });

  it('should return users list', async (t) => {
    const response = await app.inject({
      method: 'GET',
      url: '/users',
    });

    t.assert.equal(response.statusCode, 200);
    t.assert.equal(response.headers['content-type'], 'application/json; charset=utf-8');

    const body = response.json();
    t.assert.ok(Array.isArray(body.users));
  });

  it('should create a user', async (t) => {
    const response = await app.inject({
      method: 'POST',
      url: '/users',
      payload: {
        name: 'John Doe',
        email: 'john@example.com',
      },
    });

    t.assert.equal(response.statusCode, 201);

    const body = response.json();
    t.assert.equal(body.name, 'John Doe');
    t.assert.ok(body.id);
  });
});

Testing with Headers and Authentication

Test authenticated endpoints:

describe('Protected Routes', () => {
  let app;
  let authToken;

  before(async () => {
    app = await buildApp();
    await app.ready();

    // Get auth token
    const loginResponse = await app.inject({
      method: 'POST',
      url: '/auth/login',
      payload: {
        email: 'test@example.com',
        password: 'password123',
      },
    });

    authToken = loginResponse.json().token;
  });

  after(async () => {
    await app.close();
  });

  it('should reject unauthenticated requests', async (t) => {
    const response = await app.inject({
      method: 'GET',
      url: '/profile',
    });

    t.assert.equal(response.statusCode, 401);
  });

  it('should return profile for authenticated user', async (t) => {
    const response = await app.inject({
      method: 'GET',
      url: '/profile',
      headers: {
        authorization: `Bearer ${authToken}`,
      },
    });

    t.assert.equal(response.statusCode, 200);
    t.assert.equal(response.json().email, 'test@example.com');
  });
});

Testing Query Parameters

Test routes with query strings:

it('should filter users by status', async (t) => {
  const response = await app.inject({
    method: 'GET',
    url: '/users',
    query: {
      status: 'active',
      page: '1',
      limit: '10',
    },
  });

  t.assert.equal(response.statusCode, 200);
  const body = response.json();
  t.assert.ok(body.users.every((u) => u.status === 'active'));
});

// Or use URL with query string
it('should search users', async (t) => {
  const response = await app.inject({
    method: 'GET',
    url: '/users?q=john&sort=name',
  });

  t.assert.equal(response.statusCode, 200);
});

Testing URL Parameters

Test routes with path parameters:

it('should return user by id', async (t) => {
  const userId = 'user-123';

  const response = await app.inject({
    method: 'GET',
    url: `/users/${userId}`,
  });

  t.assert.equal(response.statusCode, 200);
  t.assert.equal(response.json().id, userId);
});

it('should return 404 for non-existent user', async (t) => {
  const response = await app.inject({
    method: 'GET',
    url: '/users/non-existent',
  });

  t.assert.equal(response.statusCode, 404);
});

Testing Validation Errors

Test schema validation:

describe('Validation', () => {
  it('should reject invalid email', async (t) => {
    const response = await app.inject({
      method: 'POST',
      url: '/users',
      payload: {
        name: 'John',
        email: 'not-an-email',
      },
    });

    t.assert.equal(response.statusCode, 400);
    const body = response.json();
    t.assert.ok(body.message.includes('email'));
  });

  it('should reject missing required fields', async (t) => {
    const response = await app.inject({
      method: 'POST',
      url: '/users',
      payload: {
        name: 'John',
        // missing email
      },
    });

    t.assert.equal(response.statusCode, 400);
  });

  it('should coerce query parameters', async (t) => {
    const response = await app.inject({
      method: 'GET',
      url: '/items?limit=10&active=true',
    });

    t.assert.equal(response.statusCode, 200);
    // limit is coerced to number, active to boolean
  });
});

Testing File Uploads

Test multipart form data:

import { createReadStream } from 'node:fs';
import FormData from 'form-data';

it('should upload file', async (t) => {
  const form = new FormData();
  form.append('file', createReadStream('./test/fixtures/test.pdf'));
  form.append('name', 'test-document');

  const response = await app.inject({
    method: 'POST',
    url: '/upload',
    payload: form,
    headers: form.getHeaders(),
  });

  t.assert.equal(response.statusCode, 200);
  t.assert.ok(response.json().fileId);
});

Testing Streams

Test streaming responses:

it('should stream large file', async (t) => {
  const response = await app.inject({
    method: 'GET',
    url: '/files/large-file',
  });

  t.assert.equal(response.statusCode, 200);
  t.assert.ok(response.rawPayload.length > 0);
});

Mocking Dependencies

Mock external services and databases:

import { describe, it, before, after, mock } from 'node:test';

describe('User Service', () => {
  let app;

  before(async () => {
    // Create app with mocked dependencies
    const mockDb = {
      users: {
        findAll: mock.fn(async () => [
          { id: '1', name: 'User 1' },
          { id: '2', name: 'User 2' },
        ]),
        findById: mock.fn(async (id) => {
          if (id === '1') return { id: '1', name: 'User 1' };
          return null;
        }),
        create: mock.fn(async (data) => ({ id: 'new-id', ...data })),
      },
    };

    app = Fastify();
    app.decorate('db', mockDb);
    app.register(import('./routes/users.js'));
    await app.ready();
  });

  after(async () => {
    await app.close();
  });

  it('should call findAll', async (t) => {
    const response = await app.inject({
      method: 'GET',
      url: '/users',
    });

    t.assert.equal(response.statusCode, 200);
    t.assert.equal(app.db.users.findAll.mock.calls.length, 1);
  });
});

Testing Plugins in Isolation

Test plugins independently:

import { describe, it, before, after } from 'node:test';
import Fastify from 'fastify';
import cachePlugin from './plugins/cache.js';

describe('Cache Plugin', () => {
  let app;

  before(async () => {
    app = Fastify();
    app.register(cachePlugin, { ttl: 1000 });
    await app.ready();
  });

  after(async () => {
    await app.close();
  });

  it('should decorate fastify with cache', (t) => {
    t.assert.ok(app.hasDecorator('cache'));
    t.assert.equal(typeof app.cache.get, 'function');
    t.assert.equal(typeof app.cache.set, 'function');
  });

  it('should cache and retrieve values', (t) => {
    app.cache.set('key', 'value');
    t.assert.equal(app.cache.get('key'), 'value');
  });
});

Testing Hooks

Test hook behavior:

describe('Hooks', () => {
  it('should add request id header', async (t) => {
    const response = await app.inject({
      method: 'GET',
      url: '/health',
    });

    t.assert.ok(response.headers['x-request-id']);
  });

  it('should log request timing', async (t) => {
    const logs = [];
    const app = Fastify({
      logger: {
        level: 'info',
        stream: {
          write: (msg) => logs.push(JSON.parse(msg)),
        },
      },
    });

    app.register(import('./app.js'));
    await app.ready();

    await app.inject({ method: 'GET', url: '/health' });

    const responseLog = logs.find((l) => l.msg?.includes('completed'));
    t.assert.ok(responseLog);
    t.assert.ok(responseLog.responseTime);

    await app.close();
  });
});

Test Factory Pattern

Create a reusable test app builder:

// test/helper.ts
import Fastify from 'fastify';
import type { FastifyInstance } from 'fastify';

interface TestContext {
  app: FastifyInstance;
  inject: FastifyInstance['inject'];
}

export async function buildTestApp(options = {}): Promise<TestContext> {
  const app = Fastify({
    logger: false, // Disable logging in tests
    ...options,
  });

  // Register plugins
  app.register(import('../src/plugins/database.js'), {
    connectionString: process.env.TEST_DATABASE_URL,
  });
  app.register(import('../src/routes/index.js'));

  await app.ready();

  return {
    app,
    inject: app.inject.bind(app),
  };
}

// Usage in tests
describe('API Tests', () => {
  let ctx: TestContext;

  before(async () => {
    ctx = await buildTestApp();
  });

  after(async () => {
    await ctx.app.close();
  });

  it('should work', async (t) => {
    const response = await ctx.inject({
      method: 'GET',
      url: '/health',
    });
    t.assert.equal(response.statusCode, 200);
  });
});

Database Testing with Transactions

Use transactions for test isolation:

describe('Database Integration', () => {
  let app;
  let transaction;

  before(async () => {
    app = await buildApp();
    await app.ready();
  });

  after(async () => {
    await app.close();
  });

  beforeEach(async () => {
    transaction = await app.db.beginTransaction();
    app.db.setTransaction(transaction);
  });

  afterEach(async () => {
    await transaction.rollback();
  });

  it('should create user', async (t) => {
    const response = await app.inject({
      method: 'POST',
      url: '/users',
      payload: { name: 'Test', email: 'test@example.com' },
    });

    t.assert.equal(response.statusCode, 201);
    // Transaction is rolled back after test
  });
});

Parallel Test Execution

Structure tests for parallel execution:

// Tests run in parallel by default with node:test
// Use separate app instances or proper isolation

import { describe, it } from 'node:test';

describe('User API', async () => {
  // Each test suite gets its own app instance
  const app = await buildTestApp();

  it('test 1', async (t) => {
    // ...
  });

  it('test 2', async (t) => {
    // ...
  });

  // Cleanup after all tests in this suite
  after(() => app.close());
});

describe('Post API', async () => {
  const app = await buildTestApp();

  it('test 1', async (t) => {
    // ...
  });

  after(() => app.close());
});

Running Tests

# Run all tests
node --test

# Run with TypeScript
node --test src/**/*.test.ts

# Run specific file
node --test src/routes/users.test.ts

# With coverage
node --test --experimental-test-coverage

# Watch mode
node --test --watch

rules

authentication.md

configuration.md

content-type.md

cors-security.md

database.md

decorators.md

deployment.md

error-handling.md

hooks.md

http-proxy.md

logging.md

performance.md

plugins.md

routes.md

schemas.md

serialization.md

testing.md

typescript.md

websockets.md

SKILL.md

tile.json