跳转到主要内容
Chinese, Simplified

过去 10 年是分布式架构快速普及的时期。这种新架构背后的承诺是提高企业的敏捷性。频繁发布新功能使我们能够测试有关客户需求的假设。成功的关键因素之一是每次部署的信心。每次新软件版本即将投入生产时,运行一组测试所获得的信心。与测试范围由整个系统的复杂性定义的单体系统相比,在微服务中可以限制测试范围以节省时间,但仍不影响信心。这种新的架构方法需要审查我们所知道的测试结构。今天,我们将介绍对微服务系统的体面质量保证有用的自动化测试类型。

在本文中,您将学习

 

  • 在微服务系统中测试什么
  • 每个微服务部分需要哪些测试类型
  • 如何有效地实施它们
  • 如何平衡您的测试以实现成本和时间效率

本文中的所有代码示例都是用 Java 编写的,但所介绍的方法和模式也适用于其他语言。所有提到的工具都可以与不同的语言和框架一起使用,例如卡夫卡,WireMock。

微服务系统中的测试类型


当您查看典型应用程序的成分时,识别测试微服务所需的测试类型会更容易。通常它是一个被集成和协调代码包围的到达域。然后,系统的每个部分都由具有单一职责的较小单元组成,如果可能的话。

msa testing

让我们尝试根据主题的标准来识别测试类型,测试应该检查。

单元测试


最小的软件片段通过单元测试进行测试。此类测试的目标是检查每个软件单元在与其他单元隔离的情况下是否正确运行。大多数专业开发人员每天都会编写它。测试的单元隔离是通过模拟所有依赖项来构建的。

单元测试的特点是:

  • 速度——单个测试的平均执行时间低于 1 毫秒
  • 可重复性——单元测试不依赖于环境
  • 简单性——在狭窄的代码段上调用测试,因此很容易识别失败原因


集成测试


查看上图,您可以注意到微服务应用程序需要连接和协作的外部系统:

  • 数据库,例如PostgreSQL
  • 消息代理,例如阿帕奇卡夫卡
  • 其他可通过 REST 或 SOAP 访问的服务


通常,在代码级别,开发人员会提取负责与外部系统通信的专用代码片段,并为域层和集成系统之间的通信提供方便的接口。

  • 对于数据库集成,我们可以使用 ORM 和 Spring Data
  • 对于消息代理集成,我们可以使用 Spring Cloud Streams 来创建消息网关
  • 对于不提供 SDK 的 REST 服务,我们可以使用纯 Java 11 HTTP 客户端


集成测试的主题是那些提供与外部系统通信的类/功能。任何集成代码的单元测试几乎没有什么好处,因为它假定所有依赖项都被模拟。在集成测试中,即使使用集成系统的测试实例,也应该使用真实的网络协议访问所有依赖项。

与单元测试相比,集成测试需要更多的时间来执行并且不验证业务逻辑的实现。

相反,集成测试的目的是检查:

  • 是否建立了与数据库的连接并且请求的查询是否正确执行
  • 消息网关是否广播包含正确数据的消息
  • HTTP 客户端是否正确理解服务器的响应


组件测试


让所有部分独立正常工作并确保所有外部依赖项都可以访问并不能保证整个微服务的行为符合预期。为了验证它,测试包含在单个组件中的完整用例或流程。外部依赖可以用进程内模拟来代替,以加快执行速度。每个测试都以通过公开接口的业务功能请求开始,并使用公共接口或通过检查模拟模式使用情况来验证预期结果。

除了检查微服务本身是否正常工作之外,组件测试还验证:

  • 微服务配置
  • 微服务是否与其依赖项/协作者正确通信


端到端测试


层次结构类型中最高的测试是 E2E 测试。在这种测试中,整个微服务系统都在运行,通常与生产环境的设置完全相同。成功的端到端测试结果意味着用户的旅程已经完成。端到端测试是最通用的测试类型,但这种通用性的代价是复杂性和脆弱性。

组件测试的目的是检查:

  • 整个微服务系统是否正常工作
  • 系统配置是否有效
  • 微服务之间是否正确通信
  • 整个业务流程能否顺利完成

测试类型分布


一旦我们确定使用不同类型的测试来确保代码在不同级别上“工作”,就该开始考虑实际问题了。

经验表明,与单元测试和集成测试相比,编写和维护组件测试的难度要高得多。

经验还表明,与组件测试相比,编写和维护 E2E 测试的难度要高得多。

msa testing

知道了这一点,我们必须采取一些方法来跨测试类型分布我们的测试覆盖率。一般原则可以表述为“尝试用你能写的最简单的测试来测试实现的行为”。尽可能在单元测试级别测试您的功能。这些很便宜,因此您可以负担得起测试所有可能的情况。

如前所述,组件测试更难编写和维护,因此您希望将它们的数量保持在相当低的水平。与其测试每一个可能的案例,不如尝试限制自己从业务角度测试主要案例——例如一条“幸福的道路”和一条“悲伤的道路”。

另一方面,E2E 测试是完全不同的野兽。 E2E 测试失败的原因有很多,而且很难识别。请记住,组件和集成测试都不会捕获由以下原因引起的错误:

  • 发生 HTTP 超时
  • 系统配置错误
  • 使用不兼容版本的合作服务


这意味着为了可维护性,E2E 测试应仅限于关键的业务流程和用户旅程。

测试系统概述


在本文的下一段中,我们将介绍已识别测试类型的实现。 为此,让我们回顾一下测试系统的高级快照。

出于本文的目的,让我们考虑一个系统“Piko”,它使用户能够管理和发布他们的旅游景点。

Piko 系统由三个部分组成:

piko-admin –> piko-locations <– piko-maps

从创建位置到发布的用户旅程包括以下步骤:

# Description Component
1 The user creates the location piko-locations
2 The user marks the location as awaiting the publication piko-locations
3 The administrator accepts the location piko-admin
4 The published location is sent to the application that serves public traffic piko-maps

应用程序之间的大部分通信由 Kafka 消息代理处理。

对于用户身份验证,Piko 使用 AWS Cognito 服务颁发的 JWT 令牌。

您可以使用以下命令签出代码:

git 克隆 https://github.com/bluesoftcom/piko.git

实施单元测试


很可能,您已经在编写单元测试了。 该主题非常受欢迎,并且已经在大量其他材料中涵盖。 然而,让我们快速浏览一下可以使您的单元测试变得更好的一组实践。

让我们考虑以下测试用例:

@Test
void testChangeLocationStatusToAwaitingForPublication() {

// given:

   final LoggedUser loggedUser = someLoggedUser().build();
   final Location location = someLocation().build();
   when(locationsRepository.find(any())).thenReturn(location);

// when:

   final DetailedJsonLocation updatedLocation = 
locationsService.updateLocationStatus(
         location.getId(),
         LocationStatus.AWAITING_PUBLICATION,
         loggedUser
);

// then:

assertThat(updatedLocation.getStatus()).isEqualTo(LocationStatus.AWAITING_PUBLICATION);
   verify(locationsGateway).sendPublicationRequestIssued(location.getId());
}

这个单一的测试已经包含许多技巧/方法,确实使该测试清晰且可维护。

使用 given-when-then 结构保持您的测试井井有条


每个测试应包括三个部分:

  • given——这代表了测试前关于世界状态的所有假设
  • when - 这指定了测试的行为
  • then——这描述了期望的结果

这种结构可以更广泛地应用,而不仅仅是单元测试。它适用于我们编写的所有自动化测试。此外,它也是用户故事接受标准的一种高度合格的形式。尝试清楚地识别测试的每个部分,尤其是给出的时间和时间。请记住,当部分仅包含经过测试的调用时是理想的。

使用夹具(fixtures )使您的测试设置清晰易读


测试设置通常是最复杂和最长的部分。随着给定部分的增长和可读性的降低,您应该立即做出反应。缺乏快速轻松设置测试的方法会促使开发人员进行不受控制的代码重复。

提供干净和灵活的测试设置方法的最佳方法之一是“夹具”模式。它只需要提取一个类,该类将包含准备好在测试中使用的预配置构建器的工厂方法。提到的测试使用 someLocation() 方法来创建 Location 对象。

