Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

单元测试 #35

Open
chiyan-lin opened this issue May 18, 2023 · 4 comments
Open

单元测试 #35

chiyan-lin opened this issue May 18, 2023 · 4 comments

Comments

@chiyan-lin
Copy link
Owner

chiyan-lin commented May 18, 2023

什么是单元测试

单元测试(英语:Unit Testing)又称为模块测试 [来源请求] ,是针对程序模块软件设计的最小单位)来进行正确性检验的测试工作。

程序员每修改一次程序就会进行最少一次单元测试,在编写程序的过程中前后很可能要进行多次单元测试,以证实程序达到软件规格书要求的工作目标,没有程序错误

对于单元测试

刻板印象

谈到单测,可能大家的第一反应都是没啥用,没时间,写起来很麻烦

  • 单元测试浪费了太多的时间
  • 单元测试仅仅是证明这些代码做了什么
  • 我是很棒的程序员,我是不是可以不进行单元测试?
  • 后面的集成测试将会抓住所有的bug
  • 单元测试的成本效率不高我把测试都写了,那么测试人员做什么呢?
  • 公司请我来是写代码,而不是写测试测试代码的正确性,并不是我的工作

在日常开发需求或者一些工具的时候,比较普遍的做法还是完成一个函数或者组件,直接在浏览器或者控制台进行业务相关的调试,尽管这个组件或者这个函数本身是业务无关的。

单元测试是必要的

提高代码质量和可维护性,节省手动测试的时间

  • 放心重构 当进行架构升级进行代码重构,有单元测试的代码重构完成可以实现原本的输出对应正确的输入,让重构者放心
  • 认同感 在自己开发的时候就已经有了基础保障,接入的开发者也会更认同自己写出来的东西
  • 稳定性 单元测试是所有测试中最底层的一类测试,是第一个环节,也是最重要的一个环节,是唯一一次有保证能够代码覆盖率达到100%的测试,是整个软件测试过程的基础和前提,单元测试防止了开发的后期因bug过多而失控,单元测试的性价比是最好的。
  • 全面性 在编写测试用例的时候,我们可以根据业务情况,把所有各种边界的 case 都列出来,在开发之前就涵盖所有的情况,同时这也对软件开发者有一定的代码编写要求。

单元测试的分类

  • 小型测试,针对单个函数的测试,关注其内部逻辑,mock所有需要的服务。小型测试带来优秀的代码质量、良好的异常处理、优雅的错误报告
  • 中型测试,验证两个或多个制定的模块应用之间的交互大型测试,也被称为“系统测试”或“端到端测试”。
  • 大型测试在一个较高层次上运行,验证系统作为一个整体是如何工作的。

对于前端,我们的单元测试集中在两块,类库的函数单元测试和UI组件的单元测试两大类也就是中小型测试和大型测试。今天围绕如何进行测试,展开说下这两种测试我们应该怎么做。

函数单元测试

我们实现的各种方法大多是运行在浏览器端的,通过 node 提供的断言方法,有些场景我们很难去模拟,比如对 dom 的操作。前端单测有很多方案,这里我们选择 jest 作为测试框架,关于 jest ,不熟悉的同学可以先看下文档 https://jestjs.io/

安装

npm i --save-dev jest  ts-jest @types/jest jest-environment-jsdom
//  对应包的功能分别是 核心包 支持ts的jest 支持dom的环境

新建配置文件

npx ts-jest config:init

执行上面指令出来的配置文件如下 jest.config.js

module.exports = {
  preset: 'ts-jest',
  testEnvironment: 'node',
};

这样运行的时候会报错,

这里不太好用js配置的方法,我们换一种,用 jest.config.json

{
  "preset": "ts-jest",
  "testEnvironment": "node"
}

用上面的文件替换 init 创建出来的文件

编写测试用例

在自己项目对应的 src 里面新建 test 文件夹,这一步比较随意,自己一句自己的习惯来做,jest 可以指定需要被扫描的单元测试文件

这里用一个例子来说下我们的代码含有 dom 和 window 的情况我们可以怎么模拟

