Jest前端测试

196 阅读10分钟

第一章 集成测试和单元测试

项目中的前端测试,一般包括单元测试和集成测试两种。

1)单元测试

概念:验证代码中的各个独立单元(函数或方法)是否按预期工作。在单元测试中,每个单元都会被隔离测试,以确保其功能正确。

特点:测试覆盖率高、与业务代码耦合度高、代码量大、过于独立

适用场景:单个函数、类等。

2)集成测试

概念:测试多个单元、组件或模块之间的交互,确保不同部分之间能够正常的协作。

特点:测试覆盖率低、耦合度低、代码量小

适用场景:适用于业务逻辑的测试,如一个完整的功能。

单元测试和集成测试是提高代码质量的关键工程实践,有助于早期发现和解决问题,提高代码的可维护性和可靠性。

在前端项目中,如果不依靠任何第三方插件,也可以对项目代码进行测试。我们以函数为例,根据我们目前掌握的知识进行简单测试。

首先假设有一个main.js文件,包含两个方法需要测试:

const sum = (a,b)=>{
    return a + b
}

const minus = (a,b)=>{
    return a - b
}

然后针对summinus方法编写测试用例(至于函数名expect只是为了能更好的引出Jest,其他名字也可以的😊):

function expect(result){
    return {
        toBe: function (actual){
            if(result !== actual){
                throw new Error('预期值和实际值不相等');
            }
        }
    }
}

最后执行测试用例:

expect(sum(3,6)).toBe(8);
expect(minus(6,2)).toBe(4);

image.png

通过node main.js运行文件来检测用例是否通过。

我们为了使代码结构更清晰,再进一步优化测试用例(这里的test方法也是为了引出Jest,改个名字也可以😜)

function expect(result){
    return {
        toBe: function (actual){
            if(result !== actual){
                throw new Error('预期值和实际值不相等');
            }
        }
    }
}

function test(decs,fn){
    try{
        fn();
    }catch(err){
        console.log(`${decs}没有通过测试`);
    }
}
test('测试加法',()=> expect(sum(3,6)).toBe(9));
test('测试减法',()=> expect(minus(6,2)).toBe(2));

image.png

以上就是我们自己写的测试用例,也可以完成对应函数的测试。但是对于过于复杂的组件或函数,自己编写测试用例则显得很吃力。

目前市面上针对前端测试的插件有很多,例如Jest、Jasmine等,本文主要介绍Jest的使用,希望大家可以举一反三。

第二章 Jest入门

1. 项目引入

第一步:下载依赖包 npm install jest -D

第二步:改写package.json文件(多种配置)

"test": "jest" // 当运行npm run test 时执行

"test": "jest --watchAll" // 自动监听所有测试文件,一旦发生改变会自动重新测试

第三步:通过jest导出的API进行测试

// main.test.js

const math = require('./main');
const { sum, minus } = math;

test('测试加法',()=> {
    expect(sum(3,6)).toBe(9);
});
test('测试减法',()=> {
    expect(minus(6,2)).toBe(4);
});

第四步:执行测试 npm run test

image.png

test.only()表示只执行当前的测试用例,省略skipped其他测试用例的执行

image.png

2. 使用模块语法

使用Jest进行测试需要基于模块的导出和导入。

  • CommonJS模块化:
// 导出
module.exports = {... ...}

// 导入
const xxx = require('./xxx');
  • ESModule模块化:
// 导出
export function sum (a,b){
    return a + b
}

// 导入
import { sum } from './main';

⚠️:当使用ESModule模块化时,Jest运行会报错“有未知语法错误”。需要搭配babel编译器进行编译,转化为Jest可以识别的CommonJS模块化。

image.png

下载babel插件:

npm install @babel/core @babel/preset-env -D

配置babelrc文件:

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

image.png

jest底层集成了一个 babel-jest插件,当运行npm run jest时,babel-jest会检测当前项目是否安装了babel-core依赖,如果安装了,则会从.babelrc文件获取相关配置,并在运行测试代码之前,利用babel-core对当前的代码做一次转换(将ESModule转化为CommonJS),然后再运行转换后的测试用例代码。

3. 初始化配置文件

生成配置文件:npx jest --init

image.png

npx jest --[string]可以生成对应的测试覆盖率结果

image.png image.png

4. Jest的匹配器

test('测试加法',()=> {
    // 测试描述:测试加法
    // 测试结果:期待9等于9
    expect(9).toBe(9);
});

Jest中的测试用例在使用时,其逻辑类似于expect()===valueexpect() === value。而其中的toBe()就是一种匹配器。

Jest中的匹配器有很多,下面罗列了一些常用的匹配器:

  • toBe(value):判断两个是否相等,判断逻辑类似于Object.is(NaN等于NaN,+0不等于-0)
test('toBe使用',()=> {
    expect(9).toBe(9);
    expect('hello').toBe('hello');
    expect({a: 1}).toBe({a: 1}); // 测试不通过,引用地址不同
    expect(null).toBe(null);
    expect(+0).toBe(-0); // 测试不通过
    expect(NaN).toBe(NaN);
});
  • toEqual(value):判断两个引用对象的内容是否相同,对于基本数据类型的判断逻辑同toBe一致