public class LocationFixtures {

   public static Location.LocationBuilder someLocation() {

      return Location.builder()

.status(LocationStatus.DRAFT)

.id(“e3c2e50c-7cb3-11ea-bc55-0242ac130003”)

.lat(51)

.lng(21)

.createdAt(Instant.parse(“2012-12-12T00:00:00Z”))

.owner(“trevornooah”)

.name(“Corn Flower Museum”);

}

}

然后,可以自定义返回的构建器,以帮助您构建所需的任何测试用例。

使用描述性断言库


那些使用 JUnit 4 的读者仍然记得内置的断言库有多么有限。 在 JUnit 5 中它明显好转,但 AssertJ 库在这方面遥遥领先。

考虑以下测试和故障报告示例:

JUnit

@Test
void testJunit() {
   final List<String> input = List.of("John", "Brad", "Trevor");
   final List<String> expected = List.of("John", "Trevor");
assertIterableEquals(expected, input);
}
produces
iterable contents differ at index [1], expected: <Trevor> but was: <Brad>

AssertJ

@Test
void testAssertj() {
   final List<String> input = List.of("John", "Brad", "Trevor");
   assertThat(input).containsExactly("John", "Trevor");
}

produces
Expecting:
<["John", "Brad", "Trevor"]>
to contain exactly (and in same order):
<["John", "Trevor"]>
but some elements were not expected:
<["Brad"]>

防止你的测试断言不同的东西


该原则跨越了单个单元测试的边界,但随着代码库的增长变得至关重要。 它是所需测试套件特性的实际实现——“对于一个错误,一个单元测试应该失败”。

考虑以下方法:

public DetailedJsonLocation updateLocation(String locationId, 
JsonLocation jsonLocation, LoggedUser user) {

log.info(“Updating location: {} by user: {} with data: {}”, 
locationId, user, jsonLocation);

final Location location = locationsRepository.find(locationId);

checkUserIsLocationOwner(location, user);


 final LocationStatus previousStatus = location.getStatus();

final Location updatedLocation = transactionOperations.execute(status ->

{

final Location foundLocation = locationsRepository.find(locationId);

      // … mapping code

      return foundLocation;

});

   if (previousStatus == AWAITING_PUBLICATION) {

locationsGateway.sendPublicationRequestWithdrawn(updatedLocation.getId());

}

   return toDetailedJsonLocation(updatedLocation);

}

此方法包含四个测试:

Test method Asserted scope
testUpdateLocationAwaitingForPublication
Behavior unique to updating location that awaits for publication
testUpdateDraftLocation
Behavior unique to updating location that is a draft
testUpdateLocation
Behavior common to all location updates
testUpdateLocationOwnerByOtherUser
Behavior unique to update of the location owner by somebody else

使用这种方法,如果位置更新过程中存在错误,我最终将只有一个失败的测试而不是四个。这简化了根本原因的分析。

实施集成测试


如上一节所述,集成测试旨在测试用作外部系统适配器的类的行为。确保您对所有集成都有准确的测试覆盖率。在本节中,我们将回顾一种测试微服务中实现的最常见集成的方法。

测试数据库集成


数据库肯定是最容易测试的集成,尤其是当您使用 Spring-Data 时,它为您提供了开箱即用的生成存储库实现。由于最近 Docker 的流行度增长,开发人员可以使用真实数据库进行测试,而不是像 H2 这样的内存数据库。该方法在 piko 中实现,您可以在 docker-compose.yml 和 application.yml 中找到数据库配置。

当您的应用程序调用一些复杂的查询,或者特别是动态查询时,您应该为该存储库编写集成测试。例如 piko-locations 调用使用参数和排序的查询 LocationsRepository#findByOwner。

通过重用我之前为单元测试创​​建的固定装置,我可以很容易地测试该查询(以及排序)。

