一、问题引入:Jest测Node微服务时,数据库模拟为啥这么难搞

很多做Node.js微服务的开发者,写单元测试时最头疼的就是数据库的处理——比如你写了个用户登录的接口,测试时得连真实数据库插个测试用户、查密码、删测试数据,一来二去测试跑的慢不说,还容易因为数据库状态没清干净导致测试结果忽对忽错;要是直接写死代码里的数据库查询结果(就是大家说的硬编码Mock),又怕测出来的结果和真实数据库的逻辑不一样,上线后出问题。所以这章就专门说Jest测Node微服务时,到底该怎么选数据库的模拟方式,以及选的时候要考虑啥。

二、两种核心方案:真实数据库 vs 深度Mock

先给大家说清楚,测数据库相关逻辑时,常用的两种方案到底是什么。

2.1 真实数据库方案:用测试环境的真实库测

这个方案好理解,就是专门搭一个和生产环境一模一样的测试用数据库,比如生产用的是MySQL 8.0,测试环境也装MySQL 8.0,表结构、索引、触发器啥的全和生产一致。测试时所有的数据库操作(增删改查)都直接连这个测试库,测完再把测试数据清掉。

举个完整的例子,咱们用Node.js + Jest + MySQL + Knex.js(一个常用的数据库操作工具)来写,先把技术栈标清楚: 技术栈:Node.js 18+, Jest 29+, MySQL 8.0, Knex.js 2.5.1

首先得有个Knex的配置文件knexfile.js,专门给测试环境配数据库:

module.exports = {
  development: {
    client: 'mysql2',
    connection: {
      host: 'localhost',
      user: 'test_user',
      password: 'test_pass',
      database: 'test_db',
    },
  },
};

然后写一个简单的用户服务userService.js,里面有个根据用户名查用户的方法:

// 引入Knex,配置用测试环境的数据库
const knex = require('knex')(require('./knexfile').development);

// 根据用户名查询用户
async function getUserByUsername(username) {
  // 直接查真实数据库
  const users = await knex('users').where('username', username);
  return users.length > 0 ? users[0] : null;
}

module.exports = { getUserByUsername };

接着写Jest的测试用例userService.test.js,用真实数据库的话,要注意每次测试前清数据、测试后关数据库连接:

const knex = require('knex')(require('./knexfile').development);
const { getUserByUsername } = require('./userService');

// 所有测试开始前,先清空users表,保证测试环境干净
beforeAll(async () => {
  await knex('users').truncate();
});

// 所有测试结束后,关闭Knex的数据库连接,避免内存泄漏
afterAll(async () => {
  await knex.destroy();
});

// 测试用例:查存在的用户名,返回正确的用户
test('getUserByUsername返回存在的用户', async () => {
  // 先往真实测试库插一个测试用户
  await knex('users').insert({ username: 'test_user', password: '123456' });
  // 调用服务方法
  const user = await getUserByUsername('test_user');
  // 断言结果正确
  expect(user).not.toBeNull();
  expect(user.username).toBe('test_user');
});

// 测试用例:查不存在的用户名,返回null
test('getUserByUsername返回不存在的用户为null', async () => {
  const user = await getUserByUsername('not_exist_user');
  expect(user).toBeNull();
});

2.2 深度Mock方案:不碰真实数据库,完全模拟

深度Mock的意思是,完全不连真实数据库,把所有和数据库相关的操作(比如Knex的查询、插入方法)都用Jest的Mock功能模拟出来,测试时只验证自己写的业务逻辑对不对,不管数据库的具体实现。

还是用刚才的技术栈,把测试用例改成深度Mock的版本userService.mock.test.js

// 先Mock整个Knex模块,这样测试时用到的knex都是模拟的
jest.mock('knex', () => {
  // 模拟Knex的实例方法,比如where、insert、truncate这些
  const mockKnex = {
    truncate: jest.fn().mockResolvedValue(), // 模拟truncate返回Promise
    insert: jest.fn().mockResolvedValue(), // 模拟insert返回Promise
    where: jest.fn().mockReturnThis(), // 模拟where返回自己,方便链式调用
  };
  // Knex模块的导出是一个函数,调用后返回模拟的实例
  return jest.fn(() => mockKnex);
});

const knex = require('knex')(require('./knexfile').development);
const { getUserByUsername } = require('./userService');

// 测试前清空Mock的调用记录,避免测试互相影响
beforeEach(() => {
  jest.clearAllMocks();
});

// 测试用例:查存在的用户名,返回正确的用户
test('getUserByUsername返回存在的用户', async () => {
  // 先模拟where方法的返回结果,也就是数据库查出来的内容
  knex.where.mockResolvedValue([{ username: 'test_user', password: '123456' }]);
  // 调用服务方法
  const user = await getUserByUsername('test_user');
  // 断言结果正确
  expect(user).not.toBeNull();
  expect(user.username).toBe('test_user');
  // 额外断言:确实调用了knex的where方法,参数是对的
  expect(knex.where).toHaveBeenCalledWith('username', 'test_user');
});

// 测试用例:查不存在的用户名,返回null
test('getUserByUsername返回不存在的用户为null', async () => {
  // 模拟where方法返回空数组,代表数据库没查到
  knex.where.mockResolvedValue([]);
  const user = await getUserByUsername('not_exist_user');
  expect(user).toBeNull();
});

