这一次,彻底入门前端测试,覆盖单元测试、组件测试(2.4w 字)

前端测试一直是前端工程化中很重要的话题,但是很多人往往对测试产生误解,认为测试不仅没有什么用而且还浪费时间,或者测试应该让测试人员来做,自己应该专注于开发。所以,文章开头会先从”软件工程语境下的软件测试”的角度,介绍软件测试的定义、作用及其分类,让读者正确认识测试,明确自身在软件测试阶段中的定位,以及在软件测试过程中所承担的职责和所应完成的任务。

在理解软件测试的定义及作用之后,就要开始入门前端测试了,在这一部分我介绍了许多常用的自动化测试基础知识,比如断言、模拟,还介绍了单元测试框架 Jest 和最新的 Vitest 的基本使用并进行了较深入的比较。

最后是前端测试的实战部分,我演示了如何测试一个地址列表小应用(GitHub 仓库在这里),先介绍进行组件测试时要使用的组件挂载库 Vue Test Utils 和 Vue Testing Library,然后重点介绍了进行组件测试时的测试原则、测试技巧和一些注意事项。

本文篇幅较长,全程高能,建议收藏慢慢观看。

软件工程语境下的软件测试

什么是软件测试

什么是软件测试?要回答这个问题,我们首先需要先明确为什么要进行软件测试?答案很简单,就是为了保证软件质量。对于软件来说,不论采用什么技术和什么方法来进行开发,软件产品中都或多或少会存在一些错误和问题。采用先进的开发方式、完善的开发过程,可以减少错误的引入,但是不可能完全杜绝软件中的错误,而这些错误就需要通过测试来找出。因此,软件测试是软件质量保证的关键步骤。

关于软件测试的定义,一直有正反两方面的争辩。正面的观点是:软件测试是使用人工或自动手段来运行或测定某个系统的过程,目的在于检验它是否满足规定的需求或是弄清预期结果与实际结果之间的差别。这个观点明确地提出了软件测试是以检验是否满足需求为目标。

而反面的观点是:测试是为了发现错误而执行一个程序或系统的过程,测试就是为了发现缺陷,而不是证明程序无错误,如果没有发现程序中的问题,并不能说明问题就不存在,而是还没发现软件中潜在的问题。该观点认为,一个成功的测试必须是发现了软件问题的测试,否则测试就没有价值。

这正反两面的观点是从不同的角度看问题,一方面通过测试来保证质量,检验软件是否满足需求,另一方面由于测试不能证明软件没有丝毫错误,所以要尽可能找出不能正常工作的地方。在具体的应用场景中,软件测试应该在这两者之间取得平衡,或者有所侧重。

软件测试与软件开发的关系

介绍完了为什么需要软件测试以及什么是软件测试,我们明确了软件测试的定义及其作用。在这一小节,我们探讨软件测试和软件开发的关系,研究软件测试在软件工程中所扮演的角色。

在人们的刻板印象中,软件测试的活动似乎仅仅发生在编码完成之后,被认为是一种检验产品的手段,成为软件生命周期的最后一项活动而进行。在著名的软件瀑布模型中,软件测试处在编程阶段的下游,位于维护阶段的上游,先有编程、后有测试,测试的位置被放得很清楚。瀑布模型中的测试只有等到程序完成了之后才会执行,强调测试仅仅是对程序的检验:

4acb47292139318e0884686a3219163.jpg

然而瀑布模型属于传统的软件工程,存在较大的局限性,与软件开发的迭代思想、敏捷方法存在冲突,也不符合当今软件工程的实际需求。实际上,软件测试贯穿着整个软件生命周期,从需求评审、设计评审开始,软件测试就介入到软件的开发活动中。例如,通过对需求定义的阅读、讨论和审查,不仅可以发现需求定义的问题,还可以了解产品的设计特性、用户的真正需求,从而确定测试目标、准备测试用例并策划测试活动。

同理,在软件设计阶段,通过了解系统是如何实现的、构建在什么运行环境中等问题,可以衡量系统的可测试性、检查系统的设计是否符合系统的可靠性要求。

因此,软件测试和软件开发在整个软件生命周期中是相互协作,共同工作的。在软件的项目启动时,软件测试的工作便随之开始了。V 模型很好地反映了软件测试和软件开发之间的关系:

765000aa33001ac66fa5f64ce4ea3c0.jpg

如图所示,左边是软件定义和实现的过程,右边是对左边所构造的结果进行检验的过程,即测试与开发之间是一对一的关系,通过对开发工作成果的检验,来确认其是否满足规定的要求。

你可能会对 V 模型右边的各种测试类型有些疑惑,像功能测试、验收测试等测试都属于软件测试的分类。

软件测试的分类

软件测试可以从不同角度进行分类,例如根据测试的方法进行分类,也可以根据测试的目标和测试的阶段进行分类。如图所示,是软件测试的三维空间:

对于前端程序员来说,我们应该对其中的单元测试、集成测试和系统测试较为熟悉,这三个层次其实是按照被测试的对象或测试阶段划分的。具体内容在后面几节会介绍。

功能测试也称正确性测试,用于验证每个功能是否按照事先定义的要求正常工作,比如我们前端程序员写的大部分单元测试就属于功能测试。而其他目的的测试,如压力测试、兼容性测试和安全性测试则一般交给专业的测试人员负责。

回归测试是为保证软件中新的变化(如增加、修改代码)不会对原有功能的正常使用有影响和进行的测试。比如我们将新代码提交到版本控制库后在 CI/CD 管道运行测试脚本的行为,就属于回归测试。

此外,还有四类测试需要我们特别注意:

静态测试和动态测试

根据程序是否运行,测试可以分为静态测试和动态测试。

静态测试包括对软件产品的需求和设计规格说明书的评审,对程序代码的审查以及静态分析等。比如我们在编写完代码之后一般都会简单地检查所写的代码,通过观察程序的控制流或走向来分析其行为是否符合预期,这种静态分析便属于静态测试。

此外,使用 TypeScript 可以做到在编码时静态分析代码并进行类型检查,从而发现并提示程序中隐藏的类型错误,这个过程也属于静态测试。如果没有使用 TypeScript 等强类型语言,我们通常都需要在代码中使用 typeof 等关键字进行类型判断来避免这类类型错误,并在单元测试中创建并运行相应的测试用例来确保代码对类型的判断符合预期。所以从测试这一角度来看,TypeScript 这类语言或工具在某种程度上也解放了程序员的双手,让我们不必编写复杂且麻烦的判断程序类型处理是否正常的测试用例,专注于对程序的功能测试。

当然,使用像 ESlint、Prettier 这类的 linter 或 formatter 也属于静态测试,用以检查代码的格式、风格是否符合团队规范。

像这类使用工具对代码进行静态分析,检查代码是否符合需求的过程,属于自动化测试,所使用的工具称为测试工具。后面我们将聚焦于如何使用 Jest、Vitest 等测试工具或技术进行自动化测试及编写测试代码。

动态测试是通过运行程序发现错误,通过观察代码运行过程来获取系统行为、内存、堆栈及测试覆盖率等各方面的信息,来判断系统是否存在问题,或者通过有效的测试用例对应的输入输出关系来分析被测程序的运行情况,来发现缺陷。当写完一个组件后,我们都会让代码在浏览器上跑起来,观察组件的渲染效果或运行结果判断是否符合预期,这种行为就属于动态测试。

自动化测试和手工测试

刚才提到了自动化测试,这一小节我们来详细地介绍自动化测试。

软件测试是一项艰苦的工作,需要投入大量的时间和精力,据统计,软件测试会占用整个开发时间的 40%。但是,软件测试工作具有比较大的重复性。我们知道,在软件发布或新代码提交之前,都会进行多轮回归测试,也就是说,大量的测试用例会被重复执行很多遍,然而这个时候所进行的测试仅仅是为了验证所提交的功能或代码不会对已经实现的代码造成影响,所以找到缺陷的可能性一般很小。尽管执行大量的回归测试的效率低,但又是十分必要的。所以,自动化测试产生了。

自动化测试是相对手工测试而存在的概念,由手工逐个运行测试用例的操作过程被测试工具或系统自动执行的过程所代替。自动化测试是软件测试中提高测试效率、覆盖率和可靠性的重要手段,是软件测试不可分割的一部分。

自动化测试是把以人为驱动的测试行为转化为机器执行的一种过程,即模拟手工测试步骤,通过执行由程序语言编制的测试脚本,自动地完成软件的单元测试、功能测试、负载测试等工作。

对前端程序员来说,除了借助 TypeScript、ESlint 等静态测试工具进行自动化测试外,还可以使用 Jest、Vitest、Mocha 等单元测试工具和 Cypress、Playwright 等端到端测试工具来进行自动化测试。

白盒测试和黑盒测试

根据是针对软件系统的内部结构,还是针对软件系统的外部表现行为采取的测试方法,分别被称为白盒测试方法和黑盒测试方法。

白盒测试,也称为逻辑驱动测试或结构化测试,是已知产品的内部工作过程,清楚其程序结构和语句,按照程序内部的结构测试程序,测试程序内部的变量状态、逻辑结构、运行路径等,检验程序中的每条通路是否都能按预定要求正常工作,检查程序内部动作或运行是否符合设计规格要求。

有写过单元测试的同学可能知道在完成测试代码的编写后我们通常都会跑一次代码覆盖测试,根据生成的代码覆盖率报告来判断所编写的测试是否充足。这里的代码覆盖率是通过运行的测试代码所覆盖源代码的分支、函数和语句等的程度占源代码的比值来得到的。如果代码覆盖率未达到要求,我们就需要为未覆盖到的代码编写一个或多个测试用例来提高覆盖率,这种测试方法就可以称为白盒测试。

此外,刚才提到的 TypeScript、ESlint 等测试工具也可以说是一种白盒测试工具。

黑盒测试,也称为数据驱动测试,在测试时,把程序看作一个不能打开的黑盒子,在完全不考虑程序内部结构和内部特性的情况下针对软件直接进行测试。

黑盒测试不关注软件内部结构,而着眼于程序外部用户界面,关注软件的输入和输出,关注用户的需求,直接获得用户体验,从用户的角度或扮演用户角色来验证软件功能。

作为前端程序员,我们所使用的测试方法绝大多数都应该使用黑盒测试,具体的原因和做法可以看下文。

单元测试、集成测试和系统测试

软件系统是由许多单元构成的,这些单元可能是一个对象、类或函数,也可能是一个更大的单元,组件或模块。要保证软件系统的质量,首先就要保证构成系统的单元的质量,也就是要开展单元测试。通过充分的单元测试,发现并修正单元中的问题,从而为系统的质量打下基础。

单元测试的大部分工作应该由开发人员完成,但是很多开发人员只把注意力放在编程上,把代码写出来,而不愿在测试上花费时间,让测试人员去进行测试。需要明确的是,如果没有做好单元测试,软件在集成阶段及后续的测试阶段会发现更多的、各种各样的错误,大量的时间将被花费在跟踪那些隐藏在独立单元内的、简单的错误上面,导致整个项目的工期增长,提高软件成本。

