理解前端自动化测试TDD + BDD

前言

在平常的开发中,成天赶需求的咱们好像没有时间顾及自动化测试,尤为是在敏捷开发的时候。但其实自动化测试能够帮助咱们提升代码和功能的健壮程度,大幅减小可能出现的bug。javascript

尤为是在复杂系统中,自动化测试的做用不容忽视。本篇文章是我本身的学习记录,使用测试框架jest和前端框架React来简单梳理的自动化测试。css

平常开发一般涉及到业务代码的开发以及函数、组件库的开发。针对这两方面的自动化测试,在模式和流程上也有各自的要求与侧重。这就衍生出了单元测试和集成测试两种测试方法,以及TDD与BDD的测试开发流程。前端

单元测试

单元测试,见名知意,能够理解为对系统的某个单元进行测试,而这个单元,能够是某个函数,某个组件,对于这种测试形式来讲,咱们只关注这个独立的单元的功能是否正常。测试用例以当前单元内的功能做为对象。java

集成测试

将多个单元集成到一块儿,进行测试,重点关注各个单元串联起来以后的系统总体功能是否正常。此时的测试用例以多个单元组成的某个独立的系统为对象。node

以上是两种测试方法,但有时测试的细化程度与系统复杂的操做流程难以平衡,这就须要作出取舍,针对不一样的开发主体以及业务场景采用不一样的测试+开发的流程。react

TDD: 测试驱动开发(Test-Driven Development)

这种模式中,先编写测试用例,在测试用例的指导下去完善功能,当测试用例编写完而且都经过测试以后,相应的功能也就作完了。TDD的模式适合于对系统代码质量和测试覆盖率有要求的开发主体,好比函数和组件库。但一般在代码发生变化的时候,测试用例也要进行相应的调整。npm

BDD: 行为驱动开发(Behavior Driven Development)

测试用例模拟用户的操做行为,一般在完成业务代码开发以后,以用户的操做为指导编写测试代码。当测试用例跑通以后,就能够认为系统的总体流程已经流畅。BDD的模式适用于平时的业务代码开发,由于业务的需求有可能变动频繁,但操做流程有可能不会变化,当业务代码发生变化的时候,可使用原来的测试用例继续跑代码,节省了开发时间。json

我认为在平时的项目中,一般使用TDD和BDD相结合来进行测试,TDD负责方法类、独立组件的测试。BDD则负责总体业务模块的测试。前端工程化

从Demo入手来理解自动化测试

让咱们用一个demo来理解一下前端自动化测试,先从搭建环境开始,认识一下和jest以及React有关的配套工具和配置项。api

搭建测试环境

若是是用create-react-app 建立的项目,内部会集成好一个jest的测试环境。npm run eject将配置项暴露出来后,在package.json的jest字段内能够看到jest的配置项,也能够将这些配置项复制出来,粘贴到新建的jest.config.js中。

create-react-app生成的jest配置项内容

* 是匹配任意文件夹,是匹配任意文件名

