浅谈前端自动化测试

页面作为一种特殊的GUI软件,在前端工程中,很少提到自动化测试,业内也基本没有相对成熟的方案。

究其原因,主要是产品迭代速度实在太快,导致测试脚本无法跟上UI的快速变化,再加上人力原因,导致页面基本不做自动化测试。

虽然在产品快速迭代期,自动化测试无法落地,但是一旦进入稳定期,引入自动化测试还是能帮助较少开发成本。

1. 什么是测试?

测试其实就是在已经开发完成的软件之上采用人工或非人工的方式验证软件是否符合预期,是否会造成损失等潜在问题的一种方式。

多数情况下,前端代码都是研发手工自测,或是提测后由QA人员手工测试。

手工测试当然也是没有问题的,但是通过自动化的测试工具,可以更加快速高效且准确定位问题所在。

自动化测试实际上是运行一段测试代码,去验证目标代码是否满足某个期望。

2. 为什么要测试?

测试的目的在于及时发现错误,提高代码质量和开发效率,避免存在 BUG 的代码发布上线造成损失。

测试自动化的好处在于反馈及时,无需人工干预,能够极大地提高前端的开发效率。

在日常的开发过程中,可能经常需要在项目跑起来之后去人工测试某些操作或者流程是否能够正常运行,又或者经常需要打断点或者使用 console.log 查看控制台信息来检查某个函数是否执行。

这些需要我们自己手工测试代码的执行结果是否符合预期的场景,完全可以使用自动化测试的脚本代替。

现有的很多成熟的自动化测试框架完全可以模拟我们的手工操作,使用脚本自动运行测试用例,通常只需要几秒就能给出准确的反馈,同时还能侦听代码变化,自动执行项目中发生了变化的代码对应的测试用例,能够极大提高我们的开发效率。

在公司业务和人员变动都比较快的当下,编写自动化测试脚本的收益越来越高。开发者再也不用害怕引入回归 BUG,也再也不用害怕把代码交给他人维护。有了测试脚本的约束,迭代/重构都能更加从容。

3. 有哪些测试类型?

前端测试主要分为3中:单元测试(Unit Test)、集成测试(Integration Test)、UI 测试(UI Test)

三种测试的占比顺序应该是:单元测试 > 集成测试 > UI 测试。

但是大多数情况下,我们的测试占比是反过来。人工干预的UI 测试占比最多,单元/集成测试反而被忽略。

3.1 单元测试(Unit Test)

单元测试是保证代码能正常运行的基础,主要用于公共类库、多个组件共用的子组件等测试。

单元测试也应该是项目中数量最多、覆盖率最高的。

能进行单元测试的函数/组件,一定是低耦合的,这也从一定程度上保证了我们的代码质量。

3.2 集成测试(Integration Test)

集成测试通常被应用在耦合度较高的函数/组件、经过二次封装的函数/组件、多个函数/组件组合而成的函数/组件等。

集成测试的目的在于,测试经过单元测试后的各个模块组合在一起是否能正常工作。

3.3 UI 测试(UI Test)

提到 UI 测试,不可避免地要涉及另一个名词:端到端测试(E2E Test),这两个在很多情况下会被混为一谈,但实际两者稍有区别:

  • UI 测试只是对于前端的测试,是脱离真实后端环境的,仅仅只是将前端放在真实环境中运行,而后端和数据都应该使用 Mock 的。
  • 端到端测试则是将整个应用放到真实的环境中运行,包括数据在内也是需要使用真实的。

对前端而言,UI 测试更贴近于我们的开发流程。在前后端分离的开发模式中,前端开发通常会使用到 Mock 的服务器和数据,因而我们需要在开发基本完成后进行相应的 UI 测试。

UI 测试的自动化程度还不高,大多数还依赖于手工测试。在一些自动化测试工具中有创建快照的功能,也能帮助我们在一定程度上实现 UI 测试的自动化。

4. 如何选择测试工具?

现在市面上有很多流行的测试工具,但普遍都存在一个问题:新特性的支持滞后

前端测试的框架可谓是百花齐放。

  • 单元测试(Unit Test)有 Mocha, Ava, Karma, Jest, Jasmine 等。
  • 集成测试(Integration Test)和 UI 测试(UI Test)有 ReactTestUtils, Test Render, Enzyme, React-Testing-Library, Vue-Test-Utils 等。