作为软件开发人员,一定要明确一点:软件存在的错误发现得越早,修改和维护的费用就越低,难度也越小,而单元测试就是早期抓住这些错误的最好时机。

单元测试强调被测试对象的独立性,被测的独立单元将与程序的其他部分隔离开,以避免其他单元对该单元的影响。例如将被测模块与其父模块和子模块隔离开,单独进行测试。但是将其依赖隔离开的话又可能导致被测模块无法正常工作,这时候就需要用到前端单元测试中经常使用的 Mock,即模拟,具体内容可以看下文。

在软件开发中,经常会遇到这样的情况:单元测试时能确认每个模块都能单独工作,但这些模块集成在一起之后会出现有些模块不能正常工作的问题。仔细思考便可以知道,这主要是因为模块集成到一起后相互调用时的接口出现问题,如接口参数不匹配、传递错误数据等问题。这时就需要进行集成测试。集成测试是将已通过测试的单元按设计要求集成起来再进行的测试,以检查这些单元之间的接口是否存在问题。

在进行集成测试时,需要选择集成模式,即按照怎样的策略进行集成。集成测试基本可以概括为以下两种:

  • 非渐增式测试模式:先分别测试每个模块,再把所有模块按设计要求放在一起结合成所要的程序进行测试;

  • 渐增式测试模式:把下一个要测试的模块同已经测试好的模块结合起来进行测试,测试是在模块一个一个的扩展下进行,测试的范围也逐步增大。

在实际工作中,一般采用渐增式测试模式,具体的实践有自顶向下、自底向上、混合策略等。当然,具体情况具体分析。

经过集成测试之后,分散开发的模块被集成起来,构成相对完整的体系,其中各模块间接口存在的种种问题基本都已消除,此时就可以进入系统测试阶段。

系统测试是将经过集成测试过后的软件,作为计算机系统的一个部分,与计算机硬件、数据和平台等系统元素结合起来,在真实运行环境下对计算机系统进行一系列的严格有效的测试来发现软件的潜在问题,保证系统的正常运行。

系统测试分为功能测试和非功能性测试。

系统级功能测试不仅要考虑模块之间的相互作用,而且要考虑系统的应用环境,而且要模拟用户完成从头到尾,即端到端的业务测试, 确保系统可以完成事先设计的功能,满足用户的实际业务需求。

系统非功能性测试是在实际运行环境或模拟实际运行环境上,针对系统的非功能特性所进行的测试,包括负载测试、性能测试、安全测试等。

测试驱动开发

在敏捷方法中,提出测试驱动开发(Test Driven Development,TDD),即测试在先、编码在后的开发方法。TDD 有别于以往的先编码后测试的开发过程,而是在编程之前,先写测试脚本或设计测试用例。这种强调”测试先行“的模式,可以使开发人员对所写的代码有足够的信心,同时也有勇气进行重构。

TDD 的具体实施过程是,在打算添加某个新功能时,先不急着写功能代码,而是将各种特定条件、使用场景等想清楚,然后为待编写的代码先些一段测试用例,接着使用一些测试工具运行这段测试用例,运行的结果自然是失败,此时利用测试工具的错误信息,了解代码没有通过测试的原因,然后有针对性地逐步添加代码,接着再运行测试,不断地修改、添加代码,直至测试用例通过。

TDD 使得开发人员不能再像过去那样随意写代码,要求写的每行代码都是有效的代码。而在此之前,即使代码写完了,编程工作也还没结束,因为还没进行单元测试,经过单元测试后可能还会出现错误,需要再次进行修正。TDD 在于预设各种应用场景、前提条件,促进开发人员思考,写出更完善、更高质量的代码,提高工作效率。

此外,TDD 还可以确保测试的独立性,使测试用例的设计不受实现思维的影响,确保测试的客观和全面。

对于抽象能力高,在编写代码前喜欢先进行各种场景预设、思考前提条件的程序员来说,TDD 无疑是一种福音,但如果你抽象能力不足或急着实现功能,也不必强求,在完成功能之后及时补充单元测试就行。


在软件工程语境下的软件测试的所有相关内容和概念就介绍到这里,软件测试作为软件工程中重要的组成部分,在软件开发中发挥着至关重要的作用,贯穿软件的整个生命周期。理解软件测试的定义、作用及其分类,可以使作为程序员的我们明确自身在软件测试阶段中的定位,了解自身在软件测试过程中所承担的职责和所应完成的任务。希望你能好好理解这些内容。

现在,让我们将目光从软件工程中的测试转移到前端开发的测试中,作为前端程序员,应该做哪些测试以及怎样进行测试呢?

前端程序员所要进行的测试

作为前端开发人员,当构建一个 Web 或其他类型的应用时,从被测试对象的角度,可以进行以下三类测试:

  • 单元测试。前面提到,单元测试的大部分工作应该由开发人员完成,前端程序员也是如此。我们需要对单个独立的函数、类或一个组合式函数、hook 进行测试,将其与应用的其他部分隔离开来。而且应该进行功能测试,侧重于被测单元在功能上的正确性,而非进行兼容性测试、性能测试等其他测试。而且,由于前端应用的特殊性,为了创建一个与外界隔离的环境,我们往往需要模拟应用环境的很大一部分,如第三方模块、网络请求等;

  • 组件测试。如今大多数 Web 应用都会使用 Vue、React 这类提倡组件化开发的框架进行开发,因此对所编写的组件进行测试在前端测试中应当占据比较大的比重。组件测试需要检查组件是否正常挂载或渲染、是否可以正常交互,以及表现是否符合预期;

  • 端到端(E2E)测试。当完成单元测试和组件测试之后,我们还需要进行端到端测试,将整个应用部署到实际运行环境或模拟真实运行环境下运行,从用户的视角对整个应用进行从头到尾、端到端的业务测试,确保应用表现符合预定需求。端到端测试除了测试用户界面的真实交互效果和逻辑行为外,还会发起真实的网络请求,向后端的数据库获取数据,并且在真实浏览器上运行,捕捉应用运行出错时的快照、视频等信息。端到端测试可以说是一种系统功能测试。当然,(自动化的)端到端测试也不是非要做,也不是非要前端做,在实际开发过程中还应结合实际情况选择合适的测试方案。

除了进行以上三种功能测试外,前端程序员还可进行性能测试,如借助浏览器的 LightHouse、Performance 功能检测页面的渲染、交互性能。还可进行兼容性测试等其他测试。由于不是本文重点内容,就不进行介绍了。

对一个庞大的应用进行单元测试、组件测试和端到端测试,往往需要设计大量的测试用例,执行多次且重复的测试,要想大幅缩短在测试上所花费的时间,自然就需要用到自动化测试,通过使用测试工具、编写测试脚本来提高测试效率,所幸前端领域经过这么多年的发展,在社区上早已出现了很多优秀的开源测试工具。接下来,我将介绍如何利用测试工具进行自动化测试,编写测试脚本,让你全面地入门自动化测试。

前端自动化测试入门

如果现在要你测试以下这个函数,你要怎么做?

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

第一步自然是设计测试用例,比如输入 1 和 2,这个函数会输出 3。设计好测试用例之后,当然就要让这个函数跑起来,传入 1 和 2,打印函数返回值看看是否为 3。于是可以写出以下这段测试代码:

console.log(sum(1, 2))

然后运行这段代码,检查打印结果是否为 3。这样,一个测试代码就完成了。当然,这个函数过于简单,用静态测试的方法也能进行测试,这里只是方便举例。除此之外,这段代码运行起来还都需要人工观察运行结果来检验测试成果,这其实也不属于自动化测试的范畴。

当类似的测试做多了之后我们就可以发现一个规律,大多数测试用例,都是设计一个或多个输入数据,以及对应的输出数据,通过传入这些输入数据时被测代码是否产生或返回这些输出数据来判断被测代码是否运行正常,这个判断的过程就叫作断言(assertion)

断言

Node 的 assert 模块就提供了进行断言的 API,比如使用 equal 方法对上述的 sum 函数进行断言,可以这样:

assert.equal(sum(1, 2), 3)

运行这段代码,如果 sum 函数的实现不符合预期,equal 方法就会抛出一个 AssertionError 错误,并打印详细的错误原因。

除了 Node 提供的 assert 模块外,社区还出现了很多断言库,提供了多样的断言风格,最具代表性的当属 ChaiJest

Chai

Chai 提供了三种不同的断言风格供用户选择。

assert

assert 风格与 Node 的 assert 模块类似,但是提供了更多 API,并且可以在浏览器上运行:

const assert = require('chai').assert
const foo = 'bar'

assert.typeOf(foo, 'string') // without optional message
assert.typeOf(foo, 'string', 'foo is a string') // with optional message
assert.equal(foo, 'bar', 'foo equal `bar`')

assert 风格的 API 允许使用者传入一个可选的描述断言行为的字符串到最后一个参数,当断言失败后错误信息中就会显示这个字符串。

BDD

BDD 风格提供两类断言:expect 和 should,两者都支持链式调用的语法让使用者可以用一种贴近自然语言的方式进行断言。使用方式如下:

// expect:
const expect = require('chai').expect
const foo = 'bar'

expect(foo).to.be.a('string')
expect(foo).to.equal('bar')

// should:
const should = require('chai').should()
const foo = 'bar'

foo.should.be.a('string')
foo.should.equal('bar')
foo.should.have.lengthOf(3)

仔细观察这两类 API 的使用方式就可以看出差别:使用 expect 时只需将待测结果包裹进 expect() 函数便可进行链式调用,而使用 should 语法时则只需调用 should() 方法就可直接在待测结果上进行链式调用,其原理也很明显:调用should() 函数后在对象的原型上添加了 should() 方法的定义。

Jest

Jest 风格的 API 与 Chai 的 expect 语法类似,但是不提供链式调用,而是直接调用一个方法进行断言:

expect(2 + 2).toBe(4)
expect('How time flies').toContain('time')
expect({a: 1}).not.toEqual({b: 2})

如上例所示,像 toBe()toEqual 这类对待测内容的某个方面进行断言的方法,称为匹配器(Matcher)。常用的匹配器有 toBetoEqultoContain 等等。可以查阅 Jest 的匹配器 API 文档 了解更多内容,匹配器的数量不多,也就不到 40 个,相信你可以轻松搞定,这里就不赘述了。

使用 Jest

通过对单元测试最基本的步骤,即断言的介绍,我们了解了三种断言风格及相应的 API,在具备该编写单元测试的基本能力之后,我们来正式地学习如何使用自动化测试工具来进行单元测试,以 Jest 为例。

Jest 除了是一种断言风格之外,还是一个用于单元测试的测试框架,具备运行测试脚本的能力。它对普通的 JS 项目无需配置,开箱即用,同时支持快照测试、并行测试等优秀能力。

我们来尝试一下使用 Jest 进行单元测试。首先安装 Jest:

npm install jest -D

安装完毕后,我们新建一个 __tests__ 目录,然后创建一个 sum.spec.js 文件。默认情况下当运行测试时 Jest 会自动搜索并运行 __tests__ 目录下的所有 .js.jsx.ts 文件和根目录下所有带有 .test or .spec 后缀的文件,所以我们不必手动设置测试文件的位置。