module.exports = {
    // 测试哪些目录下的文件
    "roots": [
      "<rootDir>/src"
    ],

    // 生成测试覆盖率报告的时候,统计哪些目录下以哪些后缀为结尾的文件,前边加!是不参与统计的意思,.d.ts是ts中的类型声明文件,因此不用参与统计
    "collectCoverageFrom": [
      "src/**/*.{js,jsx,ts,tsx}",
      "!src/**/*.d.ts"
    ],

    // 使用react-app-polyfill/jsdom 解决js兼容性的一些问题
    "setupFiles": [
      "react-app-polyfill/jsdom"
    ],

    // 测试环境创建好之后,会执行里面的文件,在当前这个场景下,setupTests.js里作的事情就是引入了一些jsdom扩展的matchers。
    "setupFilesAfterEnv": [
      "<rootDir>/src/setupTests.js"
    ],

    // 当测试运行时,要执行一些测试文件,这个配置项内就是用正则匹配要被执行的文件。
    "testMatch": [
      "<rootDir>/src/**/__tests__/**/*.{js,jsx,ts,tsx}",
      "<rootDir>/src/**/*.{spec,test}.{js,jsx,ts,tsx}"
    ],

    // 由于测试环境是在node中执行的,没有dom或者window的api,因此这个配置项的值会模拟window或者dom的一些api
    "testEnvironment": "jest-environment-jsdom-fourteen",
    
    // 当引入的文件符合transform这个配置项的key的正则的时候,用value去解析转换该文件
    "transform": {
      "^.+\\.(js|jsx|ts|tsx)$": "<rootDir>/node_modules/babel-jest",
      "^.+\\.css$": "<rootDir>/config/jest/cssTransform.js",
      "^(?!.*\\.(js|jsx|ts|tsx|css|json)$)": "<rootDir>/config/jest/fileTransform.js"
    },

    // 与上边的transform是对应的,当引入的文件符合这个配置项的key的正则的时候,就忽略不作处理
    "transformIgnorePatterns": [
      "[/\\\\]node_modules[/\\\\].+\\.(js|jsx|ts|tsx)$",
      "^.+\\.module\\.(css|sass|scss)$"
    ],

    // 当引入一个模块在node_modules内找不到时,须要在自定义的路径下去找,能够将路径写在这里
    "modulePaths": [],

    // 针对css-module,使用identity-obj-proxy将样式从 .selector: { width: 20px }转换为 { .selector: '.selector' } 这样的形式,
    // 目的是在测试中,忽略样式,因此简化处理
    "moduleNameMapper": {
      "^.+\\.module\\.(css|sass|scss)$": "identity-obj-proxy"
    },

    // 在测试文件中引入文件的时候,若是引入的文件名没有写后缀,会依据下边的后缀去找这个文件
    "moduleFileExtensions": [
      "js",
      "ts",
      "tsx",
      "json",
      "jsx",
      "node"
    ],

    // npm run test命令的时候,进入jest会进入监听文件变更的模式。这些是监听的插件,也能够直接使用jest自带的监听模式
    "watchPlugins": [
      "jest-watch-typeahead/filename",
      "jest-watch-typeahead/testname"
    ]
  }

若是是彻底本身配置的项目,能够在项目内安装jest,而后npx jest --init,初始化一个jest.config.js文件来配置测试环境,固然彻底能够参考create-react-app生成的jest配置项。

以上是配置了一个基本的jest测试环境,对于React项目的测试仍是彻底不够的。

使用Enzyme测试React组件

React中组件是一个重要的概念,因此,方便灵活地对组件进行测试也很是重要。

测试组件,涉及到组件的props,state,内部方法。针对这种场景,可使用enzyme来对组件进行测试。

enzyme是Airbnb公司推出的一款针对React测试的工具,组件能够经过enzyme提供的方法在测试环境中被渲染出来,再经过其他的API能够获取或者验证组件的状态、行为。

以一个简单的组件为例:

import React from 'react';

function App() {
  return (
    <div className="App" data-test='container'>
      hello world
    </div>
  );
}

export default App;

若是对这个组件进行测试,须要首先安装enzyme。安装enzyme的同时,也须要安装enzyme针对react的一个适配器enzyme-adapter-react-16
适配器最后的数字须要与你当前项目中的react版本一致。

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

安装好以后,在测试用例的文件中引入并配置enzyme。

import React from 'react';
import App from './App';
import Enzyme, { shallow } from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';

Enzyme.configure({ adapter: new Adapter() });

test('验证App组件是否被正确挂载', () => {
  const wrapper = shallow(<App />)
  expect(wrapper.find('[data-test="container"]').length).toBe(1)
});

这段测试代码验证data-test="container"这个容器是否存在。为了和业务代码解耦,测试用例的选择器(find)不该该使用与业务相关的标记,这里在须要测试的容器上加上了一个属性: data-test='container'。

测试用例的意思是用shallow将组件渲染出来,被渲染以后的组件就能够调用一些enzyme提供的方法,这里的find就是找到data-test="container"的集合,集合的长度若是为1,那就说明该容器存在,测试经过。

固然,不可能写一个测试文件就引入一次enzyme。能够将enzyme的引入和配置工做放到测试环境准备好的时候,也就是jest.config.js中setupFilesAfterEnv配置项配置的文件中,在该文件中引入。

import Enzyme from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';

Enzyme.configure({ adapter: new Adapter() });

shallow 和 mount

在测试用例中,咱们也是须要将组件渲染出来的,只不过是这样写的:

const wrapper = shallow(<App />)

