一位测试工程师写给前端的一篇IT小文章
一直以来对于前端同学来说,自动化测试都是一个比较特殊的命题。一方面,大家其实都知道自动化测试的好处,做了什么改动只要跑一遍测试用例就知道有没有改挂了之前的逻辑,进行修改时也更有底气。而另一方面,前端本身就具有特殊性,活动页从需求评审到正式上线可能在一周内就完成了,这种迭代速度还写测试用例就是折磨自己。
但实际上,自动化测试在前端工程中也是相当重要的一部分。即使是快速迭代的活动页面,也会有通用的工具函数与 SDK,对这一部分的代码进行测试用例的完善是有必要且意义重大的,而对于某些流量
巨大且长期存在的页面,我们甚至需要进行多种测试场景的保障。
然而由于这两种情况的存在,很多前端同学其实都对自动化测试的认知相当空白,它有哪些分类?有哪些推荐的实践?有哪些框架与方案?而这篇文章的目的就是进行一个基础的扫盲,至少完
成阅读以后你会知道如何为项目编写测试用例,以及应该编写哪些场景的测试用例。
单元测试与集成测试
单元测试(Unit Testing)正如其名,其中的测试用例应当是针对代码中的各个单元的,如前端代码中,每一个工具方法都可以被作为一个单元,对应一个独立的测试用例。但这么说并不意味着你要写出
非常细粒度的代码——这不是没事折磨自己吗?我通常使用“功能单元”的方式来确定粒度,比如生产薯条的流水线上,清洗-削皮-切片-包装就是四个完全独立的功能单元。
你可能会感到疑惑,这四个功能单元明明存在依赖关系,为何说是完全独立的?这是因为在单元测试时,非常重要的一个步骤就是对当前测试单元的外部依赖进行模拟,比如我在测试削皮功能时,会直接
给到“已经清洗完毕的土豆”,然后检查“削皮后的土豆”,而不会真的去调用前后的功能单元。
常见的模拟操作可以分为 Fake、Stub、Mock、Spy 这么几种,我们在下文会有更详细的介绍。
一种常见的情况是工具方法中会基于外部依赖的表现执行不同分支的代码(if/else,try/catch,resolve/reject 等)。这种时候,我们需要做的就是通过修改外部依赖的表现,来检查工具方法内部各个代
码分支的执行情况。比如,在 fetch 成功返回时应当调用 processData 方法,在 fetch 失败时应当调用 reportError 方法,此时你就可以篡改掉 fetch 的实现,然后检查 processData 、reportError 方法是
否被调用(注意,这两个方法也需要被模拟(Stub / Spy) ,然后才能检查它们的调用情况)。
当然,完全模拟所有外部依赖是最理想的情况,在很多时候一个工具方法可能具有许多外部依赖,此时你可以省略掉其中能确定无副作用(如 logger 这样的纯函数),或者是与核心逻辑无关的部分。
我们知道,测试用例也可以反过来对代码产生检查作用,而在单元测试阶段这种作用基本是最明显的,比如你可以很容易发现某一处功能单元设计得过于耦合,或是某一外部依赖将导致代码进入错误分支
等情况。
目前推荐的单元测试方案主要有这么几种,Jest、Mocha、Sinon、Jasime 以及 AVA,它们之间各有优劣,这里不做比较。但需要注意的是,一套完整的,能够满足实际需求的单元测试方案,通常意味
着需要包括这么几个功能:
断言,Jest 提供了注入到全局的 expect 风格断言(expect(1+1).toBe(2)),而 Sinon 提供的则是类似 NodeJs asserts 模块风格的断言(``sinon.assert.pass(1 + 1 === 2)`),而 Mocha 则不绑定断
言库,你可以使用 asserts 模块或者 Chai 来进行断言。另外,断言又包括了几种不同的风格,我们同样在下文讲解。
用例收集,编写测试用例时我们同样需要基于功能单元区分,常见的方式就是 describe 收集一个功能单元,内部又使用 it/ test 来进行功能单元各个逻辑分支的验证。如:
describe('Utils.Reporter', () => {
it('should report error when xxx', () => {})
it('should show warnings when xxx', () => {})
})
模拟功能(Stub 、Fake Timers 等),包括对一个对象的 Spy,一个函数的 Stub,对一个模块的 Mock,都属于模拟的范畴。
测试覆盖率报告,这一功能常见的方式是通过 istanbul (1.0版本,2.0 更名为 nyc)或 c8 来进行实现,其原理包括代码插桩与使用 V8 引擎内置功能两种,这里不再赘述。另外一个常见的场景是输出其
他语言格式的覆盖率报告(如JUnit),社区也通过 Reporters 的机制为这些测试框架做了支持。
如果你此前并没有对这些单元测试方案非常熟悉,那我推荐你了解一下 Vitest ,来自 antfu 的作品,特色是快(毕竟基于 Vite)以及对 TypeScript、ES Module 的良好支持,我目前在工作中的单元测试
也已经全部迁移到 Vitest,同时 Vitest 还自带了 UI 界面,让你可以更享受编写测试并看着它们一个个通过的过程。
如果说单元测试是为了测试单个功能单元,那么集成测试(Integration Testing)很明显就是为了测试多个功能单元的协作。但需要注意的是,多个功能单元协作并不意味着对整个系统(流水线)进行完整的
功能测试,通常我们还会将几个功能单元分散开进行组合,成为系统的某一部分,比如清洗-削皮作为预处理功能,需要确定一箩筐土豆能否正确地变成干净的去皮土豆,切片-包装作为核心功能,
需要确定去皮土豆能变成冷冻薯条。
而要进行集成测试的编写,其实我们仍然只需要使用单元测试方案即可,因为本质上集成测试就是同时对多个功能单元进行测试,我们验证的范畴也随之扩大了而已。
而关于集成测试的维度拆分则并没有准确的界限,你可以像上面那样将预处理功能作为一个系统部分,也可以将整个流水线作为一个系统部分(还有供应链部分、烹饪部分与服务部分),按照你的实际业
务场景就行。
Mock、Fake、Stub
很多时候测试用例的运行时是受限的,比如我们并不希望真的发起网络请求,或者是和数据库交互,以及 DOM API 的操作等。这个时候我们会使用一系列模拟手段,来特定地模拟一个可交互
的对象,并通过修改它的行为来检查预期的处理逻辑是否执行。
这个模拟行为通常被直接称为 Mock,但实际上,由于模拟的对象类型以及注入的模拟逻辑,更准确的描述是将这些行为划分为三大类。首先是最常用的 Stub ,假设我们在为 UserService 编写单元测
试,其内部注入的 PrismaService 负责数据库的读写,我们可以使用一个 PrismaServiceStub 替换掉实际的服务,并且在其内部提供对应 PrismaService.user.findUnique 这样的方法,然后在我们调用
UserService.queryUser 时,就可以检查 PrismaServiceStub 上对应的方法是否被预期的入参调用,而其出参是否被预期地处理后返回。Spy 也可以认为是 Stub 的一种,但它更强调“是否按照预期调用”
这个过程,我们甚至可以仅仅监听一个对象而无需提供模拟实现(如 console 这样的 API)。
而如果我们不希望替换掉 PrismaService,而是希望它真的去进行数据读写,但不是对真实的数据库,就可以提供一个 Fake 的数据库——比如一个对象,这样对数据库的读写就变成了对内存对象的读
写,变得更加快捷和稳定,这就是 Fake。另外一个常见的 Fake 场景就是定时器,常见的单元测试框架都提供了 Fake Timers 的功能支持。
而 Mock 其实和 Stub 也非常类似,但 Mock 更像是其中“预期的入参”,而并不关注返回值,我个人理解通常项目中 fixtures 文件夹下的各种对象和 JSON 就是典型的 Mock 。
当然,Mock、Stub、Spy 三者还是非常相似的,我们也并不是必须搞清楚其中的差异,因为它们的本质都是模拟罢了。
断言:expect、assert、should
我们常见的断言包括 expect 与 assert 形式,NodeJs 提供了原生的 asserts 模块让你来编写一些简单的断言,你可以在实际代码中也使用断言来确保逻辑正确运行,而 expect 形式则通常只见于测试用
例中。如检查一个函数的调用和比较两个对象,两种风格分别是这样的:
expect(mockFn).toBeCalledWith("linbudu");
assert.pass(mockFn.calls.arg === "linbudu");
expect(obj1).toEqual(obj2);
assert.equal(obj1, obj2);
通常我个人更喜欢命令式风格明显的 expect 断言,而除了这两种风格以外,其实还有一种 should 形式的链式风格断言,它写起来是这样的:
mockFn.should.be.called();
obj1.should.equal(obj2);
值得一提的是在 Chai 这个断言库中对以上三种断言风格都进行了支持,如果你有兴趣,不妨都试一试。
前端页面中的组件测试与 E2E 测试
单元测试和集成测试是前后端应用中通用的概念,而完成了对基础功能单元的测试以后,我们需要更进一步,关注领域中特定的功能,比如从前端视角来看一个组件的 UI 与功能,从后端视角来看一个接
口面对千奇百怪入参的响应。
在当今的前端项目中,组件化应该是最明显的一个趋势,那么进行组件维度的测试也自然是相当有必要的。以 React 组件为例,我们可以模拟这个组件的入参,并观察其实际渲染的 UI 组件是否正确,以
及使用快照的方式,来检查组件的实际渲染是否一致。
目前使用的组件测试方案通常是和框架绑定的,如 React 下的 @testing-library/react? 和 Enzyme,Vue 下的 @vue/test-utils?,Svelte 下的 @testing-library/svelte,这是因为本质上我们是在孤立
地渲染这个组件,并模拟框架行为来验证其表现。
在组件测试方案中,我更推荐 @testing-library/react? (还包括 @testing-library/react-hooks),Enzyme 的 API 要更加复杂,同时其目前应该已经不再维护(或是维护力度堪忧)。使用其编写的测
试用例是这样的:
import * as React from 'react'
function HiddenMessage({children}) {
const = React.useState(false)
return (
<div>
<label htmlFor="toggle">Show Message</label>
<input
id="toggle"
type="checkbox"
onChange={e => setShowMessage(e.target.checked)}
checked={showMessage}
/>
{showMessage ? children : null}
</div>
)
}
export default HiddenMessage
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>)
// 基于模糊查询来验证 DOM 元素的存在
expect(screen.queryByText(testMessage)).toBeNull()
// 同样基于模糊查询来触发事件
fireEvent.click(screen.getByLabelText(/show/i))
// 验证结果是否符合预期
expect(screen.getByText(testMessage)).toBeInTheDocument()
})
单元测试、集成测试、组件测试,看起来我们已经非常完美地使用自动化测试从不同场景与不同维度进行了功能的验证,但实际上,我们还少了一个非常重要的维度——用户视角。在程序最终交付验收
时,我们可爱的测试同学会来把各个功能和链路都检查一遍,而即使你已经写了巨量的测试用例,还是有可能会被发现大量的问题,这就是因为视角不同。作为程序的开发者,你清楚地了解程序的控制流走
向,也对每一个分支了然于胸,所以在编写测试用例时你其实更像是上帝视角。
要从用户的视角出发,实际上我们只需要屏蔽对程序内部的所有感知,而只是去使用这个程序即可。这样的测试被称为端到端测试(End-to-End Testing,E2E),它不再关注内部功能单元的细节,而是
完全从外部还原一个真实的用户视角,如前端应用中,用户登录-搜索商品-加入购物车-编辑商品-结算商品的一系列交互,谁管你的登录背后隐藏了多少权限分级,商品货架分级设计得多么精细,只要这个流
程无法顺利走通,那你的系统就是有问题的。
而既然 E2E 测试是在模拟用户行为,那么其实我们所需要做的就是使用用户的环境来运行系统罢了。如对于前端页面,其实就是浏览器更准确地说是浏览器内核),而对于后端服务则是客户端。
以 Cypress 的功能为例,来看看我们是如何模拟用户行为的:
在前端领域中编写 E2E 测试,常见的 E2E 测试框架主要包括 Puppeteer、Cypress、Playwright、Selenium 这么几种。它们之间各有优劣,适用场景也有所不同,我们会在下面进行比较。
与其他测试场景的重要不同之一,就是 E2E 测试是可以由测试同学来编写的(如支持Python和Java的 Selenium),在产品进行迭代的同时,测试同学会按照功能点变化对应地
完善测试用例,同时确保以往所有功能的测试用例不受影响。
:time:
页:
[1]