sum.spec.js 文件下我们可以输入以下测试代码:

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

describe("sum", () => {
  test("输入 1 和 2,输出 3", () => {
    expect(sum(1, 2)).toBe(3)
  })
})

写好测试代码之后,输入以下命令就可以启动 Jest 来运行测试:

npx jest

测试运行完毕后,Jest 就会在控制台输出以下内容表明测试通过:

image.png

OK,一个超级简单的单元测试就完成了!

我们来详细介绍一下测试代码中使用到的函数:

用于组织测试代码的 describe()test()

第一个是 test() 方法,用于声明一个测试用例(test case,可直接称为一个测试,即 test)。我们在写单元测试时基本上就是以测试用例为单位来组织测试,它的第一个参数接受一个字符串,用于描述这个测试用例的内容,这里我们以“输入xx,输出xx”的格式来描述这个测试用例,这样可以清晰地表明这个测试用例的意图。

test() 方法的第二个参数是一个函数,包含了这个测试用例的主体内容,即断言。一个测试用例可以包含多个断言,但是所断言的内容应该符合这个测试用例的意图。

test() 方法还接收一个可选的 timeout 参数,用以指定测试的超时时间,默认是 5 秒。

test() 方法还有一个别名:It(),如果使用 It() 来描述测试用例可以采用更符合自然语言的语法,比如:

It("should return the correct result", () => {
  expect(sum(1, 2)).toBe(3)
  expect(sum(2, 4)).toBe(6)
  expect(sum(10, 100)).toBe(110)
})

describe() 方法可以组织一个或多个测试用例,将多个相关的测试组合成一个块,这种块叫作测试套件(test suite)。使用 describe() 来组织测试用例是一个推荐的写法,可以将测试内容与其他内容隔离,更有利于维护。

describe() 方法可以嵌套使用,比如可以像这样(来自官网示例):

describe('binaryStringToNumber', () => {
  describe('given an invalid binary string', () => {
    test('composed of non-numbers throws CustomError', () => {
      expect(() => binaryStringToNumber('abc')).toThrowError(CustomError);
    });

    test('with extra whitespace throws CustomError', () => {
      expect(() => binaryStringToNumber('  100')).toThrowError(CustomError);
    });
  });

  describe('given a valid binary string', () => {
    test('returns the correct number', () => {
      expect(binaryStringToNumber('100')).toBe(4);
    });
  });
});

嵌套的 describe() 块允许我们对测试用例进行更细粒度的分配和组织。当然,如果你不喜欢或不习惯用 describe() 也是可以的,你可以直接在顶层上下文中使用 test() 方法,Jest 会自动为其包裹一个测试套件。

到此为止,用于组织编写测试代码最常用的两个函数:describe()test() / It() 就介绍到这里了。此外,使用这两个函数时还支持使用 skiponly 等扩展方法来跳过或在某些条件下跳过或过滤测试套件和测试用例的运行:

test.skip("跳过这个测试", () => {
  expect(sum(1, 2)).toBe(3)
})

test.only("只允许这个测试", () => {
  expect(sum(1, 2)).toBe(3)
})

更多 API 及详细内容可以查阅文档,这里不过多介绍了。

看到这里,你可能会问,describe()test() 这些函数在使用之前不需要先引入吗?答案是不用,Jest 在执行测试代码之前会自动将这些全局 API 注入到全局上下文中,可以直接使用,不必手动引入。如果你更想要手动引入,可以新建一个 Jest 的配置文件,将 injectGlobals 字段的值置为 false 即可关闭全局注入。

钩子函数

当编写的测试代码较复杂,包含很多重复的如初始化的操作时,我们可以将这些重复的内容拆解(tear down)出来,放到钩子函数中执行。一个测试文件、测试套件和测试用例的执行也是有生命周期的,钩子函数将这些生命周期拆分为执行前和执行后两个阶段。Jest 提供了四个钩子函数允许使用者在这些生命周期中进行一些自定义行为。

beforeAll()afterAll() 允许使用者注册一个回调,该回调会在当前上下文中的所有测试运行之前或之后被调用一次。

比如如果将 beforeAll() 放在顶层上下文中调用:

beforeAll(() => {
  console.log(1)
})

describe("sum1", () => {
  test("测试1", () => {
    expect(sum(1, 2)).toBe(3)
  })
  
  test("测试2", () => {
    expect(sum(1, 2)).toBe(3)
  })
})

describe("sum2", () => {
  test("测试3", () => {
    expect(sum(1, 2)).toBe(3)
  })
  
  test("测试4", () => {
    expect(sum(1, 2)).toBe(3)
  })
})

console.log(1) 语句只会在两个套件内的测试执行前执行一次。afterAll() 也是同理。

而如果将 beforeAll() 放到测试套件内执行:

describe("sum1", () => {
  test("测试1", () => {
    expect(sum(1, 2)).toBe(3)
  })
  
  test("测试2", () => {
    expect(sum(1, 2)).toBe(3)
  })
})

describe("sum2", () => {
  beforeAll(() => {
    console.log(1)
  })
  
  test("测试3", () => {
    expect(sum(1, 2)).toBe(3)
  })
  
  test("测试4", () => {
    expect(sum(1, 2)).toBe(3)
  })
})

console.log(1) 语句只会在 sum2 套件内的测试执行前执行一次。

beforeEach()afterEach() 所注册的回调会在当前上下文中的每个测试运行之前或之后被调用一次。注意与 beforeAll()afterAll() 的区别,前者是运行每个测试前后执行一次,后者是在运行所有测试前后执行一次。

这四个钩子函数是我们编写测试代码时非常常用的函数了,一些模拟、初始化和清除状态的逻辑我们都会放到钩子函数中进行。你可能会问,如果测试文件之间也包含一些重复的逻辑时要怎么处理呢?

Jest 允许我们编写一个在每个测试文件的测试代码运行之前运行的 setup file 进行跨文件的配置。我们需要先新建一个 setup file,比如在根目录下创建一个 jest-setup.js 文件,输入以下内容:

beforeAll(() => {
  console.log(1)
})

接着在根目录下新建一个 Jest 的配置文件 jest.config.js,输入以下内容:

/** @type {import('jest').Config} */
module.exports = {
  setupFilesAfterEnv: ['<rootDir>/jest-setup.js'],
}

setupFilesAfterEnv 字段用于指定一个 setup file 数组的路径,这些文件会在 Jest 的执行环境(包括 describe()、钩子函数等全局 API 的初始化)安装之后、测试代码运行之前执行。


有关使用 Jest 进行单元测试的两个基础知识就介绍到这里了,在开始接下来的重点内容之前,我们来聊聊 Jest 这个测试框架本身。

通过以上几个示例,我们算是小小地入门了 Jest 的使用,充分体会到了 Jest 的”无需配置“的妙处,即安装之后即可开始编写测试代码,并且无需手动引入相关 API,测试代码写完之后启动一行命令即可开始运行测试,不得不说真的很方便。

但是,我们刚才所展示的仅仅是一个非常简单的测试一个 JS 函数的场景,要是应用场景更复杂一点,比如对 Web 应用进行单元测试,Jest 可能就不会像现在这样方便了。为什么这么说呢?

思考一下,Jest 是如何运行测试文件的?自然是用 Node 运行的,详细点说,就是在注入 describ()beforeAll 等全局 API 后,就使用 Node 来运行测试代码,处理所导入的依赖的路径解析和加载。这时如果导入的是一个 vue 文件,测试就会立即失败,因为 Jest 不认识这个类型的文件。甚至如果直接使用 TypeScript 来编写测试代码,也会导致测试失败。这就意味着,如果是 .ts.vue.jsx 类型的文件 Jest 就无法先天支持了,因为它只认识 JS 语法。要想支持其他语法的运行,就需要使用一些 transformer 进行转换, 将 .tsjsx 等语法转换为标准 JS 语法,才能继续执行测试代码。

比如想让 Jest 能够加载、运行 .ts.vue 格式的文件,就需要这样配置:

// jest.config.js
module.exports = {
  transform: {
      '^.+\\.(j|t)sx?$': 'babel-jest',
      '^.+\\.vue$': '@vue/vue3-jest'
  }
}

我们使用 babel 来处理 Typescript 内容,将其中的类型注解删除掉,需要提前安装 @babel/core@babel/preset-env@babel/preset-typescript 这几个包并新建一个 babel.config.js 来配置 babel 的行为:

// babel.config.js
module.exports = {
  presets: [
    '@babel/preset-typescript',
    ['@babel/preset-env', { targets: { node: 'current' } }],
  ],
}

要想 Jest 支持 Vue 文件的转换需要使用 vue-jest,这里我用的是支持 Vue3 的版本。

此外,Jest 还未支持 ESM 规范,仍处于实验阶段,也需要使用 babel 降级。所以,如果想要使用 Jest 来测试一个 Web 应用,需要进行更多配置。另外,由于 Jest 的测试和构建工具的开发、构建是在两个不同的转换管道中进行的,需要使用两个不同的配置文件,这也无形中加大了项目前期搭建的负担。

因此,相比于使用 Jest,我更推荐你使用 Vitest

image.png

Vitest 是一个由 Vite 提供支持的极速单元测试框架,它底层依赖了 Vite,借助于 Vite 强大的能力,Vitest 支持了以下这些优秀特性:

  • 与 Vite 共享同一套配置!如果你的项目也是选择 Vite 作为构建工具,那么你可以与 Vite 共享同一套配置。这是因为 Vitest 同样使用 Vite 对你的测试代码及其引入的所有模块使用 Vite 的解析器、加载器、转换器进行处理,这意味着当 Vitest 运行时你的 Vite 配置文件中配置的除用于生产环境以外的所有插件都会被调用并发挥作用,你的测试环境会和开发环境一样使用同一个运行管道,而不需要像 Jest 一样进行额外的配置!

  • 真正的开箱即用,而且速度超快!借助 Esbuild 对 TypeScript、JSX 和 ESM 语法的天然支持,Vite 原生就具备处理这几类语法的能力,促使 Vitest 做到真正的开箱即用,并且速度超快!

  • 测试的 HMR!Vite 的开发服务器在对模块进行加载、转换的过程中,会逐步构建出一个缓存转换结果、记录模块依赖关系等信息的模块图(Module Graph)。借助模块图,Vite 可以清晰地处理模块热更新的边界,确定进行更新的范围。而 Vitest 借助 Vite 的 HMR 能力,同样可以做到在修改源代码后重新运行依赖该源代码的测试文件,做到测试的 HMR。Vitest 在默认情况下会启动监听模式(watch mode),即自动进行 HMR,这对喜欢使用 TDD 的模式进行开发的同学来说无疑是个福音!

