使用微服务进行持续测试(带有示例)
微服务架构什么时候才是主流?
为系统选择和设计正确的架构至关重要。必须确保服务需求的质量和非功能性需求的处理,例如可维护性、可扩展性和可测试性。
在公司采用敏捷和DevOps之后,微服务架构是最新生态系统中经常出现的选择。虽然这不是事实上的选择,但在处理广泛增长的系统以及单体架构不再可行的维护时,它是首选选项之一。保持组件面向服务和松散耦合可以实现持续的开发和发布周期。这促使企业不断测试和升级其软件。
需要这种架构的主要先决条件是:
- 领域驱动设计
- 持续交付和 DevOps 文化
- 故障隔离
- 去中心化
它具有以下优点:
- 团队所有权
- 频繁发布
- 维护更方便
- 更轻松地升级到新版本
- 技术不可知论
它有以下缺点:
- 微服务到微服务的通信机制
- 增加服务数量会增加整个系统的复杂性
架构越分散、越复杂,在控制成本和风险的同时确保系统可扩展和维护的难度就越大。一项业务交易可能涉及协议和技术的多种组合。它不仅涉及用例,还涉及其操作。采用敏捷和 DevOps 方法时,应该在灵活性与功能之间找到平衡,以实现持续修订和测试。

与微服务相关的测试策略的重要性
在组织中采用 DevOps 的目的是消除各个孤立的部门并走向一个整体团队。此举旨在专门改善软件团队和运营团队之间的关系和流程。以更快的速度交付还意味着确保持续测试作为软件交付管道的一部分。每天部署(在某些情况下甚至每几个小时一次)是快速端到端业务解决方案交付的主要目标之一。这里必须牢记可靠性和安全性,这就是测试的用武之地。
包含测试驱动开发是获得代码可用于生产的真正信心的唯一方法。有效的测试用例可以为系统增加价值,因为它们验证并记录系统本身。除此之外,良好的代码覆盖率可以鼓励改进并在重构过程中提供帮助。
微服务架构分散了通信渠道,这使得测试变得更加复杂。这并不是一个无法克服的问题。拥有微服务的团队不应该害怕引入更改,因为它们可能会破坏现有的客户端应用程序。考虑到持续集成和持续部署是当前的最佳实践,手动测试效率非常低。 DevOps 工程师应确保在其开发工作流程中包含自动化测试:编写测试、添加/重构代码和运行测试。
常见的微服务测试方法
测试金字塔是一个简单的概念,它可以帮助我们确定编写测试时所需的工作量,以及如果粒度减小则测试数量应该减少的地方。当考虑微服务的持续测试时,它也适用。

为了使主题更加具体,我们将使用 Spring Boot 和 Java 来测试示例微服务。从构造上来说,微服务架构比单体架构更复杂。尽管如此,我们仍将重点放在测试类型上,而不是架构上。我们的代码片段基于一个最小项目,该项目由一个 API 驱动的微服务组成,拥有一个使用 MongoDB 的数据存储。
单元测试
单元测试应该是大多数测试,因为它们快速、可靠且易于维护。这些测试也称为白盒测试。实现它们的工程师熟悉逻辑,并正在编写测试来验证模块规范并检查代码质量。
这些测试的重点是系统中孤立的一小部分,即被测类 (CUT)。单一职责原则是关于如何管理与功能相关的代码的良好指南。
单元测试最常见的形式是“单独单元测试”。 ” 它不跨越任何界限,也不需要除 CUT 之外的任何合作者。
正如 Bill Caputo 所概述的,数据库、消息传递渠道或其他系统是边界; CUT 使用或要求的任何附加类都是协作者。单元测试永远不应该跨越边界。当使用协作者时,我们正在编写“社交单元测试”。对 CUT 使用的依赖项使用模拟是一种通过“单独单元测试”来测试社交代码的方法。 ”
在传统的软件开发模型中,开发人员测试尚未得到广泛采用,测试必须与开发完全不同步。实现高代码覆盖率评级被认为是测试套件信心的关键指标。
随着敏捷和短迭代周期的引入,很明显以前的测试模型不再有效。预计会不断发生频繁的变化。测试可观察的行为比覆盖所有代码路径更为重要。单元测试应该更多地关注断言而不是代码覆盖率,因为目的是验证逻辑是否按预期工作。
当测试没有正确的断言时,拥有大量测试和高百分比代码覆盖率的组件是没有用的。应用更加行为驱动的开发 (BDD) 方法可确保测试验证最终状态并且行为符合业务设置的要求。在明确的范围内进行集中测试的优点是更容易识别失败的原因。 BDD 测试让我们更加确信失败是功能行为变化的结果。否则更多地关注代码覆盖率的测试无法提供太多信心,因为由于代码路径实现细节而导致测试本身所做的更改产生影响,失败的风险会更高。
测试应遵循 Martin Fowler 提出的建议(参见《重构:改进现有代码的设计》,第二版。Kent Beck 和 Martin Fowler。Addison-Wesley。2018 年):

