后台自动化测试与持续部署实践

作者:cloudyzhao,腾讯 PCG 后台开发工程师

随着 DevOps 研发模式思想的普及,“测试左移”、“开发负责质量”等理念也开始深入各业务团队。本文以一个实际项目( LogReplay )的 DevOps 实践为例,介绍如何通过可测性提升、自动化测试、持续集成和持续部署流程,最终实现后台微服务的高质量、持续、自动化部署。

测试左移是 DevOps 研发模式中开发全面负责质量的核心环节之一,而测试左移的一个重要手段,就是在开发过程中的各环节快速执行大量有效的自动化测试用例,从而尽早地发现得到质量反馈,发现潜在的代码问题。(详细开展参考 2.自动化测试)

软件可测试性对软件研发和质量保障有着至关重要的作用,可测试性是实现高质量、高效率交付的基础。可测试性差,会直接增加测试成本,让测试的结果验证变得困难,进而会让开发者不愿意做测试,或者让测试活动延迟发生。所以在全面开展自动化测试之前,需要提升软件的可测试性。(参考 1.可测性提升)

通过大量的自动化测试,我们可以取代低效的手工测试验证。将测试合理的配置到 CI/CD 流水线中,从而可以在提交代码后,立即进行测试、构建制品,再通过一系列环境的测试验证(在上一个环境测试通过后,才能进入下一个环境),最终将制品自动发布上线。(参考 3.持续集成,持续部署)

1. 可测性提升

1.1. 可测性是什么

可测性,简单来说,就是指一个软件系统能够被测试的难易程度。可测试性差,会直接增加测试成本,让测试的结果验证变得困难,进而会让工程师不愿意做测试,或者让测试活动延迟发生。

常见的可测试性问题:

接口测试层面:

  1. 接口测试缺乏详细的设计文档:接口测试如果没有设计契约文档作为衡量测试结果的依据,就会造成测试沟通成本高, 无法有效开展结果验证,开发和测试来回扯皮的尴尬窘境。即使有了文档,还必须保持文档能够及时更新,否则会造成误导。

  2. 构建 Mock 服务的成本过高:微服务架构下,如果构建 Mock 服务的难度和成本过高,会直接造成不可测或者测试成本过高。

  3. 接口调用的结果验证困难:接口成功调用后,判断接口行为是否符合预期的验证点难易获取。

  4. 接口调用不具有幂等性:接口内部处理逻辑依赖与未决因素,比如时间、不可控输入、后台批处理 job、随机变量等,破坏接口调用的幂等性。

  5. 接口参数设计过于复杂,暴露了很多不必要的参数:很多内部参数不应该在接口参数上暴露出来,这些参数应该做到无感知,需要保持接口设计的简单性。

  6. 使用定制化的私有协议:非标的私有化协议会提升测试的难度,通用类的工具无法直接使用。

被测代码层面:

  1. 私有函数的调用:在代码级测试中,私有函数无法直接调用。

  2. 私有变量的访问:私有变量缺乏访问手段,以至于无法进行结果验证。

  3. 函数功能的多样性:一个函数如果颗粒度太大,同时实现了好几个功能,会大大提升测试的难度,一来这是因为功能多必然入参也多,测试的时候参数初始化难度就会变大,二来结果验证的关注点也会同时变多,容易出现更多的组合验证,严重的时候会出现组合爆炸。

  4. 代码依赖关系复杂:被测代码中依赖了外部系统或者不可控组件,比如,需要依赖第三方服务、网络通信、数据库等。

  5. 代码可读性差:代码使用“奇技淫巧”,造成可读性差,同时又缺乏必要的注释说明。

  6. 重复代码多:重复代码意味着重复逻辑,如果有改动,各个重复逻辑都需要被测试到,测试成本高。

  7. 代码的圈复杂度(Cyclomatic Complexity)过高:圈复杂度过高的代码往往测试成本很高。

  8. 设计上钩子和注入点缺失:没有预留钩子或者注入点,后期调试和定位问题的扩展能力变差。

1.2. 可测性怎么提升

1.2.1. 提升可观测性

可观测性是指能否容易地观察程序的行为、输入和输出,一般是指系统内的重要状态、信息可通过一定手段由外部获得的难易程度。

任何一项操作或输入都应该有预期的、明确的响应或输出,而且这个响应或者输出必须是可见及并且是可查询的,“不可见”和”不可查询“就意味着“不可发现”,可观测性就差,进而影响可测试性。

“可见”的前提是输出,提高可观测性就应该多多输出,包括分级的事件日志(Logging)、调用链路追踪信息(Tracing)、各种聚合指标(Metrics),同时也应该提供各类可测试性接口获取内部信息以及系统内部自检信息的上报,以确保影响程序行为的因素可见。另外,有问题的输出要易于识别,无论通过日志自动分析还是界面高亮显示的方式,要能有助于发现。

在实际的项目实践中,我们主要从以下几个方面去提升服务的可观测性:

1) 收敛接口返回的状态码

当前服务的下游依赖服务越多,具体的失败点也就会越多,直接下游服务数量会增加失败点的常数量级(加法关系),而间接下游服务的数量会增加失败点的几何量级(乘数关系)。我们不可能把下游暴露的错误原原本本地透传到客户端去,因为客户端不见得能理解所有错误,或者不一定根据不同错误有不同的应对措施,因此状态码必须收敛。

2) 失败必须往上游抛出

上游不必关心具体失败点(即端到端调用的返回信息可能不足以定位失败点),但必须向上游抛出。将失败完全内部消化,只会让上游调用者不知道请求是否成功,或者无法确认应对操作。

在 trpc(腾讯内部一款服务框架)服务中,统一的错误由错误码 code 和错误描述 msg 组成,这与 go 语言常规的 error 只有一个字符串不是很匹配。因此,我们使用 trpc 框架封装的 errs.New 将状态码与状态消息一并返回(如果下游未用 errs.New 返回错误,上游拿到的状态码是 999)

