全面掌握 Jest:从零开始的测试指南(上篇)

随着JavaScript在前后端开发中的广泛应用,测试已成为保证代码质量的关键环节。

为什么需要单元测试

在我们的开发过程中,经常需要定义一些算法函数,例如将接口返回的数据转换成UI组件所需的格式。为了校验这些算法函数的健壮性,部分开发同学可能会手动定义几个输入样本进行初步校验,一旦校验通过便不再深究。

然而,这样的做法可能会带来一些潜在的问题。首先,边界值的情况往往容易被忽视,导致校验不够全面,增加了系统出现故障的风险。其次,随着需求的变化和演进,算法函数可能需要进行优化和扩展。如果前期的校验工作不够彻底,不了解现有函数覆盖的具体场景,就可能导致在后续的修改中引入新的问题。

单元测试可以有效地解决上述问题。在定义算法函数时,同步创建单元测试文件,并将可能出现的各种场景逐一列举。如果单元测试未能通过,项目在编译时会直接报错,从而能够及时发现并针对性地解决问题。此外,当后续有新同学加入并需要扩展功能时,他们不仅需要在原有的单元测试基础上添加新的测试用例,还能确保新功能的正确性,同时保障原有功能的正常运行。

1_单元测试执行结果.png

自定义测试逻辑

在开始使用工具来进行单元测试之前,我们可以先自定义一个工具函数供测试使用。

例如,我们有一个 add 函数,期望它能够正确计算两个数的和,并验证其结果是否符合预期。比如,我们希望验证 2 + 3的结果是否等于 5 ,可以使用 expect(add(2, 3)).toBe(5) 这样的代码来实现。为此,我们可以自行定义一个expect 函数,使其具备类似Jest中 expect 函数的功能

function add(a, b) { return a + b; }
function expect(result) {
  return {
    toBe(value) {
      if (result === value) {
        console.log("验证成功");
      } else {
        throw new Error(`执行错误:${result} !== ${value}`);
      }
    },
  };
}

// 调用示例
try {
  expect(add(2, 3)).toBe(5);  // 输出:"验证成功"
  expect(add(2, 3)).toBe(6);  // 抛出错误
} catch (err) {
  console.error(err.message);  // 输出:"执行错误:5 !== 6"
}

为了使测试更具描述性和可读性,我们可以进一步增强我们的测试逻辑。例如,我们可以添加一个 test 函数,用于描述测试的目的,并在测试失败时提供更详细的错误信息。

function test(description, fn) {
  try {
    fn();
    console.log(`测试通过: ${description}`);
  } catch (err) {
    console.error(`测试失败: ${description} - ${err.message}`);
  }
}
// 调用示例
test("验证 2 + 3 是否等于 5", () => {
  expect(add(2, 3)).toBe(5);
});
test("验证 2 + 3 是否等于 6", () => {
  expect(add(2, 3)).toBe(6);
});
2_自定义测试函数.png

通过这种方式,我们模拟了一个简单的测试用例,其中 testexpect 函数类似于Jest中的功能。然而,我们的自定义版本相对简陋,缺乏 Jest 提供的丰富功能。

Jest

通过上述示例,我们可以了解到编写测试的基本思路和方法。然而,在实际开发中,我们需要一个功能更加强大、易用性更高的测试工具。Jest 正是这样一个工具,它不仅提供了丰富的匹配器(如toBe、toEqual等),还支持异步测试Mock函数Snapshot测试 等功能。

引入 Jest 的依赖后,我们可以直接使用其内置的 testexpect 函数,从而大大提高测试的效率和准确性。Jest 的强大之处在于它能够帮助我们全面地覆盖各种测试场景,并提供详细的错误报告,使我们能够快速定位和解决问题。

初始化

首先,我们通过 npm install jest -D 安装 Jest 依赖,然后执行 npx jest --init。此时,命令行工具会出现一系列交互式问答,询问你是否要为 Jest 添加名为 test 的脚本指令、是否使用 TypeScript 作为配置文件、测试用例执行环境、是否需要代码覆盖率测试报告、生成测试报告的平台的编译器以及是否需要在每次测试用例执行前重置 Mock 函数状态。

3_jest init.png

完成所有问答后,Jest 会修改 package.json 文件,并生成jest.config.js配置文件。在执行测试用例时,将依据这些配置项进行。

我们创建一个 math.test.js 文件,并将之前的测试代码放入其中

function add(a, b) {
  return a + b;
}
test("测试 add 函数", () => {
  expect(add(2, 3)).toBe(5);
});

通过 npm run test 执行 Jest 运行指令,可以在命令行工具查看详细的测试信息,包括哪个文件的哪条测试用例的状态,以及简易的测试覆盖率报告。

