干货 | 质量保障新手段,携程回归测试平台实践

2021-02-01 09:51:58 浏览数 (1)

作者简介

Sedro,携程资深测试工程师,专注于测试技术探索及测试工具研发。

一、系统回归问题

回归测试是软件生命周期一个十分重要的环节,但项目在随着版本的逐步迭代,功能日益增多,系统愈加复杂,在测试过程中测试人员常常需要回归稳定版本的功能以保证不被待发布版本需求所影响。若要对系统进行全方位回归,这个测试的工作量将会非常庞大,而且可能几百上千用例中才会发现一个甚至是0个问题,测试投入产出不成比例。

而CPR则为上述问题提供了较好的解决方案。它通过基于稳定版本的输出,对待发布版本的输出进行比较,同时还将校验两个版本对下游请求的差异。根据比对结果评判待发布版本是否正确,可以大大降低回归的工作量。

二、CPR目标

  • 大量真实流量确保覆盖率
  • 将录制的流量作为用例管理起来进行自动化回归
  • 流量回放支持子调用自动化mock,避免回放产生脏数据
  • 流量回放支持子调用结果的验证
  • 减少人力资源

三、目标实现基本过程

1)首先将部署了稳定代码的服务器作为流量采集源。测试人员在进行功能、接口测试时,实现测试执行过程中主调用以及子调用的入参和返回值的录制。通过功能和接口测试实现对应用功能的全量覆盖,使得应用中请求流量都会被录制到。

2)将录制到的请求流量复制到console平台,由测试人员分析有效的流量归纳为用例。后续即可采用这些有效用例来对待发布版本进行回放和差异比对。

3)回放完成告知到测试人员,由测试人员对回放的差异结果进行确认,快速发现被测系统bug并解决。

四、CPR原理及实现

4.1. CPR录制的数据抓取点

如上图所示,CPR采集的数据主要为两方面:待测应用接收到的客户端请求及响应;待测应用接收请求后向外部服务的子调用请求及响应。CPR目前支持的请求类型如上图橙色框中所示;但CPR本身设计时对各类请求采用插件式设计,使其具有较好的扩展性,对于后续其他需要捕获的类型能很好的进行支持。当前CPR系统支持的报文协议为JSON和PB(ProtoBuf)。

4.2. CPR结构简介

CPR分为两大组件:

1)CPR (CtripPaymentRepeater) 组件,该组件基于开源的jvm-sandbox开发,用于录制和回放流量。此组件核心为两部分:

  • CPRRecord:目标是在稳定代码环境中录制请求调用的入参和返回值,并上送到存储服务。使得CPR Replay具备回放流量的数据。
  • CPRReplay:功能为接收回放服务提供的回放流量,在待测代码环境中进行回放,并将回放结果上送至对比服务,让其实现正常系统和待测系统返回结果比对差异的能力。

2)CPRConsole组件,该组件主要录制/回放的配置管理;数据存储/数据对比等具备多种能力。主要包含三部分:

  • 存储服务:对接收到的录制流量数据,将其持久化保存,待后续用户筛选有效流量。
  • 回放服务:功能为将录制流量进行还原,然后对入口调用做一次流量的发起,使得CPR Replay。
  • 对比服务:对接收到的回放数据与录制数据进行差异比对。

4.3 CPR处理流程

如上图所示,通过这两大组件的协同工作将稳定代码的流量自动捕获并持久化存储,然后由测试人员分析流量收藏为有效用例,对于后续待正式发布的代码,可以用有效用例来进行回放和差异比对,根据差异比对结果发现待测系统存在的问题。

该系统在处理比对时有两个与其他方案较大不同的特性:

1)对子调用MOCK处理:本方案采用的是记录下原始子调用内容及子调用返回内容;在系统内部完成对子调用返回的处理,不再需要实际的子系统或mock系统进行测试支撑。以此减小了环境、数据测试处理的难度和准确性。

2)子调用比对数据的处理:本方案采用的是比对子调用请求。例如一个DB操作,若请求内容是无误的,则认定请求正常;不再去比对具体的数据库存储值。这样的处理方式同样简化了测试环境搭建、测试数据准备,同时还解决了测试环境中数据共享变更可能带来的数据差异问题。

4.4 流量复制实现

在整个录制过程中,CPR进行录制的处理流程主要是在DefaultEventListener类中实现。

1)DefaultEventListener类

