前端单元测试实践

226 阅读10分钟

背景:上周组内在进行如何提高前端的代码质量的话题做了头脑风暴、提出的观点很多,比如:加强codeReview、标明测试回归的范围啊等等、其中一点提高了单元测试、虽然这个东西出现了很久但是鲜有在业务迭代中使用、不过学学还是好的、不然出去都不知道这是个啥岂不是很尴尬?

好吧 言归正传、让我们开启单元测试之旅

单元测试是什么?

单元测试 是针对 程序的最小单元 来进行正确性检验的测试工作。程序单元是应用的最小可测试部件。一个单元可能是单个程序、类、对象、方法等。 --维基百科

单元测试能解决什么问题?

  • 较少的bug
  • 能够快速定位问题
  • 提高代码的质量
  • 减少调试的时间成本
  • 为以后更好的重构
  • ...

前端领域的现状

     听过翻阅资料得知、前端使用比较多的主要为MochaJest 、就star来说Jest可以说是遥遥领先、且生态比较丰富、接下来展开看一下jest是如何得到前端同学们的青睐的。

hello Jest

image.png 通过上图不难发现 Jest的无需配置快照隔离性接口模拟等都是非常亲民的、上手非常快基本是零基础完成可以驾驭。话不多说直接上手。

准备环境

mkdir Jest-1 && cd Jest-1 && npm init -y

yarn add --dev jest 
// or
npm install --save-dev jest
// package.json
{
  "name": "jese-1",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "jest"
  },
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "jest": "^27.5.1"
  }
}

上述代码直接在本地创建了一个Jest-1的文件夹,然后手动安装Jest,在根目录创建一个__tests__的目录

/__tests__/sum.test.js

function sum(a, b) {
    return a + b;
}

it('加法测试', () => {
    expect(sum(1,2)).toBe(3)
})

test('两个数组是否相等', () => {
    expect([1,2,3]).toStrictEqual([1,2,3]);
});

test('两个数组一定不相等', () => {
    expect([1,2,3]).not.toStrictEqual([5]);
});

test('小数点相加', () => {
    const value = 0.1 + 0.2;
    expect(value).toBeCloseTo(0.3);
});
test('是否等于null', () => {
    expect(null).toBeNull(null);
});

执行:yarn test or npm run test 就可以看到效果;就这么简单;
你可能不知道那些toBetoStrictEqualtoStrictEqual是什么意思 没关系!直接转到文档一探究竟、如果你之前了解过jQuery的话、那么对链式调用一定很熟悉、通过以上代码基本的简单的测试流程、接下来只是需要我们了解它的基本语法和配置即可。

Jest的生命周期

  • describe(name, fn):描述块,讲一组功能相关的测试用例组合在一起
  • it(name, fn, timeout):别名test,用来放测试用例
  • afterAll(fn, timeout):所有测试用例跑完以后执行的方法
  • beforeAll(fn, timeout):所有测试用例执行之前执行的方法
  • afterEach(fn):在每个测试用例执行完后执行的方法
  • beforeEach(fn):在每个测试用例执行之前需要执行的方法

代码示例:

beforeAll(() => console.log('1 - beforeAll'));
afterAll(() => console.log('1 - afterAll'));
beforeEach(() => console.log('1 - beforeEach'));
afterEach(() => console.log('1 - afterEach'));
test('', () => console.log('1 - test'));
describe('Scoped / Nested block', () => {
  beforeAll(() => console.log('2 - beforeAll'));
  afterAll(() => console.log('2 - afterAll'));
  beforeEach(() => console.log('2 - beforeEach'));
  afterEach(() => console.log('2 - afterEach'));
  test('', () => console.log('2 - test'));
});

// 1 - beforeAll
// 1 - beforeEach
// 1 - test
// 1 - afterEach
// 2 - beforeAll
// 1 - beforeEach
// 2 - beforeEach
// 2 - test
// 2 - afterEach
// 1 - afterEach
// 2 - afterAll
// 1 - afterAll

    借用文档的中的例子走一下、基本生命周期的执行顺序一目了然,知道了生命周期的过程、那么对一些场景就有了解决方案,比如:

