乘着改革开放的浪潮,这段时间我们终于接触到非常火热的前端项目构架 React+Redux。
这个构架下的前端项目,最大的优点就是 Redux 鼓励各个组件无状态化 (no state),利用 store 统一管理 state,从而使各个组件之间相对更加独立和易于维护,使得前端的构架更加简单化。下图中左边是经典 React 中各组件的层级关系,右边是引入 Redux 之后的层级。在左图中,当修改父节点/组件时,子组件也可能会被破坏掉;而右图中能够影响到各个组件的因素只有 state。
在传统 JS Web 项目中的自动化测试,通常会有这些比较突出的问题
那么作为新技术的 React+Redux 的出现,会不会给也测试带来一些新的思路或者机会来解决这些问题呢?比如放弃掉经典的 UI 自动化测试?
作为浸泡在测试金字塔理论中多年的吃瓜的测试群众,我对越低层的测试成本更少、反馈问题更快这个道理深以为然。所以在面对这样的新鲜事物的时候,总愿意去分析下是否可以结合项目的技术特点,尽量把自动化测试往低层移,减少成本,加速反馈周期。还可以把锅甩给研发同学。
为了分析技术上实现的可行性,我们至少需要知道 React 和 Redux 的一些基本概念:
- Store : 全局唯一的对象,用来保存 state
- State : 某个时间点上 state 的快照,和改时间点上的 view 应该是一一对应的
- Action : view 通过
store.dispatch(action)
发出的通知,表示 state 应该要发生变化了。- Reducer : 接受 action 和当前 state,返回新的 state 的函数
- UI Component : 纯负责显示 UI,无状态
- Container (Component): 负责一些业务逻辑和 connect UI 组件
- Provider : React-Redux 库的让 react 组件拿到新的 state 的方法
还需要了解 Redux 大致的工作流程:
我刚好写了个简单的demo,能大概看懂这个 demo 中,各个组件是做什么的,如何工作的,对后面的内容有极大帮助。demo 使用了 webpack 作为打包和本地运行工具
这么看起来,redux 通过 state-view 一一对应的架构保证了只要 view 变,state 一定变,反之亦然。这种一一对应的关系减少了组件之间发生关联 (变化) 的可能性,从而减轻了测试复杂度。最后再总结一下,发现针对该架构的自动化测试其实只需要保证下面几点就够了:
从上面儿的分析结果来看,只用低层测试来保证质量的想法,好像有点儿靠谱的样子。接下来就是做一些小的 demo 来验证下真实项目中是否行得通。
好的单元测试应该有哪些特点呢?
那么首先,我们应该是尽量选择一款满足上面需求的测试工具。
满足上面条件的 JS 前端单元测试工具/框架很多,比较流行的是 mocha+chai、JEST 等。这里我们使用JEST测试框架和Enzyme测试工具库。
经过实际使用后发现,JEST 对比 Mocha 来说,虽然运行速度上感觉比 Mocha 稍慢,但是因为如下几个优点最后胜出:
JEST 和 Enzyme 官方提供了详细的安装指导,实际安装完成后发现还是有坑。这里把安装过程重新梳理下。
首先是 JEST,
$ npm install --save-dev jest
如果需要在测试项目中使用 babel,还需要额外安装 babel-jest,
$ npm install --save-dev babel-jest
然后是 Enzyme,
$ npm install enzyme --save-dev
如果使用的是 react13 以上的版本,则需要额外安装 react-addons-test-utils
$ npm i --save-dev react-addons-test-utils
安装完成后,就可以开始写测试啦~
不要方!JEST 运行基础功能虽然无需配置,但是官方依然提供了配置选项来实现个性化需求。
例如,在单元测试覆盖率检查的时候,默认只检查被测试文件所使用到的源文件的覆盖率。然而,我们可以通过在 package.json 文件中配置 jest 的 collectCoverageFrom 参数,来指定检查所有需要测试的文件 (无论源文件有没有被测试文件使用到)
以上面提到的 demo 为例。我们需要确定单元测试的范围 -- 目标测试的文件是src
文件夹下面的.jsx
或者js
文件,同时需要忽略其中的一些配置性质的jsx
/js
,比如store.js
、provider.jsx
和用于合并 reducer 的index.js
。另外,还有覆盖率检查的时候生成 coverage 文件夹下面的 js,编译后在 dist 文件夹下面生成的 js 文件,以及 webpack 的 config 文件都不需要测试。那么我们就在 package.json 里面加上这样一段内容
"jest": {
"collectCoverageFrom" : [
"**/*.{js,jsx}",
"!**/coverage/**",
"!**/dist/**",
"!**/store.js",
"!**/provider.jsx",
"!**/index.js",
"!**/webpack.config.js"
]
}
然后给我们单元测试的覆盖率定个小目标,95% 吧。只有当测试覆盖率大于等于这个比例的时候测试才会通过。
"jest": {
"collectCoverageFrom" : [
"**/*.{js,jsx}",
"!**/coverage/**",
"!**/store.js",
"!**/provider.jsx",
"!**/index.js",
"!**/webpack.config.js"
],
"coverageThreshold": {
"global": {
"branches": 95,
"functions": 95,
"lines": 95,
"statements": 95
}
}
}
JEST 配置选项有很多有用的功能,例如指定加载启动文件、指定 moduleNameMapper、指定别名等。详见这里。
最后,我们还要给执行 JEST 加上一个命令:在 package.json 文件的 scripts 区域中增加一句
"scripts": {
...
"test": "jest"
}
这样,我们就可以通过 npm test
命令来跑测试了。
工具准备完成,就可以开始写测试啦~
不要方!我们知道 Redux 基本概念中的 store 等组件的功能和目的各不相同,那么针对各种组件的特性,我们分别应该如何测试呢?翻看 Redux 官网,发现这里有详细的例子和介绍,附上官网传送门。
需要注意的是,UI Component 由于无状态化,和只负责显示 DOM 的作用,所以针对它们的单元测试只需要验证是否按预期显示了 DOM 就行了。组件中的 props、方法等则无需测试。
还是以 demo 中的代码为例子。我们 footer.jsx 组件的代码是酱紫的:
import React from 'react'
//这里是太长不想贴上来的css代码..
export default class Footer extends React.Component {
constructor() {
super()
this.handleClick = this.handleClick.bind(this)
}
handleClick(){
this.props.onClick()
}
render(){
return(
<div style={styles.base}>
<footer>
<a onClick={this.handleClick}>click footer to back</a>
</footer>
</div>
)
}
}
Footer.propTypes = {
onClick: React.PropTypes.func.isRequired
}
其中render()
是 React 中负责显示 DOM 的代码, handleClick()
是一个自定义方法,onClick
则是一个 props。再来看看测试代码 footer.test.js
import React from 'react'
import { shallow } from 'enzyme'
import Footer from '../../src/components/UIs/footer'
const props = {
onClick: jest.fn()
}
describe('Footer component', () => {
it('should render dom', () => {
const wrapper = shallow(<Footer {...props}/>)
expect(wrapper.find('a').text()).toContain('click footer')
})
})
测试代码中使用了 enzyme 库中的 shallow 功能。shallow 是官方测试工具库react-addons-test-utils
中 shallow rendering 的封装。是将一个组件渲染成虚拟 DOM 对象的 “浅渲染”。这种渲染不会涉及子组件,不需要 DOM,速度非常快。
源代码中onClick
后调用的方法,在这里被 JEST 自带的 mocked 方法jest.fn()
代替掉了,我们这个测试只测试了组件是否被正常显示出来了。expect
部分是断言,实现内容是在被渲染出的 footer 组件中找到 a 标签,然后断言它的text()
中有没有包含期望的文字。通过这种方式我们可以得知组件是否有被显示出来。
除了text()
属性以外,还可非常灵活的通过其他方式来得知组件是否被正常显示。例如:
expect(wrapper.find('button').exists()).toBeTruthy()
expect(wrapper.find('input').props().type).toBe('text')
前者是断言被渲染出的组件中是否有button
标签的存在;后者是断言组件中的input
标签是否有type="text"
这个属性。
针对各个 action、reducer 和 UI Component 的测试写完成后,我们来运行下测试,查看覆盖率。
Tips: 可以通过npm test <测试文件名>
运行单个测试
这个时候我们就看到了之前配置的测试覆盖率检查范围的作用了:报告明确告诉了我们,app.jsx
没有被测试到。另外,在footer.jsx
中还有第 19 行以及userName.jsx
第 34 行也没有被测试到,覆盖率一片红..
检查了下未被覆盖的 footer 19 行和 userName 34 行,发现正是之前特意忽略掉的 UI Component 内的方法。app.jsx 是一个 Container 组件,还没有写任何测试。
发现问题了,那就赶紧补测试吧~
不要方!我们先来仔细分析下。
那么换句话说,我只需要按照功能测试的方法,以 user journey 的角度来测试这个组件,就可以覆盖到所有东西咯?再拿 demo 来练练手。
先确定 demo 的功能和 user journey 是:
那么我们测试的内容就应该是:
import React from 'react'
import { createStore } from 'redux'
import { mount } from 'enzyme'
import { Provider } from 'react-redux'
import ReactDom from 'react-dom'
import App from '../../src/components/app'
import Reducer from '../../src/reducers'
let store
let wrapper
const fillin = (byCssSelector, text) => {
wrapper.find(byCssSelector).simulate('change', {target: {value:text}})
}
beforeEach( () => {
store = createStore(Reducer)
wrapper = mount (
<Provider store={store}>
<App />
</Provider>
)
})
describe('User journey', ()=> {
describe('user input a string in the field', ()=> {
it('should display inputed name', ()=> {
fillin('#userName', 'Han mei 妹@')
expect(wrapper.find('#userInput').text()).toContain('Han mei 妹@')
})
})
describe('click submit button', ()=> {
it('should show welcome page, and original view disappear', ()=> {
fillin('#userName', '李磊@_@')
wrapper.find('form').simulate('submit')
expect(wrapper.find('#welcome').text()).toContain('李磊@_@')
expect(wrapper.find('#userInput').exists()).toBeFalsy()
})
})
describe('click footer to back', ()=> {
it('should show welcome page, and original view disappear', ()=> {
fillin('#userName', 'linTao123')
wrapper.find('form').simulate('submit')
wrapper.find('a').simulate('click')
expect(wrapper.find('#userInput').exists()).toBeTruthy()
})
})
})
simulate
方法是 enzyme 封装好的模拟页面元素事件的方法,用来模拟"click","submit","change","doubeClick"等事件。
测试代码中的fillin
方法实现的是在渲染后的 DOM 中找到一个 web element,然后用simulate
方法模拟绑定在该元素上面的onChange()
事件。
beforeEach
方法是一个JEST 的 Hook,在每一个 it/test 开头的测试之前都会执行里面的内容。在 demo 的案例中,我把store.js
和provider.js
里面的内容照搬了过来,每个测试执行前都会新生成一个 store,实现重置 store 的功能(重置测试环境)。
功能测试看上去也没问题了,所有的用户场景貌似都覆盖完了。这个时候我们再来检查下覆盖率
$ npm test -- --coverage
100% 覆盖率了有木有!完美啊有木有!
为何加上功能测试以后,之前 UI 组件里面没有测试到的方法也被覆盖到了呢?分析下产品代码,原来是在页面元素上执行操作的时候,就会调用到 UI 组件上的这些方法,而这些操作后来被功能测试覆盖到了。
这么一来,又避免了重复的测试代码 :)
那么对测试群众来说,从质量保证的角度出发,单元测试覆盖率 100% 是否就足够了呢?
肯定不够啊!
结合实际的项目经验来看,JEST 的测试还可以根据产品的实际需求,做一些诸如:
这些测试原本在 UI 自动化功能测试中也比较常见,这里我们都可以把它们挪到低层中去。所以具体的测试用例,在单元测试覆盖率超级高的前提下,我们测试的群众还可以跟研发结对完成。或者指导研发完成,要不干脆自己加上去算了。
另外,产品的功能性测试完成的情况下,我们还需要考虑下非功能性的问题,例如兼容性、性能、安全性等。再加上测试金字塔的顶端之上,其实还有探索性测试的位置。产品的基本功能由单元测试保障了,剩下的时间,我们可以做更多的探索性测试了不是吗~
总之,干掉 UI 自动化功能测试只是一个加速测试反馈周期、减少投入成本的尝试。软件的质量不仅仅是测试攻城狮的事情,而是整个团队的责任。坚持一些重要的编码实践,比如 state less 的组件、build security in 等,也是提高质量的重要手段。