@Test
void testFindByOwner() {
// given:    final String owner = "owner:" + UUID.randomUUID().toString();
   final Location location1 = locationsRepository.save(
      someLocation()
      .id(UUID.randomUUID().toString())
      .createdAt(Instant.parse("2020-03-03T12:00:00Z"))
      .owner(owner)
      .build()
    );
    final Location location2 = locationsRepository.save(
        someLocation()
        .id(UUID.randomUUID().toString())
        .createdAt(Instant.parse("2020-03-03T13:00:00Z"))
        .owner(owner)
        .build()
    );
    final Location location3 = locationsRepository.save(
        someLocation()
        .id(UUID.randomUUID().toString())
        .owner("other-user")
        .build()
    );

// when:    final List<Location> locations = 
   locationsRepository.findByOwner(owner, Sort.by("createdAt"));


// then:

   assertThat(locations).containsExactly(location1, location2);
}

有用的注释:如果你的测试是@Transactional,你应该在测试调用之前和之后调用EntityManager.flush()。 Hibernate 不保证所有数据库指令都立即发送到数据库,而是在事务刷新期间发送。

这种行为有时会导致误报结果,其中测试通过只是因为 Hibernate 没有执行数据库语句,而是将它们缓存在内存中。

测试 HTTP 客户端和模拟服务器


使用 HTTP 协议通过网络进行通信是微服务集成的重要组成部分。毫不奇怪,为了进行彻底的测试,开发人员需要强大的工具来模拟外部服务。为此目的,最受欢迎的工具是 WireMock 库,它使存根服务器变得非常容易。

虽然 WireMock 用例的可能性令人印象深刻,但在“Piko”中,我们将自己限制为存根一个 AWS Cognito 端点以获取用户详细信息。

考虑到实现,准备一个专门的类来管理 WireMock 服务器和存根是非常方便的。在 piko-admin 应用程序中,您可以找到为此目的编写的 CognitoApi 类。

@Component
@Slf4j
public class CognitoApi implements Closeable {
   private final WireMockServer server;
   private final CognitoProperties cognitoProperties;
    
@SneakyThrows

public void mockSuccessfulAdminGetUser(String username) {

final String json = “{…}”;

server.stubFor(

post(urlPathEqualTo(cognitoProperties.getEndpoint().getPath()))

.andMatching(request ->

request.header(“X-Amz-Target”).firstValue()

.equals(“AWSCognitoIdentityProviderService.AdminGetUser”)

? MatchResult.exactMatch()

: MatchResult.noMatch()

)

.willReturn(

aResponse()

.withStatus(200)

.withBody(json)

)

);

}

}

以这种方式准备的存根可以很容易地用于集成测试,只需一行代码:

