如何自动化测试 React Native 项目 (下篇) - 单元测试

原创发布于 tech.glowing.com

接着上篇的内容, 这篇文章会详细的介绍在 Glow 我们如何写单元测试, 以及在 React Native 中各个模块单元测试的详细实现方式。

单元测试工具 - Jest & Enzyme

 

Jest - Facebook

Jest 是 Facebook 开源的 Javascript 测试框架,提供了许多好用的 API,先介绍下主要的优点:

  • 自带 snapshot 测试,让UI测试简单有效
  • 几乎 0 配置,自带各种功能。 相比其他单元测试:karma (test runner) + mocha (test framework) + chai (assertion) + sinon (test spy) + ...
  • 并行执行测试 case
  • 提供 watch mode,很方便的可以实行 TDD 的开发模式或者更新代码的同时自动运行单元测试。
  • 提供简单实用的 spy, mock 方法。 用 jest.fn() 就可以实现 spy function。
  • 自带清晰易懂的 code coverage 生成功能。 集成了 istanbul
  • 不仅适用于 React Native 测试, 也可以适用于 React.js, Vuejs 等其他 js lib 或者 framework。

Enzyme - Airbnb

Enzyme 是 AirBnb 开源的用于 React测试的 js utility。(在 vuejs 测试中可以用 vue-test-utils)

  • Enzyme 提供了可以直接操作 React component 中的 props 和s tate 的方法,使得建造测试 context 变的简单。不需要调用c omponent 原本的方法来更新 state, prop 等。

  • Enzyme 提供了三种 render React component的方法, static, shallow 和 mount。

最常用的render方法是 Shallow Render。

这种方法的特点是只 render 当前组件中一层深的元素, 不会去渲染当前组件中用到的子组件。 这就保证了测当前组件的时候, 不会受到子组件行为的影响。符合分层测试的需求;并且也比较快速。需要渲染更深层次的子组件时也可以用 enzyme 提供的dive方法来实现。

单元测试实践

组件UI测试 (Snapshot)

传统的 Snapshot 测试一般是渲染一个UI组件 -> 截取屏幕快照 -> 和之前的屏幕快照对比。如果对比失败了(两个截图不同),要么是有 bug, 要么需要升级屏幕快照(UI 意料之中的更新)。

Jest Snapshot Test的特点:

  • Jest 使用一个 test renderer 来生成出 React tree 的序列化结构树。toMatchSnapshot 方法会帮你对比这次要生成的结构和上次的区别。

  • 当元素的 prop 或者 state 不同时,会生成不同情况的snapshot来覆盖这些情况下的UI结构。

  • Jest 的 snapshot 测试不仅可以对比React tree结构的区别, 也可以对比其他可序列化的值的区别。 比如对比Redux某个状态的state是否和之前相同。

Snapshot可以很大幅度的减少组件UI测试花费的精力, 工作流程如下:

  • 传统的assertion: expect(result).toEqual(expectedResult) 现在可以写成 expect(result).toMatchSnapshot(), 同时生成结果的snapshot被储存在*.snap文件中。

  • 当生成的 snapshot 结果发生变化时, jest 会清楚的告诉测试人员哪块 component tree 发生了变化(看起来就像 git diff),这样测试人员就可以知道这里是 bug 还是正确的UI更新。

  • 当 snapshot 结果需要升级更新时, 只需要执行 jest -u 指令即可更新之前生成的 snapshot 结果。

为什么 Snapshot 在 React 测试中是可靠的呢?

  • 在 React(以及 React Native ) 的开发理念中, 开发者把重点放在描述要显示的组件在不同输入时的静态状态,然后交给React去处理UI的更新。 可以想象成每次UI有变化时会重新生成这个组件并刷新, React会帮开发者处理具体怎么高效的变化。

  • 因此我们在测试组件的时候, 也只要把重点放在测试我们如何描述这个组件。当一个组件的 prop 和 state 确定时, 我们用 snapshot 保证在这个状态下组件的序列化结构是符合预期的,而不需要考虑状态转变时发生的动态变化。

  • 对测试来说, 我们永远应该把注意力放在自己team写的代码上, 因此可以足够安全的认为当生成的 snapshot 正确时,组件的UI渲染也是正确的。