示例代码:

function foo() {
  console.log("每次测试之前都要调用我");
}

function bar(str) {
  const string = `hello Jest`;
  return string.indexOf(str) !== -1;
}

function isArrs(str) {
  const arrs = [1, 2, 3, 4, 5];
  return arrs.includes(str)
}

function descBefore () {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve('我在作用域中')
        },1000)
    })
}
beforeAll(() => console.log('啥也别说、我是第一个'));

beforeEach(() => {
  foo();
});

afterAll(() => console.log('总算全部都执行完了'));

test("是否存在字符串-1", () => {
  expect(bar("hello")).toBeTruthy();
});

it("是否存在字符串-2", () => {
  expect(bar("Jest")).toBeTruthy();
});

describe("执行作用域", () => {
  beforeEach(() => {
    // return descBefore()
  });

  test("是否存在数字1", () => {
    expect(isArrs(1)).toBe(true);
  });

  test("是否存在数字2", () => {
    expect(isArrs(2)).toBe(true);
  });
});

处理异步

    就前端而言、同步的执行逻辑并没有需要我们注意的、但是围绕这业务迭代,处理请求相关的数据进行页面的渲染才是我们经常遇到的、Jest这里也给我们提供了很多的方案:

示例代码-1:

 // src/template2/index.js

const time = 1000; // 阈值

// 超过请求的阈值进行限制:
// https://stackoverflow.com/questions/49603939/message-async-callback-was-not-invoked-within-the-5000-ms-timeout-specified-by
// jest.setTimeout(300000);

// 成功的状态
function fetchData() {
    const data = {
        code:1,
        massage:"成功",
        data:[
            {
                id:1,
                name:"北京"
            },
            {
                id:2,
                name:"青岛"
            }
        ]
    }
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            resolve(data)
        },time)
    })
}


// 失败的状态
function fetchErrorData() {
    const data = {
        code:1,
        massage:"成功",
        data:[
            {
                id:1,
                name:"北京"
            },
            {
                id:2,
                name:"青岛"
            }
        ]
    }
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            reject('Code error')
        },time)
    })
}

module.exports = {
    fetchData,
    fetchErrorData
};
// src/template2/promise.test.js

/**
 *  toEqual : 深度对比两个对象是否相等 < https://jestjs.io/zh-Hans/docs/expect#toequalvalue >
 *  toMatch : 匹配以检查字符串是否与正则表达式匹配。<https://jestjs.io/zh-Hans/docs/expect#tomatchregexp--string >
 * 
 */
const { fetchData, fetchErrorData }  = require('../../src/template2');
const _test = {
    code:1,
    massage:"成功",
    data:[
        {
            id:1,
            name:"北京"
        },
        {
            id:2,
            name:"青岛"
        }
    ]
}

// 测试成功的状态
test('测试接口返回成功的的状态-1',() => {
    
    return fetchData().then(data => {
        expect(data).toEqual(_test)
    })
})

// ! 测试失败的状态
test('测试异步数据是否返回失败',() => {
    expect.assertions(1);
    return fetchErrorData().catch(e => expect(e).toMatch('error'));
});


// 另外的方法实现:
test('测试接口返回成功的的状态-2', () => {
    return expect(fetchData()).resolves.toEqual(_test);
});

// Async/Await
test('测试接口返回成功的的状态-1',async () => {
    const result = await fetchData()
    expect(result).toEqual(_test)
})
test('测试异步数据是否返回失败', async () => {
    expect.assertions(1);
    try {
      await fetchErrorData();
    } catch (e) {
      expect(e).toMatch('error');
    }
});


经过简单的模拟 我就可以实现异步请求的操作、这里需要注意两个点:

  1. 进行promise异步操作的时候、一定返回return、具体可以自行查询文档
  2. 请求超过5000会出现报错,可以动态的设置 jest.setTimeout(300000); 详细资料