func (s *helloServerImpl) SayHello(ctx context.Context, req *pb.HelloRequest, rsp *pb.HelloReply) error {
    if failed { // 业务逻辑失败
        return errs.New(your-int-code, "your business error message") // 失败 自己定义错误码,错误信息返回给上游
    }
    return nil // 成功返回nil
}

3) 接入分布式日志收集

在排查错误时需要找到具体失败点,记录失败点的手段有多种:可以使用日志系统记录下来,可以在相同的错误码中使用不同的错误信息,也可以在全链路追踪中埋点。

其中,接入分布式日志收集,可以最大限度保留定位信息。我们可以使用腾讯内部日志工具上报到鹰眼(腾讯内部工具),也可以使用智研(腾讯内部工具)。接入智研只需要在 trpc_go.yaml(trpc 服务的配置文件)里配置,查看日志的体验基本相当于 kibana。

![图片]

4) 接入全链路追踪系统

状态码和状态消息是面向客户的,拿着它们去找失败点可能会定位精度不足。全链路追踪非常有价值,任何现代的后端系统都应该接入一套 OpenTelemetry 的实现,使用 OpenTelemetry 的好处是其协议具有通用性,可以很好地被各种工具支持。每一个严肃的业务开发都有必要了解这一块的知识,当你需要排查一个线上问题而无从下手时就会深有体会。

腾讯内部天机阁工具可以作为 OpenTelemetry 的后端,我们将所有的服务接入了天机阁。接入全链路追踪后,在接口测试和端到端测试时,使用统一的格式将 Trace ID 打印到 test log 中,一旦测试失败,就可以拿着 Trace ID 去快速定位失败点。

1.2.2. 提升可理解性

可理解性是指被测系统的信息获取是否容易,信息本身是否完备,并且易于理解。比如被测对象是否有说明文档,并且文档本身可读性以及及时性都有保证。常见的可理解性包含以下这些方面:

  • 提供用户文档(使用手册等)、工程师文档(设计文档等)、程序资源(源代码、代码注释等)以及质量信息(测试报告等)

  • 文档、流程、代码、注释、提示信息易于理解

  • 被测对象是否有单一且清楚定义的任务,体现出关注点分离

  • 被测对象的行为是否可以进行具有确定性的推导与预测

  • 被测对象的设计模式能够被很好地理解,并且遵循行业通用规范

我们在可理解性上的实践目前并不多,后面有更多经验时再进行分享。

1.2.3. 提升可控制性

可控制性是指能否容易地控制程序的行为、输入和输出,是否可以将被测系统的状态控制到测试条件的要求。一般来讲,可控制性好的系统一定更容易被测试,也更容易实现自动化测试。可控制性一般体现在以下各个方面:

  • 在业务层面,业务流程和业务场景应该易分解,尽可能实现分段控制与验证。对于复杂的业务流程需合理设定分解点,在测试时能够对其进行分解。

  • 在架构层面,应采用模块化设计,各模块之间支持独立部署与测试,具有良好的可隔离性,便于构造 Mock 环境来模拟依赖。

  • 在数据层面,测试数据也需要可控制性,能够低成本构建多样性的测试数据,以满足不同测试场景的要求。

  • 在技术实现层面,可控制性的实现手段涉及很多方面,比如提供适当的手段在系统外部直接或间接的控制系统的状态及变量、在系统外部实现方便的接口调用、私有函数以及内部变量的外部访问能力、运行时的可注入能力、轻量级的插桩能力、使用 AOP(Aspect Oriented Programming)、trpc-filter(trpc 服务的插件特性)等实现更好的可控制性等。

为了提升中间件的可隔离性及更好的构建测试数据,我们对中间件做了下述几项治理工作:

1) 统一使用名字服务进行寻址

微服务架构下,如果还是使用固定的 ip:port 访问中间件,将难以灵活应对中间件扩缩容,也很难使用集群式管理。因此,寻址方式应该统一使用名字服务,统一通过 namespace+env 方式进行寻址,无需再为各个环境单独配置 ip:port。

以 db 为例,目前 db 可支持通过域名或名字服务寻址:

a. 通过域名寻址,需要为 Production 和 Development 分别配置不同的域名,如下面的 tap1.comtap2.com;

b. 通过名字服务寻址,不同环境用同一个名字 tap.db,再通过 namespace/env 到北极星(腾讯内部的统一服务发现和治理平台)寻址不同环境的地址,即可访问;

2) 访问 client 统一

使用腾讯内部统一的中间件管理模块 trpc-database 作为中间件的访问 client,有几个优势:

a. trpc-database 封装了 34 种中间件的 client,基本满足需要;

b. 每种中间件的访问,统一使用一种 client,避免使用了一些个人 github 名下的 client 实现,因功能或使用上的差异导致写代码时容易出 bug(如https://github.com/jinzhu/gorm);

c. 基于 trpc 框架,可以使用 007(腾讯内部的服务监控平台)、天机阁等能力,提升可观测性,更方便监控;

d. 可以使用 trpc-filter 对流量作额外操作,比如修改路由等,操控更灵活。

3) 生产环境与测试环境所用中间件实例有严格区分,需要物理隔离

中间件环境分为正式环境 Production、基线环境 Development 和自动化测试环境,手工测试和体验使用 Development 环境的中间件,自动化测试则使用自动化测试环境。不同环境,尤其是生产环境与诸测试环境之间,需要使用不同的实例作物理隔离,这是避免测试行为影响生产环境的必要手段。

除了中间件的治理,其他我们在可控制性上的实践目前也不够多,后面有更多经验时再进行更多的分享。

2. 自动化测试

2.1. 概述

微服务架构下,测试分为三个层次:

  1. 端到端测试:覆盖整个系统,多个服务的集成测试,通常模仿用户从接入层测试

  2. 接口测试:针对服务接口进行测试

  3. 单元测试:针对代码单元进行测试