实际应用时,我们用了 jest 的 shallow 方法来生成测试组件的wrapper; 用 enzyme-to-json/serializer 这个 lib 把生成的 shallowWrapper 转化成 snapshot 结果。
用 shallow 的好处是保证每个组件测试的独立性,比如在当前组件的 snapshot 结构树中, 我只关心我用到的 childComponent 的名字和传给他什么 prop, 具体这个组件的内部UI结构应该交给这个组件本身的snapshot测试去保证。

举例我们要测试一个 <Home /> 组件。

// 生成这个组件的shallowWrapper, props为测试时需要传给组件的prop
const setup = props => {  
  return shallow(<Home {...props}/>>);
}
const wrapper = setup({title: 'example title', dateIdx: 100});

// 生成snapshot并和之前的结果对比

expect(wrapper).toMatchSnapshot();  
// 序列化结构树会自动和*.snap中的结果比较

如果是第一次生成 snapshot, 应该去仔细看一下 Home.react-test.js.snap 中生成的结构树,防止原始的 snapshot 就是错误的。

除了测试,组件本身的设计也会影响到测试的效率。
比如一个逻辑很复杂的组件, props可能是几个很复杂的 Object, 那么这个组件内部除了显示逻辑还包含了很多从这些 Object 中计算出需要显示的data的逻辑。 这样在设计和维护单元测试脚本时就很困难。

正确的做法应该是在设计 Component 的时候就设计成 Container - Presentational Component的模式。(参考 Smart and Dumb components - Dan Abramov)。

这样的好处是比如本来UI上需要显示一段 text, 这段 text 根据几个复杂的 Object 计算出来,那原本的测试就需要mock这些复杂的 Object 并保证 snapshot 的正确性。 当把这个Component 重构成presentational的组件之后,它只需要一个这个 text 字段的 prop 传给他一个 string, 然后把这个 prop 显示在UI上, 计算逻辑被抽象到了父组件或者 selector层(redux和component之间)。

这样我们的测试脚本的可维护性就变高了, 这个组件本身也变得更加单纯了。

组件交互测试

用 Enzyme shallow 生成的 ReactWrapper 会提供一些用来进行组件交互测试的 API,比如 find(), parents(), children()等选择器进行元素查找; state(), props()进行数据查找; setState(), setProps()进行数据操作; simulate()模拟时间触发。

在交互测试中,我们主要利用 simulate() API模拟事件,来判断这个元素的 prop 上的特定函数是否被调用, 传参是否正确, 以及组件状态是否发生意料之中的修改。在最近的 enzyme 版本更新后, shallowWrapper 的 component lifecycle 函数也会被正确的调用。因此对组件状态的测试是比较容易的。

比如我们有一个元素中包含了下面这块代码:

...
<PrimaryButton onPress={()=>{Logger.log('Button_Clicked')}}>  
...

我们的测试脚本可以这么写:

// Mock Logger module中的方法, 用jest.fn来实现spy方法
Logger.log = jest.fn();

// setup shallowWrapper
const setup = props => shallow(<SomeComponent {...props}/>>);  
const wrapper = setup();

// 找到元素并且模拟press事件
wrapper.find('PrimaryButton').simulate('press');

// Assert正确的方法被调用, 并且传参正确
expect(Logger.log).toBeCalledWith('Button_Clicked');  

如果 press 事件导致 state 变化或者UI变化, 也可以用 enzyme 提供的API或者用 snapshot 进行测试。

要注意的是在这个 case 中我们用了 shallow render,simulate 的点击事件只是执行了这个组件的 onPress 方法,而这个 PrimaryButton 的组件内部是不是把这个 onPress 真正的执行了这个 case 并不关心。因此需要另一个针对 PrimaryButton 组件的单元测试来保证 onPress 这个prop被正确的处理了。

