在Node.js项目中接入Cassandra之后,测试代码经常会面临一个尴尬的局面:业务逻辑本身很简单,但每个测试用例都需要连接真实的Cassandra集群才能跑起来。一旦测试环境数据库不可用,整个CI流程就会挂掉,测试速度也会被网络IO拖累。这篇文章就来聊聊如何在Node.js的单元测试中模拟Cassandra客户端,让测试完全不依赖外部数据库,同时还能覆盖正常查询、分页、超时等不同场景。

在动手写代码之前,先理解一个核心事实:cassandra-driver这个官方驱动的Client对象本质上只是一组方法集合,业务代码真正依赖的是它的connect、execute、shutdown这几个接口。只要我们能在测试中把这些方法替换成可控的假实现,业务代码就感知不到区别。这也是Mock方案可行的理论基础。
方案一:封装数据访问层再注入Mock客户端
最推荐的做法是不要让cassandra-driver的Client实例散落在各个业务文件里,而是先抽出一层统一的DAO层。比如创建一个UserDao类,在构造函数中接收一个client实例。这样在测试时,你可以传入一个自己伪造的client,完全不需要碰真实的数据库连接。
这种方案的好处是架构上更清晰,DAO层天然就是单元测试的边界。缺点是需要对现有代码做一定重构,如果项目里到处都是new CassandraClient()的调用,就需要先把这些创建逻辑集中到依赖注入容器或者工厂函数里。
// userDao.js —— 数据访问层,client通过构造函数注入
class UserDao {
constructor(client) {
this.client = client;
}
async getUserById(id) {
const query = 'SELECT * FROM users WHERE id = ?';
const result = await this.client.execute(query, [id], { prepare: true });
if (result.rows.length === 0) {
return null;
}
return result.rows[0];
}
}
module.exports = UserDao;业务代码只需要在初始化时把真实的client传进去,生产环境和测试环境走的是同一套逻辑。这种显式注入的方式也让代码的依赖关系一目了然,后续维护成本更低。
方案二:实现一个内存版的Mock Cassandra客户端
如果不想做大规模重构,或者希望mock行为更逼真一些,可以自己实现一个完整的MockClient类,用内存中的JavaScript对象来模拟Cassandra表。核心思路是解析CQL语句中的表名和条件,然后从内存数据里筛选结果。虽然不需要实现完整的CQL解析器,但针对项目里常见的几种查询语句做模式匹配,通常已经够用。
// mockClient.js —— 简化版的内存Cassandra Mock
class MockCassandraClient {
constructor() {
this.tables = new Map(); // 表名 -> rows数组
this.connected = false;
}
// 预置测试数据
seed(tableName, rows) {
this.tables.set(tableName, rows);
}
async connect() {
this.connected = true;
return Promise.resolve();
}
async execute(query, params = []) {
if (!this.connected) {
throw new Error('未连接到任何主机,请先调用connect');
}
// 只处理项目中最常见的SELECT语句
const selectMatch = query.match(/SELECT\s+\*\s+FROM\s+(\w+)\s+WHERE\s+(\w+)\s*=\s*\?/i);
if (!selectMatch) {
throw new Error('Mock客户端不支持该CQL语句: ' + query);
}
const [, tableName, column] = selectMatch;
const rows = (this.tables.get(tableName) || []).filter(
row => String(row[column]) === String(params[0])
);
return { rows, info: { isPaging: false } };
}
async shutdown() {
this.connected = false;
}
}
module.exports = MockCassandraClient;这个MockClient用正则匹配了最常见的SELECT * FROM ... WHERE ... = ?语句,测试时通过seed方法注入预设数据,就能让DAO层的查询逻辑跑起来。配合测试框架使用时,每个测试用例都可以重新seed数据,互不干扰。
这种方案的优势在于mock行为完全可控,你可以精确决定返回哪些数据、抛出什么错误。劣势则是CQL支持有限,如果业务里用到了批量写入、聚合函数或者二级索引,正则匹配会越来越吃力。遇到这种情况,建议只mock到方法级别,而不是真的去解析CQL。
方案三:使用测试框架的替身库拦截驱动行为
第三种方案借助sinon.js或者Jest内置的mock功能,直接替换掉Client原型上的方法。这种方式改动最小,特别适合给遗留代码补测试。比如用sinon的stub可以临时接管execute方法,指定任意返回值或者抛出指定异常。
// 使用sinon stub掉cassandra-driver的execute
const sinon = require('sinon');
const cassandra = require('cassandra-driver');
describe('UserService', function () {
let sandbox;
beforeEach(function () {
sandbox = sinon.createSandbox();
sandbox.stub(cassandra.Client.prototype, 'connect').resolves();
sandbox.stub(cassandra.Client.prototype, 'execute').resolves({
rows: [{ id: 'u1', name: '张三', age: 28 }],
info: { isPaging: false }
});
});
afterEach(function () {
sandbox.restore();
});
it('查询用户返回正确字段', async function () {
const client = new cassandra.Client({ contactPoints: ['127.0.0.1'], localDataCenter: 'datacenter1' });
const dao = new UserDao(client);
const user = await dao.getUserById('u1');
if (!user || user.name !== '张三') {
throw new Error('断言失败:用户数据不符合预期');
}
});
});用stub的方式不需要实现任何数据存储逻辑,每个测试用例直接声明期望的返回值,写起来非常快。而且sinon还支持rejects来模拟连接超时、查询失败等异常路径,让错误处理代码也能被测试覆盖到。
需要留意的是,stub原型方法属于全局性修改,一定要在afterEach中调用restore恢复原状,否则会污染其他测试文件。在Jest环境下也可以用jest.mock('cassandra-driver')做模块级替换,原理类似但隔离性更好。
模拟分页、超时等复杂场景
真实业务里Cassandra的查询经常涉及分页,execute返回结果中的pageState字段是分页游标。Mock时可以在返回对象里带上一个假的pageState字符串,并在第二次调用时返回剩余数据,这样就能验证业务代码的分页循环逻辑是否正确。
// 模拟分页的stub写法
let callCount = 0;
sandbox.stub(cassandra.Client.prototype, 'execute').callsFake(async () => {
callCount++;
if (callCount === 1) {
return { rows: [{ id: 'u1' }], pageState: 'fake-state-token', info: {} };
}
return { rows: [{ id: 'u2' }], pageState: null, info: {} };
});超时和连接失败的场景同样重要。可以用callsFake配合一个延时reject的Promise来模拟慢查询,检验业务代码是否正确设置了超时阈值和重试逻辑。Cassandra驱动本身有内置故障转移机制,mock时不必复刻这部分细节,只需要验证上层代码对错误的处理是否合理即可。
三种方案怎么选
简单总结一下:如果项目还在早期或者愿意做小规模重构,首选方案一的依赖注入加方案二的内存MockClient,测试代码可读性和可维护性最好;如果要给历史代码快速补测试,方案三的sinon stub最省事;如果是端到端集成测试,则建议直接用Testcontainers启动一个真实的Cassandra Docker容器,单元测试和集成测试分层各司其职。
无论选择哪种方式,核心原则都是一致的:单元测试不应该依赖外部服务。把数据库访问隔离在清晰的边界之外,不仅让测试跑得又快又稳,也会反过来促使整个项目的架构更加合理。