三种测试从上到下实施的容易程度递增,但是测试效果递减。端到端测试最费时费力,但是通过测试后我们对系统最有信心。单元测试最容易实施,效率也最高,但是测试后不能保证整个系统没有问题。

我们当然希望能“简单点”,用一种测试搞定所有的事情,但实际的实践来看,目前还没有这种“银弹”方案,我们仍然需要组合式的开展。

然而问题是,什么时候该写单元测试、什么时候该写接口测试或端到端测试?又各需要写多少呢?

2.2. 测试编写

关于什么时候需要写什么样的测试,在我们的实践中,得出的结论是:

  1. 核心功能场景,需要写端到端测试。核心功能场景的定义:
  • 主流程:出错时会对导致用户无法继续使用的场景

  • 部分关键流程:出错时会导致的损失比较大的场景

  1. 服务提供的对外接口,需要写接口测试。
  • 存量服务:调用量 TOP 60% 以上的对外接口

  • 增量服务:全部对外接口

  1. 代码的导出函数,需要写单元测试
  • 存量代码:近期需要重构、修改较多的 package 中的导出函数

  • 增量代码:所有导出函数

2.2.1 单元测试编写

我们的实践中,主要有手工编写单元测试和借助 TestOne 单测辅助工具自动生成单测用例。手工写单元测试的方法的文章比较多,观点也非常多,单元测试代码怎么写、也有非常多的教程介绍,推荐使用 PCG Testability 认证培训中所介绍的单元测试的 5 个方法(聚焦行为、显式依赖、封装细节、职责单一、Readability),这里不再细述。

单元测试在腾讯内部的普及时间并不长,我们存在一些没有单测用例或者单测用例较少的存量代码库,这些代码库一旦由于业务需要发生逻辑变更,缺少快速回归的自动化测试手段。对于这些问题,我们使用了 TestOne 单测辅助工具,来协助我们提高编写单测的效率和质量,以及提升存量代码库的自动化率。

1)增量代码场景

对于 Logreplay 不断迭代的需求中的增量代码,可以使用 TestOne 单测辅助工具 的脚手架功能快速生成单测模版,相较于 gotests 生成的模版,工具提供了依赖分析、调用链分析、mock 生成、指针类型断言分析等功能,可以起到测试数据简化、单测有效性提升、可读性提升,进而提升了单测编写的整体效率和质量。

如对于如下业务代码:

// AddUser 添加用户// AddUser 添加用户
func AddUser(ctx context.Context, client gp.UserClientProxy, req *gp.UserGroupUserReq,
 rsp *pb.UserGroupUserResp) error {
 userRsp, err := client.UserGroupUser(ctx, req)
 if err != nil {
 log.Errorf("%v", err)
 return err
 }
 if userRsp.Code != 0 {
 rsp.Code = pb.Code_FAIL
 rsp.Message = fmt.Sprintf("addUser code=%d,msg=%s", userRsp.Code, userRsp.Message)
 }
 rsp.Code = pb.Code_SUCCESS
 return nil
}

生成的脚手架如下

可以看到,测试数据按手写结构体的方式逐字段展开,方便开发同学填写不同的测试数据;变量 rsp 虽然在入参,但根据变量依赖分析判断其在方法中被写入,会提示开发同学对其进行断言,设置 rspWhenReturn 字段,在调用结束后进行断言验证;通过方法调用链分析判断被测方法有 trpc 方法调用,自动生成了 mock trpc 接口调用的框架,方便开发同学直接构造 mock 场景;同时 //FIXME 注释会提示开发同学确认单测逻辑,避免造成辅助工具的滥用。

2)存量代码场景

对于 Logreplay 存量代码库,有的单测用例偏少,后续如果代码逻辑发生变更,缺少快速进行验证的单测回归测试手段。我们借助了 TestOne 单测辅助工具自动生成单测用例,为当前代码库快速建立质量保护网,为后续发生变更时进行验证提供了基本保障。

目前 LogReplay 项目的单测用例已经覆盖了大部分的代码行,每天都会本地和流水线上运行。

2.2.2. 接口测试编写

经验总结:

在接口测试的实践中,我们认为比较重要的实践经验:

  1. 测试用例代码跟业务代码一样,要符合语言规范。

  2. 用例一般是 setup、invoke、assert、teardown 4 段式,setup 一般是准备协议数据, invoke 一般是往接口发送请求,

assert 是断言,断言除检查返回码外,还需要检查返回的协议数据,teardown 对数据进行还原/释放。

  1. 每个用例有自己独立的测试流量,该数据不在不同用例之间共用,且一般在单独文件中描述,不与用例写在同一个文件里。

  2. 涉及帐号的用例请求,需要从测试数据管理系统中申请,一般不要在用例里硬编码。

  3. 涉及写接口的用例,要么给请求染色,要么在隔离环境的隔离中间件实例中进行测试。

  4. 我们建议限定接口测试涉及服务的范围,即只检查服务本身的有效性、可用性,而将下游的服务、中间件依赖 mock 掉。

更真实的场景校验,建议放在集成测试、端到端测试。

开始编写:

写接口测试,其实跟写单元测试类似,下面是一个简单的接口测试示例,测试服务 hello.world 的 某个 接口:

func TestDemo(t *testing.T) {
    opts := []client.Option{
        client.WithServiceName("trpc.hello.world.hello"),
        client.WithNamespace("Development"),
    }
    request := &pb.HelloRequest{
        Msg: "my test message",
    }
    rsp, err := pb.NewHelloClientProxy().SayHello(context.Background(), request, opts...)
    assert.NoError(t, err)
    assert.NotEmpty(t, rsp.Msg)
}

惟一的问题是,腾讯内部 OA 网络与 IDC 网络不通,写好的接口测试用例,无法直接运行。这时候,可以在用例代码中引入 TestOne 的接口测试 SDK,无需增加任何其它代码,就可以打通网络了:

使用 Mock 提升稳定:

当我们需要把接口测试用例放到 MR 阶段运行,更早的发现和修复问题,同时应该更大范围的开始编写接口测试用例时,很快就有了新的问题:

  • MR 阶段的运行非常频繁,失败次数会被指数级的放大,对失败更加敏感,原先的稳定性已经满足不了要求;

  • 写测试时,被测服务会经常依赖一些其他服务,而依赖的服务可能还没有开发完成,测试难以编写;

数着日渐凋零的头发,我们开始分析失败的测试用例,发现失败的主要原因是用例质量不够高、依赖的服务变更了、用例并发运行时的数据冲突。怎么解决呢?一方面我们需要提升用例的编写质量,另一方面,我们需要解决因多个的测试环境共同使用相同的服务依赖及中间件依赖导致的不稳定问题,我们使用了 TestOne 提供的沙箱测试环境和 接口测试 SDK 的 Mock 能力来解决,同时对中间件进行了一些治理(参考 #1.2)。

使用普通测试环境和 TestOne 沙箱测试环境的对比:

使用 TestOne 的接口测试 SDK 提供的 Mock 能力,Mock 下游依赖的服务 及 依赖的中间件,这是 mock http 的 Demo:

// 构造 http 的规则m := mock.NewHTTP("hello.world.com", env)
err := m.URI("/path/hello").
        Rule(mock.Any()).
        Return(`{"status": "ok", "token": 1, "value": "2"}`)
assert.Nil(t, err)

下面是具体的用例场景:

1) Mock 下游服务

当下游/外部服务还没有开发完成时、或者下游/外部服务经常变更/不稳定、或者下游/外部服务返回的数据比较难触发时,我们在接口测试中使用 TestOne 提供的服务 Mock 能力。下面是以 LogReplay 项目的 1 个接口测试用例示例。

用例场景 :查询当前用户下所有的 app 信息。

接口内部逻辑 :传入用户信息,根据用户信息调用下游“hello.world”服务

“sayHello”接口,然后内部处理之后返回。

逻辑验证 :这个接口测试我们主要想验证的是该接口的联通性和内部处理逻辑是否正常。

无 mock 用例编写代码

func TestMethod_sayHello(t *testing.T) {
   // 调用接口准备阶段
   req := &hellopb.sayHelloReq{BaseReq: &common.BaseReq{Username: "testOne"}}
   var env = getEnv()
   opts := []client.Option{
      client.WithServiceName(helloSvcServiceName),
      client.WithNamespace("test"),
      client.WithCalleeEnvName(env),
   }

   cases := []struct {
      name    string
      wantErr error
      want    []string
   }{
      {
         name: "error",
         want:    []string{"success","testOne"},
         wantErr: nil,
      },
   }
   for _, tt := range cases {
      t.Run(tt.name, func(t *testing.T) {
         // 调用接口
         rsp, err := hellopb.NewHelloSvcClientProxy().SayHello(context.Background(), req, opts...)
         // 断言结果可能不稳定
         assert.Equal(t, tt.wantErr, err)
         rspCheck(rsp, tt.want, t)
      })
   }
}

有 mock 用例编写 :在用例中我们对下游的 TRPC 服务进行了 mock,从而避免下游服务还没开发完成、或变更等带来的问题

