前端测试 如何在 React+Redux 的项目中更优雅的实现前端自动化测试

大大灰灰狼 · 2017年03月18日 · 最后由 qilei 回复于 2017年04月11日 · 474 次阅读
本帖已被设为精华帖!

乘着改革开放的浪潮,这段时间我们终于接触到非常火热的前端项目构架 React+Redux

这个构架下的前端项目,最大的优点就是 Redux 鼓励各个组件无状态化 (no state),利用 store 统一管理 state,从而使各个组件之间相对更加独立和易于维护,使得前端的构架更加简单化。下图中左边是经典 React 中各组件的层级关系,右边是引入 Redux 之后的层级。在左图中,当修改父节点/组件时,子组件也可能会被破坏掉;而右图中能够影响到各个组件的因素只有 state。

经典react架构和加入redux架构后的层级

在传统 JS Web 项目中的自动化测试,通常会有这些比较突出的问题

  • UI 自动化功能测试受制于环境 (运行 os,浏览器等) 维护困难,运行缓慢,而且非常容易因为前端变化而被破坏。
  • 单元测试覆盖点有限,无法覆盖所有的测试点。

那么作为新技术的 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 大致的工作流程:

  1. 用户操作 view 触发 action
  2. store 被 action 通知 state 要变化了,调用 reducer
  3. reducer 计算新的 state 应该是啥样,返回新的 state 给 store
  4. store 通过 react 组件把新的 state 对应的 view 显示给客户

我刚好写了个简单的demo,能大概看懂这个 demo 中,各个组件是做什么的,如何工作的,对后面的内容有极大帮助。demo 使用了 webpack 作为打包和本地运行工具

这么看起来,redux 通过 state-view 一一对应的架构保证了只要 view 变,state 一定变,反之亦然。这种一一对应的关系减少了组件之间发生关联 (变化) 的可能性,从而减轻了测试复杂度。最后再总结一下,发现针对该架构的自动化测试其实只需要保证下面几点就够了:

  • 各个单独组件能够正常显示 DOM 元素
  • 如果 state 改变了, 那么我只需要确保相应的 view 发生了变化
  • 如果 view 发生了改变, 那么我只需要确保相应的 state 存进了 store

验证分析结果

从上面儿的分析结果来看,只用低层测试来保证质量的想法,好像有点儿靠谱的样子。接下来就是做一些小的 demo 来验证下真实项目中是否行得通。

好的单元测试应该有哪些特点呢?

  1. 简单易懂。最好是 BDD 风格的,一眼就可以看出你在测试什么,减少维护成本
  2. 高覆盖率,研发重构的时候会更有信心
  3. 跑的快,不要有额外的工作 (例如维护复杂的环境依赖等)
  4. 从客户价值 (business value) 角度出发,确保软件的可交付性。

那么首先,我们应该是尽量选择一款满足上面需求的测试工具。

测试工具选择

满足上面条件的 JS 前端单元测试工具/框架很多,比较流行的是 mocha+chai、JEST 等。这里我们使用JEST测试框架和Enzyme测试工具库。

经过实际使用后发现,JEST 对比 Mocha 来说,虽然运行速度上感觉比 Mocha 稍慢,但是因为如下几个优点最后胜出:

  • 和 React 师出同门,FB 官方支持
  • 已经集成了测试覆盖率检查、mock 等功能,不需要安装额外的库
  • 文档完备,官方提供了和 babel、webpack 集成情况下以及异步调用的测试解决方案
  • 官方提供 snapshot testing 解决方案
安装

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.jsprovider.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 组件,还没有写任何测试。

实现功能测试

发现问题了,那就赶紧补测试吧~
不要方!我们先来仔细分析下。

  • 容器 (container) 组件的主要作用是链接 UI 组件,里面可能也包含了一些业务逻辑
  • UI Component 中的方法,最终会通过容器组件对组件的调用而被调用到。

那么换句话说,我只需要按照功能测试的方法,以 user journey 的角度来测试这个组件,就可以覆盖到所有东西咯?再拿 demo 来练练手。

先确定 demo 的功能和 user journey 是:

  • 用户输入任意字符,输入的同时,会在下方显示出输入的值
  • 点击 Submit 按钮后提交 form,更新界面显示
  • 点击 footer 后可以回到首页

首页同步显示输入的内容

提交表单后显示新的视图

那么我们测试的内容就应该是:

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.jsprovider.js里面的内容照搬了过来,每个测试执行前都会新生成一个 store,实现重置 store 的功能(重置测试环境)。

功能测试看上去也没问题了,所有的用户场景貌似都覆盖完了。这个时候我们再来检查下覆盖率

$ npm test -- --coverage

功能测试完成后的覆盖率

100% 覆盖率了有木有!完美啊有木有!
为何加上功能测试以后,之前 UI 组件里面没有测试到的方法也被覆盖到了呢?分析下产品代码,原来是在页面元素上执行操作的时候,就会调用到 UI 组件上的这些方法,而这些操作后来被功能测试覆盖到了。

这么一来,又避免了重复的测试代码 :)

收尾

那么对测试群众来说,从质量保证的角度出发,单元测试覆盖率 100% 是否就足够了呢?

肯定不够啊!

结合实际的项目经验来看,JEST 的测试还可以根据产品的实际需求,做一些诸如:

  • 点击某个页面元素后,需要在页面上显示新的区块,并且要加载指定的的 css 的测试。
  • 点击某个 link,需要跳转到指定的网站的测试
  • 等等

这些测试原本在 UI 自动化功能测试中也比较常见,这里我们都可以把它们挪到低层中去。所以具体的测试用例,在单元测试覆盖率超级高的前提下,我们测试的群众还可以跟研发结对完成。或者指导研发完成,要不干脆自己加上去算了。

另外,产品的功能性测试完成的情况下,我们还需要考虑下非功能性的问题,例如兼容性、性能、安全性等。再加上测试金字塔的顶端之上,其实还有探索性测试的位置。产品的基本功能由单元测试保障了,剩下的时间,我们可以做更多的探索性测试了不是吗~

总之,干掉 UI 自动化功能测试只是一个加速测试反馈周期、减少投入成本的尝试。软件的质量不仅仅是测试攻城狮的事情,而是整个团队的责任。坚持一些重要的编码实践,比如 state less 的组件、build security in 等,也是提高质量的重要手段。

如果觉得我的文章对您有用,请随意打赏。您的支持将鼓励我继续创作!
共收到 2 条回复 时间 点赞
思寒_seveniruby 将本帖设为了精华贴 03月18日 21:43

鼓掌,说的太好了.

这有些像单元级别的 ui 测试。ui 测试主要还是做回归用的。不过确实将 ui 尽量下放到单元级别 也是趋势。

需要 登录 后方可回复, 如果你还没有账号请点击这里 注册