DefaultEventListener是用来处理触发事件的类。不同的插件 (soa,mysql,http,redis等) 可以自定义自己的EventListener来进行各自特殊的事件处理。目前插件默认使用DefaultEventListener。

在DefaultEventListener中,承接的是所有事件的处理,也就是说录制/回放操作都是集中在这个类中实现的,只是根据不同的条件来区分是录制流量还是回放流量,从而判断该执行录制还是该执行回放。

当事件第一次过滤时,会进行一次初始化跟踪器。

其中traceId实际上是这个跟踪器的独立标识。所以无论是录制和回放都会有其独立的traceId。

2)Before事件处理

对应的方法是DefaultEventListener.doBefore。

根据traceID判断当前流量是否为回放流量,若为回放流量则调用processor.doMock方法执行Mock,执行完成后直接返回,不再执行后续操作。

若当前流量为录制流量,则基于当前获取到的信息初始化Invocation实例,其中会调用插件中processor#assembleIdentity、processor#assembleRequest、processor#assembleResponse、processor#assembleThrowable四个方法,分别处理调用标识、请求参数、返回结果、抛出异常。最后将当前事件的Invocation信息存放到录制缓存中。

3)Return事件处理/ Throw事件处理

Return/ Throw事件的处理逻辑基本一致。

判断当前流量是否为回放流量,若为回放流量则直接返回,不执行后续操作。

若录制流量则从录制缓存中获取对应的Invocation实例,调用插件调用处理器的assembleResponse/assembleThrowable方法,并将reponst/throwable结果设置到Invocation实例中。

回调调用监听器InvocationListener#onInvocation方法,判断Invocation是入口调用还是子调用,如果是子调用则保存到录制缓存中,如果不是则调用消息投递器的broadcastRecord方法将录制记录序列化后上传给cpr-console。

4.5 流量回放实现

回放流程,是由测试人员选择有效用例,在待测系统中执行回放。

回放服务接受到回放记录,对记录进行反序列化,还原到原来录制的对象,保证classloader与录制时候一致。CPR在执行回放任务的过程中,首先会根据录制记录的信息,构造相同的请求,对被挂载的任务进行请求,并跟踪回放请求的处理流程,以便记录回放结果以及执行mock动作。整个回放过程比较复杂,主要包括以下几个个流程:

1)回放请求的处理

这里以http回放入口为例,RepeaterModule#repeat方法是回放请求最初处理入口。添加@Command注解,使得外部可以使用http协议方式调用入口。

该方法在接收外部的回放请求后,对请求参数进行校验,校验通过后会将参数实例化,形成一个回放事件RepeatEvent,发布到EventBusInner中被RepeatSubscribeSupporter#onSubscribe方法进行处理。

处理过程为首先会将RepeatEvent的参数进行反序列化,获取回放相关的录制记录的信息,然后通过这些信息从prepeater-console拉取对应的录制记录详情(RecordModel),最后用默认流量分发器DefaultFlowDispatcher进行分发。

2)回放任务的分发

回放任务的分发由DefaultFlowDispatcher#dispatch方法实现。

主要是针对回放任务的信息进行校验,校验失败会抛出错误到上层。通过校验的回放任务,则会初始化回放上下文信息,并存放到回放缓存中。根据回放任务的入口插件类型,获取对应插件的repeater进行回放处理。

3)回放结果执行与记录

回放任务执行、回放结果记录的实现逻辑在AbstractRepeater#repeat中实现。

通过executeRepeat方法交由各个插件的回放器实例来调用。当插件开始执行回放任务时,会根据回放上下文以及当前repeater的应用信息初始化回放结果记录RepeaterModel的实例。

初始化回放结果记录后,初始化回放线程跟踪并触发回放请求并获取回放请求返回的结果。

获取回放请求返回的结果后,停止线程跟踪,将回放结果以及当前的一些状态信息保存到回放结果记录的实例中。

最后通过调用消息投递器的AbstractBroadcaster#broadcastRepeat方法将回放结果记录序列化后上传到crp-console保存。

4)回放过程中的Mock处理

在回放过程中,直接在DefaultEventListner#Before事件处理中执行。

processor#doMock方法,交给插件调用处理器执行mock,并且只对子调用事件执行mock。

