
今天来写一个每一个开发者都会遇到的、并且让人抓狂的问题——“时好时坏”的CI测试失败!
我们的CI上会运行针对每次提交或者build的测试,其中包括针对服务器代码的单元测试。因为代码比较复杂往往一些测试需要比较多的依赖,有时候会因为一些网络抖动等原因导致测试失败。这种情况一般情况下重新跑一次测试就会解决,但是我们最近遇到了一个测试它有时候也会像以前一样偶尔失败,但是最近变得失败得越来越频繁,是时候把它揪出来了!
什么样的测试case?
这个case是这样的,它的职责是确保用户运行时设置能被正确地创建、查询和更新。是一个挺重要的测试,但是最近会频繁的报告失败!
Test User Runtime Settings : Should destroy user specific runtime settings
Unhandled promise rejection: Error: Could not update attributes. Object with id xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx does not exist!
观察到的现象
错误里的id每次报错都会不同,这提示这是一个每次测试任务都会重新生成的id!
但是如果你点一下“重新运行”,它往往又通过了!这种间歇性的失败最让人头疼,因为它难以复现,更难以定位。最近,它的失败频率明显增高,已经影响到开发了所以不能再坐视不管了。
// 这是一个Jasmine测试套件
describe('用户运行时设置测试套件', () => {
// 在每个测试开始前,我们清空相关数据,确保测试独立
beforeEach(async function(done) => {
await settingsService.destroyAll({});
done();
});
it('应该能通过ID和用户获取设置', async () => {
// 1. 创建第一个设置实例
const firstSetting = await settingsService.createByEndUser({ /* ...初始数据... */ });
expect(firstSetting.id).toBeDefined();
// 2. 通过ID查询,应该能查到刚创建的实例
const foundById = await settingsService.findBySettingsId(firstSetting.id);
expect(foundById).toBeDefined();
// 3. 为同一用户创建第二个设置(模拟更新)
const secondSetting = await settingsService.createByEndUser({
id: firstSetting.id, // 使用相同的逻辑ID,服务内部应生成新记录
/* ...更新后的数据... */
});
expect(secondSetting.id).not.toEqual(firstSetting.id);
// 4. 获取该用户的最新设置,应该拿到第二个实例
const latestForUser = await settingsService.findByUser(/* ...用户和其他参数... */);
expect(latestForUser.id).toEqual(secondSetting.id); // 断言在这里可能失败!
done();
});
});
这个测试的逻辑很清晰:创建 -> 查询 -> 更新(创建新记录)-> 验证最新记录。通过屏蔽其他测试的操作最终锁定错误就出在最后一步会直接爆出前面提到的“对象不存在”的异常,然后导致后面一系列的断言失败。
第一次尝试,怀疑测试本身写的有问题
面对“时好时坏”的问题,我的第一反应是:测试本身写得有问题吗? 尤其是涉及异步操作(async/await)和测试框架(Jasmine)的生命周期时。
我搜索了一下,果然发现了一个Jasmine的问题,这里可以看其他人的讨论:https://github.com/jasmine/jasmine/issues/1731。
从Jasmine官方文档:https://jasmine.github.io/tutorials/async 也不推荐在一个测试函数里混用 async 和 done 回调。我们的原始测试恰好用了 async (done) => {...} 这种形式。
为什么这样写可能会出问题?
这等于告诉Jasmine测试结束的两种方式:1)async函数隐式返回的Promise完成;2) 显式调用done()。Jasmine可能会在第一次收到“完成”信号时就清理环境并开始下一个测试,导致未完成的异步操作(比如数据库写入)被意外中断,状态混乱。
第一次修复尝试:
我果断移除了所有不必要的 done 回调,让测试完全基于 async/await。
// 修改前: beforeEach(async function(done) { ... done(); })
// 修改后:
beforeEach(async () => {
await settingsService.destroyAll({});
// 不再需要 done()
});
// 修改前: it('...', async (done) => { ... done(); })
// 修改后:
it('应该能通过ID和用户获取设置', async () => { // 移除了 done 参数
// ... 测试逻辑 ...
// 移除了最后的 done() 调用
});
结果: 本地和CI跑了几次,都通过了!我一度以为问题就这么简单地解决了,松了一口气。
问题再现
然而就在我以为上次的修改已经解决了问题的时候,发现build又开始失败而失败的原因还是因为这个问题!这说明我们可能只是解决了一个潜在的不稳定因素,但不是根本原因。
是时候采取更彻底的方法了:由于CI限制和每次调起测试耗费大量的时间,最好的办法是在本地复现这个错误。但是我尝试了很多次,在本地这个测试都能成功的通过。一个猜想是我们的开发环境(Windows)和生产CI环境(Ubuntu)存在差异,这个很可能就是导致我在本地无法准确复现的原因。我决定使用WSL(Windows Subsystem for Linux) 来搭建一个与CI一致的测试环境。
这里我们使用Win11的内置WSL(ubuntu 22.04), 和 VS Code的 WSL插件来操作。拷贝项目到WSL然后成功运行项目!

