测试匠谈 | 别让测试用例成为负债 — 三个实用的接口测试建议
「测试匠谈」栏目由优测倾心打造,汇集腾讯明星产品团队顶尖的技术专家,倾囊相授测试领域的知识技能与实践!
本期嘉宾介绍
陈涛,腾讯微信事业群研发工程师,微信支付后台架构委员会成员,负责特征识别支付前沿技术研发工作,推动支付场景与技术能力的深度融合与创新。
导语
接口的功能测试是测试用例最多的部分,然而,随着功能的不断迭代,测试用例的数量和复杂性也在增加,维护成本可能会超过其带来的收益。本文聚焦于接口测试中的功能测试,提出了三个实用的建议,帮助开发团队在长期项目中有效地管理和维护测试用例,避免测试用例成为负债。
背景与挑战
在后台,接口测试用于验证接口的功能、性能、安全性和可靠性。接口的这四类测试各有各的测试工具和方法。其中功能测试是接口测试核心的部分,是其他几类测试的基础。一般不加限定的测试均是指功能测试,这也是本文聚焦的范围。功能测试也是需要最多测试用例的部分。当我们下定决心,把保障质量的主要方式从手工测试或依赖联调转向使用接口测试用例时,一开始效果是令人欣喜的,测试用例的确能帮助我们发现问题、增强上线的信心。但随着时间流逝功能不断迭代,测试用例越来越多、混乱程度越来越高,测试用例的维护成本极容易超过测试用例带的收益。
建议01:按统一的规则映射测试用例
之所以测试用例会随着规模变大而变得更混乱,是因为没有按很好的结构来组织测试用例。下面以我维护的某生物识别项目为例来说明没有规则最终测试用例会演变成什么样。项目一开始也没有使用测试用例的测试接口,等到业务规则组合情况过多、每次回归验证的压力较大时,才开始编写测试用例,所以项目的测试用例是早期一次性编写的测试用例加上后续做需求写的测试用例累加起来的。而早期一次性编写的测试用例,我们没有按照一定的规则或套路来编写,具体表现有:
① 测试文件的组织没有规律,有按依赖方的、有按进一步风险识别策略的、有按生物识别的各种返回情况的:
② 测试文件内测试用例的命名随意,拆分规则不清晰:
③ 早期编写测试用例没有规则,导致后续的维护者很难全面梳理清楚现有的测试用例是怎么组织的,修改代码后需要对应的修改哪些测试用例。所以每次新增/修改产品特征,只好新增测试用例,每次新增测试用例又导致了测试用例间覆盖的产品特征有重复:
④ 同研发同学在不同时期添加的相同的测试用例,仅mock的账号不一致:
测试用例间覆盖产品特征重复加上不断地新增测试用例会导致对测试用例的维护是『被动』的,即改动代码后不清楚影响多少测试用例,一般情况是被动地运行下测试用例,看看哪些跑不过,再来看看跑不过的测试用例是测试用例需要修改还是被测代码需要修改,而不是『主动』地知道影响哪些测试用例,受影响的测试用例需要如何调整。
当测试用例陷入被动维护的境地时,那测试用例就妥妥地变成了负债。所以必须要按一定的规则来映射测试用例,让研发同学知道要为接口写哪些测试用例,修改接口后知道要修改哪些测试用例。
建议02:用接口的代码和测试分层的思想映射测试用例
建议一说要有规则,相当于解决有无的问题,从无规则到有规则意识上的转变是最重要的,至于具体什么规则反而是次要的,各个团队找到最适合自己的即可。我们团队也制定了一套写接口测试用例的规则。
按接口的代码映射测试用例
按接口的代码(分支覆盖或条件覆盖)映射测试,这个观点隐含的意思是接口测试是白盒测试,那为什么接口不是根据对外的表现如契约来映射呢?在实践中我们发现按契约来测试接口可能会漏掉某些测试用例,原因是契约不容易面面俱到。契约的前置条件中对系统中实体的状态要求讲得比较清楚,但往往容易忽略一些技术上的防御,如生物识别支付接口入参是用户的生物特征,接口的最主要职责是用生物特征换取用户的一个付款码(类似手机上的付款码),在某个生物特征已经换取过付款码后,如果接口还允许该生物特征再次换码,则有造成用户重复支付的风险。
Image image(req.image());
// 检查图片是否为重放的图片
if (image.Lock() == false) {
ret = comm::ERR_REPLAY_IMAGE;
return ret;
}
另外,接口对依赖方处理的各种情况也不容易在契约上列『全』,如生物识支付使用的支付平台的接口查询订单详情,查询时会返回超过频率限制,如果接口在业务流程上对限频有特殊的处理,则也需要有单独的测试用例来验证。契约中列举的一般是预期中的情况(业务流程的分支),但包括上述两种情况的『非预期的逻辑错误』有可能在契约中没有体现。当然如果你的契约就是列得比较全,各种情况都在契约中体现了,那证明你把契约维护得非常好,按契约来映射测试用例也没问题。但在没做到把契约维护得这么好之前,按接口代码来映射测试用例是比较稳妥的办法。
另一种不关心接口实现的测试方法是测试矩阵,即把接口的所有输入项均列出来,并且列出每项输入数据的等价类,按这些等价类的排列组合构造测试矩阵。
如最经典的登录功能测试矩阵:用户名3个等价类(有效、无效、空)╳ 密码3个等价类(有效、无效、空)= 9个测试用例。
然而后台接口的输入数据不仅限于接口的入参,参考《软件测试》52讲¹ 中的内容,后台接口的输入项包括:
- 接口的输入数据
- 从DB中读取的数据
- 依赖方返回的数据
- 配置中的数据
- 代码中的全局变量、静态变量
- 时间、随机数
即我们要测试这么多类数据的等价类排列组合情况下接口是否表现正常。这种测试方法需要在有哪些输入数据项和有哪些等价类的维护上耗费大量的精力,即使在有测试用例自动生成等工具的加持下,维护成本依然很高。相比测试矩阵,按接口的代码来映射测试用例会更实际一点。
测试用例分层
按接口的代码映射测试用例是否应该把接口执行的所有代码按一定的覆盖原则全部测试一遍呢?
接口的每个子处理过程、每个子处理过程的递归子处理过程、外部依赖,外部依赖递归的依赖都是接口处理过程的一部分,如果要用测试用例把每个地方的分支或返回情况都测试到,则按接口代码映射测试用例也会像测试矩阵一样测试用例特别多,本质上变成一个裁剪版的测试矩阵。为解决这个矛盾,我们需要引入测试用例分层。我们团队测试分层的方法来源于《软件方法》在第8章²引入的一个假设,系统由3种类构成:
各种类的职责划分如下:
- 控制类:控制用例(系统用例)流,为实体分配职责(为完成系统用例,也会使用边界类)。
- 实体类:系统的核心,封装核心域逻辑和数据。
- 边界类(外系统):每个有接口的外系统映射成一个边界类。
我们可以应用模型驱动设计的方法将控制类的方法映射为后台接口(另一个主题,可以先简单理解为控制类就是编排了封装核心域逻辑的实体类和需要外系统帮助时使用的边界类,不展开)。在实体类和外系统边界类已有各自测试用例的情况下,实体类的测试用例只需要验证实体类和外系统边界类『集成』在一起后是否正确。
以下是各种类测试的建议:
- 外系统边界类测试:外系统的边界类由外系统自己来做。
- 实体类测试:实体类是封装最多领域逻辑的地方,测试用例应该最多,为了执行效率,使用单元测试来测试实体类。
- 控制类:实体类和外系统边界类的集成测试,只测试集成部分。
下面以验证手机号后4位风险识别策略为例说明测试分层的效果:
生物识别支付使用起来很方便,直接识别即可完成付款,但有时系统判定本次识别有风险时,则需要用户输入手机号后4位做进一步的确认。在实现中,是否需要验证后4位的逻辑是封装在『识别结果』类中。
验证后4位有基于识别风险和产品规则等 5个原因,并且每个原因只用一个测试用例就可以覆盖。在测试验证手机号后4位时:
- 为这5个原因各写1个单元测试。
- 为获取生物识别付款码接口写1个验证后4位的测试用例。
实体类已经把各种验证原因做了较完备的测试,控制类只需要测试识别结果类在返回验证后4位时接口的表现是否正确。
测试分层的另外一个显著效果是:在有核心域有复用时,测试用例的数量会大幅下降。还是验证手机号后4位为例,项目后来又拓展了2个其他的场景:生物识别后借充电宝和生物识别会员身份,同样需要验证手机号后4位。
- 如果测试不分层:总测试用例数=场景数×验证手机号测试用例数=3×5=15
- 如果测试分层:总测试用例数=场景数+验证手机号测试用例数=3+5=8
另外一个需要注意的是,控制类不仅编排了实体类,也编排了外系统的边界类,外系统的接口测试已经由外系统自身解决了,但外系统的接口和系统内的实体类搭配在一起是否能正确地工作,这个也需要控制类的测试来覆盖,从这个角度看,接口测试是不能mock掉依赖的接口的,否则边界类的集成就没被验证到,需要更高层次的集成测试来覆盖。
当然你的系统可能不是按控制类、实体类、边界类来组织的,但只要你的系统有层次,就可以做到测试分层,如整洁架构这种洋葱形架构就可以把业务实体用单元测试来覆盖,外层的用例和控制器则只测试对内层的编排,原理雷同。同时我们也发现有的系统做不到测试分层,只能用测试矩阵等蛮力的方式,究其原因是系统本身没有一个好的层次结构,没有将最复杂的核心域逻辑封装到类似实体类的内部。好的测试用例层次结构其实来源于好的代码层次。
至此,还有一个概念值得被提出来,那就是『测试覆盖率』。测试覆盖率是衡量测试用例对被测对象覆盖程序的一个指标。建议二提到的按接口代码映射接口测试用例加上测试用例分层的思想做到代码覆盖率100%不是问题,但真正要保证软件质量,还要追求功能的覆盖率,即软件功能的每个前置条件、执行过程中每个业务规则的每个条件的组合情况都要覆盖到。像测试矩阵一样,想做到功能全覆盖是非常困难的,笔者所在部门也在实践一种功能全覆盖的方法:
首先,规范化需求模型的语法规则,再根据需求模型遍历出所有的操作路径、接着展开每条路径上的业务规则,对于所有路径上的所有业务规则组合自动生成测试用例的剧本。然后,基于测试剧本人工补充前置条件构造、结果断言等部分形成测试用例。剧本的生成全部用算法,避免因为人的原因漏写测试用例,做到了操作路径和业务规则全覆盖,篇幅原因,不详细展开。
对比之下,建议二的做法是不一定能做到功能全覆盖的,但在需求模型也没有完善得建立起来或需求模型还在快速变化不稳定的时期,建议二是值得考虑和实践的一种方法,实践中我们应该不局限于代码覆盖率,而是追求更高的功能覆盖率:
- 实体类单元测试不在本文展开,但遵循测试公共API、测试行为而不是方法等原则可以做到从功能及其各种路径角度编写测试用例。
- 控制类的测试分支覆盖接近于功能覆盖,如果差距很大,需要检查是否将过多的职责分配给了控制类,考虑将领域逻辑更多地封装到实体类,用实体类的单元测试来覆盖。
建议03:直接使用前置接口的测试用例构造前置条件
上面两条建议说明了要有规则和按什么规则来映射测试用例,具体到测试用例的编写,我们推崇《Google软件工程》³建议的『强调行为的结构测试』,对于接口测试来说,即接口的每个行为对应一个测试用例,近似于接口的每种返回情况一个测试用例。由于只测一个行为,所以每个测试用例可以足够简单,形式也可统一,建议的形式为Given, When, Then三步:
- Given 构造前置条件
- When 调用被测接口
- Then 判断结果是否符合预期
可以说,只要掌握了这些好的原则,写出好的测试用例并不难,但我们在实践中发现,即使写出了『好』的测试用例,也出现了测试用例漏测的问题。为了说明问题,先解释测试相关的两类API:
- 可测试性API:用于构造测试用例的前置条件,保证每个测试用例都可以独立执行。
- 可验证性API:用于验证测试用例执行完成后,系统内实体的状态是否符合预期。
可验证性API是可选的,一般情况下,我们验证接口的返回(含返回码和返回内容)是否符合预期即可。我们在实践中发现,过度地使用可测试性API会造成覆盖不到接口间的依赖测试的问题。还是以生物识别项目为例,能识别成功是依赖于先开通的,如果用可测试性API,则识别的测试用例是这样写的:
deftest_recognize_return_success(self):
# Given 使用可测试性API构造一个开通的用户
user = test_data.get_user()
device = test_data.get_device()
construct_open_user(user)
# When 识别
res = api_call(get_recognize_req(user.registry_image, device));
# Then 识别成功
assertEqual(res.retCode, 0)
一般情况下,接口测试框架都有能力将When部分即被测接口的请求路由到测试者的私有环境,但是Given部分的可测试性API,受限于其实现,可能不会路由到测试者的私有环境:
如果开通接口有改动,在改动之后想测试对识别是否有影响,则使用上面的测试用例无法测出这个影响,上面的测试用例变成一个无效的测试。原因是测试框架并不保证可测试性API同样路由到包含开通接口改动的环境,甚至可测试性API是通过直接构造数据的方式实现的,这两种情况都无法保证包含前置开通接口改动的部分在测试中被执行到,所以达不到测试的目的。解决办法是,直接使用开通的测试用例来构造前置条件,让测试用例对前置接口也路由到测试者的私有环境,前置接口的改动一定被执行到:
deftest_recognize_return_success(self):
# Given 直接使用开通测试用例构造前置条件
open_service_test = OpenServiceTest()
open_service_test.test_open_service_return_success()
# When 识别
res = api_call(get_recognize_req(open_service_test.image, open_service_test.device));
# Then 识别成功
assertEqual(res.retCode, 0)
那可测试性API用于什么情况呢,答案是只用于没有任何前置接口的接口,仅用于构造接口的输入数据:
有多个前置接口的情况较复杂,这个与具体的业务流程强相关,按照图中建议的指定前置接口的方式不会漏测某个依赖,大家可以结合自己的业务尝试下,这里就不展开了。
上面的分析得出,在系统内,只有为那些没有前置步骤的接口编测试用例时才需要用到可测试性API。另外,可测试性API还广泛用于产品的验收,这些可测试性API可以直接构造复杂的需求验收前置条件。前者不妨称之为基础可测试性API,后者称之为复杂可测试性API。
我推荐复杂可测试性API由基础可测试性API+系统内业务接口+外系统可测试性API三者按执行者正常执行顺序组合出来。『不推荐使用直接构造数据的方式制作复杂可测试性API』,原因是:
- 直接构造数据和系统内业务接口会构成实现同一目的两种手段,这两种手段需要同步维护,增加了维护的负担。
- 直接构造数据会绕过系统的各项校验,测试用例能执行通过依赖了绕过这些校验,可能会造成漏测出某些bug。
- 系统内业务接口就是执行者实际使用的,使用系统内业务接口构造复杂可测试性API可以增强上线的信心。
总结
对于接口测试,本文提出了三个建议,分别是:
- 映射测试用例一定要有规则,这个一个长期项目能够把测试用例维护起来的必要条件,否则测试用例就会越来越混乱,重复率高,维护测试用例的成本超过了测试用例带来的收益。
- 接口的测试用例推荐直接用代码来映射,而且用测试分层的思想只测对封装核心域逻辑的实体和外系统接口的编排,不是任何测试都放到接口测试里。
- 接口测试还要注意的是不要忽略接口间的依赖测试,而可测试性API容易使接口间依赖测试失效,推荐直接使用依赖接口的测试用例来构造前置条件可有效避免失效的问题。
我们在掌握了测试理论后,编写出高质量测试用例不是问题,但在真实的项目特别是需要长期维护的项目中,就需要有规则、有方法来做测试这件事。如果说学习了测试相关的知识,随意的写一些测试用例叫写代码,那有规则、有方法地维护测试用例,无论多大规模的测试用例都能hold得住,都能从测试中获得收益,那就是在做工程。相信马上写具体的测试用例可以由AI来帮助我们完成,但要制定在长期项目中维护测试用例的规则和方法还需要我们人来完成,才是我们人的价值。
参考资料:
- 《软件测试》
- 《软件方法》
- 《Google软件工程》
本文未注明其它来源的内容,其版权归原作者所有,未经原作者允许不得转载本文内容。如需转载本文,请在显著位置注明出处(优测云服务平台,以及文章链接:https://utest.21kunpeng.com/home/topic/api)