前言

如果说在工作里有个事情一直在颠覆我的认知,那就是 CI 了。每个阶段我对它的理解都是不同的,每个阶段我都会这么想:恩,CI 也就是这样了。但是项目发展一段时间以后我就又会想:哦,原来 CI 比我以前想的难的多。我相信在项目中实践完美的 CI 方案是每个 QA 的理想,可惜它就好似别人家的孩子一样,是我们心中的痛。所以今天我们调侃一下 CI 吧。本篇文章假设读者已经清楚的知道 CI 的概念和优点。所以就不在过多的介绍概念性的东西了。

工程文化

CI 需要工程文化的支撑,它不是某个人某个职位的事情,它需要整个技术团队所有人的努力,是一种所有人都为产品质量努力的工程文化。对于 QA 来说,在推动 CI 的最初,也就是说服所有人 CI 的重要性以及澄清 CI 的形式是比较重要也比较难的。因为很多人的传统思想都是说测试是 QA 的事情,或者说大家都忙于加班,哪有时间写 UT,又或者说本身就对 CI 这个事情有所质疑,甚至不知道 CI 是什么也时有发生,所这个时候澄清 CI 这件事本身就是比较重要的。需要让团队中的所有人慢慢转变他们的思想和工作方式。可能首先是先劝服项目经理和开发的老大,然后慢慢的去推动这件事。如果你已经处于一个良好的工程文化下,那么恭喜你,你已经走过了最艰苦的第一段路。如果你说服不了他们,或者成果比较小,过程也比较慢。那其实我们自己也是有一些事情可以做的。先从 QA 这里把 CI 的架子搭起来,营造一个良好的质量的生态环境后。 别人看到了 CI 的样子,是有助于他们理解你工作的重要性的。所以我们今天大部分介绍的都是 QA 做的事情。

高度自动化

想要做好 CI 就得高度自动化,这个大家都没意见。我们需要自动化编译,部署,运行单元测试,集成测试,功能测试。 只可惜单元测试这个最重要的组成部分在我们国内是很难推的。我相信大家都熟知下面这个金字塔形的自动化体系。

可惜理想是丰满的,现实是骨感的。单测在我们国内的工程文化中并不吃得开。所以往往我们做的都是一个倒过来的金字塔。这就在运行时间,稳定性和覆盖率方面有了极大的挑战。所以各位小伙伴,碰到肯在 UT 上下功夫的团队就要珍惜啊~(感谢第四范式的 RD 小伙伴,UT 真的挺上心的)。虽然大部分的团队情况并不理想,但是该做的还是得做,我们还是可以在 UI 自动化和接口自动化上动手脚。

技术选型

这个可说的不多,无非就是市面上一些典型的工具和框架。我介绍下我们公司用的。

环境搭建方面:docker 驱动,可以看一下我之前发的环境管理相关的文章。

单元测试方面 (Java):

  1. Junit:本来想用我熟悉的 testng,但是开发的同学说测试 springmvc 只能用 Junit。所以只能这样了
  2. mockito:大名鼎鼎的 java mock 框架。解耦,提高覆盖率,行为测试的神器。
  3. mockmvc:想测试 springMVC 的 controller 的话,只能用这玩意了
  4. hsqldb:java 的 memoryDB,能够模拟真实的数据库,但是运行在内存中。单元测试的不二神器,提高运行速度,跟真实环境解耦。
  5. jacoco:java 的代码覆盖率神器。

单元测试方面 (scala)

  1. scala test: 相当于 java 的 junit,但同时把断言,mockito 等等功能都集成进来了,是一个大的测试包
  2. spark test:因为我们只用 scala 处理 spark,所以不涉及到数据库,但是涉及到了 spark 的测试。这是个开源的 spark 测试项目,里面可以帮助你启动 local mode 的 spark,并提供了一些 RDD,DF 等等的断言工具。缺点是运行的速度仍然不够快。

接口测试方面:

  1. rest-assrued:代替 http,它的 api 和独创的断言机制很赞
  2. assertJ:java 断言神器,db 的断言基本全靠它了。同时自动化测试中的数据恢复机制也是基于它实现的
  3. testng:这个不用说了
  4. allure report:高大上的 report 框架
  5. 还有一些小的,我自己封装的东西就不说了。

UI 自动化方面:

  1. selenide:基于 webdriver 的测试框架
  2. 其他的跟接口测试差不多。

分支策略

说起 CI,那就离不开分支模型。一个好的分支模型是做 CI 的基础。我先来描述一下我见过的分支模型吧。

