今天看啥  ›  专栏  ›  zenglinan

前端测试之 Jest 单元测试

zenglinan  · 掘金  ·  · 2020-02-04 07:17
阅读 14

前端测试之 Jest 单元测试

一、Jest 简介

  1. 优势: 速度快、API简单、配置简单
  2. 前置: Jest 不支持 ES Module 语法,需要安装 babel
npm install -D @babel/core @babel/preset-env
复制代码

.babelrc

{
  "presets": [
    [
      "@babel/preset-env", {
        "targets": {
          "node": "current"
        }
      }
    ]
  ]
}
复制代码

jest 在运行前会检查是否安装 babel,如果安装了会去取 .babelrc 文件,结合 babel 将代码进行转化,运行转化后的代码。 3. jest 默认配置

npx jest --init
复制代码
  1. jest 模式
  • jest --watchAll:当发现测试文件变动,将所有测试文件重新跑一遍
  • jest --watch:需要和 git 结合使用,会比较现有文件和 commit 的文件的差异,只测试差异文件

二、Jest 匹配器

常见匹配器

  • toBe
  • toEqual:判断对象内容是否相等
  • toMatchObject:expect(obj).toMatchObject(o),期望 o 中包含 obj
  • toBeNull
  • toBeUndefined
  • toBeDefinded
  • toBeTruthy
  • toBeFalsy
  • not:用于否定,比如 .not.toBeTruthy()

Number 相关

  • toBeGreaterThan(大于) / toBeGreaterThanOrEqual(大于等于)
  • toBeCloseTo:用于比较浮点数,近似相等时断言成立
  • toBeLessThan / toBeLessThanOrEqual

String 相关

  • toMatch:参数可以传字符串或正则

Array Set 相关

  • toContain

异常匹配器

  • toThrow:
const throwError = () => {
  throw new Error('error')
}

it('can throw error', () => {
  expect(throwError).toThrow('error') // 判断throw函数可以抛出异常,异常信息为 "error"。也可以写正则
})
复制代码

这里有个小技巧:当我们想忽略掉单个文件中的其他测试用例,只针对一个测试用例做调试的时候,可以加上 .only

it.only('test', () => {
  // ...
})
复制代码

但这并不会忽略其他测试文件的测试用例

三、测试异步代码

这里有三个异步方法,对这三个方法进行代码测试,"www.dell-lee.com/react/api/d…" 会返回 {success: true}, "www.dell-lee.com/react/api/4…" 则不存在。

import axios from 'axios'

export function getData1() {
  return axios.get('http://www.dell-lee.com/react/api/demo.json')
}

export function getData2(fn) {
  axios.get('http://www.dell-lee.com/react/api/demo.json').then(res => {
    fn(res)
  })
}

export function get404() {
  return axios.get('http://www.dell-lee.com/react/api/404.json')
}
复制代码

对于异步代码测试,时机很重要,必须保证我们的测试用例在异步代码走完之后才结束。有以下几种办法:

  1. done,控制测试用例结束的时机
  2. 如果函数执行的返回值是 Promise,将这个 Promise return 出去
  3. async + await
import {getData1, getData2, get404} from './fetchData/fetchData'

it('getData1 方法1', (done) => {
  getData1().then(res => {
    expect(res.data).toEqual({
      success: true
    })
    done()  // 如果不加 done,还没执行到 .then 方法,测试用例已经结束了
  })
})

it('getData1 方法2', () => {
  return getData1().then(res => {
    expect(res.data).toEqual({
      success: true
    })
  })
})

it('getData2 方法2', (done) => {
  getData2((res) => {
    expect(res.data).toEqual({
      success: true
    })
    done()
  })
})

it('getData1 方法3', async () => {
  const res = await getData1()
  expect(res.data).toEqual({
    success: true
  })
})

/*********** 重点关注 ***********/
it('get404', (done) => {
  expect.assertions(1)
  get404().catch(r => {
    expect(r.toString()).toMatch('404')
    done()
  })
})
复制代码

重点讲一下上面的最后一个测试用例,假设我们现在有一个返回的是 404 的接口,我们需要对这个接口测试,期望他返回 404。 我们用 catch 捕获,在 catch 中判断。

但是,假如这个接口返回的不是 404,而是正常返回 200,这个 catch 则不会执行,expect 也不会执行,测试依然是通过的。这不符合我们的预期!所以,我们需要加上 expect.assertions(1) 进行断言:下面一定会执行一个 expect

当然,也可以用 async await 方法进行 404 接口的测试

it('get404 方法3', async () => {
  await expect(get404()).rejects.toThrow()
})
复制代码

四、Jest 中的一些钩子函数

  • beforeAll:所有用例开始执行前
  • beforeEach:每个用例执行前
  • afterEach
  • afterAll
  • describe

前四个钩子使用起来很简单,调用方法如下:

beforeAll(() => {
  // ...
})
复制代码