func TestMethod_sayHelloSUT(t *testing.T) {
   // 此处调用接口准备阶段,代码跟上面一样,省略
   cases := []struct {...}{
      {
         name: "success app1",
         // 配置 mock 规则,确保每次该接口返回的都是我们需要的 “app1”
         prepare: func() []*mock.MockServer {
            // 配置下游 trpc 接口服务名称
            ms := mock.New("hello.world", env)
            // 配置下游 trpc 接口路径和预设返回值
            err := ms.URI("/trpc.hello.world.hello/sayHello").
                     Rule(mock.Any()).
                     Return(hellopb.helloRsp{
                        Pms: []*authoritypb.Permission{{Name: "testOne"}}, })
            assert.Nil(t, err)
            return []*mock.MockServer{ms} },
          want: []string{"success","app1"}, wantErr: nil, }, }
          for _, tt := range cases {
            t.Run(tt.name, func(t *testing.T) {
            if tt.prepare != nil {
               mocks := tt.prepare() defer func() {...}() }
          // 调用接口,代码跟上面一样,省略 })
    }
}

2) Mock 中间件

当测试环境的 MySQL 等中间件不稳定、或者数据经常被修改、或者想要的数据比较难触发时,如希望 SQL "select count(*) " 返回 100W 等比较大的数据,我们在接口测试中使用 TestOne 提供的中间件 Mock 能力。下面是以 LogReplay 项目的 1 个接口测试用例示例。

用例场景 :接口为从 mysql 中获取当前命令任务的数量。

func TestMethod_sayHelloSUT(t *testing.T) {
   // 此处调用接口准备阶段,代码跟上面一样,省略
   cases := []struct {...}{
      {
         name: "get count 9",
         // 配置 mock 规则,确保每次该sql返回的都是我们需要的 “9”
         prepare: func() []*mock.MockServer {
            // 配置依赖 mysql 信息
            msMysql := mock.NewMysql(env)
            // 配置 mysql 的 database 和预设返回值
            err := msMysqlCount.URI("hello").
    Rule(mock.Contains(mock.POSITION_BODY, "", `"count(*)"`)).
    Return(mock.NewMysqlSelect(map[string]interface{}{"count(*)": 9},map[string]mock.MysqlType{
     "updated_at": mock.MYSQL_TYPE_DATETIME}))
   assert.Nil(t, err)
            return []*mock.MockServer{ms}
         },
         want:    []string{"success","9"},
         wantErr: nil,
      },
   }
   // 调用接口,代码跟上面一样,省略
   

通过使用 TestOne 沙箱测试环境,测试稳定性有了大幅提升,能很好的应付 MR 高频率的运行场景。另一方面,因为沙箱测试环境运行的接口测试用例,可以非常方便的使用 mock ,因依赖服务没开发完成导致的用例写不了的问题也解决了。

使用自动生成提升效率:

当我们想要快速的将用户的流量数据转换成接口测试,使用 TestOne 流量生成用例功能。流量生成用例可以录制线上用户流量,快速生成我们需要的接口测试用例。这里我们以 某个服务 服务为例

先录制流量,然后筛选流量:

最后会根据上面的流量生成对应的接口测试用例

使用接口调试工具自动生成用例提升效率

当我们对新接口进行的接口调试时候,可以使用 TGuitar 的后台接口调试工具,对调试成功的数据可以自动生成接口用例,提升编写用例的效率、新增接口的用例覆盖以及对构造的接口数据的复用性。

  1. 打开 TestOne Guitar IDE 插件进行接口调试:

  1. 运行生成测试用例

  1. 目录已经生成好测试用例:

使用接口覆盖率制定策略:

在开始大范围使用时,我们使用 TestOne 提供的接口覆盖率指标来制定接口测试的编写目标及策略:

目标:接口覆盖率和接口测试稳定性达到了较高的水平

策略:按接口调用量排序,优先覆盖调用量大的接口;使用流量转用例工具提升效率;下游服务的依赖,需要尽量 mock,提升稳定性。

结果:接口测试用例覆盖了大部分的接口,其中使用 mock 的用例、使用沙箱测试环境运行的用例占比达到了一半以上 、不含 mock 规则的用例稳定性明显低于含 mock 规则的用例稳定性。

2.2.3. 端到端测试编写

端到端测试用例的写法,跟接口测试基本一致,不一样的地方:

  1. 可能需要申请测试数据,如申请 QQ 测试账号数据,可以使用 TestOne 接口测试 SDK 提供的测试数据申请能力:
// 申请测试帐号
reqAcquire := bit.AcquireParams{
  AppName:    "hello",
  DataType:   "1",
  PoolType:   "2",
  RequireNum: 1,
  Executor:   "3",
  Scripts:    []string{"hello.js"},
 }
td, acquire, err := bit.AcquireSync(&reqAcquire)
assert.NotEqual(t, 0, len(td.Data), "AcquireSync Data Length")
  1. 为了真实的模拟用户的端到端使用场景,一般不是直接发包到 TRPC 服务,而是发到接入层服务:
s := sender.New("http")
op := []sender.Option{
 sender.WithL5(),
 sender.WithAccount(account),
 sender.WithTimeout(time.Second * 4),
}
body := fmt.Sprintf(`{
    "ListReq": {
    "attchInfo": "",
    "person": "%s",
    "type": 1
  }
}`, account["person_01"])
rsp, err := s.Send(body, op...)
assert.Nil(t, err, "GetList err")
  1. 个用例中包含多个接口请求,且前一个请求的返回数据、会作为后面一个请求的参数:``
rsp1, err := s.Send(body, op...)
data := gjson.Get(rsp, "...").Array()
rsp2, err := s.Send(data, op...)

通过上面的方式写完一些用例后,我们把这些用例放到流水线中尝试运行,但很快,我们就遇到了一些问题:

  • 因为一个端到端用例覆盖了多个微服务,用例运行失败后,定位非常困难;

  • 端到端测试在预发布环境运行,我们的预发布环境并没有想像中的稳定,测试经常会以各种姿势失败;

  • 用例运行的次数越多,失败的越多,刚开始我们还积极的定位问题、更新用例,但随着越来越多的失败跟用例代码无关、也不是服务逻辑 bug,我们开始逐步对端到端测试失去了信心;

  • 团队开始出现扯皮,因一个用例涉及多个服务,用例失败后,每个服务的负责人都不认为是自己的问题,用例的编写者也不愿再负责。

为了解决这些问题,我们接入天机阁去提升系统的可观测性(参考 #1.2),从而更方便的定位问题;我们进行了环境治理和相应的稳定性提升(参考#3.1),减少因为环境及稳定性的原因导致的失败;我们也用了一些方法来提升端到端测试的可靠性(参考 #3.1)。

最后,我们逐步意识到,端到端测试用例不宜写太多,端到端测试用例只需要覆盖非常非常核心的场景就行,其它的场景逻辑,应该用更简单、更好维护的接口测试来替代。

我们端到端测试用例覆盖了大部分的核心场景,整体稳定性很高

2.3. 调试运行

2.3.1. go test 直接运行

不管是单测用例,还是接口测试、端到端测试用例,都可以直接用 go test 直接运行。

2.3.2. 使用 TestOne Guitar CLI 运行

接口测试场景,使用 TestOne Guitar CLI 运行,会自动创建稳定的沙箱测试环境、执行测试、销毁测试环境、生成测试报告。使用前需要先编写测试计划 TESTPLAN 文件:

suite: {
  name: "case_suite_1"
  case: "//api/test/"   // 设置用例路径
}
plan: {
  name: "api_test"  // 设置测试计划名称
  type: API_TEST
  sandbox: {
    deploy: {
      env_name: "test"  // 基线环境
    }
    aut: {
      app_name: "hello"
      release_unit: "world"
      cloudbuild:  {   // 构建方式
        profile:  "CLOUDBUILD"
        name:  "BUILD"
      }
    }
  }
  suite: "case_suite_1"
}

然后使用 CLI 执行上面的 TESTPLAN:

guitar test -p //TESTPLAN -n api_test

执行结果:

2.3.3. 使用 TestOne Guitar IDE 插件运行

也可以使用 TestOne Guitar IDE 插件来运行测试,在 IDE 内,写测试代码的过程中,就可以非常方便的点击运行,不需要输入任何命令:

运行结束后自动显示测试报告:

2.4. 失败定位

当某次测试任务结束、发现有用例失败时,首先可以通过日志里展示的错误信息来定位问题;如果发现错误是下游返回的,则需要通过链路追踪查找最后一个报错的服务。

如果我们发现,一段时间内某些用例或服务频繁出错,可以将错误码聚合进行问题定位。

项目经历重构后,用例执行从成功变成失败,可以使用请求/应答 diff 的方式来定位。

2.4.1. 日志定位

面对用例失败,首先考虑的定位手段是用例执行过程的日志,我们可以在 TestOne Web 测试报告上看到。日志里的错误一般有三种类型:

  1. 断言错误

建议用例断言 err 以及返回码,这样可以根据 err 或者返回码快速定位问题来源。

本例中,可以从错误信息中得知错误码为 10002,在 trpc 中这是业务错误码。我们可以在被测服务代码中查找其含义,以及返回该错误的逻辑分支,进而定位问题。

有时候我们会遇到框架错误,请参考下一节。

  1. 非超时的 panic panic 里有堆栈,能方便定位到用例行。

  2. 超时 超时类问题比较复杂,我们需要 review 用例里是否存在死循环,是否某个请求耗时过长需要优化;或者较为罕见(且不推荐)地,用例执行了特别多的步骤,逻辑特别复杂,需要增加耗时时间。

2.4.2. 常见框架错误定位

在日志中,最常见的是业务错误,偶尔也会出现框架错误。以请求 trpc 服务的错误为例,一般建议业务错误码>10000,1 ~ 200 以及 999 是框架错误码。几种比较常见的错误如下:

  1. 141, tcp client transport ReadFrame: trpc framer: read framer head magic not match

最有可能的是服务协议,跟请求它的客户端协议不一致:比如服务暴露了 http 端口,客户端却以 trpc 协议去访问。

2.111, service timeout

a. 检查服务本身的超时设置

769c0a965d1f29ced8065a44c01a5a84

b. 检查发出请求的 client 的超时

c. 确认是否上游已经将超时时间用完

串行调用时,同一个 ctx 传递的耗时是统一计算的,比如下面的例子里,如果 85 行的 Find 已经耗掉大量时间,91 行的 All 就很容易超时了。因此,如果函数里起 goroutine,一般会给它一个新的 ctx(比如 context.Background())。

3.999

一般是下游直接用 errors.New(msg)返回了不带状态码的错误,没有用框架自带的 errs.New(code, msg)。

4.其它

详细的错误码说明可以参考腾讯内部的错误码文档。

2.4.3. 链路追踪定位

被测服务接入天机阁后,在接口、集成、端到端测试用例运行中,TestOne 自动化测试工具会将天机阁 Trace ID 打印出来。当用例运行失败后,我们可以在测试报告中方便的找到 Trace ID 信息,点击可以跳转到天机阁页面,快速定位到用例失败的原因。

详细信息:

如上例,从天机阁的调用链中,开发者可以很清晰地找到最后一个返回错误的服务是 A 服务的 a 接口,协议为 oidb,错误码为 11000。一般情况下,如果这个服务并非本次测试的被测对象,而且比较稳定,那么就有较大概率是环境问题,部署的服务版本不对。如果错误是被测服务直接返回的,我们优先检查被测服务是否有问题,再检查测试用例参数构造是否有错误。

2.4.4. 错误码聚合定位

当一段时间内某些用例或服务频繁出错,我们以上游调用接口为维度来聚合下游错误,发现采样周期内下游频繁出现的问题,从而有针对性地进行处理修复:

如上例,5 天内删除文档的 a 接口下游的 b 接口出现 2017 次 15702 错误,最终发现是 teardown 时同一个文档被删除两次导致。

2.4.5. 数据 diff

当服务发生了重构,重构前用例能通过,重构后却失败的时候,使用 TestOne 数据 diff 能力进行定位。下图是针对某条用例 2 次执行的协议请求(request)/应答(response),逐字段对比结构体中每个字段的差异。

这个例子里,我们可以看到由于被测服务 msg 里多返回了一个逗号,导致用例断言失败。

2.5. 有效性提升

我们写了很多单测、接口测试、端到端测试用例,单测覆盖率、接口测试覆盖率都很高,但是依然还是有一些逻辑 bug 漏出,甚至有一些 bug 场景是有自动化测试覆盖的。这让我们开始审视我们自动化测试的有效性。

经过 review,我们发现了一些问题:

  • 部分用例无断言

  • 有些用例虽然有断言,但断言无实际效果,比如接口测试用例,只断言了返回码,并没有断言实际的返回数据

  • 有些用例虽然写了,但一直没有在流程中运行

  • 有些用例在流程中运行,失败后并没有得到修复,而是直接被注释了

那么,如何在流程中发现这些问题,从而提升测试用例的有效性呢?

2.5.1. 加强 CR

在代码 CR 的过程中,我们通常喜欢只看业务逻辑代码,往往容易忽视对测试用例代码的 CR。测试代码跟业务逻辑代码一样,都非常重要,也都非常容易出问题,需要严格进行 CR。

我们要求所有的代码必需经过 iRead 认证的同学 review 后才能合入,同时,需要对测试代码进行仔细 review,保障测试代码有效性。以下是我们总结的一些测试代码 review 的规则:

  • 是否有断言,断言是否足够

  • 用例代码的删除或注释是否合理

  • 导出函数是否有写单测用例

  • 测试用例是否覆盖足够的分支情况

  • 用例之间是否有依赖关系

  • 用例是否有明显的影响性能的写法,如 sleep

  • ……

2.5.2. 问题复盘

对线上缺陷问题、oncall 单等进行复盘,分析问题应发现阶段及未发现原因,自动化测试用例为什么没有覆盖到,并进一步分析用例覆盖缺失的原因,补充及修改用例。

2.5.3. 借助工具进行有效性扫描

借助 TestOne 提供的用例有效性检测工具,来实现事前的有效性检测。

单测有效性检测通常有两种方案,一种是静态代码扫描,一种是动态代码注入。静态扫描速度快,通常可用于发现一些简单的有效性问题,比如无断言、编译错误、断言不完整等;动态代码注入则通过在测试运行过程中动态修改原代码,模拟各种错误场景来检测用例的错误覆盖情况。其执行耗时较长,但能发现更为精细的有效性问题,比如边界值覆盖缺失、条件分支覆盖缺失等。

TestOne 用例有效性检测工具,支持两种不同的检测方式:通过在 MR 流水线中配置静态扫描方式,可以快速地发现和拦截增量的无断言、编译错误等问题;同时在定时构建流水线中接入动态注入的方式;通过每周报来发现更多用例有效性的问题并持续优化。

工具扫描的结果中,包含了有效性评分、问题列表,以下是有效性报告示例:

2.5.4. 统计用例执行率等信息

给 TestOne 后台自动化工具提需求,支持用例执行统计功能,定制用例执行率、执行次数、失败分布等数据报表,定时 review 用例的执行情况,并进行优化调整(支持中)。

3. 持续集成,持续部署

3.1. 准备工作

3.1.1. 提升系统稳定性

微服务系统的稳定性至关重要,如果微服务不够稳定、错误比较多,那么就会阻碍后续的自动化测试的执行(测试用例会因为被测服务稳定性差而出现随机失败),进而影响持续部署流程。

首先,梳理项目的服务依赖关系,去除不必要的依赖,将网关、权限较验等一些公共能力切换到更稳定的统一的 PaaS 服务,提升稳定的同时也能减少自身维护成本:
然后,通过接入秒级监控,更好的实时监控容器的运行情况,只需要在代码中匿名导入指定的插件即可

插件会监控当前容器的秒级 cpu/内存/磁盘 IO/网络/QPS/耗时/失败率,如果相关指标连续多次超过预先设置的阀值会以 oncall 工单的方式告警,同时会提供响应的当前容器的一些详细信息方便定位问题。

通过持续优化秒级监控发现的问题,我们将系统的稳定性提升到 99.99% 以上并能够一直保持。

3.1.2. 提升测试稳定性

单元测试的稳定性提升方式,主要有:

  • 避免使用 sleep

  • 减少 mock 的使用

  • 不要在用例中修改或依赖系统环境,如时钟

  • 不使用随机数作为输入

  • 单测中不能访问数据库、网络,不要跨进程调用

  • ……

接口测试的稳定性提升方式,主要有:

  • 下游服务及外部 http 依赖,尽量使用 mock

  • 依赖的数据,应该在 setup 时初始化,不要依赖库中现有的数据

  • 对测试数据的修改,测试结束后需要还原

  • 运行时使用独立的隔离测试环境,避免冲突

  • ……

接口测试和端到端测试实践的过程中,我们经常会遇到不稳定的用例( Flaky Test ):相同的测试用例,有时测试通过,有时又测试不通过。这样的测试用例可以理解为是不稳定、可靠度低的测试用例。造成用例不稳定的原因有很多种,比如测试代码本身的问题、测试框架的问题、被测系统及其依赖的软件库的问题等。如果有很多这种测试用例,我们将会对测试失去信心。

在 LogReplay 项目的自动化实践中,我们使用 TestOne Flakiness 缓解方案提升端到端测试可靠性的运行方案:监控并记录测试用例的可靠性数值(flakiness 值),如果达到某个阈值,则认为这个用例不可靠,并自动移除该测试用例(不在关键路径中运行、或测试结果不作为关键路径是否成功的标志)。如下图所示:

使用这种方案后,在关键流程中运行的端到端测试用例,稳定性提升到了 99%以上,让大家对测试信心,有了比较大的提升。

3.1.3. 提升环境稳定性

对微服务环境进行标准化治理,设置 Sandbox,Test,Staging,Canary ,Production 环境,标准化 CD 过程在各个环境中的升级。各个环境的作用:

  • Sandbox 环境:沙箱测试环境,一般用于特性开发和接口测试,每次测试时都会新创建一套独立使用的环境,与其它测试环境环境完全隔离

  • Test 环境:基线测试环境,一般开发自己在上面开发和测试,按策略定期更新 Test 基线环境

  • Staging 环境:内外团体验环境,预发布环境。定期检查,通过策略进行定时或条件触发式组合去 pick 新的制品

  • Canary 环境(金丝雀环境):在全量更新到生产环境之前的一个小流量验证环境,来确保最终要部署到生产环境的制品没问题

  • Production 环境:线上环境,最终的生产环境。生产环境的更新会涉及到一个灰度过程

LogReplay 项目所有服务都严格按照以上环境进行设置,并严格遵守各环境的部署策略及以下准入准出条件:

1) Sandbox 环境
准入条件:

  • 编译构建成功

  • 单元测试 100% 通过

准出条件: 接口测试 100% 通过

2) Test 环境:

准入条件:

  • 代码已合入主干

  • 接口测试 100% 通过

准出条件:

  • 接口测试 100% 通过(回归)

3) Staging 环境:准入条件:

  • 集成/端到端测试 100% 通过

  • 服务已接入 oncall (oncall 要从预发布环境开始)

