当我们谈论前端测试时,我们谈些什么

测试,以及前端测试

一般地说,测试的目的是为了保证代码如 预期 那样运行。
在一些特定场景下,我们会将测试提升到较为重要的地位,先编写能描述功能的测试,再编写具体代码,也就是我们所称的 TDD
但具体地说,编写测试的时机并不重要,重要的是测试代码应当覆盖重要逻辑的核心流程,这对代码质量是非常重要的。

另外的,因为前后端职责、环境的不同,导致在测试代码、测试策略上会有很大的区别。前端代码因为 UI 的易变性,导致基于 UI 而编写的抽象逻辑和测试逻辑都很难复用。另外的,前端代码的边界条件非常多。所以对测试的资源投入就需要在一些特定情况下才能有较大收益。

例如,如果我们确定某个功能在未来会有较大重构、或者我们修复了一些比较难复现的 bug。这种时候就非常有必要添加完善的测试。这可以保证我们在未来修改代码时,不必花费时间在重新检查各种边界条件上。

但如果一个前端功能还未完全确定时,特别是交互逻辑还未确认时,编写大量的测试代码就不是很明智了。

测试的分类及它们所专注的

从测试的目的来说,其具体的分类并不重要。重要的是我们从哪个维度去测试我们的系统。
从逻辑上讲,一个系统是由一系列子系统组成的。我们需要确保的是微观上子系统自身的正确性,以及宏观上各子系统之间交互的正确性。注意到后者所谓的正确性不仅仅在于代码的正确,更重要的是需要逻辑能够符合我们的预期。

从微观角度来说,一个功能函数的输入输出,一个 UI 功能的交互逻辑都是需要测试的。在这个维度下,我们常用的是 Unit Testing

Unit Testing 所专注的是系统中最小单元。这个最小单元有可能是函数,也有可能是模块或类。当我们在编写代码时注意了单一职权的原则,我们的 Unit Testing 就可以变得非常专注。在核心流程外,也可以针对各种各样的边界条件编写大量的测试代码,以保证其稳定性。

而众所周知的是,最小单元的正确并不一定能保证系统整体的正确。所以从宏观角度来说,我们需要一些测试来保证这些最小单元相互之间可以很好地协作。另外的,对于宏观系统还有个重要的测试理由是。最小单元往往是由我们自己使用的,通过使用 TypeScript 这种具有完善类型系统的语言,可以很好地保证最小单元的输入与输出。但由最小单元组成的系统却无法保证,特别是对于前端代码来说。

你永远都无法知道用户会怎样点击一个按钮。这导致了一段非常简单的前端逻辑可能会有极其多的边界条件。保证系统在大多数流程下正确运行就是 Integration TestingE2E Testing 的职责了。

另外的,因为前端代码是面向用户视觉的。所以视觉测试也是十分重要的。固定的页面可以通过截图比对来实现,而结构稳定的界面则可以通过断言特定的 DOM 来实现。

Jest,以及 Jest 提供的模拟

Jest 是一款开箱即用的测试框架。开发者无需为其配置断言库、测试覆盖率计算工具等周边功能,而只需要专注于测试本身即可。下面是一个简单的使用:

1
2
3
4
5
6
7
export const sum = (a, b) => {
return a + b;
}

test('add sum function', () => {
expect(sum(1 + 2)).toBe(3);
})

断言

Jest 提供了很多常用的断言,来帮助你写出语义化更好的测试。例如:

  • toBeNull
  • toBeUndefined
  • toBeDefined
  • toBeTruthy
  • toBeFalsy
  • toMatch
  • toContain
  • toThrow

模拟

我们通过模拟追踪一个函数、模块的周边信息。例如被调用次数,传入的参数,输出的返回值。事实上这些东西与函数本身的逻辑并无多大关系。我们在这些时候往往将函数当作一个黑盒,仅关心其对信息的处理。

Jest 提供了一种方法可以让我们轻松的模拟一个函数。如果我们不定义函数内部的实现,jest.fn() 就会返回 undefined。我们可以以下面一段代码来说明 jest.fn() 的基本行为:

1
2
3
4
5
6
7
8
9
test('jest.fn()', () => {
const fn = jest.fn();
const res = fn(1, 2, 3);

expect(fn).toBeCalled();
expect(fn).toBeCalledTimes(1);
expect(fn).toHaveBeenCalledWith(1, 2, 3);
expect(res).toBeUndefined();
})

还有一个比较重要的特性是,我们可以为 jest.fn 定义返回值。比如下面的代码:

1
2
3
4
5
6
7
8
9
10
11
test('jest.fn() mockReturnValueOnce & mockReturnValue', () => {
const fn = jest.fn();
fn.mockReturnValueOnce('Spike');
.mockReturnValueOnce('Jet');
.mockReturnValue('Ed');

expect(fn()).toBe('Spike');
expect(fn()).toBe('Jet');
expect(fn()).toBe('Ed');
expect(fn()).toBe('Ed');
})

当然了,你也许在返回值之前,想要对值执行一些逻辑,那么你就可以这样写来修改实现:

1
2
3
4
5
6
7
test('jest.fn() mockImplementationOnce', () => {
const fn = jest.fn();
fn.mockImplementationOnce(() => {
console.log('internal logic.');
return 'Bebop';
})
})

在知道了这些后,你也许会很容易想到,我们可以借助这些 API 来帮助我们测试一些难以在测试阶段获取到的资源。例如测试后端 API 接口。下面是一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// api.js
import axios from 'axios'

export const fetchData = () => {
return axios.get('/').then(res => res.data)
}

// test.js
import axios from 'axios'
import {fetchData} from './api'

jest.mock('axios')
test('fetchData function', () => {
axios.get.mockResolvedValue('123')

fetchData().toEqual('123')
})

但这意味着你需要将 mock 数据全部写在测试代码中,而诸如 RESTful 接口通常具有很长的返回值,所以这也许不是一个好主意。

jest 为你提供了一个更好的办法,就是在 __mocks__ 文件夹下写这些模拟函数。

1
2
3
4
5
6
7
8
9
10
11
12
// __mocks__/api.js
export const fetchData = () => {
return '123'
}

// test.js
jest.mock('./api')

import { fetchData } from './api'
test('fetchData function', () => {
fetchData().toEqual('123')
})

此时,jest 就会自动去 __mocks__ 目录下寻找要模拟的文件。

另外的,你也可以在模拟了整个模块的前提下,使用真实文件中的函数。

1
2
3
4
5
// test.js
jest.mock('./api')

import { fetchData } from './api'
const { changeData } = jest.requireActual('./api')

不过也许你也需要在测试时真实的执行被模拟的函数,那么你可以借助 jest.spyOn 来创建一个模拟函数。这个模拟函数在执行的时候,也会同时执行真实的函数。

1
2
3
4
5
6
7
8
9
10
11
import { video } from './video'

test('play video', () => {
const spy = jest.spyOn(video, 'play')
const isPlaying = video.play()

expect(spy).toHaveBeenCalled()
expect(isPlaying).toBe(true)

spy.mockResotre()
})

CyPress 与 E2E 测试

在我们通过 Unit TestType System 保证了系统最小单元的稳定性后,我们还需要做的一件事就是保证子系统间协作的稳定性。在前端领域,这些协作大多由用户对页面的操作而触发。

CyPress 是一个很棒的 E2E 测试工具,它提供了一系列的方法来模拟用户行为。例如我们有如下 HTML:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Cypress tutorial for beginners</title>
</head>
<body>
<main>
<form>
<div>
<label for="name">Name</label>
<input type="name" required name="name" id="name" />
</div>
<div>
<label for="email">Email</label>
<input type="email" required name="email" id="email" />
</div>
<div>
<label for="message">Your message</label>
<textarea id="message" name="message" required></textarea>
</div>
<div>
<button type="submit">SEND</button>
</div>
</form>
</main>
</body>
</html>

那么,我们可以这样为表单添加一段测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
describe('test form', () => {
it ('can fill the form', () => {
cy.visit('/') // 导航至指定路径
cy.get('form') // 获取元素

cy.get('input[name="name"]')
.type("Jet")
.should("have.value", "Jet")
// "have.value" 是断言
// https://docs.cypress.io/guides/core-concepts/introduction-to-cypress.html#Assertions

cy.get('input[name="email"]')
.type("Jet@thoughtworks.com")
.should("have.value", "Jet@thoughtworks.com")

cy.get('textarea')
.type("here we go again")
.should("have.value", "here we go again")

cy.get("form").submit()

})
})

另外的,你也可以创建一个模拟的 API 来测试提交行为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
describe('test form', () => {
it ('can fill the form', () => {
cy.visit('/')
cy.get('form')

cy.server() // 启动一个虚拟服务器
cy.route({
url: '/users/**',
methods: 'POST',
response: {
status: 'Form saved!',
code: 201
}
})

cy.get('form').submit()
cy.contains('Form saved!')
})
})
Author: ShroXd
Link: http://www.bebopser.com/2021/05/11/FrontendTest/
Copyright Notice: All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating additionally.