@Test
void testSendLocationPublishedNotification() throws Exception {
// given:    final String username = "johndoe-" + UUID.randomUUID().toString();
   final String recipient = String.format("<%s@email.test>", username);
   cognitoApi.mockSuccessfulAdminGetUser(username);
// ... }

测试与 SMTP 服务器的集成


交易电子邮件发送是应用程序中常见的业务需求之一。 虽然开发人员使用 Papercut 等本地 SMTP 服务器已经有很长时间了,但在自动化测试中使用类似概念仍然不常见。

在 Docker Hub 上有许多本地 SMTP 服务器,它们公开 API 以供编程使用。 其中之一是 schickling/mailcatcher。 在测试中发送电子邮件后,您需要做的就是调用 MailCatcher API 并进行断言。

@Test
void testSendLocationPublishedNotification()

throws

Exception {
// given:    final String username = "johndoe-" + UUID.randomUUID().toString();
   final String recipient = String.format("<%s@email.test>", username);
   cognitoApi.mockSuccessfulAdminGetUser(username);

   final DetailedJsonLocation location =

;

   // when:

adminLocationsNotifications.sendLocationPublishedEmail(location);

   // then:

 final List<MessageSummary> messages = mailCatcherClient.listMessages();

 final MessageSummary foundMessage = findByRecipient(messages, recipient);

assertThat(foundMessage).isNotNull();

 

final MailcatcherMessage message =

mailCatcherClient.fetchMessage(foundMessage.getId());

 assertThat(message.getRecipients()).contains(recipient);

assertThat(message.getSender()).isEqualTo(String.format(“<%s>”,

notificationsProperties.getFromAddress()));

assertThat(message.getSubject()).isEqualTo(“Your location Corn Flower Museum was published”);

 assertThat(message.getSource())

.contains(username)

.contains(location.getLocation().getName());

}

使用 MailcatcherClient 获取捕获的消息后,我可以检查电子邮件是否发送到正确的地址或从正确的地址发送,以及检查电子邮件内容是否包含所需的信息。


测试消息代理集成


微服务集成的第二种常见方式是使用一些消息代理。 如果使用 Spring Cloud Streams 向 Apache Kafka 发送和接收 Piko 异步消息。 除了简化消息生产者和消费者实现之外,它还公开了一个方便的 API 来测试消息传递。

在单元测试部分,您可能已经注意到调用负责将消息发送到 Kafka 主题的 LocationsGateway 的断言。

让我们考虑一个验证 LocationsGateway 发送正确消息的测试:

@BeforeEach
void setUp() {
locationEventsMessageCollector.dumpMessages();
}

@Test
void testSendPublicationRequestWithdrawn() {
   // given:
 final Location location = locationsRepository.save(
someLocation()
.id(UUID.randomUUID().toString())
.build()
);

   // when:
locationsGateway.sendPublicationRequestWithdrawn(location.getId());

   // then:
final LocationMessage message =

locationEventsMessageCollector.captureLastLocationMessage();
assertThat(message).isEqualTo(
LocationMessage.builder()
.id(location.getId())
.status(location.getStatus())
.event(LocationEventType.PUBLICATION_REQUEST_WITHDRAWN)
.lat(location.getLat())
.lng(location.getLng())
.createdAt(location.getCreatedAt())
.owner(location.getOwner())
.name(location.getName())
.build()
);
}

该测试使用 LocationEventsMessageCollector,它是一个非常薄的类,可以更方便地处理消息。 它使用 Spring Cloud Streams

public LocationMessage captureLastLocationMessage() {
final Message<String> lastMessage = (Message<String>) messageCollector
.forChannel(locationEventsBindings.outboundChannel())
.poll();
Objects.requireNonNull(lastMessage, “Could not capture last message for: “ 
+ locationEventsBindings.OUT + ” binding”);
      return objectMapper.readValue(lastMessage.getPayload(),

LocationMessage.class);
}

实施组件测试


组件测试是您将在特定微服务代码中找到的最后一种测试类型。 它们的范围是最大的,它们确保整个业务流程在应用程序内部正确执行。

在编写组件测试时,应特别注意编写准确的断言。 虽然组件测试的范围很广,但您应该尽量保持您的断言肤浅,只断言整个操作成功并且调用了必要的函数。 无需检查每个功能和集成的行为,因为它已通过单元和集成测试进行验证。

使用 HTTP 调用的测试流程


我们的大部分业务流程都是通过调用 REST 端点触发的,因此 Spring Framework 有一个方便的工具来测试 HTTP 端点——MockMvc 也就不足为奇了。 让我们看看它是如何用于 Piko 系统中最重要的组件测试 - 验证位置发布过程的。

@Test
void testPublishLocation() throws Exception {

// given:

   final LoggedUser loggedUser = someLoggedUser("user-" + UUID.randomUUID().toString())
      .build();
   final Location location = locationsRepository.save(
       someLocation()
       .id(UUID.randomUUID().toString())
       .owner(loggedUser.getUsername())
       .build()
    );
    cognitoApi.mockSuccessfulAdminGetUser(loggedUser.getUsername());

// when & then:

   mvc.perform(put("/locations/{locationId}/status", location.getId())
         .contentType(MediaType.APPLICATION_JSON)
         .content("{ \"status\": \"PUBLISHED\" }")
         .with(authenticatedUser(loggedUser)))
     .andDo(print())
     .andExpect(status().isOk())
  .andExpect(jsonPath("$.status").value(LocationStatus.PUBLISHED.name()));

// and:    final Location foundLocation = 
locationsRepository.tryFind(location.getId());
    assertThat(foundLocation).isNull();
    
    final LocationMessage locationMessage = 
locationEventsMessageCollector.captureLastLocationMessage();
    assertThat(locationMessage.getId()).isEqualTo(location.getId());
assertThat(locationMessage.getEvent()).isEqualTo(LocationEventType.LOCATION_PUBLISHED);

    final String expectedRecipient = String.format("<%s>", 
loggedUser.getEmail());
    final List<MailCatcherClient.MessageSummary> capturedEmails = 
mailCatcherClient.listMessages();
    assertThat(capturedEmails).anySatisfy(capturedEmail ->
                                          
assertThat(capturedEmail.getRecipients()).contains(expectedRecipient)
                                         );
}

即使测试的“then”部分很长,你仍然可以注意到上面提到的“浅断言”原则。 通过观察业务流程结果来测试组件行为。 预期结果是:

  • REST API 响应操作成功
  • 位置已从 piko-admin 数据库中删除
  • 应用程序已广播 LOCATION_PUBLISHED 事件
  • 有一封电子邮件发送给用户

这种方法使该测试不受我们不需要在此处检查的可能的小而频繁的更改的影响。 所有这些都包含在不同的测试中。

Change example Verification point
Change in the endpoint response format Unit test and another component test
Change in the Kafka message format Integration tests
Change in the email logic Integration test

 

使用消息代理调用的测试流程


这可能不是特别直观,但是对传入消息做出反应的侦听器通常会调用业务流程,并且是组件测试的完美测试对象。 就像在 HTTP 端点测试中一样,我们想要测试整个接口层、业务逻辑和集成。

与 HTTP 端点的 MockMvc 类似,Spring Cloud Streams 公开了方便的 API 以将消息放入通道并路由到我们的应用程序。 让我们看一下在 piko-locations 中检查位置发布过程阶段的测试。

@Test
void testLocationPublishedEvent() throws Exception {
// given:    final Location location = locationsRepository.save(
      someLocation()
      .id(UUID.randomUUID().toString())
      .status(LocationStatus.AWAITING_PUBLICATION)
      .lastPublishedAt(null)
      .build()
    );
    final LocationMessage message = toLocationMessage(location, 
LocationEventType.LOCATION_PUBLISHED);


// when:

   locationEventsBindings.inboundChannel().send(

new

GenericMessage<>(
          objectMapper.writeValueAsString(message)
        )
    );

       // then:

final Location publishedLocation =

locationsRepository.find(location.getId());

assertThat(publishedLocation.getStatus()).isEqualTo(LocationStatus.PUBLISHED);

assertThat(publishedLocation.getLastPublishedAt()).isNotNull();

}

