log
Swift Code Chronicles

Fastify Best Practices #7: Building a Robust and Reliable Jest Test Suite

Published on June 26, 2025
Updated on June 26, 2025
63 min read
TypeScript

In modern backend development, writing high-quality tests is not just a nice-to-have; it’s the cornerstone of ensuring application stability and maintainability. A great test suite gives us the confidence to iterate and refactor frequently.

Within the NodeJS ecosystem, Jest is a widely popular testing framework. However, in practice, we often encounter two tricky challenges:

  1. How can we effectively test logic that interacts with a database?
  2. How can we elegantly test HTTP API interfaces?

This article will delve into these two problems and provide a production-ready solution based on community best practices.


Database Access Testing: Say Goodbye to Mocks, Embrace Reality

Backend applications are inseparable from databases. When it comes to testing the data access layer, we typically face a choice:

  1. Mocking the Database Driver: This involves skipping actual database operations by simulating (mocking) the database’s return results. The advantage of this method is its speed and independence from external environments. However, its fatal flaw is that it cannot verify whether our SQL queries or query builder logic is correct. This often leads to tests passing while the application fails in production, causing the tests to lose their core purpose.
  2. Using a Real Database: This involves connecting to a real database instance during the testing process. This method offers the highest fidelity and ensures the correctness of data manipulation logic. However, traditionally, maintaining a separate test database environment is cumbersome, especially in CI/CD automation pipelines where setting up and cleaning up the environment is a major challenge.

My Perspective: For core business logic, I strongly recommend adopting the second approach. The primary goals of testing are to find problems and build confidence. If a test doesn’t reflect the real-world operating conditions, the confidence it provides is illusory. We should focus on solving environmental challenges rather than sacrificing test reliability for the sake of convenience.

Fortunately, with Testcontainers, we can perfectly solve the problem of managing a real database environment.

Achieving a Dynamic Database Environment with Testcontainers

Testcontainers is a powerful library that allows us to programmatically create and destroy temporary Docker containers. This means we can dynamically start a brand new, clean database instance before each test suite run and automatically destroy it afterward.

Core Advantages:

  • Environment Isolation: Each test runs in an independent database, preventing data pollution and interference between test cases.
  • No Manual Management: By binding the database’s lifecycle to the testing process, it becomes an “out-of-the-box” solution.
  • CI/CD-Friendly: It runs seamlessly in any environment that supports Docker.

Let’s see how to integrate it into Jest using @testcontainers/postgresql.

First, install the dependencies:

# Testcontainers is only used during development and testing, so install it as a devDependency
npm i -D @testcontainers/postgresql

Next, configure the global setup and teardown scripts in jest.config.ts. This ensures that the database container starts before all tests and is destroyed after all tests are finished.

// jest.config.ts
import type { Config } from 'jest';

const config: Config = {
  // ... other Jest configurations
  // A global setup script that runs once before all tests
  globalSetup: '<rootDir>/tests/global-setup.ts',
  // A global teardown script that runs once after all tests
  globalTeardown: '<rootDir>/tests/global-teardown.ts',
};

export default config;

Now, let’s write these two core scripts:

global-setup.ts: Starting and Initializing the Database

// tests/global-setup.ts
import { PostgreSqlContainer } from '@testcontainers/postgresql';
import { execSync } from 'child_process';
import dotenv from 'dotenv';
import path from 'path';
import 'tsconfig-paths/register';

export default async () => {
  console.log('Setting up test environment...');

  // Inject base environment variables
  dotenv.config({ path: path.resolve(__dirname, '../env/.env') });

  // Start a PostgreSQL container instance
  console.log('Starting PostgreSQL container...');
  const container = await new PostgreSqlContainer('postgres:15-alpine')
    .withDatabase(process.env.DB_NAME || 'test_db')
    .withUsername(process.env.DB_USER || 'test_user')
    .withPassword(process.env.DB_PASSWORD || 'test_password')
    .start();
  console.log('PostgreSQL container started.');

  // Crucial step: After the container starts, its port and other details are dynamically assigned.
  // We need to overwrite the environment variables with these real connection parameters
  // so that the application can connect to the correct database instance during tests.
  Object.assign(process.env, {
    DB_HOST: container.getHost(),
    DB_USER: container.getUsername(),
    DB_PASSWORD: container.getPassword(),
    DB_PORT: container.getPort().toString(),
    DB_NAME: container.getDatabase(),
  });

  // Run database migrations and seeding
  // Kysely is used as an example here; you can replace it with your ORM or migration tool
  console.log('Running database migrations and seeding...');
  const kyselyPath = './node_modules/.bin/kysely';
  execSync(`${kyselyPath} migrate:latest && ${kyselyPath} seed:run`);
  console.log('Database is ready.');

  // Store the container instance in a global variable to access it in the teardown script
  (global as any).__TESTCONTAINER__ = container;
};

global-teardown.ts: Destroying the Database Container

// tests/global-teardown.ts
import type { StartedPostgreSqlContainer } from '@testcontainers/postgresql';

export default async () => {
  console.log('\nTearing down test environment...');

  const container: StartedPostgreSqlContainer = (global as any).__TESTCONTAINER__;

  if (container) {
    await container.stop();
    console.log('Container stopped successfully.');
  }
};

With this setup, we have a fully automated, high-fidelity database testing environment. While this approach might be slightly slower than mocking, the reliability gained is well worth it.