单分支模型

在软件开发中,持续集成能够解决的问题是尽早的集成和尽早的反馈。因此,尽管目前流行的所有版本控制工具都提供了分支/合并功能,但在团队和项目都比较小的时候,还是使用单分支开发策略是比较适合做 CI 的。这样会减少多分支在合并时的开销,也减少了 CI 消耗的资源。这时候 CI 的模式图大概是下面这个样子 (一个朋友的图,我自己懒得画了)

这个是比较理想的模式,RD 在 check out 代码后进行开发,开发完成后在本地自动化运行测试。证明新开发的功能在本地是没有问题的。但是在这之前其他的 RD 不停的往分支上 push 代码。所以这时候才需要从分支中在将最新的代码 merge 回本地再进行一次自动化测试以保证自己的代码不会跟最新的代码冲突。最后 push 到分支上,分支的代码配置了触发式的自动化测试。当监控到有新的代码更新,自动化部署和测试就会运行起来做最后一道检查。

我来解释一下上面的行为,可能现在有人会觉得困惑。尤其是在拉取最新代码到本地运行一次自动化测试后,到了主干上还要做一轮测试。主要有以下几个原因

  1. 本地环境和测试环境不一致,导致有些 case 在本地没问题,但放到 server 上就会出问题了。
  2. 运行测试后 RD 有时会忘记 push 某个文件的情况,需要在分支上做最后一道保险
  3. 现实的情况是依靠人为的自觉很难保证每一次都按约定运行测试,所以要求程序做最后一道验证。
分析一下

这时候对于自动化最大的压力在于应变的速度,尤其是环境部署架构的应变速度,因为所有代码提交都发生在一个分支上,变化会比较快,相较下来没有缓冲时间,构建失败影响的几乎是所有人。而且选择单分支模型的项目很多都还在快速发展期,产品在业务上和架构上变化会比较多。所以这时候对自动化的应变能力有很大的要求。不仅仅是测试,还有编译和部署。我们需要在架构上把自动化设计的比较好,不能开发那边随便改了一个地方,你这边就要改一大堆。那样跟本改不过来。如果你没自信做到好的应变,就只自动化那些最稳定的模块。别把时间浪费在都修改脚本的事情上。

上面就是 CI 的一个简短流程。不论是我们现在的单分支模型还是以后多分支模式开发。针对某一个分支的流程差不多都是这样的。其实在外企中还经常用到一个概念叫令牌,不过我不详细描述了,因为我实在没怎么实践过这个东西,只是当年外派到外企的时候用过,不知道是否合适用到我们这的环境下。这种单分支模型是初创公司常用的模式。

多分支多版本模式

每个公司的开发模式都不一样,我介绍一下我们公司玩的模式。下面是分支模型图。

这是我们当初的分支模型图,我们目前的行为跟图上略有区别,但是区别不大。其实挺好理解的,我们没搞那么复杂。把 develop 分支当成主要的开发分支。 在 develop 分支的基础上拉出多个 feature 分支,以 feature 为核心各自组成 feature team。每个 feature 分支其实就可以当做一个单分支开发模型了,feature 开发并测试完成后 merge 回 develop。但是在开发中我们要频繁的从 develop 上 merge 代码到 feature 分支上。以尽早发现 feature 分支跟 develop 上功能冲突的情况,也是为了尽早发现 bug。只有通过了 CI 的代码才准许合并回 develop。这么做我觉得主要有以下几个好处。

  1. 最大程度的保证了 develop 分支的代码质量。代码都是经过测试才合并到 develop 分支的。
  2. 把各 feature team 之间的互相影响降到了最低。每个 feature 都在自己的分支上工作。
  3. 版本发布与 feature 开发解耦,现在他们是两条线。单分支开发模型中 feature 开发和版本发布是绑定在一起的。某些重构或者周期较长或者暂时不打算发布的 feature 可以在独立的分支上开发。
分析一下