除了以上三点通过 Vite 得到支持的优秀能力之外,Vitest 还具备以下几个功能:

  • 多线程运行测试文件。通过使用 Worker 线程尽可能多地并发运行测试文件,使多个测试同时运行。同时,Vitest 还隔离了每个测试文件的运行环境,使某一个文件的状态不会对其他文件造成影响。

  • 支持多数测试框架的常用功能。例如快照测试、模拟(Mock)、测试覆盖率、并发运行测试和模拟 DOM 等功能。

  • 兼容 Chai 和 Jest 的 API。内置 Chai 的断言 API 和 Jest 的大多数 API。

Vue 官方的脚手架 create-vue 已将 Vitest 作为默认的单元测试框架。如果你还在犹豫不决,觉得 Vitest 还是一个较新的测试框架,怀疑是否可以在实际项目中使用的话,可以看这篇文章

使用 Vitest

在介绍完 Vitest 的功能之后,我们来尝试一下它的基本使用。同样先安装 Vitest:

npm install -D vitest

安装完毕后,我们来编写测试代码,同样对 sum 函数进行测试:

import { describe, test, expect } from "vitest"

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

describe("sum", () => {
  test("输入 1 和 2,输出 3", () => {
    expect(sum(1, 2)).toBe(3)
  })
})

由于 Vitest 在默认情况下不自动注入全局 API,因此我们需要手动引入 describe()test() 等方法。当然,可以通过配置 globals 字段来开启自动注入,这里我们先不开启。

测试代码编写好后,运行以下命令启动 Vitest:

npx vitest

与 Jest 不同,Vitest 会将所有带有 spec 和 test 后缀的 js、ts 等类型文件视为测试文件。具体可以看 include 字段

当控制台打印以下信息时说明测试通过:

image.png

以上就是 Vitest 的基本使用了,关于 Vitest 的更多内容可以看下文的实战小节。现在我们来继续学习单元测试的常用功能,这部分是重点内容。

测试异步代码

在真实的场景中测试异步代码是一件非常常见的事,比如测试后端 API,异步函数等等。由于异步代码的特殊性,在测试它们时需要做更多的工作。

比如我们要测试以下这个异步函数:

async function hello() {
  return "Hello World!"
}

要断言它返回了 “Hello World!” 字符串,如果按照测试同步函数的方式进行测试:

test("hello", () => {
  expect(hello()).toBe("Hello World!")
})

运行这个测试会直接报错:

image.png

原因很简单,我们断言的并不是 “Hello World!” 这个字符串,而是这个函数返回的 Promise 对象。

知道了原因之后,我们可以很自然地进行改进:

test("hello", async () => {
  const res = await hello()
  expect(res).toBe("Hello World!")
})

我们改为将一个异步函数传入 test() 方法,该异步函数使用 await 关键字等待 hello 函数 resolve 并返回结果,接着就可以对其返回结果进行断言。

除了使用 await 等待异步函数调用完成之外,我们还可以使用 resolves()rejects() 方法。使用方式如下:

test("hello", async () => {
  await expect(hello()).resolves.toBe("Hello World!")
})

可以看到,使用 resolves() 可以从 hellow 函数返回的 Promise 对象中提取所 resolve 的值,然后直接对该值进行断言。

rejects() 方法的使用方式同理:

async function hello() {
  throw new Error("Hello World!")
}

test("hello", async () => {
  await expect(hello()).rejects.toThrow("Hello World!")
})

以上便是测试异步代码的两种方法,比较简单,相信你可以很快掌握。

处理定时器

虽然定时器回调也算是异步代码的一种,但是它毕竟不返回 Promise,我们还需对其进行其他处理。

比如测试以下代码:

let a = 1

function timer() {
  setTimeout(() => {
    a = 2
  }, 3000)
}

要断言调用 timer 函数会在 3 秒后将 a 的值置为 2,我们要怎么做呢?如果直接使用同步的方式,即:

test("timer", () => {
  expect(a).toBe(1)
  timer()
  expect(a).toBe(2)
})

运行结果自然是报错,因为第二个断言会在回调调用之前执行:

image.png

(注:在这个示例中我们在对函数进行调用之前断言了 a 的初始状态,即第一个断言,这是为了保证在进行正式的断言之前待测对象的状态不会发生意外改变,确保正式的断言的结果是由我们的操作(这里是调用 timer 函数和)产生的,而非外界的干扰。你可以把这个步骤理解为一种控制变量的做法。)

要想做到断言定时器的操作结果,我们可以使用 vitest 模块导出的 vi 工具对象中的 useFakeTimers() 方法,该方法的作用顾名思义——使用假的定时器。当调用 useFakeTimers() 方法使用 fake timers 之后,所有对定时器的调用,包括 setTimeoutsetIntervalnextTicksetImmediate ,所传入的回调都会被”滞留”在定时器队列中,得不到执行,即使达到了指定的 timeout 时间。需要手动调用 vi.runAllTimers()vi.advanceTimersByTime() 等方法才可以执行这些回调。比如:

test("timer", () => {
  vi.useFakeTimers()
  expect(a).toBe(1)
  
  timer()
  vi.runAllTimers()
  
  expect(a).toBe(2)
  
  vi.useRealTimers()
})

调用了 vi.useFakeTimers() 使用 fake timers 之后,我们可以调用 vi.runAllTimers() 来运行所有处于队列中的定时器回调。另外,为了避免对其他测试造成影响,在测试的最后我们还需要调用 vi.useRealTimers() 恢复使用真实定时器。在实际场景中,我们可以选择在钩子函数中处理这些初始化、清除副作用的操作。

我们也可以使用 vi.advanceTimersByTime(),它可以只执行所传入的毫秒数以内对应的超时时间的回调:

test("timer", () => {
  vi.useFakeTimers()
  expect(a).toBe(1)

  timer()

  vi.advanceTimersByTime(2000)
  expect(a).toBe(1)

  vi.advanceTimersByTime(3000)
  expect(a).toBe(2)

  vi.advanceTimersByTime(4000)
  expect(a).toBe(2)

  vi.useRealTimers()
})

除了模拟定时器外,vi.useFakeTimers() 还可以模拟日期(Date),具体用法可以看 vi.setSystemTime() 方法。

模拟(Mock)

在真实的测试场景中,我们需要应付许多调用后端 API 的模块,由于调用这些接口需要发起网络请求,导致测试时间变相增长,降低测试效率,并且,我们做的也不是端到端测试,而是将待测对象与外界隔离的单元测试,不必发起真实的网络请求。另外,在单元测试下为了屏蔽其他模块,比如第三方模块,我们还需要避免对它们的调用,甚至伪造一个假的模块。更重要的是,很多时候我们还需要断言待测对象对其他模块或方法的调用,即进行监听。在这种情况下,模拟(Mock)就派上用场了。

模拟的方式,大致可以分为两种:stub(存根) 和 spy(监听)。

stub 会改变被模拟对象的实现,即伪造另一个版本来代替被模拟的对象。与之相反,spy 无需改变被模拟对象的实现,但是会监听对其的使用,如监听函数的调用次数、传入的参数等等。

我这里仅仅按照”实现是否被更改”来对模拟的方式进行分类,也有将模拟分为 mock、stub 和 fake 等等的分类,其实也不必纠结这几种分类和模拟方式之间的差异,大多数场合将它们统称为模拟(Mock)即可。

接下来我按照模拟函数、模拟模块的顺序来介绍模拟的具体使用。

模拟函数

比如现在我们要监听对 obj 对象的 sum 方法的调用,获取对该方法的调用次数、参数、返回值等信息,要怎么做呢:

const obj = {
  sum: (a: number, b: number) => {
    return a + b
  }
}

可以使用 vi.spyOn() 方法:

test("spy", () => {
  vi.spyOn(obj, "sum")

  obj.sum(1, 2)

  expect(obj.sum).toBeCalledTimes(1)
  expect(obj.sum).toBeCalledWith(1, 2)
  expect(obj.sum).toHaveReturnedWith(3)

  vi.mocked(obj.sum).mockClear()
})

vi.spyOn() 可以监听一个对象上的方法。在调用被监听的函数之后,我们就可以通过 toBeCalledTimes()toBeCalledWith() 等等这类匹配器来断言调用信息。

vi.spyOn() 返回一个 SpyInstance 类型的对象,我们也可以直接在这个对象上进行断言,比如:

test("spy", () => {
  const spy = vi.spyOn(obj, "sum")

  obj.sum(1, 2)

  expect(spy).toHaveBeenCalledOnce()
  expect(spy).toHaveBeenNthCalledWith(1, 1, 2)
  expect(spy).toHaveReturnedWith(3)

  spy.mockClear()
})

你可能已经注意到了我们在测试最后调用了一个 mockClear() 方法,该方法用于清除被模拟的对象的所有调用信息。使用它的目的与前文使用的 vi.useRealTimers() 一样,为了不对其他测试造成影响。类似的方法还有 mockReset()mockRestore(),前者用于清除调用信息和将被模拟对象的实现置为一个空函数,后者用于清除调用信息和还原被模拟对象的原始实现。这个示例中由于我们仅仅是对函数进行监听,没有修改内部实现, 因此调用 mockClear() 就足够了。

对每个模拟对象调用 mockClear()mockReset() 很快会变成重复的行为,我们可以使用 vi.clearAllMocks()vi.resetAllMocks()vi.restoreAllMocks() 一次性对所有的模拟对象进行这些操作,通常把对这三个方法的调用放到钩子函数里。

mockClear() 是 SpyInstance 和 MockInstance 类型上的方法,所以我们可以直接在 vi.spyOn() 返回的对象上调用它,如果我们想直接在原函数上调用该方法,像下面这样:

obj.sum.mockClear()

如果你使用的是 JS,这可以行得通,但是如果你使用的是 TS 的话,就会直接报错了。在这种情况下,可以使用 vi.mocked() 来为被模拟的对象提供类型支持:

vi.mocked(obj.sum).mockClear()

如果我们要模拟另一个模块导出的函数要怎么做呢?比如:

// math.ts
export function sum(a: number, b: number) {
  return a + b
}

这时候我们可以以命名空间的形式来导入 math 模块:

import * as math from "./math"

test("spy", () => {
  vi.spyOn(math, "sum")

  math.sum(1, 2)

  expect(math.sum).toBeCalledTimes(1)
  expect(math.sum).toBeCalledWith(1, 2)
  expect(math.sum).toHaveReturnedWith(3)

  vi.mocked(math.sum).mockClear()
})

可以看到十分简单粗暴。看到这里你可能会有疑问:只能监听对象上的方法吗,不能直接监听函数吗?

据我所知,好像不能。如果真想直接监听函数的话,可以这样做:

import { sum } from "./math"

test("spy", () => {
  const math = { sum }
  vi.spyOn(math, "sum")

  math.sum(1, 2)

  expect(math.sum).toBeCalledTimes(1)
  expect(math.sum).toBeCalledWith(1, 2)
  expect(math.sum).toHaveReturnedWith(3)

  vi.mocked(math.sum).mockClear()
})

直接将它放到一个对象上就行了。

学完了怎么监听函数,我们来看看怎么模拟一个函数。比如我们想将 sum 函数模拟成以下这样:

function sum(a: number, b: number) {
  return a + b + 100
}

可以直接在 SpyInstance 上调用 mockImplementation() 方法:

test("mock", () => {
  vi.spyOn(obj, "sum").mockImplementation((a, b) => a + b + 100)

  obj.sum(1, 2)

  expect(obj.sum).toHaveReturnedWith(103)

  vi.mocked(obj.sum).mockRestore()
})

mockImplementation() 方法可以直接在 SpyInstance 和 MockInstance(继承了 SpyInstance)上使用,用于模拟被模拟对象的实现。由于我们更改了 sum 的内部实现,因此测试完毕后需要调用 mockRestore() 将其还原。

模拟模块

介绍完了如何监听和模拟函数,我们来看看如何模拟模块。

模拟模块需要使用 vi.mock() 方法,比如要模拟刚刚的 math 模块,我们可以这样做:

import { sum } from "./math"
 
vi.mock('./math')

test("mock", () => {
  sum(1, 2)

  expect(sum).toHaveBeenCalledOnce()
  expect(sum).toHaveBeenCalledWith(1, 2)

  vi.mocked(sum).mockRestore()
})

我们将要模拟的模块的路径传入 vi.mock() 方法后,该方法会自动模拟被模拟模块的所有导出内容,所以当我们调用了该模块的某一个导出函数后,我们就可以直接对其进行断言。

我们也可以传入一个工厂函数来定义该模块要导出什么内容,比如:

vi.mock('./math', () => ({
  sum: (a: number, b: number) => a + b + 100
}))

我们模拟了 math 模块的导出内容,其返回了一个新的 sum 方法。但是运行测试发现测试失败了:

image.png

这是因为我们仅仅模拟了 math 模块,而没有模拟它导出的 sum 函数。我们来学习模拟函数的另一种方法:vi.fn()

调用 vi.fn() 会返回一个空的 Mock 类型的模拟函数,Mock 也继承了 SpyInstance,我们可以直接对该函数调用 toHaveBeenCalledOnce() 等匹配器。直接调用模块函数会返回 undefined。我们可以传入一个函数到 vi.fn() 中来模拟其返回的模拟函数的实现。以上代码可以修改为:

vi.mock('./math', () => ({
  sum: vi.fn((a: number, b: number) => a + b + 100)
}))

运行测试后显示测试通过,说明我们模拟成功了。

如果我们只想模拟模块导出的某个特定函数,其他导出内容维持原样,可以这样做:

import { sum } from "./math"
import * as Math from "./math"
 
vi.mock('./math', async () => ({
  ...await vi.importActual<typeof Math>('./math'),
  sum: vi.fn((a: number, b: number) => a + b + 100)
}))

vi.importActual() 可以原封不动地导入模块的所有导出内容。注意当使用 TS 时,记得传入类型。

除了传入一个工厂函数外,我们还可以将要模拟的导出内容放到一个 __mocks__ 目录里,这样当调用 vi.mock() 时如果 __mocks__ 目录下存在同名文件,所有导入都会返回其导出。比如在 __tests__ 目录下新建一个 __mocks__ 目录,然后创建一个 math.ts 文件,内容如下:

import { vi } from "vitest"

export const sum = vi.fn((a: number, b: number) => a + b + 100)

然后将测试的模拟代码修改为:

vi.mock('./math')

重新运行测试,测试会通过。

注意,对 vi.mock() 的调用会被自动提升到顶层上下文,即使在测试套件或测试内调用它也是如此。所以如果你只是想在某个套件或测试内模拟模块,可以使用 vi.importMock() 方法:

import * as Math from "./math"

test("mock", async () => {
  const { sum } = await vi.importMock<typeof Math>('./math')
  sum(1, 2)

  expect(sum).toHaveBeenCalledOnce()
  expect(sum).toHaveBeenCalledWith(1, 2)

  sum.mockRestore()
})

该方法使用方式与 vi.mock() 相同,只是将模拟的行为定义在测试套件或测试内而已。另外,调用该方法后会返回原函数类型和 Mock 类型的交叉类型,所以可以不用使用 vi.mocked() 就可以获取类型信息。

模拟全局变量

模拟全局变量的方式比较简单,使用 vi.stubGlobal() 就可以。这里直接贴出文档示例:

import { vi } from 'vitest'

const IntersectionObserverMock = vi.fn(() => ({
  disconnect: vi.fn(),
  observe: vi.fn(),
  takeRecords: vi.fn(),
  unobserve: vi.fn(),
}))

vi.stubGlobal('IntersectionObserver', IntersectionObserverMock)

测试覆盖率

很多人在写完单元测试后会想知道自己写的测试是否已经够多了,这时候他们会看测试的覆盖率是否够高。

测试覆盖率,顾名思义,就是检查测试所覆盖的源代码量占源代码总数的比例。Vitest 支持通过 c8istanbul 获得测试的覆盖率,我们来尝试一下。

Vitest 默认情况下使用 c8,我们需要先安装对应的包:

npm i -D @vitest/coverage-c8

然后更新测试代码,这次我们还是来测试 sum 函数:

import { test, expect } from "vitest"
import { sum } from "../src/math"
 
test("sum", () => {
  expect(sum(1, 2)).toBe(3)
})

然后在命令行中输入以下命令:

npx vitest run --coverage

然后控制台就输出了测试覆盖率的报告:

image.png

以上这四个参数的含义分别是:语句覆盖率(statements)、分支覆盖率、函数覆盖率和行覆盖率。同时根目录下还生成了一个 coverage 目录,记录了更详细的统计信息。

使用 istanbul 的方式也是差不多,安装对应的包就行了,不再赘述了。

istanbul 的原理是把源代码进行转译,插入用于记录某个函数、语句被调用次数等记录的代码,然后将记录到的信息存储到某个变量中,测试完毕后就可以通过这个变量获取统计到的信息,生成覆盖率报告。而 c8 是直接使用 V8 引擎内置的覆盖率统计,测试完成后直接生成报告。

在实际项目中为了保证程序员们写单测的数量或质量,会限定测试覆盖率的阈值,然后在代码提交前或者在集成管道中检查测试覆盖率是否达到这个阈值。我们来尝试一下。

如果你使用的是 Vite,那么你可以直接在 vite.config.ts 里进行配置:

/// <reference types="vitest" />
import {defineConfig} from "vite"

export default defineConfig({
  // 其它配置项...

  test: {
    coverage: {
      lines: 80,
      functions: 80,
      branches: 80,
      statements: 80
    }
  },
})

我们将 80% 作为阈值。为了方便演示,我们来修改 sum 函数的实现:

export function sum(a: number, b: number) {
  if(a > 100) {
    return 100
  }
  return a + b
}

然后同样运行刚才那个命令运行测试,覆盖率报告如下:

image.png

由于未达到阈值,控制台报错,然后我们就可以观察哪部分代码的分支或行等没有被覆盖到,为其补充测试用例。这种根据程序的内部实现,如分支、函数等创建测试用例进行测试的方式,其实就属于白盒测试。如果要判断黑盒测试的覆盖率,可以通过判断所使用的测试用例所对应的等价类占总的等价类(包括有效等价类和无效等价类)及边界值的比例来得出。感兴趣的朋友可以自行查阅相关资料。

关于如何输出测试覆盖率并做覆盖率检查的使用就介绍到这里了。测试覆盖率作为检查单元测试是否充分的手段,在一定程度上确实是一个有效的工具。但是,高测试覆盖率不等于高的测试质量,在很多情况下高测试覆盖率其实是一个很容易达到的数字。比如我们可以把测试用例改成这样:

test("sum", () => {
  expect(sum(1, 2)).not.toBe(100)
})

以上这个测试用例是:输入 1 和 2,不会输出 100。测试覆盖率达到了 100%,超额完成了任务要求,但是这个测试的质量就真的很高么?答案显然是否定的,因为这个测试用例并没有任何意义,我们应该断言它返回了正确的结果(即 3),而不是断言它返回了其它无关的数字,除非进行穷举,断言它不等于除 3 以外的所有数字,但这显然是不可能的。

很多人写测试时以高覆盖率为目标,会以覆盖率达到 100% 为傲,但这并没有什么用,你应该做的、思考的,是如何设计出高质量的测试用例,而不是盯着一个数字疯狂堆用例。很多情况下,即使达到了 100% 也不能说明程序就没有问题,正如文章开头说的那样,软件测试是检验其是否满足规定的需求,或者找出程序中潜在的错误。

Martin Fowler 在这篇文章中提到:高测试覆盖率并不意味着什么,它反而在帮助检查源代码中还没有被测试的地方这个方面有效果。他认为,如果你做到了以下这两点,就说明你写的测试已经充足了:

  • 你很少在生产中碰到 bug;

  • 当修改代码时你很少会犹豫、害怕它会导致生产事故。

关于测试覆盖率我要说的就是这些了,希望能提高你对测试覆盖率的认知。

配置类浏览器环境

Vitest、Jest 等单元测试框架由于是运行在 Node 环境中的,如果我们要测试一个 Web 应用,进行组件测试,就需要有类浏览器环境,以支持 DOM API、本地存储、Cookie 等浏览器特性。

Vitest 提供了 environment 选项来配置测试环境,除 Node 之外还支持了 jsdomHappy DOMEdge Runtime

jsdom 是一个用于 Node 环境的对许多 Web 标准的 JS 实现,它的使用示例如下:

const jsdom = require("jsdom")
const { JSDOM } = jsdom

const dom = new JSDOM(`<!DOCTYPE html><p>Hello world</p>`)
console.log(dom.window.document.querySelector("p").textContent)

可以看到,只要将 HTML 字符串传入 JSDOM 构造函数中,就可以在返回的实例上使用许多包括 querySelector() 等众多 Web API。

尽管 jsdom 实现了许多 Web API,但是它毕竟运行在一个模拟的浏览器环境(即无头浏览器)中,许多特性仍然无法实现,一个是布局(layout),即无法计算某个元素在页面中的布局,如在视口中的位置(getBoundingClientRects())和 offsetTop 等属性;一个是 navigation。所以在某些场景下使用 jsdom、Happy DOM 进行 Web 环境下的测试可能无法很好地满足你的需求,在这种情况下你需要让待测对象在一个真实的浏览器上运行,比如使用 Cypress 来进行测试。

Happy DOM 与 jsdom 一样实现了 Web 浏览器的诸多特性,相比于后者,它拥有更高的性能,但实现的特性要少一点。

我们来使用 jsdom 来配置类浏览器环境,首先需要安装 jsdom:

npm -D install jsdom

接着修改配置:

// vite.config.ts
test: {
    environment: "jsdom",
},

就可以在全局使用 Web API 了:

test("dom", () => {
  const div = document.createElement('div')
  div.className = 'dom'
  document.body.appendChild(div)
  expect(document.querySelector('.dom')).toBe(div)
})

0.23.0 开始,Vitest 支持使用自定义的环境,需要创建一个命名格式为 vitest-environment-${name} 的导出环境对象的包,并且还导出了 populateGlobal 方法方便填充 global 对象。你可以点击这里查看指引。

使用 jest-dom