4_npm run test.png

在实际使用场景中,add 函数通常定义在项目文件中,并通过ES 模块化 (export 和 import) 方式导出和导入。默认情况下,Jest 并不支持 ES 模块化语法,因此我们需要通过 Babel 进行配置。

首先,执行以下命令安装 Babel 及其核心库和预设

npm install @babel/core @babel/preset-env --save-dev

然后,创建babel.config.js文件并定义配置

module.exports = {
  presets: [
    [
      "@babel/preset-env",
      {
        targets: {
          node: "current",
        },
      },
    ],
  ],
};

接着,将 add 函数移到 math.js 文件中,并使用 export 导出

// math.js
export function add(a, b) {
  return a + b;
}

最后,在 math.test.js 文件中使用 import 导入

// math.test.js
import { add } from './math';
test("测试 add 函数", () => {
  expect(add(2, 3)).toBe(5);
});

通过以上步骤,你就完成了使用 Jest 执行 ES 模块化代码的环境初始化。

匹配器

Jest 中最常用的功能之一就是匹配器。在前面进行测试时,我们就接触过 toBe 这一匹配器,它用于判断值是否相等。除此之外,还有许多其他类型的匹配器。

值相等

判断值相等有两种匹配器:toBetoEqual。对于基本数据类型(如字符串、数字、布尔值),两者的使用效果相同。但对于引用类型(如对象和数组),toBe 只有在两个引用指向同一个内存地址时才会返回 true

const user = { name: "alice" };
const info = { name: "alice" };

test("toEqual", () => {
  expect(info).toEqual(user); // 通过,两者结构相同
});
test("toBe", () => {
  expect(info).toBe(user); // 不通过,两者的引用地址不同
});
是否有值

存在 toBeNulltoBeUndefinedtoBeDefined 匹配器来分别判断值是否为 null、未定义或已定义。

test("toBeNull", () => {
  expect(null).toBeNull();
  expect(0).toBeNull(); // 不通过
  expect("hello").toBeNull(); // 不通过
  expect(undefined).toBeBull(); // 不通过
});

test("toBeUnDefined", () => {
  expect(null).toBeUndefined(); // 不通过
  expect(0).toBeUndefined(); // 不通过
  expect("hello").toBeUndefined(); // 不通过
  expect(undefined).toBeUndefined();
});

test("toBeDefined", () => {
  expect(null).toBeDefined();
  expect(0).toBeDefined();
  expect("hello").toBeDefined();
  expect(undefined).toBeDefined(); // 不通过
});
是否为真

toBeTruthy 用于判断值是否为真,toBeFalsy 用于判断值是否为假,not 用于取反。

test("toBeTruthy", () => {
  expect(null).toBeTruthy(); // 不通过
  expect(0).toBeTruthy(); // 不通过
  expect(1).toBeTruthy();
  expect("").toBeTruthy(); // 不通过
  expect("hello").toBeTruthy();
  expect(undefined).toBeTruthy(); // 不通过
});
test("toBeFalsy", () => {
  expect(null).toBeFalsy();
  expect(0).toBeFalsy();
  expect(1).toBeFalsy(); // 不通过
  expect("").toBeFalsy();
  expect("hello").toBeFalsy(); // 不通过
  expect(undefined).toBeFalsy();
});
test("not", () => {
  expect(null).not.toBeTruthy();
  expect("hello").not.toBeTruthy(); // 不通过
});
数字比较

toBeGreaterThan 用于判断是否大于某个数值,toBeLessThan 用于判断是否小于某个数值,toBeGreaterThanOrEqual 用于判断是否大于或等于某个数值,toBeCloseTo 用于判断是否接近某个数值(差值 < 0.005)。

test("toBeGreaterThan", () => {
  expect(9).toBeGreaterThan(5);
  expect(5).toBeGreaterThan(5); // 不通过
  expect(1).toBeGreaterThan(5); // 不通过
});

test("toBeLessThan", () => {
  expect(9).toBeLessThan(5); // 不通过
  expect(5).toBeLessThan(5); // 不通过
  expect(1).toBeLessThan(5);
});

test("toBeGreaterThanOrEqual", () => {
  expect(9).toBeGreaterThanOrEqual(5);
  expect(5).toBeGreaterThanOrEqual(5);
  expect(1).toBeGreaterThanOrEqual(5); // 不通过
});

test("toBeCloseTo", () => {
  expect(0.1 + 0.2).toBeCloseTo(0.3);
  expect(1 + 2).toBeCloseTo(3);
  expect(0.1 + 0.2).toBeCloseTo(0.4); // 不通过
});
字符串相关

