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 shippingNote vào Order buộc sửa mọi lời gọi new 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ơn withVip(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 MotherTest Data Builder
Độ dài một testRấ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ôngKhông, phải mở MotherCó, đ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ợpTăng theo số trường
Thêm trường mới vào lớp miềnSửa mọi phương thức MotherSửa một chỗ trong build()
Phù hợpVà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ạiVai tròTrong ví dụ
DummyTruyền vào cho đủ chữ ký, không bao giờ được dùng(e, m) -> { } ở ObjectMotherTest
StubTrả về câu trả lời chuẩn bị sẵnsku -> 100 cho InventoryClient
SpyStub có ghi lại cách bị gọiSpyNotifier 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ùngPaymentGateway do Mockito tạo
FakeCài đặt chạy thật nhưng đi đường tắtFakeOrderRepository 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ùng verify, 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 khi any() sẽ cho test xanh dù số tiền sai.
  • Đồng hồ là collaborator. Tiêm Clock cố đị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 SUCCESS

Cá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 Money hai 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 verify cho hợp đồng với hệ thống ngoài, không rải verifyNoMoreInteractions.
  • Bật strict stubs (MockitoExtension) để bắt stub thừa.
  • Tiêm Clock, không dùng now() 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.