当在 Web 环境下对 DOM 进行测试时,我们会发现对 DOM 结点进行断言会比较麻烦,比如断言其是否有某个属性、是否可见,一个按钮是否被禁用,输入框是否聚焦等等,我们通常需要调用多个 DOM API 逐步提取出想要的属性或值等才能达到我们的目的。

jest-dom 提供许多 Jest 匹配器来帮助我们简化这些步骤,由于 Vitest 兼容 Jest 的断言风格,所以 jest-dom 也可以在 Vitest 上使用。我们来尝试一下。

首先进行安装:

npm install -D @testing-library/jest-dom

安装完毕后我们需要应用这些匹配器,可以选择在 setup file 中进行这个操作:

// __tests__/vitest-setup.ts
import '@testing-library/jest-dom'

注意,引入这个包时它会在内部使用 expect.extend() 方法来应用这些自定义匹配器,这意味着 expect 必须是一个全局 API。Vitest 默认情况下关闭全局 API 的注入,我们可以手动开启,并配置 setup file 的路径:

/// <reference types="vitest" />
import path from "path"
import { defineConfig } from "vite"

export default defineConfig({
  // 其它配置项...

  test: {
    globals: true,
    environment: "jsdom",
    setupFiles: path.resolve(__dirname, '__tests__/vitest-setup'),
  },
})

如果你不喜欢开启全局注入,可以将 setup file 的内容改成这样:

// __tests__/vitest-setup.ts
import matchers from '@testing-library/jest-dom/matchers'
import { expect } from 'vitest'

expect.extend(matchers)

现在就能使用 jest-dom 提供的匹配器了:

test("dom", () => {
  const div = document.createElement('div')
  div.className = 'dom'
  document.body.appendChild(div)
  expect(div).toBeInTheDocument()
})

jest-dom 提供的匹配器数量不多,只有二十几个,建议你到仓库把它们都看一遍熟悉一下。

快照测试

快照是一个序列化的字符串,你可以用它来确保待测对象的输出不会发生改变。使用方式如下:

test("sum", () => {
  const res = sum(1, 2)
  expect(res).toMatchSnapshot()
})

toMatchSnapshot() 匹配器用于对所传入的期望与之前保存的快照进行比较。当第一次使用它时会在测试文件的目录下新建一个 __snapshots__ 目录存放每个测试文件中的快照,内容大致如下:

// Vitest Snapshot v1

exports[`sum 1`] = `3`;

当第二次使用 toMatchSnapshot() 匹配器时就会进行比较,如果不匹配就会报错,比如:

test("sum", () => {
  const res = sum(100, 200)
  expect(res).toMatchSnapshot()
})

修改测试代码,重新运行测试后就会报错:

image.png

如果快照不匹配是预期的行为,可以在控制台键入“u”更新失败的快照。

如果你不希望将快照保存在另一个目录中,可以选择内联快照,使用 toMatchInlineSnapshot 匹配器:

test("sum", () => {
  const res = sum(1, 2)
  expect(res).toMatchInlineSnapshot()
})

使用 toMatchInlineSnapshot() 后运行测试,生成的快照会作为参数直接被写入匹配器的括号内:

test("sum", () => {
  const res = sum(1, 2)
  expect(res).toMatchInlineSnapshot('3')
})

Jest 文档中推荐了快照测试的另一个用途:测试 React 组件,提供的示例如下:

import renderer from 'react-test-renderer';
import Link from '../Link';

it('renders correctly', () => {
  const tree = renderer
    .create(<Link page="http://www.facebook.com">Facebook</Link>)
    .toJSON();
  expect(tree).toMatchSnapshot();
});

以上代码渲染了 Link 组件,然后对序列化后的结果进行快照测试。所保存的快照是这个样子:

exports[`renders correctly 1`] = `
<a
  className="normal"
  href="http://www.facebook.com"
  onMouseEnter={[Function]}
  onMouseLeave={[Function]}
>
  Facebook
</a>
`;

通过对组件的渲染结果进行快照测试,可以很方便地找出所修改的内容与之前的版本不匹配的地方,然后进行修复或更新。

但是,你不应该过度依赖快照测试,也不应该过分对组件进行快照测试,因为快照测试并不能很好地表达测试用例的意图而仅仅比较序列化后的结果,当快照出现不匹配时我们无法立即断定这是因为代码某处地方出现 bug 还是代码更新后的正常现象,为了找出不匹配的原因我们可能会在这个不匹配的地方上浪费大量的时间,甚至放弃思考武断地选择更新快照。

快照测试是一把双刃剑,它在某些场景下可能会很有用,但是也有可能让测试走向另一个极端。我个人还是建议开发者编写有意图的测试,从程序的输入输出等方面入手,专注设计高质量的测试用例。

Testing Library 的作者 Kent C. Dodds 在他的这篇博客中介绍了几个他觉得非常适合使用快照测试的地方,感兴趣的同学可以看一看。


到此为止,关于如何使用单元测试框架进行自动化测试的入门内容就介绍到这里了,相信你已经收获良多了。接下来我们就开始进入实战部分,对一个小型的 Web 应用进行单元测试、组件测试。

前端自动化测试实战

我们来对以下这个地址列表小应用进行单元测试和组件测试:

CPT2209201943-375x812.gif

技术栈主要是 Vue3、Pinia 和 TypeScript。
源代码仓库在这里,我还提供了使用 Vuex 的分支,你可以拉下来边学习边对照。

准备工作

使用 Vue Test Utils

Vue Test Utils 是官方提供的组件挂载库,它提供了许多实用的 API 来支持对 Vue 组件的测试,我们来尝试一下。

首先安装包:

npm install -D @vue/test-utils

然后新建一个测试文件,输入以下代码:

import { expect, test } from 'vitest'
import { mount } from '@vue/test-utils'
import { defineComponent } from 'vue'

const Component = defineComponent({
  template: '<p>Hello World!</p>',
})

test('Component', () => {
  const wrapper = mount(Component)

  expect(wrapper.find('p').text()).toBe('Hello World!')
})

运行测试后终端会显示测试通过。

我们使用了 mount 方法来挂载组件,mount 方法内部会先创建一个包含该组件的父组件,然后调用 createApp() 方法创建 Vue 应用,将父组件作为根组件传进去,最后挂载到一个 div DOM 节点上。

mount 方法还支持传入一个配置对象来支持对组件的渲染或初始化进行更多配置,我挑几个较常用的配置项列在下面:

  • data:用于覆盖组件默认的 data 数据,比如:

    const Component = defineComponent({
      data() {
        return {
          msg: 'Hello World!',
        }
      },
      template: '<p>{{ msg }}</p>',
    })
    
    test('Component', () => {
      const wrapper = mount(Component, {
        data() {
          return {
            msg: '111',
          }
        },
      })
    
      expect(wrapper.find('p').text()).toBe('111')
    })
    
  • props:设置渲染组件的 props:

    const Component = defineComponent({
      props: {
        msg: {
          type: String,
          required: true,
        },
      },
      template: '<p>{{ msg }}</p>',
    })
    
    test('Component', () => {
      const wrapper = mount(Component, {
        props: {
          msg: 'Hello World!',
        },
      })
    
      expect(wrapper.find('p').text()).toBe('Hello World!')
    })
    
  • globals:

    • plugins:设置要应用到所创建的 app 的插件;
    • stubs:设置对待测组件的子组件的存根,当你不想渲染某些子组件或者模拟子组件时这个选项会很有用。
  • shallow:当不想渲染所有子组件时可以将这个选项置为 true。

mount 方法的配置选项的几个字段就介绍到这里,为了避免篇幅过多,还是建议大家去看对应的 API 文档,讲得很详细。

调用 mount 方法会返回一个 VueWrapper 类型的对象,它提供了许多工具方法来方便对组件进行断言或更新组件的状态。比如上面几个示例的 text 方法就可以返回一个元素的文本内容,这里列举几个其他几个常用的方法,更多详情可以看这

  • emitted:返回组件发出的所有事件,使用示例如下:

    const Component = defineComponent({
      emits: ['fetch'],
      setup(props, { emit }) {
        emit('fetch', '123')
      },
    })
    
    test('Component', () => {
      const wrapper = mount(Component)
    
      expect(wrapper.emitted()).toHaveProperty('fetch')
      expect(wrapper.emitted('fetch')?.[0]).toEqual(['123'])
    })
    
  • find:查询组件中的 DOM 节点,返回一个 DOMWrapper 类型的对象。DOMWrapper 在使用上与 VueWrapper 差不多,都可以使用很多工具方法;

  • trigger:触发组件 DOM 事件:

    const Component = defineComponent({
      data() {
        return {
          count: 0,
        }
      },
      template: '<button @click="count++">{{ count }}</button>.',
    })
    
    test('Component', async () => {
      const wrapper = mount(Component)
      const button = wrapper.find('button')
      expect(button.text()).toBe('0')
    
      await button.trigger('click')
    
      expect(button.text()).toBe('1')
    })
    

    注意,为了保证触发事件后进行断言时 DOM 已更新,trigger 方法返回了一个 Promise,它只有在 DOM 更新后才会 resolve,所以我们需要进行 await;

  • unmount:卸载组件。

Vue Test Utiles 还暴露了一个 flushPromises 方法,调用并 await 它可以确保所有微任务(包括 DOM 更新)都会执行完毕。它内部同时使用了宏任务和微任务来达到这个目的。

Vue Test Utiles 的基本使用就介绍到这,之所以介绍得比较简短,除了节省篇幅外,主要原因是我们并不使用它来作为 Vue 组件的挂载库,我们使用的是 Vue Testing Library

使用 Vue Testing Library

Vue Testing Library 是一个用于 Vue 的测试库,它内部依赖了 DOM Testing Library 和 Vue Test Utils。相比于 Vue Test Utils,Vue Testing Library 可以使用更简洁的 API 来与组件进行交互,它摒弃了操作、查询 Vue 组件时需要使用的过度依赖其内部实现的 API,而将这些操作简化为最原始的,更加抽象的原生 DOM 操作。

Testing Library 是一个专注于模拟用户的行为进行测试的库,它只暴露可以让使用者以一种接近用户使用方式进行测试的 API,它的指导原则是:

The more your tests resemble the way your software is used, the more confidence they can give you.

这同时也是我们对组件进行测试的测试原则,即我们的测试不应过度依赖待测试对象的内部实现,而是从一个用户的角度思考其输入和输出,大多数情况下,对于一个组件来说,其输入可以是:用户的交互、Props、其它从外部输入的数据(例如 store、API 调用);其输出可以是:视图、事件、其它 API 调用(例如调用 router、store 的方法)。

只注重组件的输入输出可以让我们写出易维护的测试代码,让我们有信心对代码进行重构,当我们进行迭代时测试也会在合适的时候失败,而不是改个类名就直接报错。

Vue Testing Library 使用 Queries API 来查询组件内部的 DOM 结点,Queries API 是从 DOM Testing Library 引入的方法,我们来简单介绍一下。

