接着上篇的内容, 这篇文章会详细的介绍在 Glow 我们如何写单元测试, 以及在 React Native 中各个模块单元测试的详细实现方式。
Jest 是 Facebook 开源的 Javascript 测试框架,提供了许多好用的 API,先介绍下主要的优点:
jest.fn()
就可以实现 spy function。Enzyme 是 AirBnb 开源的用于 React测试的 js utility。(在 vuejs 测试中可以用 vue-test-utils)
最常用的render方法是 Shallow Render。
这种方法的特点是只 render 当前组件中一层深的元素, 不会去渲染当前组件中用到的子组件。 这就保证了测当前组件的时候, 不会受到子组件行为的影响。符合分层测试的需求;并且也比较快速。需要渲染更深层次的子组件时也可以用 enzyme 提供的dive方法来实现。
传统的 Snapshot 测试一般是渲染一个UI组件 -> 截取屏幕快照 -> 和之前的屏幕快照对比。如果对比失败了(两个截图不同),要么是有 bug, 要么需要升级屏幕快照(UI 意料之中的更新)。
Jest Snapshot Test的特点:
toMatchSnapshot
方法会帮你对比这次要生成的结构和上次的区别。Snapshot可以很大幅度的减少组件UI测试花费的精力, 工作流程如下:
expect(result).toEqual(expectedResult)
现在可以写成 expect(result).toMatchSnapshot()
, 同时生成结果的snapshot被储存在*.snap文件中。jest -u
指令即可更新之前生成的 snapshot 结果。为什么 Snapshot 在 React 测试中是可靠的呢?
实际应用时,我们用了 jest 的 shallow
方法来生成测试组件的wrapper; 用 enzyme-to-json/serializer
这个 lib 把生成的 shallowWrapper 转化成 snapshot 结果。 用 shallow 的好处是保证每个组件测试的独立性,比如在当前组件的 snapshot 结构树中, 我只关心我用到的 childComponent 的名字和传给他什么 prop, 具体这个组件的内部UI结构应该交给这个组件本身的snapshot测试去保证。
举例我们要测试一个
组件。
// 生成这个组件的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 并不会受影。 这样测试之间就相互独立了。
这几种 React Native 不同layer的测试都属于功能函数测试,一个良好的 React Native 项目应该把业务逻辑尽量都实现在这几个 layer 中, 而不是堆放在组件中。也就是把显示(views)和逻辑分开。
这样纯函数和函数式变成的优势就体现出来了,不仅code结构和层级变的清晰,编写和维护单元测试也变得简单了。
如果你的项目有难以测试的函数/组件, 应该先想着如何refactor,把庞大复杂的逻辑/组件拆分成功能单一的单元, 尽量让一个函数只做一个task。
先看一下我们目前 React Native 的逻辑结构:
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 了。
纯 Object的 action 测试比较简单, 保证 action creator 函数返回的 Object 正确就可以了。
Async action的测试有两种不错的方案:
configureMockStore
,将 redux-thunk 这种异步中间件传入进去处理,获得封装后的 store.dispatch 来派发actionconst 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”}});
…
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, 最终的结果就应该是唯一的。
和普通的js函数型单元测试没有区别,就不多赘述了。
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 测试中把 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。这也是必不可少的一步。
最近我们用了 React Apollo 和 graphene (+ SqlAlchemy) 来做 graphql 在 client 和 server 的实现。
测试Apollo client时可以用 apollo-test-utils 来 mock 网络返回的结果。
测试 server 的时候和正常的 WWW API 测试类似, 只要保证发送的请求(同样需要 mock header 并正确的调用 setContext 来设置 graphql 请求的参数)在 server 上返回结果正确即可。只要把 client 调用的Query语句覆盖一遍就足够了。
前面讲的实际测试方法中都是单元化的去测试, 在实践中也会有一些集成测试来保证这些单元组装起来也是work的。
如何来规划集成测试的 scope 也是根据项目不同来选择合适的方案的,有这样一层测试可以在不依赖于大量E2E测试的情况下保证各个组件之间也是正确工作的,是对测试效率和测试信心都有好处的一种这种方案。
我认为这样的测试体系是比较安全高效的,用大量的自动化测试代替了人不擅长的重复性测试工作。还有些未来测试可以做的事情:
就写到这里, 希望对阅读的人有所帮助~