这样的好处是当 PrimaryButton 自身出现bug时, 之后这个组件本身的单元测试会 fail, 其他用到这个组件的 Component 并不会受影。 这样测试之间就相互独立了。

Reducer/Action handler/Selector/Utils 测试

这几种 React Native 不同layer的测试都属于功能函数测试,一个良好的 React Native 项目应该把业务逻辑尽量都实现在这几个 layer 中, 而不是堆放在组件中。也就是把显示(views)和逻辑分开。

这样纯函数和函数式变成的优势就体现出来了,不仅code结构和层级变的清晰,编写和维护单元测试也变得简单了。

如果你的项目有难以测试的函数/组件, 应该先想着如何refactor,把庞大复杂的逻辑/组件拆分成功能单一的单元, 尽量让一个函数只做一个task。

先看一下我们目前 React Native 的逻辑结构:

Structure

  • Redux 的 Store 中储存着 global 的 App state

  • Selectors 把A pp state(有时候和 component 的 prop 一起)转化成 Component(React Views)显示时需要的简单的Prop

  • Component 要改变 App state 的时候, dispatch 一个 action 到 Action handler 中(react-thunk),来执行异步的 action(用了redux-thunk, 最终会dispatch纯Object的action)或者纯Object 的 action。

  • Reducer接收action和旧的app state生成新的app state并存到Store中。

  • Store改变后会通过Selectors更新Component的UI。

1. Reducer测试

Reducer 是纯函数, 因此测试的时候只要引入函数, 传入特定参数,判断函数返回是否符合预期即可。 可以利用 jest 的 snapshot test 来判断结果。

举个例子, 有reducer如下(我们在redux中使用了Immutable.js):

// reducer
export function localUserReducer(state, action) {  
  switch (action.type) {
    case Actions.UPDATE_USER:
      state = state.merge(Immutable.fromJS(action.user));
      break;
    default:
    break
  }
  return state;
}

// action
export function updateUser(user: Object): Object {  
  return {
    type: Actions.UPDATE_USER,
    user: user,
  };
}

测试脚本可以这么写

// ingore import of reducer and action
it('should merge user info when updateUser action is dispatched', ()=>{  
    const state = Immutable.fromJS({ name: 'old_name', other: 'old_other' });
    expect(localUserReducer(state, updateUser({ new: 'new fields', name: 'new name' }))).toMatchSnapshot();
})

生成的snapshot如下:

exports[`should merge user info when updateUser action is dispatched`] = `  
Immutable.Map {  
  "name": "new name",
  "other": "old_other",
  "new": "new fields",
}
`;

可以看到 snapshot 中得到了一个 Immutable.Map 类型的对象, 并且Map的值正确的被 merge 了。

2. Action Handler测试

纯 Object的 action 测试比较简单, 保证 action creator 函数返回的 Object 正确就可以了。

Async action的测试有两种不错的方案:

  • 借助第三方库configureMockStore,将 redux-thunk 这种异步中间件传入进去处理,获得封装后的 store.dispatch 来派发action

  • 利用 jest 的 spy 函数, mock const dispatch = jest.fn(), 然后把 dispatch 传给异步 action 的函数, 并验证 dispatch spy 被传了正确的 object action 参数。

比如有个异步 action:

export function saveOnboardingUser(user) {  
  return async (dispatch) => {
    await someAsyncFunction();
    dispatch(updateUser(user))
    return user;
  }
}

测试代码:

...
const dispatch = jest.fn();  
await Actions.saveOnboardedUser({name: 'example'});  
expect(dispatch).toBeCalledWith({ "type": "update_local_user", "user":{ "name": "example"}});  
...
3. Selector 测试

Selector 这层我们用了 reselect 这个库, selector 的作用是从 redux store 的 state 中选出我们需要的值。
因此 selector 也是纯函数, 在测试的时候只需要 mock一个 redux 的 state, 然后保证 selector 的结果正确即可。