三、选方案的关键权衡:从四个维度判断

到底选真实数据库还是深度Mock,不能凭感觉,得结合具体情况从四个方面判断。

3.1 第一个维度:测试的核心目的是什么

如果你的测试是为了验证业务逻辑本身,比如“用户登录时,密码校验通过才返回成功”,这个逻辑和数据库没关系,那用深度Mock就行,因为核心是测你写的代码,不是测数据库能不能查到数据。但如果你的测试是为了验证数据库相关的逻辑,比如“数据库索引会不会影响查询速度”“触发器会不会自动更新时间”,那必须用真实数据库,因为深度Mock模拟不出来索引、触发器这些数据库特有的逻辑。

举个例子,如果你写了个批量插入用户的接口,要求插入100条数据时不能超过500毫秒,这个性能测试就必须用真实数据库,因为Mock的insert方法根本测不出来真实的插入速度。

3.2 第二个维度:测试的运行速度和稳定性

真实数据库的测试速度肯定比深度Mock慢,因为每次测试都要连数据库、执行SQL、清数据。比如一个有100个测试用例的项目,用真实数据库可能要跑10秒,用深度Mock可能只要1秒。而且真实数据库的测试还容易不稳定,比如测试时数据库突然连不上、测试数据没清干净导致下一个测试用例出错。

但深度Mock也有稳定性问题,就是你Mock的逻辑可能和真实数据库的逻辑不一样。比如你Mock的where方法是“只要参数对就返回数据”,但真实数据库里如果有软删除(比如加了is_deleted字段),你没在Mock里加这个判断,测出来的结果就会和真实情况不一样。

3.3 第三个维度:项目的维护成本

真实数据库的维护成本比较高,比如你要专门搭一个测试环境的数据库,还要保证测试数据每次都清干净,不然测试结果会乱。如果是团队开发,还要给所有开发者配测试数据库的权限,不然别人拉了代码跑测试会报错。

深度Mock的维护成本主要是写Mock代码的成本,比如数据库操作逻辑变了,Mock代码也要跟着变。比如原来的where方法是查username,后来改成查email和username,那Mock的where方法也要改,不然测试会报错。

3.4 第四个维度:团队的技术能力

深度Mock对开发者的技术能力要求比较高,因为你要完全理解数据库操作的逻辑,才能写对Mock代码。比如你要Mock一个复杂的链式查询(比如knex('users').where('age', '>', 18).where('is_active', true).select('username', 'email')),就得知道怎么模拟链式调用的返回值,不然Mock代码会写的很乱,甚至写不对。

如果团队里大部分开发者对Mock的理解不够深,那可能选真实数据库更稳妥,因为真实数据库的逻辑是现成的,不用自己模拟。

四、两种方案的应用场景和注意事项

4.1 真实数据库的应用场景和注意事项

应用场景

  1. 测数据库相关的业务逻辑,比如软删除、触发器、索引、事务、批量操作的性能等;
  2. 集成测试,比如测整个接口从接收请求到返回结果的完整流程,包括数据库操作;
  3. 项目初期,团队对Mock的理解不够深,想保证测试结果的准确性。

注意事项

  1. 一定要用专门的测试数据库,不能用开发环境或生产环境的数据库,不然测试数据会污染开发或生产数据;
  2. 每次测试前一定要清测试数据,最好用beforeAll或beforeEach方法清空表,保证每个测试用例的环境都是干净的;
  3. 测试结束后一定要关闭数据库连接,不然会导致内存泄漏,影响后续测试;
  4. 测试数据库的配置要和生产环境一致,比如字符集、时区、索引、触发器等,不然测出来的结果和生产环境不一样。

4.2 深度Mock的应用场景和注意事项

应用场景

  1. 单元测试,测业务逻辑本身,比如密码校验、参数校验、数据处理等;
  2. 测试逻辑复杂,和数据库交互少的模块,比如一个专门处理数据格式的工具函数;
  3. 项目要求测试速度快,比如持续集成(CI)时,要快速跑测试保证代码质量。

注意事项

  1. Mock的逻辑一定要和真实数据库的逻辑一致,比如软删除、联表查询、排序等逻辑,都要在Mock里体现;
  2. 不要过度Mock,比如不要把整个服务都Mock了,只Mock和数据库相关的部分;
  3. 要定期检查Mock代码和真实数据库逻辑的一致性,比如数据库表结构变了,Mock代码也要跟着变;
  4. 可以用一些工具来简化Mock,比如jest-mock-extended,这个工具可以自动生成Mock的类型,减少写Mock代码的工作量。

五、总结

测Node.js微服务时的数据库模拟,没有绝对的好坏,只有适合不适合。真实数据库适合测数据库相关的逻辑和集成测试,结果准确但速度慢、维护成本高;深度Mock适合测业务逻辑本身和单元测试,速度快、维护成本低但对技术能力要求高。

选的时候可以按这个流程来:先看测试的核心目的,如果是测数据库相关的,就选真实数据库;如果是测业务逻辑,就看项目对测试速度的要求,要求快就选深度Mock,要求稳定就选真实数据库;再看团队的技术能力,如果团队对Mock不熟悉,就选真实数据库。

最后给大家一个建议:不要只用一种方案,可以混合用,比如单元测试用深度Mock,集成测试用真实数据库,这样既能保证测试速度,又能保证测试结果的准确性。