11

前端测试金字塔

对于一个 Web 应用来说,理想的测试组合应该包含大量单元测试(unit tests),部分快照测试(snapshot tests),以及少量端到端测试(e2e tests)。参考测试金字塔,我们构建了前端应用的测试金字塔。

image.png

单元测试

针对程序模块进行测试。模块是软件设计中的最小单位,一个函数或者一个 React 组件都可以称之为一个模块。单元测试运行快,反馈周期短,在短时间内就能够知道是否破坏了代码,因此在测试组合中占据了绝大部分。

快照测试

对组件的 UI 进行测试。传统的快照测试会拍摄组件的图片,并且将它和之前的图片进行对比,如果两张图片不匹配则测试失败。Jest 的快照测试不会拍摄图片,而是将 React 树序列化成字符串,通过比较两个字符串来判断 UI 是否改变。因为是纯文本的对比,所以不需要构建整个应用,运行速度自然比传统快照测试更快。

E2E 测试
相当于黑盒测试。测试者不需要知道程序内部是如何实现的,只需要根据业务需求,模拟用户的真实使用场景进行测试。

技术选型

测试种类 技术选型
单元测试 Jest + Enzyme
快照测试 Jest
E2E 测试 jest-puppeteer

Jest 是 Facebook 开源的测试框架。它的功能很强大,包含了测试执行器、断言库、spy、mock、snapshot 和测试覆盖率报告等。


Enzyme 是 Airbnb 开源的 React 单元测试工具。它扩展了 React 官方的 TestUtils,通过类 jQuery 风格的 API 对 DOM 进行处理,减少了很多重复代码,可以很方便的对渲染出来的结果进行断言。


jest-p[uppeteer]() 是一个同时包含 Jest 和 Puppeteer 的工具。Puppeteer 是谷歌官方提供的 Headless Chrome Node API,它提供了基于 DevTools Protocol 的上层 API 接口,用来控制 Chrome 或者 Chromium。有了 Puppeteer,我们可以很方便的进行端到端测试。

React 测试策略

测试本质上是对代码的保护,保证项目在迭代的过程中正常运行。当然,写测试也是有成本的,特别是复杂逻辑,写测试花的时间,可能不比写代码少。所以我们要制定合理的测试策略,有针对性的去写测试。至于哪些代码要测,哪些代码不测,总的来说遵循一个原则:投入低,收益高。「投入低」是指测试容易写,「收益高」是测试的价值高。换句话说,就是指测试应该优先保证核心代码逻辑,比如核心业务、基础模块、基础组件等,同时,编写测试和维护测试的成本也不宜过高。当然,这是理想情况,在实际的开发过程中还是要进行权衡。

单元测试

基于 React 和 Redux 项目的特点,我们制定了下面的测试策略:

分类 哪些要测? 哪些不测?
组件 有条件渲染的组件(如 if-else 分支,联动组件,权限控制组件等)
有用户交互的组件(如 Click、提交表单等)
* 逻辑组件(如高阶组件和 Children Render 组件)
connect 生成的容器组件
纯组合子组件的 Page 组件
纯展示的组件
组件样式
Reducer 有逻辑的 Reducer。如合并、删除  state。 纯取值的 reducer 不测。比如
(_, action) => action.payload.data 
Middleware 全测
Action Creator 全不测
方法 validators
formatters
* 其他公有方法
私有方法
公用模块 全测。比如处理 API 请求的模块。
Note: 如果使用了 TypeScript,类型约束可以替代部分函数入参和返回值类型的检查。

快照测试

Jest 的 snapshot 测试虽然运行起来很快,也能够起到一定保护 UI 的作用。但是它维护起来很困难(大量依赖人工对比),并且有时候不稳定(UI 无变化但 className 变化仍然会导致测试失败)。因此,个人不推荐在项目中使用。但是为了应付测试覆盖率,以及「给自己信心」,也可以给以下部分添加 snapshot 测试:

  • Page 组件:一个 page 对应一个 snapshot。
  • 纯展示的公用 UI 组件。

快照测试可以等整个 Page 或者 UI 组件构建完成之后再添加,以保证稳定。

E2E 测试

覆盖核心的业务 flow。

一个好的单元测试应该具备的条件?

安全重构已有代码