test('测试描述',()=> {
    expect({a: 1}).toEqual({a: 1});
    expect({a: 1, b: NaN}).toEqual({a: 1, b:NaN});
    expect({a: 1, b: -0}).toEqual({a: 1, b: +0}); // 测试不通过,-0不等于+0
    expect({a: 1, b: {c: ['1']}}).toEqual({a: 1, b: { c: ['1']}});
});
  • toBeNull():判断值是否为null
test('测试描述',()=> {
    expect(null).toBeNull();
    expect(undefined).toBeNull(); // 测试不通过
});
  • toBeUndefined():判断值是否为undefined
test('测试描述',()=> {
    expect(null).toBeUndefined(); // 测试不通过
    expect(undefined).toBeUndefined();
});
  • toBeDefined():判断值是否被定义过,undefined属于未被定义
test('测试描述',()=> {
    expect(null).toBeDefined();
    expect(undefined).toBeDefined(); // 测试不通过
    expect(1).toBeDefined();
});
  • toBeTruthy():判断值是否为真,会通过JS转换为布尔值
test('测试描述',()=> {
    expect(null).toBeTruthy(); // 测试不通过
    expect(undefined).toBeTruthy(); // 测试不通过
    expect(1).toBeTruthy();
});
  • toBeFalsy():判断值是否为假,会通过 JS转换为布尔值
test('测试描述',()=> {
    expect(null).toBeFalsy();
    expect(undefined).toBeFalsy();
    expect(1).toBeFalsy(); // 测试不通过
});
  • .not:对匹配器取相反的值
test('测试描述',()=> {
    // 期待null不是假值
    expect(null).not.toBeFalsy(); // 测试不通过
    // 期待undefined不是假值
    expect(undefined).not.toBeFalsy(); // 测试不通过
});
  • toBeGreaterThan(number | bigint):判断两个数值,前者比后者大
test('测试描述',()=> {
    expect(9).toBeGreaterThan(8);
    expect(9).toBeGreaterThan(10); // 测试不通过
});
  • toBeLessThan(number | bigint):判断两个数值,前者比后者小
test('测试描述',()=> {
    expect(9).toBeLessThan(8); // 测试不通过
    expect(9).toBeLessThan(10);
});
  • toBeGreaterThanOrEqual(number | bigint):判断两个数值,前者大于等于后者
test('测试描述',()=> {
    expect(9).toBeGreaterThanOrEqual(8);
    expect(9).toBeGreaterThanOrEqual(10); // 测试不通过
});
  • toBeLessThanOrEqual(number | bigint):判断两个数值,前者小于等于后者
test('测试描述',()=> {
    expect(9).toBeLessThanOrEqual(8); // 测试不通过
    expect(9).toBeLessThanOrEqual(10);
});
  • toBeCloseTo(number,numDigits?):判断两个浮点数是否近似相等,第二个参数是可选的,表示要检查的小数点后位数
test('测试描述',()=> {
    expect(0.1 + 0.2).toBeCloseTo(0.3,5);
    expect(0.1 + 0.2).toBeCloseTo(0.3,20); // 测试不通过
    expect(0.1 + 0.2).toBeEqual(0.3); // 测试不通过,0.3 !== 0.3000000004
});
  • toMatch(regexp | string):判断字符串是否包含某个字符,参数可以是字符串,也可以是正则表达式
test('测试描述',()=> {
    expect('http://www.baidu.com').toMatch('baidu');
    expect('http://www.baidu.com').toMatch(/baidu/ig);
    expect('http://www.baidu.com').toMatch('souhu'); // 测试不通过
});
  • toContain(item):判断Array或者Set数据中是否包含某一项item
test('测试描述',()=> {
    expect([1,2,3,4]).toContain(4);
    expect([1,2,3,4]).toContain(5); // 测试不通过
    expect(new Set([1,2,3,4])).toContain(4);
});
  • toThrow(error?):判断函数运行结果是否抛出异常以及异常信息是否为预期值,异常信息error为可选参数
test('测试描述',()=> {
    expect(()=> {
        throw new Error('error')
    }).toThrow('error11'); // 测试不通过,异常信息不相等
    expect(()=> {
        throw new Error('error')
    }).toThrow('error');
});

5. Jest命令行工具

image.png

默认模式下,Jest每次测试都会重新执行所有的test()测试用例,但是当测试用例数量很多时,就会浪费时间和性能。针对此情况,Jest提供了一些命令行指令供用户选择不同测试模式。

image.png

  • f模式:Press f to run only failed tests. 仅会重新测试上一次test时出错的用例。使用时只需要在修改fail test前,按下键盘f键,再次点击可以退出f模式

image.png

  • o模式:Press o to only run tests related to changed files. 相当于jest --watch,仅会重新测试本次修改涉及到的文件中的所有测试用例。使用时只需按下键盘o键,再次点击可以退出o模式

⚠️o模式基于git管理项目代码,因为o模式下需要记录一次修改涉及到的文件列表,而git可以记录文件的修改、新增、删除等操作。

  • t模式:Press to filter by a test name regex pattern. 根据test name过滤测试用例,只重新执行过滤后的测试用例

image.png

  • p模式:Press p to filter by a filename regex pattern. 根据filename过滤测试用例,只重新执行过滤后的测试用例。

⚠️p模式必须和--watchAll结合使用

6. Jest测试异步代码

1)第一种:有回调函数

import axios from 'axios';

