快照测试

上一章我们在项目中引入了 React,现在我们开始 React App 的开发和测试吧。

说起组件测试,很多人都会第一时间想到 快照测试。可能你也听过这个名字,但是你是否了解其中的细节呢?这一章就来聊聊 快照测试

Title 组件

我们在 src/components/Title.tsx 写一个 Title 组件:

  1. // src/components/Title.tsx
  2. import React, { CSSProperties, FC } from "react";
  3. interface Props {
  4. type: "large" | "small";
  5. title: string;
  6. }
  7. // large 样式
  8. const largeStyle: CSSProperties = {
  9. fontSize: "2em",
  10. color: "red",
  11. };
  12. // small 样式
  13. const smallStyle: CSSProperties = {
  14. fontSize: "0.5em",
  15. color: "green",
  16. };
  17. // 样式 Mapper
  18. const styleMapper: Record<"small" | "large", CSSProperties> = {
  19. small: smallStyle,
  20. large: largeStyle,
  21. };
  22. // 组件
  23. const Title: FC<Props> = (props) => {
  24. const { title, type } = props;
  25. return <p style={styleMapper[type]}>{title}</p>;
  26. };
  27. export default Title;

然后在 src/App.tsx 里使用这个组件:

  1. // src/App.tsx
  2. import React from 'react';
  3. import Title from "components/Title";
  4. const App = () => {
  5. return (
  6. <div>
  7. <section>
  8. <Title type="small" title="小字" />
  9. <Title type="large" title="大字" />
  10. </section>
  11. </div>
  12. )
  13. }
  14. export default App;

在页面上可以看到这样的效果:

快照测试 - 图1

第一个快照

在写测试前,我们要安装一下 React 的测试库。或许你听说过很多测试 React 的测试库,这里我只推荐 React Testing Library

  1. npm i -D @testing-library/react@12.1.4

tests/components/Title.test.tsx 添加一个快照测试:

  1. // tests/components/Title.test.tsx
  2. import React from "react";
  3. import { render } from "@testing-library/react";
  4. import Title from "components/Title";
  5. describe("Title", () => {
  6. it("可以正确渲染大字", () => {
  7. const { baseElement } = render(<Title type="large" title="大字" />);
  8. expect(baseElement).toMatchSnapshot();
  9. });
  10. it("可以正确渲染小字", () => {
  11. const { baseElement } = render(<Title type="small" title="小字" />);
  12. expect(baseElement).toMatchSnapshot();
  13. });
  14. });

执行测试后,会发现在 tests/components/ 下多了一个 Title.test.tsx.snap 文件,打开来看看:

  1. // tests/components/Title.test.tsx.snap
  2. // Jest Snapshot v1, https://goo.gl/fbAQLP
  3. exports[`Title 可以正确渲染大字 1`] = `
  4. <body>
  5. <div>
  6. <p
  7. style="font-size: 2em; color: red;"
  8. >
  9. 大字
  10. </p>
  11. </div>
  12. </body>
  13. `;
  14. exports[`Title 可以正确渲染小字 1`] = `
  15. <body>
  16. <div>
  17. <p
  18. style="font-size: 0.5em; color: green;"
  19. >
  20. 小字
  21. </p>
  22. </div>
  23. </body>
  24. `;

什么是快照测试

在讲 “什么是快照测试” 之前,我们先来说说 “为什么要有快照测试”。

使用 jestReact Testing Library 都能完成基础的交互测试以及功能测试,但是组件毕竟是组件,是有 HTML 结构的。 如果不对比一下 HTML 结构,很难说服自己组件没问题。但是这就引来了一个问题了:要怎么对比 HTML 结构?

最简单的方法就是把这个组件的 HTML 打印出来,拷贝到一个 xxx.txt 文件里,然后在下次跑用例时,把当前组件的 HTML 字符串和 xxx.txt 文件里的内容对比一下就知道哪里有被修改过。 这就是快照测试的基本理念,即:先保存一份副本文件,下次测试时把当前输出和上次副本文件对比就知道此次重构是否破坏了某些东西。

只不过 jest 的快照测试提供了更高级的功能:

  1. 自动创建把输出内容写到 .snap 快照文件,下次测试时可以自动对比
  2. 输出格式化的快照文件,阅读友好,开发者更容易看懂
  3. 当在做 diff 对比时,jest 能高亮差异点,而且对比信息更容易阅读

现在我们来看上面这个例子:

  1. 用例第一次执行时,把 baseElement 的结构记录到 Title.test.tsx.snap
  2. 等下次再执行,Jest 会自动对比当前 baseElement DOM 快照以及上一次 Title.test.tsx.snap 的内容