export function getEnv(): TypeEnv {
	// 扩展性支持
	if (typeof (window as any).$getEnv === 'function') {
		return (window as any).$getEnv()
	}

	const hostname = location.hostname
	if (['127.0.0.1', '0.0.0.0', 'localhost'].indexOf(hostname) > -1) {
		return 'dev'
	}
	if (/(.*)\.oppoer\.me/.test(hostname)) {
		if (
			/[-](pre|gray)[-.]/.test(hostname)
			|| /^(pre|gray)[-.]/.test(hostname)
		) {
			return 'pre'
		}
		return 'prod'
	}
	return 'dev'
}

上面的文件,用到了 window 上面的 location 属性,来看下单元测试的代码

/**
 * @jest-environment jsdom
 */
const env = require('../env')

test('验证正式环境', () => {
	window = Object.create(window)
	const url = 'https://odata.oppoer.me/home/'
	Object.defineProperty(window, 'location', {
		value: {
			href: url,
			hostname: url.match(/^http(s)?:\/\/(.*?)\//)?.[2],
		},
		writable: true
	})
	expect(env.getEnv()).toBe('prod')
})

其中几个值得注意的,上面的 @jest-environment 是重新指定 jest 的执行环境,另外就是重写 location 的部分属性,也可以直接在单测里面写 document 这样的浏览器特性。

执行单元测试

可以在我们 package.json 上新增 script

"scripts": {
	"jest": "jest",
	"jest-c": "jest src/test/* --coverage"
},

直接执行 jest ,jest 会扫描项目内所有符合 test case 的 js/ts 文件,并且执行。这里符合 test case 可以配置 jest 的配置,默认的 testMatch 配置是 [ "**/__tests__/**/*.[jt]s?(x)", "**/?(*.)+(spec|test).[jt]s?(x)" ],所以在命名单测文件的时候,使用标准格式如 env.test.ts

开发过程怎么只运行自己的编写的单测

jest 在执行的时候默认搜索整个项目,但是在写单测的时候,等整个项目的跑完有不好找自己的想要的那个,jest 的命令行就支持这种指定操作。

jest src/test/env.test.ts

jest 后面的第一个参数就是单测的地址

UI单元测试

这里我们以 vue 为 UI 开发框架,做 UI 的单元测试

vue3 的脚手架集成了一整套单元测试的流程,vue2 的话需要搭配一些工具,这里以 vue2 为例来看看 UI 组件的单元测试

安装

 // Vue Test Utils 是 vue 提供的测试套件,单元测试的进行是在 node 上的,需要在 node 上模拟实际页面的渲染,Vue Test Utils 为jest和vue提供了一个桥梁,暴露出一些接口,让我们更加方便的通过Jest为Vue应用编写单元测试。
 // Jest 如何处理 *.vue 文件,我们需要安装和配置 vue-jest 预处理器
npm i @vue/test-utils babel-jest jest  jest-serializer-vue  jest-transform-stub  vue-jest -D

2.修改.babelrc配置
在根目录的.babelrc中添加如下配置

"env": {
    "test": {
      "presets": ["env"]
    }
  }

就变成了如下(项目本身的配置不用改)

{
  "presets": [
    ["env", { "modules": false }]
  ],
  "env": {
    "test": {
      "presets": ["env"]
    }
  }
}

3.建立测试文件目录
在根目录下建立test目录,test里面再按照如下建立对应文件,文件夹,图上的红字是注释
image.png

4.添加jest配置,jest.conf.js内容如下,相关属性的解释也写在了注释里

const path = require('path');

module.exports = {
    verbose: true,
    testURL: 'http://localhost/',
    rootDir: path.resolve(__dirname, '../../'),
    moduleFileExtensions: [
        'js',
        'json',
        'vue'
    ],
    moduleNameMapper: {
        '^@\/(.*?\.?(js|vue)?|)$': '<rootDir>/src/$1',   // @路径转换,例如:@/components/Main.vue -> rootDir/src/components/Main.vue
        '\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$': '<rootDir>/test/unit/__mocks__/fileMock.js', // 模拟加载静态文件
        '\\.(css|less|scss|sass)$': '<rootDir>/test/unit/__mocks__/styleMock.js'  // 模拟加载样式文件   
    },
    testMatch: [ //匹配测试用例的文件
        '<rootDir>/test/unit/specs/*.spec.js'
    ],
    transform: {
        '^.+\\.js$': '<rootDir>/node_modules/babel-jest',
        '.*\\.(vue)$': '<rootDir>/node_modules/vue-jest'
    },
    testPathIgnorePatterns: [
        '<rootDir>/test/e2e'
    ],
    // setupFiles: ['<rootDir>/test/unit/setup'],
    snapshotSerializers: ['<rootDir>/node_modules/jest-serializer-vue'],
    coverageDirectory: '<rootDir>/test/unit/coverage', // 覆盖率报告的目录
    collectCoverageFrom: [ // 测试报告想要覆盖那些文件,目录,前面加!是避开这些文件
        // 'src/components/**/*.(js|vue)',
        'src/components/*.(vue)',
        '!src/main.js',
        '!src/router/index.js',
        '!**/node_modules/**'
    ]
}

备注:

单元测试的思想是单纯的测试组件,对于样式,图片等这些静态资源是不予测试的,所以上面的配置中才有了对这些静态资源进行了模拟加载,不然Jest + Vue Test Util 这俩哥们解析不了scss, css, img.. 这些静态资源,测试就跑不起来了。
同时对于组件内引用的外部资源,也需要模拟,比如axios,下面的测试代码里面有处理的演示。

5.给测试添加eslint配置,test/unit/ 目录下的.eslintrc内容如下

{
  "env": { 
    "jest": true
  }
}

6.__mocks__ 文件目录下建立 fileMock.js,用来处理测试中遇到的静态资源, 内容就一行代码

module.exports = 'test-file-stub';
  1. specs下写测试用例代码,像下图所示(组件名+spec):

image.png

  1. package.jsonscripts 里添加测试命令
"unit": "jest --config test/unit/jest.conf.js --coverage"

执行 npm run unit 就可以启动测试了,测试完毕会产生类似下图的报告, 测试覆盖率,测试用例,镜像..都有

image.png

编写测试用例

先看下我演示的项目,如下

image.png

checkbox 开关控制图片的显隐
表单请求有验证,点击立即创建触发表单验证,验证通过提交表单;点击重置按钮去掉验证提示。

我的组件就两个

image.png

一个Form.vue 一个 Main.vue, 就对这俩个组件测试。

测试用例写了三个,如下

image.png

里面详细的代码我就不贴出来了,可以去项目源码里面看。
下面说下写这几个测试用例需要注意的地方
1.由于项目用到了element-ui 所以在写测试用例的时候,也需要给模拟的Vue(createLocalVue) install element-ui
关键部分的代码如下:

import { mount, createLocalVue  } from '@vue/test-utils'
const localVue = createLocalVue()
import ElementUI from 'element-ui'
localVue.use(ElementUI)

import Form from '@/components/Form'

// 测试表单请求
describe('Test Form Request', () => {
  it('Form Request Sucess', () => {
    let wrapper = mount(Form, {
      stubs: {
        transition: false
      },
      localVue
    })
  })
})

2.checkbox切换的时候,控制图片显示/隐藏,需要用nextTick

it('show switch img', () => {
    wrapper.setData({ switchvalue: true })
    // 修改完数据 dom操作没同步 需要用 nextTick
    return Vue.nextTick().then(function() {
      expect(wrapper.findAll('.logoImg').length).toBe(1)
    })
  })

3.模拟axios
为什么要模拟axios ?

因为Jest + Vue Test Utils这套环境中是没有 axios的,所以他不认 axios, 但是组件代码里面确实调用了axios, 那么我们就需要模拟一个 axios 出来

新建 axios.js 文件

image.png

axios.js 的内容如下:

module.exports = {
    get: jest.fn(() => Promise.resolve({ status: 200 }))
}

我这里只用到了 status: 200,大家根据自己需求设置返回的数据。
测试用例代码如下:

import { mount, createLocalVue  } from '@vue/test-utils'
import Vue from 'vue'
const localVue = createLocalVue()
import ElementUI from 'element-ui'
localVue.use(ElementUI)
import axios from 'axios'

import Form from '@/components/Form'

// 测试表单请求
describe('Test Form Request', () => {
  it('Form Request Sucess', () => {
    let wrapper = mount(Form, {
      stubs: {
        transition: false
      },
      localVue,
      propsData: {
        initFormData: {
          name: '一起团建',
          type: ['地推活动'],
          desc: '吃喝玩乐'
        }
      }
    })
    wrapper.find('.confirm').trigger('click')
    return Vue.nextTick().then(function() {
        expect(wrapper.vm.sucess).toBe(true)
        let url = 'http://rap2api.taobao.org/app/mock/233956/tbl-unit-test?name=' + wrapper.vm.ruleForm.name + '&nature=' + wrapper.vm.ruleForm.type.join(',') + '&form=' + wrapper.vm.ruleForm.form
        expect(axios.get).toBeCalledWith(url)
    })
  })
})
@chiyan-lin
Copy link
Owner Author

@chiyan-lin
Copy link
Owner Author

jest 原理浅析

测试函数
const sum = (a, b) => a + b;

单元测试例子

test("sum test", () => {
  expect(sum(1, 2)).toBe(3);
});
  • test 块是单独的测试块,它拥有描述和划分范围的作用,即它代表我们要为该计算函数 sum 所编写测试的通用容器。
  • expect 是一个断言,该语句使用输入 1 和 2 调用被测函数中的 sum 方法,并期望输出 3。
  • toBe 是一个匹配器,用于检查期望值,如果不符合预期结果则应该抛出异常。

实现测试块


// 我们需要在全局创建一个缓存
global.STATE_SYMBOL = {
  testBlock: []
}

// 把测试包装实际测试的回调函数存起来
const dispatch = (event) => {
  const { fn, type, name } = event
  switch (type) {
    case 'ADD_TEST':
      const { testBlock } = global.STATE_SYMBOL
      testBlock.push({ fn, name })
      break
  }
}

// 把测试包装实际测试的回调函数存起来
const test = (name, fn) => {
  dispatch({ type: 'ADD_TEST', fn, name })
}
 

实现断言和匹配器

断言库也实现也很简单,只需要封装一个函数暴露匹配器方法满足以下公式即可:

expect(A).toBe(B)

// 这里我们实现 toBe 这个常用的方法,当结果和预期不相等,使用 node 的断言抛出来异常
import assert from 'node:assert/strict';

const expect = (actual) => ({
  toBe (expected) {
    console.log('expected', expected)
    assert.equal(actual, expected)
  }
})

在测试框架中,我们并不需要手动引入 test、expect 和 jest 这些函数,每个测试文件可以直接使用,所以我们这里需要创造一个注入这些方法的运行环境。这里我们用到了 node 的 vm 模块


const context = {
  console: console.Console({ stdout: process.stdout, stderr: process.stderr }),
  expect,
  require,
  test
}
vm.createContext(context) // Contextify the object.
const code = fs.readFileSync(path.resolve(__dirname, './test.js'))
vm.runInContext(code, context)

// 执行单元测试
global.STATE_SYMBOL.testBlock.forEach(async (item) => {
  const { fn, name } = item
  try {
    await fn.apply(this)
    console.log(`√ ${name} passed`)
  } catch {
    console.log(`× ${name} error`)
  }
})

@chiyan-lin
Copy link
Owner Author

// test.js

const sum = (a, b) => a + b
test('test', () => {
expect(sum(1, 2)).toBe(3)
})

@chiyan-lin
Copy link
Owner Author

chiyan-lin commented May 21, 2023

// jest

const vm = require('node:vm')
const assert = require('assert')
const fs = require('fs')
const path = require('path')

global.STATE_SYMBOL = {
  testBlock: []
}

const dispatch = (event) => {
  const { fn, type, name } = event
  switch (type) {
    case 'ADD_TEST':
      const { testBlock } = global.STATE_SYMBOL
      testBlock.push({ fn, name })
      break
  }
}

// 把测试包装实际测试的回调函数存起来
const test = (name, fn) => {
  dispatch({ type: 'ADD_TEST', fn, name })
}

const expect = (actual) => ({
  toBe (expected) {
    console.log('expected', expected)
    assert.equal(actual, expected)
  }
})

const context = {
  console: console.Console({ stdout: process.stdout, stderr: process.stderr }),
  expect,
  require,
  test
}
vm.createContext(context) // Contextify the object.
const code = fs.readFileSync(path.resolve(__dirname, './test.js'))
vm.runInContext(code, context)

global.STATE_SYMBOL.testBlock.forEach(async (item) => {
  const { fn, name } = item
  try {
    await fn.apply(this)
    console.log(`√ ${name} passed`)
  } catch {
    console.log(`× ${name} error`)
  }
})

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant