农村的师傅的博客

一个迫于生计,无法放飞自我,导致喜欢上了前端开发,并即将成长为强者(指头发)的程序猿。

0%

npm包项目-基于puppeteer的功能测试

在公司中,我开发了一个封装音视频多人互动业务的npm包,由于我开发的这个SDK项目,并非是独立的js逻辑,还包含了后台服务接口以及IM,同时其封装的业务逻辑还包含所谓的多人、多平台,故其测试的工作量和复杂性会更大。为了减少自测成本、覆盖更多的测试边界场景来高效的保障其高质量,故我引用了puppetee来完成该npm包的功能测试来实现其目的。

说明

前文参考:npm包项目-前端工程化演进

这里将该npm包提供的功能理解为一个会议软件所包含的基础功能,例如加入会议、邀请入会、发言等,同样这里的业务涉及到了多端(多个不同用户)、多平台(移动端、pc端等),而我们面向的是一个垂直领域的教学场景,例如多个班级在一起上同一堂课,这样进行一个类比,其实他们之间的场景会非常相似。

注:下文的SDK特指该npm包

问题分析

npm包的功能测试在很久之前就已经在考虑,在说明这个问题之前,先主要介绍一下开发该SDK和自测的一些步骤:

  • 如果是一个新功能,通常在代码逻辑编写完成后,直接利用项目中的自测demo来编写一些示例去验证逻辑的正确性,保证其质量
    • 这一个步骤,需要做一些测试前的准备,因为我们是一个多端互动SDK,你需要:加入其他端加入互动(多人功能的验证时)、进行一些操作将各端固定到你期望的状态(哪些人上台、哪些人发言等),然后才是验证要测试的功能。
    • 如果自测过程中发现问题,然后进行改动,通常你需要重新走上面这一步的流程。
  • 如果是一个旧功能的改动,同样的也会在改动完成后,利用之前编写过的自测demo来进行验证。
  • 如果是一个较为底层的模块,例如IM的逻辑改动,也是同理进行自测验证。

从上面的流程中可以看到,我们在编写一个功能时,利用项目中的自测demo可以让我们边开发边验证,但是这里其实存在一些问题:

  • 首先考虑到的是自测成本,该SDK的业务功能非常多,且有些流程很长,故手动自测走完一个基础流程都非常耗时
  • 自测demo本身很难包含一些异常和边界场景,且自测流程可能较长,其最多覆盖的是一些正常情况下的功能流程。
  • 由于SDK本身并不是单纯的逻辑库,且包含了大量的IM和后台接口服务,且IM的逻辑和业务处理本身也是SDK功能所需要验证的重要部分。如果利用单元测试的mock来承担此重任,除了mock本身的复杂度之外,其测试所覆盖的范围也就不够了,测试效果大打折扣。
  • 较为基础的服务改动,也许正常情况下不会有问题,但是在一些特殊场景下会出现预期之外的异常,且在自测过程中较难发现的情况,例如之前在修改一个IM登录功能时,由于事件的绑定逻辑考虑不全,在IM异常掉线时且自动重连后,无法正常触发连接事件。

这些问题的本质,其实是你的时间成本,你能花更多的时间去自测、去覆盖更多的测试场景,自然这些问题就不会存在,所以,我打算将我自己在自测SDK的这一部分工作进行自动化,既解放开发者的时间,也能很好的解决上述问题。

功能测试实践

一言蔽之,为SDK添加功能测试的主要点在于:

  • 减少自测成本,提高开发效率
  • 覆盖更多的测试边界场景
  • 解决自测和单元测试无法覆盖到的测试场景,能够从真正的业务角度去使用和测试SDK整体的功能
  • 在底层模块改动时,可以利用功能测试,及时发现改动所带来的未知影响,保证高质量

虽然它是一个npm仓库,非纯粹的逻辑和UI库,但是我们仍然可以利用一些技术,来完整模拟一个开发者的所有的测试行为,以达到自动化测试的效果。

自然,这里想做的是SDK的功能测试,其实你也可以把他当做系统测试或者集成测试,重点是你知道你要做它的目的以及你想要达到的效果和实现方式:从使用SDK的业务使用者角度来看,SDK的所有功能在各种正常情况下、异常情况下时的行为是否符合预期。

功能测试和单元测试的选择

在说明这个问题之前,我们还是回到我们的问题和需求上,我们的目的就是为了将由开发者去做的自测工作,交由自动化测试来完成。那我们不妨对比一下功能测试和单元测试的优劣,以便我们更好的进行选择:

测试方式 单元测试 功能测试
环境 在node层编写并运行测试代码,并验证SDK功能模块 在node层编写测试代码,让SDK在浏览器中运行并验证SDK功能模块
额外工作 IM的web sdk在node层无法运行,需要mock掉该模块、需要mock IM消息,成本较高 引入puppeteer,需要在node层控制puppeteer进行测试步骤,需要一定的封装成本
应用场景 理论上单元测试需要快,所以接口和异步都需要mock掉的,和该测试场景不太匹配 本身就是为了web的UI和异步(例如接口)测试去实现的,更符合该场景以及SDK的运行环境。且每个chrome页面带有天然的隔离性。
测试效果 由于mock掉了较为重要的IM服务,故其测试覆盖的范围也并不全面,不完全符合真正的人为测试。且node和浏览器的运行环境存在一定差异,影响测试结果的准确性 无需做任何mock手段,基本上等同于开发在自测demo中进行的自测验证

我们前面提到,由于SDK本身库并不是单纯的逻辑库,且包含了大量的IM和后台接口服务,且IM的逻辑和业务处理本身也是SDK功能所需要验证的重要部分,综合来看,以单元测试的形式来完成我们的需求,显然是不如直接将其作为功能测试的形式来的合适。

测试方案

如上所述,SDK本身并非是一个纯粹逻辑的工具库,更像是一个业务模块的封装,其功能上会依赖IM服务、后台服务接口,我们需要从业务使用者的角度去验证SDK整体的功能模块是否正常,

所以,这里所做的测试不单单只是在node层面就可以搞定的了,真正需要测试的代码是需要在浏览器上运行的。

至于他们的自动化功能测试究竟是怎样的一个流程,下面以验证课中两端进行申请发言的测试套件流程为例:

基础流程

技术选型

SDK中使用的是vite + puppeteer + jest来实现整体的功能测试。这三类分别承担着不同的功能职责:

  • vite:作为在web端运行的sdk,自然,我们在对其进行测试时,也需要一个web应用来作为容器运行SDK,并且作为web层,会在这里实现并封装一些方法以及提供可测试的能力供puppeteer层去调用和验证SDK的数据和状态。
  • puppeteer:这个用来控制chrome去一步步执行SDK中的方法,并利用它去验证SDK的事件触发和数据状态是否符合预期。它是node层和chrome浏览器之间的沟通桥梁。
  • jest:node层的测试框架,我们需要基于一个测试框架,通过puppeteer去编写测试代码,并且利用测试框架来验证测试结果。

其他选择:

  • vitest:它和jest一样是测试框架,且基本上开箱即用,一开始我选择的是它,不过由于当时SDK还未转换为monorepo,也没有抽离出types库,而vitest的mock存在比较棘手的问题,故后续又换回jest
  • Cypress、WebdriverIO等:它们也是基于浏览器运行你的web应用并且提供非常丰富的功能来支持你的测试能力,为啥不直接用这种成熟的端到端框架?
    • 其一的考量是他们对于普通web应用的端到端测试的支持非常好,然而,我们的这个功能测试中虽然也是利用浏览器,但是,我们所需要测试的应用是没有任何web UI的,这些框架这对于我们来说太重了
    • 另外一个则我们的实现和成本,由于我们想要测试内容的特殊性,我们没有UI,我们只需要调用浏览器中SDK对象的某个方法,并验证其数据和事件触发是否符合预期,也就是我们仅仅需要一个能让我们控制浏览器中的内容的库即可,自然puppeteer这种会是更好的选择。
  • Playwright:和puppeteer类似,不过支持更多的语音和更多的端,而且他们的API都是基本类似的,由于之前对puppeteer更熟悉,所以还是选择了puppeteer

PS:通过后面的实现你可以发现,其实更多的技术选型只是一种替代,对于我们本身的功能测试编写,没有明显的关联,所以,用你熟悉的就好。

功能测试项目的架构

项目架构它主要分为2部分:

  • browser:则是利用vite开发的一个简单web服务,也是SDK实例运行的地方
  • testService:node层面的测试代码,完成测试逻辑的编写

整体的项目架构如下:

整体架构