快照测试通过说明渲染组件没有变,如果不通过则有两种可能:

  1. 代码有 Bug。 本来好好的,被你这么一改,改出了问题
  2. 实现了新功能。 新功能可能会改变原有的 DOM 结构,所以你要用 jest --updateSnapshot 来更新快照

这就是快照测试了……吗?当然不是!哦,我是说做法就是这么简单,但是思路上并不简单。

缺陷

快照测试虽然简单,但是它有非常多的问题。我搜罗了网上很多资料,这里稍微总结一下它的缺点以及应对思路。

避免大快照

现在 Title 比较简单,所以看起来还可以,但真实业务组件中动辄就有十几个标签,还带上很多乱七八糟的属性,生成的快照文件会变得无比巨大。

对于这个问题,我们能做的就是避免大快照,不要无脑地记录整个组件的快照,特别是有别的 UI 组件参与其中的时候:

  1. const Title: FC<Props> = (props) => {
  2. const { title, type } = props;
  3. return (
  4. <Row style={styleMapper[type]}>
  5. <Col>
  6. 第一个 Col
  7. </Col>
  8. <Col>
  9. <div>{title}</div>
  10. </Col>
  11. </Row>
  12. )
  13. };

::: warning 注:这里引用了 antdColRow 组件,跑测试时会报:[TypeError: window.matchMedia is not a function]。 这是因为 jsdom 没有实现 window.matchMedia,所以你要在 jest-setup.ts 里添加这个 API 的 Mock: :::

  1. // tests/jest-setup.ts
  2. // 详情:https://jestjs.io/docs/manual-mocks#mocking-methods-which-are-not-implemented-in-jsdom
  3. Object.defineProperty(window, 'matchMedia', {
  4. writable: true,
  5. value: jest.fn().mockImplementation(query => ({
  6. matches: false,
  7. media: query,
  8. onchange: null,
  9. addListener: jest.fn(), // deprecated
  10. removeListener: jest.fn(), // deprecated
  11. addEventListener: jest.fn(),
  12. removeEventListener: jest.fn(),
  13. dispatchEvent: jest.fn(),
  14. })),
  15. });

上面测试生成的快照会是这样:

  1. exports[`Title 可以正确渲染大字 1`] = `
  2. <body>
  3. <div>
  4. <div
  5. class="ant-row"
  6. style="font-size: 2em; color: red;"
  7. >
  8. <div
  9. class="ant-col"
  10. >
  11. 第一个 Col
  12. </div>
  13. <div
  14. class="ant-col"
  15. >
  16. <div>
  17. 大字
  18. </div>
  19. </div>
  20. </div>
  21. </div>
  22. </body>
  23. `;

杂揉了 antd 的 DOM 结构后,快照变得非常难读。解决方法是只记录第二个 Col 的 DOM 结构就好:

  1. describe("Title", () => {
  2. it("可以正确渲染大字", () => {
  3. const { getByText } = render(<Title type="large" title="大字" />);
  4. const content = getByText('大字');
  5. expect(content).toMatchSnapshot();
  6. });
  7. it("可以正确渲染小字", () => {
  8. const { getByText } = render(<Title type="small" title="小字" />);
  9. const content = getByText('小字');
  10. expect(content).toMatchSnapshot();
  11. });
  12. });

执行 npx jest --updateSnapshot 后,会生成如下快照:

  1. exports[`Title 可以正确渲染大字 1`] = `
  2. <div>
  3. 大字
  4. </div>
  5. `;
  6. exports[`Title 可以正确渲染小字 1`] = `
  7. <div>
  8. 小字
  9. </div>
  10. `;

看这样的快照就清爽多了。不过,快照并不是越小越好。因为如果快照太小你可能会想:这样的快照还不如我写 expect(content.children).toEqual('大字') 来得简单。

所以,对于那种输出很复杂,而且不方便用 expect 做断言时,快照测试才算是一个好方法。 这也是为什么组件 DOM 结构适合做快照,因为 DOM 结构有大量的大于、小于、引号这些字符。如果都用 expect 来断言,expect 的结果会写得非常痛苦。 不过,需要注意的是:不要把无关的 DOM 也记录到快照里,这无法让人看懂。

假错误

假如现在把 title 的 “大字” 改成 “我是一个大帅哥”:

  1. describe("Title", () => {
  2. it("可以正确渲染大字", () => {
  3. const { getByText } = render(<Title type="large" title="我是一个大帅哥" />);
  4. const content = getByText('大字');
  5. expect(content).toMatchSnapshot();
  6. });
  7. });

