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

nero

发布于 2020-04-30 09:04:70

前言

在日常的开发中,整天赶需求的我们好像没有时间顾及自动化测试,尤其是在敏捷开发的时候。但其实自动化测试可以帮助我们提高代码和功能的健壮程度,大幅减少可能出现的bug。

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

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

单元测试

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

集成测试

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

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

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

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

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

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

我认为在平时的项目中,通常使用TDD和BDD相结合来进行测试,TDD负责方法类、独立组件的测试。BDD则负责整体业务模块的测试。

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

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

搭建测试环境

如果是用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,使开发时更加关注业务代码,不必先写繁琐的测试用例。而且只要操作流程不会变,那测试用例也基本不用动,更加适合平时业务的开发。

总结

自动化测试确实会在一定程度上增加开发的工作量,但经过测试的系统,稳定性的提升会让我们更有信心。文中介绍的两种开发+自动化测试的组合模式可以应对不同的开发场景,希望大家可以针对自己的场景,选择合适的方式来引入自动化测试,无论是对提升系统健壮程度还是深化前端工程化,都非常有帮助。