模拟函数

    通过上面的代码可以知道、虽然我们可以进行异步的请求、在实际的业务开发中、我们不可能等待接口开发完毕在进行单元测试的编写、一定是在完成某一段业务逻辑后编写相应的单元测试、这样的情况就造成了接口并没有完成、但是我需要模拟接口的请求、那么就需要用到了mock函数、当然mock函数不单单是为了模拟异步的数据形成、还有很多的功能、想要了解的小伙伴可以查看相应的文档

准备环境:

mkdir Jest-mock && cd Jest-mock && npm init -y 

yarn add  @babel/core  @babel/preset-env axios babel-jest jest json-server -D

.babelrc.js

module.exports = {
  // See https://babeljs.io/docs/en/babel-preset-env#targets
  presets: [['@babel/preset-env', {targets: {node: 'current'}}]],
};

db.json

db文件和json-server依赖相关联、本意是本地创建一个server环境、详细了解查阅相关文档

{
	"userRole":[
		{"roleId":"1","roleName":"超级管理员"},
		{"roleId":"2","roleName":"后台管理人员"}
	],
	"user":{
            "data":{
                "name":"demo",
                "age":"18"
            }
	}
}

package.json

{
  "name": "jese-2",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "jest",
    "server": "json-server --watch db.json"
  },
  "author": "",
  "license": "ISC",
  "dependencies": {},
  "devDependencies": {
    "@babel/core": "^7.17.5",
    "@babel/preset-env": "^7.16.11",
    "axios": "^0.26.0",
    "babel-jest": "^27.5.1",
    "jest": "^27.5.1",
    "json-server": "^0.17.0"
  }
}

以上环境配置完毕、创建业务代码: src/users.js

import axios from 'axios';

class Users {
  static all() {
    return axios.get('http://127.0.0.1:3000/user').then(resp => {
      return  resp.data
    });
  }
}

export default Users;

创建测试文件: Jest-mock/__tests__/users.test.js


import Users from '../users';
import axios from 'axios';

// jset 接管
jest.mock('axios');

const users2 = {
    "data":{
        "name":"demo",
        "age":"18"
    }
};
const users1 = {
  "data":{
      "name":"demo",
      "age":"18"
  }
};


it('测试接口', async () => {
  axios.get.mockResolvedValue(users1);
  const data = await Users.all()
  expect(data).toEqual(users2)
});

    通过以上的实例可以看到、Jest拦截了axios的接口状态、然后通过mockResolvedValue函数进行了模拟的操作进行了接口数据的拦截、虽然实现了数据的mock,但是这种方案不适合在项目里自定义接口请求模块

为了模拟我们自己手写的自定义的模块,我们可以这样:

    把之前的src/users.js放在创建好的moduls文件夹中、同时在当前的文件夹中创建__mocks__文件夹、里面存放同名的测试数据、结构如下:

替换前:

.
├── __tests__
│   └── users.test.js
├── db.json
├── package.json
├── users.js
└── yarn.lock

替换后:

├── __tests__
│   └── users.test.js
├── db.json
├── moduls
│   ├── __mocks__
│   │   └── users.js
│   └── users.js
├── package.json
└── yarn.lock

使用mock数据进行测试

//Jest-mock/__tests__/users.test.js

import Users from '../moduls/users';

// jset 接管
jest.mock('../moduls/users.js');

const usersInfo = {
    "data":{
        "name":"demo",
        "age":"18"
    }
};

it('测试接口', async () => {
  const data = await Users.all()
  expect(data).toEqual(usersInfo)
});

根据业务需求创建mock数据

// Jest-mock/moduls/__mocks__/users.js
module.exports={
  all(){
    return new Promise(function(resolve){
        resolve({
          "data":{
            "name":"demo",
            "age":"18"
          }
        })
      })
  }
}

    这样做的好处不言而喻、虽然本质上就是存放的位置不同、在相应的文档可以得到体现,维护性和业务清晰度都有了很高的提升,尤其在多人维护中往往可以提升很大的效率。

