
Ddd Testing Strategist
- 16 installs
- 1 repo stars
- Updated July 29, 2026
- full-statck-skills/ddd-skills
Defines DDD testing strategy across the test pyramid, from value-object unit tests to E2E, with per-architecture mock strategies and TDDD.
About
Provides DDD testing strategy covering the test pyramid, layer-specific patterns, mock strategies by architecture, and Test-Driven Domain Design. A developer uses it to design tests for aggregates, repositories, and application services in a DDD app.
- DDD test pyramid with coverage targets
- Mock strategies varied by architecture type
Ddd Testing Strategist by the numbers
- 16 all-time installs (skills.sh)
- Ranked #1,468 of 2,153 Testing & QA skills by installs in the Skillselion catalog
- Data as of Jul 30, 2026 (Skillselion catalog sync)
npx skills add https://github.com/full-statck-skills/ddd-skills --skill ddd-testing-strategistAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 16 |
|---|---|
| repo stars | ★ 1 |
| Last updated | July 29, 2026 |
| Repository | full-statck-skills/ddd-skills ↗ |
What it does
Defines DDD testing strategy across the test pyramid, from value-object unit tests to E2E, with per-architecture mock strategies and TDDD.
Files
DDD Testing Strategist
DDD testing strategy — from value object unit tests to end-to-end validation across all DDD architectures. Covers the DDD test pyramid, layer-specific test patterns, mock strategies by architecture, Test-Driven Domain Design (TDDD), CI/CD pipeline integration, and N+1 query detection.
Workflow
用户输入 → 场景分类 → 输出测试策略 + 代码模板
Step 1: 按用户问题场景分类(金字塔/AR/Repository/Mock/CQRS/TDDD/CI-CD/覆盖率)
Step 2: 从 references 获取对应测试模板
Step 3: 按架构类型调整 Mock 策略
Step 4: 输出代码模板 + CI/CD 配置 + 覆盖率目标什么时候用:用户询问 DDD 测试策略、聚合根测试、Repository 测试、Mock 策略、CQRS 测试、TDDD、CI/CD 测试集成时触发。
Boundary
✅ 擅长处理
1. DDD 测试金字塔(60% Domain + 20% Integration + 10% E2E + 10% Architecture) 2. 各层测试策略:Value Object / Aggregate Root / Domain Service / Repository / Application / Adapter 3. 按架构类型差异化测试(Layered / Hexagonal / Clean / COLA / CQRS / Event Sourcing) 4. 测试驱动领域设计(TDDD)方法论 5. Mock 策略选择(Mock Ports / Capture Events / Real DB) 6. CI/CD 三阶段流水线 + N+1 查询检测 + ArchUnit 架构测试 7. 测试覆盖率目标设定 + 分支覆盖率检查
⚠️ 需要条件
1. 已有 DDD 项目代码或正在设计领域模型 2. 已有测试框架(JUnit / Mockito / Spring Test / Testcontainers) 3. 理解 DDD 战术模式(Entity / VO / Aggregate / Repository / Domain Event)
❌ 超出范围(不适用场景)
1. 非 DDD 项目测试 — 标准测试框架(JUnit / pytest / Jest) 2. 纯前端测试 — Cypress / Playwright / Vitest 3. 性能 / 负载 / 安全测试 — JMeter / k6 / OWASP ZAP 4. 语言特定测试教程 — 概念和模式语言无关 5. 非 DDD 项目的 CI/CD 配置
Audience
This skill is designed for: Backend developers (implementing DDD architectures), Software architects (evaluating and selecting patterns), Tech leads (reviewing team implementations), and DDD beginners (learning domain-driven design fundamentals).
Rules
1. DDD test pyramid must prioritize Domain layer testing (60% of total tests). 2. Domain layer tests must never mock Domain Services or Aggregate Roots — only mock external ports. 3. Repository integration tests must use Testcontainers with real databases, never mocks. 4. Event Sourcing projects must include event replay and projection tests. 5. Architecture compliance tests (ArchUnit) must run in CI pipeline.
DDD 测试金字塔
DDD 金字塔与经典金字塔的关键区别:Domain 层(VO + AR + DS)占 60%,是测试核心。
╱ E2E Tests ╲ ← 10%
╱──────── API Integration ────────╲ ← 10%
╱────── Repository Integration ──────╲ ← 10%
╱─────── Application Service ─────────╲ ← 10%
╱──────── Domain Service Tests ──────────╲ ← 15%
╱────────── Aggregate Root Tests ───────────╲ ← 25%
╱──────────── Value Object Tests ──────────────╲ ← 15%各层测试策略
① Value Object 测试(纯函数)
@Test void money_should_prevent_negative_amount() {
assertThrows(IllegalArgumentException.class, () -> new Money(-1.0, "CNY")); }
@Test void money_add_should_sum_same_currency() {
assertEquals(new Money(30.0, "CNY"), new Money(10.0, "CNY").add(new Money(20.0, "CNY"))); }覆盖:构造验证、运算逻辑、等值比较、不变式。
② Aggregate Root 测试(核心业务逻辑)
class OrderPayTest {
@Test void pay_changes_status_to_paid_when_draft() { ... }
@Test void pay_emits_orderPaidEvent() { ... }
@Test void pay_fails_when_already_paid() { ... }
@Test void pay_fails_when_cancelled() { ... } }覆盖:每个状态转移独立测试类 → happy path + 边界条件 + 不变量 + 事件。
③ Domain Service 测试(Mock Repository,Capture Event)
@Test void pricing_service_applies_vip_discount() {
when(orderRepository.findById(order.getId())).thenReturn(Optional.of(order));
pricingService.calculatePrice(order.getId());
assertEquals(new Money(90.0, "CNY"), order.getTotalAmount()); }Mock 原则:只 Mock Repository / Gateway(Interface),不 Mock Domain Service 或 Aggregate Root。
④ Application Service + ⑤ Repository + ⑥ Adapter 测试
@Test void place_order_creates_and_saves() {
var orderId = handler.handle(new PlaceOrderCommand("cust-123", ...));
assertNotNull(orderId); }
@SpringBootTest @Testcontainers
class OrderRepositoryImplTest {
@Test void persists_and_retrieves_complete_aggregate() { ... } }
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
class OrderControllerTest {
@Test void post_orders_returns_201() { ... }
@Test void post_invalid_product_returns_400() { ... } }App Service:Mock 所有外部端口。Repository:Testcontainers + 真实 DB。Adapter:MockMvc + 真实 DB。
Mock 策略矩阵
| 测试目标 | Repository | Gateway | EventBus | External |
|---|---|---|---|---|
| Value Object | N/A | N/A | N/A | N/A |
| Aggregate Root | N/A | N/A | Capture | N/A |
| Domain Service | Mock | Mock | Capture | Mock |
| Application Service | Mock | Mock | Mock | Mock |
| Repository Integration | Real DB | N/A | N/A | N/A |
| API Integration | Real DB | Mock | Mock | Mock |
| E2E | Real DB | Real | Real | Real |
原则:Domain 层不需要 Mock;只 Mock 接口(Port)不 Mock 实现类;领域事件用 Capture 模式。
各架构测试差异
| 架构 | 测试重心 | 差异说明 |
|---|---|---|
| Layered | Aggregate Root + Repository | Domain 层全量测试(60%+) |
| Hexagonal | Port Mock + Adapter Integration | Mock 接口即 Mock 整个外部 |
| Clean | Entity + UseCase Interactor | UseCase 层 Mock 输出端口 |
| COLA | Domain + App Service | Domain 零依赖;额外测 CQRS 分流 |
| CQRS | Command + Query Read Model | 写模型用聚合测试;读模型直接查视图 |
| Event Sourcing | Event Replay + Projection | 聚合重放测试(必测);投影读模型测试 |
Test-Driven Domain Design(TDDD)
四步工作流:RED → GREEN → REFACT → REPEAT
@Test void tddd_order_should_not_allow_paying_twice() {
Order order = Order.create(customerId, items);
order.pay(mockGateway);
assertThrows(OrderException.class, () -> order.pay(mockGateway)); }
public void pay(PaymentGateway gateway) {
if (this.status == OrderStatus.PAID) throw new OrderException("Already paid");
this.status = OrderStatus.PAID;
addDomainEvent(new OrderPaidEvent(this.getId())); }测试覆盖率目标
| 层 | 目标 | 关键覆盖点 |
|---|---|---|
| Value Object | ≥ 95% | 构造验证、运算逻辑、等值比较 |
| Aggregate Root | ≥ 95% | 状态转移(每个路径)、领域事件 |
| Domain Service | ≥ 90% | 跨实体编排、外部数据计算 |
| Application | ≥ 80% | Use Case 完整路径 + 异常路径 |
| Repository | ≥ 80% | 聚合完整性、N+1 查询 |
| Adapter (API) | ≥ 70% | 协议转换、错误映射 |
建议优先追踪分支覆盖率,Domain 层 ≥ 90%。
Gotchas — 常见陷阱
1. 只测 App Service 不测 Domain — DDD 核心是 Domain 层。 2. Mock 了 Domain Service — Domain Service 不应被 Mock。 3. 聚合根只测 Happy Path — 必须覆盖不变式违反和边界条件。 4. 把 Repository 测试当作 E2E — Repository 只验证持久化。 5. 忘记 Event Replay 测试 — 最隐蔽的 bug 来源。 6. 测试与实现耦合 — 测试行为而非内部细节。 7. 领域事件不测试 — 每个业务方法应验证事件发布。 8. Mock DB 而不用 Testcontainers — 始终用真实数据库。 9. CQRS 不测 Query 端 — Command 和 Query 端策略完全不同。 10. N+1 查询不检测 — CI 中集成 SQL 计数检测。 11. E2E 测试太多 — 只保留 3-5 个关键旅程。 12. 忘记 ArchUnit — 依赖方向合规应自动化检查。
FAQ
| 问题 | 回答 |
|---|---|
| 聚合根测试需要 Spring 吗? | 不需要。纯 POJO,直接用 JUnit + AssertJ。 |
| Repository 应 Mock 还是真实 DB? | Domain Service 用 Mock;Repository 用真实 DB。 |
| CQRS 怎么测 Query 端? | 直接访问读模型,不需要 Mock。 |
| Event Sourcing 的必要测试? | Event Replay:给定事件序列 → 重放 → 验证状态。 |
| 怎么避免 N+1 查询? | 集成 SQLStatementCountValidator。 |
| TDDD 和传统 TDD 的区别? | TDDD 以"领域行为/状态转移"为单元。 |
Keywords
DDD testing, test pyramid, aggregate root test, value object test, domain service test, repository test, application service test, adapter test, CQRS testing, Event Sourcing test, TDDD, mock strategy, Testcontainers, ArchUnit, N+1 test, domain event test, branch coverage, CI/CD testing, integration test, E2E test, event replay test
References
| 文件 | 用途 |
|---|---|
| references/testing.md | 测试金字塔 + 各层 TypeScript 示例 |
| references/mock-integration-patterns.md | Mock 实现 + Java 集成测试 |
| references/clean-ddd-hexagonal-testing.md | Clean/Hexagonal/DDD 多层测试 |
| references/unit-testing-strategies.md | VO/AR/DS 单元测试 + 状态转移 |
| references/integration-test-strategies.md | Repository/API 集成测试 + N+1 |
| references/cqrs-event-sourcing-testing.md | CQRS + ES 重放/投影/快照测试 |
| references/test-coverage-targets.md | 覆盖率目标 + 分支覆盖率 + JaCoCo |
| references/ci-cd-test-stages.md | CI/CD 三阶段 + GitHub Actions |
| references/architecture-testing-comparison.md | 各架构测试差异 + 项目阶段策略 |
| references/test-driven-domain-design.md | TDDD 四步工作流 + 完整案例 |
Examples
| 文件 | 用途 |
|---|---|
| examples/domain-test-examples.md | VO + AR + DS 完整测试示例 |
| examples/app-adapter-test-examples.md | App Service + Adapter + E2E |
| examples/cqrs-es-test-examples.md | CQRS + ES 重放/投影/快照测试 |
| examples/builder-mock-patterns.md | Builder + Mock Repository/EventPublisher |
| examples/architecture-test-examples.md | ArchUnit 架构测试(分层/聚合隔离) |
Security & Safety
This skill is pure documentation. It contains no executable scripts, collects no user data, accesses no external services or networks.
DDD Skills Journey
📍 You are here: `ddd-testing-strategist` — Step 6
← Previous: domain-designer → Next: devops-integration 🔗 Related: code-reviewer | cqrs-architecture 🏠 Home: awesome
Application & Adapter Test Examples
Application Service Test (TypeScript — Mock Ports)
// tests/application/place_order/place_order_handler.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { PlaceOrderHandler, PlaceOrderCommand } from './place_order_handler';
import { createTestProduct } from '../../fixtures/product_fixtures';
describe('PlaceOrderHandler', () => {
let handler: PlaceOrderHandler;
let orderRepo: MockOrderRepository;
let productRepo: MockProductRepository;
let eventPublisher: MockEventPublisher;
beforeEach(() => {
orderRepo = new MockOrderRepository();
productRepo = new MockProductRepository();
eventPublisher = new MockEventPublisher();
handler = new PlaceOrderHandler(orderRepo, productRepo, eventPublisher);
});
it('creates order with items and saves', async () => {
productRepo.addProduct(createTestProduct('prod-1', 10.00));
productRepo.addProduct(createTestProduct('prod-2', 20.00));
const orderId = await handler.handle({
customerId: 'cust-123',
items: [
{ productId: 'prod-1', quantity: 2 },
{ productId: 'prod-2', quantity: 1 },
],
});
expect(orderId).toBeDefined();
const savedOrder = await orderRepo.findById(orderId);
expect(savedOrder).not.toBeNull();
expect(savedOrder!.items).toHaveLength(2);
expect(savedOrder!.total.amount).toBe(40);
});
it('publishes domain events', async () => {
productRepo.addProduct(createTestProduct('prod-1', 10.00));
await handler.handle({
customerId: 'cust-123',
items: [{ productId: 'prod-1', quantity: 1 }],
});
expect(eventPublisher.publishedEvents).toHaveLength(1);
expect(eventPublisher.publishedEvents[0]).toBeInstanceOf(OrderCreated);
});
it('throws when product not found', async () => {
await expect(
handler.handle({
customerId: 'cust-123',
items: [{ productId: 'nonexistent', quantity: 1 }],
})
).rejects.toThrow(ProductNotFoundError);
});
it('rolls back on save error', async () => {
productRepo.addProduct(createTestProduct('prod-1', 10.00));
orderRepo.simulateErrorOnSave();
await expect(
handler.handle({
customerId: 'cust-123',
items: [{ productId: 'prod-1', quantity: 1 }],
})
).rejects.toThrow();
expect(orderRepo.savedOrders).toHaveLength(0);
});
});Spring Boot API Test (Java)
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@AutoConfigureMockMvc
@Testcontainers
class OrderControllerE2ETest {
@Autowired private MockMvc mvc;
@Autowired private JdbcTemplate jdbc;
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15");
@DynamicPropertySource
static void configure(DynamicPropertyRegistry reg) {
reg.add("spring.datasource.url", postgres::getJdbcUrl);
reg.add("spring.datasource.username", postgres::getUsername);
reg.add("spring.datasource.password", postgres::getPassword);
}
@BeforeEach void setUp() {
jdbc.execute("TRUNCATE orders, order_items, products CASCADE");
jdbc.update("INSERT INTO products (id, name, price) VALUES ('p1', 'Product 1', 1000)");
jdbc.update("INSERT INTO products (id, name, price) VALUES ('p2', 'Product 2', 2000)");
}
@Test
void complete_order_workflow() throws Exception {
// Step 1: Create order
var createResult = mvc.perform(post("/orders")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"customerId":"cust-123","items":[
{"productId":"p1","quantity":2},
{"productId":"p2","quantity":1}
]}"""))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").isNotEmpty())
.andExpect(jsonPath("$.total").value(4000))
.andReturn();
String orderId = JsonPath.read(createResult.getResponse().getContentAsString(), "$.id");
// Step 2: Get order by ID
mvc.perform(get("/orders/{id}", orderId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.customerId").value("cust-123"))
.andExpect(jsonPath("$.items").isArray())
.andExpect(jsonPath("$.items.length()").value(2));
// Step 3: Pay order
mvc.perform(post("/orders/{id}/pay", orderId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("PAID"));
// Step 4: Verify status after payment
mvc.perform(get("/orders/{id}", orderId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("PAID"));
// Step 5: List orders by customer
mvc.perform(get("/orders").param("customerId", "cust-123"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.length()").value(1));
}
@Test void creates_order_and_returns_201() throws Exception {
mvc.perform(post("/orders")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"customerId":"cust-123","items":[{"productId":"p1","quantity":2}]}
"""))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").isNotEmpty())
.andExpect(jsonPath("$.customerId").value("cust-123"));
}
@Test void returns_400_for_invalid_product() throws Exception {
mvc.perform(post("/orders")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"customerId":"cust-123","items":[{"productId":"nonexistent","quantity":1}]}
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error").value("Product not found"));
}
@Test void returns_404_for_nonexistent_order() throws Exception {
mvc.perform(get("/orders/nonexistent"))
.andExpect(status().isNotFound());
}
}Adapter Mock Patterns (Hexagonal Architecture)
// Mock Repository for domain service testing
class MockOrderRepository implements OrderRepository {
private final Map<OrderId, Order> store = new HashMap<>();
private boolean shouldErrorOnSave = false;
@Override
public Optional<Order> findById(OrderId id) {
return Optional.ofNullable(store.get(id));
}
@Override
public void save(Order order) {
if (shouldErrorOnSave) throw new RuntimeException("Simulated save error");
store.put(order.getId(), order);
}
public void simulateErrorOnSave() { this.shouldErrorOnSave = true; }
public int savedCount() { return store.size(); }
}
// Mock EventPublisher for application service testing
class MockEventPublisher implements EventPublisher {
private final List<DomainEvent> published = new ArrayList<>();
@Override
public void publish(DomainEvent event) { published.add(event); }
@Override
public void publishAll(List<DomainEvent> events) { published.addAll(events); }
public List<DomainEvent> publishedEvents() { return Collections.unmodifiableList(published); }
public void clear() { published.clear(); }
}Architecture Rule Test (ArchUnit)
@RunWith(ArchUnitRunner.class)
public class ArchitectureConstraintTest {
@Test void domain_has_no_external_dependencies() {
noClasses()
.that().resideInAPackage("..domain..")
.should().dependOnClassesThat()
.resideInAnyPackage("org.springframework..", "javax.persistence..")
.check(new ClassFileImporter().importPackages("com.example"));
}
@Test void domain_does_not_depend_on_application() {
noClasses()
.that().resideInAPackage("..domain..")
.should().dependOnClassesThat()
.resideInAPackage("..application..")
.check(new ClassFileImporter().importPackages("com.example"));
}
@Test void application_does_not_depend_on_infrastructure() {
noClasses()
.that().resideInAPackage("..application..")
.should().dependOnClassesThat()
.resideInAPackage("..infrastructure..")
.check(new ClassFileImporter().importPackages("com.example"));
}
@Test void repositories_are_named_correctly() {
classes()
.that().resideInAPackage("..domain..repository..")
.should().haveSimpleNameEndingWith("Repository")
.check(new ClassFileImporter().importPackages("com.example"));
}
@Test void domain_events_use_past_tense() {
classes()
.that().resideInAPackage("..domain..event..")
.should().haveSimpleNameEndingWith("Event")
.andShould().haveSimpleNameMatching(".*(Created|Updated|Deleted|Paid|Cancelled|Shipped|Confirmed)")
.check(new ClassFileImporter().importPackages("com.example"));
}
}Architecture Test Examples — DDD 分层架构测试
使用 ArchUnit 验证 DDD 分层架构的依赖方向合规。
分层架构依赖检查
@AnalyzeClasses(packages = "com.example.ddd")
class ArchitectureTest {
@Test void domain_layer_should_not_depend_on_other_layers() {
JavaClasses classes = new ClassFileImporter()
.importPackages("com.example.ddd");
ArchRule rule = layeredArchitecture()
.layer("Domain").definedBy("..domain..")
.layer("Application").definedBy("..application..")
.layer("Adapter").definedBy("..adapter..")
.layer("Infrastructure").definedBy("..infrastructure..")
.whereLayer("Domain").mayOnlyBeAccessedByLayers("Application", "Infrastructure")
.whereLayer("Application").mayNotBeAccessedByLayers("Adapter")
.whereLayer("Adapter").mayNotBeAccessedByLayers("Infrastructure");
rule.check(classes);
}
}聚合根不应被外部直接依赖
@Test void aggregate_should_not_expose_internals() {
ArchRule rule = classes()
.that().areAnnotatedWith(AggregateRoot.class)
.should().onlyHaveAccessorsThatAreDeclaredIn("..domain..");
rule.check(classes);
}Repository 接口应在 Domain 层
@Test void repository_interfaces_belong_in_domain() {
ArchRule rule = classes()
.that().haveSimpleNameEndingWith("Repository")
.and().areInterfaces()
.should().resideInAPackage("..domain..");
rule.check(classes);
}领域事件命名规范
@Test void domain_events_should_have_past_tense_names() {
ArchRule rule = classes()
.that().areAssignableTo(DomainEvent.class)
.should().haveSimpleNameEndingWith("Event");
rule.check(classes);
}DDD 测试模式 — Builder + Mock 实战
Test Helpers (Builder 模式)
public class OrderTestBuilder {
private CustomerId customerId = CustomerId.from("test");
private List<OrderItem> items = new ArrayList<>();
public static OrderTestBuilder anOrder() { return new OrderTestBuilder(); }
public OrderTestBuilder withItem(ProductId p, int qty, Money price) {
items.add(new OrderItem(p, Quantity.of(qty), price)); return this;
}
public Order build() {
Order o = Order.create(customerId);
items.forEach(i -> o.addItem(i.getProductId(), i.getQuantity(), i.getUnitPrice()));
return o;
}
public static Order buildDraft() { return anOrder().build(); }
public static Order buildWithItems() {
return anOrder().withItem(ProductId.from("p1"), 2, Money.cny(10))
.withItem(ProductId.from("p2"), 1, Money.cny(25)).build();
}
public static Order buildPaid(/* mock Gateway */) {
Order o = buildWithItems(); o.pay(mockGateway); return o;
}
}聚合根测试
@Test void order_pay_should_change_status() {
Order order = OrderTestBuilder.buildWithItems();
order.pay(mockGateway);
assertEquals(OrderStatus.PAID, order.getStatus());
assertTrue(order.getDomainEvents().stream().anyMatch(e -> e instanceof OrderPaidEvent));
}
@Test void order_pay_should_fail_when_cancelled() {
Order order = OrderTestBuilder.buildWithItems();
order.cancel("reason");
assertThrows(OrderException.class, () -> order.pay(mockGateway));
}仓储集成测试
@SpringBootTest @Testcontainers
class OrderRepositoryImplTest {
@Autowired OrderRepository repo;
@Test void should_save_and_load_complete() {
Order o = OrderTestBuilder.buildWithItems();
repo.save(o);
var loaded = repo.findById(o.getId());
assertTrue(loaded.isPresent());
assertEquals(o.getTotalAmount(), loaded.get().getTotalAmount());
}
}Mock 策略矩阵
| 测试目标 | Repository | Gateway | EventBus |
|---|---|---|---|
| 聚合根 | N/A | N/A | Capture |
| 领域服务 | Mock | Mock | Capture |
| 应用服务 | Mock | Mock | Mock |
| 仓储集成 | Real DB | N/A | N/A |
CQRS & Event Sourcing Test Examples
CQRS Command Handler Test
class CreateOrderHandlerTest {
@Mock private OrderRepository orderRepository;
@Mock private EventPublisher eventPublisher;
@InjectMocks private CreateOrderHandler handler;
@Test void handle_creates_and_saves_order() {
var cmd = new CreateOrderCommand("cust-123", List.of(
new OrderItemDto("p1", 2),
new OrderItemDto("p2", 1)
));
var result = handler.handle(cmd);
assertNotNull(result.orderId());
verify(orderRepository).save(any(Order.class));
verify(eventPublisher).publish(any(OrderCreatedEvent.class));
}
@Test void handle_throws_when_duplicate() {
var cmd = new CreateOrderCommand("cust-123", List.of(
new OrderItemDto("p1", 1)
));
doThrow(new DuplicateOrderException("Duplicate request"))
.when(orderRepository).save(any());
assertThrows(DuplicateOrderException.class, () -> handler.handle(cmd));
verify(eventPublisher, never()).publish(any());
}
}
class PayOrderHandlerTest {
@Mock private OrderRepository orderRepository;
@Mock private PaymentGateway paymentGateway;
@Mock private EventPublisher eventPublisher;
@InjectMocks private PayOrderHandler handler;
@Test void handle_pays_and_saves() {
var order = createTestOrder();
when(orderRepository.findById(any())).thenReturn(Optional.of(order));
handler.handle(new PayOrderCommand("order-1"));
assertEquals(OrderStatus.PAID, order.getStatus());
verify(orderRepository).save(order);
verify(paymentGateway).charge(any());
verify(eventPublisher).publish(any(OrderPaidEvent.class));
}
@Test void handle_throws_for_already_paid() {
var order = createPaidOrder();
when(orderRepository.findById(order.getId())).thenReturn(Optional.of(order));
assertThrows(OrderException.class,
() -> handler.handle(new PayOrderCommand(order.getId().value())));
verify(orderRepository, never()).save(any());
verify(paymentGateway, never()).charge(any());
}
@Test void handle_throws_when_order_not_found() {
when(orderRepository.findById(any())).thenReturn(Optional.empty());
assertThrows(OrderNotFoundException.class,
() -> handler.handle(new PayOrderCommand("nonexistent")));
}
}CQRS Query Handler Test
@SpringBootTest
@Testcontainers
class OrderQueryHandlerTest {
@Autowired private OrderQueryHandler queryHandler;
@Autowired private JdbcTemplate jdbc;
@BeforeEach void setUp() {
jdbc.execute("TRUNCATE order_read_model CASCADE");
jdbc.update("""
INSERT INTO order_read_model (id, customer_id, status, total, item_count, created_at)
VALUES ('o1', 'c1', 'PAID', 4500, 2, NOW())
""");
jdbc.update("""
INSERT INTO order_read_model (id, customer_id, status, total, item_count, created_at)
VALUES ('o2', 'c1', 'DRAFT', 2000, 1, NOW())
""");
}
@Test void get_order_by_id() {
var order = queryHandler.getOrderById("o1");
assertTrue(order.isPresent());
assertEquals("o1", order.get().getId());
assertEquals("PAID", order.get().getStatus());
assertEquals(4500, order.get().getTotal());
assertEquals(2, order.get().getItemCount());
}
@Test void get_order_returns_empty_for_nonexistent() {
assertTrue(queryHandler.getOrderById("nonexistent").isEmpty());
}
@Test void list_orders_by_customer() {
var orders = queryHandler.listByCustomer("c1");
assertEquals(2, orders.size());
}
@Test void list_orders_by_status() {
var paid = queryHandler.listByStatus("PAID");
assertEquals(1, paid.size());
assertEquals("o1", paid.get(0).getId());
}
@Test void list_orders_by_customer_and_status() {
var orders = queryHandler.listByCustomerAndStatus("c1", "DRAFT");
assertEquals(1, orders.size());
assertEquals("o2", orders.get(0).getId());
}
}Event Sourcing — Aggregate Replay Test
class EventSourcedOrderTest {
@Test void replay_events_builds_correct_state() {
var events = List.of(
new OrderCreatedEvent("order-1", "cust-123"),
new OrderItemAddedEvent("order-1", "p1", 2, 1000),
new OrderItemAddedEvent("order-1", "p2", 1, 2500),
new OrderPaidEvent("order-1", 4500)
);
var order = EventSourcedOrder.replay(events);
assertEquals(OrderStatus.PAID, order.getStatus());
assertEquals("cust-123", order.getCustomerId());
assertEquals(2, order.getItems().size());
assertEquals(4500, order.getTotal());
}
@Test void replay_handles_empty_order_creation() {
var events = List.of(
new OrderCreatedEvent("order-1", "cust-123")
);
var order = EventSourcedOrder.replay(events);
assertEquals(OrderStatus.DRAFT, order.getStatus());
assertTrue(order.getItems().isEmpty());
assertEquals(0, order.getTotal());
}
@Test void replay_handles_cancellation() {
var events = List.of(
new OrderCreatedEvent("order-1", "cust-123"),
new OrderCancelledEvent("order-1", "Customer request")
);
var order = EventSourcedOrder.replay(events);
assertEquals(OrderStatus.CANCELLED, order.getStatus());
}
@Test void replay_with_snapshot_and_new_events() {
var snapshot = new OrderSnapshot("order-1", "cust-123", OrderStatus.PAID, 4500);
var newEvents = List.of(
new OrderRefundedEvent("order-1", 4500)
);
var order = EventSourcedOrder.replayFromSnapshot(snapshot, newEvents);
assertEquals(OrderStatus.REFUNDED, order.getStatus());
assertEquals(0, order.getTotal());
}
@Test void replay_throws_on_invalid_event_sequence() {
var events = List.of(
new OrderPaidEvent("order-1", 1000) // Pay before creation
);
assertThrows(InvalidEventSequenceException.class,
() -> EventSourcedOrder.replay(events));
}
}Event Sourcing — Projection Test
class OrderProjectionTest {
private InMemoryOrderViewStore viewStore;
private OrderProjection projection;
@BeforeEach void setUp() {
viewStore = new InMemoryOrderViewStore();
projection = new OrderProjection(viewStore);
}
@Test void on_order_created_creates_view() {
projection.on(new OrderCreatedEvent("o1", "c1"));
var view = viewStore.findById("o1");
assertTrue(view.isPresent());
assertEquals("o1", view.get().getId());
assertEquals("DRAFT", view.get().getStatus());
assertEquals("c1", view.get().getCustomerId());
assertEquals(0, view.get().getTotal());
}
@Test void on_order_paid_updates_view_status_and_total() {
viewStore.save(new OrderView("o1", "DRAFT", 0));
projection.on(new OrderPaidEvent("o1", 4500));
var view = viewStore.findById("o1");
assertEquals("PAID", view.get().getStatus());
assertEquals(4500, view.get().getTotal());
}
@Test void on_item_added_updates_view_total() {
viewStore.save(new OrderView("o1", "DRAFT", 0));
projection.on(new OrderItemAddedEvent("o1", "p1", 2, 1000));
var view = viewStore.findById("o1");
assertEquals(2000, view.get().getTotal()); // 2 * 1000
}
@Test void multiple_events_produce_consistent_state() {
projection.on(new OrderCreatedEvent("o1", "c1"));
projection.on(new OrderItemAddedEvent("o1", "p1", 2, 1000));
projection.on(new OrderItemAddedEvent("o1", "p2", 1, 2500));
projection.on(new OrderPaidEvent("o1", 4500));
var view = viewStore.findById("o1");
assertEquals("PAID", view.get().getStatus());
assertEquals(4500, view.get().getTotal());
assertEquals("c1", view.get().getCustomerId());
}
@Test void projection_is_idempotent() {
projection.on(new OrderCreatedEvent("o1", "c1"));
projection.on(new OrderCreatedEvent("o1", "c1")); // duplicate
var view = viewStore.findById("o1");
assertTrue(view.isPresent());
// Should not create duplicate or throw
}
}CQRS Read Model Seed Test
@Component
class OrderReadModelSeeder {
private final JdbcTemplate jdbc;
public OrderReadModelSeeder(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
public void seedTestData() {
jdbc.execute("TRUNCATE order_read_model CASCADE");
jdbc.update("""
INSERT INTO order_read_model (id, customer_id, status, total, item_count)
VALUES ('seed-order-1', 'seed-cust-1', 'PAID', 3000, 1)
""");
jdbc.update("""
INSERT INTO order_read_model (id, customer_id, status, total, item_count)
VALUES ('seed-order-2', 'seed-cust-1', 'DRAFT', 1500, 1)
""");
}
public void cleanTestData() {
jdbc.execute("TRUNCATE order_read_model CASCADE");
}
}Domain Layer Test Examples
Value Object: Money
import static org.assertj.core.api.Assertions.*;
class MoneyTest {
@Test void creates_money_with_valid_amount() {
Money money = new Money(10.50, "USD");
assertThat(money.getAmount()).isEqualTo(10.50);
assertThat(money.getCurrency()).isEqualTo("USD");
}
@Test void throws_for_negative_amount() {
assertThatThrownBy(() -> new Money(-1, "USD"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Amount must not be negative");
}
@Test void adds_same_currency() {
Money sum = new Money(10, "USD").add(new Money(20, "USD"));
assertThat(sum).isEqualTo(new Money(30, "USD"));
}
@Test void throws_for_different_currencies_on_add() {
assertThatThrownBy(() -> new Money(10, "USD").add(new Money(20, "CNY")))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Currency mismatch");
}
@Test void multiplies_by_factor() {
Money result = new Money(5.50, "USD").multiply(3);
assertThat(result).isEqualTo(new Money(16.50, "USD"));
}
@Test void equals_same_amount_and_currency() {
assertThat(new Money(10, "USD")).isEqualTo(new Money(10, "USD"));
}
@Test void not_equals_different_amount() {
assertThat(new Money(10, "USD")).isNotEqualTo(new Money(20, "USD"));
}
}Aggregate Root: Order
class OrderTest {
// --- Create Order ---
@Test void creates_order_with_draft_status() {
Order order = Order.create(CustomerId.from("cust-123"));
assertThat(order.getStatus()).isEqualTo(OrderStatus.DRAFT);
assertThat(order.getCustomerId()).isEqualTo(CustomerId.from("cust-123"));
assertThat(order.getItems()).isEmpty();
}
@Test void creation_emits_orderCreated_event() {
Order order = Order.create(CustomerId.from("cust-123"));
assertThat(order.getDomainEvents())
.hasSize(1)
.first()
.isInstanceOf(OrderCreatedEvent.class);
}
// --- Add Item ---
@Test void adds_item_to_order() {
Order order = Order.create(CustomerId.from("cust-123"));
order.addItem(ProductId.from("p1"), Quantity.of(2), new Money(10, "USD"));
assertThat(order.getItems()).hasSize(1);
assertThat(order.getItems().get(0).getProductId()).isEqualTo(ProductId.from("p1"));
assertThat(order.getItems().get(0).getQuantity()).isEqualTo(Quantity.of(2));
}
@Test void increases_quantity_for_existing_product() {
Order order = Order.create(CustomerId.from("cust-123"));
order.addItem(ProductId.from("p1"), Quantity.of(2), new Money(10, "USD"));
order.addItem(ProductId.from("p1"), Quantity.of(3), new Money(10, "USD"));
assertThat(order.getItems()).hasSize(1);
assertThat(order.getItems().get(0).getQuantity()).isEqualTo(Quantity.of(5));
}
@Test void throws_when_adding_item_to_cancelled_order() {
Order order = Order.create(CustomerId.from("cust-123"));
order.cancel("test");
assertThatThrownBy(() ->
order.addItem(ProductId.from("p1"), Quantity.of(1), new Money(10, "USD")))
.isInstanceOf(OrderException.class)
.hasMessageContaining("cancelled");
}
@Test void throws_when_adding_item_with_zero_quantity() {
Order order = Order.create(CustomerId.from("cust-123"));
assertThatThrownBy(() ->
order.addItem(ProductId.from("p1"), Quantity.of(0), new Money(10, "USD")))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Quantity must be positive");
}
// --- Pay Order ---
@Test void pays_order_successfully() {
Order order = createOrderWithItems();
order.pay(mock(PaymentGateway.class));
assertThat(order.getStatus()).isEqualTo(OrderStatus.PAID);
assertThat(order.getPaidAt()).isNotNull();
}
@Test void pay_emits_orderPaid_event() {
Order order = createOrderWithItems();
order.pay(mock(PaymentGateway.class));
assertThat(order.getDomainEvents())
.filteredOn(e -> e instanceof OrderPaidEvent)
.hasSize(1);
}
@Test void throws_when_paying_already_paid_order() {
Order order = createPaidOrder();
assertThatThrownBy(() -> order.pay(mock(PaymentGateway.class)))
.isInstanceOf(OrderException.class)
.hasMessageContaining("already paid");
}
// --- Cancel Order ---
@Test void cancels_order() {
Order order = createOrderWithItems();
order.cancel("Customer changed mind");
assertThat(order.getStatus()).isEqualTo(OrderStatus.CANCELLED);
}
@Test void cancel_emits_orderCancelled_event() {
Order order = createOrderWithItems();
order.cancel("Customer changed mind");
assertThat(order.getDomainEvents())
.filteredOn(e -> e instanceof OrderCancelledEvent)
.hasSize(1);
}
@Test void cancelled_event_contains_reason() {
Order order = createOrderWithItems();
order.cancel("Duplicate order");
assertThat(order.getDomainEvents())
.filteredOn(e -> e instanceof OrderCancelledEvent)
.first()
.extracting("reason")
.isEqualTo("Duplicate order");
}
// --- Total Calculation ---
@Test void calculates_total_correctly() {
Order order = Order.create(CustomerId.from("cust-123"));
order.addItem(ProductId.from("p1"), Quantity.of(2), new Money(10, "USD"));
order.addItem(ProductId.from("p2"), Quantity.of(1), new Money(25, "USD"));
assertThat(order.calculateTotal()).isEqualTo(new Money(45, "USD"));
}
@Test void total_is_zero_for_empty_order() {
Order order = Order.create(CustomerId.from("cust-123"));
assertThat(order.calculateTotal()).isEqualTo(Money.zero("USD"));
}
// --- Test Fixture Builders ---
private Order createOrderWithItems() {
Order order = Order.create(CustomerId.from("cust-123"));
order.addItem(ProductId.from("p1"), Quantity.of(2), new Money(10, "USD"));
return order;
}
private Order createPaidOrder() {
Order order = createOrderWithItems();
order.pay(mock(PaymentGateway.class));
order.clearEvents();
return order;
}
}Domain Service: PricingService
class PricingServiceTest {
@Mock OrderRepository orderRepository;
@Mock DiscountPolicy discountPolicy;
@InjectMocks PricingService pricingService;
@Test void calculates_price_without_discount() {
Order order = createOrderWithItems(new Money(100, "USD"));
when(orderRepository.findById(order.getId())).thenReturn(Optional.of(order));
when(discountPolicy.isVipCustomer(order.getCustomerId())).thenReturn(false);
pricingService.calculatePrice(order.getId());
assertThat(order.getTotalAmount()).isEqualTo(new Money(100, "USD"));
}
@Test void applies_vip_discount() {
Order order = createOrderWithItems(new Money(100, "USD"));
when(orderRepository.findById(order.getId())).thenReturn(Optional.of(order));
when(discountPolicy.isVipCustomer(order.getCustomerId())).thenReturn(true);
when(discountPolicy.getVipDiscountRate()).thenReturn(0.1);
pricingService.calculatePrice(order.getId());
assertThat(order.getTotalAmount()).isEqualTo(new Money(90, "USD"));
}
@Test void applies_seasonal_discount_on_top_of_vip() {
Order order = createOrderWithItems(new Money(100, "USD"));
when(orderRepository.findById(order.getId())).thenReturn(Optional.of(order));
when(discountPolicy.isVipCustomer(order.getCustomerId())).thenReturn(true);
when(discountPolicy.getVipDiscountRate()).thenReturn(0.1);
when(discountPolicy.isSeasonalActive()).thenReturn(true);
when(discountPolicy.getSeasonalDiscountRate()).thenReturn(0.2);
pricingService.calculatePrice(order.getId());
assertThat(order.getTotalAmount()).isEqualTo(new Money(72, "USD"));
}
}Architecture Testing Comparison
各架构的测试重心差异
| 架构 | Domain 测试重心 | Application 测试 | Adapter 测试 | 关键差异 |
|---|---|---|---|---|
| DDD Layered | 聚合根 + 值对象 + 领域服务 | AppService(薄层,Mock Repository) | Controller + DTO 转换 | 优势:Domain 测试占比最高(60%+) |
| Hexagonal | 聚合根 + 端口接口 | UseCase 实现(注入端口接口) | 主适配器 + 次适配器分别测试 | 优势:端口 Mock 最容易;劣势:适配器测试多 |
| Clean Architecture | Entity + Enterprise 规则 | UseCase Interactor(Mock 输出端口) | Controller + Gateway + Presenter | 优势:企业规则可独立测试;劣势:层数多测试路径长 |
| COLA | Domain 模块 | App Service(编排 + 事务) | Adapter(Web + MQ Consumer) | 优势:模块隔离好;劣势:需额外测 CQRS 分流 |
| CQRS | Command 聚合根(同标准测试) | Command Handler + Query Handler 分离 | Command API + Query API 分别测 | 优势:读写解耦测试简单;劣势:Query 测物化视图 |
| Event Sourcing | Event Replay 重建测试 | Event Handler + Projection | Event Store 集成 | 优势:事件流可追溯;劣势:Event Replay 必测 |
Hexagonal 架构的 Port Mock 模式
// Domain 定义端口
public interface OrderRepository {
Order save(Order order);
Optional<Order> findById(OrderId id);
}
// 应用层测试 — 注入 Mock 端口
class PayOrderServiceTest {
@Mock OrderRepository orderRepository;
@Mock PaymentGateway paymentGateway;
@InjectMocks PayOrderService service;
@Test void pay_order_success() {
var order = createTestOrder();
when(orderRepository.findById(order.getId())).thenReturn(Optional.of(order));
service.pay(order.getId().value());
verify(orderRepository).save(order);
verify(paymentGateway).charge(any());
}
}
// 适配器集成测试 — 真实基础设施
@SpringBootTest
@Testcontainers
class JpaOrderRepositoryTest {
@Autowired OrderRepository orderRepository;
@Test void persists_and_retrieves() {
// 真实 DB 操作
}
}Clean Architecture 的分层测试
// Enterprise 层 — 零依赖,纯 POJO 测试
@Test void entity_order_should_not_allow_negative_total() {
assertThrows(DomainException.class, () ->
OrderEntity.create("order-1", new Money(-10, "USD")));
}
// UseCase 层 — Mock 输出端口
@Test void createOrderInteractor_should_save_to_repository() {
var repo = mock(OrderOutputPort.class);
var interactor = new CreateOrderInteractor(repo);
interactor.execute(new CreateOrderInput("cust-1", items));
verify(repo).save(any(OrderEntity.class));
}
// Adapter 层 — 集成测试
@SpringBootTest
class OrderControllerTest {
@Test void post_order_returns_201() {
// 真实 HTTP + 真实 DB
}
}COLA 架构模块化测试
// domain 模块 — 零框架依赖
@Test void order_pay_changes_status() {
Order order = Order.create(customerId, items);
order.pay(mockGateway);
assertEquals(OrderStatus.PAID, order.getStatus());
}
// app 模块 — Mock domain 仓储
@Test void orderAppService_create() {
when(orderRepository.save(any())).thenReturn(order);
var result = orderAppService.createOrder(cmd);
assertNotNull(result);
}
// adapter 模块 — 集成测试
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Test void create_returns_201() { /* ... */ }
}根据项目阶段选择测试策略
| 项目阶段 | 推荐测试重点 | 比例 |
|---|---|---|
| 新建 DDD 项目 | Value Object + Aggregate Root(TDDD) | 70% Domain + 20% + 10% |
| 已有项目引入 DDD | 现有代码分层测试 + 新 Domain 测试 | 40% 适配器 + 40% Domain + 20% |
| 遗留系统迁移 | 防腐层测试 + 新聚合测试 | 40% 集成 + 30% Domain + 30% |
| 微服务拆分后 | 契约测试 + 跨服务 E2E | 30% 契约 + 30% 单服务 + 40% E2E |
CI/CD Test Stage Configuration
三阶段测试编排
Commit Stage (< 5 min):
目标:快速反馈,6-8秒完成
✓ Value Object 单元测试
✓ Aggregate Root 测试
✓ Domain Service 测试(Mock Repository)
✓ 禁止:涉及 DB / 网络 / MQ 的测试
PR Stage (< 15 min):
目标:全面验证变更质量
✓ Repository 集成测试(Testcontainers)
✓ Application Service 集成测试(Mock Ports)
✓ API 契约测试
✓ Architecture 测试(ArchUnit)
✓ N+1 查询检测
Release Stage (< 30 min):
目标:生产环境最终验证
✓ E2E 测试(关键用户旅程 3-5 个)
✓ 性能测试(N+1 查询检测 + 响应时间)
✓ 安全扫描(已知漏洞检查)GitHub Actions 配置示例
name: DDD Testing Pipeline
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
commit-stage:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
- name: Domain Unit Tests
run: ./mvnw test -pl domain -Dtest="*Test" -DfailIfNoTests=false
pr-stage:
needs: commit-stage
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_DB: testdb
POSTGRES_USER: test
POSTGRES_PASSWORD: test
ports:
- 5432:5432
steps:
- uses: actions/checkout@v4
- name: Integration + Architecture Tests
run: |
./mvnw verify -pl infrastructure,adapter \
-Dtest="*IntegrationTest,*ArchTest" \
-Dspring.datasource.url=jdbc:postgresql://localhost:5432/testdb
release-stage:
needs: pr-stage
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: E2E Tests
run: ./mvnw verify -pl e2e -Dtest="*E2ETest"
- name: Performance Tests
run: ./mvnw gatling:testN+1 Query Detection in CI
<!-- Maven Surefire + hibernate-statistics -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<systemPropertyVariables>
<hibernate.generate_statistics>true</hibernate.generate_statistics>
</systemPropertyVariables>
</configuration>
</plugin>@Test
void aggregate_loading_should_not_cause_n_plus_1() {
SQLStatementCountValidator.reset();
Order order = repository.findById(orderId).orElseThrow();
// Access items — should NOT trigger N+1
int itemCount = order.getItems().size();
assertSelectCount(1); // Single query with JOIN
}Architecture Test Stage
@RunWith(ArchUnitRunner.class)
public class ArchitectureTest {
@Test
void domain_layer_should_not_depend_on_infrastructure() {
noClasses()
.that().resideInAPackage("..domain..")
.should().dependOnClassesThat()
.resideInAPackage("..infrastructure..")
.check(new ClassFileImporter()
.importPackages("com.example"));
}
@Test
void domain_layer_should_have_no_framework_deps() {
noClasses()
.that().resideInAPackage("..domain..")
.should().dependOnClassesThat()
.resideInAnyPackage("org.springframework..", "javax.persistence..")
.check(new ClassFileImporter()
.importPackages("com.example"));
}
}Testing Patterns
Sources:
- The Clean Architecture — Robert C. Martin
- Hexagonal Architecture — Alistair Cockburn
- Unit Testing — Martin Fowler
- Test Pyramid — Martin Fowler
Testing strategies for Clean Architecture + DDD + Hexagonal systems.
Testing Pyramid
%%{init: {'theme': 'base', 'themeVariables': { 'fontSize': '14px'}}}%%
flowchart TB
subgraph Pyramid["Testing Pyramid"]
E2E["E2E Tests\nFew, slow, expensive"]
Integration["Integration Tests\nSome, moderate speed"]
Unit["Unit Tests (Domain & Application)\nMany, fast, cheap"]
end
E2E --- Integration
Integration --- Unit
style E2E fill:#ef4444,stroke:#dc2626,color:white
style Integration fill:#f59e0b,stroke:#d97706,color:white
style Unit fill:#10b981,stroke:#059669,color:white---
Unit Tests
Domain Layer Tests
Test business logic in isolation. No mocks needed—domain has no dependencies.
// tests/domain/order/order.test.ts
describe('Order', () => {
describe('create', () => {
it('creates order with draft status', () => {
const customerId = CustomerId.from('cust-123');
const order = Order.create(customerId);
expect(order.status).toBe(OrderStatus.Draft);
expect(order.customerId).toEqual(customerId);
expect(order.items).toHaveLength(0);
});
it('emits OrderCreated event', () => {
const customerId = CustomerId.from('cust-123');
const order = Order.create(customerId);
expect(order.domainEvents).toHaveLength(1);
expect(order.domainEvents[0]).toBeInstanceOf(OrderCreated);
});
});
describe('addItem', () => {
it('adds item to order', () => {
const order = createDraftOrder();
const productId = ProductId.from('prod-123');
const quantity = Quantity.create(2);
const price = Money.create(10.00, 'USD');
order.addItem(productId, quantity, price);
expect(order.items).toHaveLength(1);
expect(order.items[0].productId).toEqual(productId);
expect(order.items[0].quantity).toEqual(quantity);
});
it('increases quantity for existing product', () => {
const order = createDraftOrder();
const productId = ProductId.from('prod-123');
const price = Money.create(10.00, 'USD');
order.addItem(productId, Quantity.create(2), price);
order.addItem(productId, Quantity.create(3), price);
expect(order.items).toHaveLength(1);
expect(order.items[0].quantity.value).toBe(5);
});
it('throws when order is cancelled', () => {
const order = createCancelledOrder();
expect(() => {
order.addItem(ProductId.from('prod-123'), Quantity.create(1), Money.create(10, 'USD'));
}).toThrow(InvalidOrderStateError);
});
it('throws when quantity is zero', () => {
const order = createDraftOrder();
expect(() => {
order.addItem(ProductId.from('prod-123'), Quantity.create(0), Money.create(10, 'USD'));
}).toThrow(InvalidQuantityError);
});
});
describe('confirm', () => {
it('changes status to confirmed', () => {
const order = createOrderWithItems();
order.confirm();
expect(order.status).toBe(OrderStatus.Confirmed);
});
it('emits OrderConfirmed event', () => {
const order = createOrderWithItems();
order.confirm();
const events = order.domainEvents.filter(e => e instanceof OrderConfirmed);
expect(events).toHaveLength(1);
});
it('throws when order is empty', () => {
const order = createDraftOrder();
expect(() => order.confirm()).toThrow(EmptyOrderError);
});
it('throws when already confirmed', () => {
const order = createConfirmedOrder();
expect(() => order.confirm()).toThrow(InvalidOrderStateError);
});
});
describe('total', () => {
it('calculates total from all items', () => {
const order = createDraftOrder();
order.addItem(ProductId.from('p1'), Quantity.create(2), Money.create(10, 'USD'));
order.addItem(ProductId.from('p2'), Quantity.create(1), Money.create(25, 'USD'));
expect(order.total.amount).toBe(45); // 2*10 + 1*25
});
it('returns zero for empty order', () => {
const order = createDraftOrder();
expect(order.total.amount).toBe(0);
});
});
});
// Test helpers (builders)
function createDraftOrder(): Order {
return Order.create(CustomerId.from('cust-123'));
}
function createOrderWithItems(): Order {
const order = createDraftOrder();
order.addItem(ProductId.from('prod-123'), Quantity.create(1), Money.create(10, 'USD'));
return order;
}
function createConfirmedOrder(): Order {
const order = createOrderWithItems();
order.setShippingAddress(createTestAddress());
order.confirm();
return order;
}
function createCancelledOrder(): Order {
const order = createOrderWithItems();
order.cancel('Test cancellation');
return order;
}Value Object Tests
// tests/domain/shared/money.test.ts
describe('Money', () => {
describe('create', () => {
it('creates money with valid amount', () => {
const money = Money.create(10.50, 'USD');
expect(money.amount).toBe(10.50);
expect(money.currency).toBe('USD');
});
it('throws for negative amount', () => {
expect(() => Money.create(-1, 'USD')).toThrow(InvalidMoneyError);
});
});
describe('add', () => {
it('adds two money values with same currency', () => {
const a = Money.create(10, 'USD');
const b = Money.create(20, 'USD');
const result = a.add(b);
expect(result.amount).toBe(30);
expect(result.currency).toBe('USD');
});
it('throws for different currencies', () => {
const usd = Money.create(10, 'USD');
const eur = Money.create(10, 'EUR');
expect(() => usd.add(eur)).toThrow(CurrencyMismatchError);
});
});
describe('equality', () => {
it('equals money with same amount and currency', () => {
const a = Money.create(10, 'USD');
const b = Money.create(10, 'USD');
expect(a.equals(b)).toBe(true);
});
it('not equal with different amount', () => {
const a = Money.create(10, 'USD');
const b = Money.create(20, 'USD');
expect(a.equals(b)).toBe(false);
});
});
});Application Layer Tests
Test use cases with mocked ports.
// tests/application/place_order/handler.test.ts
describe('PlaceOrderHandler', () => {
let handler: PlaceOrderHandler;
let orderRepo: MockOrderRepository;
let productRepo: MockProductRepository;
let eventPublisher: MockEventPublisher;
beforeEach(() => {
orderRepo = new MockOrderRepository();
productRepo = new MockProductRepository();
eventPublisher = new MockEventPublisher();
handler = new PlaceOrderHandler(orderRepo, productRepo, eventPublisher);
});
it('creates order with items and saves', async () => {
productRepo.addProduct(createTestProduct('prod-1', 10.00));
productRepo.addProduct(createTestProduct('prod-2', 20.00));
const command: PlaceOrderCommand = {
customerId: 'cust-123',
items: [
{ productId: 'prod-1', quantity: 2 },
{ productId: 'prod-2', quantity: 1 },
],
};
const orderId = await handler.handle(command);
expect(orderId).toBeDefined();
const savedOrder = await orderRepo.findById(OrderId.from(orderId));
expect(savedOrder).not.toBeNull();
expect(savedOrder!.items).toHaveLength(2);
expect(savedOrder!.total.amount).toBe(40); // 2*10 + 1*20
});
it('publishes domain events', async () => {
productRepo.addProduct(createTestProduct('prod-1', 10.00));
const command: PlaceOrderCommand = {
customerId: 'cust-123',
items: [{ productId: 'prod-1', quantity: 1 }],
};
await handler.handle(command);
expect(eventPublisher.publishedEvents).toHaveLength(1);
expect(eventPublisher.publishedEvents[0]).toBeInstanceOf(OrderCreated);
});
it('throws when product not found', async () => {
const command: PlaceOrderCommand = {
customerId: 'cust-123',
items: [{ productId: 'nonexistent', quantity: 1 }],
};
await expect(handler.handle(command)).rejects.toThrow(ProductNotFoundError);
});
it('rolls back on error', async () => {
productRepo.addProduct(createTestProduct('prod-1', 10.00));
orderRepo.simulateErrorOnSave();
const command: PlaceOrderCommand = {
customerId: 'cust-123',
items: [{ productId: 'prod-1', quantity: 1 }],
};
await expect(handler.handle(command)).rejects.toThrow();
expect(orderRepo.savedOrders).toHaveLength(0);
});
});
// Mock implementations
class MockOrderRepository implements IOrderRepository {
savedOrders: Order[] = [];
private shouldError = false;
async findById(id: OrderId): Promise<Order | null> {
return this.savedOrders.find(o => o.id.equals(id)) ?? null;
}
async save(order: Order): Promise<void> {
if (this.shouldError) {
throw new Error('Simulated save error');
}
this.savedOrders.push(order);
}
async delete(order: Order): Promise<void> {
const index = this.savedOrders.findIndex(o => o.id.equals(order.id));
if (index >= 0) {
this.savedOrders.splice(index, 1);
}
}
simulateErrorOnSave(): void {
this.shouldError = true;
}
}
class MockEventPublisher implements IEventPublisher {
publishedEvents: DomainEvent[] = [];
async publish(event: DomainEvent): Promise<void> {
this.publishedEvents.push(event);
}
async publishAll(events: DomainEvent[]): Promise<void> {
this.publishedEvents.push(...events);
}
}---
Integration Tests
Test adapters with real infrastructure (databases, message brokers).
// tests/integration/postgres/order_repository.test.ts
describe('PostgresOrderRepository', () => {
let pool: Pool;
let repository: PostgresOrderRepository;
beforeAll(async () => {
pool = new Pool({ connectionString: process.env.TEST_DATABASE_URL });
repository = new PostgresOrderRepository(pool);
});
beforeEach(async () => {
await pool.query('TRUNCATE orders, order_items CASCADE');
});
afterAll(async () => {
await pool.end();
});
describe('save and findById', () => {
it('persists and retrieves order', async () => {
const order = Order.create(CustomerId.from('cust-123'));
order.addItem(ProductId.from('prod-1'), Quantity.create(2), Money.create(10, 'USD'));
await repository.save(order);
const retrieved = await repository.findById(order.id);
expect(retrieved).not.toBeNull();
expect(retrieved!.id.value).toBe(order.id.value);
expect(retrieved!.items).toHaveLength(1);
expect(retrieved!.items[0].quantity.value).toBe(2);
});
it('updates existing order', async () => {
const order = Order.create(CustomerId.from('cust-123'));
order.addItem(ProductId.from('prod-1'), Quantity.create(1), Money.create(10, 'USD'));
await repository.save(order);
order.addItem(ProductId.from('prod-2'), Quantity.create(3), Money.create(20, 'USD'));
await repository.save(order);
const retrieved = await repository.findById(order.id);
expect(retrieved!.items).toHaveLength(2);
});
it('returns null for nonexistent order', async () => {
const result = await repository.findById(OrderId.from('nonexistent'));
expect(result).toBeNull();
});
});
describe('delete', () => {
it('removes order from database', async () => {
const order = Order.create(CustomerId.from('cust-123'));
await repository.save(order);
await repository.delete(order);
const retrieved = await repository.findById(order.id);
expect(retrieved).toBeNull();
});
});
});API Integration Tests
// tests/integration/http/orders_api.test.ts
describe('Orders API', () => {
let app: Express;
let pool: Pool;
beforeAll(async () => {
pool = new Pool({ connectionString: process.env.TEST_DATABASE_URL });
app = createApp(pool);
});
beforeEach(async () => {
await db.truncate("orders", "order_items", "products");
await db.products.insertMany([
{ id: "prod-1", name: "Product 1", price: 1000 },
{ id: "prod-2", name: "Product 2", price: 2000 }
]);
});
afterAll(async () => {
await pool.end();
});
describe('POST /orders', () => {
it('creates order and returns 201', async () => {
const response = await request(app)
.post('/orders')
.send({
customer_id: 'cust-123',
items: [
{ product_id: 'prod-1', quantity: 2 },
{ product_id: 'prod-2', quantity: 1 },
],
});
expect(response.status).toBe(201);
expect(response.body.id).toBeDefined();
});
it('returns 400 for invalid product', async () => {
const response = await request(app)
.post('/orders')
.send({
customer_id: 'cust-123',
items: [{ product_id: 'nonexistent', quantity: 1 }],
});
expect(response.status).toBe(400);
expect(response.body.error).toContain('Product not found');
});
});
describe('GET /orders/:id', () => {
it('returns order details', async () => {
const createResponse = await request(app)
.post('/orders')
.send({
customer_id: 'cust-123',
items: [{ product_id: 'prod-1', quantity: 2 }],
});
const orderId = createResponse.body.id;
const response = await request(app).get(`/orders/${orderId}`);
expect(response.status).toBe(200);
expect(response.body.id).toBe(orderId);
expect(response.body.items).toHaveLength(1);
});
it('returns 404 for nonexistent order', async () => {
const response = await request(app).get('/orders/nonexistent');
expect(response.status).toBe(404);
});
});
});---
Architecture Tests
Verify dependency rules and naming conventions.
// tests/architecture/dependency_rules.test.ts
import { describe, it } from 'vitest';
describe('Dependency Rules', () => {
it('domain layer has no external dependencies', () => {
// Verify domain/ imports only shared kernel types
const disallowedImports = [
'@nestjs', 'typeorm', 'express', 'axios',
'knex', 'prisma', 'sequelize', 'mongoose'
];
for (const pkg of disallowedImports) {
expect(scanImports('src/domain/**/*.ts')).not.toContain(pkg);
}
});
it('application layer does not import infrastructure', () => {
expect(scanImports('src/application/**/*.ts'))
.not.toMatch(/infrastructure/);
});
});---
Test Naming Convention
describe('[Aggregate/Component]', () => {
describe('[Method/Behavior]', () => {
it('[expected behavior] when [condition]', () => { ... });
});
});
// Examples
describe('Order', () => {
describe('confirm', () => {
it('changes status to confirmed when order has items and address');
it('throws EmptyOrderError when order has no items');
it('throws InvalidOrderStateError when already confirmed');
});
});---
Key Principles
1. Domain tests need no mocks — Domain code is pure, inject dependencies 2. Test business invariants, not CRUD — Don't test getters/setters 3. Mock ports, not implementations — Mock the interface, not the adapter 4. Integration tests for adapters — Verify repository, gateway, API behavior 5. One aggregate per test transaction — Test complete lifecycle 6. Test domain events — Verify events are emitted at correct times 7. Architecture tests in CI — Enforce dependency rules automatically
CQRS & Event Sourcing Testing
CQRS Command Side Testing
Command side = write model. Test aggregate roots with mocked ports. Same pattern as standard Domain testing.
@Test void create_order_command_creates_aggregate() {
var cmd = new CreateOrderCommand("cust-1", List.of(
new CreateOrderItem("p1", 2),
new CreateOrderItem("p2", 1)
));
var result = createOrderHandler.handle(cmd);
assertNotNull(result.orderId());
verify(orderRepository).save(any(Order.class));
}
@Test void pay_order_command_changes_status() {
var order = createTestOrder();
when(orderRepository.findById(order.getId())).thenReturn(Optional.of(order));
var cmd = new PayOrderCommand(order.getId().value());
payOrderHandler.handle(cmd);
assertEquals(OrderStatus.PAID, order.getStatus());
verify(orderRepository).save(order);
}
@Test void cancel_order_command_throws_for_nonexistent() {
var cmd = new CancelOrderCommand("nonexistent");
when(orderRepository.findById(any())).thenReturn(Optional.empty());
assertThrows(OrderNotFoundException.class, () -> cancelOrderHandler.handle(cmd));
}CQRS Query Side Testing
Query side = read model. Test materialized views or query repositories directly — no mocks needed.
@SpringBootTest
@Testcontainers
class OrderQueryServiceTest {
@Autowired private OrderQueryService queryService;
@Autowired private JdbcTemplate jdbc;
@BeforeEach void setUp() {
jdbc.execute("TRUNCATE order_view CASCADE");
// Seed the read model
jdbc.update("INSERT INTO order_view (id, customer_id, status, total, created_at) " +
"VALUES ('order-1', 'cust-1', 'PAID', 4500, NOW())");
jdbc.update("INSERT INTO order_view (id, customer_id, status, total, created_at) " +
"VALUES ('order-2', 'cust-1', 'DRAFT', 2000, NOW())");
}
@Test void find_by_id_returns_dto() {
var dto = queryService.findById("order-1");
assertTrue(dto.isPresent());
assertEquals("order-1", dto.get().getId());
assertEquals("PAID", dto.get().getStatus());
assertEquals(4500, dto.get().getTotal());
}
@Test void find_by_customer_returns_all_orders() {
var orders = queryService.findByCustomer("cust-1");
assertEquals(2, orders.size());
}
@Test void find_by_status_returns_filtered() {
var paid = queryService.findByStatus("PAID");
assertEquals(1, paid.size());
}
}Event Sourcing — Event Replay Tests
Test that aggregate state can be correctly reconstructed from event streams.
@Test void replay_events_reconstructs_aggregate_state() {
var events = List.<DomainEvent>of(
new OrderCreatedEvent("order-1", "cust-1"),
new OrderItemAddedEvent("order-1", ProductId.from("p1"), 2, Money.usd(10)),
new OrderPaidEvent("order-1", Money.usd(20))
);
var order = Order.replay(events);
assertEquals(OrderStatus.PAID, order.getStatus());
assertEquals("cust-1", order.getCustomerId().value());
assertEquals(1, order.getItems().size());
assertEquals(new Money(20, "USD"), order.getTotalAmount());
}
@Test void replay_empty_events_throws() {
assertThrows(IllegalArgumentException.class,
() -> Order.replay(List.of()));
}
@Test void replay_invalid_event_sequence_throws() {
// Pay before creating
var events = List.<DomainEvent>of(
new OrderPaidEvent("order-1", Money.usd(20))
);
assertThrows(InvalidEventSequenceException.class,
() -> Order.replay(events));
}Event Sourcing — Projection Tests
Test that projection/denormalizer builds the correct read model from events.
class OrderProjectionTest {
private InMemoryOrderViewStore viewStore;
private OrderProjection projection;
@BeforeEach void setUp() {
viewStore = new InMemoryOrderViewStore();
projection = new OrderProjection(viewStore);
}
@Test void on_order_created_creates_view() {
projection.on(new OrderCreatedEvent("order-1", "cust-1"));
var view = viewStore.findById("order-1");
assertTrue(view.isPresent());
assertEquals("order-1", view.get().getId());
assertEquals("DRAFT", view.get().getStatus());
}
@Test void on_order_paid_updates_view_status() {
viewStore.save(new OrderView("order-1", "DRAFT"));
projection.on(new OrderPaidEvent("order-1", Money.usd(100)));
var view = viewStore.findById("order-1");
assertEquals("PAID", view.get().getStatus());
}
@Test void on_item_added_updates_total() {
viewStore.save(new OrderView("order-1", "DRAFT", 0));
projection.on(new OrderItemAddedEvent("order-1", ProductId.from("p1"), 2, Money.usd(10)));
var view = viewStore.findById("order-1");
assertEquals(20, view.get().getTotal()); // 2 * 10
}
@Test void multiple_events_produce_correct_state() {
projection.on(new OrderCreatedEvent("order-1", "cust-1"));
projection.on(new OrderItemAddedEvent("order-1", ProductId.from("p1"), 2, Money.usd(10)));
projection.on(new OrderItemAddedEvent("order-1", ProductId.from("p2"), 1, Money.usd(25)));
projection.on(new OrderPaidEvent("order-1", Money.usd(45)));
var view = viewStore.findById("order-1");
assertEquals("PAID", view.get().getStatus());
assertEquals(45, view.get().getTotal());
}
}Event Sourcing — Snapshot Tests
Test that snapshot + remaining events reconstruct correctly.
@Test void snapshot_with_remaining_events_reconstructs_state() {
var snapshot = new OrderSnapshot("order-1", "cust-1", OrderStatus.DRAFT, Money.zero("USD"));
var remainingEvents = List.<DomainEvent>of(
new OrderItemAddedEvent("order-1", ProductId.from("p1"), 2, Money.usd(10)),
new OrderPaidEvent("order-1", Money.usd(20))
);
var order = Order.replayFromSnapshot(snapshot, remainingEvents);
assertEquals(OrderStatus.PAID, order.getStatus());
assertEquals(new Money(20, "USD"), order.getTotalAmount());
}Integration Test Strategies
Repository Integration Tests
Test aggregate root persistence — save and load the complete aggregate graph. Use Testcontainers for real database.
Complete Aggregate Persistence
@SpringBootTest
@Testcontainers
class OrderRepositoryImplIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15")
.withDatabaseName("testdb");
@DynamicPropertySource
static void configure(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@Autowired private OrderRepository repository;
@Autowired private JdbcTemplate jdbc;
@BeforeEach void clean() {
jdbc.execute("TRUNCATE TABLE orders, order_items CASCADE");
}
@Test void persists_and_retrieves_aggregate() {
Order order = Order.create(CustomerId.from("cust-1"));
order.addItem(ProductId.from("p1"), Quantity.of(2), Money.usd(10));
order.addItem(ProductId.from("p2"), Quantity.of(1), Money.usd(25));
repository.save(order);
var loaded = repository.findById(order.getId()).orElseThrow();
assertEquals(order.getId(), loaded.getId());
assertEquals(order.getCustomerId(), loaded.getCustomerId());
assertEquals(2, loaded.getItems().size());
assertEquals(new Money(45, "USD"), loaded.getTotalAmount());
}
@Test void updates_existing_aggregate() {
Order order = Order.create(CustomerId.from("cust-1"));
order.addItem(ProductId.from("p1"), Quantity.of(1), Money.usd(10));
repository.save(order);
order.addItem(ProductId.from("p2"), Quantity.of(3), Money.usd(20));
repository.save(order);
var loaded = repository.findById(order.getId()).orElseThrow();
assertEquals(2, loaded.getItems().size());
}
@Test void deletes_aggregate() {
Order order = Order.create(CustomerId.from("cust-1"));
repository.save(order);
repository.delete(order);
assertTrue(repository.findById(order.getId()).isEmpty());
}
@Test void returns_empty_for_nonexistent() {
assertTrue(repository.findById(OrderId.from("nonexistent")).isEmpty());
}
}N+1 Query Detection
@Test
void loading_aggregate_should_not_cause_n_plus_1() {
// Create order with many items
Order order = Order.create(CustomerId.from("cust-1"), create50Items());
repository.save(order);
// Capture SQL count
SQLStatementCountValidator.reset();
var loaded = repository.findById(order.getId());
assertTrue(loaded.isPresent());
assertSelectCount(1); // 1 query with JOIN, not 1 + N
}API Integration Tests
Test HTTP endpoints with real database and mocked external services.
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@AutoConfigureMockMvc
@Testcontainers
class OrderControllerIntegrationTest {
@Autowired private MockMvc mvc;
@Autowired private JdbcTemplate jdbc;
@BeforeEach void setUp() {
jdbc.execute("TRUNCATE orders, order_items, products CASCADE");
jdbc.update("INSERT INTO products (id, name, price) VALUES ('p1', 'Product 1', 1000)");
jdbc.update("INSERT INTO products (id, name, price) VALUES ('p2', 'Product 2', 2000)");
}
@Test void create_order_returns_201() throws Exception {
mvc.perform(post("/orders")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"customerId":"cust-1","items":[{"productId":"p1","quantity":2}]}
"""))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").isNotEmpty())
.andExpect(jsonPath("$.total").value(2000));
}
@Test void create_order_with_invalid_product_returns_400() throws Exception {
mvc.perform(post("/orders")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"customerId":"cust-1","items":[{"productId":"nonexistent","quantity":1}]}
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error").value("Product not found"));
}
@Test void get_order_returns_200() throws Exception {
String orderId = createTestOrder().getBody().getId();
mvc.perform(get("/orders/{id}", orderId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(orderId))
.andExpect(jsonPath("$.items").isArray());
}
@Test void get_nonexistent_order_returns_404() throws Exception {
mvc.perform(get("/orders/nonexistent"))
.andExpect(status().isNotFound());
}
}Messaging Integration Tests
Test event publishing and consumption with a real message broker.
@SpringBootTest
@EnableKafka
@Testcontainers
class OrderEventPublisherIntegrationTest {
@Container
static KafkaContainer kafka = new KafkaContainer(
DockerImageName.parse("confluentinc/cp-kafka:latest"));
@Autowired private OrderEventPublisher publisher;
@Autowired private KafkaTemplate<String, String> kafkaTemplate;
@Test void publishes_domain_event_to_kafka() {
var event = new OrderPaidEvent(OrderId.from("order-1"), Money.usd(100));
publisher.publish(event);
// Verify event was published to the correct topic
// using a test consumer
}
}各层测试模式 — Mock 实现与集成测试
Mock Repository 实现
public class MockOrderRepository implements OrderRepository {
private final Map<OrderId, Order> store = new HashMap<>();
private boolean shouldError = false;
@Override
public Optional<Order> findById(OrderId id) {
return Optional.ofNullable(store.get(id));
}
@Override
public void save(Order order) {
if (shouldError) throw new RuntimeException("Simulated save error");
store.put(order.getId(), order);
}
@Override
public void delete(Order order) {
store.remove(order.getId());
}
public void simulateErrorOnSave() { this.shouldError = true; }
}Mock Event Publisher
public class MockEventPublisher implements EventPublisher {
public final List<DomainEvent> publishedEvents = new ArrayList<>();
@Override
public void publish(DomainEvent event) { publishedEvents.add(event); }
@Override
public void publishAll(List<DomainEvent> events) { publishedEvents.addAll(events); }
}应用层测试(Mock 端口)
class PlaceOrderHandlerTest {
private PlaceOrderHandler handler;
private MockOrderRepository orderRepo;
private MockProductRepository productRepo;
private MockEventPublisher eventPublisher;
@BeforeEach void setUp() {
orderRepo = new MockOrderRepository();
productRepo = new MockProductRepository();
eventPublisher = new MockEventPublisher();
handler = new PlaceOrderHandler(orderRepo, productRepo, eventPublisher);
}
@Test void creates_order_with_items_and_saves() {
productRepo.add(createTestProduct("prod-1", 10.00));
productRepo.add(createTestProduct("prod-2", 20.00));
var cmd = new PlaceOrderCommand("cust-123", List.of(
new ItemCmd("prod-1", 2), new ItemCmd("prod-2", 1)));
var orderId = handler.handle(cmd);
assertNotNull(orderId);
var saved = orderRepo.findById(OrderId.from(orderId.value())).orElseThrow();
assertEquals(2, saved.getItems().size());
assertEquals(new Money(40, "USD"), saved.getTotal());
}
@Test void publishes_domain_events() {
productRepo.add(createTestProduct("prod-1", 10.00));
var cmd = new PlaceOrderCommand("cust-123", List.of(new ItemCmd("prod-1", 1)));
handler.handle(cmd);
assertEquals(1, eventPublisher.publishedEvents.size());
assertTrue(eventPublisher.publishedEvents.get(0) instanceof OrderCreated);
}
@Test void throws_when_product_not_found() {
var cmd = new PlaceOrderCommand("cust-123", List.of(new ItemCmd("nonexistent", 1)));
assertThrows(ProductNotFoundError.class, () -> handler.handle(cmd));
}
@Test void rolls_back_on_error() {
productRepo.add(createTestProduct("prod-1", 10.00));
orderRepo.simulateErrorOnSave();
var cmd = new PlaceOrderCommand("cust-123", List.of(new ItemCmd("prod-1", 1)));
assertThrows(RuntimeException.class, () -> handler.handle(cmd));
assertEquals(0, orderRepo.savedCount());
}
}仓储集成测试(真实 DB)
@SpringBootTest
@Testcontainers
class PostgresOrderRepositoryTest {
@Autowired private OrderRepository repository;
@Autowired private JdbcTemplate jdbc;
@BeforeEach void setUp() {
jdbc.execute("TRUNCATE orders, order_items CASCADE");
}
@Test void persists_and_retrieves_complete_aggregate() {
Order order = Order.create(CustomerId.from("cust-123"));
order.addItem(ProductId.from("prod-1"), Quantity.of(2), Money.usd(10));
repository.save(order);
var retrieved = repository.findById(order.getId()).orElseThrow();
assertEquals(order.getId(), retrieved.getId());
assertEquals(1, retrieved.getItems().size());
assertEquals(2, retrieved.getItems().get(0).getQuantity().value());
}
@Test void updates_existing_order() {
Order order = Order.create(CustomerId.from("cust-123"));
order.addItem(ProductId.from("prod-1"), Quantity.of(1), Money.usd(10));
repository.save(order);
order.addItem(ProductId.from("prod-2"), Quantity.of(3), Money.usd(20));
repository.save(order);
var retrieved = repository.findById(order.getId()).orElseThrow();
assertEquals(2, retrieved.getItems().size());
}
@Test void returns_empty_for_nonexistent() {
assertTrue(repository.findById(OrderId.from("nonexistent")).isEmpty());
}
}API 集成测试
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@AutoConfigureMockMvc
class OrdersApiTest {
@Autowired private MockMvc mvc;
@Autowired private JdbcTemplate jdbc;
@BeforeEach void setUp() {
jdbc.execute("TRUNCATE orders, order_items, products");
jdbc.update("INSERT INTO products VALUES ('prod-1', 'Product 1', 1000)");
}
@Test void creates_order_and_returns_201() throws Exception {
mvc.perform(post("/orders")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"customer_id":"cust-123","items":[{"product_id":"prod-1","quantity":2}]}"""))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").isNotEmpty());
}
@Test void returns_400_for_invalid_product() throws Exception {
mvc.perform(post("/orders")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"customer_id":"cust-123","items":[{"product_id":"nonexistent","quantity":1}]}"""))
.andExpect(status().isBadRequest());
}
@Test void returns_404_for_nonexistent_order() throws Exception {
mvc.perform(get("/orders/nonexistent"))
.andExpect(status().isNotFound());
}
}事件分发器模式
public class EventDispatcher {
private final Map<String, List<EventHandler<?>>> handlers = new HashMap<>();
public <T extends DomainEvent> void register(String eventType, EventHandler<T> handler) {
handlers.computeIfAbsent(eventType, k -> new ArrayList<>()).add(handler);
}
public void dispatch(DomainEvent event) {
handlers.getOrDefault(event.getEventType(), List.of())
.forEach(h -> h.handle(event));
}
public void dispatchAll(List<DomainEvent> events) {
events.forEach(this::dispatch);
}
}
// 注册示例
dispatcher.register("order.created", new OrderCreatedHandler());
dispatcher.register("order.confirmed", new OrderConfirmedHandler());
dispatcher.register("order.shipped", new SendShippingNotificationHandler());Test Coverage Targets
分层覆盖率目标
| 层 | 覆盖率目标 | 关键覆盖点 | 工具建议 |
|---|---|---|---|
| Value Object | ≥ 95% | 构造验证、运算逻辑、等值比较、不变式 | JaCoCo / Istanbul |
| Aggregate Root | ≥ 95% | 状态转移(每个路径)、不变量、领域事件 | JaCoCo / Istanbul |
| Domain Service | ≥ 90% | 跨实体编排、外部数据获取 + 计算 | JaCoCo / Istanbul |
| Application | ≥ 80% | Use Case 完整路径 + 异常路径 | JaCoCo / Istanbul |
| Repository | ≥ 80% | 保存 + 加载聚合完整性、N+1 查询 | JaCoCo / Istanbul |
| Adapter (API) | ≥ 70% | 协议转换、参数校验、错误映射 | JaCoCo / Istanbul |
| E2E | 关键路径 | 核心用户旅程(3-5 个) | Cucumber / Cypress |
代码行 vs 分支覆盖率
// 代码行覆盖率 100%,但分支覆盖率 50%
public void pay(Order order) {
if (order.canBePaid()) { // 分支 1: true
order.setStatus(PAID); // 只测了 true 分支
}
// 分支 2: false — 没测试
}建议:优先追踪分支覆盖率(Branch Coverage),Domain 层的分支覆盖率目标 ≥ 90%。
测试优先级矩阵
| 优先级 | 覆盖内容 | 比例 |
|---|---|---|
| P0 — 必须覆盖 | 聚合根状态转移、领域事件、不变量(invariant) | 60% |
| P1 — 建议覆盖 | 领域服务编排、Value Object 运算、仓储加载 | 25% |
| P2 — 按需覆盖 | Application Service 路径、API 错误映射 | 10% |
| P3 — 可选覆盖 | 异常消息格式、日志输出 | 5% |
覆盖率检查配置
JaCoCo (Java)
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>**/dto/**</exclude>
<exclude>**/config/**</exclude>
</excludes>
<rules>
<rule>
<element>PACKAGE</element>
<limits>
<limit>
<counter>BRANCH</counter>
<value>COVEREDRATIO</value>
<minimum>0.80</minimum>
</limit>
</limits>
</rule>
</rules>
</configuration>
</plugin>Istanbul (TypeScript/JavaScript)
{
"nyc": {
"branches": 80,
"lines": 85,
"functions": 85,
"exclude": ["**/*.dto.ts", "**/config/**"]
}
}Test-Driven Domain Design (TDDD)
What is TDDD?
TDDD = Test-Driven Development applied to DDD domain modeling. The key difference from classical TDD:
| Aspect | Classical TDD | TDDD |
|---|---|---|
| Test unit | Method / Function | Domain behavior (state transition) |
| Focus | Implementation correctness | Business invariant preservation |
| Refactor target | Code structure | Domain model (extract VO, identify aggregate) |
| Mock scope | System dependencies | Domain ports (Repository, Gateway) |
| Test language | Technical (assertEquals) | Business (assertOrderCanBePaid) |
| Test naming | testAdd() | pay_should_change_status_to_paid_when_draft |
TDDD Workflow
┌─────────────────────────────────────────────────────────┐
│ Step 1: RED — Write a failing business behavior test │
│ - Test a domain invariant or state transition │
│ - No Mock, no Spring, pure domain logic │
│ - Example: "a paid order cannot be paid again" │
└─────────────────────────┬───────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ Step 2: GREEN — Write minimal domain code to pass │
│ - Only implement the current test's behavior │
│ - Do NOT add extra "nice to have" logic │
│ - Do NOT think about persistence or framework │
└─────────────────────────┬───────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ Step 3: REFACTOR — Improve domain model │
│ - Extract Value Objects (e.g., Money, OrderStatus) │
│ - Identify aggregate boundaries │
│ - Add Repository interfaces (still no implementation) │
│ - Rename to ubiquitous language terms │
└─────────────────────────┬───────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ Step 4: REPEAT — Cover all state transitions │
│ - Each state transition = one test class │
│ - Happy path + all boundary conditions │
│ - Domain events verified for each transition │
└─────────────────────────────────────────────────────────┘TDDD Complete Example: Order Payment
Step 1: RED — Write the test
class OrderPayTest {
@Test
void pay_should_change_status_to_paid_when_draft() {
Order order = Order.create(CustomerId.from("cust-1"));
order.pay();
assertEquals(OrderStatus.PAID, order.getStatus());
}
@Test
void pay_should_emit_orderPaid_event() {
Order order = Order.create(CustomerId.from("cust-1"));
order.pay();
assertThat(order.getDomainEvents())
.anyMatch(e -> e instanceof OrderPaidEvent);
}
@Test
void pay_should_fail_when_already_paid() {
Order order = Order.create(CustomerId.from("cust-1"));
order.pay();
assertThrows(OrderException.class, order::pay);
}
@Test
void pay_should_fail_when_cancelled() {
Order order = Order.create(CustomerId.from("cust-1"));
order.cancel("test");
assertThrows(OrderException.class, order::pay);
}
}Step 2: GREEN — Minimal domain code
public class Order {
private OrderStatus status = OrderStatus.DRAFT;
private final List<DomainEvent> events = new ArrayList<>();
private final OrderId id;
private final CustomerId customerId;
private Order(CustomerId customerId) {
this.id = OrderId.generate();
this.customerId = customerId;
events.add(new OrderCreatedEvent(this.id, this.customerId));
}
public static Order create(CustomerId customerId) {
return new Order(customerId);
}
public void pay() {
if (status == OrderStatus.PAID) {
throw new OrderException("Order already paid");
}
if (status == OrderStatus.CANCELLED) {
throw new OrderException("Cancelled order cannot be paid");
}
this.status = OrderStatus.PAID;
events.add(new OrderPaidEvent(this.id));
}
public void cancel(String reason) {
if (status == OrderStatus.SHIPPED) {
throw new OrderException("Cannot cancel shipped order");
}
this.status = OrderStatus.CANCELLED;
events.add(new OrderCancelledEvent(this.id, reason));
}
// getters...
}Step 3: REFACTOR — Extract concepts
// Extract status machine
public enum OrderStatus {
DRAFT(true, true, false, false),
PAID(false, false, true, false),
SHIPPED(false, false, false, false),
CANCELLED(false, true, false, false);
private final boolean canPay;
private final boolean canCancel;
private final boolean canShip;
private final boolean canRefund;
OrderStatus(boolean canPay, boolean canCancel, boolean canShip, boolean canRefund) {
this.canPay = canPay;
this.canCancel = canCancel;
this.canShip = canShip;
this.canRefund = canRefund;
}
// accessors...
}
// Simplify pay() method
public void pay() {
if (!status.canPay()) {
throw new OrderException("Order cannot be paid in status: " + status);
}
this.status = OrderStatus.PAID;
events.add(new OrderPaidEvent(this.id));
}
// Add Repository interface
public interface OrderRepository {
Order save(Order order);
Optional<Order> findById(OrderId id);
}Step 4: REPEAT — Cover cancel, ship, refund transitions
class OrderCancelTest {
@Test void cancel_changes_status_when_draft() { ... }
@Test void cancel_emits_orderCancelled_event() { ... }
@Test void cancel_fails_when_shipped() { ... }
}
class OrderShipTest {
@Test void ship_changes_status_when_paid() { ... }
@Test void ship_emits_orderShipped_event() { ... }
@Test void ship_fails_when_draft() { ... }
}
class OrderRefundTest {
@Test void refund_changes_status_when_paid() { ... }
@Test void refund_emits_orderRefunded_event() { ... }
@Test void refund_fails_when_draft() { ... }
}When to Use TDDD
| Use TDDD | Don't Use TDDD |
|---|---|
| Building a new aggregate root | Implementing simple CRUD |
| Complex business rules with state machines | Writing Repository implementations |
| Domain logic with multiple invariants | Configuring framework dependencies |
| Event Sourcing aggregate design | UI / API layer development |
| When you're unsure about model boundaries | When domain model is already stable |
Testing Patterns
Sources:
- The Clean Architecture — Robert C. Martin
- Hexagonal Architecture — Alistair Cockburn
- Unit Testing — Martin Fowler
- Test Pyramid — Martin Fowler
Testing strategies for Clean Architecture + DDD + Hexagonal systems.
Testing Pyramid
%%{init: {'theme': 'base', 'themeVariables': { 'fontSize': '14px'}}}%%
flowchart TB
subgraph Pyramid["Testing Pyramid"]
E2E["E2E Tests\nFew, slow, expensive"]
Integration["Integration Tests\nSome, moderate speed"]
Unit["Unit Tests (Domain & Application)\nMany, fast, cheap"]
end
E2E --- Integration
Integration --- Unit
style E2E fill:#ef4444,stroke:#dc2626,color:white
style Integration fill:#f59e0b,stroke:#d97706,color:white
style Unit fill:#10b981,stroke:#059669,color:white---
Unit Tests
Domain Layer Tests
Test business logic in isolation. No mocks needed—domain has no dependencies.
// tests/domain/order/order.test.ts
describe('Order', () => {
describe('create', () => {
it('creates order with draft status', () => {
const customerId = CustomerId.from('cust-123');
const order = Order.create(customerId);
expect(order.status).toBe(OrderStatus.Draft);
expect(order.customerId).toEqual(customerId);
expect(order.items).toHaveLength(0);
});
it('emits OrderCreated event', () => {
const customerId = CustomerId.from('cust-123');
const order = Order.create(customerId);
expect(order.domainEvents).toHaveLength(1);
expect(order.domainEvents[0]).toBeInstanceOf(OrderCreated);
});
});
describe('addItem', () => {
it('adds item to order', () => {
const order = createDraftOrder();
const productId = ProductId.from('prod-123');
const quantity = Quantity.create(2);
const price = Money.create(10.00, 'USD');
order.addItem(productId, quantity, price);
expect(order.items).toHaveLength(1);
expect(order.items[0].productId).toEqual(productId);
expect(order.items[0].quantity).toEqual(quantity);
});
it('increases quantity for existing product', () => {
const order = createDraftOrder();
const productId = ProductId.from('prod-123');
const price = Money.create(10.00, 'USD');
order.addItem(productId, Quantity.create(2), price);
order.addItem(productId, Quantity.create(3), price);
expect(order.items).toHaveLength(1);
expect(order.items[0].quantity.value).toBe(5);
});
it('throws when order is cancelled', () => {
const order = createCancelledOrder();
expect(() => {
order.addItem(ProductId.from('prod-123'), Quantity.create(1), Money.create(10, 'USD'));
}).toThrow(InvalidOrderStateError);
});
it('throws when quantity is zero', () => {
const order = createDraftOrder();
expect(() => {
order.addItem(ProductId.from('prod-123'), Quantity.create(0), Money.create(10, 'USD'));
}).toThrow(InvalidQuantityError);
});
});
describe('confirm', () => {
it('changes status to confirmed', () => {
const order = createOrderWithItems();
order.confirm();
expect(order.status).toBe(OrderStatus.Confirmed);
});
it('emits OrderConfirmed event', () => {
const order = createOrderWithItems();
order.confirm();
const events = order.domainEvents.filter(e => e instanceof OrderConfirmed);
expect(events).toHaveLength(1);
});
it('throws when order is empty', () => {
const order = createDraftOrder();
expect(() => order.confirm()).toThrow(EmptyOrderError);
});
it('throws when already confirmed', () => {
const order = createConfirmedOrder();
expect(() => order.confirm()).toThrow(InvalidOrderStateError);
});
});
describe('total', () => {
it('calculates total from all items', () => {
const order = createDraftOrder();
order.addItem(ProductId.from('p1'), Quantity.create(2), Money.create(10, 'USD'));
order.addItem(ProductId.from('p2'), Quantity.create(1), Money.create(25, 'USD'));
expect(order.total.amount).toBe(45); // 2*10 + 1*25
});
it('returns zero for empty order', () => {
const order = createDraftOrder();
expect(order.total.amount).toBe(0);
});
});
});
// Test helpers (builders)
function createDraftOrder(): Order {
return Order.create(CustomerId.from('cust-123'));
}
function createOrderWithItems(): Order {
const order = createDraftOrder();
order.addItem(ProductId.from('prod-123'), Quantity.create(1), Money.create(10, 'USD'));
return order;
}
function createConfirmedOrder(): Order {
const order = createOrderWithItems();
order.setShippingAddress(createTestAddress());
order.confirm();
return order;
}
function createCancelledOrder(): Order {
const order = createOrderWithItems();
order.cancel('Test cancellation');
return order;
}Value Object Tests
// tests/domain/shared/money.test.ts
describe('Money', () => {
describe('create', () => {
it('creates money with valid amount', () => {
const money = Money.create(10.50, 'USD');
expect(money.amount).toBe(10.50);
expect(money.currency).toBe('USD');
});
it('throws for negative amount', () => {
expect(() => Money.create(-1, 'USD')).toThrow(InvalidMoneyError);
});
});
describe('add', () => {
it('adds two money values with same currency', () => {
const a = Money.create(10, 'USD');
const b = Money.create(20, 'USD');
const result = a.add(b);
expect(result.amount).toBe(30);
expect(result.currency).toBe('USD');
});
it('throws for different currencies', () => {
const usd = Money.create(10, 'USD');
const eur = Money.create(10, 'EUR');
expect(() => usd.add(eur)).toThrow(CurrencyMismatchError);
});
});
describe('equality', () => {
it('equals money with same amount and currency', () => {
const a = Money.create(10, 'USD');
const b = Money.create(10, 'USD');
expect(a.equals(b)).toBe(true);
});
it('not equal with different amount', () => {
const a = Money.create(10, 'USD');
const b = Money.create(20, 'USD');
expect(a.equals(b)).toBe(false);
});
});
});Application Layer Tests
Test use cases with mocked ports.
// tests/application/place_order/handler.test.ts
describe('PlaceOrderHandler', () => {
let handler: PlaceOrderHandler;
let orderRepo: MockOrderRepository;
let productRepo: MockProductRepository;
let eventPublisher: MockEventPublisher;
beforeEach(() => {
orderRepo = new MockOrderRepository();
productRepo = new MockProductRepository();
eventPublisher = new MockEventPublisher();
handler = new PlaceOrderHandler(orderRepo, productRepo, eventPublisher);
});
it('creates order with items and saves', async () => {
productRepo.addProduct(createTestProduct('prod-1', 10.00));
productRepo.addProduct(createTestProduct('prod-2', 20.00));
const command: PlaceOrderCommand = {
customerId: 'cust-123',
items: [
{ productId: 'prod-1', quantity: 2 },
{ productId: 'prod-2', quantity: 1 },
],
};
const orderId = await handler.handle(command);
expect(orderId).toBeDefined();
const savedOrder = await orderRepo.findById(OrderId.from(orderId));
expect(savedOrder).not.toBeNull();
expect(savedOrder!.items).toHaveLength(2);
expect(savedOrder!.total.amount).toBe(40); // 2*10 + 1*20
});
it('publishes domain events', async () => {
productRepo.addProduct(createTestProduct('prod-1', 10.00));
const command: PlaceOrderCommand = {
customerId: 'cust-123',
items: [{ productId: 'prod-1', quantity: 1 }],
};
await handler.handle(command);
expect(eventPublisher.publishedEvents).toHaveLength(1);
expect(eventPublisher.publishedEvents[0]).toBeInstanceOf(OrderCreated);
});
it('throws when product not found', async () => {
const command: PlaceOrderCommand = {
customerId: 'cust-123',
items: [{ productId: 'nonexistent', quantity: 1 }],
};
await expect(handler.handle(command)).rejects.toThrow(ProductNotFoundError);
});
it('rolls back on error', async () => {
productRepo.addProduct(createTestProduct('prod-1', 10.00));
orderRepo.simulateErrorOnSave();
const command: PlaceOrderCommand = {
customerId: 'cust-123',
items: [{ productId: 'prod-1', quantity: 1 }],
};
await expect(handler.handle(command)).rejects.toThrow();
expect(orderRepo.savedOrders).toHaveLength(0);
});
});
// Mock implementations
class MockOrderRepository implements IOrderRepository {
savedOrders: Order[] = [];
private shouldError = false;
async findById(id: OrderId): Promise<Order | null> {
return this.savedOrders.find(o => o.id.equals(id)) ?? null;
}
async save(order: Order): Promise<void> {
if (this.shouldError) {
throw new Error('Simulated save error');
}
this.savedOrders.push(order);
}
async delete(order: Order): Promise<void> {
const index = this.savedOrders.findIndex(o => o.id.equals(order.id));
if (index >= 0) {
this.savedOrders.splice(index, 1);
}
}
simulateErrorOnSave(): void {
this.shouldError = true;
}
}
class MockEventPublisher implements IEventPublisher {
publishedEvents: DomainEvent[] = [];
async publish(event: DomainEvent): Promise<void> {
this.publishedEvents.push(event);
}
async publishAll(events: DomainEvent[]): Promise<void> {
this.publishedEvents.push(...events);
}
}---
Integration Tests
Test adapters with real infrastructure (databases, message brokers).
// tests/integration/postgres/order_repository.test.ts
describe('PostgresOrderRepository', () => {
let pool: Pool;
let repository: PostgresOrderRepository;
beforeAll(async () => {
pool = new Pool({ connectionString: process.env.TEST_DATABASE_URL });
repository = new PostgresOrderRepository(pool);
});
beforeEach(async () => {
await pool.query('TRUNCATE orders, order_items CASCADE');
});
afterAll(async () => {
await pool.end();
});
describe('save and findById', () => {
it('persists and retrieves order', async () => {
const order = Order.create(CustomerId.from('cust-123'));
order.addItem(ProductId.from('prod-1'), Quantity.create(2), Money.create(10, 'USD'));
await repository.save(order);
const retrieved = await repository.findById(order.id);
expect(retrieved).not.toBeNull();
expect(retrieved!.id.value).toBe(order.id.value);
expect(retrieved!.items).toHaveLength(1);
expect(retrieved!.items[0].quantity.value).toBe(2);
});
it('updates existing order', async () => {
const order = Order.create(CustomerId.from('cust-123'));
order.addItem(ProductId.from('prod-1'), Quantity.create(1), Money.create(10, 'USD'));
await repository.save(order);
order.addItem(ProductId.from('prod-2'), Quantity.create(3), Money.create(20, 'USD'));
await repository.save(order);
const retrieved = await repository.findById(order.id);
expect(retrieved!.items).toHaveLength(2);
});
it('returns null for nonexistent order', async () => {
const result = await repository.findById(OrderId.from('nonexistent'));
expect(result).toBeNull();
});
});
describe('delete', () => {
it('removes order from database', async () => {
const order = Order.create(CustomerId.from('cust-123'));
await repository.save(order);
await repository.delete(order);
const retrieved = await repository.findById(order.id);
expect(retrieved).toBeNull();
});
});
});API Integration Tests
// tests/integration/http/orders_api.test.ts
describe('Orders API', () => {
let app: Express;
let pool: Pool;
beforeAll(async () => {
pool = new Pool({ connectionString: process.env.TEST_DATABASE_URL });
app = createApp(pool); // Configures real repositories
});
beforeEach(async () => {
await db.truncate("orders", "order_items", "products");
await db.products.insertMany([
{ id: "prod-1", name: "Product 1", price: 1000 },
{ id: "prod-2", name: "Product 2", price: 2000 }
]);
});
afterAll(async () => {
await pool.end();
});
describe('POST /orders', () => {
it('creates order and returns 201', async () => {
const response = await request(app)
.post('/orders')
.send({
customer_id: 'cust-123',
items: [
{ product_id: 'prod-1', quantity: 2 },
{ product_id: 'prod-2', quantity: 1 },
],
});
expect(response.status).toBe(201);
expect(response.body.id).toBeDefined();
});
it('returns 400 for invalid product', async () => {
const response = await request(app)
.post('/orders')
.send({
customer_id: 'cust-123',
items: [{ product_id: 'nonexistent', quantity: 1 }],
});
expect(response.status).toBe(400);
expect(response.body.error).toContain('Product not found');
});
});
describe('GET /orders/:id', () => {
it('returns order details', async () => {
const createResponse = await request(app)
.post('/orders')
.send({
customer_id: 'cust-123',
items: [{ product_id: 'prod-1', quantity: 2 }],
});
const orderId = createResponse.body.id;
const response = await request(app).get(`/orders/${orderId}`);
expect(response.status).toBe(200);
expect(response.body.id).toBe(orderId);
expect(response.body.items).toHaveLength(1);
});
it('returns 404 for nonexistent order', async () => {
const response = await request(app).get('/orders/nonexistent');
expect(response.status).toBe(404);
});
});
});---
Architecture Tests
Verify architectural rules are followed.
// tests/architecture/dependency_rules.test.ts
import { filesOfProject } from 'ts-arch';
describe('Architecture', () => {
describe('Dependency Rules', () => {
it('domain should not depend on application', async () => {
const rule = filesOfProject()
.inFolder('domain')
.shouldNot()
.dependOnFiles()
.inFolder('application');
await expect(rule).toPassAsync();
});
it('domain should not depend on infrastructure', async () => {
const rule = filesOfProject()
.inFolder('domain')
.shouldNot()
.dependOnFiles()
.inFolder('infrastructure');
await expect(rule).toPassAsync();
});
it('application should not depend on infrastructure', async () => {
const rule = filesOfProject()
.inFolder('application')
.shouldNot()
.dependOnFiles()
.inFolder('infrastructure');
await expect(rule).toPassAsync();
});
it('domain should have no external framework dependencies', async () => {
const rule = filesOfProject()
.inFolder('domain')
.shouldNot()
.dependOnFiles()
.matchingPattern('node_modules/(express|pg|axios|typeorm)/');
await expect(rule).toPassAsync();
});
});
describe('Naming Conventions', () => {
it('repositories should be named *Repository', async () => {
const rule = filesOfProject()
.inFolder('domain/**/repository')
.should()
.matchPattern('.*Repository\\.ts$');
await expect(rule).toPassAsync();
});
it('domain events should be named in past tense', async () => {
const rule = filesOfProject()
.inFolder('domain/**/events')
.should()
.matchPattern('.*(Created|Updated|Deleted|Confirmed|Shipped|Cancelled)\\.ts$');
await expect(rule).toPassAsync();
});
});
});---
Test Organization
tests/
├── unit/
│ ├── domain/
│ │ ├── order/
│ │ │ ├── order.test.ts
│ │ │ ├── order_item.test.ts
│ │ │ └── value_objects.test.ts
│ │ └── shared/
│ │ ├── money.test.ts
│ │ └── email.test.ts
│ └── application/
│ ├── place_order/
│ │ └── handler.test.ts
│ └── confirm_order/
│ └── handler.test.ts
├── integration/
│ ├── persistence/
│ │ └── postgres_order_repository.test.ts
│ ├── messaging/
│ │ └── rabbitmq_event_publisher.test.ts
│ └── http/
│ └── orders_api.test.ts
├── e2e/
│ └── order_workflow.test.ts
├── architecture/
│ └── dependency_rules.test.ts
├── fixtures/
│ ├── order_fixtures.ts
│ └── product_fixtures.ts
└── helpers/
├── test_database.ts
└── mock_factories.ts---
Test Fixtures & Builders
// tests/fixtures/order_fixtures.ts
export class OrderBuilder {
private customerId: CustomerId = CustomerId.from('default-customer');
private items: Array<{ productId: ProductId; quantity: Quantity; price: Money }> = [];
private status: 'draft' | 'confirmed' | 'shipped' | 'cancelled' = 'draft';
withCustomer(id: string): this {
this.customerId = CustomerId.from(id);
return this;
}
withItem(productId: string, quantity: number, price: number): this {
this.items.push({
productId: ProductId.from(productId),
quantity: Quantity.create(quantity),
price: Money.create(price, 'USD'),
});
return this;
}
confirmed(): this {
this.status = 'confirmed';
return this;
}
build(): Order {
const order = Order.create(this.customerId);
for (const item of this.items) {
order.addItem(item.productId, item.quantity, item.price);
}
if (this.status === 'confirmed') {
order.setShippingAddress(new AddressBuilder().build());
order.confirm();
}
order.clearEvents(); // Clear events from building
return order;
}
}
// Usage
const order = new OrderBuilder()
.withCustomer('cust-123')
.withItem('prod-1', 2, 10.00)
.withItem('prod-2', 1, 25.00)
.confirmed()
.build();---
Key Testing Principles
1. Test behavior, not implementation - Focus on what, not how 2. Domain tests need no mocks - Domain layer is pure 3. Mock at port boundaries - Application tests mock driven ports 4. Integration tests use real infra - Test actual database, message broker 5. Fast unit tests, slower integration - Run unit tests frequently 6. Test business rules in domain - Not in application or infrastructure
Unit Testing Strategies — Domain Layer
Value Object Tests
Value objects are immutable, self-validating types. Tests focus on construction validation, behavior (arithmetic/operations), and equality.
Construction Validation
@Test void money_throws_for_negative_amount() {
assertThrows(IllegalArgumentException.class, () -> new Money(-1, "USD"));
}
@Test void money_throws_for_null_currency() {
assertThrows(NullPointerException.class, () -> new Money(10, null));
}
@Test void money_creates_successfully_with_valid_input() {
assertDoesNotThrow(() -> new Money(10.50, "USD"));
}Behavior Tests
@Test void money_add_returns_sum_same_currency() {
assertEquals(new Money(30, "USD"), new Money(10, "USD").add(new Money(20, "USD")));
}
@Test void money_add_throws_for_different_currencies() {
assertThrows(IllegalArgumentException.class,
() -> new Money(10, "USD").add(new Money(20, "CNY")));
}
@Test void money_multiply_scales_correctly() {
assertEquals(new Money(25, "USD"), new Money(5, "USD").multiply(5));
}
@Test void money_multiply_throws_for_negative_factor() {
assertThrows(IllegalArgumentException.class,
() -> new Money(10, "USD").multiply(-1));
}Equality Tests
@Test void money_equals_same_amount_and_currency() {
assertEquals(new Money(10, "USD"), new Money(10, "USD"));
}
@Test void money_not_equals_different_amount() {
assertNotEquals(new Money(10, "USD"), new Money(20, "USD"));
}
@Test void money_not_equals_different_currency() {
assertNotEquals(new Money(10, "USD"), new Money(10, "CNY"));
}Aggregate Root Tests
Test business rules, state transitions, invariants, and domain event emission. Each state transition = one test class.
Order State Transition Tests
class OrderPayTest {
@Test void pay_changes_status_to_paid_when_draft() {
Order order = Order.create(customerId, items);
order.pay(mockGateway);
assertEquals(OrderStatus.PAID, order.getStatus());
}
@Test void pay_emits_orderPaidEvent() {
Order order = Order.create(customerId, items);
order.pay(mockGateway);
assertTrue(order.getDomainEvents().stream()
.anyMatch(e -> e instanceof OrderPaidEvent));
}
@Test void pay_throws_when_already_paid() {
Order order = createPaidOrder();
assertThrows(OrderException.class, () -> order.pay(mockGateway));
}
@Test void pay_throws_when_cancelled() {
Order order = Order.create(customerId, items);
order.cancel("test");
assertThrows(OrderException.class, () -> order.pay(mockGateway));
}
@Test void pay_records_payment_details() {
Order order = Order.create(customerId, items);
order.pay(mockGateway);
assertNotNull(order.getPaidAt());
assertEquals(PaymentMethod.WECHAT, order.getPaymentMethod());
}
}
class OrderCancelTest {
@Test void cancel_changes_status_when_draft() {
Order order = Order.create(customerId, items);
order.cancel("Customer request");
assertEquals(OrderStatus.CANCELLED, order.getStatus());
}
@Test void cancel_emits_orderCancelledEvent() {
Order order = Order.create(customerId, items);
order.cancel("Customer request");
assertTrue(order.getDomainEvents().stream()
.anyMatch(e -> e instanceof OrderCancelledEvent));
}
@Test void cancel_throws_when_already_shipped() {
Order order = createShippedOrder();
assertThrows(OrderException.class, () -> order.cancel("test"));
}
}Invariant Tests
class OrderInvariantTest {
@Test void total_must_equal_sum_of_item_subtotals() {
Order order = Order.create(customerId, List.of(
new OrderItem(ProductId.from("p1"), Quantity.of(2), new Money(10, "USD")),
new OrderItem(ProductId.from("p2"), Quantity.of(1), new Money(25, "USD"))
));
assertEquals(new Money(45, "USD"), order.calculateTotal());
}
@Test void cannot_add_item_after_shipped() {
Order order = createShippedOrder();
assertThrows(OrderException.class,
() -> order.addItem(ProductId.from("p3"), Quantity.of(1), new Money(10, "USD")));
}
@Test void order_without_items_cannot_be_confirmed() {
Order order = Order.create(customerId);
assertThrows(OrderException.class, order::confirm);
}
}Domain Service Tests
Test cross-entity business logic with mocked repositories. Do NOT mock other domain services.
class PricingServiceTest {
private PricingService pricingService;
private OrderRepository orderRepository;
@Test void applies_vip_discount_for_vip_customer() {
Order order = Order.create(vipCustomerId, items);
when(orderRepository.findById(order.getId())).thenReturn(Optional.of(order));
pricingService.calculatePrice(order.getId());
assertEquals(new Money(90, "USD"), order.getTotalAmount()); // 10% off
}
@Test void applies_no_discount_for_regular_customer() {
Order order = Order.create(regularCustomerId, items);
when(orderRepository.findById(order.getId())).thenReturn(Optional.of(order));
pricingService.calculatePrice(order.getId());
assertEquals(new Money(100, "USD"), order.getTotalAmount()); // full price
}
@Test void applies_seasonal_discount_when_active() {
Order order = Order.create(customerId, items);
when(orderRepository.findById(order.getId())).thenReturn(Optional.of(order));
when(discountPolicy.isSeasonalActive()).thenReturn(true);
pricingService.calculatePrice(order.getId());
assertTrue(order.getDomainEvents().stream()
.anyMatch(e -> e instanceof SeasonalDiscountAppliedEvent));
}
}Test Naming Convention
describe '[Aggregate/Component]'
describe '[Method/Behavior]'
it '[expected outcome] when [condition]'Examples:
OrderPayTest.pay_throws_when_already_paidMoney.add_returns_sum_same_currencyPricingService.applies_vip_discount_for_vip_customer