export const fetchData = (callback)=>{
    axios.get('http://www.dell-lee.com/react/api/demo.json').then((result)=>{
        callback(result.data);
    })
}

回调函数的执行属于异步,如果直接进行expect断言则会拿不到执行结果,所以需要将断言的代码执行推迟。test回调函数中的done属性,表示回调函数中的逻辑异步执行。

import { fetchData } from './fetchData';

test('fetchData 返回结果测试',(done)=>{
    fetchData((data)=>{
        expect(data).toEqual({
            success: true
        });
        done();
    })
})

image.png

2)第二种:返回promise对象

export const fetchData = ()=>{
    return axios.get('http://www.dell-lee.com/react/api/demo.json');
}
  • 可以使用.then进行测试
test('fetchData 返回结果测试',()=>{
    return fetchData().then((result)=>{
        expect(result.data).toEqual({ success: true })
    })
})
  • 可以使用.catch进行测试时,但是需要结合expect.assertions(1)确保当异步代码执行成功时,也会进行catch的测试
test('fetchData 返回结果测试',()=>{
    expect.assertions(1);
    return fetchData().catch((e)=>{
        expect(e.toString().indexOf('404') > -1).toBe(true)
    })
})
  • 可以使用.resolves.toMatchObject进行promise对象的测试
test('fetchData 返回结果测试',()=>{
    return expect(fetchData()).resolves.toMatchObject({
        data:{ success: true }
    })
})
  • 可以使用.rejects.toThrow进行promise对象的测试
test('fetchData 返回结果测试',()=>{
    return expect(fetchData()).rejects.toThrow()
})
  • 可以使用await/async进行测试,代替done()
test('fetchData 返回结果测试',async ()=>{
    await expect(fetchData()).resolves.toMatchObject({
        data:{ success: true }
    })
})

test('fetchData 返回结果测试',async ()=>{
    const result = await fetchData();
    return expect(result.data).toMatchObject({ success: true })
})

对于异步代码的测试用例形式多种多样,可以根据项目要求及个人习惯,自行选择。

7. Jest的钩子函数

钩子函数指的是在Jest测试过程中会自动执行的函数,和Vue/React中的生命周期钩子函数含义相同。

  • beforeAll:所有测试用例执行前触发
  • afterAll:所有测试用例执行完成后触发
  • beforeEach:单个测试用例执行前触发
  • afterEach:单个测试用例执行后触发
beforeAll(()=>{
    console.log('beforeAll');
});
beforeEach(()=>{
    console.log('beforeEach');
});
afterEach(()=>{
    console.log('afterEach');
});
afterAll(()=>{
    console.log('afterAll');
});

image.png

我们尝试利用钩子函数对class类中的方法进行测试:

export class Counter {
    constructor(){
        this.number = 0
    }
    add(){
        this.number += 1;
    }
    minus(){
        this.number -= 1;
    }
}
import { Counter } from './Counter';

let counter = null;

beforeEach(()=>{
    counter = new Counter();
});

test('测试add方法',()=>{
    counter.add();
    expect(counter.number).toBe(1);
});

test('测试minus方法',()=>{
    counter.minus();
    expect(counter.number).toBe(-1);
})

image.png

Jest提供了一个describe钩子用于对不同模块、不同分类的测试用例进行归纳整理,并在控制台及覆盖率文件中有所体现。

describe('测试Counter相关方法', () => {
    let counter = null;
    beforeEach(() => {
        counter = new Counter();
    });
    describe('测试加法相关代码',()=>{
        test('测试add方法', () => {
            counter.add();
            expect(counter.number).toBe(1);
        });
    })
    describe('测试减法相关代码',()=>{
        test('测试minus方法', () => {
            counter.minus();
            expect(counter.number).toBe(-1);
        })
    })
})

image.png

每一个describe中都可以嵌套多个钩子函数以及新的describe,内部嵌套的的钩子函数和describe的执行顺序是由外到内执行,而且每一个describe内的钩子函数只对当前describe内的test有效。

describe('第一层',()=>{
    beforeAll(()=>{});
    test('',()=>{});
    
    describe('第二层-1',()=>{
        beforeAll(()=>{});
        test('',()=>{});
        
        describe('第三层',()=>{
            beforeAll(()=>{});
            test('',()=>{});
            ... ...
        })
    })
    // 第二层-1内的beforeAll对这里不起作用
    describe('第二层-2',()=>{
        beforeAll(()=>{});
        test('',()=>{});
    })
})

8. Jest的Mock

8.1 基本的mock

通过Jest.fn()生成一个Mock函数,该函数的执行可以被Jest追踪并捕获到。

Jest.fn()返回的Mock函数的格式大致如下所示:

image.png

其中`mock`属性上包含了一些可以用于判断的属性值:

image.png

  • calls:被调用的次数,[]数组参数表示被调用时传递的参数。如[[1],[2]]则表示一共被调用了两次,第一次传递参数为1,第二次传递参数为2
  • instances:mock函数被调用次数,以及每次调用的this指向
  • invocationCallOrder:mock函数执行的顺序,异步执行还是同步执行。如[1,2,3]则表示共执行三次,每次都是按照顺序执行;[1,3,2]则表示共执行三次,第3次早于第2次执行
  • results:函数类型及返回值

利用该Mock函数可以对项目内编写的回调函数进行测试。

export function demo(callback){
    callback();
}
import { demo } from './demo';