这种多分支开发模型会对 CI 带来比较大的负担,分析一下有以下几点。

  1. 测试代码跟随开发走多分支模型:大多数公司的测试代码都是单分支模型,但如果想做 CI 就必须跟随开发的分支策略。因为 feature 分支和 develop 分支的行为是不一样的,同一套代码不能运行在这两个分支里。需要跟开发一样单独开辟一个 feature 分支并在这个分支上写测试代码,结束后合并回 develop。总归一句话,一切跟着开发走。
  2. 部署同样多分支模型:原因跟上面的一样,不过发生的频率很低,毕竟这个阶段部署方式不会频繁变化。
  3. 资源消耗:多分支模型,在每个分支上都要做 CI。这对测试资源是有一定要求的。
  4. 测试代码的质量要求:单分支模型的时候要求测试代码应变要快,多分支模型的时候不仅要快,更要稳。 也就是说测试代码是稳定的,不能总随机性的失败。例如我们知道 UI 自动化是以不稳定闻名的,这时候你要用各种手段把他们稳定下来。不稳定的不要加到 CI 里。因为这时候同时存在很多分支,每天都运行很多次 CI。出现错误你要一个一个去排查,错误多了会让人发疯的。以前单分支模型的时候跑的次数少,可能一天就一次,排查的压力小。现在这个问题被扩大了数倍。所以一定要稳
  5. 对 QA 能力的要求:除了写代码以外,合并分支,解决冲突,code review,该走的正规流程可能都要走。多分支模型会把 QA 们打散到各个 feature team 里,也可能一个 QA 对着几个 feature team。而且要求所有 CI 问题都在 feature 分支上就解决掉,所以这时候对 QA 们的平均水平有所要求,自己水平不行指望别人帮你的情况,最好还是别发生。
  6. 分支爆炸:像我们公司这种 to b 的业务。会有很多个 release 分支,这些 release 分支跟那些 feature 分支不一样,他们很可能一直都不会被销毁。因为一个分支对应一个发布版本,对应着一批客户。不是每个客户都愿意升级版本的。所以如果出了问题可能要在 release 分支下追加 fix 来为客户解决问题。所以测试代码你也要保留这个 release 分支以防万一。so,分支爆炸的情况也挺烦的

恩,可以看出来 ,这时候测试的压力比较大,但没办法,这是 CI 的代价。都是为了尽早暴露问题,尽早反馈。 我们也可以根据情况只维持一个 develop 的 CI。这样做的代价很小。但是有些问题都会很晚才暴露出来,这对发布版本来说就不是什么好事了,大家根据自己项目的情况抉择吧。

多模块多产品线模式

这个我简单说一下吧,因为其实没啥本质的变化。就是产品可能按模块,甚至按产品线划分了团队,代码都是放在不同的库里。 这很常见的,针对不同的模块,产品线做 CI 就可以了。 只是记得需要把整个产品都部署起来做测试也加进 CI 里,以证明他们相互合作是没问题的。这个我就不多说了。

分支模型总结

各家的分支模型都不一样,CI 的方式也不一样。也许有比我们更高效的方式,但我确实没实践过。所以我只能介绍下我们做 CI 的方式,权当抛砖引玉了。

CI 的执行机制

  1. 监控触发:监控到代码提交就触发一次构建,单元测试都用这种方式,因为单元测试运行快。
  2. 定时触发:每隔一段时间触发一次,对于运行比较慢的 UI 和接口测试,一般使用这种方式。
  3. 手动触发:在 Jenkins 或类似的平台上配置好任务,可能准许认为的手动触发一次。 例如开发想 push 代码前在自己的环境上跑跑冒烟测试。

一般都是 3 种方式配合着使用,这方面没什么太好说的。

细节决定成败

我们说想做好 CI 是比较难的,因为它是一系列细节的集合。想做好它就要把很多很多细节做好。例如流程,沟通,代码。想做好 CI 就不是单独 QA 一个角色能做好的。很多时候 CI 推不起来都是因为团队的工程文化导致的。例如 RD 不想写单元测试,或者大家觉得 CI 没必要,或者压根就没什么好的分支模型,有些时候 QA 确实挺无奈的。上面说的分支模型和流程是我们团队历经 1 年多的演变而来,这里面大家的功劳很大,好多东西是 RD 和产品人员自发推进的,我很感谢他们给了我这么有利的环境,感谢第四范式的小伙伴们。

好了,书归正传,虽然我们在现实中会碰到很多困难,但是我们自己也是可以做一些事情的。我们说细节决定 CI 的成败,我来说说从 QA 的角度哪些细节需要注意的,介绍一些小技巧。

自动化测试中的应变和维稳

前面说应变和维稳很重要,我们不希望在 CI 中频繁的修改大量脚本,那样会让人崩溃的。很多地方的自动化到最后都处于崩溃状态就是因为实在维护不过来了。我见过几千条脚本每次 CI 失败个几十一百的情况。这时候 CI 已经是负担了,里面有脚本不稳定的因素,也有开发做个小改动,这边失败了几十个 case 需要一个个去改的情况。所以我来说说自动化测试中应变和维稳的小技巧吧。

