One of the most popular training programs for our customers at NodeSource is the Testing Workshop, which includes a general introduction to testing but quickly dives into hands-on Fastify examples and CI/CD with GitHub Workflows. I thought I'd take a few essential Fastify bits from this training and have it in a blog post, for quick future reference.

Fastify has great support for testing, but it requires some preparation. And it can be confusing, especially for Node.js first-timers. The Fastify documentation covers everything from the necessary setup to testing with HTTP injection and testing with a live server, but all examples use tap and CommonJS. I thought I'd casually revisit the basic setup using ESM and Vitest, which I happen to like better than tap nowadays, and expand with a few more advanced examples and edge cases.

Basic setup

The first thing to know is that in order to test your Fastify server, it can't be defined directly in the body of a script, it needs to be wrapped in a function:

export function getServer () {
  const server = Fastify()
  server.get('/', (_, reply) => reply.send('Hello'))
  return server
}

This is so getServer() can be imported both from the script responsible for starting the server (i.e., calling server.listen()) and from tests. In this scenario, you would have at least three scripts:

├── server.js
├── server.test.js
└── index.js

But you can also have the same script where getServer() is defined start the server.

You only need to ensure it only does so when that one script is being directly executed. If you're using ESM, here's how you'd do that:

export function getServer () { ... }

if (process.argv[1] === new URL(import.meta.url).pathname) {
  const server = getServer()
  await server.listen({ port: process.env.PORT ?? 3000 })
}

Personally, I prefer this method as it eliminates the need to have a separate script just for starting the server, but YMMV.

Testing via HTTP Injection

Fastify packs a little library called light-my-way, which allows you to inject fake HTTP requests into a Node.js server, without requiring it to be listening, that is, it does not require a socket to be created. It's available as the inject() method of the Fastify server instance. Here's what server.test.js could look like:

import { test, expect, beforeAll, afterAll } from 'vitest'
import { getServer } from './server.js'

let app

beforeAll(() => {
  app = getServer()
})

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

test('should return Hello', async () => {
  const response = await app.inject({ url: '/' })
  expect(await response.body).toBe('Hello')
  await app.close()
})

Notice how app is set in beforeAll() and cleaned up in afterAll().

Also notice how response.body returns the full body as a string — but if it were a JSON a document, you could also call response.json() method that parses JSON and returns an object.

Sometimes other kinds of setup and cleanup code are necessary, such as setting up mocks and resetting them— but more on this in a bit.

You can use inject() to test just about any kind of HTTP request:

inject({
  method: String,
  url: String,
  query: Object,
  payload: Object,
  headers: Object,
  cookies: Object
})

Even though it can take a second parameter as callback, if none is provided inject() will always return a Promise.

Testing a Live Server

You can also easily actually start the server and bind to a socket for live testing.

If you call the listen() method from the Fastify server instance without any parameters, it'll automatically pick a port for you, which can then be accessed dynamically via server.address().port:

import { test, expect, beforeAll, afterAll } from 'vitest'
import { getServer } from './server.js'

let app
let port
let base

beforeAll(async () => {
  app = getServer()
  await app.listen()
  port = app.server.address().port
  base = path => `http://localhost:${port}${path}`
})

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

test('should listen and return Hello', async () => {
  const response = await fetch(base('/'))
  expect(await response.text()).toBe('Hello')
})

You'll rarely want to do this though, since HTTP injection is faster and covers nearly all scenarios. Starting a live server is generally only absolutely required when you need to perform E2E testing or load testing, which is covered next:

Load Testing Fastify

The easiest way to load test your Fastify server is using autocannon, which is not only a CLI but a library you can use in tests:

import autocannon from 'autocannon'

const results = await autocannon({ url: 'http://localhost:3000/' })

In results, you should see a comprehensive list of compound metrics, such as errors to track number of errors, non2xx to track number of non-2xx HTTP responses, and latencies, requests and throughput individual metrics groups (encoded as Base64 histograms).