准出条件:

  • 集成/端到端测试 100% 通过(不同的服务进入后有一定概率会有冲突,需要再次回归,确保没有问题)

  • 待满一定的时间,或达到一定的流量(预发布环境,需要达到一定时长或流量,才能得到充分的验证,如 6 小时、超过 100 次访问)

  • 无 oncall 单

4) Canary 环境:

准入条件:

  • 性能测试通过(mini 压测,详细可参考压测工具介绍)

  • 服务已接入 oncall

准出条件:

  • 集成/端到端测试 100% 通过(需要再次回归,确保没有问题)

  • 待满一定的时间,或达到一定的流量(小流量验证环境,需要达到一定时长或流量,才能得到充分的验证,如 6 小时、超过 100 次访问)

  • 无 oncall 单

经过严格的准入准出条件把关,环境的稳定性会大幅提升,同时,所有的服务修改上线都严格按环境部署策略顺序(先到测试环境、再到预发布环境、金丝雀环境、正式环境),确保各环境始终和正式环境是同步更新的,避免环境的不一致导致的问题。

3.2. CI 流程配置

持续集成(Continuous Integration),将微服务的代码持续合并到主干,合并时通过构建和运行自动化测试来保证质量。

LogReplay 项目的实践中,每次代码合入前都会触发代码 Review、单元测试、代码扫描、安全扫描、测试用例有效性扫描、接口测试,验证合入前的分支代码质量是否达标,只有所有的扫描及测试都通过,才允许合入代码。代码合入后,会再次触发单元测试、代码扫描、安全扫描、变异测试、接口测试,回归验证合入后的代码是否有质量问题。