封装一切可能的可控的变化因素

我们最常见的可控制的变化因素有哪些呢?以 UI 自动化为例

  1. 参数个数的变化,数据库中和页面上各种 error code 的变化,error message 的文案变化,各种 task,订单等等的 status 的定义的变化等等。
  2. 页面元素结构的变化,属性的变化,新增减元素的变化,各类元素的文案变化等等。
利用好枚举

首先,把什么 error code,status code,error message 的都封装成枚举,其实开发也是这么做的,没准哪天他们重构的时候这些就都变了。如下:

严格规定所有需要用到这些概念的时候都使用枚举,在设计方法的时候就把枚举当做参数传递,或者定义一个类的时候把属性规定为枚举类型。总之,强制使用枚举。这样在变化的时候你只改这个枚举就可以了。

page object 老生重谈

咳咳,说的烂大街的事了,而且没什么难的。但我这里还是强调一下。首先 page object 大家肯定都做过这件事,把每个 page 的元素查询方式都顶一个专门的 page class 里面。但是这样还不够,我们要把通用的操作都放在一个方法里。如下:

我把页面操作按算子划分,所有想设置模型预测这个算子的操作必须调用这个方法。可以看到里面有很多判断,是否要自定义字段,是否全选等。就是这个算子的所有操作路径都在这个方法里。因为分成多个方法难免代码重复,代码重复难免增加维护成本。大家看到我用的参数是一个对象类型,而不是基本类型。也许很多人的习惯是用 String,int 等等这些基本数据类型搞一个方法。但这种设计方式在遇到页面删减元素的时候就悲剧了,假如新加一个必填的元素,你要在这个方法里也加一个参数来控制这个元素。但以前的 case 还是按老的方法签名设置的调用。所以所有调用这个方法的 case 都会改。 而如果你定义了一个对象当做参数,这时候只需要在对象中增加一个属性,然后给个默认值就行了。这样很多 case 其实不用改。看一下这个对象的定义:

这个对象中还使用了枚举,我们看一下枚举的定义:

因为控件是按文案搜的,也就是 text(),所以把文案也封装成枚举,以防设计人员哪天看这个文案不满意要换一个。这就是我之前说的封装一切可控的变化。只要这个东西是多次使用的,就封装起来。当然这里其实也可以不用枚举,只是我习惯了。

如果某些功能,例如一些悬浮层是对所有页面生效的,或者一些操作是对所有页面生效的。可以把它放在 base page 里面,其他的 page 继承这个 page。如下:

为了稳定使尽一切手段

stager

这是我跟老外学的,老外管这种方式叫 stager,我理解就是后门。意思是使用一些其他的手段帮助测试把前置条件准备好。 还是以 UI 自动化为例,假如我要测试批注合同的功能,我需要先创建合同吧,不仅是评论,查询,删除,修改,状态转变等等都需要一个合同作为前置条件。如果都在页面上操作这个重复就太多了,创建一个合同可能要跳转 N 个页面,这不仅耗时,而且页面操作是有不稳定因素的,容易失败。如果前端页面出现 bug 也同样会导致一大批的 case 失败。 所以老外搞出了 stager 这个方式。跳过页面,调用产品代码或者直接访问数据库等方式制作一些通用的服务。帮助脚本创建这些前置条件。 他们的理念是我们专们有 case 校验这个页面,没必要让所有脚本都走这个页面,那太耗时了。所以那时候他们的自动化有两种,一种叫 BQ,意思是短小的 case 队列,不使用 stager,专门校验页面,逻辑简单。另一种叫 LQ,意思是长的队列,里面都是复杂的 case,使用 stager 创建前置条件。

数据库操作和文件系统的操作做辅助
  1. 不管什么类型的测试,有些时候光检验接口返回值,或者页面返回信息是不靠谱的,验证不完全的。如果是 UI 测试你想要验证结果可能做不到或者要跳转很多页面。这时候也许直接验证数据库和文件系统反而比较靠谱。
  2. 有些接口是异步的,你需要等待它执行完毕后去验证结果。可你从接口返回值那里得不到执行信息,因为它是异步的。同样页面上你也得不到信息,或者想得到你就得频繁的刷新,或者又是跳转好几个页面。这时候还是直接写一段逻辑轮循数据库的状态比较靠谱。例如下面的样子:

再举一个轮询 hadoop 集群任务的例子

页面的稳定