API Interface Testing: Designing for Testability

For API interface testing, our goal is to simulate HTTP requests and verify server responses without actually listening on a network port. To achieve this, we need to solve a few problems:

1. Decoupling Application Build from Service Listening

Problem: Running app.listen() directly in tests not only occupies a port but also slows down the tests and is difficult to manage.

Solution: Separate the Fastify application’s build logic from its service listening startup. This is an excellent design pattern that greatly improves an application’s testability.

Build Logic (src/app.ts): This file is only responsible for creating and configuring the Fastify instance, acting like an “application factory.”

// src/app.ts
import autoLoad from '@fastify/autoload';
import fastify, { FastifyInstance, FastifyServerOptions } from 'fastify';

export function build(opts?: FastifyServerOptions): FastifyInstance {
  const server = fastify({
    // ... logger, etc.
    ...opts,
  });

  // Automatically load plugins, routes, etc.
  // ...

  return server;
}

Startup Logic (src/index.ts): This file is the application’s entry point. It calls build() to get an instance and then starts the service.

// src/index.ts
import { build } from './app';

const app = build();

app.listen({ host: '0.0.0.0', port: Number(process.env.PORT) || 3000 }, (err) => {
  if (err) {
    app.log.error(err);
    process.exit(1);
  }
  console.log(app.printRoutes());
});

During testing, we only need to import and call the build() function to get a clean Fastify instance.

2. Managing the Test Server’s Lifecycle

Problem: When should the server instance be created and destroyed? If we only create one globally, tests might interfere with each other due to mocks or other state changes.

Solution: Create an independent server instance for each test file (i.e., each describe block). We can encapsulate this process in a helper function to simplify it.

// tests/helpers/fastify.helper.ts
import { build } from '@/app';
import { FastifyInstance } from 'fastify';

/**
 * A helper function used inside a describe block.
 * It automatically handles the creation and destruction of the Fastify instance, ensuring test isolation.
 * @returns A getApp function to safely retrieve the app instance within test cases.
 */
export function setupFastify() {
  const appContainer: { instance: FastifyInstance | null } = { instance: null };

  // Create and initialize the app before all tests in the current describe block run
  beforeAll(async () => {
    appContainer.instance = build();
    await appContainer.instance.ready(); // Wait for all plugins to be loaded
  });

  // Close the app after all tests in the current describe block have run
  afterAll(async () => {
    await appContainer.instance?.close();
  });

  // Return a closure function so that 'it'/'test' blocks can safely get the initialized app instance
  return {
    getApp: (): FastifyInstance => {
      if (!appContainer.instance) {
        throw new Error(
          'Fastify app instance is not available. Ensure getApp() is called within a test case (it/test).',
        );
      }
      return appContainer.instance;
    },
  };
}

The specific usage of this helper will be explained below.

3. Sending HTTP Requests and Making Assertions

Problem: How do we send HTTP requests to the in-memory Fastify instance within Jest?

Solution: Use the supertest library. It provides a very fluent, chainable API for constructing requests and asserting responses, making it highly readable.

Alternative Approach: Fastify comes with a built-in app.inject() method, which can also simulate requests without needing an extra dependency. inject() might have slightly better performance as it doesn’t go through a real HTTP socket. However, supertest’s BDD-style assertions (.expect(200).expect('Content-Type', /json/)) are often preferred by developers. For most projects, supertest is the more recommended choice.

First, install supertest:

npm i -D supertest @types/supertest

Then, combine setupFastify and supertest in your test cases:

import { setupFastify } from '@/tests/helpers/fastify.helper';
import supertest from 'supertest';

describe('GET /book Endpoint', () => {
  // Call setupFastify at the top level of the describe block
  const { getApp } = setupFastify();

  describe('Success cases', () => {
    it('should return a book by its ID', async () => {
      // Get the app instance for the current test context within the 'it' block using getApp()
      const app = getApp();
      const bookId = '123e4567-e89b-12d3-a456-426614174100';

      // Make a request using supertest
      const response = await supertest(app.server)
        .get(`/book/${bookId}`)
        //.query({ fields: 'id,title' }) // Example: How to add query parameters
        //.set('Authorization', 'Bearer your_token') // Example: How to set request headers
        .expect(200) // Assert the status code
        .expect('Content-Type', /application\/json/); // Assert the response header

      // Use Jest's expect for more complex assertions on the response body
      expect(response.body).toEqual({
        id: bookId,
        title: 'Book One',
        author: 'Author One',
      });
    });
  });
});

Conclusion

By combining Testcontainers, application logic decoupling, and supertest, we can build a powerful, reliable, and maintainable test suite for our Fastify applications.

Key Takeaways:

  1. Test Fidelity: Whenever possible, use real dependencies (like databases) for integration testing. Testcontainers is a powerful tool for achieving this.
  2. Design for Testability: Separating the core application build logic from runtime details (like listen) is key.
  3. Test Isolation: Utilize Jest’s lifecycle hooks (beforeAll/afterAll) to ensure each test suite runs in a clean environment.
  4. Choose the Right Tools: supertest provides an elegant, declarative syntax for API testing.

Investing time in establishing a solid testing process like this will pay huge dividends throughout the project’s entire lifecycle.

About

A personal blog sharing technical insights, experiences and thoughts

Quick Links

Contact

  • Email: hushukang_blog@proton.me
  • GitHub

© 2025 Swift Code Chronicles. All rights reserved