这里稍微解释一下重要的模块:

  • Test Template模块:这一个是node层的模块,其本质是将一些非常通用的代码给封装起来,便于在编写测试套件时,能够直接使用,提高测试套件的编写效率。例如,每个测试套件都会执行的打开一个tab页、加载page页面等逻辑
  • UserAgent:这是一个用户代理模块,用户在测试套件中,获取一个空闲的用户,所谓空闲用户,是指没有被其他测试套件所引用的用户,这在下文中再详细展开。
  • SDK Proxy:这个是对浏览器的SDK实例的一个代理,通常你需要在测试套件中调用SDK的某个方法来执行你的测试步骤,你可以直接通过puppeteer的接口,但是这里对其进行了一个封装,同样也是为了简便测试套件的编写效率
  • WindowContext Proxy:和SDK Proxy同理,只不过是对于浏览器中的windowContext实例的一个代理
  • 浏览器SDK实例:在浏览器web应用中,通过页面url传递过来的参数实例化出来的一个SDK实例
  • 浏览器WindowContext:对浏览器中一些通用方法的封装,以便puppeteer可以直接调用,例如日志打印,解散房间等功能的封装。
  • 浏览器AskAnswer:用于用来测试和验证SDK特殊的申请/邀请的应答模式所编写的模块

问题和难点以及处理方案

支持多用户和多端的多测试场景

我们是一个音视频互动业务,我们的业务中,需要包含多个用户、同一个用户的不同端在一个互动房间中,我们需要根据房间中的人数、不同端和不同用户的角色、房间状态、角色状态来验证不同的场景和功能。例如:

  • 特殊场景:验证同一个用户在已经有一个端入课的情况下的申请入课行为:一个用户可以登录不同的端(APP、PC、主机等),但是同一个时间,只有一个端能够加入一个互动,此时需要验证在用户A的APP端已经入课的情况下,用户B的PC端去申请入课时的行为。
  • 多端场景:不同端是指同一个用户在登录IM时,所使用的平台是Windows还是IOS或者安卓来进行区分
  • 角色权限:验证主持人赋予普通成员以授课老师的角色:多个用户在一个互动房间中,主持人角色的用户授予某个成员为授课老师时,其自身以及其他端能否收到该成员的角色变更的通知,以及该授课老师的权限是否正常。
  • 边界条件:验证房间人数已满时的申请入课:假设房间最大4个人,那么如果房间人数已满4个,则第五个用户入课时申请入课,此时申请会失败,那么我就需要5个用户来实现这一个测试场景。

这些场景都是我们在自测过程中,需要进行验证但是又非常复杂的,走完一个测试场景,不只是需要多个用户、多个端,甚至其用户的状态都需要事先达到某个预定状态,这在自测过程中是非常痛苦的一件事。而我们的功能测试就需要突破这些限制:

测试流程

  1. 准备好任意数量的用户信息,以便在测试套件中获取,测试套件无需感知它获取到的是哪个用户,只需要他是普通的能够创建课程、加入互动的用户即可,这个用户在每个Page页面的角色、端都是由测试套件自行为这个用户赋予的。
  2. 测试套件会通过UserAgent模块去获取自己期望的用户数量,测试套件想要测试什么场景,需要多少用户,都是它自行决定的,UserAgent模块对已加载的用户数据会进行缓存,以便不同测试套件的复用
  3. 测试套件在获取到用户数据后,会通过puppeteer去打开自身测试场景所需要的任意Page页面,并将用户信息和端信息通过url参数注入到Page中
  4. Page在加载时,会基于url传递的数据,去初始化和登录SDK,初始化一些上下文等
  5. 测试套件会按照需要验证的场景,一步一步控制不同页面的行为,并验证其结果是否符合预期来完成整个测试套件。

数据和事件验证

测试步骤大致都理清楚了,现在的一个问题是,在你执行完你想要测试的功能和步骤后,如何验证他是否符合你的预期的?

基于该SDK的特性,通常我们都会去看两个方面:房间状态是否符合预期以及相应的事件是否有触发。

房间状态验证

我们的架构是将房间状态都放在云端,每个端都通过IM的方法去同步云端的最新数据,你将房间状态理解为一个json对象就好,而这个房间状态同步的功能是由SDK封装并提供给业务,当房间状态变化时,云端会发送最新的房间状态IM消息给各端,SDK接收到消息后会合并到本地的房间状态,并通过事件通知给业务。

例如房间目前只有用户A、B,A、B端的本地SDK中都各自维护着最新的房间状态数据,当有一个C端加入到这个房间后,云端会利用IM通知用户A、B有人加入进来了,并将加入进来的用户信息告知A、B端,此时A、B、C端都是最新的房间状态数据。

所以,在这个“新用户加入房间”的测试场景下,当C加入房间成功后,需要校验A、B端的数据是否包含C这个用户的数据。不过C加入房间后,IM信令的时机是不确定的,该如何验证呢?