主流测试工具比较

框架 断言 仿真 快照 异步测试
Mocha 默认不支持,可配置 默认不支持,可配置 默认不支持,可配置 友好
Ava 默认支持 不支持,需第三方配置 默认支持 友好
Jasmine 默认支持 默认支持 默认支持 不友好
Jest 默认支持 默认支持 默认支持 友好
Karma 不支持,需第三方配置 不支持,需第三方配置 不支持,需第三方配置 不支持,需第三方配置

Mocha

  • Mocha 是生态最好,使用最广泛的单测框架,但是他需要较多的配置来实现它的高扩展性。

Ava

  • Ava 是更轻量高效简单的单测框架,但是自身不够稳定,并发运行文件多的时候会撑爆 CPU。

Jasmine

  • Jasmine 是单测框架的“元老”,开箱即用,但是异步测试支持较弱。

Jest

  • Jest 基于 Jasmine, 做了大量修改并添加了很多特性,同样开箱即用,但异步测试支持良好。

Karma

  • Karma 能在真实的浏览器中测试,强大适配器,可配置其他单测框架,一般会配合 Mocha 或 Jasmine 等一起使用。

每个框架都有自己的优缺点,没有最好的框架,只有最适合的框架。Augular 的默认测试框架就是 Karma + Jasmine,而 React 的默认测试框架是 Jest。

Jest 被各种 React 应用推荐和使用。它基于 Jasmine,至今已经做了大量修改并添加了很多特性,同样也是开箱即用,支持断言,仿真,快照等。Create React App 新建的项目就会默认配置 Jest,我们基本不用做太多改造,就可以直接使用。

采用何种测试思想?

TDD:Test-Driven Development(测试驱动开发)

  • TDD:Test-Driven Development(测试驱动开发):TDD 则要求在编写某个功能的代码之前先编写测试代码,然后只编写使测试通过的功能代码,通过测试来推动整个开发的进行

BDD:Behavior-Driven Development(行为驱动开发)

  • BDD:Behavior-Driven Development(行为驱动开发):BDD 可以让项目成员(甚至是不懂编程的)使用自然语言来描述系统功能和业务逻辑,从而根据这些描述步骤进行系统自动化的测试

Jest 基本语法

由于大厂普遍使用 React/Vue 进行开发,而 React/Vue 官方推荐的单元测试工具都是 Jest,因此本文我们就简单介绍一下 Jest 的基本语法。

匹配器

Number

// sum.ts
const sum = (a: number, b: number): number => {
  return a + b
}

// sum.test.ts
describe('should sum function run correctly', () => {
	test('input: 1, 2 expect: 3', () => {
		// toBe: 判断是否严格相等(使用 Object.is)
		expect(sum(1, 2)).toBe(3) // toXXX: 匹配器
		// toEqual: 判断值相等
		expect(sum(1, 2)).toEqual(3)

		// toBeDefined: 判断是否被定义
		expect(sum(1, 2)).toBeDefined()
		// toBeUndefined: 判断是否未定义
		expect(sum(1, 2)).not.toBeUndefined()

		// toBeTruthy: 判断是否为真
		expect(sum(1, 2)).toBeTruthy()
		// toBeFalsy: 判断是否为假
		expect(sum(1, 2)).not.toBeFalsy()

		// toBeGreaterThan: 判断是否大于预期值
		expect(sum(1, 2)).toBeGreaterThan(2)
		// toBeLessThan: 判断是否小于预期值
		expect(sum(1, 2)).toBeLessThan(4)
		// toBeGreaterThanOrEqual: 判断是否大于等于预期值
		expect(sum(1, 2)).toBeGreaterThanOrEqual(3)
		// toBeLessThanOrEqual: 判断是否小于等于预期值
		expect(sum(1, 2)).toBeLessThanOrEqual(3)
	})
})

String

// showHello.ts
const showHello: string = "Hello World"

// showHello.test.ts
describe("show showHello defined correctly", () => {
	it("expect to match 'Hello'", () => {
		expect(showHello).toMatch(/hello/i)
	})

	it('expect to match 'World', () => {
		expect(showHello).toMatch('World')
	})
})

Array & Iterable

// array.ts
const array: number[] = [1, 2, 3, 4, 5]

