记录、分享、学习

Behavior Driven Development in ReactJS

7 min

如果想跳过前文,可以直接定位到 实战篇

What is Test Driven Development?

Coding of features and tests go hand in hand.

  1. Write a unit test.
  2. Run the test. See it fail.
  3. Write the feature code to pass the test.
  4. Refactor the code.

Why TDD?

  • It reduces errors and defects in the long run.
  • It leads to higher quality code.

What is Behavior Driven Development?

  • A variation of TDD that tests for user scenarios.
  • Given, when, then… [ pattern ]
  • Given notes, when deleting, then remove a note.
  • BDD consists of scenarios/specifications.

Test Tools

  • Jest
  • Enzyme

如果想看 create-react-app 或 jest/enzyme 环境的配置,可以 定位到 setup 内容

我的实战

项目中 Jest and Enzyme 的实战。

1. 第一个 Unit Test: toMatchSnapshot

快照是 Jest 把调用时的 component 的结构记录下来,下次可以用来对比结构有没有差异。

如果不一样,Jest 会报错,如果是预期内的展示,可以按 u 把当前快照更新为最新的 snapshot。

it('render correctly', () => {
  
  expect(app).toMatchSnapshot();
  
});
  

2. 测试 component 的 state

state 的初始化检测 —— 状态 gifts 的值为空数组。

it('init `state` for gifts as an empty list', () => {
  
  expect(app.state().gifts).toEqual([]);
  
});
  

注意:在 jest 中获得 state 是一个 state() 函数。

3. 点击交互的测试

通过 className 去查找交互元素,模拟用户行为,其中 simulate 是 Enzyme 提供的模拟函数。

it('add a gift to `state` when click the `add` button', () => {
  
  app.find('.btn-add').simulate('click');
  
  expect(app.state().gifts.length).not.toBe(0);
  
});
  
// 检验某个 component(Gift)是否存在
  
it('create a Gift component', () => {
  
  expect(app.find(Gift).exists()).toBe(true);
  
});
  

4. 利用 describe 划分测试代码块

describe把测试分组。也可以使用describe 定义一个场景,把相似的操作合并。

以下的两个测试都需要先触发一次 add-gift 按钮的点击,再验证相应的测试逻辑。

下面有两个 hook,beforeEachafterEach,可以用来执行前置共同的 action结束之后的 reset 逻辑

describe('when clicking the `add-gift` button', () => {
  
    beforeEach(() => {
  
      app.find('.btn-add').simulate('click');
  
    });
  
    afterEach(() => {
  
      // reset state `gifts` to []
  
      app.setState({
  
        gifts: []
  
      });
  
    });
  
    it('add a gift to `state`', () => {
  
      // app.find('.btn-add').simulate('click');
  
      expect(app.state().gifts.length).not.toBe(0);
  
    });
  
    it('display gifts on the rendered list', () => {
  
      // app.find('.btn-add').simulate('click');
  
      const stateListLength = app.state().gifts.length;
  
      const listItemLength = app.find('.list-item').length;
  
      expect(stateListLength).toEqual(listItemLength);
  
    });
  
  });
  

5. 父子组件交互测试

1)背景

在 GiftGiver 内,父组件<App />根据 state中的gifts 数组渲染子组件<Gift />,而子组件有一个删除按钮,点击后可以从父组件 stategifts 去掉命中当前 GiftID 的数据项。

// App.js
  
// state
  
this.state.gifts = [{
  
  id: xxx
  
}]
  
removeGift(id) {
  
  // this.state.gifts.filter(gift => gift.id !== id)
  
}
  
// render
  
{ this.state.gifts.map(gift => (
  
  <Gift gift={gift} removeGift={removeGift} />
  
))}
  
// Gift.js
  
// render
  
// const { gift, removeGift } = this.props;
  
<div>
  
  <Button onClick={ gift => removeGift(gift.id) }>remove</Button>
  
</div>
  

2)设计思路

  1. removeGift 挂在父组件(<App />)上,入参 giftID
  2. gift的数据和removeGift 作为 props 传给子组件(<Gift />
  3. 在子组件(<Gift />),有一个删除按钮,点击后调用父组件的 callback 函数,入参 giftID

3)写 test case 的思路