一个是用定时器,例如在C加入房间成功后,等待1s再去验证A、B端的数据,因为通常1s足够IM信令发送到端上了。不过这里问题确实不小:

  • 一个是时间并不确定,1s钟看起来很长,但是你不能保证没有网络波动较差的情况
  • 而你如果将其延迟,例如3s,那每个数据验证都需要固定的3s,那整个测试流程会被大大的延长,万一它IM 300ms就返回了呢,那时间岂不白白浪费了。

故利用定时器去验证数据并不是一个好方法,不过你似乎想,是否可以每隔100ms去验证一下,直到验证数据通过,这是个不错的方法,而且puppeteer已经提供了。那就是puppeteer的waitForFunction函数

waitForFunction你可以认为它本身就是利用定时去验证一下你传递的函数的返回值,是否返回true,如果是,则通过,如果一直不通过,则抛出异常,这非常符合我们的数据验证的需求。具体可以参考:

事件触发的验证

事件触发又该如何验证?虽然大部分的SDK行为所带来的结果可以反映在数据上,但是还是有一部分内容,是没有在房间状态中的,例如申请入课:

申请入课流程

在上面的流程中,当B发送申请入课的请求时,对于客户端A来说,会收到一个有用户申请入课的事件,但是在SDK的房间状态上并不会体现,故这里需要的是验证SDK的某个事件是否被触发,且事件的数据符合预期。

这里借助了puppeteer的waitForFunction方法,且需要在浏览器的web应用中,对SDK的事件触发进行一次封装。

事件触发校验

其主要利用的是浏览器层的SDK实例在事件触发时,通过puppeteer挂载的方法通知给node层的SDKProxy代理对象,由SDKProxy将此次的flag设置到浏览器层的flagMap上,此时node层就可以基于puppeteer去验证flag,以此确定该事件是否触发。

这个方法只能简单验证是否触发,如果还想要确认事件的数据是否符合预期,你还可以利用jest的fn方法获取一个mock函数,并通过SDKProxy监听,在该事件触发时,你就可以通过fn函数来确认事件及事件的数据是否符合预期了,不过此时你就要考虑自行实现一个轮询校验了。

简化测试套件的编写:模板代码Template

对于不同类型的测试套件,例如:校验IM、校验入课、校验课程恢复等等,他们的测试套件的编写都有一定的共性,且编写一个测试套件,通常分为几个步骤:

  1. 创建浏览器上下文
  2. 初始化用户数据和环境配置
  3. 访问页面
  4. 调用功能
  5. 对数据和事件触发进行验证

如此,可以将这一部分都封装成一个个模板,在编写测试套件时,直接通过声明式的方式来组合我们的模板即可。

其他问题和思考

功能测试项目和SDK项目处于分隔状态

在一开始时,由于SDK项目还是一个multirepo仓库,且使用功能测试所依赖的技术栈和SDK项目本身的依赖差距较大,不太适合放到一起,在一开始的功能测试是单独作为一个git仓库项目进行开发和维护。

那时候在为SDK新增一个功能时,则需要等SDK发布新版本或者通过link的方式进行功能测试的验证,比较麻烦,这也是促成SDK向monorepo转变的一个原因。

直到SDK变成monorepo项目后,整个功能测试项目就已转移到SDK项目中去,作为其一个子包进行维护。

为什么不在浏览器中运行测试?

其实是可以的,我们可以在web中编写测试的用例,利用chai这种可以在浏览器运行的断言来运行我们的测试。

但是有其他问题需要考虑:

  • 自动运行,如何驱动测试,且和我们现有工程进行结合?自行编写node脚本吗?
  • 相比在node中编写的测试框架,直接在web中编写测试,缺少一些我们所认知的
  • 测试报告
  • 测试是否通过的判断

或者,我了解到vitest中存在一个浏览器模式,他可以让我们编写的测试代码,在浏览器下进行运行,参考:https://cn.vitest.dev/guide/browser.html,它也支持Playwright,那它是否是我们另外一个比较好的选择呢?

不过在我进行技术调研时,貌似它目前是一个实验性API,并不稳定。虽然,这个方案解决了我们需要在node中编写调用浏览器方法的代理问题,但是相对于其不稳定的特性,貌似我们目前的重点是测试的可用性(用户鉴权、活动、并行问题等)

而且它貌似文档少的可怜,我们可能需要依赖其原始的代理之类的功能,那这个还需要进行研究。

