前端测试之Jest深入浅出

18,794 阅读28分钟

1. 为什么要做前端测试

首先,我认为前端测试并不是所有项目都必须的,因为写测试代码是需要要花费一定时间的,当项目比较简单的时候,花时间写测试代码可能反而会影响开发效率,但是需要指出的是,我们前端开发过程中,编写测试代码,有以下这些好处:

  1. 更快的发现bug,让绝大多数bug在开发阶段发现解决,提高产品质量
  2. 比起写注释,单元测试可能是更好的选择,通过运行测试代码,观察输入和输出,有时会比注释更能让别人理解你的代码(当然,重要的注释还是要写的。。。)
  3. 有利于重构,如果一个项目的测试代码写的比较完善,重构过程中改动时可以迅速的通过测试代码是否通过来检查重构是否正确,大大提高重构效率
  4. 编写测试代码的过程,往往可以让我们深入思考业务流程,让我们的代码写的更完善和规范。

2. 什么是TDDBDD

2.1 TDD与单元测试

2.1.1 什么是TDD

所谓TDD(Test Driven Development),即测试驱动开发,简单的来说就是先编写测试代码,然后以使得所有测试代码都通过为目的,编写逻辑代码,是一种以测试来驱动开发过程的开发模式。

2.1.2 单元测试

所谓单元测试(unit testing),是指对软件中的最小可测试单元进行检查和验证。通俗的讲,在前端,单元可以理解为一个独立的模块文件,单元测试就是对这样一个模块文件的测试。

对于一个独立的模块(ES6模块),因为功能相对独立,所以我们可以首先编写测试代码,然后根据测试代码指导编写逻辑代码。

所以提到TDD,这里的测试一般是指单元测试

2.2 BDD与集成测试

2.2.1 什么是BDD

所谓BDD(Behavior Driven Development),即行为驱动开发,简单的来说就是先编写业务逻辑代码,然后以使得所有业务逻辑按照预期结果执行为目的,编写测试代码,是一种以用户行为来驱动开发过程的开发模式。

2.2.2 集成测试

所谓集成测试(Integration Testing),是指对软件中的所有模块按照设计要求进行组装为完整系统后,进行检查和验证。通俗的讲,在前端,集成测试可以理解为对多个模块实现的一个交互完整的交互流程进行测试。

对于多个模块(ES6模块)组成的系统,需要首先将交互行为完善,才能按照预期行为编写测试代码。

所以提到BDD,这里的测试一般是指集成测试。

3. Jest使用---引言部分

3.1 我们如何写测试代码?

如果我们之前从来没有接触过测试代码,那让我们自己来设计测试代码的写法,会是什么样呢?我们需要让测试代码简单,通俗易懂,比如我们举个例子如下:

export function findMax (arr) {
    return Math.max(...arr)
}

我们写了一个很简单的获取数组最大值的函数(你可能觉得这样写并不严谨,但我们为了简单,暂时假设输入是非空数值数组),如果对这个函数写一个测试其正确与否的测试程序,它可能构思是这样的:

我期望 findMax([1, 2, 4, 3]) 的结果是 4

进一步转化为英文:

I expect findMax([1, 2, 4, 3]) to be 4

用程序性的语言表示,expect作为一个函数,为它传入想要测试的对象(findMax函数),把输出结果也做一层封装toBe(4):

expect(findMax([1, 2, 4, 3])).toBe(4)  // 有内味了

更进一步,我们想要增加一些描述性信息,比如

测试findMax函数,我期望 findMax([1, 2, 4, 3]) 的结果是 4

这个时候,我们可以再做一层封装,定义一个test函数,它有两个参数,第一个参数是一些描述性信息(这里是 测试findMax函数),第二个参数是一个函数,函数里可以执行我们上面的逻辑,如下:

test('findMax函数输出', () => {
    expect(findMax([1, 2, 4, 3])).toBe(4) // 内味更深了
})

3.2 简单的自己实现测试代码

我们自己可以简单的实现下test函数和expect函数,因为存在链式调用toBe,所以expect函数最终应该返回一个具有toBe方法的对象,如下:

// expect函数
function expect (value) {
    return {
        toBe: (toBeValue) => {
            if (toBeValue === value) {
                console.log('测试通过!')
            } else {
                throw new Error('测试不通过!')
            }
        }
    }
}

// test函数
function test (msg, func) {
    try {
        func()
        console.log(`${msg}测试过程无异常!`)
    } catch (err) {
        console.error(`${msg}测试过程出错!`)
    }
}

