3. JavaScript 测试基础:断言库、测试用例结构与异步测试

好,咱们进入正题。这一章我打算聊聊 JavaScript 测试最核心的三个东西:断言库测试用例结构、还有异步代码怎么测。这三个东西你搞明白了,后面写测试用例基本就顺了。

我记得我刚入行那会儿,写测试全靠 console.log 打印出来肉眼对比。后来被 senior 骂了一顿,才老老实实学断言库。嗯,真香。

3.1 断言库:expect 与 assert

断言是什么?说白了就是「我猜这个值应该是 xxx,如果不是,你就给我报错」。测试框架本身不负责断言,它只负责跑用例。断言这件事,得靠专门的库来做。

目前最主流的两个风格:assert(Node.js 内置)和 expect(Jest/Chai 等框架常用)。

3.1.1 assert 风格

Node.js 自带的 assert 模块,写法比较「老派」。你看一眼就明白了:

const assert = require('assert');

// 基本用法
assert.strictEqual(1 + 1, 2);
assert.notStrictEqual(1 + 1, '2');

// 对象比较
assert.deepStrictEqual({ a: 1 }, { a: 1 });

// 布尔判断
assert.ok(true);
assert.ok(1 === 1, '1 应该等于 1');

我个人习惯在写 Node.js 原生模块测试时用 assert,因为它零依赖。但说实话,它的报错信息不够友好。你想想看,一个 assert.strictEqual 失败了,就给你扔个「AssertionError」,连期望值和实际值都不给你列清楚。

注意:assert 的 ===== 容易搞混。建议始终用 strictEqualdeepStrictEqual,别用 equaldeepEqual。后者做的是弱比较,坑很多。

3.1.2 expect 风格

expect 风格就优雅多了。它用链式调用的方式,读起来像英语句子:

// Jest 或 Vitest 中的 expect
expect(1 + 1).toBe(2);
expect({ a: 1 }).toEqual({ a: 1 });
expect('hello').toHaveLength(5);
expect([1, 2, 3]).toContain(2);

// 布尔判断
expect(true).toBeTruthy();
expect(false).toBeFalsy();

// 异常判断
expect(() => {
  throw new Error('出错了');
}).toThrow('出错了');

我在项目中遇到过一个问题:有人用 toBe 比较对象,结果永远失败。为什么?因为 toBe 用的是 Object.is,比较的是引用。对象比较得用 toEqualtoStrictEqual

小技巧:写断言时,尽量让错误信息「自解释」。比如 expect(x).toBeGreaterThan(0)expect(x > 0).toBe(true) 报错信息清晰得多。

3.2 测试用例结构:describe 与 it

测试用例怎么写?核心就两个东西:describe 用来分组,it 用来写具体的测试。有些框架里 it 也叫 test,一回事。

3.2.1 基本结构

describe('计算器模块', () => {
  describe('加法功能', () => {
    it('应该正确计算两个正数相加', () => {
      expect(add(1, 2)).toBe(3);
    });

    it('应该正确处理负数相加', () => {
      expect(add(-1, -2)).toBe(-3);
    });
  });

  describe('减法功能', () => {
    it('应该正确计算两个正数相减', () => {
      expect(subtract(5, 3)).toBe(2);
    });
  });
});

你看,describe 可以嵌套。外层 describe 描述模块,内层 describe 描述具体功能。it 里面写一条条具体的测试。

我曾经见过一个项目,所有测试用例都平铺在一个文件里,没有 describe 分组。跑测试的时候,报错信息根本不知道是哪个模块出了问题。嗯,后来重构花了两天。

3.2.2 生命周期钩子

测试框架还提供了几个钩子函数,用来做「前置准备」和「后置清理」:

钩子 执行时机 典型用途
beforeAll 当前 describe 中所有测试之前,执行一次 初始化数据库连接、加载配置
afterAll 当前 describe 中所有测试之后,执行一次 关闭数据库连接、清理临时文件
beforeEach 每个 it 之前都执行一次 重置状态、创建测试数据
afterEach 每个 it 之后都执行一次 清理测试数据、恢复 mock
describe('用户服务', () => {
  let userService;

  beforeAll(() => {
    // 只初始化一次
    userService = new UserService();
  });

  beforeEach(() => {
    // 每个测试前重置数据
    userService.clear();
  });

  afterEach(() => {
    // 每个测试后清理
    console.log('测试完成');
  });

  it('应该能创建用户', () => {
    userService.create('张三');
    expect(userService.count()).toBe(1);
  });

  it('应该能删除用户', () => {
    userService.create('李四');
    userService.delete('李四');
    expect(userService.count()).toBe(0);
  });
});
核心原则:每个 it 之间应该是「隔离」的。一个测试的失败不应该影响另一个测试。所以 beforeEach 里做重置,afterEach 里做清理,这个习惯一定要养成。

3.3 同步与异步代码测试

前端开发,异步是家常便饭。回调、Promise、async/await,每种写法测试起来都不一样。

3.3.1 测试回调函数

早期的 Node.js 代码大量使用回调。测试回调函数,关键是要告诉框架「等异步操作完成再判断」:

// 被测函数
function fetchData(callback) {
  setTimeout(() => {
    callback('data');
  }, 100);
}

// 测试用例
it('应该通过回调返回数据', (done) => {
  fetchData((result) => {
    expect(result).toBe('data');
    done(); // 告诉框架,异步操作完成了
  });
});

注意那个 done 参数。如果不调用 done,测试会超时失败。如果调用了两次 done,也会报错。

我曾经踩过的坑:回调函数里如果抛了异常,但没有被 try/catch 捕获,done 永远不会被调用,测试就会一直挂在那。所以建议回调里加个 try/catch,或者用 Promise 替代回调。

3.3.2 测试 Promise

Promise 的测试就简单多了。你直接 return 一个 Promise,框架会自动等待它 resolve:

// 被测函数
function fetchDataPromise() {
  return new Promise((resolve) => {
    setTimeout(() => resolve('data'), 100);
  });
}

// 测试用例 - 方式1:return Promise
it('应该返回数据', () => {
  return fetchDataPromise().then((result) => {
    expect(result).toBe('data');
  });
});

// 测试用例 - 方式2:使用 resolves
it('应该返回数据(更简洁)', () => {
  return expect(fetchDataPromise()).resolves.toBe('data');
});

我个人更推荐第二种写法,resolvesrejects 让代码更简洁。测试 reject 的情况也一样:

// 被测函数
function fetchDataReject() {
  return new Promise((_, reject) => {
    setTimeout(() => reject(new Error('失败')), 100);
  });
}

it('应该抛出错误', () => {
  return expect(fetchDataReject()).rejects.toThrow('失败');
});

3.3.3 测试 async/await

async/await 是目前最主流的写法。测试起来也最自然:

// 被测函数
async function fetchDataAsync() {
  return 'data';
}

// 测试用例
it('应该返回数据', async () => {
  const result = await fetchDataAsync();
  expect(result).toBe('data');
});

// 测试异常
it('应该抛出错误', async () => {
  await expect(fetchDataReject()).rejects.toThrow('失败');
});

你想想看,async/await 的测试和同步代码几乎一样,只是多了个 await。这也是为什么现在大家都用 async/await 的原因——心智负担最小。

一个容易忽略的点:测试 async 函数时,如果忘了写 await,测试会直接通过,因为返回的是一个 Promise 而不是实际值。我建议在 eslint 里开启 no-return-awaitrequire-await 规则,帮你避免这种低级错误。

3.4 本章小结

这一章的内容其实就三句话:

  • 断言:用 expect 或 assert 来验证结果。推荐 expect,可读性更好。
  • 结构:用 describe 分组,用 it 写用例。别忘了 beforeEach 和 afterEach 做隔离。
  • 异步:回调用 done,Promise 用 return,async/await 用 await。选 async/await 最省心。

下一章我会讲 mock 和 stub,那是测试里最实用的技巧之一。到时候我会分享一个我当年在项目中「mock 错了导致线上 bug」的惨痛教训。嗯,敬请期待。


公众号:蓝海资料掘金营,微信deep3321