整体流程设计:

流水线中的单元测试、接口测试运行,使用的是 TestOne Guitar 插件,因本地运行时也使用了 Guitar CLI,流水线配制时非常简单清爽,只需要在拉取代码后,加上 Guitar 插件指定运行的 testplan 文件就行,无需再去配置覆盖率、产物解析、环境部署、质量红线等众多的插件。

目前 CI 流程整体运行稳定

3.3. CD 流程配置

持续部署(Continuous Deployment),是 CI 的延续,持续、自动化的将微服务部署到测试和生产环境,不需要人工干预。

LogReplay 项目的实践中,当主干有代码合入时,CD 流水线会自动触发拉取主干代码构建,先自动部署到 Test 环境、再到 Staging 环境、再到 Canary 环境,每个环境都设置了准入及准出条件,当所有条件都满足后,再按一定策略自动部署到生产环境,一旦出现问题(灰度策略中介绍),会自动进行回滚,全过程无需人工干预。

整体流程设计:

灰度策略配置:

  1. 根据服务节点的数量,自动选用不同的灰度策略

当节点数小于 10 个时,按节点灰度发布:

  • 第一批 1 ~ 2 个节点

  • 第二批 3 ~ 5 个节点(根据总节点数-2 判断当前流程是否需要执行)

  • 第三批 6 ~ 9 个节点(根据总节点数-5 判断当前流程是否需要执行)