We can use it to write a test to determine the server P99 latency must always be above below a certain threshold, and that it must be able to process certain number of requests per second. Here's what that would look like:

import { decodeFromCompressedBase64 } from 'hdr-histogram-js'

test('should have good performance', async () => {
  const results = await autocannon({ duration: 5, url: base('/') })
  expect(results.non2xx).toBe(0)
  expect(results.errors).toBe(0)
  const histogram = decodeFromCompressedBase64(results.latencies)
  expect(histogram.getValueAtPercentile(99)).toBeLessThan(50)
  expect(results.totalCompletedRequests).toBeGreaterThan(50000)
}, 7000)

Notice how decodeFromCompressedBase64() from hdr-histogram-js needs to be used to decode the Base64-encoded results given by autocannon. Also notice test() takes a second parameter this time, indicating the test timeout — in this case, a bit higher than the duration of the autocannon run.

Mocking Fastify Decorations

Sometimes, you might need to mock a few methods in your code if you cannot actually execute them safely in a testing environment. Thankfully, Vitest has good support for mocking, using popular Jest idioms. Let's try and mock the execution of a database connection method provided by a plugin.

First, let's add the plugin itself to server.js:

import Fastify from 'fastify'
import fp from 'fastify-plugin'

export function getServer () {
  const server = Fastify()
  server.register(fp(function (scope, _, done) {
    scope.decorate('db', {
      connect () {
        console.log('This shouldn\'t run in a testing environment')
      }
    })
    scope.addHook('onReady', () => {
      scope.db.connect()
    })
    done()
  }))
  server.get('/', (_, reply) => reply.send('Hello'))
  return server
}

This plugin is using fastify-plugin to be registered at the current encapsulation context, but in a real world scenario this would live in its own file (and potentially be a package in node_modules). Here's how the test looks like:

import { test, expect, beforeAll, afterAll, vi } from 'vitest'
import { getServer } from './server.js'

let app
let spy

beforeAll(async () => {
  app = getServer()
  spy = vi.spyOn(app.db, 'connect')
  spy.mockImplementation(() => {
    console.log('Safely executed db.connect()')
  })
  await app.ready()
})

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

test('should connect to database', async () => {
  expect(spy).toHaveBeenCalledTimes(1)
})

Notice how vi is imported at the top, this is where Vitest keeps a set of utilities, such as spyOn(). We use it to create a spy object and track the db.connect method. In the test, we verify if that method was called. But that doesn't quite do it:

What's going on? Fastify registers its plugins asynchronously by default (via avvio), which means when app receives the Fastify server instance reference, plugins might not have finished registered yet — that's why you typically await on ready(). But that doesn't solve the issue either, because we need to spy on that particular method the moment the plugin is registered.

The fix is to await on that plugin itself in getServer(), so you can be sure it'll have already been registered by the time the test receives it:

export async function getServer () {
  const server = Fastify()
  await server.register(fp(function (scope, _, done) {
  ...

Needless to say, from this moment forward you'll have to await on getServer() as well, so any functions that call it need to become async.

End-to-End Testing

Regardless of your Fastify server also being responsible for serving your frontend, if that is part of the same codebase, you can still have E2E tests as part of the same suite. This to me is preferrable than using whatever test runner your E2E testing framework provides, because it keeps tests in a consistent language.

Using Playwright as a library, you can add E2E tess to the same Vitest suite you have for all your other kinds of tests. Here's a simple example:

import { chromium, devices } from 'playwright'

test('should render index in a browser', async () => {
  const browser = await chromium.launch()
  const context = await browser.newContext(devices['Desktop Chrome'])
  const page = await context.newPage()

  await page.goto(base('/'))
  const body = await page.waitForSelector('body')

  expect(await body.innerText()).toBe('Hello')

  await context.close()
  await browser.close()
}, 10000)

Again, notice the timeout as second parameter — typically you'll want a higher value for browser tests, mainly because Playwright takes a bit of time to launch the browser you're using for the first time.

Check out the source code for all examples here.