I. 父组件的测试用例 App.test.js

涉及的核心逻辑或交互:负责从数据源 this.state.gifts 中干掉对应数据的函数removeGift

测试思路removeGift入参 giftID 后,检查会不会正确地从 state 中去掉该项数组(giftID === item.id)。

实现详情:

a)前置操作:模拟调用行为。

beforeEach(() => {
  
  // call the `removeGift` function in App.js
  
  app.instance().removeGift(firstGiftID);
  
});
  

b)断言逻辑:确定 this.state.gifts 中没有包含对应项

it('gift with ID ${firstGiftID} is not in the state `gift`', () => {
  
  const { gifts } = app.state();
  
  const targetGiftList = gifts.find(gift => gift.id === firstGiftID) || [];
  
  expect(targetGiftList.length).toBe(0);
  
});
  
II. 子组件的测试用例 Gift.test.js

涉及的核心逻辑或交互

  • 点击一个删除按钮

  • 调用父组件传过来的 callback 函数,并传入 id 测试思路

  • 在 shallow 时,模拟父元素传入对应的 props

  • 模拟用户行为,点击删除按钮

  • 检查回调函数有没有被调用,以及传入的参数对不对 实现详情

1)在 shallow 时,模拟父元素传入对应的 props。

const mockRemove = jest.fn(); // 在第 3 点说明
  
const giftID = 1;
  
const props = {
  
  gift: {
  
    id: giftID
  
  },
  
  removeGift: mockRemove
  
};
  
const gift = shallow(<Gift { ...props } />);
  

2)beforeEach 里模拟删除按钮的点击

beforeEach(() => {
  
  gift.find('.btn-delete').simulate('click');
  
});
  

3)检查回调函数有没有被调用,以及传入的参数对不对

从第 1 点可以看到,shallow 渲染传入 props 时,回调函数把原本的 removeGift 函数替换成 jest 的 mock。(const mockRemove = jest.fn();)

因为该方法提供了一个断言检测方法,我们可以通过这个方式,检查回调函数有没有被调用以及传入的参数是否符合预期,实际的测试语句如下。

it('calls the removeGift callback', () => {
  
  expect(mockRemove).toHaveBeenCalledWith(giftID);
  
});
  

6. coverage testing

检测实际被调用代码的覆盖程度。(冗余代码检测)

npm run test -- --coverage
  

指定 --coverage目标文件:,在package.json 下,添加以下语句:

"jest": {
  
  "collectCoverageFrom": [
  
    "src/**.js",
  
    "!src/index.js"
  
  ]
  
}
  

Tips

如果存在某些函数/逻辑没有覆盖到,可以考虑新增一个和 component同级的helpers文件夹,在里面单独写那些跟组件基本功能无关的逻辑,如用于生成 ID 的 ID 生成函数,可以单拎出来放进helpers 及进行相应的单元测试。

Setup

Preparation

  1. node, v8.x
  2. npm, v5.x
  3. create-react-app

Steps

I. create-react-app yourProjectName

II. install dependencies

  • dependencies: react-dom & react
  • devDependencies: enzyme & jest-cli III. enzyme-adapter-react-16

In order to use the most current version of React > 16, we now need to install “enzyme adapters” to provide full compatibility with React.

npm i enzyme-adapter-react-16 --save-dev  
  

Next, add a src/tempPolyfills.js file to create the global request animation frame function that React now depends on.

src/tempPolyfills.js should contain the following contents:

const requestAnimationFrame = global.requestAnimationFrame = callback => {
  
  setTimeout(callback, 0);
  
}
  
export default requestAnimationFrame;
  

Finally, add a src/setupTests.js file to configure the enzmye adapter for our tests. The disableLifecyleMethods portion is needed to allow us to modify props through different tests.

src/setupTests.js should contain the following contents:

import requestAnimationFrame from './tempPolyfills';
  
import { configure } from 'enzyme';
  
import Adapter from 'enzyme-adapter-react-16';
  
configure({ adapter: new Adapter(), disableLifecycleMethods: true });