Fastify 最佳实践 #7: 打造健壮、可靠的 Jest 测试套件
在现代后端开发中,编写高质量的测试不仅是锦上添花,更是保障应用稳定、可维护的基石。一份优秀的测试套件能够让我们在频繁迭代和重构时充满信心。
在 NodeJS 的生态中,Jest 是一个广受欢迎的测试框架。然而,在实践中我们常常会遇到两个棘手的挑战:
- 如何有效地测试与数据库交互的逻辑?
- 如何优雅地对 HTTP API 接口进行测试?
本篇文章将深入探讨这两个问题,并提供一套基于社区最佳实践的、可用于生产环境的解决方案。
数据库访问测试:告别 Mock,拥抱真实
后端应用离不开数据库。对于数据访问层的测试,我们通常面临一个抉择:
- Mock 数据库驱动:通过模拟(Mock)数据库的返回结果来跳过实际的数据库操作。这种方法的优点是速度快、不依赖外部环境。但其致命缺点是,它无法验证我们编写的 SQL 语句或查询构建器逻辑是否正确。这常常导致测试通过,但应用在生产环境出错,让测试失去了核心意义。
- 使用真实数据库:在测试过程中连接一个真实的数据库实例。这种方法保真度最高,能确保数据操作逻辑的正确性。但传统方式下,维护一个独立的测试数据库环境相对繁琐,尤其是在 CI/CD 自动化流程中,环境的准备和清理更是一大难题。
我的观点:
对于核心业务逻辑,我强烈建议采用第二种方法。测试的首要目标是发现问题和建立信心。如果测试不能反映真实运行情况,那么它提供的信心就是虚假的。我们应该致力于解决环境问题,而不是为了图方便而牺牲测试的可靠性。
幸运的是,借助 Testcontainers,我们可以完美地解决真实数据库环境的管理难题。
使用 Testcontainers 实现动态数据库环境
Testcontainers 是一个强大的库,它允许我们通过代码以编程方式来创建和销毁临时的 Docker 容器。这意味着我们可以在每次运行测试套件前,动态启动一个全新的、干净的数据库实例,测试结束后再自动销毁它。
核心优势:
- 环境隔离:每次测试都运行在独立的数据库中,杜绝了数据污染和测试用例间的相互影响。
- 无需手动管理:将数据库的生命周期与测试流程绑定,实现了“开箱即用”。
- CI/CD 友好:在任何支持 Docker 的环境中都能无缝运行。
让我们看看如何通过 @testcontainers/postgresql 将它集成到 Jest 中。
首先,安装依赖项:
# testcontainers 只在开发和测试阶段使用,因此安装到 devDependencies
npm i -D @testcontainers/postgresql
接着,在 jest.config.ts 中配置全局的 setup 和 teardown 脚本。这能确保数据库容器在所有测试开始前启动,并在所有测试结束后销毁。
// 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
然后,在测试用例中结合 setupFastify 和 supertest 进行测试:
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 应用构建一个强大、可靠且易于维护的测试套件。
核心要点回顾:
- 测试保真度:尽可能使用真实的依赖(如数据库)进行集成测试,
Testcontainers是实现这一点的利器。 - 可测试性设计:将应用的核心构建逻辑与运行时细节(如
listen)分离是关键。 - 测试隔离:利用 Jest 的生命周期钩子(
beforeAll/afterAll)确保每个测试套件都在一个干净的环境中运行。 - 选择合适的工具:
supertest为 API 测试提供了优雅的声明式语法。
投资时间来建立这样一套稳固的测试流程,将在项目的整个生命周期中带来巨大的回报。