该测试用例更简单,但您也可以发现浅断言方法。使用 inboundChannel().send(...) 将消息发送到通道是完全同步的,因此我们的测试环境是安全且可重复的。

在 Maven 中组织测试执行


Apache Maven 使用两个生命周期阶段来执行测试:测试和验证。它们之间的区别在于默认为该阶段执行的插件。

在阶段测试中,执行插件 maven-surefire-plugin,它运行具有 Test 后缀的测试用例。

在验证阶段,执行插件 maven-failsafe-plugin,运行具有 IT 后缀的测试用例。

Maven FAQ 中介绍了它们之间的技术差异。

对于项目构建组织,基于“IoC 容器要求”标准区分测试而不是为每种测试类型设计不同的执行是实用的,因为它只会使您的构建更长。

最简单和最方便的设置是为您的单元测试添加后缀 Test 和 IT 的集成和组件测试。然后,默认配置将使 maven-surefire-plugin 立即进行单元测试,并使 maven-failsafe-plugin 进行集成和组件测试。

概括


在本文中,我们介绍了有助于确保开发的应用程序正常工作的测试类型。对于每种类型的测试,我们都回顾了有用的技术、模式、原则和工具来实现它们。

在高级别的组件测试中,您可以注意到我们所采取的措施相互配合得多么好,允许在测试中重用测试逻辑。

Used tool or pattern Unit tests Integration tests Component tests
Fixtures yes yes yes
Server stubs no yes yes
Message collectors no yes yes

所有类型的审查测试相互重叠一点,让我们在一个应用程序中完成代码验证过程。

原文:https://bluesoft.com/microservices-testing-strategy/

本文:https://jiagoushi.pro/node/1832

Article
知识星球
 
微信公众号
 
视频号