test('测试demo函数是否正确执行',()=>{
    // 生成mock函数
    const func = jest.fn();
    // 将mock函数作为参数传递给被测试函数
    demo(func);
    // 根据mock函数是否执行判断被测试函数的正确性,toBeCalled表示是否被执行
    expect(func).toBeCalled();
})

1)可以根据calls属性进行测试

// mock函数是否被调用了一次
expect(func.mock.calls.length).toBe(1);

// mock函数第一次执行时的参数是否为25
expect(func.mock.calls[0]).toEqual([25]);

2)可以根据results属性进行测试

// 设置mock函数返回值
func.mockReturnValue('hello');

// 根据返回值判断mock函数是否正确执行
expect(func.mock.results[0].value).toBe('hello');

对mock函数设置返回值的API包括以下:

  • mockReturnValue:设置单一返回值,不管调用几次返回值都是固定值
  • mockReturnValueOnce:设置一次的返回值,返回值仅能在单次调用的返回值中使用
func.mockReturnValueOnce('1')
    .mockReturnValueOnce('2')
    .mockReturnValueOnce('3');
// 第一次调用返回'1'
// 第二次调用返回'2'
// 第三次调用返回'3'
// 第四次调用无返回值,为undefined

‼️ mock函数的作用可以归纳总结为以下几点:

  • 捕获函数调用和返回结果、this指向、调用顺序、接收参数
  • 自定义函数返回值
  • 改变函数内部实现

🚀 mock函数有很多扩展功能的API,以下是部分举例:

  • 基于已有函数封装mock函数:
func.mockImplementation(()=>{
    // 逻辑设置... 
    // 返回值设置...
    return '1'
})
  • 基于已有函数封装单次的mock函数:
func.mockImplementationOnce(()=>{
    // 逻辑设置... 
    // 返回值设置...
    return '1'
})
  • 设置mock函数返回值为this
func.mockReturnThis()

8.2 模块的mock

  • 第一种:使用jest.mock自动mock模块

在项目中对于后端接口的测试也可以使用mock函数进行,其优势是不会对接口发起请求,而是改写前端fetch函数内的逻辑,模拟接口并设置返回值,而对于接口能否成功执行的测试应有后端同事进行单元测试。

前端通过axios封装请求接口的方法:

export function getData(){
    return axios.get('/api').then((res)=> res.data);
}

通过jest.mock模拟axios内部的逻辑实现,并设置返回值

jest.mock('axios'); // 自动mock axios

test.only('测试getData函数是否正确执行', async()=>{
    axios.get.mockResolvedValue({data:'hello'});
    await getData().then((data)=>{
        return expect(data).toBe('hello');
    })
})
  • 第二种:jest.mock()直接在单元测试里面mock模块
jest.mock('fs',()=>{
    readFileSync: jest.fn();
})

8.3 类的mock

项目中如果对一个功能很复杂的类如果编写测试用例时,不仅耗费成本,还很耗费性能。为此可以通过mock模拟一个简单的类,从而对类整体或者内部的方法进行单独测试。

假设目前有一个功能很复杂的类:

export default class Utils{
    constructor(){
        this.number = 0;
    }
    add(a,b){
        return a + b
    }
    minus(a,b){
        return a - b
    }
    noun(a,b){
        return a * b
    }
}

1)自动模拟:通过jest.mock([path])模拟类

Jest会将jest.mock([path])语法的执行提升到当前文件的最上方,并且在底层自动对path路径指向的类作如下处理:

  • 将类本身转换为mock函数:const Util = jest.fn()
  • 将类内部的方法转换为mock函数:const Util.a = jest.fn()
// 模拟类
jest.mock('./util');

import { demoFunction } from './demo';
import Utils from './util';

test('测试类',()=>{
    demoFunction();
    // 测试类是否被实例化
    expect(Utils).toHaveBeenCalled();
    // 测试类的add方法是否被调用
    expect(Utils.mock.instances[0].add).toHaveBeenCalled();
    // 测试类的minus方法是否被调用
    expect(Utils.mock.instances[0].minus).toHaveBeenCalled();
})

2)主动模拟:根目录下创建__mocks__文件夹,并在内部创建模拟文件。主动模拟支持自定义内部逻辑,使用更加灵活

const Utils = jest.fn(()=>{
    console.log('Utils');
});
Utils.add = jest.fn(()=>{
    console.log('add');
});
Utils.minus = jest.fn(()=>{
    console.log('minus');
});
export default Utils;

对于类的测试,并不关心类中某个方法的返回值是否正确,而是关心类是否能被实例化、类中的方法是否能被调用等,所以在测试类时可以考虑使用mock模拟,从而提高性能。

第二章 Jest进阶

1. snapshot快照测试

1.1 toMatchSnapshot()

Jest的toMatchSnapshot用于进行快照测试,首次调用该方法时,会根据函数返回值生成快照并保存到根目录下,以后每次修改函数时都会将函数返回值和快照进行比较,当比较结果不同时,会提示测试不通过以及修改快照等一系列操作提示信息。

snapshot快照测试适合对配置文件等有大量变量的文件进行测试。

我们尝试对下面的函数进行测试:

export function snapTest(){
    return {
        name: '小明',
        age: 18,
        sex: "M",
        email: "123456789@qq.com",
        phone: 15112345678
    }
}
import { snapTest } from './snapDemo';

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