马上就得到这样的报错:

快照测试 - 图2

这里只是文案改了一下,业务代码并没有任何问题,测试却出错了,这就是测试中的 “假错误”。 虽然普通的单测、集成测试里也可能出现 “假错误”, 但是快照测试出现 “假错误” 的概率会更高,这也很多人不信任快照测试的主要原因。

在一些大快照,复杂组件的情况下,只要别的开发者改了某个地方,很容易导致一大片快照报错,基于人性的弱点,他们是没耐心看测试失败的原因的, 再加上更新快照的成本很低,只要加个 --updateSnapshot 就可以了,所以人们在面对快照测试不通过时,往往选择更新快照而不去思考 DOM 结构是否真的变了。

这些因素造成的最终结果就是:不再信任快照测试。 所以,你也会发现市面上很多前端测试的总结以及文章都很少做 快照测试。很大原因是快照测试本身比较脆弱, 而且容易造成 “假错误”。

快照的扩展

很多人喜欢把快照测试直接等于组件的 UI 测试,或者说快照测试是只用来测组件的。而事实上并不是! Jest 的快照可不仅仅能记录 DOM 结构,还能记录 一切能被序列化 的内容,比如纯文本、JSON、XML 等等。

举个例子:

  1. // getUserById.ts
  2. const getUserById = async (id: string) => {
  3. return request.get('user', {
  4. params: { id }
  5. })
  6. }
  7. // getUserById.test.ts
  8. describe('getUserById', () => {
  9. it('可以获取 userId == 1 的用户', async () => {
  10. const result = await getUserById('1')
  11. expect(result).toEqual({
  12. // 非常巨大的一个 JSON 返回...
  13. })
  14. })
  15. });

这个例子我们测试了 getUserById 的结果。在平常业务开发中,HTTP 请求返回的结果都是比较大的,有时候还会带一些冗余的数据,写 expect 的结果太麻烦了。 这里我们可以给当前的 response 拍一张快照,下次再用这张快照对比就好了:

  1. // getUserById.ts
  2. const getUserById = async (id: string) => {
  3. return request.get('user', {
  4. params: { id }
  5. })
  6. }
  7. // getUserById.test.ts
  8. describe('getUserById', () => {
  9. it('可以获取 userId == 1 的用户', async () => {
  10. const result = await getUserById('1')
  11. expect(result).toMatchSnapshot();
  12. })
  13. });

快照测试还适用于那些完全没有测试的项目。 想想看,如果你要把测试引入一个项目,你可能连 expect 的结果都不知道是啥样。

你会怎么做?要是我,我就先不写断言,先跑一次测试,把 result 打印出来,再把打印内容贴到 toEqual({...}),这样的过程不就是上面的快照测试么? 所以,快照测试非常适合在线上跑了很久的老项目,不仅能验证组件,还能验证函数返回、接口结果等。

总结

这一章我们学会了 快照测试。快照测试的思想很简单:

  • 先执行一次测试,把输出结果记录到 .snap 文件,以后每次测试都会把输出结果和 .snap 文件做 对比
  • 快照失败有两种可能:
    • 业务代码变更后导致输出结果和以前记录的 .snap 不一致,说明业务代码有问题,要排查 Bug
    • 业务代码有更新导致输出结果和以前记录的 .snap 不一致,新增功能改变了原有的 DOM 结构,要用 npx jest --updateSnapshot 更新当前快照

不过现实中这两种失败情况并不好区分,更多的情况是你既在重构又要加新需求,这就是为什么快照测试会出现 “假错误”。而如果开发者还滥用快照测试,并生成很多大快照, 那么最终的结果是没有人再相信快照测试。一遇到快照测试不通过,都不愿意探究失败的原因,而是选择更新快照来 “糊弄一下”。

要避免这样的情况,需要做好两点:

  • 生成小快照。 只取重要的部分来生成快照,必须保证快照是能让你看懂的
  • 合理使用快照。 快照测试不是只为组件测试服务,同样组件测试也不一定要包含快照测试。快照能存放一切可序列化的内容。

根据上面两点,还能总结出快照测试的适用场景:

  • 组件 DOM 结构的对比
  • 在线上跑了很久的老项目
  • 大块数据结果的对比

从这一章可以看出,做测试没有我们想的 —— 干就完了。很多时候需要我们做取舍,找到合适的测试策略。 这在下一章会有更明显的体验,下在让我们进入下一章的学习吧。