(虽然我们使用 Vue Testing Library 进行测试,但是我还是推荐你阅读一下 Vue Test Utiles 的文档,因为前者也是基于 Vue Test Utiles 开发出来的,渲染组件的配置字段和更新组件的方法有部分重合;此外,它的文档还较系统地介绍了如何测试一个 Vue 组件,包括自定义事件、路由、状态管理等等,非常值得一读。)

Queries

如果只查询一个 DOM 结点的话,按照查询 DOM 的结果来分类,Queries API 可以分为 3 种:

  • getBy**:当没有查询到或查询到多个结果时报错;
  • queryBy**:当没有查询到时返回 null,查询到多个结果时报错;
  • findBy**:异步查询 DOM,当没有查询到或查询到多个结果时报错,返回一个 Promise。这在查询只有在视图更新后才会变化的 DOM 时会很有用。

如果要查询多个 DOM 结点的话:

  • getAllBy**:查询结果返回一个数组,其它与 getBy** 相同;
  • queryAllBy**:没有查询到时返回空数组,查询到时返回一个数组;
  • findAllBy**:查询结果返回一个数组,其它与 findBy** 相同。

按照查询 DOM 的方式来分类,可以分为 8 种,具体可以看这里,就不列举了。同时文档还为这些 API 的使用优先级排了序

DOM Testing Library 本质上是对给定的 DOM 元素进行各种 DOM API(如 querySelector()) 的调用最后返回查询结果,使用方式大致如下:

const input = getByLabelText(container, 'Username')

可以看到,使用时需要先传入一个根节点,DOM Testing Library 会对其子元素进行查询。

由于 Vue 组件的根节点一般是固定的,Vue Testing Library 修改了 Queries API 的实现,省略了根节点的传入:

const { getByText } = render(Component)

getByText('Hello World!')
render

render 方法用于挂载 Vue 组件,相当于 Vue Test Utils 的 mount 方法,但是略有不同,接口如下:

function render(Component, options, callbackFunction) {
  return {
    ...DOMTestingLibraryQueries,
    container,
    baseElement,
    debug(element),
    unmount,
    html,
    emitted,
    rerender(props),
  }
}

使用方式与 mount 方法差不多,但是返回了 Queries API 和几个变量和方法,具体可以看这里

render 方法的内部实现也很简单,大致就是修改了组件挂载的节点然后调用 mount 方法而已。

fireEvent

fireEvent 方法顾名思义,用来给 DOM 结点触发事件,使用方式如下:

await fireEvent.click(getByText('Click me'))

跟 Vue Test Utils 的 trigger 方法一样,为了保证 DOM 的更新,调用它会返回一个 Promise,我们需要对它进行 await。

fireEvent 的原理是对所传入的元素调用 dispatchEvent 方法触发事件,然后调用 Vue Test Utils 的 flushPromises() 等待 DOM 更新。

cleanup

cleanup 方法用于卸载所有已挂载的组件。Vue Testing Library 内部维护了一个存放已挂载组件的列表,当调用 render 函数时就会将所渲染的组件添加到该列表中。调用 cleanup 时就会对列表中的每个组件调用 Vue Test Utils 的 unmount 方法进行卸载。

在默认情况下 Vue Testing Library 会在 afterEach 钩子中调用 cleanup 函数,所以我们可以不用手动调用它。但是还有一个问题需要注意,我们放在后面讲。


Vue Testing Library 的基本使用就介绍到这里,API 不多,上手非常容易,另外它的源码量也不多,只有不到 200 行,感兴趣的同学可以阅读一下。

内联组件库

如果我们所测试的组件依赖了组件库提供的组件的话,在 Vitest 下可能会出现报错:

image.png

从报错信息可以看出,Vitest 无法识别 vant 某个组件的 CSS 文件。出现这个问题是因为 Vitest 在默认情况下不会对 node_modules 的模块进行转换,而是直接交给 Node 执行,所以当然就不认识 CSS 文件了。之所以这么做是因为 node_modules 里的包一般都是 Node 能识别的 ESM 或 CJS 格式,出于性能考虑,当然不必对它们进行处理,Vitest 也不会将它们纳入模块图。

所以这个报错的解决方法已经很明了了,就是让 Vitest 对 vant 进行转换,可以使用 deps.inline 选项来达到这个目的:

// vite.config.ts
test: {
  deps: {
    inline: ['vant'],
  },
},

其它配置

测试的目录结构直接照搬 src 目录的就行,方便维护和后期迭代。

如果要使用 vi.useFakeTimers() 时记得这样做:

vi.useFakeTimers({
  toFake: ['setTimeout', 'clearTimeout'],
})

具体原因可以看这条 issue。在示例项目中我已将以上代码放到 setup file 中。

如果你使用 Vite,还需要在配置文件加上一条配置:

resolve: {
    conditions: process.env.VITEST ? ['node'] : [],
},

具体看这个 issue

最后,将配置文件的 test.globals 置为 true,为什么呢?为了兼容 Jest 生态。现在大部分库都是兼容 Jest 的,这意味着它们会假定 expect、afterEach 等 API 是可以从全局获取的。

比如 Vue Testing Library 会在 afterEach 钩子中调用 cleanup 函数来卸载所有 Vue 组件:

if (typeof afterEach === 'function' && !process.env.VTL_SKIP_AUTO_CLEANUP) {
    afterEach(() => {
      cleanup()
    })
}

如果不开启 globals 的话,我们就需要手动调用 cleanup。

再比如前文的 jest-dom 也还是需要引入所有 matcher 然后手动进行扩展,以及后文要介绍的 Pinia 提供的用于测试的 createTestingPinia 方法也是这样。所以,为了避免测试时出现无法预料的问题,还是建议开启 globals。

测试 LoginForm

测试实战的第一个示例,我们来测试 LoginForm,即登录表单组件。功能很简单,提交时进行调用 API 进行登录,登录成功后存储 token 并调用 router 跳转到新页面。此外还包含表单验证、按钮禁用的小功能。

所以我们要测试的功能和对应的用例如下:

  • 填写表单登录成功后将 token 存储到本地存储。输入为用户填写表单,输出为 localStorage 的 token 字段。由于 jsdom 提供了本地存储的支持,所以我们可直接调用 localStorage。如果不支持的话,就需要 mock 了;

  • 填写表单登录成功 1 秒后调用 router 跳转页面。输入为用户填写表单,输出为调用 router.replace() 和传入的参数。所以我们需要 mock vue-router 模块,才能断言对 useRouter() 等方法的调用。你可能会问,为什么不等跳转到新页面后直接断言页面的 URL 呢?

    由于我们仅仅挂载了待测的组件,如果要跳到新页面的话就需要使用 RouterView 组件,然后还需要挂载一个 APP 组件来放置 RouterView,接着配置路由表创建一个 router 实例,将其应用到 APP 组件中。可见工作量还是非常大的,如果不嫌麻烦的话就可以这样做。但是个人认为这么做也没有什么意义,本质上也还是模拟一个 router,因此直接 mock vue-router 模块就足够了。

  • 提交表单时提交按钮被禁用、提交失败时按钮启用;输入为提交表单,输出为按钮的状态;

  • 表单验证:输入框失焦或提交表单时如果有未填写的则显示提示信息。输入分别为输入框失焦和提交表单,输出为显示提示信息。

按照这样的思路,即组件功能的输入输出设计测试用例,是一个推荐的做法。

登录时需要发起请求,所以还需要模拟调用的后端 API。通过前面的学习你应该知道怎么模拟函数了,像这样:

import * as loginAPI from '~/api/userManagement'

vi.spyOn(loginAPI, 'login').mockImplementation(vi.fn().mockResolvedValue({
  token: 'acbdefgfedbca123',
}))

如果要模拟返回成功结果,可以像上面这样使用 mockResolvedValue 方法,它可以模拟返回一个 resolve 的 Promise。如果要模拟失败结果,则可以使用 mockRejectedValue 方法:

vi.mocked(loginAPI.login).mockImplementation(vi.fn().mockRejectedValue('rejected'))

现在我们就可以写出第一个测试用例:

  describe('填写表单进行登录', () => {
    test('输入用户名和密码进行登录可以登录成功, 将 token 存储到本地存储中', async () => {
      // 模拟后端 API
      vi.spyOn(loginAPI, 'login').mockImplementation(vi.fn().mockResolvedValue({
          token: 'acbdefgfedbca123',
      }))
      const { getByPlaceholderText, getByTestId } = render(LoginForm)
      expect(localStorage.getItem('token')).toBeNull()

      await fireEvent.update(getByPlaceholderText('用户名'), 'jeanmay')
      await fireEvent.update(getByPlaceholderText('密码'), 'password123456')
      await fireEvent.submit(getByTestId('form'))

      expect(localStorage.getItem('token')).toBe('acbdefgfedbca123')
    
      // 清除本地存储
      localStorage.removeItem('token')
      vi.clearAllMocks()
    })
  })

注意,我将测试里的代码分为了四个步骤:

  • 第一个步骤是进行测试前的初始化,完成模拟 API、渲染组件和“控制变量“这些准备工作;
  • 第二个步骤是进行测试,即触发原先规定好的输入和输出,这里我们填写表单内容并提交。一定要记得调用 fireEvent 后还要 await 它确保视图更新;
  • 第三个步骤是进行断言,断言输出结果是否符合我们的预期,这里断言了本地存储中是否有我们模拟的 token;
  • 最后是进行测试的收尾,一些状态或副作用的清除在这一步完成,这里我们完成了本地存储的 token 和模拟的 API 调用记录的删除,此外还有 Vue Testing Library 自动帮我们卸载组件。

这四个步骤非常重要,按照这个方式来组织测试代码可以很清晰地表达测试的意图,确保测试的独立性和可维护性。

一些重复的初始化和收尾工作可以提取出来放到钩子函数中或提到更上层的作用域,抽离出来后最终代码是这样的:

describe('LoginForm', () => {
  afterEach(() => {
    vi.clearAllMocks()
  })

  describe('填写表单进行登录', () => {
    vi.spyOn(loginAPI, 'login').mockImplementation(vi.fn().mockResolvedValue({
      token: 'acbdefgfedbca123',
    }))

    afterEach(() => {
      localStorage.removeItem('token')
    })

    test('输入用户名和密码进行登录可以登录成功, 将 token 存储到本地存储中', async () => {
      const { getByPlaceholderText, getByTestId } = render(LoginForm)
      expect(localStorage.getItem('token')).toBeNull()

      await fireEvent.update(getByPlaceholderText('用户名'), 'jeanmay')
      await fireEvent.update(getByPlaceholderText('密码'), 'password123456')
      await fireEvent.submit(getByTestId('form'))

      // await waitFor(() => expect(localStorage.getItem('token')).toBe('acbdefgfedbca123'))
      expect(localStorage.getItem('token')).toBe('acbdefgfedbca123')
    })
  })
})

接下来写第二个用例的代码,由于使用了 router,我们需要模拟 vue-router 模块,模拟代码如下:

import type * as VueRouter from 'vue-router'

const replace = vi.fn()
vi.mock('vue-router', async () => ({
  ...await vi.importActual<typeof VueRouter>('vue-router'),
  useRouter: () => ({
    replace,
  }),
}))