image.png

修改snapTest方法,增加一条返回值,test测试将会报错:

school: "北大光华"

image.png

可以根据提示信息,切换模式对快照报错进行处理

image.png

  • u模式:直接更新所有出错的快照
  • i模式:进入编辑模式,按照顺序修改出错的快照

image.png

对于随时变化的属性值的测试,可以在调用toMatchSnapshot()时传递配置项

// 测试属性time每次test时均不相等
time: new Date()

// 添加配置项
toMatchSnapshot({
    time: expect.any(Date) // 匹配任意Date类型数据
})

1.2 toMatchInlineSnapshot()

Jest提供的toMatchInlineSnapshot(propertyMatchers?, inlineSnapshot)会将生成的快照放于当前test()方法内部,所以被称为行内快照。

test("测试snapTest1", () => {
    expect(snapTest1()).toMatchInlineSnapshot(`
        Object {
            "age": 18,
            "name": "小明",
            "sex": "M",
        }
    `);
});

2. 定时器timer测试

Jest中针对setTimeout、setInterval等宏任务的测试用例编写,可以通过done()表示异步任务执行完毕,也可以通过mock timer实现。

2.1 常规实现

首先编写setTimeout函数用于被测试:

export function timer(callback){
    setTimeout(()=>{
        callback();
    },3000)
}

然后编写测试用例:

import { timer } from './timer';

test('测试test异步函数',(done)=>{
    timer(()=>{
        expect(1).toBe(1);
        done(); // 内部代码异步执行
    })
})

2.2 useFakeTimers

import { timer } from './timer';

// 指定jest在下面的代码中使用假的定时器
jest.useFakeTimers();

test('测试test异步函数',()=>{
    // 创建可追踪的函数
    const fn = jest.fn();
    // 调用函数
    timer(fn);
    // 立即执行任务队列中所有的任务
    jest.runAllTimers();
    // 判断mock函数被调用的确切次数
    expect(fn).toHaveBeenCalledTimes(1);
});

jest.useFakeTimers()用于指定Jest使用假的定时器,通过jest.fn()创建可追踪的函数。

jest.runAllTimers()指的是执行完所有挂起的宏任务和微任务队列,如果某个任务本身调度了新的任务,那么这些任务将不断被耗尽,直到队列中没有剩余的任务为止。

.toHaveBeenCalledTimes(number)用于断言mock函数被调用的确切次数。

Jest中对于任务队列的执行规则设置有很多种,如下所示:

  • jest.runOnlyPendingTimers():仅立即执行当前挂起的宏任务
  • jest.runAllTimers():立即执行任务队列中所有任务
  • jest.advanceTimersByTime(msToRun):将当前挂起的下一个宏任务提前一定时间执行,该时间和宏任务的delay应该保持一致。连续调用的advanceTimersByTime,其时间在上一个基础上进行累计
import { timer } from './timer';

jest.useFakeTimers();

test('测试test异步函数',()=>{
    const fn = jest.fn();
    timer(fn);
    // 根据需要测试的setTimeout函数的延迟时间设置delay
    jest.advanceTimersByTime(3000);
    expect(fn).toHaveBeenCalledTimes(1);
});

3. 操作Dom节点的测试

假设现在有一个函数,每次调用都会在body下面追加一个div标签

import $ from 'jquery';

export const appendDiv = ()=>{
    $('body').append('<div />');
}

利用test对该方法进行测试

import $ from 'jquery';
import { appendDiv } from './demo';

test('测试appendDiv方法',()=>{
    appendDiv();
    // 判断body下div的数量与函数调用次数是否相等
    expect($('body').find('div').length).toBe(1);
})

第三章 TDD与BDD

1. TDD

TDD全称为“Test Driven Development”,意为测试驱动的开发。

  • 第一步:基于需求,拆分功能,编写多个测试用例
  • 第二步:运行测试用例,测试用例无法通过
  • 第三步:编写代码,使测试用例通过
  • 第四步:优化代码,完成功能开发
  • 第五步:重复上述步骤,完成需求开发

TDD的开发流程可以概括为测试用例从红到绿的过程,所以TDD又被称为“Red-Green development”。

image.png

TDD测试模式的特点有以下几点:

  • 减少项目迭代过程中回归的bug
  • 代码质量更高
  • 测试覆盖率高
  • 错误的测试代码不易出现

2. BDD

BDD全称为“Behavior Driven Development”,意为行为驱动的开发。

BDD编写测试用例时,先开发业务需求和功能,然后再考虑测试用例的编写。要求开发人员站在测试角度去编写测试用例,需要对业务需求和交互逻辑有所掌握。BDD鼓励软件项目中的开发者,QA和非技术人员或商业参与者之间共同协作。

  • 第一步:完成需求功能开发
  • 第二步:站在用户角度编写测试用例

image.png

BDD集成测试特点如下:

  • 测试重点在UI(DOM)
  • 安全感高
  • 速度慢

3. TDD&BDD

⭐️ TDD

  • 先写测试再写代码
  • 一般结合单元测试使用,属于白盒测试
  • 测试重点在代码
  • 安全感低(测试用例通过不代表业务逻辑通过)
  • 速度快(shallow)

⭐️ BDD

  • 先写代码,再写测试
  • 一般结合集成测试使用,属于黑盒测试
  • 测试重点在UI(DOM)
  • 安全感高
  • 速度慢(mount)

