想象一下一个有以下这些做法的组织
- 在 GitHub 上推送代码
- 运行 CI/CD 管道
- 运行生产工作负载
- 使用 Google 云服务
来了一个新的工程经理,想了解以下内容:
对于每个 PR,执行类似生产环境的 Kubernetes 集群的集成测试。
这听起来挺有道理。
在这一系列帖子中,我将展示你是如何做到这一点的,我的计划如下:
- 这篇博客文章重点介绍了应用、基本的 GitHub 工作流配置,以及本地测试和工作流运行期间的测试。
- 第二篇博客文章将详细介绍如何设置 Google Kubernetes Engine 实例并调整工作流以适应它。
- 最后一篇博客文章将描述如何在 独立的虚拟 Kubernetes 集群 中隔离每次运行。
我写了这本书《Integration Testing from the Trenches》(《从实战中学习集成测试》)。书中,我将“集成测试”的概念定义为:
集成测试(Integration Testing)指的是至少两个组件是否能协同工作。
我在OOP中翻译如下:
集成测试(Integration Testing)是一种测试至少两个类如何协作的策略。
几年之后,我在一篇博客文章中再次定义了这个概念:几年之后
我们来想想制造汽车的过程。单一类别的测试就像是单独测试每一个螺丝和螺母。假设测试这些部件没有发现任何问题,但若直接在没有建造原型车并进行试驾的情况下大规模生产汽车,仍然会非常危险。
自那以后,技术进步了很多。
测试容器(Testcontainers)我在这里用“技术”这个词非常泛指,但我想到的是Testcontainers这个工具,
使用真实依赖的单元测试
Testcontainers 是一个开源库,用于提供一次性、轻量级的数据库、消息代理、网络浏览器以及其他几乎所有可以在 Docker 容器中运行的实例。
事实上,Testcontainers 用容器化的“真实”依赖项替换了模拟对象。这真的是一大革新:不再需要花时间编写模拟代码来模拟依赖项,只需正常设置它们。
例如,没有 Testcontainers,你得为你的数据访问层提供模拟;有了它,你只需启动一个数据库容器就可以开始了。
当时,使用本地 Docker 服务的成本在测试环境中抵消了许多好处。这种情况已经不再适用,因为现在 Docker 服务几乎可以在任何地方使用。
我对“集成测试”的定义稍微改了一下:
集成测试通常需要大量的前期准备工作。
此定义故意模糊,因为“意义”对不同的组织、团队和个人有着不同的含义。请注意,谷歌将测试分为两类:一类是快速测试,另一类是慢速测试。这个定义也是模糊的,以适应不同的场景。
无论如何,黄金法则仍然适用:你越接近最终环境,你覆盖的风险范围就越广,你的测试也就越有价值。如果我们的目标生产环境是 Kubernetes,那么在 Kubernetes 上运行应用程序并进行黑盒式测试将获得最大的收益。但这并不意味着在距离较远的环境中进行白盒测试就没有价值;而是说,测试环境与目标环境之间差距越大,我们发现的问题就越少。
在这篇博客文章中,我们将使用 GitHub 作为测试环境的基础来执行单元测试,并使用一个完整的 Kubernetes 集群用于集成测试。关于什么是最佳实践,没有绝对的答案,因为最佳实践在不同的组织之间,甚至在同一组织内的不同团队之间,根据不同的情况而有所不同。每个工程师需要根据自己的具体情况来决定设置这样一个环境是否值得,因为环境越接近生产,设置就越复杂,成本也就越高。
用例:含数据库的应用程序让我们开始看看如何测试一个使用数据库存储数据的应用程序。我想要的很简单,只要标准的工程实践就行。我将使用一个基于JVM的CRUD应用,但大多数内容也适用于其他技术栈。接下来的博客文章将较少涉及特定语言内容。
这是些细节:
- Kotlin,因为我喜欢这种语言
- Spring Boot:它是基于JVM的应用程序中最常用的框架
- Maven:无可匹敌
- Project Reactor和协程,因为它让事情变得更有意思
- PostgreSQL:目前,它是一个非常流行的数据库,并且Spring对它的支持非常好
- Flyway (Flyway链接)
如果你不了解 Flyway,它允许你将数据库模式和数据结构跟踪在代码库中,并管理版本间的变更,这些变更叫作迁移。每个迁移都有一个唯一的版本号,例如 v1.0, v1.1, v2.1.2 等。Flyway 按顺序尝试应用迁移。如果某个迁移已应用,则跳过它。Flyway 使用专用表记录已应用过的迁移。
这种方法是不可或缺的;另一个选择是 Liquibase,它也遵循相同的规则。
Spring Boot 完全集成了 Flyway 和 Liquibase。当应用启动时,框架会自动启动它们。如果某个 Pod 被杀死并重新启动,Flyway 会先检查迁移表,只应用之前未执行的迁移。
不想详细解释这个应用的细节;你可以在Github网址找到代码。
单元测试按照我上面的定义,单元测试应当简单易设。使用Testcontainers,确实如此。
测试代码会先计算表中的项目数量,插入一个新项目,再计算项目数量。然后,它会检查是否插入成功。
- 比初始计数多了一个项,这个新增的项就是我们刚才加进去的那个。
@SpringBootTest //1
class VClusterPipelineTest @Autowired constructor(private val repository: ProductRepository) { //2
@Test
fun `当插入一个新的Product时,数据库中的Product数量应该增加一个,即增加了新插入的Product,并且最后插入的Product应该是最新插入的那个`() { //3
runBlocking { //4
val initialCount = repository.count() //5
// 测试的其他部分
}
}
}
全屏模式 退出全屏
- 初始化 Spring 上下文环境
- 插入仓库数据
- 赞扬 Kotlin 支持描述性函数命名
- 在阻塞函数中执行非阻塞代码
- 使用仓库数据
我们现在需要一个PostgreSQL数据库;Testcontainers可以帮我们提供一个这样的数据库。为了避开冲突,它会随机选择一个空闲端口。我们需要它来连接数据库,执行Flyway迁移任务,并运行测试代码。这样一来,所有步骤就能顺利进行。
所以,我们也得写点额外的代码吧:
@Profile("local") //1
class TestContainerConfig {
companion object { // 伴生对象
val name = "test" // 名称
val userName = "test" // 用户名
val pass = "test" // 密码
val postgres = PostgreSQLContainer<Nothing>("postgres:17.2").apply { //1
withDatabaseName(name) // 设置数据库名称
withUsername(userName) // 设置用户名
withPassword(pass) // 设置密码
start() // 启动
}
}
}
class TestContainerInitializer : ApplicationContextInitializer<ConfigurableApplicationContext> {
override fun initialize(applicationContext: ConfigurableApplicationContext) {
if (applicationContext.environment.activeProfiles.contains("local")) { // 环境中的活跃配置是否包含“local”
TestPropertyValues.of( //2
"spring.r2dbc.url=r2dbc:postgresql://${TestContainerConfig.postgres.host}:${TestContainerConfig.postgres.firstMappedPort}/$name",
"spring.r2dbc.username=$name",
"spring.r2dbc.password=$pass",
"spring.flyway.url=jdbc:postgresql://${TestContainerConfig.postgres.host}:${TestContainerConfig.postgres.firstMappedPort}/$name",
"spring.flyway.user=$name",
"spring.flyway.password=$pass"
).applyTo(applicationContext.environment) // 应用到环境中
}
}
}
进入全屏 退出全屏
- 启动容器,但前提是Spring Boot的
local
配置文件必须被激活 - 修改配置值
如果我们修改了 application.yaml
文件来重用同名的 R2BC 参数,那么我们也不需要指定 spring.flyway.user
和 spring.flyway.password
。
spring:
application:
name: vcluster-pipeline
r2dbc:
username: test
password: test
url: r2dbc:postgresql://localhost:8082/flyway-test-db
flyway:
user: ${SPRING_R2DBC_USERNAME} #1
password: ${SPRING_R2DBC_PASSWORD} #1
url: jdbc:postgresql://localhost:8082/flyway-test-db
全屏 退出全屏
一个聪明的方法来进一步减少代码中的配置冗余
我们还标注了先前的测试类,使其使用初始化器。
@SpringBootTest
@ContextConfiguration(initializers = [TestContainerInitializer::class])
class VClusterPipelineTest @Autowired constructor(private val repository: ProductRepository) {
//保持不变
}
全屏,退出全屏
Spring Boot 提供了几种激活 profile 的选项。在本地开发中,我们可以使用一个简单的 JVM 属性,比如说 mvn test -Dspring.profiles.active=local
;而在 CI 流水线里,我们会使用环境变量。
我还会使用Flyway来创建用于集成测试的数据库结构。在这个例子中,被测系统就是整个应用程序;因此,我将通过HTTP端点来测试它。这是API的端到端测试。代码将测试相同的行为,只是将被测系统作为黑盒处理。
class VClusterPipelineIT {
val logger = LoggerFactory.getLogger(this::class.java)
@Test
fun `当插入一个新的产品时,数据库中的产品数量应该增加一个,且最后插入的产品就是这个新插入的产品`() {
val baseUrl = System.getenv("APP_BASE_URL") ?: "http://localhost:8080" //1
logger.info("使用的基础URL是: $baseUrl")
val client = WebTestClient.bindToServer() //2
.baseUrl(baseUrl)
.build()
val initialResponse: EntityExchangeResult<List<Product?>?> = client.get() //3
.uri("/products")
.exchange()
.expectStatus().isOk
.expectBodyList(Product::class.java)
.returnResult()
val initialCount = initialResponse.responseBody?.size?.toLong() //4
val now = LocalDateTime.now()
val product = Product(
id = UUID.randomUUID(),
name = "我的超赞产品",
description = "这真是超赞的产品",
price = 100.0,
createdAt = now
)
client.post() //5
.uri("/products")
.bodyValue(product)
.exchange()
.expectStatus().isOk
.expectBody(Product::class.java)
client.get() //6
.uri("/products")
.exchange()
.expectStatus().isOk
.expectBodyList(Product::class.java)
.hasSize((initialCount!! + 1).toInt())
}
}
全屏模式 退出全屏
- 获取部署的应用程序 URL
- 创建使用该 URL 的 web 客户端
- 获取初始项目清单
- 获取项目数量;如果有大量项目,我们确实应该提供计数功能
- 插入一个新项目并确保其正常工作
- 获取项目列表并确保项目数量增加了
在我们继续之前,让我们在 GitHub 工作流中运行测试,
GitHub 工作流程假设你对GitHub工作流有一定了解。如果不熟悉的话,GitHub提供了几种触发方式:手动触发、定时触发或事件触发。GitHub工作流是一种自动化任务的声明性定义,一个工作流由多个步骤组成。
我们希望在每个拉取请求(pull request)上运行工作流(workflow),以确保测试能正常运行。
name: PR测试 #1
on: #2
pull_request:
_branches: [ "master" ] #2
Note: There seems to be a minor formatting issue with the underscore before "branches" which should be removed for accurate representation. The corrected version would look like this:
name: PR测试 #1
on: #2
pull_request:
branches: [ "master" ] #2
全屏 退出全屏
- 设置一个描述性的名字
- 当有向主分支的PR时触发
第一步算是相当标准的。
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: 拉取代码
uses: actions/checkout@v4
- name: 安装JRE环境
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 21
cache: maven #1
点击全屏,开启全屏,点击退出全屏
setup-java
操作包含一个用于构建工具的缓存选项。它会在不同运行间缓存依赖项,从而加快后续运行的速度。除非你有特别的理由不使用该选项,否则我建议你使用这个选项。
出于同样的原因,我们应该缓存我们的构建成果。在研究这篇文章的过程中,我发现GitHub会在不同的运行以及同一运行中的各个步骤中丢弃这些构建成果。所以,我们可以通过显式缓存来加快这些运行的速度:
- name: 缓存构建输出
使用缓存操作 actions/cache@v4 <1>
with:
path: target
key: ${{ runner.os }}-build-${{ github.sha }} (运行器操作系统和github提交哈希值) <2>
restore-keys:
${{ runner.os }}-build (恢复密钥:) <3>
进入全屏 退出全屏
- 使用与
actions/setup-java
在后台相同的动作 - 计算缓存键。在这种情况下,
runner.os
应该是固定的,但这应该是在不同操作系统上运行矩阵时的一般方法。 - 如果操作系统相同,则重用缓存
- 名称: 运行单元测试
运行: ./mvnw -B test
环境变量设置:
SPRING_PROFILES_ACTIVE: local <1>
全屏 退出全屏
- 激活本地配置。工作流环境自带Docker守护进程,因此Testcontainer能够顺利下载并运行数据库容器。
此时,我们应该运行集成测试。然而,我们需要部署应用才能运行此测试。为此,我们需要可用的资源和环境支持。
GitHub 上的另類的「单元测试」以上内容在GitHub上运作得很好,但我们可以通过使用GitHub的服务容器服务容器来更接近部署设置。让我们将PostgreSQL从Testcontainers迁移到GitHub上的服务容器。
移除 Testcontainers 相当简单:我们不启用 local
配置。
使用 GitHub 的服务容器功能需要在我们的工作流程中添加一个额外的部分。
jobs:
build:
runs-on: ubuntu-latest
env:
GH_PG_USER: testuser # 说明:这些变量用于PostgreSQL数据库的连接配置。
GH_PG_PASSWORD: testpassword # 说明:这些变量用于PostgreSQL数据库的连接配置。
GH_PG_DB: testdb # 说明:这些变量用于PostgreSQL数据库的连接配置。
services:
postgres:
image: postgres:15
options: >- # 配置健康检查命令和间隔时间等
--health-cmd "pg_isready -U $POSTGRES_USER"
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432/tcp # 映射PostgreSQL的默认端口
env:
POSTGRES_USER: ${{ env.GH_PG_USER }} # 设置环境变量以匹配数据库配置
POSTGRES_PASSWORD: ${{ env.GH_PG_PASSWORD }} # 设置环境变量以匹配数据库配置
POSTGRES_DB: ${{ env.GH_PG_DB }} # 设置环境变量以匹配数据库配置
进入全屏 退出全屏
- 在作业级别定义环境变量,以便在各个步骤中使用它们。虽然你可以使用秘密变量,但在这种情况下,数据库实例不会暴露在工作流外部,并且会在工作流完成后关闭。使用环境变量已经足够,无需增加不必要的秘密。
- 确保PostgreSQL运行正常后再继续。
- 分配一个随机端口,并将其映射到基础的
5432
端口。 - 使用这些环境变量。
按照上述配置运行测试非常简单。
- 名称 - 运行“unit”测试
执行: ./mvnw -B test
环境:
SPRING_FLYWAY_URL: jdbc:postgresql://localhost:${ job.services.postgres.ports['5432'] }/${{ env.GH_PG_DB }} #1
SPRING_R2DBC_URL: r2dbc:postgresql://localhost:${ job.services.postgres.ports['5432'] }/${{ env.GH_PG_DB }} #1
SPRING_R2DBC_USERNAME: ${{ env.GH_PG_USER }}
SPRING_R2DBC_PASSWORD: ${{ env.GH_PG_PASSWORD }}
切换到全屏 退出全屏
- GitHub 在本地 Docker 中运行 PostgreSQL,因此主机地址是
localhost
。我们可以通过${ job.services.postgres.ports['5432'] }
获取随机端口。
有关 job.services.<service_id>
的更多信息,请参考 GitHub 文档。
在这篇文章中,我们介绍了如何为一个简单应用进行单元测试和集成测试,并利用 Testcontainers 在本地环境中执行这些测试。接着,我们通过使用 GitHub 服务容器自动化了单元测试的 GitHub 工作流。在接下来的文章中,我们将准备在云供应商的基础设施上运行 Kubernetes 环境,构建镜像并部署到云供应商的基础设施中。
这篇帖子的完整源代码可在GitHub找到。
更进一步:
- 从实际战场理解集成测试
- 为什么单元测试和集成测试会互相反对?
- 编写GitHub工作流程
- GitHub服务容器(Service Containers)
- jobs.<job_id>.服务
- vCluster(虚拟集群)
……
原发表于A Java Geek,2025年2月9日
[OOP]:面向对象编程
[CRUD]:创建、读取、更新、删除
[JVM]:Java虚拟机
[SUT]:测试系统