单元测试一个很重要的价值是为重构保驾护航。当输入不变时,当且仅当「被测业务代码功能被改动了」时,测试才应该挂掉。也就是说,无论怎么重构,测试都不应该挂掉。

在写组件测试时,我们常常遇到这样的情况:用 css class 选择器选中一个节点,然后对它进行断言,那么即使业务逻辑没有发生变化,重命名这个 class 时也会使测试挂掉。理论上来说,这样的测试并不算一个「好的测试」,但是考虑到它的业务价值,我们还是会写一些这样的测试,只不过写测试的时候需要注意:使用一些不容易发生变化的选择器,比如 component name、arial-label 等。

保存业务上下文

我们经常说测试即文档,没错,一个好的测试往往能够非常清晰的表单业务或代码的含义。

快速回归

快速回归是指测试运行速度快,且稳定。要想运行速度快,很重要的一点是 mock 好外部依赖。至于怎么具体怎么 mock 外部依赖,后面会详细说明。

单元测试怎么写?

定义测试名称

建议采用 BDD 的方式,即测试要接近自然语言,方便团队中的各个成员进行阅读。编写测试用例的时候,可以参考 AC,试着将 AC 的 Give-When-Then 转化成测试用例。

GIVEN: 准备测试条件,比如渲染组件。
WHEN:在某个具体的场景下,比如点击 button。
THEN:断言

describe("add user", () => {
  it("when I tap add user button, expected dialog opened with 3 form fields", () => {
    // Given: in profile page. 
    // Prepare test env, like render component etc.
    
    // When: button click. 
    // Simulate button click
    
    // Then: display `add user` form, which contains username, age and phone number.
    // Assert form fields length to equal 3
  });
});

Mock 外部依赖

单元测试的一个重要原则就是无依赖和隔离。也就是说,在测试某部分代码时,我们不期望它受到其他代码的影响。如果受到外部因素影响,测试就会变得非常复杂且不稳定。

我们写单元测试时,遇到的最大问题就是:代码过于复杂。比如当页面有 API 请求、日期、定时器或 redux conent 时,写测试就变得异常困难,因为我们需要花大量时间去隔离这些外部依赖。

隔离外部依赖需要用到测试替代方法,常见的有 spies、stubs 和 mocks。很多测试框架都实现了这三种方法,比如著名的 Jest 和 Sinon。这些方法可以帮助我们在测试中替换代码,减少测试编写的复杂度。

spies

spies 本质上是一个函数,它可以记录目标函数的调用信息,如调用次数、传参、返回值等等,但不会改变原始函数的行为。Jest 中的 mock function 就是 spies,比如我们常用的 jest.fn() 。

// Example:
onSubmit() {
  // some other logic here
  this.props.dispatch("xxx_action");
}

// Example Test:
it("when form submit, expected dispatch function to be called", () => {
  const mockDispatch = jest.fn();
  
  mount(<SomeComp dispatch={mockDispatch}/>);
  // simlate submit event here 
  expect(mockDispatch).toBeCalledWith("xxx_action");
  expect(mockDispatch).toBeCalledTimes(1);
});

spies 还可以用于替换属性方法、静态方法和原型链方法。由于这种修改会改变原始对象,使用之后必须调用 restore 方法予以还原,因此使用的时候要特别小心。

// Example:
const video = {
  play() {
    return true;
  },
};

// Example Test:
test('plays video', () => {
  const spy = jest.spyOn(video, 'play');
  const isPlaying = video.play();

  expect(spy).toHaveBeenCalled();
  expect(isPlaying).toBe(true);

  spy.mockRestore();
});

stubs

stubs 跟 spies 类似,但与 spies 不同的是,stubs 会替换目标函数。也就是说,如果使用 spies,原始的函数依然会被调用,但使用 stubs,原始的函数就不会被执行了。stubs 能够保证明确的测试边界。它可以用于以下场景:

  • 替换让测试变得复杂或慢的外部函数,如 ajax。
  • 测试异常条件,如抛出异常。