第四章 Jest在React中的基本使用

1. React环境中配置Jest

通过Create React App安装的React项目中,默认配置了Jest,不需要在单独下载相关依赖。对于Jest的全局配置,可以在package.json文件或者根目录下的jest.config.js文件均可。

image.png

React会自动生成一些配置项,对于这些配置项也可以在自己创建的项目中使用。了解每个配置项的含义和作用,有助于我们加深对Jest使用及性能的了解。

  • collectCoverageFrom:统计项目中哪些文件的测试覆盖率,及项目测试时忽略哪些文件
"collectCoverageFrom": [
    "src/**/*.{js,jsx,ts,tsx}", // 测试js、jsx、tsx后缀的文件
    "!src/**/*.d.ts" // 不测试.d.ts后缀的文件
]
  • setupFiles:测试前的一些准备工作
"setupFiles": [
    "react-app-polyfill/jsdom" // 利用polyfill垫片对jsdom进行处理
]
  • testMatch:运行npm run test时执行的测试文件
"testMatch": [
    "<rootDir>/src/**/__tests__/**/*.{js,jsx,ts,tsx}",
    "<rootDir>/src/**/*.{spec,test}.{js,jsx,ts,tsx}"
]
  • transform:对于某些后缀文件的转换规则
"transform": {
    "^.+\\.(js|jsx|mjs|cjs|ts|tsx)$": "<rootDir>/config/jest/babelTransform.js",
    "^.+\\.css$": "<rootDir>/config/jest/cssTransform.js",
    "^(?!.*\\.(js|jsx|mjs|cjs|ts|tsx|css|json)$)": "<rootDir>/config/jest/fileTransform.js"
}
  • transformIgnorePatterns:忽略对某些后缀文件的转换
"transformIgnorePatterns": [
    "[/\\\\]node_modules[/\\\\].+\\.(js|jsx|mjs|cjs|ts|tsx)$", // 忽略node包中的文件
    "^.+\\.module\\.(css|sass|scss)$"
]

2. Enzyme配置

Enzyme是由Airbnb开发的一个React测试工具,Enzyme在底层对ReactDOM做了封装,并暴露出很多API,可以直接使用Enzyme代替ReactDOM,编写测试用例。

Enzyme具有以下特点:

  • 可以模拟组件的渲染和交互
  • 支持多种组件的测试,包括函数组件和类组件
  • 提供了丰富的选择器,可以帮助我们选择组件中的元素
  • 支持快照测试,可以帮助我们检查组件的渲染结果是否正确

首先需要下载安装Enzyme:

npm install Enzyme

添加Enzyme配置:

import Enzyme, { shallow } from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';

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

1)shallow:对组件的浅渲染

import Enzyme, { shallow } from 'enzyme';
// 对App组件进行浅渲染
const wrapper = shallow(<App />);

shallow浅渲染只会对当前组件进行渲染测试,对于内层组件不会进行处理,一般用于单元测试。

利用shallow对下面的App组件进行测试:

function App() {
    return (
        <div className="app-container" title='dell lee'>
            hello world !
        </div>
    );
}

可以对className属性和title属性进行测试:

const wrapper = shallow(<App />);
expect(wrapper.find('.app-container').length).toBe(1);
expect(wrapper.find('.app-container').prop('title')).toBe('dell lee');

⚠️如果在项目中使用className或者style等属性进行测试,则会造成测试和业务代码紧耦合,如果由于业务需求更改了className/style等属性的名称,则会导致测试用例无法通过。所以在项目中可以使用自定义属性[data-xxx=xxx]的方式编写测试用例

expect(wrapper.find('[data-test="container"]').length).toBe(1);
expect(wrapper.find('[data-test="container"]').prop('title')).toBe('dell lee');

💡Enzyme可以结合很多第三方插件来扩展其功能,其中jest-enzyme是针对jest的使用,利用jest-enzyme可以简化很多Enzyme的操作

const wrapper = shallow(<App />);
const container = wrapper.find('[data-test="container"]');
// expect(container.length).toBe(1);
expect(container).toExist();
// expect(container.prop('title')).toBe('dell lee');
expect(container).toHaveProp('title', 'dell lee');

2)mount:对组件的全渲染

mount全渲染会对当前组件及其子组件等都进行渲染测试,一般用于多个组合组件功能的集成测试。

import React from 'react';
import Enzyme, { mount } from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';

import App from './App';

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

it('renders without crashing', () => {
    const wrapper = mount(<App />);
    const container = wrapper.find('[data-test="container"]');
    // expect(container.length).toBe(1);
    expect(container).toExist();
    // expect(container.prop('title')).toBe('dell lee');
    expect(container).toHaveProp('title', 'dell lee');
});

3. 基于TDD测试React组件

假设我们的项目需求如下图所示,一个简单的todoList组件。根据业务逻辑将其拆分成Header组件和TodoList组件。

image.png

3.1 Header组件测试用例

1)第一步:创建Header.test.js文件,并编写测试用例。

🤔☁️Header组件应该包括一个input输入框

it('组件中包含输入框', () => {
    const wrapper = shallow(<Header />); // 单元测试使用浅渲染即可
    const inputElem = wrapper.find("[data-test='input']");
    expect(inputElem.length).toBe(1);
});

2)第二步:执行测试用例,控制台肯定会报错