性能优化思考:测试的并行执行

由于用户数量是有限的,同一时间一个用户只能在一个房间中,所以,我们测试的并行数量可能并不会太高,甚至于,由于某些场景太过复杂(涉及多用户多端、复杂前置状态、存在边界场景等),为了保证测试的稳定以及方便排查问题,我们的测试需要串行执行。

不过我们执行一个测试由于大部分操作都是异步,且都需要一定的延迟才会对数据进行验证,所以,一个测试用例的耗时,可能至少是秒级的。如果无法并行测试,那么我们的整个测试的运行时间会非常长。

所以,我们仍然可以考虑使用一些方式来加快我们的测试速度:

  • 一个是在一个测试套件中,去验证多个逻辑。因为我们通常是基于用户入课后,对其进行功能校验,那么我们可以考虑在一个测试套件中,可以准备一个环境,并验证其多种功能(然而你很难保证其同时操作和验证的稳定性,尤其在调试的时候)
  • 多准备几个用户,并为其进行分组,甚至于我们可以自动化用户和房间的创建。

不过实际仍然有一些需要注意的点:

  • 在测试环境的后台服务其并发和性能并不高,所以如果并行的用户数量太多,可能导致其服务不稳定而测试失败。
  • 基于用户数量不可能和每个测试套件一一对应,实际上可能也不会如此,而通常,一个场景需要依赖一个用户甚至多个用户,那么此时其他场景就需要等待用户了,这个和锁类似。

实现思路

如果要实现类似功能,则需要一些额外的处理了,我们想要的是:

  • 所有用户作为一个用户池,需要用户进行测试的,则需要去获取闲置用户,否则进行等待。或者利用类似于互斥锁的东西。
  • 对于一个测试套件,即测试文件(看不同测试框架的定义),我们希望其应该是串行执行,而对于多个不同测试套件,我们尽量期望在用户不受限的情况下,互不干扰的并发执行(一个测试套件可能需要不同的多个用户)
  • 由于不依赖于cookie,则我们每个测试的web环境都是以page作为最小单元(不是browser),理论上我们只需要一个browser实例即可。
  • 每个page基本上需要加载一个用户来配合运行,不然这个page加载出来也许没有测试意义
  • 将所有用户作为每个测试套件的共享资源,我们可以同时并行多个测试套件,但是只有当用户数量满足这个测试套件的要求时(在业务中,用户也是存在权限的),才真正能开始进行测试(在beforeAll中,是不会算作测试时间的)

并行测试任务共享的用户数据池:

  • 每个测试任务利用userAgent来获取用户,在beforeAll中加载这个测试套件中的所有测试用户数据,待所有测试用户数据加载完毕时,才会开始进行测试。
  • userAgent通过自身实现的一个IdleQueue实例来控制和获取空闲的用户数据,IdleQueue在内存中实现了基本的互斥锁。如果是多进程,则可以利用文件锁的形式来跨进程的互斥锁。
  • 只有获取到空闲用户的测试任务可以加载这个用户数据、缓存用户数据以及检查query数据,如果不符合,则释放锁。
  • 基于文件锁,让所有的测试任务进程共享用户数据,且获取锁,再加载用户数据以及缓存(json文件)。而如果先获取用户数据和缓存以及query校验后再确定是否可以获取锁,会导致多个进程之间加载同一个用户导致token失效,以及文件写入冲突。

由于时间问题,该方案尚未得到充分实践,故这里仅仅提供一个思路。

结语

目前的功能测试包括核心的IM模块、加入房间流程、申请入课、邀请入课、发言等,覆盖整个SDK功能业务的70%以上,这个自动化测试是针对具体业务的,他能否存在共性,能应用到其他库或者类似的场景?

由于它是针对具体业务和模块的功能测试方案,无法直接应用到其他项目中,不过其其思路,或者其中的一些实践是可以沉淀,并给其他类似应用进行参考:

  • 针对SDK,或者某一个独立的业务模块,在不需要涉及UI验证的情况下,我们可以通过单元测框架作为测试驱动,利用puppeteer沟通web应用,控制SDK或者模块在真实的环境中运行,并通过SDK的返回值、事件以及数据去验证其功能是否符合预期。
  • 他不需要通过UI和交互就能来完成你的测试套件的编写,即能够脱离实际的业务应用确能够以业务使用的角度去完整测试你的整个SDK的功能,且完全等同开发自身进行自测的行为,
  • 比单测覆盖的更加全面,可以作为SDK或者独立模块高质量、高效率保证的一种全新方式。