测试支持 ddk-test
两样东西:对聚合和业务规则的断言,以及 DDK 集成的几种中间件的 Testcontainers 预置。以 test 范围引入。
xml
<dependency>
<groupId>com.domaindrivenkit</groupId>
<artifactId>ddk-test</artifactId>
<scope>test</scope>
</dependency>用 archetype 生成的项目已经带上了这个依赖。
领域断言
DdkAssertions 和 AssertJ 的 Assertions 并用。聚合的测试是纯单元测试,不需要启动 Spring。
java
Order order = Order.place(customerId, lines);
assertThat(order).hasRaisedExactly(OrderPlacedEvent.class);
order.clearEvents();
order.pay();
assertThat(order).hasRaised(OrderPaidEvent.class, event -> assertThat(event.orderId()).isEqualTo(order.id()));
order.clearEvents();
assertThatRejected(order::cancel)
.withCode(OrderError.ORDER_NOT_CANCELLABLE)
.withoutRaisingEventsOn(order);| 断言 | 检查什么 |
|---|---|
hasRaised(type) | 至少登记过一个该类型的事件 |
hasRaised(type, requirements) | 恰好登记过一个该类型的事件,并且它满足给定的断言 |
hasRaisedExactly(types...) | 登记的事件类型与顺序和给定的完全一致 |
hasNotRaised(type) / hasRaisedNoEvents() | 没有登记过该类型的事件,或者一个事件都没有 |
assertThatRejected(action) | 操作抛出了 BusinessException |
.withCode(errorCode) / .withArgs(args...) | 拒绝时带的错误码、填进消息里的参数 |
.withoutRaisingEventsOn(aggregate) | 被拒绝的操作没有在聚合上留下事件 |
三点约定:
- 断言不清空事件。 它读的是聚合上尚未发布的事件,所以一次操作之后可以连着写多个断言。想只看某一步产生的事件,先调用聚合的
clearEvents()。 - 按错误码断言,不按消息文案。 文案会改,错误码才是给调用方的契约,见异常体系与统一响应。
- 失败消息列出实际登记的事件,例如
Expected Order to have raised OrderPaid, but its events were [OrderPlaced]。
为什么值得单独做:聚合的测试里最常写的就是这两句话,「这次操作产生了什么事实」和「这次操作被哪条规则拒绝」。用原生 AssertJ 每次都要拼 singleElement().isInstanceOfSatisfying(...) 或 isInstanceOfSatisfying(BusinessException.class, e -> ...),测试读起来像在描述集合和异常,而不是在描述业务。
容器预置
java
@Testcontainers(disabledWithoutDocker = true)
class OrderEventsTest {
@Container
static final RocketMqContainer ROCKETMQ = DdkContainers.rocketmq();
@DynamicPropertySource
static void rocketmq(DynamicPropertyRegistry registry) {
registry.add("ddk.event.rocketmq.name-server", ROCKETMQ::getNameServer);
}
@BeforeAll
static void topic() {
ROCKETMQ.createTopic("order-events", 4);
}
}| 预置 | 镜像 | 说明 |
|---|---|---|
DdkContainers.redis() | redis:7-alpine | getHost()、getPort() |
DdkContainers.mysql() | mysql:8.4 | 字符集 utf8mb4;测试类路径上需要 org.testcontainers:testcontainers-mysql 和 MySQL 驱动 |
DdkContainers.rocketmq() | apache/rocketmq:5.5.0 | 一个容器里运行 NameServer 和一个 broker;getNameServer()、createTopic(topic, queues) |
DdkContainers.elasticsearch() | docker.elastic.co/elasticsearch/elasticsearch:9.4.5 | 单节点,关闭认证;getHttpHostAddress()。测试类路径上需要 org.testcontainers:testcontainers-elasticsearch。镜像约 1.1 GB,占用约 1 GB 内存。在 main 分支上,0.8.0 之后的下一个版本 |
Redis 和 MySQL 的预置只是固定了镜像版本和字符集。RocketMQ 才是自己写容易出错的那个,预置处理了三个坑:
text
new RocketMqContainer(image)
在宿主机上选一个空闲端口 P
broker.conf:brokerIP1 = Docker 宿主机地址,listenPort = P,autoCreateTopicEnable = false
声明并绑定 P:P
同一个容器里启动 mqnamesrv 和 mqbroker,等日志出现 "The broker ... boot success"
createTopic(topic, queues)
mqadmin updateTopic -b 127.0.0.1:P
轮询 mqadmin topicRoute,直到路由在 NameServer 上可见- broker 的地址要让宿主机连得上。 客户端先问 NameServer 要 broker 地址,再直连 broker。broker 登记的是它自己监听的端口,所以容器内外的端口必须一致,不能用 Testcontainers 默认的随机映射。
- Linux 上的 Docker 只映射容器声明过的端口。 只绑定不声明,在 Mac 上能跑,到了 CI 就连不上。
- 建 topic 有两处异步。 按集群名建 topic 时,如果 broker 还没登记到 NameServer,
mqadmin什么也不做却照样返回 0;topic 建好后路由登记到 NameServer 也是异步的,紧接着发送会得到No route info。预置直接对 broker 建 topic,并等路由可见后才返回。
DDK 自己的集成测试用的就是这些预置。
不适用的场景 / 已知问题
- 没有 Kafka、PostgreSQL 的预置:Testcontainers 自带的
KafkaContainer、PostgreSQLContainer已经够用。 - RocketMQ 预置是单 broker,不能用来测主从切换、多 broker 下的队列分布。
- 没有应用服务层的测试脚手架(例如内存版仓储)。应用服务的测试目前仍然用
@SpringBootTest加内存 H2。