image.png

3)第三步:编写Header组件内容,添加input输入框

render() {
    return ( 
        <div> <input data-test='input'/> </div>
    )
}

4)第四步:重新执行测试,显示测试通过

image.png

以上就是一个完整的TDD单元测试流程,按照该流程继续完善Header组件的测试用例和功能代码即可。💪📣

🤔☁️Header中的Input组件应该是一个受控组件,具有value属性

// Header.test.js
it('输入框内容初始为空', () => {
    const wrapper = shallow(<Header />);
    const inputElem = wrapper.find("[data-test='input']");
    expect(inputElem.prop('value')).toEqual('');
});
// Header.js
constructor() {
    this.state = { value: '' }
}

render() {
    const { value } = this.state;
    return (
        <div>
            <input
                data-test='input'
                value={value}
            />
        </div>
    )
}

🤔☁️当用户输入时,Header中的Input组件的value值应该跟随变化

// Header.test.js
it('输入框内容随用户输入变化', () => {
    const wrapper = shallow(<Header />);
    const inputElem = wrapper.find("[data-test='input']");
    const userInput = '今天要学习 Jest';
    // 利用Enzyme组件库中的API模拟输入框的change事件
    inputElem.simulate('change', {
        target: { value: userInput}
    });
    expect(wrapper.state('value')).toEqual(userInput);
});
// Header.js
handleInputChange(e) {
    this.setState({
        value: e.target.value
    })
}

render() {
    return (
        <div>
            <input
                data-test='input'
                value={value}
                onChange={this.handleInputChange}
            />
        </div>
    )
}

🤔☁️ Input输入框内无内容时,按键盘回车键应该无任何反应

it('输入框无内容时触发回车事件,无反应', () => {
    // Header接收父组件传递过来的函数,执行待办事项保存功能
    const fn = jest.fn();
    const wrapper = shallow(<Header addUndoItem={fn} />);
    const inputElem = wrapper.find("[data-test='input']");
    // 设置输入框初始值为空字符串
    wrapper.setState({ value: ''})
    // 模拟输入框的回车事件
    inputElem.simulate('keyUp', {
        keyCode: 13
    });
    expect(fn).not.toHaveBeenCalled();
});

🤔☁️ Input输入框内有内容时,按键盘回车键应该执行父组件传递过来的事件

it('输入框有内容被触发时,外部传入的函数被调用', () => {
    const fn = jest.fn();
    const wrapper = shallow(<Header addUndoItem={fn} />);
    const inputElem = wrapper.find("[data-test='input']");
    const userInput = '学习 React';
    wrapper.setState({ value: userInput})
    inputElem.simulate('keyUp', {
        keyCode: 13
    });
    expect(fn).toHaveBeenCalled();
    expect(fn).toHaveBeenLastCalledWith(userInput);
})

3.2 TodoList组件测试用例

🤔☁️ TodoList组件应该具备一个输入列表,并且初始化时应该为空

it('初始化列表为空', () => {
    const wrapper = shallow(<TodoList />);
    expect(wrapper.state('undoList')).toEqual([]);
});

🤔☁️ TodoList应该给Header传递一个增加undoList内容的方法

it('Header组件存在addUndoItem属性', () => {
    const wrapper = shallow(<TodoList />);
    const Header = wrapper.find('Header');
    expect(Header.prop('addUndoItem')).toBe(wrapper.instance().addUndoItem);
});

🤔☁️ 当Header按回车时,undoList应该新增内容

it('addUndoItem方法被调用, undoList数据项增加', () => {
    const wrapper = shallow(<TodoList />);
    const Header = wrapper.find('Header');
    const addFunc = Header.prop('addUndoItem');
    addFunc('学习 React');
    expect(wrapper.state('undoList').length).toBe(1);
    expect(wrapper.state('undoList')[0]).toBe('学习 React');
    addFunc('学习 React 2');
    expect(wrapper.state('undoList').length).toBe(2);
});

3.3 TodoListItem组件测试用例

🤔☁️ 初始的未完成列表为空数组,count为0

it('未完成列表当数据为空数组时 count 数目为0, 列表无内容', () => {
    const wrapper = shallow(<UndoList list={[]}/>);
    const countElem = wrapper.find('[data-test="count"]');
    const listItems = wrapper.find('[data-test="list-item"]');
    expect(countElem.text()).toEqual("0");
    expect(listItems.length).toEqual(0);
});

🤔☁️ 修改后的未完成列表不为空,count大于0

it('未完成列表当数据有内容时count数目显示数据长度, 列表不为空', () => {
    const listData = ['学习Jest', '学习TDD', '学习单元测试'];
    const wrapper = shallow(<UndoList list={listData}/>);
    const countElem = findTestWrapper(wrapper, "count");
    const listItems = findTestWrapper(wrapper, "list-item");
    expect(countElem.text()).toEqual("3");
    expect(listItems.length).toEqual(3);
});

🤔☁️ 在未完成列中点击删除按钮,可以删除单条数据

it('未完成列表当数据有内容时,点击某个删除按钮,会调用删除方法', () => {
    const listData = ['学习Jest', '学习TDD', '学习单元测试'];
    const fn = jest.fn();
    const wrapper = shallow(<UndoList deleteItem={fn} list={listData}/>);
    const deleteItems = findTestWrapper(wrapper, "delete-item");
    deleteItems.at(1).simulate('click');
    expect(fn).toHaveBeenLastCalledWith(1);
});