Jest 中也提供了类似的 API [](https://jestjs.io/docs/en/jes...[]()jest.spyOn().mockImplementation(),如下:

const spy = jest.fn();
const payload = [1, 2, 3];

jest
  .spyOn(jQuery, "ajax")
  .mockImplementation(({ success }) => success(payload));

jQuery.ajax({
  url: "https://example.api",
  success: data => spy(data)
});

expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenCalledWith(payload);

mocks

mocks 是指用自定义对象代替目标对象。我们不仅可以 mock API 返回值和自定义类,还可以 mock npm 模块等等。

// mock middleware api
const mockMiddlewareAPI = {
  dispatch: jest.fn(),
  getState: jest.fn(),
};

// mock npm module `config`
jest.mock("config", () => {
  return {
    API_BASE_URL: "http://base_url",
  };
});

使用 mocks 时,需要注意:

  • 如果 mock 了某个模块的依赖,需要等 mock 完成了之后再 require 这个模块。

有如下代码:

// counter.ts
let count = 0;

export const get = () => count;
export const inc = () => count++;
export const dec = () => count--;

错误做法:

// counter.test.ts
import * as counter from "../counter";

describe("counter", () => {
  it("get", () => {
    jest.mock("../counter", () => ({
      get: () => "mock count",
    }));
    expect(counter.get()).toEqual("mock count"); // 测试失败,此时的 counter 模块并非 mock 之后的模块。
  });
});

正确做法:

describe("counter", () => {
  it("get", () => {
    jest.mock("../counter", () => ({
      get: () => "mock count",
    }));
    const counter = require("../counter"); // 这里的 counter 是 mock 之后的 counter
    expect(counter.get()).toEqual("mock count"); // 测试成功
  });
});
  • 多个测试有共享状态时,每次测试完成之后需要重置模块 jest.resetModules() 。它会清空所有 required 模块的缓存,保证模块之间的隔离。

错误的做法:

describe("counter", () => {
  it("inc", () => {
    const counter = require("../counter");
    counter.inc();
    expect(counter.get()).toEqual(1);
  });

  it("get", () => {
    const counter = require("../counter"); // 这里的 counter 和上一个测试中的 counter 是同一份拷贝
    expect(counter.get()).toEqual(0); // 测试失败
    console.log(counter.get()); // ? 输出: 1
  });
});

正确的做法:

describe("counter", () => {
  afterEach(() => {
    jest.resetModules(); // 清空 required modules 的缓存
  });
  
  it("inc", () => {
    const counter = require("../counter");
    counter.inc();
    expect(counter.get()).toEqual(1);
  });

  it("get", () => {
    const counter = require("../counter"); // 这里的 counter 和上一个测试中的 counter 是不同的拷贝
    expect(counter.get()).toEqual(0); // 测试成功
    console.log(counter.get()); // ? 输出: 0
  });
});

修改代码,从一个外部模块 defaultCount 中获取 count 的默认值。

// defaultCount.ts
export const defaultCount = 0;

// counter.ts
import {defaultCount} from "./defaultCount";

let count = defaultCount;

export const inc = () => count++;
export const dec = () => count--;
export const get = () => count;

测试代码:

import * as counter from "../counter"; // 首次导入 counter 模块
console.log(counter); 

describe("counter", () => {
  it("inc", () => {
    jest.mock("../defaultCount", () => ({
      defaultCount: 10,
    }));
    const counter1 = require("../counter"); // 再次导入 counter 模块
    
    counter1.inc();
    
    expect(counter1.get()).toEqual(11); // 测试失败
    console.log(counter1.get()); // 输出: 1
  });
});

再次 require counter 时,发现模块已经被 require 过了,就直接从缓存中获取,所以 counter1 使用的还是counter 的上下文,也就是 defaultCount = 0。而调用 resetModules() 会清空 cache,重新调用模块函数。

在上面的代码中,注释掉 1,2 行,测试也会成功。大家可以想想为什么?

编写测试

组件测试

渲染组件

要对组件进行测试,首先要将组件渲染出来。Enzyme 提供了三种渲染方式: 浅渲染、全渲染以及静态渲染。

浅渲染(Shallow Render)

shallow 方法会把组件渲染成 Virtual DOM 对象,只会渲染组件中的第一层,不会渲染它的子组件,因此不需要关心 DOM 和执行环境,测试的运行速度很快。

浅渲染对上层组件非常有用。上层组件往往包含很多子组件(比如 App 或 Page 组件),如果将它的子组件全部渲染出来,就意味着上层组件的测试要依赖于子组件的行为,这样不仅使测试变得更加困难,也大大降低了效率,不符合单元测试的原则。

浅渲染也有天生的缺点,因为它只能渲染一级节点。如果要测试子节点,又不想全渲染怎么办呢?shallow 还提供了一个很好用的接口 .dive,通过它可以获取 wrapper 子节点的 React DOM 结构。

示例代码:

export const Demo = () => (
  <CompA>
    <Container><List /></Container>
  </CompA>
);

使用 shallow 后得到如下结构:

<CompA>
  <Container />
</CompA>

使用 .dive() 后得到如下结构:

<div>
  <Container>
      <List />
  </Container>
</div>
全渲染(Full DOM Render)

mount 方法会把组件渲染成真实的 DOM 节点。如果你的测试依赖于真实的 DOM 节点或者子组件,那就必须使用 mount 方法。特别是大量使用 Child Render 的组件,很多时候测试会依赖 Child Render 里面的内容,因此需要需要用全渲染,将子组件也渲染出来。

全渲染方式需要浏览器环境,不过 Jest 已经提供了,它的默认的运行环境 jsdom ,就是一个 JavaScript 浏览器环境。需要注意的是,如果多个测试依赖了同一个 DOM,它们可能会相互影响,因此在每个测试结束之后,最好使用 .unmount() 进行清理。

静态渲染(Static Render)

将组件渲染成静态的 HTML 字符串,然后使用 Cheerio 对其进行解析,返回一个 Cheerio 实例对象,可以用来分析组件的 HTML 结构。

测试条件渲染

我们常常会用到条件渲染,也就是在满足不同条件时,渲染不同组件。比如:
 

import React, { ReactNode } from "react";

const Container = ({ children }: { children: ReactNode }) => <div aria-label="container">{children}</div>;
const CompA = ({ children }: { children: ReactNode }) => <div>{children}</div>;
const List = () => <div>List Component</div>;

interface IDemoListProps {
  list: string[];
}

export const DemoList = ({ list }: IDemoListProps) => (
  <CompA>
    <Container>{list.length > 0 ? <List /> : null}</Container>
  </CompA>
);

对于条件渲染,这里提供了两种思路:

  • 测试是否渲染了正确节点

一般的做法是将 DemoList 组件渲染出来,再根据不同的条件,去检查是否渲染出了正确的节点。

describe("DemoList", () => {
  it("when list length is more than 0, expected to render List component", () => {
    const wrapper = shallow(<DemoList list={["A", "B", "C"]} />);
    expect(
      wrapper
        .dive()
        .find("List")
        .exists(),
    ).toBe(true);
  });

  it("when list length is more than 0, expected to render null", () => {
    const wrapper = shallow(<DemoList list={[]} />);
    expect(
      wrapper
        .dive()
        .find("[aria-label='container']")
        .children().length,
    ).toBe(0);
  });
});
  • 公用组件 + 只测判断条件

我们可以抽象一个公用组件 <Show/> ,用于所有条件渲染的组件。这个组件接受一个 condition ,当满足这个 condition 时显示某个节点,不满足时显示另一个节点。

<Show condition={}  ifNode={} elseNode={} />

我们可以为这个组件添加测试,确保在不同的条件下显示正确的节点。既然这个逻辑得已经得到了保证,使用 <Show/> 组件的地方就无需再次验证。因此我们只需要测试是否正确生成了 condition 即可。

export const shouldShowBtn = (a: string, b: string, c: string) => a === b || b === c;
describe("should show button or not", () => {
  it("should show button", () => {
    expect(shouldShowBtn("x", "x", "x")).toBe(true);
  });
  it("should hide button", () => {
    expect(shouldShowBtn("x", "y", "z")).toBe(false);
  });
});

对于有权限控制的组件,一个小的配置改变也会导致整个渲染的不同,而且人工测试很难发现,这种配置多一个 prop 检查会让代码更加安全。

测试用户交互

常见的有点击事件、表单提交、validate 等。

  • 点击事件 click。
  • onSubmit 。主要是测试 onSubmit 方法被调用之后是否发生了正确的行为,如 dispatch action 。
  • validate 。 主要是测试 error message 是否按正确的顺序显示。

Action Creator 测试

action creator 的实现和测试都非常简单,这里就不举例了。但要注意的是,不要将计算逻辑放到 aciton creator 中。

错误的方式:

// action.ts
export const getList = createAction("@@list/getList", (reqParams: any) => {
  const params = formatReqParams({
    ...reqParams,
    page: reqParams.page + 1,
    startDate: formatStartDate(reqParams.startDate)
    endDate: formatStartDate(reqParams.endDate)
  });
  
  return {
    url: "/api/list",
    method: "GET",
    params,
  };
});

正确的方式:

// action.ts
export const getList = createAction("@@list/getList", (params: any) => {
  return {
    url: "/api/list",
    method: "GET",
    params,
  };
});

// 调用 action creator 时,先把值计算好,再传给 action creator。

// utils.ts
const formatReqParams = (reqParams: any) => {
return formatReqParams({
    ...reqParams,
    page: reqParams.page + 1,
    startDate: formatStartDate(reqParams.startDate)
    endDate: formatStartDate(reqParams.endDate)
  });
};

// page.ts
getFeedbackList(formatReqParams({}));

Reducer 测试

Reducer 测试主要是测试「根据 Action 和 State 是否生成了正确的 State」。因为 reducer 是纯函数,所以测试非常好写,这里就不细讲了。 

Middleware 测试

测试 middleware 最重要的就是 mock 外部依赖,其中包括 middlewareAPI 和 next 。

Test Helper:

class MiddlewareTestHelper {
  static of(middleware: any) {
    return new MiddlewareTestHelper(middleware);
  }

  constructor(private middleware: Middleware) {}

  create() {
    const middlewareAPI = {
      dispatch: jest.fn(),
      getState: jest.fn(),
    };
    const next = jest.fn();
    const invoke$ = (action: any) => this.middleware(middlewareAPI)(next)(action);

    return {
      middlewareAPI,
      next,
      invoke$,
    };
  }
}

Example Test:

it("should handle the action", () => {
  const { next, invoke$ } = MiddlewareTestHelper.of(testMiddleware()).create();
  invoke$({
    type: "SOME_ACTION",
    payload: {},
  });
  expect(next).toBeCalled();
});

测试异步代码

默认情况下,一旦到达运行上下文底部,jest测试立即结束。为了解决这个问题,我们可以使用:

  • done() 回调函数
  • return promise
  • async/await

错误的方式:

test('the data is peanut butter', () => {
  function callback(data) {
    expect(data).toBe('peanut butter');
  }

  fetchData(callback);
});

正确的方式:

test('the data is peanut butter', done => {
  function callback(data) {
    expect(data).toBe('peanut butter');
    done();
  }

  fetchData(callback);
});
test('the data is peanut butter', () => {
  expect.assertions(1);
  return fetchData().then(data => {
    expect(data).toBe('peanut butter');
  });
});
test("the data is peanut butter", async () => {
  const data = await fetchData();
  expect(data).toBe("peanut butter");
});

执行测试

采用「红 - 绿」的方式,即先让测试失败,再修改代码让测试通过,以确保断言被执行。

快照测试怎么写?

通过 redux-mock-store,将组件需要的全部数据准备好(给 mock store 准备 state),再进行测试。

从测试的角度反思应用设计

「好测试」的前提是要有「好代码」。因此我们可以从测试的角度去反思整个应用的设计,让组件的「可测试性」更高。

  • 单一职责。 一个组件只干一类事情,降低复杂度。只要每个小的部分能够被正确验证,组合起来能够完成整体功能,那么测试的时候,只需要专注于各个小的部分即可。
  • 良好的复用。 即复用逻辑的同时,也复用了测试。
  • 保证最小可用,再逐渐增加功能。 也就是我们平时所说的 TDD。
  • ...

Debug

console.log(wrapper.debug());

参考文章 

译-Sinon入门:利用Mocks,Spies和Stubs完成javascript测试
使用Jest进行React单元测试
对 React 组件进行单元测试
How to Rethink Your Testing
使用Enzyme测试React(Native)组件
Node.js模块化机制原理探究
单元测试的意义、做法、经验
React 单元测试策略及落地 


橘子小睿
665 声望75 粉丝

前端开发者