跳到主要内容

前端测试

我们通常会使用console.log(fn())来测试函数输出是否符合预期,而这种做法有几点缺陷:

  1. 不直观。我们需要把实际的输出和内心的预期进行对比,才能知道输出是否正确。
  2. 测试用例没有持久化。
  3. 测试用例无法脱离浏览器运行。

于是,测试框架出现了。

断言

断言通常指我们期望A和B的值相等或具有某种关系,否则就会抛出异常。比如使用console.log(fn())我们就在断言fn()的返回值和内心预期值相等。

通常我们会采用专门的断言库,比如Node核心库assert,又或者最主流的社区断言库Chai

Node#assert

import assert from 'assert'

function fn() {
return 100
}
assert.strictEqual(fn(), 100) // 不报错就说明结果正确
assert.strictEqual(fn(), 200) // 抛出异常

Chai

Chai支持三种风格的断言,分别是TDD(测试驱动开发)风格的assertBDD(行为驱动开发)风格的expect、以及BDD风格的should

import {
assert,
} from 'chai'

function fn() {
return 100
}
assert.equal(fn(), 100) // TDD风格
import {
expect,
} from 'chai'

function fn() {
return 100
}
expect(fn()).to.equal(200) // BDD风格
import {
should
} from 'chai'

function fn() {
return 100
}
should()
fn().should.equal(100) // BDD风格

Mocha

Mocha是一个较主流的测试框架,它本身不具备断言的功能,因此通常和Chai搭配使用。默认的测试文件放置在项目根目录的test文件夹下面。

npm i mocha -D
npx mocha
// test/index.js
import { expect } from 'chai'

describe('测试用例组', () => {
it('test one', () => {
// 通过用例
})
it('test two', () => {
throw new Error('233') // 未通过用例
})

it('test three', () => {
expect(100).to.equal(200) // 未通过用例
})
})

Jest

JestFacebook出品的主流测试框架,Create-React-App内置Jest作为测试框架,Jest本身拥有断言的能力,同时Jest框架内部还集成了jsdom环境,我们只需要将Jest配置项testEnvironmentnode改为jsdom即可在单元测试中操作DOM。

默认的测试文件为根目录文件夹__tests__内部的文件和*.test.js后缀的文件。

npm i jest -D
npx jest
# or
npx jest --watch
// __tests__/index.js
test('1 + 1 equal 2', () => { // test 也可以写成 it
expect(1 + 1).toBe(2) // Jest自带expect断言
})

test('空测试用例', () => {
// 通过用例
})

describe('', () => {
it('测试用例1', () => {
expect(1 + 1).toBe(2)
})

it('测试用例2', () => {
expect(10 / 2).toBe(5)
})
})

配置文件

// jest.config.js 
module.exports = {
testMatch: [ // 默认值
"**/__tests__/**/*.[jt]s?(x)",
"**/?(*.)+(spec|test).[tj]s?(x)"
],
testEnvironment: "node", // 默认值node,可以改成jsdom来操作DOM
}

我们可以通过修改配置信息来调整测试文件的位置,或者是改成jsdom环境。

module.exports = {
testMatch: ['<rootDir>/test/**/*.js'],
testEnvironment: 'jsdom'
}
it('测试DOM', () => {
document.body.innerHTML = `<div class="test">akara</div>`
const el = document.querySelector('.test')
expect(el.innerHTML).toBe('akara')
})

Matchers

// 等值判断
toBe
toEqual // 用于对比两个对象的所有属性
toBeUndefined
toBeNull

// 包含判断
toHaveProperty
toContain
toMatch

// 逻辑判断
toBeTruthy // 1 '1' 也是 truthy
toBeFalsy // 0 '' 也是 falsy
toBeGreaterThan
toBeLessThan

// 取反 .not.
expect(1 + 1).not.toBe(3)

异步测试

it('测试异步', () => {
setTimeout(() => {
expect(1 + 1).toBe(3)
}, 1000)
})

通常当我们的测试函数在调用结束时也没有抛出异常就代表着通过了测试用例。上述代码在函数调用结束时还没有调用expect断言函数来抛出异常,因此通过了测试用例,而这与我们的预期不符。