单元测试覆盖率图解

image.png

  • %Stmts是语句覆盖率(statement coverage):是不是每个语句都执行了?
  • %Branch分支覆盖率(branch coverage):是不是每个 if 代码块都执行了?
  • %Funcs函数覆盖率(function coverage):是不是每个函数都调用了?
  • %Lines行覆盖率(line coverage):是不是每一行都执行了?

Jest接入vue、react

目前不管是vue、还是react一些成熟的框架都继承了单元测试、比如:Vue-clinuxtjscreate-react-app等、需要注意的就是编写组件单元测试和常规单元测试的区别、其实原理大同小异、这里的我举一个简单的例子大家可以了解一下、详细的可以根据自己的业务进行翻阅,Vue Test Utils

Vue
// Jest-Vue/test/NuxtLogo.test.js

import { mount } from '@vue/test-utils'
import TimerTool from '@/components/sum/TimerTool.vue'


/**
 *  toContain: 检查一个字符串是另外一个字符串的子字符串 < https://jestjs.io/zh-Hans/docs/expect#tocontainitem > 
 *  wrapper : 常用方法集合 < https://v1.test-utils.vuejs.org/zh/api/wrapper/#%E5%B1%9E%E6%80%A7 >
 */
describe('检查组件是否正常的加载', () => {
  const wrapper = mount(TimerTool) // 获取vue的实例对象。

  it('是否正确的呈现正确的节点', () => {
    expect(wrapper.html()).toContain(`<span class="count">0</span>`)
  })
  
  it('是否存在button', () => {
    const button = wrapper.find('button')
    expect(button.exists()).toBe(true)
  })

})

describe('模拟用户的操作', () => {
  const wrapper = mount(TimerTool)
  
  // console.log(wrapper.html())
  it('检查默认值', () => {
    expect(wrapper.vm.count).toBe(0)
  })
  
  it('检查计数器的文本是否更新',() => {
    const button = wrapper.find('button');
    button.trigger('click')
    expect(wrapper.vm.count).toBe(1)
  })
})

详细了解可点击完整实例代码

React
// jest-react/src/hidden-message.test.js

import '@testing-library/jest-dom'
// NOTE: jest-dom adds handy assertions to Jest and is recommended, but not required

import * as React from 'react'
import {render, fireEvent, screen} from '@testing-library/react'
import HiddenMessage from './hidden-message'

test('shows the children when the checkbox is checked', () => {
  const testMessage = 'Test Message'
  render(<HiddenMessage>{testMessage}</HiddenMessage>)
  expect(screen.queryByText(testMessage)).toBeNull()
  fireEvent.click(screen.getByLabelText(/show/i))
  expect(screen.getByText(testMessage)).toBeInTheDocument()
})

详细了解可点击完整实例代码

单元测试带来什么影响

    说了这么多、其实我们应该知道不管什么模块、或者什么功能都有双面性、有利必有弊 只是我们在权衡两者的时候,选择了当前项目最优的结果。

场景受限
    要谨记单元测试不是万能的、并不能解决全部的问题、受限于测试范围和场景以及数据、只能满足单模块内部的功能验证的需求;比如: 对于一些异常的请求返回状态 收到三方的服务控制、根据无法得知进行单元测试的编写;

时间成本:
    简单的函数处理对其整个的需求排期或许影响不大、但是如果是整个需求链路的单元测试编写、那么耗费的时间成本将是开发排期的1/3左右

性能成本:
    不能解决或者发现模块可靠性、性能相关、多线程访问等相关的问题、这几类的问题还是从设计上分析、编码时需要注意;

编写单元测试的建议

  • 越重要的代码、越要写单元测试.
  • 代码做不到单元测试、多思考如何改进、而不是放弃.
  • 边写业务代码、边写单元测试、而不是完成整个功能在写.
  • 多思考如何改进、简化测试代码.

参考链接