3.4 对样式采用快照测试

// Header.test.js文件

it('Header 渲染样式正常', () => {
    const wrapper = shallow(<Header />);
    expect(wrapper).toMatchSnapshot();
});

image.png

4. 基于BDD测试React组件

利用BDD思想对项目进行测试时,站在用户的角度,页面中输入框回车增加待办事项属于核心功能,所以我们针对该功能进行测试:

it(`
    1. Header 输入框输入内容
    2. 点击回车
    3. 列表中展示用户输入的内容项
`, () => {
    const wrapper = mount(<TodoList />);
    const inputElem = wrapper.find('[data-test="header-input"]');
    const content = "Dell Lee";
    // 模拟input输入框改变事件
    inputElem.simulate('change', {
        target: {value: content}
    });
    // 模拟输入框回车事件
    inputElem.simulate('keyUp', {
        keyCode: 13
    });
    const listItem = wrapper.find('[data-test="list-item"]');
    // 测试list列表数据量和数据内容
    expect(listItem.length).toEqual(1);
    expect(listItem.text()).toContain(content);
});

5. 基于BDD测试Redux功能

如果使用TDD进行redux的测试,那么action,reducer等都需要编写测试用例,代码量大,也不能保证数据流正确。所以推荐使用BDD来进行Redux的测试。

首先将todoList案例中的数据保存到store中:

function Headers(Props) {
    const { onAddItem } = props;
    const dispatch = useDispatch();
    const [value, setValue] = React.useState("");
    return (
        <div className="Header">
          <input
            className="header-input"
            type="text"
            data-test="input"
            value={value}
            onChange={(e) => {...}}
            onKeyUp={(e) => {...}}
          />
        </div>
    );
}
function TodoList() {
    const { items } = useSelector((state: any) => ({ 
        items: state.todo.items 
    }));
    
    return (
        <div>
          <Headers />
          {items.map(item => (
              <div data-test="list-item" key={item}>{item}</div>
          ))}
      </div>
  );
}

然后编写业务逻辑测试用例:

import React from 'react';
import { Provider } from 'react-redux';
import { mount } from 'enzyme';
import TodoList from '../../index';
import { findTestWrapper } from '../../../../utils/testUtils';
import store from '../../../../store/createStore';

it(`
    1. Header输入框输入内容
    2. 点击回车
    3. 列表中展示用户输入的内容项
`, () => {
    // 完全渲染组件时需要在外层包裹store
    const wrapper = mount(<Provider store={store}><TodoList /></Provider>);
    const inputElem = wrapper.find('[data-test="header-input"]');
    const content = "Dell Lee";
    inputElem.simulate('change', {
        target: {value: content}
    });
    inputElem.simulate('keyUp', {
        keyCode: 13
    });
    const listItem = wrapper.find('[data-test="list-item"]');
    expect(listItem.length).toEqual(1);
    expect(listItem.text()).toContain(content);
});

对比state和store测试用例的区别,在于对组件渲染时需要包裹Provider并传递store属性

// state
const wrapper = mount(<TodoList />);
// store
const wrapper = mount(
    <Provider store={store}><TodoList /></Provider>
);

6. 异步代码的测试

在todoList组件中实现一个功能,在页面初始化的时候请求后台数据并用于展示List。

componentDidMount() {
    axios.get('/undoList.json').then((res) => {
        this.setState({
            undoList: res.data
        })
    }).catch((e) => {
        console.log(e)
    })
}

编写测试用例:

🚗第一步:模拟axios

const mockUndoList = {
    data: [{
        status: 'div',
        value: 'dell lee hello'
    }],
    success: true
};

export default {
    get(url) {
        if(url === '/undoList.json') {
            return new Promise((resolve, reject) => {
                resolve(mockUndoList)
            });
        }
    }
}

🚗第二步:编写测试用例

it(`
    1. 用户打开页面
    2. 请求接口
    3. 展示接口返回的数据
`, (done) => {
    const wrapper = mount(
        <Provider store={store}><TodoList /></Provider>
    );
    setTimeout(() => {
        wrapper.update();
        const listItem = findTestWrapper(wrapper, 'list-item');
        expect(listItem.length).toBe(1);
        done();
    });
});

image.png

第五章 总结

1. 目录架构优化

以React项目为例,一个React项目一般由react-router、Redux、Jest等生态周边组成,为了使项目目录结构更清晰,测试用例文件编写更方便,可以按照以下结构进行目录设置。

image.png image.png

2. 前端自动化测试优点

前端自动化测试的优势有以下几点:

  • 更高的组织代码,提高项目的可维护性
  • 降低出现Bug的概率,尤其是回归Bug
  • 提高修改工程项目的安全性
  • 项目具有潜在的文档特性
  • 利于扩展开发人员知识面

3. 前端自动化测试分类

  • 单元测试:测试单个功能能否正常使用
  • 集成测试:测试完整的业务逻辑功能
  • 边界测试:测试组件在输入或输出达到极限或边界条件时的行为
  • 响应测试:测试UI组件在不同的设备或屏幕尺寸下的行为
  • 交互测试:测试UI组件在不同操作下的交互行为
  • 异常测试:测试组件在遇到错误或非法输入时能否正确处理
  • 性能测试:测试组件的性能