我们的测试方法,只是对数字做了简单的测试,实际项目中,需要测试的类型是很多的,这个时候我们就可以选择一些比较成熟的测试框架。一个简单好用,功能强大的工具就呈现在我们面前,它就是jest

4. Jest使用---入门部分

4.1 准备工作

我们这部分的例子主要是为了介绍jest最基本的用法,首先我们先简单的搭建一下演示环境。

第一步,使用npm init -y(我的node版本是v12.14.1npm版本是v6.13.4)初始化项目

第二步,安装jest npm install --save-dev jest(安装可以参考官网

第三步,运行npx jest --init命令,生成一份jest的配置文件jest.config.js,我的选择如下

第四步,运行npm i babel-jest @babel/core @babel/preset-env -D安装babel,并且配置.babelrc如下

{
  presets: [
    [
      '@babel/preset-env',
      {
        targets: {
          node: 'current',
        },
      },
    ],
  ],
};

第五步,根目录下建立src文件夹,新建两个文件basic.jsbasic.test.js

第六步,package.json增加一条命令:

 "scripts": {
    "test": "jest"
  },

以上六步完成后,我们的项目结构应该如下图

4.2 最基本的jest用法

接下来我们采用TDD加单元测试的方式来学习jest基本用法:

首先,在basic.js里定义两个工具函数

// 1. 寻找最大值
export function findMax (arr) {
    
}

// 2. 给定一个整数数组 nums 和一个目标值 target,在该数组中找出和为目标值的那 两个 整数,如果存在,返回true,否则返回false
export function twoSum (nums, target) {

};

既然是TDD,我们首先编写测试代码,在这个过程中,我们逐步学习各种jest的基本用法。测试代码在basic.test.js文件中编写:

import { findMax, twoSum } from './basic'

// 期望findMax([2, 6, 3])执行后结果为6
test('findMax([2, 6, 3])', () => {
    expect(findMax([2, 6, 3])).toBe(6)
})

// 期望twoSum([2, 3, 4, 6], 10)执行后结果为true
test('twoSum([2, 3, 4, 6], 10)', () => {
    expect(twoSum([2, 3, 4, 6], 10)).toBe(true)
})

从上面代码,我们可以看到,jest测试代码的写法,和之前我们自己写的是一样的(当然啦,本来就是模仿jest的),此时我们运行npm test命令,观察命令行输出如下:

注意我红框里的部分,Expected代表期望函数执行的结果,也就是toBe里的那个值,Received代表实际执行函数得到的结果,因为我们还没有编写业务代码,所以Received都是undefined,最后显示一共1个测试文件(Test Suites)和2条测试代码,它们都测试失败了。

接下来我们完善basic.js里的逻辑

// 1. 寻找最大值
export function findMax (arr) {
    return Math.max(...arr)
}

// 2. 给定一个整数数组 nums 和一个目标值 target,在该数组中找出和为目标值的那 两个 整数,如果存在,返回true,否则返回false
export function twoSum (nums, target) {
    for (let i = 0; i < nums.length - 1; i++) {
       for (let j = i + 1; j < nums.length; j++) {
           if (nums[i] + nums[j] === target) {
               return true
           }
       } 
    }
    return false
};

然后我们再次运行npm test,得到结果如下

我们可以看到,所有测试用例都通过了(直观的就是都绿了)。这种首先所有测试用例都没有通过(一片红),随着我们开发过程的进行,一步步的,最终测试代码都通过(一片绿)的过程,就是TDD和单元测试的开发过程。

4.3 更多的jest matchers

像是上小节,在expect函数后面跟着的判断结果的toBejest中被称为matcher,我们这一小节就来介绍另外一些常用的matchers

4.3.1 toEqual

我们首先改造下刚刚的twoSum函数,让它返回找到的两个数的索引数组(leetcode第一题)

// 2. 给定一个整数数组 nums 和一个目标值 target,在该数组中找出和为目标值的那 两个 整数,
// 并返回他们的数组下标(假设每种输入只会对应一个答案,数组中同一个元素不能使用两遍)。
export function twoSum (nums, target) {
    for (let i = 0; i < nums.length - 1; i++) {
       for (let j = i + 1; j < nums.length; j++) {
           if (nums[i] + nums[j] === target) {
               return [i, j]
           }
       } 
    }
    return []
};

接下来测试代码部分我们只保留对twoSum函数的测试,并同步修改测试代码

test('twoSum([2, 3, 4, 6], 10)', () => {
    expect(twoSum([2, 3, 4, 6], 10)).toBe([2, 3])
})

我们的期望是函数执行的结果是[2, 3]这样的数组,看起来没问题,运行npm test

我们发现并没有通过测试,这是因为,toBe可以判断基本类型数据,但是对于数组,对象这样的引用类型是没办法判断的,这个时候,我们就需要使用toEqual

test('twoSum([2, 3, 4, 6], 10)', () => {
    expect(twoSum([2, 3, 4, 6], 10)).toEqual([2, 3])
})

改成toEqual之后,测试代码就成功了

4.3.2 判断逻辑真假相关的一些matchers

这部分内容很简单,也比较多,所以直接在代码里注释说明:

test('变量a是否为null', () => {
    const a = null
    expect(a).toBeNull()
})

test('变量a是否为undefined', () => {
    const a = undefined
    expect(a).toBeUndefined()
})

test('变量a是否为defined', () => {
    const a = null
    expect(a).toBeDefined()
})

test('变量a是否为true', () => {
    const a = 1
    expect(a).toBeTruthy()
})

test('变量a是否为false', () => {
    const a = 0
    expect(a).toBeFalsy()
})

测试结果如下:

4.3.3 not修饰符

很简单,not就是对matcher的否定

test('test not', () => {
    const temp = 10
    expect(temp).not.toBe(11)
    expect(temp).not.toBeFalsy()
    expect(temp).toBeTruthy()
})

测试结果如下:

4.3.4 判断数字相关的一些matchers

这部分内容很简单,也比较多,所以直接在代码里注释说明:

// 判断数num是否大于某个数
test('toBeGreaterThan', () => {
    const num = 10
    expect(num).toBeGreaterThan(7)
})

// 判断数num是否大于等于某个数
test('toBeGreaterThanOrEqual', () => {
    const num = 10
    expect(num).toBeGreaterThanOrEqual(10)
})

// 判断数num是否小于某个数
test('toBeLessThan', () => {
    const num = 10
    expect(num).toBeLessThan(20)
})

// 判断数num是否小于等于某个数
test('toBeLessThanOrEqual', () => {
    const num = 10
    expect(num).toBeLessThanOrEqual(10)
    expect(num).toBeLessThanOrEqual(20)
})

测试结果如下:

上面介绍的都是整数判断,十分简单,但是如果是浮点数相关的判断,会不太一样,比如,我们知道0.1 + 0.2 = 0.3这个式子在数学中没有问题,但是在计算机中,由于精度问题,这个0.1 + 0.2结果如果用toBe结果并不是准确的0.3,如果我们想要判断浮点数的相等,在jest中提供了一个toBeCloseTomatcher可以解决:

test('toBe', () => {
    const sum = 0.1 + 0.2
    expect(sum).toBe(0.3)
})

test('toBeCloseTo', () => {
    const sum = 0.1 + 0.2
    expect(sum).toBeCloseTo(0.3)
})

上面的测试结果如下:

4.3.5 字符串匹配toMatch

这个matcher就是用来判断字符串是否和toMatch提供的模式匹配,如下:

// 字符串相关
test('toMatch', () => {
    const str = 'Lebron James'
    expect(str).toMatch(/Ja/)
    expect(str).toMatch('Ja')
})

4.3.6 数组,集合相关的matchers

可以使用toContain判断数组或者集合是否包含某个元素,使用toHaveLength判断数组的长度,代码如下:

test('Array Set matchers', () => {
    const arr = ['Kobe', 'James', 'Curry']
    const set = new Set(arr)
    expect(arr).toContain('Kobe')
    expect(set).toContain('Curry')
    expect(arr).toHaveLength(3)
})

4.3.7 异常相关的matchers

使用toThrow来判断抛出的异常是否符合预期:

function throwError () {
    throw new Error('this is an error!!')
}
test('toThrow', () => {
    expect(throwError).toThrow(/this is an error/)
})

5. jest进阶用法

5.1 分组测试与勾子函数

所谓分组测试,核心在于,将不同的测试进行分组,再结合勾子函数(生命周期函数),完成不同分组的定制化测试,以满足测试过程重的复杂需求。

我们首先在src下新建两个文件hook.jshook.test.js,这一部分代码在这两个文件中完成,首先直接给出hook.js代码

// hook.js
export default class Count {
    constructor () {
        this.count = 2
    }
    increase () {
        this.count ++
    }

    decrease () {
        this.count --
    }

    double () {
        this.count *= this.count
    }

    half () {
        this.count /= this.count
    }
} 

现在呢,我们想要对Count类的四个方法单独测试,数据互相不影响,当然我们可以自己去直接实例化4个对象,不过,jest给了我们更优雅的写法---分组,我们使用describe函数分组,如下:

describe('分别测试Count的4个方法', () => {
    test('测试increase', () => {
        
    })
    test('测试decrease', () => {
        
    })
    test('测试double', () => {
        
    })
    test('测试half', () => {
        
    })
})

这样我们就使用describe函数配合test将测试分为了四组,接下来,为了能更好的控制每个test组,我们就要用到jest的勾子函数。 我们这里要介绍的是jest里的四个勾子函数beforeEach,beforeAll,afterEach,afterAll

顾名思义,beforeEach是在每一个test函数执行之前,会被调用;afterEach则是在每一个test函数执行之后调用;beforeAll是在所有test函数执行之前调用;afterAll则是在所有test函数执行之后调用。我们可以看下面这个例子:

import Count from "./hook"

describe('分别测试Count的4个方法', () => {
    let count
    beforeAll(() => {
        console.log('before all tests!')
    })

    beforeEach(() => {
        console.log('before each test!')
        count = new Count()
    })

    afterAll(() => {
        console.log('after all tests!')
    })

    afterEach(() => {
        console.log('after each test!')
    })

    test('测试increase', () => {
        count.increase()
        console.log(count.count)
    })
    test('测试decrease', () => {
        count.decrease()
        console.log(count.count)
    })
    test('测试double', () => {
        count.double()
        console.log(count.count)
    })
    test('测试half', () => {
        count.half()
        console.log(count.count)
    })
})

输出的结果如图:

可以看到,我们在每个test执行之前,beforeEach里面重新实例化了count,所以每一次的count是不同的。合理的使用勾子函数,我们可以更好的定制测试。

5.2 异步代码测试之定时器

在我们前端开发过程中,由于javascript是单线程的,异步编程是我们开发人员经常要做的工作,而异步代码也是最容易出错的地方,对异步代码逻辑进行测试,是很有必要的,这一节将对jest如何进行异步测试,做一个详细的介绍。

5.2.1 从最简单的setTimeout开始

我们首先新建timeout.js,timeout.test.js文件,timeout.js文件代码很简单:

export default (fn) => {
    setTimeout(() => {
       fn()
    }, 2000)
}

我们现在的目标就是去测试,写的这个函数,是不是会像我们预期的那样,传入一个函数作为参数(简单为主,没有做参数校验),2s后,执行这个函数。

我们的测试代码(timeout.test.js)如下:

import timeout from './timeout'

test('测试timer', () => {
    timeout(() => {
        expect(2+2).toBe(4)
    })
})

如果我们运行这段测试代码,一定是会通过的,但是,这真的代表我们写在timeout里的方法测试通过了吗?我们在timout.js中打印输出一段文字

export default (fn) => {
    setTimeout(() => {
       fn()
       console.log('this is timeout!')
    }, 2000)
}

然后我们运行测试代码(npm test timeout.test这样只运行一个文件),你会发现,什么打印内容都没有输出:

其实产生这种现象的原因也很简单,jest在运行测试代码,执行test方法时,从函数内部第一行执行到最后一行,当执行逻辑走到代码块最后一行时,没有异常就会返回测试成功,这个过程中不会去等待异步代码的执行结果,所以我们这样的测试方法,不管setTimeout里怎么实现,回调函数里怎么实现,都不会执行回调函数内部的逻辑。

如果我们需要测试代码在真正执行了定时器里的异步逻辑后,才返回测试结果,我们需要给test方法的回调函数传入一个done参数,并在test方法内异步执行的代码中调用这个done方法,这样,test方法会等到done所在的代码块内容执行完毕后才返回测试结果:

import timeout from './timeout'

test('测试timer', (done) => {
    timeout(() => {
        expect(2+2).toBe(4)
        done()
    })
})

我们可以看到,增加done参数之后,得到了预期的结果,打印输出了内容,证明我们回调函数内的代码执行了。

5.2.2 使用fakeTimers提高测试效率

我们上一小节介绍了如何去测试写在定时器里异步代码的执行,但这里存在一个问题,比如,我们的定时器可能需要几十秒才执行内部逻辑(这虽然很少见,主要看业务需求),我们的测试代码也会很久才会返回结果,这无疑大大的降低了开发测试效率。

jest也考虑到了这一点,让我们可以使用fakeTimers模拟真实的定时器。这个fakeTimers在遇到定时器时,允许我们立即跳过定时器等待时间,执行内部逻辑,比如,对于刚刚的timeout.test,我们的测试代码可以做如下改变:

  1. 首先,我们使用jest.fn()生成一个jest提供的用来测试的函数,这样我们之后回调函数不需要自己去写一个
  2. 其次,我们使用jest.useFakeTimers()方法启动fakeTimers
  3. 最后,我们可以通过jest.advanceTimersByTime()方法,参数传入毫秒时间,jest会立即跳过这个时间值,还可以通过toHaveBeenCalledTimes()这个mathcer来测试函数的调用次数。

完整代码如下:

test('测试timer', () => {
    jest.useFakeTimers()
    const fn = jest.fn()
    timeout(fn)
    // 时间快进2秒
    jest.advanceTimersByTime(2000)
    expect(fn).toHaveBeenCalledTimes(1)
})

我们依然获得了预期的测试结果,注意观察输出结果里测试timer(12ms)对比之前的测试timer(2021ms),可以看到,定时器的延迟时间,确实被跳过了,这提高了测试开发效率。

5.2.3 更复杂的定时器场景

经过前面两节的介绍,对于定时器这种异步场景的测试代码编写,实际上我们已经掌握核心内容,这一节,我们去探讨一个更为复杂的场景,那就是定时器嵌套。

我们首先改造timout里的代码如下:

export default (fn) => {
    setTimeout(() => {
       fn()
       console.log('this is timeout outside!')
       setTimeout(() => {
            fn()
           console.log('this is timeout inside!')
       }, 3000)
    }, 2000)
}

按照上一小节的写法,我们的测试代码可以改造为:

test('测试timer', () => {
    jest.useFakeTimers()
    const fn = jest.fn()
    timeout(fn)
    // 时间快进2秒
    jest.advanceTimersByTime(2000)
    expect(fn).toHaveBeenCalledTimes(1)
    // 时间快进3秒
    jest.advanceTimersByTime(3000)
    expect(fn).toHaveBeenCalledTimes(2)
})

其实也很简单,就是在第一次2s后,再过3s后执行第二个定时器,此时fn被调用了2次,所以我们只需要加上最后两行代码就可以了。执行结果如下:

我们可以看到,两条打印结果都输出了。但是目前的这种实现不是很好,试想一下,如果这里面的定时器嵌套比较多,或者我们不清楚有几个定时器,就会比较麻烦。jest为这种情况提供了两个有用的方法:

  1. jest.runAllTimers()

这个方法就如同它的名字一样,调用之后,会执行所有定时器,我们的代码可以改造如下:

test('测试timer', () => {
    jest.useFakeTimers()
    const fn = jest.fn()
    timeout(fn)
    jest.runAllTimers()
    expect(fn).toHaveBeenCalledTimes(2)
})

可以看到,两个定时器内部的打印都输出了,而且jest依旧快速的跳过了定时器等待时间。

  1. jest.runOnlyPendingTimers()

这个方法的意思是,只执行当前正在等待的所有定时器,这个例子中,只有外层定时器是正在等待的,内层定时器只有在外层定时器执行时,才处于等待状态,我们改造测试代码如下:

test('测试timer', () => {
    jest.useFakeTimers()
    const fn = jest.fn()
    timeout(fn)
    jest.runOnlyPendingTimers()
    expect(fn).toHaveBeenCalledTimes(1)
})

可以看到,只有外层定时器里的内容被打印输出了。如果我们想要继续输出内部定时器的内容,因为此时内部定时器处于等待状态,所以再次执行jest.runOnlyPendingTimers()即可。

关于上述内容,有一点需要说明:

如果我们编写了多个test函数,它们都使用fakeTimers,一定要在beforeEach勾子中每次都调用jest.useFakeTimers(),否则,多个test函数中的fakeTimers会是同一个,将会互相干扰,产生不符合预期的执行结果

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

5.3 异步代码测试之数据请求(promise/async await)

5.3.1 传统的promise写法

在我们前端开发中,通过请求后端接口获取数据是很重要的一个流程,这一节主要就是介绍这个过程中如何编写测试代码(实际上这里的很多内容,之前介绍定时器的章节是有介绍过的)

为了简单起见,我们使用axios(npm i axios)这个成熟的库来辅助我们做数据请求。首先新建request.js, request.test.js这两个文件,在request.js文件请求一个免费api:

import axios from 'axios'

export const request = fn => {
    axios.get('https://jsonplaceholder.typicode.com/todos/1').then(res => {
        fn(res)
        console.log(res)
    })
}

我们在request.test.js中,为了保证异步代码执行完毕后结束测试,和之前介绍的一样,在test的回调函数中传入done参数,在回调函数里执行done(),代码如下:

import { request } from './request'

test('测试request', (done) => {
    request(data => {
        expect(data.data).toEqual({
            "userId": 1,
            "id": 1,
            "title": "delectus aut autem",
            "completed": false
          })
        done()
    })
})

我们可以看到,打印出了请求回来的内容,测试代码也正确的拿到数据,测试通过。

我们现在改造一下request.js的代码,让它返回一个promise:

export const request = () => {
    return axios.get('https://jsonplaceholder.typicode.com/todos/1')
}

为了测试上述代码,我们request.test.js也要做一定的修改:

test('测试request', () => {
    return request().then(data => {
        expect(data.data).toEqual({
            "userId": 1,
            "id": 1,
            "title": "delectus aut autem",
            "completed": false
          })
    })
})

注意,上面的写法不需要传入done参数了,但是,需要我们使用return返回,如果不写return,那jest执行test函数时,将不会等待promise返回,这样的话,测试结果输出时,then方法将不会执行。我们可以尝试以下两种写法(改变"completed": true),第一种写法测试不会通过,第二种测试是可以通过的(因为promise并没有返回结果):

// 第一种
test('测试request', () => {
    return request().then(data => {
        expect(data.data).toEqual({
            "userId": 1,
            "id": 1,
            "title": "delectus aut autem",
            "completed": true
          })
    })
})

// 第二种
test('测试request', () => {
    request().then(data => {
        expect(data.data).toEqual({
            "userId": 1,
            "id": 1,
            "title": "delectus aut autem",
            "completed": true
          })
    })
})

上面的测试代码,我们也可以写成下面的形式:

test('测试request', () => {
    return expect(request()).resolves.toMatchObject({
        data: {
            "userId": 1,
            "id": 1,
            "title": "delectus aut autem",
            "completed": false
          }
    })
})

注意,resolves将返回request方法执行后所有返回内容,我们使用toMatchObject这个matcher,当传入的对象能够匹配到request方法执行后返回对象的一部分键值对,测试就会通过。

5.3.2 使用async await语法糖

async await本质上就是promise链式调用的语法糖,我们上一小节最后的测试代码,如果使用async await的方式去书写,如下:

// 写法一
test('测试request', async () => {
    const res = await request()
    expect(res.data).toEqual({
        "userId": 1,
        "id": 1,
        "title": "delectus aut autem",
        "completed": false
    })
})
// 写法二
test('测试request', async () => {
    await expect(request()).resolves.toMatchObject({
        data: {
            "userId": 1,
            "id": 1,
            "title": "delectus aut autem",
            "completed": false
            }
        })
})

我们上述两种写法都是可以通过测试的。

5.3.3 对于请求出现错误的测试

在我们实际项目中,需要对这种接口请求做错误处理,同样,也需要对异常情况编写测试代码。

我们首先在request.js增加一个方法:

export const requestErr = fn => {
    return axios.get('https://jsonplaceholder.typicode.com/sda')
}

这里请求一个不存在的接口地址,会返回404,于是我们的的测试代码为:

test('测试request 404', () => {
    return requestErr().catch((e) => {
        console.log(e.toString())
        expect(e.toString().indexOf('404') > -1).toBeTruthy()
    })
})

这里有个地方需要注意一下,下图是jest官网的一段说明:

大概意思就是说,如果测试代码里使用catch,jest不回去执行catch里的内容,所以需要我们去写expect.assertions(1)这句话,代表,期望执行的断言是1次,catch方法算一次断言,所以,正常情况,由于不会执行catch,这里会报错(执行了0次断言),当这里报错了,说明我们的代码也按照预期产生了异常。

这种写法目前已经不需要了,详细见removed useless expect.assertions,所以,现在就按照上面那种方式,直接书写,测试通过代表确实如我们预期的产生异常。

同样的,我们还可以使用另一种方式完成异常代码测试:

test('测试request 404', () => {
    return expect(requestErr()).rejects.toThrow(/404/)
})

这里的rejects和上一节的resolves相互对于,代表执行方法产生的错误对象,这个错误对象抛出404异常(toThrow(/404/))

我们同样可以使用async await语法糖书写异常测试的代码:

test('测试request 404', async () => {
    await expect(requestErr()).rejects.toThrow(/404/)
})
// 或者可以使用try catch语句写的更完整
test('测试request 404', async () => {
    try {
        await requestErr()
    } catch (e) {
        expect(e.toString()).toBe('Error: Request failed with status code 404')
    }
})

5.4 在测试中模拟(mock)数据

我们首先新建mock.js, mock.test.js文件

5.4.1 使用jest.fn()模拟函数

首先在mock.js写一个函数:

export const run = fn => {
   return fn('this is run!')
}

实际上之前我们已经使用过jest.fn()了,这里我们更进一步的学习它。

  1. 首先,我们的fn()函数可以接受一个函数作为参数,这个函数就是我们想要jest.fn()为我们mock的函数,我们编写mock.test.js
test('测试 jest.fn()', () => {
    const fn = jest.fn(() => {
        return 'this is mock fn 1'
    })
})
  1. 其次,jest.fn()可以初始化时候不传入参数,然后通过调用生成的mock函数的mockImplementation或者mockImplementationOnce方法来改变mock函数内容,这两个方法的区别是,mockImplementationOnce只会改变要mock的函数一次:
test('测试 jest.fn()', () => {
    const func = jest.fn()
    func.mockImplementation(() => {
        return 'this is mock fn 1'
    })
    func.mockImplementationOnce(() => {
        return 'this is mock fn 2'
    })
    const a = run(func)
    const b = run(func)
    const c = run(func)
    console.log(a)
    console.log(b)
    console.log(c)
})

我们可以看到,函数执行的结果第一次是this is mock fn 2,之后都是this is mock fn 1

同样的,我们可以使用mock函数的mockReturnValuemockReturnValueOnce(一次)方法来改变函数的返回值:

test('测试 jest.fn()', () => {
    const func = jest.fn()
    func.mockImplementation(() => {
        return 'this is mock fn 1'
    })
    func.mockImplementationOnce(() => {
        return 'this is mock fn 2'
    })
    func.mockReturnValue('this is mock fn 3')
    func.mockReturnValueOnce('this is mock fn 4')
        .mockReturnValueOnce('this is mock fn 5')
        .mockReturnValueOnce('this is mock fn 6')
    const a = run(func)
    const b = run(func)
    const c = run(func)
    const d = run(func)
    console.log(a)
    console.log(b)
    console.log(c)
    console.log(d)
})

注意到,方法是可以链式调用的,方便多次输出不同的返回值。

  1. 最后,我们可以使用toBeCalledWith这个matcher来测试函数的传参数是否符合预期:
test('测试 jest.fn()', () => {
    const func = jest.fn()
    const a = run(func)
    expect(func).toBeCalledWith('this is run!')
})

5.4.2 模拟接口中获取的数据

很多时候,我们在前端开发过程中,后端接口还没有提供,我们需要去mock接口返回的数据。

我们首先在mock.js中编写一个简单的请求数据的代码:

import axios from 'axios'

export const request = fn => {
    return axios.get('https://jsonplaceholder.typicode.com/todos/1')
}

接着,我们在mock.test.js中,使用jest.mock()方法模拟axios,使用mockResolvedValuemockResolvedValueOnce方法模拟返回的数据,同样的,mockResolvedValueOnce方法只会改变一次返回的数据:

import axios from 'axios'
import { request } from './mock'

jest.mock('axios')

test('测试request', async () => {
    axios.get.mockResolvedValueOnce({ data: 'Jordan', position: 'SG' })
    axios.get.mockResolvedValue({ data: 'kobe', position: 'SG' })
    await request().then((res) => {
        expect(res.data).toBe('Jordan')
    })
    await request().then((res) => {
        expect(res.data).toBe('kobe')
    })
})

我们使用jest.mock('axios')来使用jest去模拟axios,测试正确的通过了。

5.5 dom相关测试

dom相关的测试其实很简单,我们首先新建dom.js, dom.test.js两个文件,代码如下:

// dom.js
export const generateDiv = () => {
    const div = document.createElement('div')
    div.className = 'test-div'
    document.body.appendChild(div)
}

// dom.test.js
import { generateDiv } from './dom'

test('测试dom操作', () => {
    generateDiv()
    generateDiv()
    generateDiv()
    expect(document.getElementsByClassName('test-div').length).toBe(3)
})

这里只有一点要说明,jest的运行环境是node.js,这里jest使用jsdom来让我们可以书写dom操作相关的测试逻辑。

5.6 快照(snapshot)测试

我们如果没有接触过快照测试,可能会觉得这个名字很高大上。所以我们首先新建snapshot.js, shapshot.test.js来看看快照测试究竟是什么。

在我们的日常开发中,总会写一些配置性的代码,它们大体不会变化,但是也会有小的变更,这样的配置可能如下(snapshot.js):

export const getConfig = () => {
    return {
        server: 'https://demo.com',
        port: '8080'
    }
}

我们的测试代码如下:

import { getConfig } from './snapshot'

test('getConfig测试', () => {
    expect(getConfig()).toEqual({
        server: 'https://demo.com',
        port: '8080'
    })
})

这样我们通过了测试。但是,假如后续我们的配置改变了,我就需要同步的去修改测试代码,这样会比较麻烦,从而,jest为我们引入了快照测试,先上测试代码:

test('getConfig测试', () => {
    expect(getConfig()).toMatchSnapshot()
})

我们运行测试代码之后,会在项目根目录下生成一个__snapshots__文件夹,下面有一个snapshot.test.js.snap快照文件,文件内容如下:

// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`getConfig测试 1`] = `
Object {
  "port": "8080",
  "server": "https://demo.com",
}
`;

jest会在运行toMatchSnapshot()的时候,首先检查有没有这个快照文件,如果没有,则生成,当我们改动配置内容时,比如把port改为8090,再次运行测试代码,测试不通过,结果如下:

这个时候,我们只需要运行npm test snapshot.test -- -u,就可以自动更新我们的快照文件,测试再次通过,这就让我们不需要每次更改配置文件的时候,手动去同步更新测试代码,提高了测试开发效率:

此时我们的快照文件更新为如下代码:

// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`getConfig测试 1`] = `
Object {
  "port": "8090",
  "server": "https://demo.com",
}
`;

6. jest其它一些有用的知识

6.1 让jest监听文件变化

这个功能很简单,我们只需要运行jest命令的时候,后面加上--watch即可,我们在package.json中新增一条命令:

"scripts": {
    "test": "jest",
    "test-watch": "jest --watch"
},

在新增完这条命令后,为了能让jest可以监听文件变化,我们还需要把我们的代码文件变成一个git仓库,jest也正式依靠git的能力实现监听文件变化的,我们运行git init,接着我们运行npm run test-watch,在一定时间后,我们开启监听模式,命令行最后几行输出应该是:

这里对watch模式的几个有用功能做一个简单介绍(也就是图中英文说明):

  1. a键运行所有测试代码
  2. f键只运行所有失败的测试代码
  3. p键按照文件名筛选测试代码(支持正则)
  4. t键按照测试名筛选测试代码(支持正则)
  5. q键盘推出watch模式
  6. enter键触发一次测试运行

这些我建议大家自行去尝试,它们都是十分简单好用的功能。

6.2 生成测试覆盖率文件

测试覆盖率,简单来说就是我们业务代码中,编写测试代码的比例,jest给我们提供了直接生成测试覆盖率文件的方法,也就是运行jest命令时后面加上--coverage参数,我们修改package.json文件如下:

"scripts": {
    "test": "jest",
    "test-watch": "jest --watch",
    "coverage": "jest --coverage"
},

接下来,运行npm run coverage,我们可以看到命令行输出如下:

这是一份测试覆盖率表格。同时,我们发现,文件夹下自动生成了一个coverage文件夹:

我们在浏览器中运行index.html,如下图:

这个页面向我们展示了项目中不同文件的测试覆盖率,我们可以点击不同文件名字进入查看具体一个文件中,哪些代码被测试到了,哪些没有被测试到。

这里对这个表格项目做一个简单的说明:

  1. Statements是语句覆盖率:表示代码中有多少执行的语句被测试到了

  2. Branches是分支覆盖率:表示代码中有多少if else switch分支被测试到了

  3. Functions是函数覆盖率:表示代码中被测试到的函数的占比

  4. Lines是行覆盖率:表示代码中被测试到的行数占比

我们可以利用生成的测试覆盖率文件,更好的完善改进我们的测试代码。

6.3 关于jest.config.js配置文件

我对于学习一个工具的配置文件的建议是,首先按照默认的来,当你需要改变配置的时候,再去查阅官方文档学习,不推荐去死记硬背。

我这里也不会去介绍怎么去配置jest文件,我们可以通过jest初始化时候默认生成的那个jest.config.js来学习(有详细注释),也可以在官网中查阅相关的配置参数。

7. 写在最后

由于篇幅原因,不适合再介绍更多的信息,更多的api相关的信息,建议去查阅官网来学习。

这篇文章个人认为已经把jest的基础和最核心的内容做了阐述,可能我们开发过程中,使用react(enzyme), vue( @vue/test-utils)这样的开发框架,使用webpack这样的工程化工具,在使用jest的时候,会结合使用一些开源库,我相信学好了jest本身之后,配置和使用它们都不会有太多困难。

最后,希望这篇文章可以帮助到大家,感谢能看到这里的每一个小伙伴。