Skip to content

测试支持 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)被拒绝的操作没有在聚合上留下事件

三点约定:

  1. 断言不清空事件。 它读的是聚合上尚未发布的事件,所以一次操作之后可以连着写多个断言。想只看某一步产生的事件,先调用聚合的 clearEvents()。
  2. 按错误码断言,不按消息文案。 文案会改,错误码才是给调用方的契约,见异常体系与统一响应。
  3. 失败消息列出实际登记的事件,例如 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-alpinegetHost()、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 上可见
  1. broker 的地址要让宿主机连得上。 客户端先问 NameServer 要 broker 地址,再直连 broker。broker 登记的是它自己监听的端口,所以容器内外的端口必须一致,不能用 Testcontainers 默认的随机映射。
  2. Linux 上的 Docker 只映射容器声明过的端口。 只绑定不声明,在 Mac 上能跑,到了 CI 就连不上。
  3. 建 topic 有两处异步。 按集群名建 topic 时,如果 broker 还没登记到 NameServer,mqadmin 什么也不做却照样返回 0;topic 建好后路由登记到 NameServer 也是异步的,紧接着发送会得到 No route info。预置直接对 broker 建 topic,并等路由可见后才返回。

DDK 自己的集成测试用的就是这些预置。

不适用的场景 / 已知问题 ​

  1. 没有 Kafka、PostgreSQL 的预置:Testcontainers 自带的 KafkaContainer、PostgreSQLContainer 已经够用。
  2. RocketMQ 预置是单 broker,不能用来测主从切换、多 broker 下的队列分布。
  3. 没有应用服务层的测试脚手架(例如内存版仓储)。应用服务的测试目前仍然用 @SpringBootTest 加内存 H2。

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权