这里的shallow,是enzyme提供的方法,能够理解为浅渲染,也就是若是被shallow包裹的组件有嵌套其余组件的话,嵌套的组件会用一个标记来替代。因此只会渲染出组件的第一层,这样作的目的是为了在对组件作单元测试的时候,只关注当前组件,同时能够大幅度提高性能。

与之对应的还有一个mount方法,这个方法会将全部嵌套的组件都渲染出来,再也不对组件进行浅渲染,至关于关注多个组件结合在一块儿的运行状况。

扩展matchers

在上面的测试用例中,调用的是jest提供的原生的matcher,其实可使用jest-enzyme提供的一些针对React组件的matchers,更方便地进行测试。

首先,安装jest-enzyme:

npm install jest-enzyme --save-dev

而后,须要在jest.config.js中,setupFilesAfterEnv中加上jest-enzyme主体文件的路径,目的是在测试环境准备好以后,初始化jest-enzyme。

"setupFilesAfterEnv": ["./node_modules/jest-enzyme/lib/index.js"'"]

使用了jest-enzyme以后,咱们的测试用例的代码能够改为

test('验证App组件是否被正确挂载', () => {
  const wrapper = shallow(<App />)
  expect(wrapper.find('[data-test="container"]')).toExist()
});

toExist方法就是jest-enzyme提供的matcher,完成的matchers列表在这里,随查随用。

Demo实战

环境准备好以后,分别使用TDD与BDD结合单元测试与集成测试开发一个简单的demo来理解这两种流程下的自动化测试。

功能点有三个

  • 输入文字,回车,列表添加一条记录
  • 回车的同时输入框内容清空
  • 点击删除会删除该条记录

代码结构:Input组件负责输入内容,List组件负责展现数据并提供删除的功能。两个组件嵌套在一个父组件(App)以内。

<div className="App">
      <Input
        onAddData={onAddData}
      />
      <List
        list={list}
        onDelete={onDelete}
      />
    </div>

TDD + 单元测试

TDD须要在测试的指导下写代码,关注点稍微偏重于测试。使用单元测试结合测试驱动开发的流程,应该逐一梳理功能,编写的测试用例应聚焦在某个单元上。

回到demo上,针对上述的三个功能点和组件各自的职责,先写测试代码,而后写业务代码,让业务最后经过测试,完成开发。同时采用单元测试的方式,要保证所编写的测试用例,只针对组件自己的功能。

先从Input组件入手,梳理组件的功能。

  • 输入内容后回车,传入的onAddData方法应该被调用,而且接收到的参数就是最终输入的内容
  • 输入内容后回车,输入框的内容应清空

从第一条开始,编写测试代码:

test('输入内容,点击回车,Input组件的onAddData应该被调用而且接收到正确的参数', () => {
  const fn = jest.fn()
  const wrapper = shallow(<Input
    onAddData={fn}
  />)
  const input = wrapper.find('[data-test="input"]')
  input.simulate('keyup', {
    keyCode: 13,
    target: { value: 'hello' }
  })
  expect(fn).toHaveBeenCalledWith('hello')
})

测试代码验证输入内容回车后,传入Input组件的函数会不会被调用,而且验证是否能够接收到正确的值。

这里用到了jest的Mock Functions功能。使用enzyme提供的shallow将组件渲染出来后,找到input并模拟keyup事件,在接下来的流程中验证fn是否被调用并接收到了正确的值。

如今由于尚未写业务代码,测试是不会经过的。接下来看一下Input组件此时的实现:

const Input = (props) => {
  const onChange = e => {
    setValue(e.target.value)
  }
  return <input
    type="text"
    data-test="input"
    onKeyUp={(e) => {
      if (e.keyCode === 13) {
        props.onAddData(e.target.value)
        setValue('')
      }
    }}
  />
}

App.js补充onAddData的函数

function App() {
  const [ list, setList ] = useState([])
  const onAddData = value => {
    setList([ ...list, value ])
  }
  return (
    <div className="App">
      <Input onAddData={onAddData}/>
    </div>
  );
}

再继续,当回车后,输入框的内容应该被清空,针对这个点编写测试代码

test('点击回车,Input组件的输入框内容应该清空', () => {
  const wrapper = shallow(<Input onAddData={() => {}} />)
  const input = wrapper.find('[data-test="input"]')
  input.simulate('keyup', {
    keyCode: 13,
    target: { value: 'hello' }
  })
  expect(input.text()).toBe('')
})

而后,在Input组件中将这个逻辑补上

const Input = (props) => {
  const [ value, setValue ] = useState('') // 针对测试用例新加的代码
  const onChange = e => {
    setValue(e.target.value)
  }
  return <input
    type="text"
    value={value}
    onChange={onChange}
    data-test="input"
    onKeyUp={(e) => {
      if (e.keyCode === 13) {
        props.onAddData(e.target.value)
        // 针对测试用例新加的代码
        setValue('')
      }
    }}
  />
}

跑一下测试,两个测试用例都经过了,就说明Input组件已经基本开发完了,下面分析一下List组件:

  • 接收到列表数据,能够正确的渲染出来
  • 点击删除按钮,onDelete应该被调用,而且接收到当前列表项的索引

从第一条开始编写测试用例

import React from 'react'
import { shallow } from 'enzyme'
import List from './List'
test('列表组件接收到列表数据,应该渲染出对应数量的列表项', () => {
  const list = ['hello', 'world']
  const wrapper = shallow(<List
    list={list}
  />)
  const items = wrapper.find('[data-test="list-item"]')
  expect(items.length).toBe(2)
  expect(items.at(0).text()).toBe('hello')
  expect(items.at(1).text()).toBe('world')
})

向List组件传入了一个数组,以后找到应该渲染出来的元素,判断其长度和各自的内容。接下来实现它

const List = (props) => {
  return <div className="list">
    {
      props.list.map((item, index) => {
        return <p key={item}>
          <span data-test="list-item">{item}</span>
          <button>删除</button>
        </p>
      })
    }
  </div>

}

App.js中将list数据传入List组件

function App() {
  const [ list, setList ] = useState([])
  const onAddData = value => {
    setList([ ...list, value ])
  }
  return (
    <div className="App">
      <Input onAddData={onAddData}/>
      <List list={list}/>
    </div>
  );
}

再看第二条:点击删除按钮,onDelete应该被调用,而且接收到当前列表项的索引。测试代码与Input组件的第一个测试用例大同小异:

test('点击删除按钮,List组件的onDelete方法应该被调用,而且接收到正确的参数', () => {
  const list = ['hello', 'world']
  const fn = jest.fn()
  const wrapper = shallow(<List
    list={list}
    onDelete={fn}
  />)
  const deleteBtn = wrapper.find('[data-test="delete-btn"]')
  deleteBtn.at(1).simulate('click')
  expect(fn).toHaveBeenCalledWith(1)
})

而后补齐这个功能的代码

const List = (props) => {
  const onDelete = index => {
    props.onDelete(index)
  }
  return <div className="list">
    {
      props.list.map((item, index) => {
        return <p key={item} >
          <span data-test="list-item">{item}</span>
          <button
            onClick={() => onDelete(index)}
            data-test='delete-btn'
          >删除</button>
        </p>
      })
    }
  </div>
}

App.js中添加删除的逻辑

function App() {
  const [ list, setList ] = useState([])
  const onAddData = value => {
    setList([ ...list, value ])
  }
  const onDelete = index => {
    const listData = [ ...list ]
    listData.splice(index, 1)
    setList(listData)
  }
  return (
    <div className="App">
      <Input onAddData={onAddData} />
      <List list={list} onDelete={onDelete} />
    </div>
  );
}

到此,这个demo就使用TDD+单元测试的模式开发完毕了。TDD因为是先写测试用例再进行开发,因此会保证每一个功能的代码都是通过测试的,bug天然就少了不少。同时在编写测试代码的时候,很天然地要去思考这个功能的代码如何组织,也在必定程度上提升了代码的可维护性。

单元测试会保证测试覆盖率很是高,但在业务开发的场景下,带来了几个问题:

  • 代码量增多,demo中为了测试功能编写了不少的测试用例,有时单元测试代码甚至会比业务代码多。
  • 业务耦合度高,测试用例中使用了业务中一些模拟的数据,当业务代码变动的时候,要去从新组织测试用例。
  • 关注点过于独立,因为单元测试只关注这一个单元的健康情况,没法保证多个单元组成的总体是否正常。

这几个问题说明用单元测试来进行业务测试或许不是一个明智的作法,下面就介绍一种适合业务场景的测试方法。

BDD + 集成测试

BDD其实是模拟用户的行为,在业务代码完成后,用测试用例模拟用户的操做行为,因为关注点上升到了整个系统的层面,因此使用集成测试,应该忽略组件个体的行为,保证系统行为的流畅。

因为是先完成业务代码,再作测试,因此看一下最终的代码:

App组件

function App() {
  const [ list, setList ] = useState([])
  const onAddData = value => {
    setList([ ...list, value ])
  }
  const onDelete = index => {
    const listData = [ ...list ]
    listData.splice(index, 1)
    setList(listData)
  }
  return (
    <div className="App">
      <Input onAddData={onAddData} />
      <List list={list} onDelete={onDelete} />
    </div>
  );
}

Input组件

const Input = (props) => {
  const [ value, setValue ] = useState('')
  const onChange = e => {
    setValue(e.target.value)
  }
  return <input
    type="text"
    value={value}
    onChange={onChange}
    data-test="input"
    onKeyUp={(e) => {
      if (e.keyCode === 13) {
        props.onAddData(e.target.value)
        setValue('')
      }
    }}
  />
}

List组件

const List = (props) => {
  const onDelete = index => {
    props.onDelete(index)
  }
  return <div className="list">
    {
      props.list.map((item, index) => {
        return <p key={item} >
          <span data-test="list-item">{item}</span>
          <button
             onClick={() => onDelete(index)}
             data-test='delete-btn' 
          >删除</button>
        </p>
      })
    }
  </div>
}

如今梳理demo的功能,有两点:

  • 输入内容回车以后,列表应该展现输入的内容
  • 点击列表项的删除按钮,应该把这一项删除

针对两个功能来编写各自的测试用例。与单元测试不一样的是,咱们的测试对象是Input、List、App这三个组件组成的系统,App组件内包含了全部逻辑,要在在测试用例中将App组件以及内部的嵌套组件都渲染出来,因此再也不使用enzyme的shallow方法,转而使用mount方法作深度渲染。

下面写出这两个功能的测试代码:

import React from 'react'
import App from './App'
import { mount } from 'enzyme'

test('Input组件输入内容后回车,List组件应该将内容展现出来', () => {
  const appWrapper = mount(<App />)
  const input = appWrapper.find('[data-test="input"]')
  input.simulate('keyup', {
    keyCode: 13,
    target: { value: 'hello' }
  })
  const items = appWrapper.find('[data-test="list-item"]')
  expect(items.length).toBe(1)
  expect(items.at(0).text()).toBe('hello')
})

test('点击列表项的删除按钮,List组件内相应的记录应被删除', () => {
  const appWrapper = mount(<App />)
  // 先添加一条数据,便于删除
  const input = appWrapper.find('[data-test="input"]')
  input.simulate('keyup', {
    keyCode: 13,
    target: { value: 'hello' }
  })
  const deleteBtn = appWrapper.find('[data-test="delete-btn"]')
  deleteBtn.at(0).simulate('click')
  const items = appWrapper.find('[data-test="list-item"]')
  expect(items.length).toBe(0)
})

第一个测试用例将App渲染出来后,找到输入框,模拟回车事件,传入相应的内容。以后找到列表项,若是列表的长度为1而且内容是hello,则测试经过。

第二个测试用例要先加1条数据,再找到删除按钮,模拟点击事件,若是此时列表项长度为0,则测试经过。

经过上面这个demo能够明白集成测试相对于单元测试,更多侧重多组件的协同,假如一个组件自己没有问题,但与其余组件配合的时候出问题了,那整个流程是不会经过测试的。再结合BDD,使开发时更加关注业务代码,没必要先写繁琐的测试用例。并且只要操做流程不会变,那测试用例也基本不用动,更加适合平时业务的开发。

总结

自动化测试确实会在必定程度上增长开发的工做量,但通过测试的系统,稳定性的提高会让咱们更有信心。文中介绍的两种开发+自动化测试的组合模式能够应对不一样的开发场景,但愿你们能够针对本身的场景,选择合适的方式来引入自动化测试,不管是对提高系统健壮程度仍是深化前端工程化,都很是有帮助。

相关文章
相关标签/搜索