测试服务
领域测试检查决定和处理器(测试领域代码)。服务测试检查其余部分:宿主能用服务的模块和配置构建出服务、它的路由能回答、它的 Saga 和定时能运行,以及它能在生产要用的存储上工作。本页在三个层次上测试 eShop 的 ordering 服务。
| 层次 | 运行 | 证明 |
|---|---|---|
| 进程内 | 服务运行在测试的 JVM 里,使用内存后端,主题在内存中 | 装配、路由、Saga、等待、定时器 |
| 真实存储 | 同上,但使用容器里的 PostgreSQL 和 ClickHouse | schema、查询、Sink |
| 契约 | 构建合并出的 Manifest 对照提交的基线 | 没有改变会在同一主版本内破坏客户端 |
在测试中运行服务
服务测试用 SpringApplicationBuilder 启动应用,使用随机端口和回环地址,并经 HTTP 与它通信:
private val context = SpringApplicationBuilder(OrderingServer::class.java, InMemoryOrderingTopics::class.java).run(
"--spring.main.banner-mode=off",
"--server.port=0",
"--server.address=127.0.0.1",
"--wow.internal.port=0",
"--wow.internal.host=127.0.0.1",
"--wow.cluster.drain-timeout=1s",
"--wow.subscription.stop-timeout=2s",
"--wow.wait.max-timeout=15s",
"--wow.timers.poll-interval=200ms",
"--eshop.ordering.payment-grace-period=2s",
)--server.port=0和--wow.internal.port=0取空闲端口。内部端口只绑定在回环网卡上时,节点不需要集群令牌就能运行;没有存储 Bean 时使用内存后端。- 较短的超时(
wow.cluster.drain-timeout、wow.subscription.stop-timeout)让测试类之间的关闭很快,wow.timers.poll-interval=200ms让定时命令及时触发。 - 应用自己的设置缩短测试要等待的东西:这里的支付宽限期是两秒,而不是三十分钟。
ordering 的 Saga 会发送 inventory 的命令,所以节点需要命令传输。测试给它内存中的主题,而不是 Kafka:
@Configuration(proxyBeanMethods = false)
class InMemoryOrderingTopics {
@Bean
fun inMemoryTopics(): InMemoryTopics = InMemoryTopics(1)
@Bean
fun sourceTopics(topics: InMemoryTopics): SourceTopics = SourceTopics(topics, producer = topics)
}wow.topics.in-memory: true 从配置做到同样的事,用于在一台机器上试用服务。
然后测试像客户端那样驱动服务,并等待异步发生的事情。库存已确认却没有支付的订单,在它的定时命令到期时会取消自己:
@Test
fun `an order not paid within its grace period should cancel itself`() {
submit("order-unpaid")
eventually("order-unpaid awaits validation") { status("order-unpaid") == "AWAITING_VALIDATION" }
post("/eshop-ordering/order/order-unpaid/confirm-order-stock", """{"productId":"mug"}""")
post("/eshop-ordering/order/order-unpaid/confirm-order-stock", """{"productId":"cap"}""")
status("order-unpaid").assert().isEqualTo("STOCK_CONFIRMED")
eventually("order-unpaid cancelled") { status("order-unpaid") == "CANCELLED" }
state("order-unpaid")["cancelReason"].asString().assert().contains("payment grace period")
val late = post("/eshop-ordering/order/order-unpaid/pay-order", """{"paymentId":"p-2","amount":29.00}""")
late.first.assert().isEqualTo(CONFLICT)
late.second["code"].asString().assert().isEqualTo("eshop-ordering.order.wrong-status")
}在真实存储上运行
内存后端不提供快照查询,也不写数仓。测试这些功能,要让服务运行在存储本身上,由 Testcontainers 启动它们。eShop 把这样的测试放在单独的源集 integrationTest 中,这样单元测试保持快速,也不需要 Docker:
private val postgres = PostgreSQLContainer("postgres:17-alpine").also { it.start() }
private val clickhouse = ClickHouseContainer(DockerImageName.parse(CLICKHOUSE_IMAGE)).also { it.start() }
private val context = SpringApplicationBuilder(OrderingServer::class.java, InMemoryOrderingTopics::class.java)
.profiles("postgres", "clickhouse")
.run(
"--spring.main.banner-mode=off",
"--spring.datasource.url=${postgres.jdbcUrl}",
"--spring.datasource.username=${postgres.username}",
"--spring.datasource.password=${postgres.password}",
"--wow.clickhouse.endpoint=${clickhouse.httpUrl}",
"--wow.clickhouse.username=${clickhouse.username}",
"--wow.clickhouse.password=${clickhouse.password}",
"--wow.log.partitions=4",
"--server.port=0",
"--server.address=127.0.0.1",
"--wow.internal.port=0",
"--wow.internal.host=127.0.0.1",
"--wow.cluster.drain-timeout=1s",
"--wow.subscription.stop-timeout=2s",
"--wow.wait.max-timeout=15s",
)
private val port = context.environment.getRequiredProperty("local.server.port", Int::class.java)- 应用的
postgresprofile 定义了它的DataSource,所以宿主选择 PostgreSQL;clickhouseprofile 打开数仓。 - 存储在启动时创建它们的 schema(
wow.ddl: auto),所以一个全新的容器不需要别的东西。 wow.log.partitions=4让日志保持很小:测试不需要 64 个分区。
检查契约
Gradle 插件 me.ahoo.wow 从服务的模块合并出它的 Manifest,并在 checkManifestCompatibility(check 的一部分)中与提交的基线 manifest/baseline.json 比较。在基线的主版本内破坏客户端的改变会让构建失败,并列出每个破坏性改变及其路径。有意的改变之后,updateManifestBaseline 记录新的基线;把它与改变一起提交,这样评审时就能看到客户端会看到什么。什么算破坏性改变,见一份契约。
跨服务测试
相互调用的服务以同样的方式一起测试:在测试的 JVM 中启动每个服务,让一个指向另一个的内部端口,再驱动整个流程。eShop 的 :eshop-e2e 模块就是这样运行五个服务的,它的集成测试还在容器中基于 Kafka 和 PostgreSQL 运行它们。