toMatch 用于判断字符串是否包含指定子字符串,部分包含即可。

test("toMatch", () => {
  expect("alice").toMatch("alice"); // 通过
  expect("alice").toMatch("lice"); // 通过
  expect("alice").toMatch("al"); // 通过
});
数组相关

toContain 用于判断数组是否包含指定元素,类似于 JavaScript 中的 includes 方法。

test("toContain", () => {
  expect(['banana', 'apple', 'orange']).toContain("apple");
  expect(['banana', 'apple', 'orange']).toContain("app"); // 不通过
});
error相关

toThrow 用于判断函数是否抛出异常,并可以指定抛出异常的具体内容。

test("toThrow", () => {
  const throwNewErrorFunc = () => {
    throw new TypeError("this is a new error");
  };
  expect(throwNewErrorFunc).toThrow();
  expect(throwNewErrorFunc).toThrow("new error");
  expect(throwNewErrorFunc).toThrow("TypeError"); // 不通过
});

以上就是各类型常用的匹配器。

命令行工具

package.json 中配置 script 指令,可以使 .test.js 文件在修改时实时自动执行测试用例。

"scripts": {
   "jest": "jest --watchAll"
},

在命令行中,你会实时看到当前测试用例的执行结果。同时,Jest 还提供了一些快捷配置,按下 w 键即可查看具体有哪些指令。

5_命令行工具.png

主要有以下几种类型:

f 模式
在所有测试用例中,只执行上一次失败的测试用例。即使其他测试用例的内容有修改,也不会被执行。

o 模式
只执行修改过的测试用例。这个功能需要配合 Git 来实现,根据本次相对于上次 Git 仓库的更改。这种模式还可以通过配置 script 指令来实现,即:

"script": {
"test": "jest --watch"
}

p模式
当使用--watchAll 时,修改一个文件的代码后,所有的测试用例都会执行。进入 p 模式后,可以输入文件名 matchersFile,此时修改任何文件只会去查找包含 matchersFile 的文件并执行。

t模式
输入测试用例名称,匹配 test 函数的第一个参数。匹配成功后即执行该测试用例。

q模式
退出实时代码检测。

通过不同的指令,你可以更有针对性地检测测试用例。

钩子函数

在 Jest 中,describe 函数用于将一系列相关的测试用例(tests)组合在一起,形成一个描述性的测试块。它接受两个参数:第一个参数是一个字符串,用于描述测试块的主题;第二个参数是一个函数,包含一组测试用例。

即使没有显式定义 describe 函数,每个测试文件也会在最外层默认加上一层 describe 包裹。

在 describe 组成的每个块中,存在一些钩子函数,贯穿测试用例的整个过程。这些钩子函数主要用于测试用例执行之前的准备工作或之后的清理工作。

常用的钩子函数
  • beforeAll 函数在一个 describe 块开始之前执行一次
  • afterAll 函数在一个 describe 块结束之后执行一次
  • beforeEach 函数在每个测试用例之前执行
  • afterEach 在每个测试用例之后执行
示例代码

下面的示例代码展示了如何使用这些钩子函数:

describe("测试是否有值", () => {
  beforeAll(() => {
    console.log("beforeAll");
  });
  afterAll(() => {
    console.log("afterAll");
  });
  beforeEach(() => {
    console.log("beforeEach");
  });
  describe("toBeNull", () => {
    beforeAll(() => {
      console.log("toBeNull beforeAll");
    });
    afterAll(() => {
      console.log("toBeNull afterAll");
    });
    beforeEach(() => {
      console.log("toBeNull beforeEach");
    });
    test("toBeNull", () => {
      expect(null).toBeNull();
    });
  });
});
输出顺序

当运行上述测试用例时,输出的顺序如下:

beforeAll
toBeNull beforeAll
beforeEach
toBeNull beforeEach
toBeNull afterAll
afterAll

通过使用这些钩子函数,你可以更好地管理测试用例的生命周期,确保每次测试都从一个干净的状态开始,并在测试结束后清理掉产生的副作用。

在这一篇测试指南中,我们介绍了Jest 的背景、如何初始化项目、常用的匹配器语法、钩子函数。下一篇篇将继续深入探讨 Jest 的高级特性,包括 Mock 函数、异步请求的处理、Mock 请求的模拟、类的模拟以及定时器的模拟、snapshot 的使用。通过这些技术,我们将能够更高效地编写和维护测试用例,尤其是在处理复杂异步逻辑和外部依赖时。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,126评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,254评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,445评论 0 341
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,185评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,178评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,970评论 1 284
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,276评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,927评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,400评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,883评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,997评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,646评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,213评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,204评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,423评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,423评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,722评论 2 345

推荐阅读更多精彩内容