// array.test.ts
describe('should array defined correctly', () => {
	it('expect to contain 1', () => {
		expect(array).toContain(1)
	})

	it('expect to contain 1', () => {
	    expect(new Set(array)).toContain(1)
	})
})

Exception

// compile.ts
const compile = (): Error => {
	throw new Error('you are using the wrong JDK')
}

// compile.test.ts
test('compiling goes as expected', () => {
	expect(compile).toThrow()
	expect(compile).toThrow(Error)
	expect(compile).toThrow('you are using the wrong JDK')
	expect(compile).toThrow(/JDK/)
})

异步代码测试

// callback
it('done', done => {
	fetch('/api')
		.then(res => {
			expect(res).toEqual({ code: 200 })
			done()
		})
		.catch(err => {
			done(err)
		})
})
it('promise resolve', () => {
	return fetch('/api').then(res => {
		expect(res).toEqual({ code: 200 })
	})
})

it('promise reject', () => {
	expect.assertions(1) // 保证 1 条断言被调用
	return fetch('/api').catch(err => {
		expect(err).toMatch('error')
	})
})

it('async/await resolve', async () => {
	const res = await fetch('/api')
	expect(res).toEqual({ code: 200 })
})

it('async/await reject', async () => {
	expect.assertions(1); // 保证 1 条断言被调用
	try {
		await fetch('/api')
	} catch (err) {
		expect(err).toMatch('error')
	}
})
// 推荐方式
it('best method', () => {
	return expect(fetch('/api')).resolves.toEqual({
		code: 200
	})
})

it('best method', () => {
	return expect(fetch('/api')).rejects.toMatch('error')
})

it('best method', async () => {
	await expect(fetch('/api')).resolves.toEqual({
		code: 200
	})
})

it('best method', async () => {
	await expect(fetch('/api')).rejects.toMatch('error')
})

生命周期钩子

// 生命周期钩子
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

生命周期钩子执行顺序符合洋葱模型。

执行顺序

describe('outer', () => {
	console.log('describe outer a')

	describe('describe inner 1', () => {
		console.log('describe inner 1')
		test('test 1', () => {
			console.log('test for describe inner 1')
			expect(true).toEqual(true)
		})
	})

	console.log('describe outer b')

	test('test 1', () => {
		console.log('test for describe outer')
		expect(true).toEqual(true)
	})

	describe('describe inner 2', () => {
		console.log('describe inner 2')
		test('test for describe inner 2', () => {
			console.log('test for describe inner 2')
			expect(true).toEqual(true)
		})
	})

	console.log('describe outer c')
})

// describe outer a
// describe inner 1
// describe outer b
// describe inner 2
// describe outer c
// test for describe inner 1
// test for describe outer
// test for describe inner 2

测试单元/用例执行顺序类似异步队列

函数 Mock

function sayHello(name) {
	return `Hello ${name}`
}

function say(cb) {
	cb('断崖')
}

it('test sayHello function run correctly', () => {
	const mockFunc = jest.fn(sayHello)

	say(mockFunc)

	// toHaveBeenCalled: 判断 Mock 函数是否被调用
	expect(mockFunc).toHaveBeenCalled()

	// toHaveBeenCalledWith: 判断 Mock 函数被调用时的参数
	expect(mockFunc).toHaveBeenCalledWith('断崖')

	say(mockFunc)

	// toHaveBeenCalledTimes: 判断 Mock 函数被调用次数
	expect(mockFunc).toHaveBeenCalledTimes(2)

	// toHaveReturned: 判断 Mock 函数是否有返回值
	expect(mockFunc).toHaveReturned()

	// toHaveReturnedWith: 判断 Mock 函数返回值
	expect(mockFunc).toHaveReturnedWith('Hello 断崖')
})
import React from 'react'
import { render, fireEvent } from '@testing-library/react'

function Component(props) {
	const { handleBtnClick } = props;

	return (
		<div data-testid="button" onClick={handleBtnClick}>
			<span>test</span>
		</div>
	)
}

it('Test Component Click', () => {
	const handleBtnClick = jest.fn()
	const wrapper = render(<Component handleBtnClick={handleBtnClick} />)
	fireEvent.click(wrapper.getByTestId('button'))
	expect(handleBtnClick).toHaveBeenCalled()
})