Fastify Plugin Testing: Lifecycle Hooks, Decorators, and inject()

Fastify Plugin Testing: Lifecycle Hooks, Decorators, and inject()

Fastify's plugin system is one of its best features — and one of the trickier things to test properly. This post covers how to use inject() for in-process route testing, how to verify lifecycle hooks fire in the right order, and how to assert that decorators are registered and behave correctly — all without spinning up a real server.

Key Takeaways

Use inject() for route tests, not supertest. Fastify's built-in inject() method sends fake HTTP requests in-process. It's faster than binding to a port and doesn't require a live server.

Always call app.ready() before inject(). Plugins register asynchronously. Calling inject() before ready() resolves means your hooks and decorators may not be registered yet.

Test encapsulation explicitly. A core promise of Fastify plugins is that decorators and hooks in a child scope don't leak to the parent. Write tests that verify this boundary holds.

Test hook execution order, not just hook existence. It's not enough to confirm that onRequest is registered. Test that it fires before preHandler, and that preHandler fires before the route handler.

Mock dependencies at the plugin boundary. Inject fake services into plugins using Fastify's decorator system rather than monkey-patching modules. This keeps tests deterministic and avoids import-order issues.

Why Plugin Testing in Fastify Is Different

Fastify's plugin architecture is built on fastify-plugin and a scoped encapsulation model. Every plugin runs in its own context unless explicitly opted out with fastify-plugin. This means:

  • Decorators added inside a plugin are not visible outside it (unless using fastify-plugin)
  • Hooks added in a plugin only fire for routes in that scope
  • Plugins load asynchronously, so test setup must await app.ready()

Testing plugins correctly means understanding these boundaries and writing assertions that prove they hold.

Setting Up a Testable Fastify App

Start with a minimal test harness using Vitest (works equally with Jest):

// test/helpers/build-app.ts
import Fastify, { FastifyInstance } from 'fastify'
import { afterEach } from 'vitest'

export async function buildApp(): Promise<FastifyInstance> {
  const app = Fastify({ logger: false })
  return app
}

export function useApp(setup: (app: FastifyInstance) => Promise<void>) {
  let app: FastifyInstance

  beforeEach(async () => {
    app = await buildApp()
    await setup(app)
    await app.ready()
  })

  afterEach(async () => {
    await app.close()
  })

  return { getApp: () => app }
}

The useApp helper ensures each test gets a fresh Fastify instance, registers plugins via the setup callback, awaits ready(), and tears down cleanly.

Testing with inject()

inject() is Fastify's built-in method for making in-process HTTP requests. It accepts the same options as a real HTTP request and returns a response object.

// test/routes/health.test.ts
import { describe, it, expect } from 'vitest'
import { buildApp } from '../helpers/build-app'

describe('GET /health', () => {
  it('returns 200 with status ok', async () => {
    const app = await buildApp()
    app.get('/health', async () => ({ status: 'ok' }))
    await app.ready()

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

    expect(response.statusCode).toBe(200)
    expect(response.json()).toEqual({ status: 'ok' })

    await app.close()
  })
})

inject() supports all HTTP methods, custom headers, request bodies, and query strings:

const response = await app.inject({
  method: 'POST',
  url: '/users',
  headers: { 'content-type': 'application/json' },
  payload: { name: 'Alice', email: 'alice@example.com' },
})

The response object has .statusCode, .headers, .body (string), .json() (parsed), and .cookies.

Testing Lifecycle Hooks

Fastify's request lifecycle fires hooks in a fixed order: onRequest → preParsing → preValidation → preHandler → handler → onSend → onResponse. Testing that your hooks fire correctly is essential.

Testing onRequest

// src/plugins/request-id.plugin.ts
import fp from 'fastify-plugin'
import { FastifyInstance } from 'fastify'
import { randomUUID } from 'crypto'

export default fp(async function requestIdPlugin(app: FastifyInstance) {
  app.addHook('onRequest', async (request) => {
    request.requestId = request.headers['x-request-id'] as string ?? randomUUID()
  })

  app.decorateRequest('requestId', '')
})
// test/plugins/request-id.test.ts
import { describe, it, expect } from 'vitest'
import { buildApp } from '../helpers/build-app'
import requestIdPlugin from '../../src/plugins/request-id.plugin'

describe('requestIdPlugin', () => {
  it('assigns a requestId from x-request-id header if present', async () => {
    const app = await buildApp()
    await app.register(requestIdPlugin)
    app.get('/echo', async (request) => ({ requestId: request.requestId }))
    await app.ready()

    const response = await app.inject({
      method: 'GET',
      url: '/echo',
      headers: { 'x-request-id': 'test-id-123' },
    })

    expect(response.json().requestId).toBe('test-id-123')
    await app.close()
  })

  it('generates a UUID requestId when header is absent', async () => {
    const app = await buildApp()
    await app.register(requestIdPlugin)
    app.get('/echo', async (request) => ({ requestId: request.requestId }))
    await app.ready()

    const response = await app.inject({ method: 'GET', url: '/echo' })
    const { requestId } = response.json()

    expect(requestId).toMatch(
      /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
    )
    await app.close()
  })
})

Testing preHandler for Authorization

// src/plugins/auth.plugin.ts
import fp from 'fastify-plugin'
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'

export default fp(async function authPlugin(app: FastifyInstance) {
  app.decorate('verifyToken', async function (request: FastifyRequest, reply: FastifyReply) {
    const token = request.headers.authorization?.replace('Bearer ', '')
    if (!token || token !== 'valid-token') {
      reply.code(401).send({ error: 'Unauthorized' })
    }
  })
})
// test/plugins/auth.test.ts
describe('authPlugin', () => {
  it('allows requests with a valid token', async () => {
    const app = await buildApp()
    await app.register(authPlugin)
    app.get('/protected', { preHandler: app.verifyToken }, async () => ({ data: 'secret' }))
    await app.ready()

    const response = await app.inject({
      method: 'GET',
      url: '/protected',
      headers: { authorization: 'Bearer valid-token' },
    })

    expect(response.statusCode).toBe(200)
    await app.close()
  })

  it('rejects requests without a token with 401', async () => {
    const app = await buildApp()
    await app.register(authPlugin)
    app.get('/protected', { preHandler: app.verifyToken }, async () => ({ data: 'secret' }))
    await app.ready()

    const response = await app.inject({ method: 'GET', url: '/protected' })

    expect(response.statusCode).toBe(401)
    expect(response.json()).toEqual({ error: 'Unauthorized' })
    await app.close()
  })
})

Testing Decorators

Decorators extend the Fastify instance, request, or reply objects. Testing them means verifying they're registered and that they behave correctly.

Testing app.decorate

// src/plugins/db.plugin.ts
import fp from 'fastify-plugin'
import { FastifyInstance } from 'fastify'

interface DbClient {
  query: (sql: string) => Promise<unknown[]>
}

export default fp(async function dbPlugin(app: FastifyInstance, opts: { client: DbClient }) {
  app.decorate('db', opts.client)
})
// test/plugins/db.test.ts
import { describe, it, expect, vi } from 'vitest'
import { buildApp } from '../helpers/build-app'
import dbPlugin from '../../src/plugins/db.plugin'

describe('dbPlugin', () => {
  it('registers the db decorator on the fastify instance', async () => {
    const mockClient = { query: vi.fn().mockResolvedValue([{ id: 1 }]) }
    const app = await buildApp()
    await app.register(dbPlugin, { client: mockClient })
    await app.ready()

    expect(app.db).toBe(mockClient)
    expect(app.hasDecorator('db')).toBe(true)
    await app.close()
  })

  it('makes db available inside route handlers', async () => {
    const mockClient = { query: vi.fn().mockResolvedValue([{ id: 1, name: 'Alice' }]) }
    const app = await buildApp()
    await app.register(dbPlugin, { client: mockClient })
    app.get('/users', async () => app.db.query('SELECT * FROM users'))
    await app.ready()

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

    expect(response.json()).toEqual([{ id: 1, name: 'Alice' }])
    expect(mockClient.query).toHaveBeenCalledWith('SELECT * FROM users')
    await app.close()
  })
})

Testing decorateRequest

// test decorating the request object
it('adds user property to request after auth', async () => {
  const app = await buildApp()

  app.decorateRequest('user', null)
  app.addHook('preHandler', async (request) => {
    request.user = { id: '42', role: 'admin' }
  })
  app.get('/me', async (request) => request.user)
  await app.ready()

  const response = await app.inject({ method: 'GET', url: '/me' })

  expect(response.json()).toEqual({ id: '42', role: 'admin' })
})

Testing Encapsulation

One of Fastify's strongest guarantees is that plugins are encapsulated. A decorator added inside a plugin scope should not be visible outside that scope (unless wrapped in fastify-plugin).

// test/plugins/encapsulation.test.ts
import { describe, it, expect } from 'vitest'
import Fastify from 'fastify'

describe('plugin encapsulation', () => {
  it('decorator added inside a scoped plugin is not visible on the root instance', async () => {
    const app = Fastify({ logger: false })

    app.register(async (scoped) => {
      scoped.decorate('secret', 'hidden')
      scoped.get('/inside', async () => ({ val: scoped.secret }))
    })

    app.get('/outside', async () => {
      // @ts-expect-error — secret is not declared on root
      return { val: app.secret ?? 'not found' }
    })

    await app.ready()

    const inside = await app.inject({ method: 'GET', url: '/inside' })
    const outside = await app.inject({ method: 'GET', url: '/outside' })

    expect(inside.json().val).toBe('hidden')
    expect(outside.json().val).toBe('not found')

    await app.close()
  })

  it('fastify-plugin bypasses encapsulation and exposes decorator to parent', async () => {
    const app = Fastify({ logger: false })
    await app.register(import('fastify-plugin').then(m =>
      m.default(async (instance) => {
        instance.decorate('shared', 'visible')
      })
    ))
    await app.ready()

    expect((app as any).shared).toBe('visible')
    await app.close()
  })
})

Testing onSend and Response Transformation

onSend hooks modify the response payload before it's sent. Test them by asserting the final response body, not just the hook registration.

// src/plugins/snake-case.plugin.ts — converts response keys to snake_case
import fp from 'fastify-plugin'
import { FastifyInstance } from 'fastify'

function toSnakeCase(obj: Record<string, unknown>): Record<string, unknown> {
  return Object.fromEntries(
    Object.entries(obj).map(([k, v]) => [
      k.replace(/([A-Z])/g, '_$1').toLowerCase(),
      v,
    ])
  )
}

export default fp(async function snakeCasePlugin(app: FastifyInstance) {
  app.addHook('onSend', async (_request, _reply, payload) => {
    if (typeof payload === 'string') {
      try {
        return JSON.stringify(toSnakeCase(JSON.parse(payload)))
      } catch {
        return payload
      }
    }
    return payload
  })
})
it('transforms camelCase response keys to snake_case', async () => {
  const app = await buildApp()
  await app.register(snakeCasePlugin)
  app.get('/user', async () => ({ firstName: 'Alice', lastName: 'Smith' }))
  await app.ready()

  const response = await app.inject({ method: 'GET', url: '/user' })

  expect(response.json()).toEqual({ first_name: 'Alice', last_name: 'Smith' })
  await app.close()
})

What to Test vs. What to Skip

Test:

  • That hooks fire and produce the expected side effect (modified request, set header, rejected request)
  • That decorators are registered (app.hasDecorator('name')) and return expected values
  • That encapsulation holds — scoped plugins don't leak to parent scope
  • That onSend transformations produce the correct final response body
  • Error paths: what happens when a hook throws or a decorator dependency is missing

Skip:

  • Implementation details inside hooks (e.g., which utility function was called internally) — test the observable HTTP outcome instead
  • Testing that Fastify itself correctly calls hooks in order — that's Fastify's test suite's job
  • Mocking app.inject() itself — it's already in-process and fast; no need to stub it
  • Testing logging output from hooks unless logging is a core feature of the plugin

Fastify's inject() gives you a full round-trip through the plugin lifecycle with zero network overhead. Use it liberally, keep each test focused on one observable behavior, and let encapsulation be a first-class concern in your test suite.

Read more