由于源代码使用的是 router.replace(),这里我们只需要模拟 useRouter 和 replace 就足够了。

测试代码我直接贴出来:

test('输入用户名和密码进行登录可以登录成功, 1 秒后调用 router.replace()', async () => {
  const { getByPlaceholderText, getByTestId } = render(LoginForm)
  expect(replace).not.toHaveBeenCalled()

  await fireEvent.update(getByPlaceholderText('用户名'), 'jeanmay')
  await fireEvent.update(getByPlaceholderText('密码'), 'password123456')
  await fireEvent.submit(getByTestId('form'))
  vi.advanceTimersByTime(1000)

  expect(replace).toHaveBeenCalledTimes(1)
  expect(replace).toHaveBeenCalledWith('/address/shipAddress')
})

由于源代码中用到了定时器,我们还需要使用 vi.useFakeTimers(),这个工作已经在 setup file 中完成了就不必再做了。

其它几个测试比较简单,所以就不必多讲了。测试 LoginForm 的介绍就到这里了,在这一小节中,我讲了如何根据待测组件的功能从输入输出的角度设计测试用例、组织测试代码的四个步骤和常见的模拟模块的方式。

此外还有几个常用技巧,比如使用 toBeInTheDocument() 匹配器判断 DOM 是否存在、使用 toBeEnabled()toBeDisabled() 判断按钮是否禁用或启用等等。

测试 AddressListItem

AddressListItem 组件通过 Props 接收地址信息,然后将其渲染到视图上,点击时跳转到新页面,长按一秒时抛出 longTouch 事件。

根据上一小节提供的方法应该可以很容易地想出如何设计测试用例,所以这里就不再介绍了。这里我们来细说一下点击跳转新页面这个功能,因为这个过程涉及到调用 store。

这个项目使用的状态管理是 Pinia,Pinia 提供了 createTestingPinia 方法来简化测试的复杂度,用法如下:

render(Component, {
  global: {
    plugins: [createTestingPinia()],
  },
})

调用 createTestingPinia 会返回一个专门用于测试的 pinia 实例,将其作为插件传入 global.plugins 之后,所有对 store 的获取都会返回一个模拟的 store 而不是原先定义的 store,所以我们不必担心调用 store 上的 action 或修改其中的状态会对其它测试或源代码中的 store 造成影响。这个模拟的 store 与原来的没有什么区别,唯一的一点不同是 pinia 会用一个模拟函数(比如 vi.fn())来替换掉所有 action,所以我们可以直接对这些 action 进行监听而不必担心它会发起网络请求或修改状态。

(注:createTestingPinia 假定 vi.fn()jest.fn() 是可以从全局获取的,所以需要开启 globals)

对 action 进行修改的源码是这样的:

const createSpy = _createSpy || typeof jest !== "undefined" && jest.fn || typeof vi !== "undefined" && vi.fn;
if (!createSpy) {
  throw new Error("[@pinia/testing]: You must configure the `createSpy` option.");
}
pinia$1._p.push(({ store, options }) => {
  Object.keys(options.actions).forEach((action) => {
    store[action] = stubActions ? createSpy() : createSpy(store[action]);
  });
  store.$patch = stubPatch ? createSpy() : createSpy(store.$patch);
});

stubActions 是传入 createTestingPinia 的一个选项。可以看到,如果 stubActions 为 false,则会使用原先的实现并启动监听。

除了传入 stubActions 选项外,我们还可以设置 store 的状态的初始值:

render(Component, {
  global: {
    plugins: [
      createTestingPinia({
        initialState: {
          counter: { n: 20 },
        },
      }),
    ],
  },
})

如果需要改变 getter 的值,我们也可以强制对其进行写入:

const counter = useCounter()

// @ts-expect-error: usually it's a number
counter.double = 2

但是需要使用 @ts-expect-error 注释绕过 TS 编译器的检查。

接下来我们来测试”点击后设置 store 的 currentAddressId” 这个用例,代码如下:

const renderAddressListItem = () => {
  return render(AddressListItem, {
    props: {
      addressInfo,
    },
    global: {
      plugins: [createTestingPinia()],
    },
  })
}

describe('AddressListItem', () => {
  afterEach(() => {
    vi.clearAllMocks()
  })

  test('点击后设置 store 的 currentAddressId', async () => {
    const { getByTestId } = renderAddressListItem()
    const address = useAddressStore()
    expect(address.currentAddressId).toBe('')

    await fireEvent.click(getByTestId('item'))

    expect(address.currentAddressId).toBe(addressInfo.addressId)
  })
})

当调用 render 的配置项较多且重复时可以将这个操作抽离成一个函数,这里是 renderAddressListItem 函数,它初始化了用于展示的地址信息,并调用了 createTestingPinia 方法。

测试代码比较简单,没有什么可以讲的地方,使用和断言 store 的方式也跟测试 router 差不多。主要是学会 createTestingPinia 方法的使用。

测试 AddressList

AddressList 组件调用 store 的 action 获取地址列表数据并传入 AddressListItem,获取地址列表后及地址列表的数量变化时都会抛出 fetch 事件,此外监听 AddressListItem 的 longTouch 事件,事件回调中调用 action 删除地址列表项。

我们来看”获取并展示地址列表信息”这个测试的代码:

test('获取并展示地址列表信息', async () => {
  const { findAllByTestId } = renderAddressList()

  expect(await findAllByTestId('item')).toHaveLength(3)
})

由于源代码中会调用 action 发起请求获取地址列表,这是一个异步的过程,所以需要使用 findAllByTestId()。

我们封装的用于渲染组件的函数如下:

const renderAddressList = (stubs = false) => {
  const spy = () => {
    return vi.fn(async () => {
      const address = useAddressStore()
      address.addressInfoList.push(...mockedAddressInfoList)
    })
  }

  if (stubs) {
    const AddressListItem = defineComponent({
      emits: ['longTouch'],
      setup(props, { emit }) {
        const emitLongTouch = async () => {
          emit('longTouch')
        }
        emitLongTouch()
      },
      template: '<div />',
    })

    return render(AddressList, {
      global: {
        stubs: {
          AddressListItem,
        },
        plugins: [createTestingPinia({
          createSpy: spy,
        })],
      },
    })
  }
  else {
    return render(AddressList, {
      global: {
        plugins: [createTestingPinia({
          createSpy: spy,
        })],
      },

    })
  }
}

由于后面几个测试用例会测试接收 AddressListItem 的 longTouch 事件并删除列表项的功能逻辑,需要模拟 AddressListItem 组件,所以渲染组件时需要分为模拟和不模拟两种情况,通过 stubs 参数来控制,默认是 false。

另外,我们还自己定义了一个传入 createSpy 选项的 spy 函数,因为 AddressList 创建前就会立即调用 store.getAddressInfoList() 获取地址列表,这意味我们必须在开始渲染该组件前模拟这个 action,创建一个新的 createSpy 函数就可以达到这个目的。在 spy 函数中我们重写了所有 action,让它们都更新 address.addressInfoList,因为测试场景比较简单,所以这样做不会出现什么大问题,当我们需要在组件创建前实现不同的 action 时可以将 spy 函数作为参数传入。

如果组件在创建前后不会立即调用 action,我们不需要重写 createSpy,直接在挂载后修改就行,比如这个测试用例:

test('监听到 Item 组件的 longTouch 事件后弹出弹窗,点击确定即可删除该 Item', async () => {
  mockedAddressInfoList.splice(0, 2)
  const { findAllByTestId, queryAllByTestId } = renderAddressList(true)
  const address = useAddressStore()
  vi.mocked(address.deleteAddress).mockImplementation(vi.fn(async () => {
    address.addressInfoList = []
  }))
  expect(await findAllByTestId('item')).toHaveLength(1)

  await fireEvent.click(screen.getByText('确认'))

  expect(address.deleteAddress).toHaveBeenCalledWith('3')
  expect(queryAllByTestId('item')).toHaveLength(0)
})

这里在组件挂载后调用了 mockImplementation 更改了 address.deleteAddress 的实现。

测试 AddressForm

测试 AddressForm 这里有两个地方需要注意。

一个是设置初始的 getter,虽然 createTestingPinia 只支持初始化 state,但是初始化 getter 也不难,因为 getter 本身就是从 state 计算得到的,所以直接设置初始 state 就可以了。

第二个是在测试用例内重写模块的模拟函数的实现,比如这个测试:

test('正确填写表单并提交成功后,1 秒后调用 router.back()', async () => {
  const back = vi.fn()
  vi.mocked(useRouter, {
    partial: true,
  }).mockImplementation(() => ({
    back,
  }))
  const { getByPlaceholderText, getByText, getByRole, getByTestId } = renderAddressForm()
  expect(back).not.toHaveBeenCalled()

  await fireEvent.update(getByPlaceholderText('请填写收货人姓名'), addressInfo.name)
  await fireEvent.update(getByPlaceholderText('手机号码'), addressInfo.mobilePhone)
  await fireEvent.click(getByPlaceholderText('点击选择省市区'))
  await fireEvent.click(screen.getByText('确认'))
  await fireEvent.update(getByPlaceholderText('详细地址'), addressInfo.detailAddress)
  await fireEvent.click(getByText('家'))
  await fireEvent.click(getByRole('switch'))
  await fireEvent.submit(getByTestId('form'))
  vi.advanceTimersByTime(1000)

  expect(back).toHaveBeenCalledTimes(1)
})

需要注意的地方是,调用 vi.mocked() 是需要额外传入一个值为 true 的 partial 字段,表明只模拟模块的部分 API。

测试 Pinia stores

除了测试组件外,我们还需要测试 store,因为 store 通常管理一个或多个业务模块的状态,负责模块级别的数据层的调度和维护,是一个 Web 应用重要的组成部分,所以对它们进行测试是自动化测试中非常重要的一环。

在 Pinia 中测试 store 非常简单,因为本质上就是对一个个 getter 和 action 做单元测试,粒度比组件要小很多。唯一要注意的地方是要记得加上这一段代码:

beforeEach(() => {
  setActivePinia(createPinia())
})

因为想要使用 store,需要有一个已注册的 pinia 实例,否则就需要手动将其传入 useAddressStore() 方法中,以上代码可以自动帮我们完成这件事情。

完成以上这件事后,剩下的事情就简单多了,也没啥好介绍的了,大伙们直接看仓库代码就够了。


前端自动化测试的组件测试实战就到这里了,我重点介绍了进行组件测试时的测试原则、测试技巧和注意事项,如果你理解并熟练了之后就会发现写测试其实真的不难,本质上还是围绕组件功能的输入输出做文章,并按照四个步骤组织测试代码,剩下的就是对各种 API 的熟练程度了。

总结

从入门到实战,以上就是前端测试的介绍的全部内容了,希望对你有所帮助。另外,在示例项目中我还使用了 Cypress 进行端到端测试,感兴趣的同学可以看一下。

最后,如果觉得这篇文章对你很有用的话,就请给我点赞收藏加关注吧~

© 版权声明
THE END
喜欢就支持一下吧
点赞5 分享
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

取消
昵称表情代码图片

    暂无评论内容