如果测试前后要做一些处理,尽可能写在这些钩子函数中,他能保证一定的执行顺序。

describe 可以用来进行用例分组,为了让我们的测试输出结果更好看,更有层次。 同时,在每个 describe 中都有上面 4 个钩子函数的存在,我们来看看具体的情况:

describe('测试 Button 组件', () => {
  beforeAll(...)  // 1
  beforeEach(...) // 2
  afterEach(...)  // 3
  afterAll(...)   // 4

  describe('测试 Button 组件的事件', () => {
    beforeAll(...)  // 5
    beforeEach(...) // 6
    afterEach(...)  // 7
    afterAll(...)   // 8
    it('event1', ()=>{...})
  })
})
复制代码

上面钩子函数的执行顺序是: 1 > 5 > 2 > 6 > 3 > 7 > 4 > 8
外部的钩子函数对 describe 内部的用例也生效,执行顺序为:先外部后内部

五、Jest 中的 mock

1. 在 Jest 中 mock 异步方法

前面提到了可以测试异步代码,对于一些接口都能进行请求测试。但假如每一个接口都真的发起请求,那一次测试需要耗费的时间是很多的。 这时候我们可以模拟请求方法,步骤如下:

  1. mock.js 中导出了我们的请求方法
import axios from 'axios'

export function getData() {
  return axios.get('http://www.dell-lee.com/react/api/demo.json')
}
复制代码
  1. 在 mock.js 的同级目录下建一个 mocks 的文件夹,文件夹内建立对应文件名的文件,这个文件就是导出的方法就是模拟请求的方法
    1580732355(1)
    这里我们直接返回一个 Promise,把假数据 resolve 出去
export function getData() {
  return Promise.resolve({
    success: true
  })
}
复制代码
  1. 测试用例部分: 这里有一个需要注意的jest.mock 不能写在任何钩子函数里,因为钩子函数的执行时机问题,beforeAll 也不行,当钩子函数执行时,没有写在钩子函数里面的代码已经执行了,也就是已经 import 了!
jest.mock('./mock/mock.js')  // 用 jest 模拟 mock.js 方法

import {getData} from './mock/mock.js'  // 导入 mock.js,但实际上 jest 会导入 __mocks__ 下的 mock.js

test('mock 方法测试', () => {
  getData().then(data => {
    expect(data).toEqual({
      success: true
    })
    done()
  })
  
})
复制代码

除了上面的这种办法,还能在 jest.config.js 中配置自动开启 mock,这样 jest 会自动去查找当前文件同级有没有 mock 文件夹,里面有没有对应文件

module.exports = {
  automock: true
}
复制代码

讲了两种 mock 的方法,还有一种极端情况需要避免 mock:
我们在 mock.js 中定义了一个需要 mock 的 getData 方法,又另外定义了一个不需要 mock 的普通方法,当我们在测试文件导入的时候,需要避免 jest 去 mocks/mock.js 下找这个普通方法,这里需要用 jest 提供的方法导入:

const { regularMethod } = jest.requireActual('./mock/mock.js')
复制代码

2. 用 Jest 操控时间

当我们有如下代码需要测试的时候:

export default (fn) => {
  setTimeout(() => {
    fn()
  }, 3000)
}
复制代码

我们不可能总是去等待定时器,这时候我们要用 Jest 来操作时间!步骤如下:

  1. 通过 jest.useFakeTimers() 使用 jest “自制的” 定时器,这里放在 beforeEach 里面是因为快进时间可能被调用多次,我希望在每个测试用例里,这个时钟都是初始状态,不会互相影响。
  2. 执行 timer 函数之后,快进时间 3 秒 jest.advanceTimersByTime(3000),这个方法可以调用任意次,快进的时间会叠加。
  3. 这时候我们已经穿梭到了 3 秒后,expect 也能生效了!

特别说明一下:jest.fn() 生成的是一个函数,这个函数能被监听调用过几次

import timer from './timer/timer'

beforeEach(() => {
  jest.useFakeTimers()
})

it('timer 测试', () => {
  const fn = jest.fn()
  timer(fn)
  jest.advanceTimersByTime(3000)
  expect(fn).toHaveBeenCalledTimes(1)
})
复制代码

3. mock 类

同样的,当我们只关注类的方法是否被调用,而不关心方法调用产生的结果时,可以 mock 类

在 util/util.js 中定义了 Util 类

export class Util {
  a() {}
  b() {}
}
复制代码

在 util/useUtil 中调用了这个类

import {Util} from './util'

export function useUtil() {
  let u = new Util()
  u.a()
  u.b()
}
复制代码

我们需要测试 u.a 和 u.b 被调用,jest.mock('./util/util') 会将 Util、Util.a、Util.b 都 mock 成 jest.fn

测试用例如下:

jest.mock('./util/util')  // mock Util 类
import {Util} from './util/util'
import {useUtil} from './util/uesUtil'

