Mở một bộ test của hệ thống đơn hàng sau hai năm phát triển, ta thường gặp cùng một cảnh: mỗi test dài ba mươi dòng, hai mươi dòng đầu chỉ để dựng Customer, Address, OrderLine rồi ráp vào Order, còn đúng một dòng chứa điều test thật sự muốn kiểm tra. Khi constructor của Order thêm một tham số, hàng trăm test vỡ biên dịch. Bài này trình bày Test Data Builder, cách Nat Pryce và Steve Freeman đề xuất để dựng dữ liệu test ngắn gọn và bền, đặt cạnh Object Mother và năm loại test double (dummy, stub, spy, mock, fake). Toàn bộ code chạy thật trên JDK 21 với JUnit 5.10.5, Mockito 5.11.0 và AssertJ 3.25.3; output trong bài được dán nguyên từ lần chạy đó.
Bài nối tiếp Anti-pattern trong Java: God object, anemic model, service locator và Virtual threads trong Java 21: khi nào thay thread pool. Nếu bạn chưa quen cách tạo đối tượng bằng Builder, hãy xem lại Builder pattern: từ Effective Java tới Lombok và record, vì Test Data Builder là một biến thể của nó với mục đích khác hẳn.
Vấn đề: dựng dữ liệu test bằng constructor
Giả sử OrderService.checkout kiểm tra đơn, trừ kho, áp giảm giá (VIP giảm 5%, mã GIAM10 giảm 10%, lấy mức cao hơn chứ không cộng dồn) rồi gọi cổng thanh toán. Đối tượng Order là một record có bảy thành phần. Test đầu tiên ta viết thường trông như sau (file NaiveOrderServiceTest.java):
Clock clock = Clock.fixed(Instant.parse("2026-07-01T03:00:00Z"), ZoneOffset.UTC);
Customer customer = new Customer("C1", "Nguyễn Văn An", "an@example.test",
new Address("12 Lê Lợi", "Hà Nội"), true);
Order order = new Order("DH001", customer,
List.of(new OrderLine("SKU-1", 2, new Money(new BigDecimal("100000")))),
null, Instant.parse("2026-07-01T02:55:00Z"), OrderStatus.NEW, null);
OrderService service = new OrderService(sku -> 100, (id, amount) -> new PaymentResult(true, "R"),
new FakeOrderRepository(), (email, msg) -> { }, clock);
assertThat(service.applyDiscount(order)).isEqualTo(Money.vnd(190000));Test này có ba vấn đề, và chúng nặng dần theo thời gian:
- Nhiễu: người đọc phải đọc cả địa chỉ, ngày tạo, ghi chú giao hàng để tìm ra điều duy nhất quan trọng, đó là
vip = true. - Giòn: thêm trường
shippingNotevàoOrderbuộc sửa mọi lời gọinew Order(...)trong toàn bộ test. - Lặp: hai mươi test cho hai mươi biến thể của đơn hàng nghĩa là hai mươi bản sao đoạn dựng này, mỗi bản lệch nhau một chút.
Hai mẫu thiết kế ra đời để giải quyết: Object Mother (khoảng đầu những năm 2000) và Test Data Builder (Nat Pryce, 2007). Mẫu thứ hai được phổ biến rộng trong cuốn Growing Object-Oriented Software, Guided by Tests của Freeman và Pryce.
Object Mother: nhà máy dữ liệu có sẵn
Theo mô tả của Martin Fowler, Object Mother là một lớp trong code test, chuyên tạo các đối tượng mẫu (một nhân viên chuẩn, một khách hàng chuẩn) để test khỏi tự dựng từ đầu; tên gọi xuất hiện trên một dự án ThoughtWorks khoảng đầu thập niên 2000. Cài đặt đơn giản là một lớp toàn phương thức tĩnh, mỗi kịch bản một phương thức:
/** Object Mother: mỗi kịch bản một phương thức tĩnh. */
final class OrderMother {
private OrderMother() {}
static final Instant NOW = Instant.parse("2026-07-01T03:00:00Z");
static Customer regularCustomer() {
return new Customer("C1", "Nguyễn Văn An", "an@example.test", new Address("12 Lê Lợi", "Hà Nội"), false);
}Test dùng nó trở nên rất ngắn: OrderMother.vipOrder(). Nhưng chính sự ngắn gọn đó che giấu hai điểm yếu. Thứ nhất, số phương thức bùng nổ theo tổ hợp: ta đã có vipOrder, orderWithCoupon, vipOrderWithCoupon, và mỗi trường mới (đơn quá hạn, đơn không có dòng, đơn của khách ở Hà Nội) nhân số phương thức lên. Thứ hai, test không còn nói rõ nó phụ thuộc dữ liệu nào: vipOrder() có hai dòng hay một dòng, giá bao nhiêu? Muốn biết phải mở Mother. Fowler cũng nêu đúng vấn đề này: nhiều test gắn chặt vào dữ liệu cụ thể của Mother, nên sửa dữ liệu chuẩn rất rủi ro, và khi lớp miền đổi thì test phải di chuyển theo.
Nat Pryce diễn đạt gọn hơn trong bài gốc: mỗi khi cần dữ liệu khác một chút, lập trình viên thêm một phương thức vào Mother, kết quả là một lớp hoặc bị lặp mã hoặc đầy phương thức nhỏ. Ông đề xuất dùng Builder thay thế.
Test Data Builder: mặc định hợp lệ, chỉ ghi đè điều quan trọng
Ý tưởng của Test Data Builder gói trong một câu: Test Data Builder chứa sẵn giá trị mặc định hợp lệ cho mọi trường, và mỗi test chỉ gọi những phương thức with... cho đúng trường mà kịch bản test quan tâm. Bài phân tích về test data builders của QWAN nhấn mạnh cùng điểm: constructor đầy đủ chôn vùi giá trị quan trọng, còn Test Data Builder giúp test tập trung vào khái niệm đang kiểm tra. Đây là Test Data Builder cho Customer:
static CustomerBuilder aCustomer() {
return new CustomerBuilder("C1", "Nguyễn Văn An", "an@example.test", new Address("12 Lê Lợi", "Hà Nội"), false);
}
CustomerBuilder withId(String v) { return new CustomerBuilder(v, name, email, address, vip); }
CustomerBuilder withName(String v) { return new CustomerBuilder(id, v, email, address, vip); }
CustomerBuilder withEmail(String v) { return new CustomerBuilder(id, name, v, address, vip); }
CustomerBuilder inCity(String city) { return new CustomerBuilder(id, name, email, new Address(address.street(), city), vip); }
CustomerBuilder vip() { return new CustomerBuilder(id, name, email, address, true); }Có ba quyết định thiết kế đáng nói:
- Hàm khởi tạo tĩnh có tên kiểu "a/an" (
aCustomer(),anOrder()). Khi import tĩnh, test đọc như tiếng Anh:anOrder().from(aCustomer().vip()). - Phương thức đặt tên theo ngữ nghĩa nghiệp vụ:
vip()vàinCity("Đà Nẵng")rõ hơnwithVip(true), vì kịch bản test nói về khái niệm chứ không nói về trường. - Bất biến: mỗi phương thức trả về một Test Data Builder mới. Lý do nằm ở mục lỗi thường gặp bên dưới.
Test Data Builder cho Order làm thêm hai việc: nhận cả Customer lẫn CustomerBuilder (nhờ vậy test viết from(aCustomer().vip()) mà không cần gọi build()), và có phương thức withOnlyLine để thay thế dòng mặc định.
static OrderBuilder anOrder() {
return new OrderBuilder("DH001", CustomerBuilder.aCustomer().build(),
List.of(new OrderLine("SKU-1", 1, Money.vnd(100000))), null, NOW, OrderStatus.NEW);
}
OrderBuilder withId(String v) { return new OrderBuilder(v, customer, lines, couponCode, createdAt, status); }
OrderBuilder from(Customer v) { return new OrderBuilder(id, v, lines, couponCode, createdAt, status); }
OrderBuilder from(CustomerBuilder v) { return from(v.build()); }
OrderBuilder withCoupon(String v) { return new OrderBuilder(id, customer, lines, v, createdAt, status); }
OrderBuilder createdAt(Instant v) { return new OrderBuilder(id, customer, lines, couponCode, v, status); }
OrderBuilder withNoLines() { return new OrderBuilder(id, customer, List.of(), couponCode, createdAt, status); }Kịch bản "khách VIP dùng mã GIAM10, mua hai món giá 100.000" giờ chỉ còn một câu đọc được:
Order order = anOrder().from(aCustomer().vip()).withCoupon("GIAM10")
.withOnlyLine("SKU-1", 2, 100_000).build();Nếu Order thêm trường shippingNote, ta sửa đúng một chỗ là build() của Test Data Builder (trong repo ví dụ, trường này được truyền null ở đó), và không test nào phải đổi. Đó là giá trị cốt lõi: tính hợp lệ mặc định được định nghĩa một lần.
So sánh trực tiếp Object Mother và Test Data Builder
Cùng một kịch bản, ba cách viết trong ObjectMotherTest.java cho kết quả giống nhau, nhưng thông tin nằm ở chỗ khác nhau:
| Tiêu chí | Object Mother | Test Data Builder |
|---|---|---|
| Độ dài một test | Rất ngắn (một lời gọi) | Ngắn (một chuỗi gọi) |
| Test có tự giải thích dữ liệu không | Không, phải mở Mother | Có, điều khác biệt hiện ngay trong test |
| Số lớp/phương thức theo số biến thể | Tăng theo tổ hợp | Tăng theo số trường |
| Thêm trường mới vào lớp miền | Sửa mọi phương thức Mother | Sửa một chỗ trong build() |
| Phù hợp | Vài kịch bản chuẩn, ít biến thể | Nhiều biến thể, đối tượng có nhiều trường |
Test Data Builder và Object Mother không loại trừ nhau. Cách kết hợp phổ biến là Object Mother trả về Test Data Builder đã cấu hình sẵn thay vì đối tượng cuối, ví dụ OrderMother.vipOrder() trả về OrderBuilder, để test vẫn ghi đè thêm chi tiết. Khi lớp miền là record bất biến (đúng như Order ở đây), Test Data Builder còn có lợi vì ta không thể "dựng rồi gọi setter" như với đối tượng khả biến.
Test double: năm vai trò, không phải một
Test Data Builder chỉ giải quyết nửa câu chuyện về dữ liệu. OrderService còn bốn collaborator: kho, cổng thanh toán, repository và dịch vụ gửi thư. Trong test ta thay chúng bằng test double. Martin Fowler, dựa theo Gerard Meszaros, phân biệt năm loại:
| Loại | Vai trò | Trong ví dụ |
|---|---|---|
| Dummy | Truyền vào cho đủ chữ ký, không bao giờ được dùng | (e, m) -> { } ở ObjectMotherTest |
| Stub | Trả về câu trả lời chuẩn bị sẵn | sku -> 100 cho InventoryClient |
| Spy | Stub có ghi lại cách bị gọi | SpyNotifier ghi danh sách thư đã gửi |
| Mock | Được nạp sẵn kỳ vọng về các lời gọi, kiểm chứng sau cùng | PaymentGateway do Mockito tạo |
| Fake | Cài đặt chạy thật nhưng đi đường tắt | FakeOrderRepository dùng HashMap |
Fake và spy viết tay trong ví dụ ngắn đến mức không cần thư viện; đây là fake (spy SpyNotifier chỉ là một List ghi lại lời gọi send, xem trong code/s6-34):
class FakeOrderRepository implements OrderRepository {
private final Map<String, Order> store = new HashMap<>();
@Override public void save(Order order) { store.put(order.id(), order); }
@Override public Optional<Order> findById(String id) { return Optional.ofNullable(store.get(id)); }Điểm đáng chú ý: fake của repository cho phép test hỏi "đơn có được lưu với trạng thái PAID không?", tức kiểm tra trạng thái cuối cùng; còn mock của cổng thanh toán cho phép hỏi "charge có được gọi đúng một lần với 950.000 không?", tức kiểm tra hành vi. Fowler gọi đó là hai phong cách khác nhau trong bài Mocks Aren't Stubs: trường phái cổ điển (classical) dùng đối tượng thật khi có thể và kiểm tra trạng thái, trường phái mockist dùng mock cho mọi collaborator có hành vi và kiểm tra tương tác. Mockist test nhỏ gọn và khoanh vùng lỗi tốt hơn, nhưng gắn chặt vào cách cài đặt nên dễ vỡ khi refactor; classical test chịu refactor tốt hơn nhưng cần dựng nhiều dữ liệu hơn, đó chính là chỗ Test Data Builder phát huy.
Ghép Test Data Builder với test double trong JUnit 5
Bộ test hoàn chỉnh dùng JUnit 5 (@Nested để nhóm theo hành vi), Mockito qua MockitoExtension và AssertJ cho assertion. Chú ý cách mỗi test chỉ nêu điều khác biệt:
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
private final Clock clock = Clock.fixed(OrderBuilder.NOW.plusSeconds(5 * 60), ZoneOffset.UTC);
private final FakeOrderRepository repository = new FakeOrderRepository(); // fake
private final SpyNotifier notifier = new SpyNotifier(); // spy
private InventoryClient inventory = sku -> 100; // stub
@Mock PaymentGateway payment; // mock
private OrderService service() {
return new OrderService(inventory, payment, repository, notifier, clock);
} @Test
void thanhToanThanhCong_luuDonVaGuiMail() {
when(payment.charge(eq("DH001"), any())).thenReturn(new PaymentResult(true, "PAY-1"));
Receipt receipt = service().checkout(anOrder().withOnlyLine("SKU-1", 2, 100_000).build());
assertThat(receipt.status()).isEqualTo(OrderStatus.PAID);
assertThat(receipt.paid()).isEqualTo(Money.vnd(200_000));
assertThat(repository.findById("DH001")).get().extracting(Order::status).isEqualTo(OrderStatus.PAID);
assertThat(notifier.sent).containsExactly("an@example.test <- Đơn DH001 đã thanh toán 200000 VND");
} @Test
void chargeDuocGoiDungMotLanVoiSoTienSauGiamGia() {
when(payment.charge(any(), any())).thenReturn(new PaymentResult(true, "PAY-2"));
service().checkout(anOrder().from(aCustomer().vip()).withOnlyLine("SKU-1", 1, 1_000_000).build());
verify(payment, times(1)).charge("DH001", Money.vnd(950_000)); // behavior verification
verifyNoMoreInteractions(payment);
}Một vài quyết định trong đoạn trên đáng giải thích:
- Kiểm tra trạng thái trước, hành vi sau. Kết quả thanh toán, trạng thái đơn lưu trong fake và thư trong spy đều là trạng thái. Chỉ riêng việc "
chargeđược gọi với số tiền sau giảm giá" mới dùngverify, vì đó là hợp đồng với hệ thống bên ngoài mà không có trạng thái nào để hỏi. - Không rải
verifyNoMoreInteractions. Tài liệu Mockito cảnh báo lạm dụng nó dẫn đến test quá chặt và khó bảo trì; ở đây nó chỉ xuất hiện ở một test nhằm khẳng định cổng thanh toán không bị gọi lần thứ hai. - Dùng giá trị thật thay vì
any()khi có thể.verify(payment).charge("DH001", Money.vnd(950_000))bắt được lỗi tính giảm giá, trong khiany()sẽ cho test xanh dù số tiền sai. - Đồng hồ là collaborator. Tiêm
Clockcố định giúp test "quá 30 phút" xác định, không phụ thuộc giờ máy.
Chạy toàn bộ bằng mvn -o test trên Apple M4, 16 GB RAM, JDK 21.0.12 cho kết quả sau (đã lược các dòng Running và thời gian; hai dòng [immutable] và [mutable] do test minh hoạ lỗi ở mục bên dưới in ra):
Tests run: 6, Failures: 0, Errors: 0, Skipped: 0 -- in demo.shop.OrderServiceTest$Checkout
Tests run: 3, Failures: 0, Errors: 0, Skipped: 0 -- in demo.shop.OrderServiceTest$TinhGiam_gia
Tests run: 3, Failures: 0, Errors: 0, Skipped: 0 -- in demo.shop.ObjectMotherTest
[immutable] số dòng a, b, c = 2, 2, 1
[mutable] số dòng sau 2 lần dùng chung = 3
Tests run: 16, Failures: 0, Errors: 0, Skipped: 1
BUILD SUCCESSCác phiên bản thực tế được khoá bởi spring-boot-starter-parent 3.3.5 chỉ để quản lý phiên bản phụ thuộc: JUnit Jupiter 5.10.5, Mockito 5.11.0, AssertJ 3.25.3 (bản mới nhất của JUnit tại thời điểm viết đã sang dòng 6, nhưng cú pháp dùng trong bài không thay đổi giữa hai dòng). Dòng Tests run: 16 ... Skipped: 1 là test minh hoạ strict stubs, chỉ bật khi truyền -Ddemo=strict.
Strict stubs: để Mockito bắt stub thừa
Khi dùng @ExtendWith(MockitoExtension.class), Mockito chạy ở chế độ strict stubs (chế độ này được tài liệu Mockito mô tả là tuỳ chọn bật qua MockitoExtension, MockitoJUnitRunner.Strict hoặc MockitoRule): stub khai báo mà không được dùng làm test thất bại. Điều này bắt được loại test "xanh giả", nơi ta chuẩn bị kịch bản cho một nhánh code rồi nhánh đó không bao giờ chạy. Để thấy tận mắt, test sau cố tình stub thừa:
@Test
void stubThua() {
when(payment.charge("DH001", Money.vnd(1))).thenReturn(new PaymentResult(true, "X"));
// test không hề gọi payment.charge
}Chạy với mvn -o test -Dtest=StrictStubsDemoTest -Ddemo=strict, Mockito báo:
org.mockito.exceptions.misusing.UnnecessaryStubbingException:
Unnecessary stubbings detected.
1. -> at demo.shop.StrictStubsDemoTest.stubThua(StrictStubsDemoTest.java:20)
Please remove unnecessary stubbings or use 'lenient' strictness. More info: javadoc for UnnecessaryStubbingException class.Cần stub thừa có chủ đích (ví dụ trong @BeforeEach dùng chung) thì đánh dấu lenient(), và tài liệu Mockito cũng lưu ý chế độ strict có thể che mất một số lỗi khác, nên đừng coi nó là lưới an toàn duy nhất.
Khi nào không nên dùng và các đánh đổi
- Đối tượng quá đơn giản: một
Moneyhai trường không cần Test Data Builder;Money.vnd(100_000)đã đủ rõ. - Test Data Builder là code phải bảo trì: QWAN thừa nhận đây là chi phí thêm, nhưng nhỏ so với lợi ích. Chi phí thực sự lớn nếu giới thiệu muộn, vì khi đó phải refactor hàng trăm test hiện có; vì vậy nên đưa vào ngay từ lúc đối tượng bắt đầu có hơn ba bốn trường.
- Giá trị mặc định chỉ để trong code test: đặt mặc định vào lớp production dễ dẫn đến việc dùng nhầm giá trị không có nghĩa nghiệp vụ.
- Đừng test riêng Test Data Builder: nó được kiểm tra gián tiếp qua các test dùng nó; nếu
build()sai, hàng loạt test đỏ cùng lúc. - Mock quá tay: mock mọi thứ khiến test phản chiếu cài đặt. Với collaborator thuần dữ liệu hoặc đã có fake đơn giản, ưu tiên đối tượng thật.
Lỗi thường gặp
Test Data Builder khả biến dùng chung giữa các test
Lỗi hay gặp nhất: khai báo một Test Data Builder mẫu static final để "đỡ tạo lại", mỗi phương thức with... sửa chính Test Data Builder rồi trả về this. Test A thêm một dòng hàng, test B thêm dòng nữa, và test C nhận về đơn có ba dòng thay vì một, tuỳ thứ tự chạy mà đỏ hoặc xanh. Test MutableBuilderPitfallTest.java tái hiện chính xác điều này:
@Test
void builderKhaBienBiNhiemBanKhiDungChung() {
SHARED_MUTABLE.withLine("SKU-2", 1, 50000); // "test A" thêm dòng
SHARED_MUTABLE.withLine("SKU-3", 1, 70000); // "test B" thêm dòng
System.out.println("[mutable] số dòng sau 2 lần dùng chung = " + SHARED_MUTABLE.lineCount());
assertThat(SHARED_MUTABLE.lineCount()).isEqualTo(3); // test C mong 1 dòng nhưng nhận 3
}
@Test
void builderBatBienKhongBiNhiem() {
Order a = SHARED_IMMUTABLE.withLine("SKU-2", 1, 50000).build();
Order b = SHARED_IMMUTABLE.withLine("SKU-3", 1, 70000).build();
Order c = SHARED_IMMUTABLE.build();
System.out.println("[immutable] số dòng a, b, c = " + a.lines().size() + ", " + b.lines().size() + ", " + c.lines().size());
assertThat(c.lines()).hasSize(1);
}Output thật của hai test nằm trong khối chạy ở trên: Test Data Builder khả biến sau hai lần dùng chung có 3 dòng, còn Test Data Builder bất biến cho 2, 2, 1 dòng cho đơn a, b, c, nghĩa là mỗi test thấy đúng thứ nó dựng và đơn mặc định c không bị nhiễm. Vì vậy Test Data Builder trong bài trả về bản sao ở mọi phương thức, và cũng nên dùng List.copyOf ngay trong constructor.
Mặc định không hợp lệ
Nếu anOrder() trả về đơn thiếu dòng hàng, mỗi test phải nhớ thêm dòng nên ta quay lại tình trạng cũ. Quy tắc: anX().build() luôn cho đối tượng hợp lệ và "bình thường nhất"; ca đặc biệt (withNoLines()) phải được gọi tên rõ.
Checklist
- Đối tượng có hơn ba bốn trường hoặc có cây lồng nhau: tạo Test Data Builder, mặc định hợp lệ, đặt tên phương thức theo ngữ nghĩa nghiệp vụ.
- Test Data Builder bất biến, mỗi
with...trả về bản mới; không giữ Test Data Builder dùng chung mà có thể sửa. - Object Mother chỉ giữ vài kịch bản chuẩn, tốt nhất trả về Test Data Builder.
- Chọn đúng double: stub cho dữ liệu vào, fake cho thứ có trạng thái, spy hoặc mock cho tương tác ra ngoài.
- Kiểm tra trạng thái là mặc định; dùng
verifycho hợp đồng với hệ thống ngoài, không rảiverifyNoMoreInteractions. - Bật strict stubs (
MockitoExtension) để bắt stub thừa. - Tiêm
Clock, không dùngnow()trong dữ liệu test. - Giữ một nhóm test với database thật bên cạnh fake.
Tóm lại, Test Data Builder không làm test ngắn đi bằng mọi giá; nó làm cho mỗi test nói rõ điều khác biệt và để việc "đối tượng hợp lệ trông như thế nào" ở một nơi duy nhất. Kết hợp với lựa chọn đúng loại test double, bộ test sống được qua nhiều đợt refactor mà không biến thành gánh nặng.
Bài tiếp theo
Khi test đã đủ tin cậy, ta có thể cải tạo code cũ mà không sợ vỡ. Mời bạn đọc tiếp Refactoring sang design pattern: một case study từ code thật.