UI 自动化中,再怎么用 stager 你也避免不了太多的页面操作。所以有些时候网卡一下,系统处理速度太慢或太快都有可能导致 UI 自动化的失败。所以在写 page object 的时候要慎重处理页面的操作。尽量避免硬等待,使用框架的轮询方法,如果出现错误话,重试一次等等。举个例子如下:

数据的稳定

我们在自动化中难免碰到 case 之间会有数据的冲突。例如某条 case 修改了产品的全局设置,某条 case 在会清理掉所有的数据,某条 case 在运行前必须要求数据是空的等等。一般我们都会在测试的 before 或者 after 方法里小心翼翼的做数据的清理。但仍然免不了想清理的数据清理不了。那么怎么办呢,如果我们有一种方式能自动的清理数据,而且可以让编写脚本的人选择清理什么数据,什么时候清理数据。那就省事很多了。如下:

这里我定义了一个注解,注解的数据恢复策略选择的是 method,意思是每运行一个 case 后,都把数据恢复成 case 执行之前的状态。还有一个级别是 class,意思是等当前 class 中所有的 case 都运行结束后,把数据恢复到这些 case 执行前的状态。这样可以保证不会因为当前 case 的执行会产生或改变什么数据会影响到之后的 case。当然也可以事先创造一批测试数据,例如订单,之后针对这个订单的任何操作都加上这个注解。这样所有 case 都可以使用这个订单测试而不担心会被其他 case 影响。具体的实现方式大家翻一下我之前写的一篇关于 assertJ 的用法监控式数据管理

代码复用的细节

上面介绍了应变和维稳的细节,现在我们谈谈代码复用。代码复用的越好,我们的维护成本越少。我介绍两个小技巧。

数据驱动

测试用例要做成数据驱动这个大家都知道,这样可以减少代码重复。 所以大家一般都会把测试数据都放在一个文件里。 这里需要注意一点的就是我们刚才讲的我们为了应变封装了很多枚举和对象。但从文件中读取出来的东西都是基本数据类型。每次读取出来再封装成对象这个成本太高了。 所以我是这么做的

我从文件中读取出来的时候就转换成对象类型了。文件中定义了很多组参数。这样很多的 case 我就用一条脚本执行了。并且我不需要做类型转换。 具体的实现细节请大家翻翻我以前的一篇关于数据驱动极其变种的文章吧。

断言定制

在测试中还有一个有很大代码量的地方就是断言,在接口测试中,尤其是 RPC 接口测试中,我们的断言会比较复杂。因为返回值复杂,以前测试 dubbo 接口的时候,发回的 java 对象特别复杂,一个元素一个元素取出来做断言那太痛苦了。如果你转成 json 做对比,又处理不了很多情况。假如某些字段是随机生成的,每次都不一样。或者说 json 太长做字符串对比,报错信息很难让你分清是哪个字段错误的。 所以我觉得做成下面这个样子比较好。

我以前写的代码了,截个图,上面就是比较两个 task 对象是否相等,这是一个 copy task 的接口,我们要比较两个从数据库中查询出来的 task 对象是否相等,因为是用 mybatis 查询出来的,所以都封装成了对象,第一行代码是创建一个责任链对象,第二行代码规定了什么东西不需要验证,因为 task 的 ID 是随机生成的不可能相等。最后我们把两个对象仍进去就行了。你不用管它怎么验证的,责任链在运行到 javaBean 类型的时候,就会用 java 反射解析两个对象的每一个属性并调用链表中其他的节点做相应的断言。不仅仅是 javaBean 类型,JSON,数组,List,Map,File 你全都能不管三七二十一的仍进责任链里。以前我们最怕的就是一个 ORM 映射出来的字段百八十个的,光是写断言就写到手软。现在完全木有这个问题了。如果有新的验证类型出现,你只需要在责任链表里增加一个节点对应这个类型作验证就好了。我在测试开发之路 ---- 可读性,可维护性,可扩展性中写过实现方式。大家可以看一下。

# 总结
CI 中我们说工程文化是灵魂,流程是核心之一,分支模型是核心之二,自动化是核心之三。我分享了一些我做 CI 中的一些经验和小技巧。其实 CI 还有很多方面,例如 CI 工具的使用配置 (如 Jenkins),一些辅助工具的使用 (如代码覆盖率),CI 就是个一堆细节堆砌出来的东西,做好所有细节就才能好 CI。 我们做的还有很多不足,希望以后能慢慢做的更好吧。也希望我这篇文章能引出更多的对 CI 的实践的讨论。


↙↙↙阅读原文可查看相关链接,并与作者交流