较少关注次要实现细节的另一个原因是重构。在重构过程中,单元测试应该给我们信心,而不是减慢工作速度。协作者实现的更改可能会导致测试失败,这可能会使测试更难以维护。强烈建议保留最少的社交单元测试。当此类测试可能会减慢开发生命周期并可能导致测试最终被忽略时,尤其如此。包含社交单元测试的一个很好的情况是负面测试,特别是在处理行为验证时。
集成测试
微服务最重大的挑战之一是测试它们与其余基础设施服务的交互,即特定 CUT 所依赖的边界,例如数据库或其他服务。测试金字塔清楚地表明,集成测试应该少于单元测试,但多于组件和端到端测试。与单元测试相比,这些其他类型的测试可能更慢,更难编写和维护,并且非常脆弱。由于网络和数据库访问,跨越边界可能会对性能和执行时间产生影响;尽管如此,它们仍然是不可或缺的,尤其是在 DevOps 文化中。
在持续部署范围中,更倾向于狭义集成测试而不是广泛集成测试。后者非常接近端到端测试,它需要运行实际的服务,而不是使用这些服务的测试替身来测试代码交互。要实现的主要目标是以快速、简单且有弹性的方式构建可管理的操作测试。集成测试重点关注 CUT 一次与一项服务的交互。我们的重点是窄集成测试。验证一对服务之间的交互可以确认是否符合预期,其中服务可以是基础设施服务或任何其他服务。
持久性测试
>一种有争议的测试类型是测试持久层,其主要目的是测试查询和对测试数据的影响。一种选择是使用内存数据库。有些人可能会考虑使用内存数据库作为社交单元测试,因为它是一个独立的测试,幂等且快速。该测试针对使用所需配置创建的数据库运行。测试运行和断言验证后,由于其短暂性,一旦 JVM 退出,数据存储就会自动清理。请记住,仍然存在与不同服务的连接,并且被视为狭义集成测试。在测试驱动开发(TDD)方法中,此类测试至关重要,因为测试套件应在几秒钟内运行。内存数据库是一种有效的权衡,可以确保测试尽可能快地进行,并且从长远来看不会被忽略。
@Before
public void setup() throws Exception {
try {
// this will download the version of mongo marked as production. One should
// always mention the version that is currently being used by the SUT
String ip = "localhost";
int port = 27017;
IMongodConfig mongodConfig = new MongodConfigBuilder().version(Version.Main. PRODUCTION)
.net(new Net(ip, port, Network.localhostIsIPv6())).build();
MongodStarter starter = MongodStarter.getDefaultInstance();
mongodExecutable = starter.prepare(mongodConfig);
mongodExecutable.start();
} catch (IOException e) {
e.printStackTrace();
}
}代码片段 1:内存 MongoDB 的安装和启动
上述不是完整的集成测试,因为内存数据库的行为与生产数据库服务器并不完全相同。因此,它不是“真正的”mongo 服务器的复制品,如果选择广泛的集成测试,就会出现这种情况。
持久性集成测试的另一个选择是连接到实际数据库服务器或使用容器来运行广泛的测试。与拥有固定服务器相比,容器可以缓解这种痛苦,因为可以根据请求提供数据库。请记住,此类测试非常耗时,对测试进行分类是一种可能的解决方案。由于这些测试依赖于 CUT 之外运行的另一个服务,因此它被视为系统测试。这些测试仍然是必不可少的,通过使用类别,人们可以更好地确定何时应该运行特定测试,以在成本和价值之间获得最佳平衡。例如,在开发周期中,可能仅使用内存数据库运行狭窄的集成测试。每晚构建还可以运行属于广泛集成测试等类别的测试。
@Category(FastIntegration.class)
@RunWith(SpringRunner.class)
@DataMongoTest
public class DailyTaskRepositoryInMemoryIntegrationTest {
. . .
}
@Category(SlowIntegration.class)
@RunWith(SpringRunner.class)
@DataMongoTest(excludeAutoConfiguration = EmbeddedMongoAutoConfiguration.class)
public class DailyTaskRepositoryIntegrationTest {
...
}代码片段 2:使用类别来区分集成测试的类型
消费者驱动的测试
进程间通信(IPC)机制是基于微服务架构的分布式系统的核心方面之一。这种设置在测试套件的创建过程中引起了各种复杂性。除此之外,在敏捷团队中,变化是不断进行的,包括 API 或事件的变化。无论系统使用哪种IPC机制,任何两个服务之间都存在契约。合约有多种类型,具体取决于人们选择在系统中使用哪种机制。当使用 API 时,契约是 HTTP 请求和响应,而在基于事件的系统中,契约是域事件本身。
测试微服务时的主要目标是确保这些契约在任何时间点都定义良好且稳定。在 TDD 自上而下的方法中,这些是首先要涵盖的测试。基本的集成测试可确保一旦客户端与与之交谈的生产者的真实状态不匹配时,消费者就能快速获得反馈。
这些测试应该是常规部署管道的一部分。他们的失败将使消费者意识到生产者方面已经发生了变化,并且需要进行变化才能再次实现一致性。无需编写复杂的端到端测试,“消费者驱动的合同测试”将针对此用例。
以下是 spring-cloud-contract 插件生成的合约验证器的示例。
@Test
public void validate_add_New_Task() throws Exception {
// given:
MockMvcRequestSpecification request = given()
.header("Content-Type", "application/json;charset=UTF-8")
.body("{"taskName":"newTask","taskDescription":"newDescription","isComplete":false,"isUrgent":true}");
// when:
ResponseOptions response = given().spec(request).post("/tasks");
// then:
assertThat(response.statusCode()).isEqualTo(200);
assertThat(response.header("Content-Type")).isEqualTo("application/json;charset=UTF-8");
// and:
DocumentContext parsedJson = JsonPath.parse(response.getBody().asString());
assertThatJson(parsedJson).field("['taskName']").isEqualTo("newTask");
assertThatJson(parsedJson).field("['isUrgent']").isEqualTo(true);
assertThatJson(parsedJson).field("['isComplete']").isEqualTo(false);
assertThatJson(parsedJson).field("['id']").isEqualTo("3");
assertThatJson(parsedJson).field("['taskDescription']").isEqualTo("newDescription");
}代码片段 3: 由 spring-cloud-contract 插件自动生成的合约验证器
在生产者中编写的BaseClass正在指示通过使用独立设置对各种类型的请求期望什么样的响应。打包的存根集合可供所有使用者使用,以便能够将它们拉入其实现中。当多个消费者使用同一份合约时,就会出现复杂性;因此,生产者需要对所需的服务合同有一个全局的了解。
@RunWith(SpringRunner.class)
@SpringBootTest
public class ContractBaseClass {
@Autowired
private DailyTaskController taskController;
@MockBean
private DailyTaskRepository dailyTaskRepository;
@Before
public void before() {
RestAssuredMockMvc.standaloneSetup(this.taskController);
Mockito.when(this.dailyTaskRepository.findById("1")).thenReturn(
Optional.of(new DailyTask("1", "Test", "Description", false, null)));
. . .
Mockito.when(this.dailyTaskRepository.save(
new DailyTask(null, "newTask", "newDescription", false, true))).thenReturn(
new DailyTask("3", "newTask", "newDescription", false, true));
}片段 4: 生产者的 BaseClass 定义每个请求的预期响应
在消费者方面,通过包含 spring-cloud-starter-contract-stub-runner 依赖项,我们将测试配置为使用存根二进制文件。该测试将使用生产者根据具有指定版本或始终是最新版本的配置生成的存根来运行。存根工件将客户端与生产者联系起来,以确保两者都在同一合同上工作。发生的任何变化都会反映在这些测试中,因此消费者将确定生产者是否发生了变化。
@SpringBootTest(classes = TodayAskApplication.class)
@RunWith(SpringRunner.class)
@AutoConfigureStubRunner(ids = "com.cwie.arch:today:+:stubs:8080", stubsMode = StubRunnerProperties.StubsMode.LOCAL)
public class TodayClientStubTest {
. . .
@Test
public void addTask_expectNewTaskResponse () {
Task newTask = todayClient.createTask(
new Task(null, "newTask", "newDescription", false, true));
BDDAssertions.then(newTask).isNotNull();
BDDAssertions.then(newTask.getId()).isEqualTo("3");
. . .
}
}代码段 5: 消费者注入生产者定义的存根版本
此类集成测试验证提供商的 API 是否仍然符合消费者的期望。当对 API 使用模拟单元测试时,我们会存根 API 并模拟行为。从消费者的角度来看,这些类型的测试将确保客户符合我们的期望。需要注意的是,如果生产者方更改 API,这些测试不会失败。必须定义测试涵盖的内容。
// the response we expect is represented in the task1.json file
private Resource taskOne = new ClassPathResource("task1.json");
@Autowired
private TodayClient todayClient;
@Test
public void createNewTask_expectTaskIsCreated() {
WireMock.stubFor(WireMock.post(WireMock.urlMatching("/tasks"))
.willReturn(WireMock.aResponse()
.withHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_UTF8_VALUE)
.withStatus(HttpStatus.OK.value())
.withBody(transformResourceJsonToString(taskOne))));
Task tasks = todayClient.createTask(new Task(null, "runUTest", "Run Test", false, true));
BDDAssertions.then(tasks.getId()).isEqualTo("1");片段 6:消费者测试对其自己定义的响应进行断言
组件测试
微服务架构可以快速增长,因此被测组件可能会与多个其他组件和多个基础设施服务集成。到目前为止,我们已经涵盖了白盒测试、单元测试和窄集成测试,以测试跨越边界与其他服务集成的 CUT。
最快的组件测试类型是进程内方法,除了使用测试替身和内存数据存储之外,测试仍保持在边界内。这种方法的主要缺点是可部署的生产服务没有经过充分的测试;相反,组件需要更改才能以不同的方式连接应用程序。首选方法是进程外组件测试。这些就像端到端测试,但随着所有外部协作者都更换为测试替身,通过这样做,它可以利用真实的网络调用来练习完全部署的工件。该测试将负责将任何外部服务正确配置为存根。
@Ignore
@RunWith(SpringRunner.class)
@SpringBootTest(classes = { TodayConfiguration.class, TodayIntegrationApplication.class,
CloudFoundryClientConfiguration.class })
public class BaseFunctionalitySteps {
@Autowired
private CloudFoundryOperations cf;
private static File manifest = new File(".manifest.yml");
@Autowired
private TodayClient client;
// Any stubs required
. . .
public void setup() {
cf.applications().pushManifest(PushApplicationManifestRequest.builder()
.manifest(ApplicationManifestUtils.read(manifest.toPath()).get(0)).build()).block();
}
. . .
// Any calls required by tests
public void requestForAllTasks() {
this.client.getTodoTasks();
}
}代码段 7:在 CloudFoundry 上部署清单以及测试所需的任何调用
Cloud Foundry 是用于基于容器的测试架构的选项之一。 “它是一个开源云应用程序平台,可以更快、更轻松地构建、测试、部署和扩展应用程序。 ” 下面是manifest.yml,这个文件定义了系统中所有应用程序的配置。该文件用于在已设置 MongoDB 服务的 Pivotal 组织空间上以生产就绪格式部署实际服务,与生产版本相匹配。
---
applications:
- name: today
instances: 1
path: ../today/target/today-0.0.1-SNAPSHOT.jar
memory: 1024M
routes:
- route: today.cfapps.io
services:
- mongo-it代码片段 8:根据 mongo 服务部署一个服务实例
当选择进程外方法时,请记住,实际边界正在测试中,因此,由于存在网络和数据库交互,测试最终会变慢。将这些测试套件编写在单独的模块中是理想的选择。能够在不同的 Maven 阶段而不是通常的“测试”阶段单独运行它们。
由于测试的重点是组件本身,因此测试涵盖了组件的主要职责,同时故意忽略系统的任何其他部分。
Cucumber 是一种支持行为驱动开发的软件工具,是定义此类行为测试的一个选项。凭借其简单的语言解析器 Gherkin,它确保客户可以轻松理解所描述的所有测试。以下 Cucumber 功能文件确保我们的组件实现符合该特定功能的业务需求。
Feature: Tasks
Scenario: Retrieving one task from list
Given the component is running
And the data consists of one or more tasks
When user requests for task x
Then the correct task x is returned
Scenario: Retrieving all lists
Given the data consists of one or more tasks
When user requests for all tasks
Then all tasks in database are returned
Scenario: Negative Test
Given the component is not running
When user requests for task x it fails with response 404片段 9:定义 BDD 测试的功能文件
端到端测试
与组件测试类似,这些端到端测试的目的不是执行代码覆盖率,而是确保系统满足所要求的业务场景。不同之处在于,在端到端测试中,所有组件都在测试期间启动并运行。
根据测试金字塔图,考虑到它们可能导致的缓慢,端到端测试的数量进一步减少。第一步是运行设置,在本例中,我们将利用 docker。
version: '3.7'
services:
today-app:
image: today-app:1
container_name: "today-app"
build:
context: ./
dockerfile: DockerFile
environment:
- SPRING_DATA_MONGODB_HOST=mongodb
volumes:
- /data/today-app
ports:
- "8082:8080"
links:
- mongodb
depends_on:
- mongodb
mongodb:
image: mongo:3.2
container_name: "mongodb"
restart: always
environment:
- AUTH=no
- MONGO_DATA_DIR=/data/db
- MONGO_LOG_DIR=/dev/log
volumes:
- ./data:/data
ports:
- 27017:27017
command: mongod --smallfiles --logpath=/dev/null # --quiet代码片段 10: 用于将定义的服务和指定版本的 mongo 部署为容器的 docker.yml 定义
根据组件测试,将端到端测试保留在单独的模块和不同的阶段是有意义的。 exec-maven-plugin 用于部署所有必需的组件,执行我们的测试,最后清理和拆除我们的测试环境。

由于这是一个广泛的堆栈测试,因此每个功能将执行较小的测试选择。根据感知的业务风险选择测试。先前类型的测试涵盖了低级细节。这意味着用户故事是否符合验收标准。这些测试还应该立即停止发布,因为此处的失败可能会导致严重的业务影响。
结论
以交接为中心的测试通常会是一个非常漫长的过程,需要长达数周的时间才能识别、修复所有错误并准备好新的部署。仅在发布后才会收到反馈,这使得我们的版本生命周期尽可能短。
持续测试方法可确保即时反馈。这意味着 DevOps 工程师可以立即了解所实现的功能是否已准备好投入生产,具体取决于测试运行的结果。从单元测试到端到端测试,它们都有助于加快评估过程。
微服务架构有助于更快地推出到生产环境,因为它是领域驱动的。它确保故障隔离并增加所有权。当多个团队从事同一个项目时,采用这种架构的另一个原因是:确保团队独立并且不会干扰彼此的工作。
通过持续测试来提高可测试性。每个微服务都有一个明确定义的域,其范围应仅限于一个参与者。应用的测试用例具体、更简洁,测试隔离,便于发布和更快部署。
按照 TDD 方法,除非返回失败的测试,否则无需编码。一旦迭代实施取得成功,这个过程就会增加信心。此过程意味着测试与实际实现并行进行,并且上述所有测试都在更改到达暂存环境之前执行。持续测试不断发展,直到进入下一个发布阶段,即暂存环境,其中重点转向更详尽的测试,例如负载测试。
敏捷、DevOps 和持续交付需要持续测试。主要好处是自动化测试产生的即时反馈。可能的影响可能会影响用户体验,但也会带来高风险的业务后果。有关持续测试的更多信息,请立即联系 phoenixNAP。