在WSL中反复调试终于成功运行测试case后,通过仔细的日志分析和断点调试,我把目标锁定在了一个服务方法上:findByUser。
这是该方法的简化逻辑:
class SettingsService {
static async findByUser(targetName, targetId, targetType, req) {
// ... 构建查询条件,从数据库查找最新设置 ...
const settings = await this.findOne({
where: whereClause,
order: 'createdDate DESC'
});
if (settings) {
// 关键问题在这里!
settings.lastViewTime = new Date();
// `upsert` 是一个异步操作,它会更新或插入记录
this.upsert(settings); // 没有 `await`!
}
// 方法立即返回了,但 `upsert` 操作可能还在进行中
return settings;
}
}
原来这是一个容易出错的竞态条件(Race Condition) !
- 测试中,
findByUser被调用,找到了secondSetting。 - 它触发了
this.upsert(settings)来更新lastViewTime,但没有等待这个操作完成。 - 函数立刻就把
settings对象返回给了测试。 - 测试拿到对象后,立刻进行断言
expect(latestForUser.id).toEqual(secondSetting.id);。 - 然后 beforeEach 运行调用
destroyAll - 然而就在这一刻,那个在后台执行的
upsert操作可能正好尝试保存。如果之前的destroyAll恰好在这微秒级的间隙内发生,就可能导致upsert失败,抛出“对象不存在”的错误。由于此upsert是更新新的查看时间的副作用操作,提前返回而不是强制等待其完成在这个场景似乎没有问题,因此这个在实际应用中没有发现问题只是在这个测试场景中暴露出来了。
解决问题
找到了根本原因,解决起来就简单了:还是基本的要等待所有的异步操作完成。
修复方案一: 给upsert 加上 await。
// 修复后的服务方法
class SettingsService {
static async findByUser(targetName, targetId, targetType, req) {
// ... 构建查询条件 ...
const settings = await this.findOne({
where: whereClause,
order: 'createdDate DESC'
});
if (settings) {
settings.lastViewTime = new Date();
// 关键修复:加上 await,确保更新完成后再返回
await this.upsert(settings);
}
return settings; // 现在可以安全返回了
}
}
修复方案二: 给测试单独加上一个1s的等待。
// Add another delay here to wait the 'findByUser' to finish, since the 'upsert' operation int this func is not awaited
await new Promise(resolve => setTimeout(resolve, 1100));
完整的测试用例
describe('用户运行时设置测试套件', () => {
// 使用纯 async/await
beforeEach(async () => {
await settingsService.destroyAll({});
});
it('应该能通过ID和用户获取设置', async () => {
// 第一部分:创建和基础查询
const firstSetting = await settingsService.createByEndUser({
id: 'placeholder',
targetType: 'dashboard',
targetName: 'main_dashboard',
runtimeSettings: {}
});
expect(firstSetting.id).toBeDefined();
const foundById = await settingsService.findBySettingsId(firstSetting.id);
expect(foundById.id).toEqual(firstSetting.id);
// 第二部分:更新并验证最新记录
const updatedData = { runtimeSettings: { filter: 'active' } };
const secondSetting = await settingsService.createByEndUser({
id: firstSetting.id,
targetType: 'dashboard',
targetName: 'main_dashboard',
...updatedData
});
expect(secondSetting.id).not.toEqual(firstSetting.id);
// 第三部分 findByUser
const latestForUser = await settingsService.findByUser(
'main_dashboard',
undefined,
'dashboard'
);
expect(latestForUser).toBeDefined();
expect(latestForUser.id).toEqual(secondSetting.id);
expect(latestForUser.runtimeSettings.filter).toEqual('active');
// 增加一秒钟的等待
await new Promise(resolve => setTimeout(resolve, 1100));
});
});
复盘与思考
这次调试之旅给了我们几个宝贵的教训:
- “时好时坏”约等于“竞态条件”:当测试间歇性失败,尤其是涉及数据库、网络等I/O操作时,首先要怀疑竞态条件。检查所有异步操作是否都被正确等待了。
- 环境一致性至关重要:很多CI问题无法在本地复现,是因为环境差异。使用Docker或WSL来镜像CI环境,是解决此类问题的利器。
- 清理测试框架的“语法糖”:像混用
async/await和done这种问题,虽然不一定是直接原因,但会引入不必要的复杂性,掩盖真正的问题。保持测试代码的简洁和规范。 - 关注“副作用”操作:在我们的例子中,
findByUser主要职责是“查询”,但它有一个更新lastViewTime的“副作用”。对于这类带副作用的函数,要格外小心其内部异步操作的完整性。
我们通过规范化测试写法消除了一个不稳定因素,又通过深入底层逻辑并修复异步等待漏洞解决了根本的竞态条件。现在,这个测试在CI上运行得很稳定了。
希望这个经历对你有帮助!下次当遇到类似的CI测试问题不妨沿着这个思路去排查一下。
7537

被折叠的 条评论
为什么被折叠?