test('util 的实例方法被执行了', () => {
  useUtil()
  expect(Util).toHaveBeenCalled()
  expect(Util.mock.instances[0].a).toHaveBeenCalled()
  expect(Util.mock.instances[0].b).toHaveBeenCalled()
})
复制代码

六、结合 Vue组件 进行单元测试

1. 简单用例入门

Vue 提供了 @vue/test-utils 来帮助我们进行单元测试,创建 Vue 项目的时候勾选测试选项会自动帮我们安装。

先来介绍两个常用的挂载方法:

  • mount:会将组件以及组件包含的子组件都进行挂载
  • shallowMount:浅挂载,只会挂载组件,忽略子组件

再来看一个简单的测试用例:

import { shallowMount } from '@vue/test-utils'
import HelloWorld from '@/components/HelloWorld.vue'

describe('HelloWorld.vue', () => {
  it('renders props.msg when passed', () => {
    const msg = 'new message'
    const wrapper = shallowMount(HelloWorld, {
      propsData: { msg }
    })
    expect(wrapper.props('msg')).toBe(msg)
  })
})
复制代码

shallowMount 会返回一个 wrapper,这个 wrapper 上面会包含很多帮助我们测试的方法,详见

2. 快照测试

快照测试的意思是,会将组件像拍照一样拍下来,存底。下次运行测试用例的时候,如果组件发生变化,和快照不一样了,就会报错。

测试用例写法如下: 第一次测试会保存 wrapper 的快照,第二次会比较当前 wrapper 和快照的区别

describe('HelloWorld.vue', () => {
  it('renders props.msg when passed', () => {
    const msg = 'new message'
    const wrapper = shallowMount(HelloWorld, {
      propsData: { msg }
    })
    expect(wrapper).toMatchSnapshot()
  })
})
复制代码

我们再来看看快照长什么样子:

1580787793(1)
可以看到,快照实际保存的就是组件渲染之后的 html 部分,css 部分没有保存,在元素上绑定的 @click 等一些事件也不会保存, 所以快照适合进行 DOM 节点是否变化的测试。

当快照发生变化时,我们可以在终端按 u 进行更新快照

1580788050(1)

3. 覆盖率测试

覆盖率测试是对测试完全程度的一个评估,测试覆盖到的业务代码越多,覆盖率越高。

在 jest.config.js 中我们可以设置 collectCoverageFrom,来设置需要进行覆盖率测试的文件,这里我们测试一下所有的 .vue 文件,忽略 node_modules 下所有文件。

要注意,在 Vue 中配置 jest,参考文档

然后添加一条 script 命令,就能进行测试了:

"test:unit": "vue-cli-service test:unit --coverage"
复制代码

执行命令会生成 coverage 文件夹,Icov-report/index.html 里会可视化展示我们的测试覆盖率

七、写在最后

1. 单元测试 or 集成测试?

就拿 shallowMount 来说,这个 api 就很适合单元测试,单元测试不关注单元之间的联系,对每个单元进行独立测试, 这也使得它代码量大,测试间过于独立。在进行一些函数库的测试,各个函数比较独立的时候,就很适合单元测试。
在进行一些业务组件测试时,需要关注组件间的联系,比较适合用集成测试。

2. TDD or BDD?

TDD:测试驱动开发,先写测试用例,然后根据用例写代码,比较关注代码本身。如下:

describe('input 输入回车,向外触发事件,data 中的 inputValue 被赋值', () => {
  const wrapper = shallowMount(TodoList)
  const inputEle = wrapper.find('input').at(0)
  const inputContent = '用户输入内容'
  inputEle.setValue(inputContent)
  // expect:add 事件被 emit
  except(wrapper.emitted().add).toBeTruthy()
  // expect:data 中的 inputValue 被赋值为 inputContent
  except(wrapper.vm.inputValue).toBe(inputContent)
})
复制代码

TDD 关注代码内部如何实现,关注事件是否触发?属性是否设置?data 数据是否被更新?

BDD:用户行为驱动开发,先写完业务代码,然后站在用户的角度去测试功能,不关注代码实现过程,只是通过模拟用户操作测试功能
比如下面这个用例:

describe('TodoList 测试', () => {
  it(`
    1. 用户在 header 输入框输入内容
    2. 键盘回车
    3. 列表项增加一项,内容为用户输入内容
  `, () => {
    // 挂载 TodoList 组件
    const wrapper = mount(TodoList)
    // 模拟用户输入
    const inputEle = wrapper.find('input').at(0)
    const inputContent = '用户输入内容'
    inputEle.setValue(inputContent)
    // 模拟触发的事件
    inputEle.trigger('content')
    inputEle.trigger('keyup.enter')
    // expect:列表项增加对应内容
    const listItems = wrapper.find('.list-item')
    expect(listItems.length).toBe(1)  // 增加 1 项
    expect(listItems.at(0).text()).toContain(inputContent)  // 增加 1 项
  })
})
复制代码

参考:

前端要学的测试课




原文地址:访问原文地址
快照地址: 访问文章快照