当节点数大于 10 个时,按比例灰度发布:

  • 第一批次 取 10%(向上取整)的节点进行灰度发布

  • 第二批次 取 30%(向上取整)的节点进行灰度发布

  • 第三批次 取 60%(向上取整)的节点进行灰度发布

  • 第四批次 剩下的节点灰度

  1. 对灰度的节点,进行自动化的监控及测试

主要监控项:

  • 流量监控:每次灰度发布之后会开始监控当前灰度节点是否有足够的流量,避免因为灰度期间流量较少不能暴露一些问题而失去灰度的意义

  • 异常监控:自动记录当前灰度节点在灰度期间 007 的异常数

  • 资源监控:会记录和对比灰度节点在灰度前后的 CPU/内存等资源的曲线是否偏离

同时,对灰度的节点进行特定的接口测试(需要在灰度环境运行、且不会影响线上数据的测试用例),验证服务在生产环境是否能正常工作(生产环境的配置及数据可能与测试环境不一样,所以需要再次确认)。

  1. 根据监控的情况,完成灰度流程

灰度流程结束状态:

  • 正常状态:灰度监控无任何异常或者异常数少于允许值会自动确认镜像完成服务全量发布

  • 异常状态:灰度监控异常数大于允许值会进入回滚流程。

  • 回滚状态:在灰度前会记录节点使用的原始镜像,同时在每一次灰度之后都会记录当前灰度的节点,回滚时会把之前已经灰度的节点发布成灰度前的镜像。

目前 CD 流程整体运行稳定,触发回滚的一些原因如下:

  • 服务发布关联依赖,比如 A 服务依赖 B 服务,A 服务先进行了部署

  • 配置文件依赖,trpc_go.yml 文件有变动需要新增正式环境的配置数据

4. 总结

在 LogReplay 项目当前的实践中,我们基本实现了微服务业务代码变更的持续部署能力:通过大量的自动化测试及完善的 CICD 流水线,代码 MR 到主干后,无需人工干预,全自动部署到生成环境中,部署过程中如果有问题将自动回滚。

实践过程中用到的后台测试工具和测试指标概览:

当然,我们的微服务中,不止有代码变更,还有配置、数据库等变更,接下来我们也需要继续探索并实现配置、数据库等变更的持续、自动化部署。

不同类型的业务场景和需求差别较大,自动化测试与持续部署的方式及思路也不尽相同,本文只是我们的“一家之言”,并不一定适用于其他的业务场景。但,我们追求的目标,应该是类似的,都是希望能“质量更高”、“速度更快”,而实现这两个目标,都离不开自动化测试、自动化部署。回到本文的标题,希望有越来越多的业务一起探索实践、及分享后台自动化测试与持续部署实践,也随时欢迎一起交流。

用到的测试工具:

文中描述的工具绝大部分皆为腾讯内部自研产品。

TestOne : 腾讯自主研发的一站式测试平台, 能给用户一站式浸入式的测试体验。

术语:

  1. CI: 持续集成(Continuous Integration)

  2. CD: 持续部署(Continuous Deployment)

  3. mockserver : 实现 mock 功能的服务,可以对服务进行 mock

  4. Sandbox/Test/Staging/Canary/Production 环境:沙箱测试环境,基线测试环境,预发布环境,金丝雀环境,正式环境,统称为线上环境

  5. Flaky Test : 片状测试,不稳定测试,是一种具有不确定性结果的测试:对于相同的代码,运行相同的测试,它有时会通过,而有时会失败。

文章转自微信公众号腾讯技术工程

1 个赞