log
码中赤兔

Fastify 最佳实践 #7: 打造健壮、可靠的 Jest 测试套件

发布于 2025年6月26日
更新于 2025年6月26日
25 分钟阅读
TypeScript

在现代后端开发中,编写高质量的测试不仅是锦上添花,更是保障应用稳定、可维护的基石。一份优秀的测试套件能够让我们在频繁迭代和重构时充满信心。

在 NodeJS 的生态中,Jest 是一个广受欢迎的测试框架。然而,在实践中我们常常会遇到两个棘手的挑战:

  1. 如何有效地测试与数据库交互的逻辑?
  2. 如何优雅地对 HTTP API 接口进行测试?

本篇文章将深入探讨这两个问题,并提供一套基于社区最佳实践的、可用于生产环境的解决方案。


数据库访问测试:告别 Mock,拥抱真实

后端应用离不开数据库。对于数据访问层的测试,我们通常面临一个抉择:

  1. Mock 数据库驱动:通过模拟(Mock)数据库的返回结果来跳过实际的数据库操作。这种方法的优点是速度快、不依赖外部环境。但其致命缺点是,它无法验证我们编写的 SQL 语句或查询构建器逻辑是否正确。这常常导致测试通过,但应用在生产环境出错,让测试失去了核心意义。
  2. 使用真实数据库:在测试过程中连接一个真实的数据库实例。这种方法保真度最高,能确保数据操作逻辑的正确性。但传统方式下,维护一个独立的测试数据库环境相对繁琐,尤其是在 CI/CD 自动化流程中,环境的准备和清理更是一大难题。

我的观点:
对于核心业务逻辑,我强烈建议采用第二种方法。测试的首要目标是发现问题建立信心。如果测试不能反映真实运行情况,那么它提供的信心就是虚假的。我们应该致力于解决环境问题,而不是为了图方便而牺牲测试的可靠性。

幸运的是,借助 Testcontainers,我们可以完美地解决真实数据库环境的管理难题。

使用 Testcontainers 实现动态数据库环境

Testcontainers 是一个强大的库,它允许我们通过代码以编程方式来创建和销毁临时的 Docker 容器。这意味着我们可以在每次运行测试套件前,动态启动一个全新的、干净的数据库实例,测试结束后再自动销毁它。

核心优势:

  • 环境隔离:每次测试都运行在独立的数据库中,杜绝了数据污染和测试用例间的相互影响。
  • 无需手动管理:将数据库的生命周期与测试流程绑定,实现了“开箱即用”。
  • CI/CD 友好:在任何支持 Docker 的环境中都能无缝运行。

让我们看看如何通过 @testcontainers/postgresql 将它集成到 Jest 中。

首先,安装依赖项:

# testcontainers 只在开发和测试阶段使用,因此安装到 devDependencies
npm i -D @testcontainers/postgresql

接着,在 jest.config.ts 中配置全局的 setupteardown 脚本。这能确保数据库容器在所有测试开始前启动,并在所有测试结束后销毁。

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

const config: Config = {
  // ... 其他 Jest 配置
  // 在 Jest 执行前运行一次的全局设置脚本
  globalSetup: '<rootDir>/tests/global-setup.ts',
  // 在 Jest 执行后运行一次的全局清理脚本
  globalTeardown: '<rootDir>/tests/global-teardown.ts',
};

export default config;

然后,我们来编写这两个核心脚本:

global-setup.ts:启动并初始化数据库

// 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...');

  // 注入基础环境变量
  dotenv.config({ path: path.resolve(__dirname, '../env/.env') });

  // 启动一个 PostgreSQL 容器实例
  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.');

  // 关键一步:容器启动后,其端口等信息是动态分配的。
  // 我们需要将这些真实的连接参数覆盖到环境变量中,
  // 这样应用在测试时才能连接到正确的数据库实例。
  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(),
  });

  // 运行数据库迁移和数据填充 (seeding)
  // 此处以 Kysely 为例,你可以换成任何你使用的 ORM 或迁移工具
  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.');

  // 将容器实例存储在全局变量中,以便在 teardown 脚本中访问
  (global as any).__TESTCONTAINER__ = container;
};

global-teardown.ts:销毁数据库容器

// 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.');
  }
};

通过以上设置,我们就拥有了一个全自动、高保真的数据库测试环境。虽然这种方式会比 Mock 稍慢,但换来的测试可靠性是完全值得的。


API 接口测试:设计可测试的应用

对于 API 接口的测试,我们的目标是在不实际监听网络端口的情况下,模拟 HTTP 请求并验证服务器的响应。要实现这一点,需要解决以下几个问题:

1. 解耦应用构建与服务监听

问题:如果在测试中直接运行 app.listen(),不仅会占用端口,还会拖慢测试速度,并且难以管理。

解决方案:将 Fastify 应用的构建逻辑启动监听分离。这是一种优秀的设计模式,能极大提高应用的可测试性。

构建逻辑 (src/app.ts): 这个文件只负责创建和配置 Fastify 实例,像一个“应用工厂”。

// 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,
  });

  // 自动加载插件和路由等
  // ...

  return server;
}

启动逻辑 (src/index.ts): 这个文件是应用的入口,它调用 build() 获取实例,然后启动服务。

// 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());
});

在测试时,我们只需要引入并调用 build() 函数即可获得一个纯净的 Fastify 实例。

2. 管理测试服务器的生命周期

问题:服务器实例应该何时创建和销毁?如果全局只创建一个,测试之间可能会因为 mock 或其他状态而相互干扰。

解决方案:为每个测试文件(describe 块)创建独立的服务器实例。我们可以封装一个辅助函数来简化这个过程。

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

/**
 * 一个在 describe 块内部使用的辅助函数。
 * 它会自动处理 Fastify 实例的创建和销毁,确保测试隔离。
 * @returns 返回一个 getApp 函数,用于在测试用例中安全地获取 app 实例。
 */
export function setupFastify() {
  const appContainer: { instance: FastifyInstance | null } = { instance: null };

  // 在当前 describe 块的所有测试运行前,创建并初始化 app
  beforeAll(async () => {
    appContainer.instance = build();
    await appContainer.instance.ready(); // 等待所有插件加载完成
  });

  // 在当前 describe 块的所有测试运行后,关闭 app
  afterAll(async () => {
    await appContainer.instance?.close();
  });

  // 返回一个闭包函数,让 it/test 块可以安全地获取已初始化的 app 实例
  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;
    },
  };
}

这个helper的具体用法会在下面说明。

3. 发送 HTTP 请求并进行断言

问题:如何在 Jest 中向内存中的 Fastify 实例发送 HTTP 请求?

解决方案:使用 supertest 库。它提供了非常流畅的链式 API 来构造请求和断言响应,可读性极高。

备选方案: Fastify 自带了 app.inject() 方法,它也可以模拟请求,并且无需引入额外依赖。inject() 的性能可能略高,因为它不经过真实的 HTTP 套接字。但 supertest 的 BDD 风格断言(.expect(200).expect('Content-Type', /json/))通常更受开发者青睐。对于大多数项目,supertest 是更推荐的选择。

首先,安装 supertest

npm i -D supertest @types/supertest

然后,在测试用例中结合 setupFastifysupertest 进行测试:

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

describe('GET /book Endpoint', () => {
  // 在 describe 块的顶层调用 setupFastify
  const { getApp } = setupFastify();

  describe('Success cases', () => {
    it('should return a book by its ID', async () => {
      // 在 it 块中通过 getApp() 获取当前测试上下文的 app 实例
      const app = getApp();
      const bookId = '123e4567-e89b-12d3-a456-426614174100';

      // 使用 supertest 发起请求
      const response = await supertest(app.server)
        .get(`/book/${bookId}`)
        //.query({ fields: 'id,title' }) // 示例:如何添加 query 参数
        //.set('Authorization', 'Bearer your_token') // 示例:如何设置请求头
        .expect(200) // 断言状态码
        .expect('Content-Type', /application\/json/); // 断言响应头

      // 使用 Jest 的 expect 进行更复杂的响应体断言
      expect(response.body).toEqual({
        id: bookId,
        title: 'Book One',
        author: 'Author One',
      });
    });
  });
});

结论

通过结合 Testcontainers应用逻辑解耦supertest,我们可以为 Fastify 应用构建一个强大、可靠且易于维护的测试套件。

核心要点回顾:

  1. 测试保真度:尽可能使用真实的依赖(如数据库)进行集成测试,Testcontainers 是实现这一点的利器。
  2. 可测试性设计:将应用的核心构建逻辑与运行时细节(如 listen)分离是关键。
  3. 测试隔离:利用 Jest 的生命周期钩子(beforeAll/afterAll)确保每个测试套件都在一个干净的环境中运行。
  4. 选择合适的工具supertest 为 API 测试提供了优雅的声明式语法。

投资时间来建立这样一套稳固的测试流程,将在项目的整个生命周期中带来巨大的回报。

关于

分享技术见解、经验和思考的个人博客

联系方式

  • Email: hushukang_blog@proton.me
  • GitHub

© 2025 码中赤兔. 版权所有