为此在异步测试的场合,我们需要告知一个测试用例何时结束,通常我们有几种手段:手动调用done函数、函数返回Promise、使用async/await

// 手动调用done函数
it('测试异步', (done) => {
setTimeout(() => {
try {
expect(1 + 1).toBe(3)
done()
} catch(e) {
done(e)
}
}, 1000)
})
// 函数返回Promise
it('Promise test 1', () => {
return new Promise((resolve, reject) => {
setTimeout(() => {
try {
expect(1 + 1).toBe(3)
resolve()
} catch(e) {
reject(e)
}
}, 1000)
})
})

function sleep() {
return new Promise((resolve, reject) => {
setTimeout(resolve, 2000)
})
}

it('Promise test 1', () => {
return sleep.then(() => {
expect(1 + 1).toBe(3) // 异常会让返回的promise改变状态,从而结束测试用例
})
})
// async + await
function sleep() {
return new Promise((resolve, reject) => {
setTimeout(resolve, 2000)
})
}

it('test', async () => {
await sleep()
expect(1 + 1).toBe(3)
})

Setup

多个单元测试可能需要相同的设置,我们可以将这些设置放在一个单独的文件中,并在JestsetupFilesAfterEnv配置项中给出文件的位置。

// jest.config.js
module.exports = {
setupFilesAfterEnv: ['./jest.setup.js']
}
// jest.setup.js
beforeAll(() => {
// 测试开始前调用
})

beforeEach(() => {
// 每个测试用例前调用
})

afterAll(() => {
// 测试结束后调用
})

事实上在Create-React-App中默认的setup文件位于src/setupTests.js。该文件默认只有一行代码

// src/setupTests.js
import '@testing-library/jest-dom';

@testing-library/jest-dom提供了一些matcher方法来辅助我们进行断言。

Mock Function

test('mock fn', () => {
const arr = [1, 2, 3]
const fn = jest.fn()
arr.forEach(fn)
expect(fn.mock.calls.length).toBe(3)
expect(fn.mock.calls[0][0]).toBe(1)
expect(fn.mock.calls[1][0]).toBe(2)
expect(fn.mock.calls[2][0]).toBe(3)
})

test('mock fn2', () => {
const arr = [1, 2, 3]
const fn = jest.fn(x => x * x)
arr.forEach(fn)
expect(fn.mock.calls.length).toBe(3)
expect(fn.mock.results[0].value).toBe(1)
expect(fn.mock.results[1].value).toBe(4)
expect(fn.mock.results[2].value).toBe(9)
})

以上代码的jest.fn(x => x * x)算是mock了返回值,还有其他方式可以用来mock返回值:

// 前两个是Once,最后的不是
const fn = jest.fn()
fn.mockReturnValueOnce(1).mockReturnValueOnce(2).mockReturnValue(100)
const result = [1, 1, 1, 1].map(fn)
expect(result).toEqual([1, 2, 100, 100])

Mock Module

假设我们的目录结构如下

  • getUser.js
  • test
    • index.js
  • node_modules
  • package.json

其中getUser是我们待测试的文件:

// getUser.js
const axios = require('axios')

module.exports = async function getUser() {
const data = await axios.get('localhost:3000/getUsers') // {name: 'akara'}
return data
}

测试代码如下:

const getUser = require('../getUser')

it('模块测试', () => {
return getUser().then(data => expect(data).toEqual({name: 'akara'}))
})

如果要进行代码测试,我们必须要运行后端服务器;并且测试过程中发请求会让测试流程更长且脆弱。因此我们要来模拟axios这个模块。

为了模拟axios,已知axios安装在node_modules里,因此我们要在node_modules的同级目录,也就是项目根目录中新建文件夹__mocks__,并在该文件夹中创建和模块同名的文件axios.js

// __mocks__/axios.js
module.exports = {
get() {
return new Promise((resolve, reject) => {
resolve({name: 'akara'})
})
}
}

除此之外,我们也需要稍微修改一下测试用例的代码:

const getUser = require('../getUser')
jest.mock('axios') // 只新加了这个代码

it('模块测试', () => {
return getUser().then(data => expect(data).toEqual({name: 'akara'}))
})

加上了jest.mock('axios')后,测试代码中需要使用axios时,并不是去找真正的axios模块,而是找到了__mocks__下的那个我们写的模块。