代码语言:javascript复制
public void doMock(BeforeEvent event, Boolean entrance, InvokeType type) throws ProcessControlException {
    /*
     * 获取回放上下文
     */
    RepeatContext context = RepeatCache.getRepeatContext(Tracer.getTraceId());
    /*
     * mock执行条件
     */
    if (!skipMock(event, entrance, context) && context != null && context.getMeta().isMock()) {
        try {
            /*
             * 构建mock请求
             */
            final MockRequest request = MockRequest.builder()
                    .argumentArray(this.assembleRequest(event))
                    .event(event)
                    .identity(this.assembleIdentity(event))
                    .meta(context.getMeta())
                    .recordModel(context.getRecordModel())
                    .traceId(context.getTraceId())
                    .repeatId(context.getMeta().getRepeatId())
                    .index(SequenceGenerator.generate(context.getTraceId()))
                    .type(type)
                    .build();
            /*
             * 执行mock动作
             */

                final MockResponse mr = StrategyProvider.instance().provide(context.getMeta().getStrategyType()).execute(request);
                 /*
                 * 处理策略推荐结果
                 */
                switch (mr.action) {
                    case SKIP_IMMEDIATELY:
                        break;
                    case THROWS_IMMEDIATELY:
                        ProcessControlException.throwThrowsImmediately(mr.throwable);
                        break;
                    case RETURN_IMMEDIATELY:
                     //   if(!type.equals(InvokeType.MYSQL) &&! type.equals(InvokeType.MYBATIS)){
                            ProcessControlException.throwReturnImmediately(assembleMockResponse(event, mr.invocation));
                    //    }
                        break;
                    default:
                        ProcessControlException.throwThrowsImmediately(new RepeatException("invalid action"));
                        break;
                }

        } catch (ProcessControlException pce) {
            throw pce;
        } catch (Throwable throwable) {
            ProcessControlException.throwThrowsImmediately(new RepeatException("unexpected code snippet here.", throwable));
        }
    }
}
代码语言:javascript复制

AbstractInvocationProcessor#doMock方法的实现,与AbstractRepeater#repeat相似,实际调用时是通过插件中各自的Processor的实例进行调用的。

当开始执行回放mock时,会先获取回放上下文的信息。根据回放上下文的信息,判断是否跳过mock的执行。当需要执行mock时,会根据回放上下文中的信息初始化MockRequest实例,通过mock策略计算获取MockResponse。

根据MockResponse返回状态进行不同的操作:

1) 当MockResponse执行结果是返回正常内容时,就会抛出一个终止当前调用操作并返回当前的返回结果,用ProcessControlException异常来阻挡需要mock的方法的实际调用。

2) 当MockResponse执行结果是返回一个异常时,就会抛出一个终止当前调用操作并抛出ProcessControlException异常,此时需要mock的方法不会被真实调用。

3) 只有当MockResponse返回状态是skip时,则不对这个调用事件做任何处理,调用会真实发生,其他任何状态都会通过抛出ProcessControlException异常来阻挡这个事件的实际调用。

4.6. 缺陷判定简析

1)对于读操作,我们主要关注在相同请求下正常系统和待测系统的返回结果的差异,读接口也提倡对所有对外请求进行mock,这样回放时能保持当时的一个现场环境,保证验证的准确性。

2)对于写操作,只验证接口返回结果是不够的,需要验证它具体写入的数据是否正确。例如创建支付订单会调用PPI的写订单PPI.Bill.CreatePaymentBill接口,那么我们需要验证回放时调用PPI的参数和录制时调用PPI的参数是否一致。

3)对比默认是全对象字段逐一对比。由于录制环境和回放环境所处环境不同,有一些必然不一致的信息,例如随机数、时间,以及系统ip等等,这些内容系统做了默认不比对处理。系统对于子调用是默认对比所有子调用,也可以通过平台配置排除一些不关心的子调用的对比。

4.7 数据安全保护

系统录制与回放过程中基于对通讯、数据的安全保护要求,系统实现了对公司神盾安全系统的接入,使得传输过程、落地数据及日志等涉及安全要求的数据内容都符合了信息安全要求。也确保了使用者不会接触到敏感数据等限制性内容。

五、小结

本文系统的介绍了CtripPaymentRepeater的目标以及实现原理以及流量复制、流量回放的技术方案,希望给因频繁进行回归测试而困扰的朋友一些帮助和借鉴。

在系统成熟的前提下,可以扩展本平台进行生产数据的采集比对。当这一步实现后,可以想见回归将真正不再是一件难事,尤其是生产回归需要面对的数据污染等问题都将不再是问题。同时该平台还可演化为MOCK平台,这样的MOCK将不再依赖任何系统,只需在待测应用的服务器上增加一个Agent即可完成。

0 人点赞