2.4 微服务的测试策略实战
前面我们介绍了微服务测试策略的内容以及影响因素,下面基于实例来看看微服务测试策略实战。我们并非只是设计一个形式化的测试策略,而是将读者带入一个新的微服务团队中,通过前面学习到的知识来设计我们的测试策略。
那么制定策略的基础是什么?笔者认为应该是我们掌握的信息——我们的系统到底是怎样的?由怎样的团队来开发?
由于微服务系统的功能、服务的数量、每个服务的架构都可能是变化的,因此一个实战的策略也一定是不断更新的,测试人员也会从设计整体性的策略,逐渐转向针对具体场景的微观测试策略。
2.4.1 迭代0
迭代0是敏捷开发中的重要阶段,我们在这个时间了解系统开发的背景、团队成员、开发测试的流程、技术栈、测试策略、测试环境信息、部署流水线、编码实践(规范)及开发中可能存在的问题、风险及应对方案。
假定读者作为一名微服务测试专家加入了一个新项目。该项目需要开发一个电商系统后台,需要包含订单管理、产品管理以及库存管理等模块,团队架构师计划采用微服务架构。作为测试专家的你,可能会采取以下策略。
首先,需要有一些套路来指导测试策略设计,通常这个套路是业界“最佳实践”。
其次,需要了解你的被测试系统,并收集影响测试策略制定的因素。
再次,如果你收集到的信息会让制定出来的测试策略偏离最佳实践,如存在开发人员不愿、不会写测试代码,以及测试环节与开发环节分开等人或流程的因素,那么需要考虑是否通过给团队赋能等影响团队的方式来让测试策略尽可能贴近最佳实践。你仍要理解会有不可控的因素导致该策略并非业界的最佳实践,该策略却很可能是符合团队现状的最佳实践。
最后,你需要与团队讨论你的测试策略,并达成一致。
对于测试策略的业界最佳实践,前面已经反复讨论过:一是,通过测试象限来启发团队思考,哪些测试是我们需要考虑的测试类型;二是,采用测试金字塔来指导我们对不同测试类型的投入程度。
迭代0提供了很好的时机让你能够拿到初步的信息,制定迭代0版的测试策略。
1. 确定测试类型有哪些
首先,从质量目标出发。对于电商项目,业务同事提醒我们,该行业属性特点要求我们既要快速交付,又要保证较高的质量。你立马意识到这必然需要依赖高度的自动化测试以及部署流水线,而从行业最佳实践来看,测试左移到开发过程中缩短反馈周期,以及右移到生产测试进一步控制发布风险是我们理想的策略。
其次,随着用户的增多,系统未来对于性能会有一定的要求,因此需要考虑性能测试。而电商系统中往往保存有用户敏感信息,安全测试也是我们需要加入到测试策略中的。通过与业务方及开发主管的讨论,得知我们是一个单纯的后台系统,没有UI,其大致架构如图2-20所示。
图2-20 电商微服务系统架构
再次,由于敏捷推崇个体和互动高于流程与工具,因此你没有纠结于如何编写出一个完善的测试策略文档,而是基于测试象限梳理了自己的想法,快速画了一个线框图,如图2-21所示。你召集团队进行了第一次讨论。
图2-21 电商微服务系统的测试象限v1.0
讨论中你会发现,团队中部分开发人员没有写过自动化测试,对于单元测试、组件测试、契约测试都没有经验,因此有些开发同事对你的测试策略提出质疑,并表示,如果要做自动化测试应该是由测试人员熟悉的端到端测试占主导。
幸运的是,虽然开发主管认同其他开发人员提出的端到端测试的必要性,但是他对单元测试、组件测试、契约测试的投入更加支持。他也提到了一个重要的点,端到端测试失败时,跨服务定位问题会花费较多的时间,在微服务场景下,最好有调用链追踪以及日志收集分析工具的支撑,来加快问题定位的速度。
对于线上的测试,大家在讨论后觉得当前阶段有些困难,当前团队中并没有人有能力构建影子测试以及金丝雀测试,因此只能暂时放弃了。
接着你与开发主管一起找到愿意参与测试技术构建的同事,开始对所有涉及的测试框架、监控工具以及示例测试代码等任务进行了分工,计划都在迭代0版本构建出来。与此同时,对于不会写测试代码的开发人员,大家制订了培训计划,在迭代0阶段进行初步的测试知识普及,并在后续的迭代N中持续培训。
最后,你提到除了测试策略外,我们还需要DevOps工程师过来做CI与环境部署自动化工作。在谈论的过程中,你幸运地发现,这名DevOps工程师曾经帮其团队进行过金丝雀发布的工作。
至此,新的测试象限被画出来了,如图2-22所示。
图2-22 电商微服务系统的测试象限v2.0
按照计划,你与团队将上面的工作都细化成技术性故事卡,开始向测试象限所需要的各种测试都迈出了第一步。
2. 构建自动化测试模板与基础设施
接下来为了构建自动化测试模板,需要决定采用哪些自动化测试框架与工具。作为测试专家,你首先想到实现微服务的技术栈是Spring Cloud,团队都是Java背景的开发。从这样的背景出发,开发同事可以用Java的JUnit框架,组件测试可以用RestAssured。API端到端测试用Postman更加合适,Postman可以做手工测试,对于简单场景添加一点断言即可方便地转成自动化测试。有了初步的方案,等开发主管构建微服务的时候,就可以正式与团队开发自动化测试模板了。下面选择订单服务作为示例来介绍实施过程。
(1)单元测试
在准备单元测试相关工具时,一位开发人员提到之前少考虑了Mock框架,并推荐PowerMock,说它十分强大,但是作为测试专家的你敏锐地发现了一个问题:PowerMock有十分强大的Mock静态方法、构造器的能力,主要用于去Mock测试那些原本没有可测试性的代码,对于经验丰富的开发,用它测试缺乏可测试性的遗留代码是很有效的,但是对于新项目,这种能力在某种程度上容易让人忽略代码应该具有可测试性这一重要的质量属性,特别是对缺乏经验的开发人员而言,这不利于帮助他们建立好的编码习惯。因此,你私下找到开发主管,给他阐明PowerMock这种黑科技可能带来的副作用,并提出了以当前团队的能力现状没有必要使用PowerMock,即使使用了它,也可能难以驾驭,因此最好直接采用Mockito。
单元测试工具已准备好,该写测试代码了,先测试哪个类呢?你与开发主管讨论后决定,应该先从价值最高、变化相对不频繁的类入手,于是选择了领域类Order.java,它通常位于model目录下,如图2-23所示。
图2-23 Order.java所在目录
该类的代码如代码清单2-1所示,里面有Public方法,也有Private方法,并非所有方法都要写单元测试,通常应该测试Public方法。
代码清单2-1 订单服务的领域类示例
public class Order { public static Order create(String id, List<OrderItem> items, Address address) { } private static BigDecimal calculateTotalPrice(List<OrderItem> items) { } private void raiseCreatedEvent(String id, List<OrderItem> items, Address address) { } public void changeProductCount(String productId, int count) { } private OrderItem retrieveItem(String productId) { } public void pay(BigDecimal paidPrice) { } public void changeAddressDetail(String detail) { } }
作为单元测试示例,我们对创建订单的方法create进行测试,测试代码如代码清单2-2所示。
代码清单2-2 领域类的单元测试示例
class OrderTest { private Address address; private OrderItem orderItem1; private OrderItem orderItem2; @Before public void setUp() { address = Address.of(“陕西", “西安", “钟楼"); orderItem1 = create(newUuid(), 1, valueOf(50)); orderItem2 = create(newUuid(), 1, valueOf(100)); } @Test public void shouldCreateOrder() { Order order = Order.create(newUuid(), newArrayList(orderItem1, orderItem2), address); assertEquals(CREATED, order.getStatus()); } ... }
假如开发人员问到确实有Private方法需要测试怎么办?那么你作为测试专家要及时提醒团队:应该考虑为什么要测试Private方法,为什么不能通过测试Public方法来实现对私有方法的覆盖。
(2)集成测试
集成测试主要分两部分:一部分是与数据库集成;另一部分是与第三方服务集成,都需要给出示例。下面你与团队分别来实现这两部分的测试示例代码。
与数据库集成的测试是对微服务中数据持久层的测试,本次项目计划采用JPA和PostgreSQL作为数据持久层。从执行速度的角度出发,有开发同事建议使用H2,H2更小、更快,但是H2与真实线上使用的PostgreSQL的SQL存在一定的差异,这样会让测试结果不那么准确,因此你建议团队最好能采用与生产环境一致的PostgreSQL的Docker镜像作为测试数据库。具体步骤如下:①准备数据库;②执行测试;③验证测试结果;④回滚数据库。
Docker的引入带来两方面的好处:一方面让数据库的构建非常简单;另一方面,无须回滚数据库,程序结束会自动销毁容器。下面是数据持久层集成测试的示例,为了自动管理Docker的数据库镜像,我们引入了Testcontainer库(具体请查询DataJpa与Testcontainer在JUnit 5下的使用),使用示例如代码清单2-3所示。
代码清单2-3 订单服务的数据库集成测试示例
@DataJpaTest @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) @TestPropertySource(properties = { "spring.datasource.url=jdbc:tc:postgresql:13.2-alpine:///order-test-db"}) class OrderRepositoryTest { ... public void shouldFindOrderByCustomerId() { Order order = orderRepository.findByCustomerId(“1234"); assertThat(order.getCustomer().getFirstName()).isEqualTo(“明"); assertThat(order.getCustomer().getLastName()).isEqualTo(“李"); } ... }
AutoConfigureTestDatabase.Replace.NONE指不用内置的内存数据库H2,而是用TestPropertySource中指定的数据库来测试。在jdbc:tc:postgresql:13.2-alpine:///order-test-db中,jdbc是连接数据库的方式,tc:postgresql:13.2-alpine是测试使用的数据库镜像,order-test-db是被测试数据库。
与第三方服务集成计划采用RESTful API,RESTful API集成测试的测试对象是当前服务中访问第三方服务的Client端。订单服务中需要调用第三方支付服务,因此有Payment-Client.java以及PaymentResponse.java。测试它需要Mock测试真实的支付服务,这个Mock测试需要能够提供HTTP返回的响应数据,WireMock就是具有这样能力的工具之一,接下来还需要两步准备工作。
第一,Mock Server的启动URL,计划采用locahost:8089。
第二,支付服务返回的响应文件为paymentResponse.json,放在test路径下的resource文件夹下。
测试的示例如代码清单2-4所示。
代码清单2-4 支付服务的第三方服务集成测试示例
@RunWith(SpringRunner.class) @SpringBootTest public class PaymentClientIntegrationTest { @Autowired private PaymentClient client; @Rule public WireMockRule wireMockRule = new WireMockRule(8089); @Test public void shouldCallPaymentClient() throws Exception { wireMockRule.stubFor(get(urlPathEqualTo("/some-test-api-key/card-id,user-name")) .willReturn(aResponse() .withBody(FileLoader.read("classpath:PaymentResponse.json")) .withHeader(CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) .withStatus(200))); Optional<PaymentResponse> paymentResponse = client.fetchPaymentResult ("tranction-id"); Optional<PaymentResponse> expectedResponse = Optional.of(new Payment-Response("支付成功")); assertThat(paymentResponse, is(expectedResponse)); } }"
除了外部集成外,Spring本身提供了一种功能,即使用MockMVC来做服务中controller、service、domain等类的集成,它无须启动Spring Boot就可以对数据持久层进行Mock测试,这样可以方便地覆盖更多数据访问时的异常场景。当然有人认为这是单元测试,有人认为这是集成测试,当前暂时放到集成测试中。
测试的示例如代码清单2-5所示。
代码清单2-5 controller类的测试示例
@RunWith(SpringRunner.class) @WebMvcTest(controllers = OrderController.class) public class OrderControllerAPITest { @Autowiredç private MockMvc mockMvc; @MockBean private OrderRepository OrderRepository; ... @Test public void shouldReturnSuccessIfOrderExist() throws Exception { Order order = Order.create("1234","order-item","西安") given(orderRepository.findById("Pan")).willReturn(Optional.of(order)); mockMvc.perform(get("/orders/id/1234")) .andExpect(status().is2xxSuccessful()); } ...
(3)组件测试
接下来需要搭建组件测试的模板,组件测试也分为两类:一类为进程内组件测试;另一类为进程外组件测试。进程内组件测试是指被测服务与测试代码在同一个进程内,对所依赖的数据库采用内存数据库H2,而第三方服务则在代码中采用网络插桩(stub)来模拟。而进程外是指被测服务与测试代码在不同的进程,与我们常见的对单个服务的测试一样,对所依赖的数据库采用真实的数据库,第三方服务采用Mock Server来模拟。
两种方式各有优劣,进程内组件测试可以做到更快、更稳定,但测试覆盖率略低,第二种方式覆盖率更高,但会更脆弱、更不好编写。组件测试时最好将应用容器化,通过docker-compose一键同时部署微服务、PostgreSQL以及WireMock。通常在项目初期组件测试数量较少,速度不是当前优先考虑的问题,建议团队暂时采用进程外组件测试,以获取更高的覆盖率。
在下面的示例中,为了简化测试执行,在根目录的script目录下创建component-test.sh脚本来准备环境并进行测试,实现一键执行,这样便于未来在CI上运行,脚本内容为:
docker-compose up ./gradlew componentTest
基于订单服务的测试示例如代码清单2-6所示。
代码清单2-6 订单服务组件测试示例
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @ActiveProfiles("test") class OrderServiceComponentTest { @Autowired private WebTestClient webClient; @Autowired private OrderRepository orderRepository; ... @Test void createOrder() { webClient.post().uri("/order") .contentType(MediaType.APPLICATION_JSON) .bodyValue("{\"amount\": \"RMB200.0\"}") .exchange() .expectStatus().isCreated(); } ...
(4)契约测试
大家对使用Pact JVM还是Spring Cloud Contract进行契约测试产生了分歧,你通过分析发现:项目只有3个服务,而且都是由本团队的开发人员维护,契约测试的投入产出比并不高,因此不会在迭代0版本给出示例,不过会在后面迭代中给大家详细讲解,让开发人员决定使用哪个。
(5)端到端测试
端到端测试在前期迭代中,利用Postman的自动化测试能力即可,非常简单。
3. 确定不同测试的投入程度
前面看起来还算顺利,接下来,我们对每种测试该如何投入呢?是否遵循测试金字塔的方式来做?此时你有些动摇,考虑当前的人员能力,尽管有了培训,也有了测试模板,但是离开发人员熟练掌握测试技术还是有相当大的距离。为了保障业务,你决定先带领其他的测试人员将API端到端自动化测试搞起来,优先保证测试覆盖。
虽然当前可以不考虑契约测试,但是从长期来看,当有新的服务加入,或者3个中的某个服务剥离出来由独立团队负责,则需要考虑它了。
关于单元测试,团队出现了分歧,开发主管认为domain类要测试,controller类没必要单独测试;一个开发人员认为单元测试写起来有些困难,不如使用上层接口测试直接覆盖;你认为,该不该写单元测试应该取决于类中的逻辑实现,你建议测试人员在进行Desk Check[1]时,通过覆盖率检测工具以及能够覆盖的业务场景去判断单元测试是否覆盖充分。
代码中很多逻辑在分层测试的底层是进行直接测试的,因此成本低很多,在上层则是间接测试,成本高,而且运行慢。所以从这个角度上看,团队还是认可了测试金字塔的价值,只不过当前的测试金字塔型是冰激凌型,后期可能会演变成金字塔型。
通过了迭代0版本,你能理解,此时制定的测试策略是一种长期的指导思想,在制定时要尽可能贴近业界最佳实践,但也要考虑现实中的策略需要符合实际情况才能保证高效率。
2.4.2 迭代N
迭代0阶段结束后,进入了正式的迭代开发中,测试策略设计的重心从涵盖整个微服务系统,转移到单个微服务或几个相关微服务。
随着项目的推进,团队决定遵循康威定律,并根据服务之间相关性进行拆分,成为3个不同的微服务全功能团队,团队组织与微服务之间的关系如图2-24所示。
图2-24 团队与微服务之间的对应关系
作为其中一个团队的测试人员,我们会更加关心自己团队所负责的服务,会对此单个服务进行测试策略设计,不过原则上会遵循整体的测试策略。由于之前没有编写契约测试,而当前多个团队之间既要独立又需要协作,为了更早地发现服务集成问题,契约测试就显得有必要了,因此你建议团队要尽快加上契约测试,具体的契约测试编写方法在后面的章节会详细说明。
1. 单个故事的测试策略
对故事的测试是测试人员日常最主要的工作。作为测试人员,我们根据故事上的AC(Acceptance Criteria,验收准则),结合我们对电商产品的业务了解来测试。下面我们来看一个具体故事的测试。
在负责订单服务的A团队接到这样一个故事:“作为一个订单服务的用户,我想下订单,并且可以成功购买。”
具体的AC如下。
AC1:假如我是一名合法用户,使用有效的信用卡,当我给一台iPhone 12下订单时,那么我应该看到订购成功。
AC2:假如我是一名合法用户,使用无效的信用卡,当我给一台iPhone 12下订单时,那么我应该看到订购失败。
当测试人员看到这个AC后,会本能地认为这个AC还有很多没有考虑到的场景,例如库存是否足够,信用卡的可消费额度是否足够,是否在有效的时间内付款等。对于非功能的场景,还会考虑是否存在抢购等性能场景,以及是否有恶意用户攻击等安全场景。
为了更好地说明如何使用不同的测试方法来测试,假定上面的考虑会有其他故事卡追踪,此次故事卡以AC1和AC2的字面内容为准。
在故事开卡阶段,传统的测试人员在设计测试策略时,会用端到端测试去覆盖各种场景。无效信用卡的场景包括信用卡的卡号长度非法、卡号字符非法、有效期过期、验证码不对、与持卡人不匹配等,需通过API将所有的值都输入一遍。
而微服务环境下的测试人员需要考虑开发的具体实现,甚至推动开发人员提升可测试性,如订单服务中是否存在CreditCard.validate()方法来验证信用卡的有效性。假如存在该方法,则可利用单元测试验证信用卡无效的相关场景,无须等到整个故事开发完成后由测试人员进行端到端测试覆盖类似的场景。
因此,对于信用卡是否有效的场景,较好的策略是采用多种测试方式分层进行。
从表2-2可以看出单元测试的成本最低,介入时间最早,测试投入的工作比重最大,而API端到端测试则介入晚、成本高、投入比例最小。
表2-2 不同测试类型的对比
实际上测试人员的角色就与前面提到的敏捷测试中的质量布道师一样,与开发人员紧密配合,通过故事开卡阶段与开发人员确认哪些测试应该由开发人员在单元测试和集成测试覆盖,哪些由测试人员用端到端验收测试覆盖。当开发人员开发结束后,通过与他们讨论单元测试与集成测试真实的覆盖情况,来确定整个故事的测试覆盖率是否充足。
如果团队的开发模式属于开发与测试对立的状态,那么策略就会不同,但是作为微服务下的测试,有必要让团队接受开发人员要重点参与底层测试的思想,其实这也是测试金字塔的另一个重要意义。
2. 单个服务的测试策略
对于这个项目中的几个服务,我们参考了迭代0阶段规划的测试金字塔的测试投入。而API网关要如何测试呢?
首先,API网关本身暂时只是一些请求的组装与转发,很少会有重要的业务逻辑。其次,API网关几乎与所有服务都有交互,使用Mock方式测试的成本极大。对API网关这个服务进行测试时,单元测试所能覆盖的逻辑非常有限,因为测试重点主要在与第三方服务的集成测试及端到端测试上。因此对这个服务的测试沿用测试金字塔比较困难。
但这是正常的,测试的核心对象是业务,如果核心业务就是在服务间的集成,那么自然重点就在集成测试,而不是在单元测试。实际上由于每个独立的微服务实现的不同,业务重点的不同,会出现这种测试投入重点不同的情况。但是对一个微服务系统而言,不同测试层次的测试投入的总体工作量,仍然类似金字塔状。假如整体不是金字塔状,那么很可能是代码可测试性不高,或者系统处于不稳定状态,如服务处于重构状态。
2.4.3 重构
由于业务的迅速发展,项目中订单服务不堪重负,导致性能下降,整个订单服务需要动“大手术”,因此团队决定重构。但是订单作为核心的服务,重构它非常危险。为了确保重构前后的业务正确性不发生变化,必须要完善自动化测试,同时需要增加性能测试。此时的订单服务内部会做修改,但对外的接口是稳定的。往下层走,测试会耦合具体实现,因此代码变更会导致团队编写的单元测试及持久层集成测试经常要被废弃。因此从经验上讲,先测试稳定的层级是最优策略之一。对外的API的接口测试是当前测试里最稳定的测试层级,而且重构订单服务的原因是性能问题,就可能存在对PostgreSQL的特殊优化,因此最好采用进程外的组件API,以及使用真实的PostgreSQL + WireMock进行测试。
这样一来,对订单服务的测试策略就会变成Spotify的蜂巢型。这也再次印证了,测试工作是与被测试系统实现强相关的,要减少测试对实现细节的耦合。测试策略不是一成不变的,而是在系统演化过程中,不断地在各种因素的综合影响下,在成本与效果之间求取平衡。
[1]Desk Check是指开发人员在完成故事卡后,向测试人员与业务分析人员快速演示故事中的验收条件是否被满足,自动化测试或其他重要的验证点是否满足要求。在流程上,不一定都是开发人员操作演示,测试人员与业务分析人员也可以主动操作。