模拟用户模块

除了axios这种安装在node_modules的Node模块,我们也可以模拟自己写的用户模块。

比如我们需要模拟lib/ajax.js这个模块,只需要在lib文件夹下面创建__mocks__,并在__mocks__下新建ajax.js即可。

Mock 静态资源

我们的React组件代码通常如下:

import React from 'react'
import './index.css'

export default function App() {
return <div>hello world</div>
}

这里的import './index.css'能使用主要是依靠了webpackloader

因此,当我们直接使用jest来测试这个文件的时候,就会出现问题。因为jest是和webpack独立的。

这个时候,我们可以来Mock这个import './index.css'

// jest.config.js
module.exports = {
"moduleNameMapper": {
"\\.css$": "<rootDir>/__mocks__/styleMock.js"
}
}

然后在项目根目录的__mocks__下新建styleMock.js即可

module.exports = {}

组件测试

组件测试的重点在于我们需要能够在Node环境下执行对DOM元素的操作,为此我们通常会使用第三方库jsdomglobal-jsdom在Node环境中引入DOM。

Jest框架内部继承了jsdom,我们只需要将Jest配置项testEnvironmentnode改为jsdom即可在单元测试中操作DOM。

test('Jest内部集成了JSDOM', () => {
const el = document.createElement('div')
el.innerHTML = 'akara'
expect(el.innerHTML).toBe('akara')
})

jsdom

const jsdom = require('jsdom')
const { JSDOM } = jsdom
const container = new JSDOM(`
<html>
<div>akara</div>
</html>
`)
console.log(container.window.document.querySelector('div').innerHTML);

global-jsdom

npm i -D jsdom global-jsdom
require('global-jsdom/register')

const el = document.createElement('div')
el.innerHTML = 'akara'
console.log(el);

@testing-library

我们可以使用@testing-library来实现对DOM或组件的测试,@testing-library/dom是个用来实现DOM测试的核心库,我们还可以使用封装了@testing-library/dom@testing-library/react@testing-library/vue等库来实现对相关组件的测试。

事实上Create-React-App创建的项目默认就使用了Jest@testing-library/react来提供组件测试的功能。

@testing-library/dom

作为核心库,@testing-library/dom提供了一系列有用的工具来帮助我们进行DOM元素的测试。

const { 
getByText,
screen,
fireEvent,
waitfor,
} = require('@testing-library/dom')

test('测试', () => {
const container = document.createElement('div')
container.innerHTML = `<div>aka</div>`
const el = getByText(container, 'akara')
// 也可以使用screen,但需要先将DOM元素添加进body中
// document.body.appendChild(container)
// const el = screen.getByText('aka')
el.addEventListener('click', function(e) {
e.target.innerHTML = 'bkb'
})
fireEvent.click(el)
expect(el.innerHTML).toBe('bkb')
})

@testing-library/react

// 默认的 App.test.js
import {
render,
screen,
fireEvent,
waitfor
} from '@testing-library/react';
import App from './App';

test('renders learn react link', () => {
render(<App />);
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});

Mock Service Worker

NSW自称是下一代的API Mocking工具,在浏览器环境中可以Mock后端接口,或者也可以在Jest这样的Node环境Mock后端接口。

在浏览器环境下NSW实际上会创建一个Service Worker,而在Node环境下NSW会创建一个服务器来实现相关的功能。

Jest的Mock Module也能够实现对后端接口的Mock,使用Mock Module还是NSW就是个见仁见智的问题了。

浏览器环境Mock

建议看官网

Node环境Mock

// src/setupTests.js
const { rest } = require('msw')
const { setupServer } = require('msw/node')

const server = setupServer(
rest.get('http://localhost:3000/test', (req, res, ctx) => {
return res(
ctx.status(200),
ctx.json({
name: 'akara'
})
)
})
)

beforeAll(() => server.listen())

afterEach(() => server.resetHandlers())

afterAll(() => server.close())
// index.test.js
const fetch = require('node-fetch')

test('测试', async () => {
const data = await fetch('http://localhost:3000/test').then(res => res.json())
expect(data.name).toBe('akara')
})

测试覆盖率

todo 伊斯坦布尔