这里只简单的写一下测试代码:

...
const state = Immutable.Map({ui: Immutable.Map({dateIdx: 10})});  
expect(homeDateSelector(state)).toBe(10);

// 或者expect(homeDateSelector(state)).toMatchSnapshot()
...

当然 homeDateSelector 中会有从 state 中得到 dateIdx 这个值的实现代码, 比如 state => state.getIn(['ui', 'dateIdx])
selector 是可嵌套的, 但只要正确的 mock redux state, 最终的结果就应该是唯一的。

4. Utils 测试

和普通的js函数型单元测试没有区别,就不多赘述了。

WWW API测试

WWW API测试是指对server接口的测试, 只要在测试代码中调用 React Native 的API模块的方法并且验证返回结果的正确性即可(可能需要 mock 一些 token,device info等信息)。
和通常的 WWW API 测试的方法几乎相同。 用Jest实现的好处是保持所有的单元测试用统一的 framework 实现和运行, 用起来比较方便。

这块测试因为需要真正的连接到 server 上, 因此可以和其他的单元测试分开以提高运行的速度。可以在 package.json 里面用不同的 yarn script, 如 yarn test-ut, yarn test-wwwapi 来分别执行单元测试和WWW API测试。

Logging 测试

我在 Logging 测试中把 logger 这个 module 在初始化测试时 global 的 mock 了一个 spy 函数。 (logger.logEvent = jest.fn(); global.logEvent = logger.LogEvent)。
这样在测试其他单元/组件时, 只要代码中调用到了 logger 模块的方法, 就可以用:

expect(logEvent).toBeLastCalledWith(eventName: 'xxxx', {type: 'xxx'})  

这样的方法来测试 logging 的正确性。
此外还需要手工的测试 logging 对应的 native module 可以把 logging 传给 server。这也是必不可少的一步。

Graphql 测试

最近我们用了 React Apollographene (+ SqlAlchemy) 来做 graphql 在 client 和 server 的实现。

测试Apollo client时可以用 apollo-test-utils 来 mock 网络返回的结果。

测试 server 的时候和正常的 WWW API 测试类似, 只要保证发送的请求(同样需要 mock header 并正确的调用 setContext 来设置 graphql 请求的参数)在 server 上返回结果正确即可。只要把 client 调用的Query语句覆盖一遍就足够了。

一些集成测试

前面讲的实际测试方法中都是单元化的去测试, 在实践中也会有一些集成测试来保证这些单元组装起来也是work的。

  • 比如把 reducer + selector + UI render 这三个layer组合起来,选择一个典型的 flow 来写集成测试。

  • 或者把 WWW API, Action Handler 和 reducer 集成起来, 保证 server 的数据和 client 是可兼容的, 防止mock的数据和 server 返回数据不一致导致的问题。

  • 或者把 parent-children Components 做一个集成测试, 也是不错的测试方案。

如何来规划集成测试的 scope 也是根据项目不同来选择合适的方案的,有这样一层测试可以在不依赖于大量E2E测试的情况下保证各个组件之间也是正确工作的,是对测试效率和测试信心都有好处的一种这种方案。

总结

  • 在 Glow 的 React Native 项目测试中, 我们有大量的单元测试,包含了Component/Reducers/Action Handlers/Selectors/Utils/WWW API/Logging 等等。 有少量的集成性测试和更少量的E2E全面测试。

  • 在 server 端有 server 的单元测试。

  • 在 Code quality 有 eslint, python和Flow type。

  • 此外还有必不可少的人工探索性测试, 来保证自动化测试无法覆盖的方面以及各种需要想象力的逻辑测试。

我认为这样的测试体系是比较安全高效的,用大量的自动化测试代替了人不擅长的重复性测试工作。还有些未来测试可以做的事情:

  • 提高单元测试,集成测试,E2E测试的覆盖率。

  • 稳定的可持续集成系统

  • DashBoard 来记录追踪测试结果来评估整体的App质量

就写到这里, 希望对阅读的人有所帮助~