Soldemy
0%

SOLID trong Java Spring Boot

Thiết kế để test được

35 phút đọc

Thiết kế để test được

Chương 7 kết thúc với một cấu trúc rõ ràng: EnrollmentReminderService chứa application policy; DueEnrollmentQueryReminderSender là ports; JPA cùng SMS provider nằm sau adapters. Unit tests của service chạy trong vài mili giây và đều pass. Tuy nhiên, lần deploy kế tiếp vẫn thất bại: tên cột trong JPQL projection không khớp entity, nên scheduled job không lấy được enrollment nào để gửi lời nhắc.

Phản ứng đầu tiên của team là thay mọi test bằng @SpringBootTest. Application context được nạp cho từng nhóm kịch bản, database test được khởi động, nhiều bean bị thay bằng mock. Suite bắt được một số lỗi wiring nhưng thời gian chạy tăng nhanh; những test vẫn mock repository không phát hiện được JPQL; một thay đổi configuration làm nhiều test không liên quan hỏng. Team có nhiều test hơn nhưng feedback vừa chậm vừa thiếu chính xác.

Vấn đề không nằm ở việc unit test hay integration test tốt hơn. Mỗi test đang trả lời một câu hỏi khác nhau. Unit test có thể chứng minh policy chọn đúng cửa sổ ba ngày và tổng hợp lỗi đúng; nó không thể chứng minh JPA query chạy trên PostgreSQL. Integration test có thể kiểm chứng mapping, constraint và transaction; nếu dùng nó cho mọi nhánh nghiệp vụ, feedback sẽ đắt và khó định vị. Thiết kế để test được bắt đầu từ việc đặt đúng boundary cho từng rủi ro.

Ghi chú

Test nhanh không đủ nếu nó kiểm tra sai assumption. Test rộng cũng không đủ nếu nó chậm, fragile và không cho biết phần nào đã hỏng. Mục tiêu là confidence phù hợp với chi phí feedback.

Testability là gì?

Testability là mức độ một hệ thống cho phép ta thiết lập điều kiện, kích hoạt behavior và quan sát kết quả bằng automated tests với chi phí hợp lý. Đây là một design quality, không phải số lượng test hay coverage percentage. Một class có thể đạt 100% line coverage nhưng vẫn có testability thấp nếu mỗi test cần database dùng chung, system clock, network và thứ tự chạy cố định.

Testability có thể được phân tích qua bốn khả năng. Controllability là khả năng đưa SUT vào trạng thái cần kiểm tra: cung cấp enrollment đến hạn, cố định thời gian hoặc mô phỏng lỗi tạm thời. Observability là khả năng nhìn thấy kết quả có ý nghĩa: ReminderBatchResult, record được lưu hoặc message được yêu cầu gửi. Isolation là khả năng giữ dependency ngoài phạm vi không tham gia test. Repeatability là khả năng chạy lại trong cùng điều kiện và nhận cùng kết quả, không phụ thuộc máy, timezone, network hoặc test chạy trước.

Subject Under Test (SUT) là thành phần có behavior mà test đang tập trung xác minh. SUT có thể là một object, một nhóm core objects cùng thực hiện use case, một adapter hoặc toàn ứng dụng. Xác định SUT trước giúp ta biết collaborator nào nên dùng thật, collaborator nào cần Test Double và infrastructure nào phải được đưa vào để kiểm chứng assumption.

Loose coupling và DIP cải thiện testability vì dependency được công bố và có thể thay tại boundary. Tuy nhiên, chúng không tự tạo test tốt. Một service phụ thuộc năm interface vẫn có thể khó test nếu contract mơ hồ, behavior phụ thuộc thời gian ẩn hoặc test verify từng lời gọi nội bộ. Ngược lại, một pure function dùng concrete Value Objects có thể test trực tiếp mà không cần interface nào.


Test cũng là một phần của kiến trúc

Test thường được đặt dưới src/test, không được deploy tới production và không phục vụ end user. Điều đó dễ tạo cảm giác test nằm ngoài hệ thống. Clean Architecture đưa ra góc nhìn chính xác hơn: tests là những system components rất cụ thể, phụ thuộc vào production code và tham gia dependency graph như mọi component khác. Không có production code nào nên phụ thuộc ngược vào test, nhưng cách test phụ thuộc production code quyết định suite có bền hay không.

Một test phải coupling với behavior cần bảo vệ. Khi rule đổi từ nhắc trước ba ngày thành năm ngày, test về cửa sổ thời gian phải đổi; đây là coupling có chủ đích. Vấn đề xuất hiện khi test coupling với structure không thuộc contract: tên private method, số lần gọi mapper, thứ tự các helper hoặc việc service chia thành hai internal objects. Refactor giữ nguyên observable behavior khi đó vẫn làm test hỏng.

Structural coupling là quan hệ chặt giữa cấu trúc suite và cấu trúc implementation. Dạng dễ thấy nhất là mỗi production class có một test class, mỗi public method có một test method, mọi collaborator đều bị mock và mọi lời gọi đều được verify. Cấu trúc ấy tạo cảm giác coverage có hệ thống, nhưng nó khiến việc Move Method, Extract Class hoặc thay cơ chế delegation phải sửa hàng loạt tests dù user-visible behavior không đổi.

Khi nhiều tests phụ thuộc vào volatile UI, framework configuration hoặc internal call graph, một thay đổi nhỏ có thể làm hàng trăm tests hỏng. Martin gọi đây là Fragile Tests Problem. Suite vốn được tạo để giảm rủi ro thay đổi lại khiến developer e ngại refactor. Giải pháp không phải xóa tests; giải pháp là kiểm tra business rules qua boundary ổn định hơn và dành test framework/infrastructure cho chính behavior của những boundary đó.


“Unit” là một boundary, không phải phép đếm class

Không có một định nghĩa duy nhất được mọi cộng đồng chấp nhận cho từ unit trong unit test. Trường phái solitary thường cô lập một object và thay collaborators bằng Test Doubles. Trường phái sociable cho phép nhiều real objects hợp tác miễn toàn bộ cụm vẫn nhanh và không đi qua external I/O. Tranh luận chỉ hữu ích khi nó giúp team thống nhất feedback nào họ muốn nhận.

Cuốn sách này dùng một quy ước vận hành: unit test phải nhanh, deterministic, chạy độc lập và không giao tiếp database, filesystem, network hoặc external process. Unit có thể là một class hoặc một cụm core objects cohesive. Quy ước này giúp phân loại chi phí mà không buộc ta mock mọi object bên cạnh SUT.

EnrollmentReminderService cùng EnrollmentReminder, DueEnrollmentReminderBatchResult có thể được test như một unit. Các records này là dữ liệu domain ổn định, rẻ và deterministic; dùng real objects làm test rõ hơn. JPA repository, system clock và SMS network vượt boundary unit vì chúng chậm, khó kiểm soát hoặc có failure modes riêng.

Annotation không quyết định một test là unit hay integration. Test dùng Mockito chưa chắc là unit nếu nó vẫn đọc filesystem. Test không dùng mock chưa chắc là integration nếu toàn bộ collaborators là core objects trong memory. Thay vì tranh luận nhãn, hãy ghi rõ SUT, process/I/O nào tham gia và risk nào được kiểm chứng.


Test Double không đồng nghĩa Mock

Test Double là tên chung cho object thay production object trong test. Gerard Meszaros xây dựng taxonomy này và Martin Fowler phổ biến nó để giảm sự nhập nhằng quanh từ mock. Năm loại thường gặp khác nhau ở mục đích, không nhất thiết ở class hierarchy hay library tạo ra chúng.

Loại

Vai trò trong test

Ví dụ enrollment

Dummy

Chỉ lấp một parameter, không được SUT sử dụng trong scenario

Một AuditMetadata tối thiểu cho nhánh không audit

Stub

Trả về canned answer đã chuẩn bị

Query trả danh sách enrollment đến hạn

Fake

Implementation chạy thật nhưng dùng shortcut không phù hợp production

Repository in-memory dùng Map thay PostgreSQL

Spy

Hoạt động như stub/fake và ghi lại interaction để test đọc

RecordingReminderSender lưu reminders đã nhận

Mock

Được cấu hình expectation và được verify sau khi SUT chạy

Mockito mock xác nhận sender nhận đúng command

Một object do Mockito tạo không tự động là Mock theo nghĩa taxonomy. Khi test chỉ dùng when(query.find...()).thenReturn(...), object đang đóng vai Stub. Khi test gọi verify(sender).send(...), object đóng vai Mock vì expectation về interaction là một phần của assertion. Trong giao tiếp hằng ngày, developer thường gọi cả hai là mock; trong chương này, ta dùng thuật ngữ chính xác để quyết định test đang xác minh điều gì.

JavaScript
// Stub: cung cấp indirect input cho SUT.
DueEnrollmentQuery queryStub = (from, to) -> List.of(dueEnrollment);

// Spy: ghi lại indirect output để assertion đọc sau.
final class RecordingReminderSender implements ReminderSender {
    private final List<EnrollmentReminder> sent = new ArrayList<>();

    @Override
    public DeliveryResult send(EnrollmentReminder reminder) {
        sent.add(reminder);
        return DeliveryResult.DELIVERED;
    }

    List<EnrollmentReminder> sentReminders() {
        return List.copyOf(sent);
    }
}

// Fake: có behavior thật, nhưng chỉ phù hợp test/local development.
final class InMemoryDueEnrollmentQuery implements DueEnrollmentQuery {
    private final List<DueEnrollment> enrollments = new ArrayList<>();

    @Override
    public List<DueEnrollment> findIncompleteExpiringBetween(
            Instant fromInclusive, Instant toExclusive) {
        return enrollments.stream()
                .filter(e -> !e.expiresAt().isBefore(fromInclusive))
                .filter(e -> e.expiresAt().isBefore(toExclusive))
                .toList();
    }
}

Nên chọn cơ chế đơn giản nhất tạo đủ control và observation. Real Value Object thường tốt hơn mock. Lambda Stub đủ cho query một method thì không cần class fake lớn. Spy phù hợp khi cần quan sát outgoing message. Mockito hữu ích khi muốn cấu hình nhanh nhiều scenario; nếu test chứa hàng chục stubbings và verifications, vấn đề có thể nằm ở boundary hoặc responsibility của SUT.

Fake có chi phí đặc biệt: nó mang behavior riêng. In-memory query dùng Java comparison có thể không tái hiện timezone conversion, collation, constraint hoặc transaction semantics của PostgreSQL. Fake giúp core test nhanh nhưng không tạo production parity. Vì vậy, fake cần contract tests và production adapter vẫn cần integration tests.


State verification và interaction verification

State verification kiểm tra state hoặc result có thể quan sát sau hành động. Interaction verification kiểm tra SUT đã gửi message nào tới collaborator. Fowler dùng sự khác biệt này để giải thích vì sao Mock không chỉ là một Stub có framework hỗ trợ. Hai cách đều hợp lệ; lựa chọn phụ thuộc behavior cần bảo vệ.

JavaScript
ReminderBatchResult result = service.remindEnrollmentsExpiringSoon();

// State/result verification: một concept, có thể cần nhiều assertions liên quan.
assertAll(
        () -> assertEquals(2, result.selected()),
        () -> assertEquals(1, result.delivered()),
        () -> assertEquals(1, result.failed()));

ReminderBatchResult là observable result tự nhiên, nên state verification rõ ràng và ít coupling. Với hành động gửi SMS, application không sở hữu một state local thuận tiện để đọc. Interaction với ReminderSender là behavior có ý nghĩa: đúng enrollment, đúng destination và đúng expiration time phải được gửi. Verify interaction ở port là hợp lý.

JavaScript
verify(reminderSender).send(argThat(reminder ->
        reminder.enrollmentId().equals(enrollmentId)
                && reminder.destination().equals("+84901234567")
                && reminder.expiresAt().equals(EXPIRES_AT)));

Không nên mở rộng verification thành internal choreography nếu client không dựa vào nó. verify(mapper).toReminder(...), verify(query).find...() rồi verifyNoMoreInteractions(...) có thể khóa thứ tự và số bước implementation. Nếu ngày mai service batch requests hoặc thay mapper bằng constructor record, business behavior vẫn đúng nhưng tests hỏng. Interaction chỉ nên được bảo vệ khi chính message hoặc protocol là contract quan sát được.


Before: một `@SpringBootTest` đang kiểm tra quá nhiều thứ

Production code ở Chương 7 đã có dependency direction tốt. Dù vậy, team có thể viết test dưới đây vì @SpringBootTest tạo cảm giác gần production. Test nạp toàn context rồi thay mọi dependency liên quan bằng Mockito beans.

JavaScript
@SpringBootTest
class EnrollmentReminderServiceContextTest {

    private static final Instant NOW = Instant.parse("2026-08-06T09:00:00Z");

    @Autowired
    EnrollmentReminderService service;

    @MockitoBean
    DueEnrollmentQuery dueEnrollmentQuery;

    @MockitoBean
    ReminderSender reminderSender;

    @MockitoBean
    Clock clock;

    @Test
    void sends_a_reminder() {
        DueEnrollment due = TestEnrollments.dueAt(NOW.plus(2, DAYS));
        when(clock.instant()).thenReturn(NOW);
        when(dueEnrollmentQuery.findIncompleteExpiringBetween(
                NOW, NOW.plus(3, DAYS)))
                .thenReturn(List.of(due));
        when(reminderSender.send(any())).thenReturn(DELIVERED);

        ReminderBatchResult result = service.remindEnrollmentsExpiringSoon();

        assertEquals(1, result.delivered());
        verify(dueEnrollmentQuery).findIncompleteExpiringBetween(
                NOW, NOW.plus(3, DAYS));
        verify(reminderSender).send(any(EnrollmentReminder.class));
    }
}

Ghi chú

- Full `ApplicationContext` được tạo để kiểm tra một Java policy thuần. - Database và SMS provider vẫn bị thay bằng mocks, nên test không xác minh query, mapping hay provider integration. - Failure có thể đến từ unrelated bean configuration, làm chậm việc định vị nguyên nhân. - Test vừa verify result vừa lặp lại internal call pattern, tăng structural coupling.

Test không hoàn toàn vô giá trị. Nó có thể phát hiện EnrollmentReminderService không được đăng ký bean hoặc Clock wiring thiếu. Nhưng cùng lúc nó cố kiểm tra policy, và đó là nơi chi phí không tương xứng. Một wiring smoke test có thể xác minh context ở một chỗ; hàng chục business scenarios nên chạy không context.

@SpringBootTest không phải anti-pattern. Nó phù hợp khi SUT là application context, configuration hoặc một vertical slice cần nhiều real components. Sai lầm là dùng annotation như cấp độ “mạnh hơn” mặc định. Test rộng hơn không tự động tốt hơn; nó chỉ bao gồm nhiều thành phần và failure modes hơn.


After phần 1: plain JUnit bảo vệ application policy

Constructor injection cho phép tạo service bằng new, đúng như Spring Boot documentation chỉ ra khi nói về unit testing. Không cần Spring extension, component scanning hay bean override. Test chỉ lắp SUT với collaborators phù hợp cho scenario.

JavaScript
class EnrollmentReminderServiceTest {

    private static final Instant NOW = Instant.parse("2026-08-06T09:00:00Z");
    private static final Clock FIXED_CLOCK = Clock.fixed(NOW, ZoneOffset.UTC);

    @Test
    void reports_delivered_and_temporarily_failed_reminders() {
        DueEnrollment first = TestEnrollments.dueAt(NOW.plus(1, DAYS));
        DueEnrollment second = TestEnrollments.dueAt(NOW.plus(2, DAYS));

        DueEnrollmentQuery query = (from, to) -> List.of(first, second);
        RecordingSender sender = new RecordingSender(
                Map.of(first.enrollmentId(), DELIVERED,
                       second.enrollmentId(), TEMPORARILY_FAILED));
        EnrollmentReminderService service =
                new EnrollmentReminderService(query, sender, FIXED_CLOCK);

        ReminderBatchResult result = service.remindEnrollmentsExpiringSoon();

        assertEquals(new ReminderBatchResult(2, 1, 1), result);
        assertEquals(
                List.of(first.enrollmentId(), second.enrollmentId()),
                sender.sentEnrollmentIds());
    }

    @Test
    void returns_an_empty_result_when_no_enrollment_is_due() {
        DueEnrollmentQuery emptyQuery = (from, to) -> List.of();
        RecordingSender sender = new RecordingSender(Map.of());
        EnrollmentReminderService service =
                new EnrollmentReminderService(emptyQuery, sender, FIXED_CLOCK);

        ReminderBatchResult result = service.remindEnrollmentsExpiringSoon();

        assertEquals(new ReminderBatchResult(0, 0, 0), result);
        assertTrue(sender.sentEnrollmentIds().isEmpty());
    }
}

final class TestEnrollments {
    private TestEnrollments() {
    }

    static DueEnrollment dueAt(Instant expiresAt) {
        UUID id = UUID.nameUUIDFromBytes(
                expiresAt.toString().getBytes(StandardCharsets.UTF_8));
        return new DueEnrollment(
                id,
                "+84901234567",
                "Java Spring Boot thực chiến",
                expiresAt);
    }
}

final class RecordingSender implements ReminderSender {
    private final Map<UUID, DeliveryResult> outcomes;
    private final List<UUID> sentIds = new ArrayList<>();

    RecordingSender(Map<UUID, DeliveryResult> outcomes) {
        this.outcomes = Map.copyOf(outcomes);
    }

    @Override
    public DeliveryResult send(EnrollmentReminder reminder) {
        sentIds.add(reminder.enrollmentId());
        return outcomes.getOrDefault(
                reminder.enrollmentId(),
                DeliveryResult.DELIVERED);
    }

    List<UUID> sentEnrollmentIds() {
        return List.copyOf(sentIds);
    }
}

Hai tests dùng real records, fixed clock, Stub query và Spy sender. Tên test nói về behavior, không nói tên method nội bộ. Mỗi test xử lý một concept: tổng hợp mixed delivery outcomes; hoặc batch rỗng không gửi gì. Test đầu có hai assertions nhưng cả hai cùng chứng minh một behavior thống nhất.

Phiên bản Mockito phù hợp khi team muốn giảm code Test Double thủ công. MockitoExtension khởi tạo mocks; SUT vẫn được tạo qua constructor để dependency rõ ràng. Không cần @MockitoBean vì không có Spring context để override.

JavaScript
@ExtendWith(MockitoExtension.class)
class EnrollmentReminderServiceMockitoTest {

    private static final Instant NOW = Instant.parse("2026-08-06T09:00:00Z");

    @Mock
    DueEnrollmentQuery dueEnrollmentQuery;

    @Mock
    ReminderSender reminderSender;

    @Test
    void sends_the_application_command_for_a_due_enrollment() {
        DueEnrollment due = TestEnrollments.dueAt(NOW.plus(2, DAYS));
        when(dueEnrollmentQuery.findIncompleteExpiringBetween(
                NOW, NOW.plus(3, DAYS)))
                .thenReturn(List.of(due));
        when(reminderSender.send(any())).thenReturn(DELIVERED);
        EnrollmentReminderService service = new EnrollmentReminderService(
                dueEnrollmentQuery,
                reminderSender,
                Clock.fixed(NOW, ZoneOffset.UTC));

        ReminderBatchResult result = service.remindEnrollmentsExpiringSoon();

        assertEquals(new ReminderBatchResult(1, 1, 0), result);
        verify(reminderSender).send(argThat(reminder ->
                reminder.enrollmentId().equals(due.enrollmentId())
                        && reminder.destination().equals(due.studentPhone())));
    }
}

Kết quả hiển thị trên màn hình sẽ là:

EnrollmentReminderServiceTest
  reports_delivered_and_temporarily_failed_reminders()  PASSED
  returns_an_empty_result_when_no_enrollment_is_due()   PASSED

EnrollmentReminderServiceMockitoTest
  sends_the_application_command_for_a_due_enrollment()  PASSED

Spring ApplicationContext started: no
External I/O performed: no

Hand-written doubles và Mockito không phải hai cấp độ trưởng thành. Recording Spy dùng domain language, dễ đọc và có thể tái sử dụng. Mockito tạo scenario nhanh nhưng dễ tích lũy stubbing/verification. Team có thể kết hợp: real core objects, small Stub bằng lambda, recording Spy cho port quan trọng và Mockito khi việc tự viết double không đem lại clarity tương ứng.

Đánh đổi của test isolation là ta không kiểm chứng production adapters. Đây không phải lỗ hổng bí mật; boundary của test đã nói rõ điều đó. Testing portfolio phải bổ sung evidence ở adapter thay vì làm unit test giả vờ biết SQL hoặc vendor protocol.


Test dễ đọc: Arrange–Act–Assert và một concept mỗi test

Một test tốt mô tả điều kiện, hành động và kết quả theo thứ tự đọc tự nhiên. Arrange chuẩn bị state và collaborators; Act kích hoạt đúng một behavior; Assert kiểm tra observable outcome. Given–When–Then là cách diễn đạt tương đương ở ngôn ngữ behavior. Không bắt buộc viết comment cho ba phần nếu khoảng trắng và tên biến đã làm cấu trúc rõ.

Tên testRemind1() không cho người đọc biết điều gì hỏng. Tên returns_an_empty_result_when_no_enrollment_is_due() ghi scenario và expectation. Khi test fail trong CI, tên này là diagnostic đầu tiên; developer chưa cần mở file vẫn biết contract nào có vấn đề.

Quy tắc một assertion mỗi test có lợi khi nó buộc test đi đến một conclusion rõ. Tuy nhiên, Clean Code cũng thừa nhận việc tạo thêm mechanism chỉ để đạt đúng một assertion có thể không đáng giá. Quy tắc tốt hơn là một concept mỗi test và số assertions tối thiểu để chứng minh concept. Ba field của ReminderBatchResult có thể được so sánh qua record equality; HTTP response có thể cần đồng thời status, content type và payload vì chúng tạo thành một response contract.

FIRST là checklist phổ biến cho clean tests: Fast, Independent, Repeatable, Self-validating và Timely. Fast và Independent giữ feedback ngắn, tránh cascade failure. Repeatable loại dependency ngầm vào môi trường. Self-validating nghĩa test pass/fail tự động thay vì yêu cầu đọc log thủ công. Timely nhắc viết test đủ sớm để thiết kế còn có thể thay đổi; chương này không biến nó thành yêu cầu mọi team phải áp dụng strict TDD cho mọi dòng code.

Test code là asset phải được đặt tên, review và refactor. Nó có thể chấp nhận một dual standard về performance hoặc memory vì không chạy production, nhưng không được hạ chuẩn về readability và correctness. Fixture khó hiểu làm developer sửa assertion theo cách cho test pass mà không biết behavior thật.


After phần 2: JPA adapter phải được test với persistence thật

Unit tests đã chứng minh service gọi query port bằng cửa sổ đúng. Chúng không chứng minh JpaDueEnrollmentAdapter tìm đúng rows, map projection đúng hoặc sử dụng half-open interval [from, to) như contract. Đây là assumption vượt architectural boundary, vì vậy cần integration test có JPA và database behavior.

@DataJpaTest nạp một slice tập trung vào JPA thay vì toàn application. Spring Data repositories và entity mapping được cấu hình; adapter riêng của ứng dụng được thêm bằng @Import. Test persist dữ liệu đại diện, flush/clear persistence context để tránh nhận lại object cache rồi gọi qua adapter như production client.

JavaScript
@DataJpaTest
@Import(JpaDueEnrollmentAdapter.class)
class JpaDueEnrollmentAdapterTest {

    private static final Instant NOW = Instant.parse("2026-08-06T09:00:00Z");

    @Autowired
    TestEntityManager entityManager;

    @Autowired
    JpaDueEnrollmentAdapter adapter;

    @Test
    void finds_only_incomplete_enrollments_inside_the_half_open_window() {
        // Test-data helper tạo entity hợp lệ; scenario chỉ thay status và expiresAt.
        EnrollmentEntity inside = enrollment(false, NOW.plus(2, DAYS));
        EnrollmentEntity completed = enrollment(true, NOW.plus(2, DAYS));
        EnrollmentEntity atExclusiveEnd = enrollment(false, NOW.plus(3, DAYS));
        entityManager.persist(inside);
        entityManager.persist(completed);
        entityManager.persist(atExclusiveEnd);
        entityManager.flush();
        entityManager.clear();

        List<DueEnrollment> result =
                adapter.findIncompleteExpiringBetween(
                        NOW, NOW.plus(3, DAYS));

        assertEquals(List.of(inside.getId()),
                result.stream().map(DueEnrollment::enrollmentId).toList());
    }
}

Method enrollment(completed, expiresAt) là test-data helper của dự án, tạo một entity hợp lệ với student/course mặc định rồi chỉ thay hai giá trị liên quan scenario. Nó không phải API của Spring. Cách rút gọn này giữ intent của test rõ; implementation helper phải được đặt trong test source và tuân cùng invariants với entity production.

Test này cố ý không mock EntityManager hay Spring Data repository. Mocking query call chỉ chứng minh adapter gọi đúng method mà test đã lập trình; nó không parse JPQL, kiểm tra column mapping hoặc chạy database constraint. Khi rủi ro nằm trong integration, Test Double làm mất chính behavior cần xác minh.

Embedded database tạo feedback nhanh nếu query và data types portable. Nhưng H2 không phải PostgreSQL thu nhỏ. Dialect, JSON, collation, generated columns, locking và transaction semantics có thể khác. Khi adapter sử dụng những đặc tính này hoặc production từng có parity defect, Testcontainers giúp chạy production-like database trong container.

JavaScript
@Testcontainers
@DataJpaTest
@Import(JpaDueEnrollmentAdapter.class)
class JpaDueEnrollmentPostgresTest {

    @Container
    @ServiceConnection
    static PostgreSQLContainer<?> postgres =
            new PostgreSQLContainer<>("postgres:16-alpine");

    // Cùng behavior assertions như adapter contract.
}

Container tăng startup time, Docker requirement và resource usage. Không nên chạy PostgreSQL cho một calculation thuần hoặc mọi getter. Có thể giữ phần lớn policy tests ở unit level, một tập adapter tests dùng production-like database và tái sử dụng container/context hợp lý. Mục tiêu là đặt chi phí nơi nó mua được confidence mà test nhỏ không thể cung cấp.


Contract test giữ Fake và production adapter cùng một lời hứa

Chương 5 dùng LSP để yêu cầu mọi implementation giữ behavioral contract; Chương 7 đặt ports ở phía application. Contract Test biến hai quyết định đó thành executable evidence. Một suite behavior dùng chung được chạy với In-Memory Fake và JPA adapter; mỗi implementation phải qua cùng scenarios mà client dựa vào.

JavaScript
abstract class DueEnrollmentQueryContract {

    private static final Instant FROM =
            Instant.parse("2026-08-06T09:00:00Z");
    private static final Instant TO = FROM.plus(3, DAYS);

    protected abstract DueEnrollmentQuery query();
    protected abstract UUID addIncompleteEnrollment(Instant expiresAt);
    protected abstract void addCompletedEnrollment(Instant expiresAt);

    @Test
    void includes_the_start_and_excludes_the_end_of_the_window() {
        UUID atStart = addIncompleteEnrollment(FROM);
        addIncompleteEnrollment(TO);

        List<UUID> ids = query()
                .findIncompleteExpiringBetween(FROM, TO)
                .stream()
                .map(DueEnrollment::enrollmentId)
                .toList();

        assertEquals(List.of(atStart), ids);
    }

    @Test
    void excludes_completed_enrollments() {
        addCompletedEnrollment(FROM.plus(1, DAYS));

        assertTrue(query()
                .findIncompleteExpiringBetween(FROM, TO)
                .isEmpty());
    }
}

InMemoryDueEnrollmentQueryContractTest triển khai ba factory hooks bằng collection; JpaDueEnrollmentQueryContractTest persist entities và trả JPA adapter. Contract suite kiểm tra semantics của port: biên thời gian và completed status. Nó không yêu cầu hai implementations dùng cùng algorithm, SQL hay performance profile.

Mỗi concrete contract-test class phải tạo hoặc reset state trước từng test. In-memory implementation dùng collection mới cho mỗi test instance; JPA implementation dọn dữ liệu bằng transaction rollback hoặc fixture setup rõ ràng. Contract suite không được phụ thuộc thứ tự chạy, nếu không một implementation có thể pass chỉ vì scenario trước đã chuẩn bị state.

Contract tests giảm nguy cơ Fake và production adapter diễn giải port khác nhau, nhưng không tạo phép chứng minh bắc cầu hoàn hảo. JPA subclass vẫn phải chạy với database đủ đại diện. SMS adapter vẫn cần HTTP stub server, vendor sandbox hoặc một integration mechanism do provider hỗ trợ để xác minh serialization, authentication và error mapping. Contract test không thể kiểm tra một external system chưa tham gia test.

Nếu Fake ngày càng phức tạp, tự triển khai transaction, uniqueness và query semantics, chi phí duy trì có thể vượt lợi ích. Khi đó team có thể dùng Stub nhỏ theo scenario hoặc chạy nhiều integration tests hơn. Fake không phải mục tiêu kiến trúc; nó là một testing option cần được đánh giá như mọi abstraction khác.


Spring test slices: dùng framework đúng phạm vi

Spring Boot cung cấp test slices để nạp phần framework liên quan đến SUT. Slice không phải unit test thuần vì có ApplicationContext, nhưng thường nhanh và dễ định vị hơn full context. Lựa chọn nên bắt đầu từ behavior cần kiểm tra, không từ annotation developer nhớ rõ nhất.

Subject hoặc rủi ro

Công cụ chính

Phần không cần nạp

Application policy

Plain JUnit, real core objects, Test Doubles khi cần

Spring context

MVC routing, JSON, validation

@WebMvcTest + @MockitoBean

Persistence, scheduler, full application

JPA mapping và query

@DataJpaTest

Web layer, SMS provider

Bean wiring/configuration

Một số ít @SpringBootTest

Không cần lặp mọi business scenario

Production database semantics

JPA slice/integration + Testcontainers

Full browser/E2E nếu không liên quan

Giả sử hệ thống có admin endpoint kích hoạt reminder batch. Controller chỉ chuyển HTTP request vào application service và map kết quả. @WebMvcTest kiểm tra routing, serialization và response contract; @MockitoBean thay service bean trong Spring TestContext để web slice không chạy batch thật.

JavaScript
@RestController
@RequestMapping("/admin/enrollment-reminders")
final class EnrollmentReminderAdminController {

    private final EnrollmentReminderService reminderService;

    EnrollmentReminderAdminController(
            EnrollmentReminderService reminderService) {
        this.reminderService = reminderService;
    }

    @PostMapping("/run")
    ReminderBatchResult runReminderBatch() {
        return reminderService.remindEnrollmentsExpiringSoon();
    }
}

@WebMvcTest(EnrollmentReminderAdminController.class)
class EnrollmentReminderAdminControllerTest {

    @Autowired
    MockMvc mockMvc;

    @MockitoBean
    EnrollmentReminderService reminderService;

    @Test
    void returns_the_batch_result_as_json() throws Exception {
        when(reminderService.remindEnrollmentsExpiringSoon())
                .thenReturn(new ReminderBatchResult(3, 2, 1));

        mockMvc.perform(post("/admin/enrollment-reminders/run"))
                .andExpect(status().isOk())
                .andExpect(content().contentTypeCompatibleWith(APPLICATION_JSON))
                .andExpect(jsonPath("$.selected").value(3))
                .andExpect(jsonPath("$.delivered").value(2))
                .andExpect(jsonPath("$.failed").value(1));

        verify(reminderService).remindEnrollmentsExpiringSoon();
    }
}

Nhiều assertions ở đây cùng chứng minh một HTTP response contract. Test không verify controller gọi mapper nào hoặc JSON library dùng method nội bộ nào. Nếu application có Spring Security, web slice cần đưa security configuration và authentication scenario vào đúng mức; bỏ filters chỉ để test xanh có thể loại chính behavior cần kiểm tra.

Context caching giúp các test có cùng configuration tái sử dụng context. Tuy nhiên, tổ hợp bean overrides và profiles khác nhau có thể tạo cache key khác, làm nhiều contexts được khởi động. Spring Framework documentation lưu ý ngay cả field name/qualifier của @MockitoBean cũng có thể ảnh hưởng việc tái sử dụng context. Đây là lý do giữ bean override nhất quán và không biến mỗi test class thành một application configuration riêng.


Ghi chú phiên bản: từ `@MockBean` sang `@MockitoBean`

Nhiều codebase Spring Boot dùng org.springframework.boot.test.mock.mockito.MockBean. Annotation này từng là cách chuẩn để thêm hoặc thay bean bằng Mockito mock. Từ Spring Boot 3.4, API đó được deprecated để chuyển sang bean override support của Spring Framework, với @MockitoBean@MockitoSpyBean; kế hoạch deprecation nêu removal ở Boot 4.0. Code mới của sách dùng @MockitoBean.

JavaScript
// Legacy Spring Boot test
@MockBean
ReminderSender reminderSender;

// Spring Framework 6.2+ / code mới của sách
@MockitoBean
ReminderSender reminderSender;

Thay annotation không biến một test rộng thành test tốt. Cả hai đều override bean trong context; câu hỏi boundary vẫn giữ nguyên. Với plain unit test, dùng Mockito @Mock hoặc Test Double thủ công. Với web slice cần Spring quản lý controller, @MockitoBean phù hợp vì bean phải được thay trong ApplicationContext.

Codebase đang ở Boot version cũ có thể tiếp tục gặp @MockBean; điều đó không tự động là design defect. Migration cần đi cùng nâng Spring generation và dependency compatibility, không nên thay import riêng lẻ trong một branch không nâng platform. Sách ghi cả hai tên để người đọc hiểu code legacy nhưng chỉ minh họa API hiện hành trong code mới.

Spring Boot 4 còn tách test support thành các module theo feature. Trong baseline 4.1 của sách, @WebMvcTest thuộc package org.springframework.boot.webmvc.test.autoconfigure và cần module spring-boot-webmvc-test; @DataJpaTest thuộc org.springframework.boot.data.jpa.test.autoconfigure và cần spring-boot-data-jpa-test. Codebase Boot 3 dùng các package cũ dưới org.springframework.boot.test.autoconfigure. Vì vậy hãy để Spring Boot BOM quản lý phiên bản và thay dependency cùng import theo một migration nhất quán; mã nguồn mẫu đi kèm sách build trực tiếp trên Boot 4.1.

@MockitoSpyBean wrap một real bean để quan sát một phần behavior. Spy có thể gọi real method trong lúc stubbing và kích hoạt side effect ngoài ý muốn; Spring documentation khuyến nghị dùng nhóm doReturn/doThrow khi when() sẽ gọi method thật. Partial mock đôi khi hữu ích cho legacy integration, nhưng không nên là mặc định để tránh thiết kế boundary rõ.


Test Pyramid là heuristic, không phải quota

Test Pyramid khuyến nghị nhiều tests nhỏ, nhanh ở đáy; ít tests rộng, chậm ở trên. Giá trị chính của mô hình là nhắc team dùng nhiều granularity và giảm số test khi feedback cost tăng. Ham Vocke cũng lưu ý tên từng tầng có thể gây tranh luận; sự nhất quán trong team và phạm vi thực tế quan trọng hơn nhãn.

Không có tỷ lệ 70/20/10 phù hợp mọi hệ thống. Một rules engine có thể có rất nhiều unit tests. Một data migration tool có phần lớn giá trị nằm ở integration với database. Một thin CRUD admin module có ít policy nhưng nhiều mapping/authorization concerns. Portfolio phải theo risk profile, không theo hình học của biểu đồ.

Mỗi tầng nên mua một loại confidence khác. Unit tests bảo vệ reminder window và failure aggregation. JPA tests bảo vệ query/mapping. Web slice bảo vệ HTTP contract. Context smoke test bảo vệ wiring. Một critical journey có thể xác nhận toàn batch chạy từ trigger tới test provider. Lặp cùng assertion “delivered bằng 1” ở năm tầng mà không kiểm tra assumption mới chỉ tăng maintenance cost.

Feedback cadence cũng nên theo chi phí. Plain unit tests chạy mỗi lần save/commit. Slice và container integration tests chạy trong pull request hoặc thường xuyên đủ để lỗi không tích lũy. External sandbox và expensive E2E có thể chạy theo pipeline/cadence riêng. Đây là quyết định vận hành; chương này chỉ đặt nguyên tắc rằng test chậm không được trở thành lý do team bỏ chạy toàn bộ suite.


Khó viết test như tín hiệu của từng chữ SOLID

Testability là nơi hậu quả của SOLID trở nên quan sát được. Một test khó viết không chứng minh ngay nguyên lý nào bị vi phạm, nhưng hình dạng của difficulty giúp đặt câu hỏi đúng.

Góc nhìn

Dấu hiệu trong test

Câu hỏi cần điều tra

SRP

Fixture phải lắp nhiều collaborator không liên quan; thay đổi của nhiều actor sửa cùng test

SUT đang chứa bao nhiêu responsibility/lý do thay đổi?

OCP

Thêm strategy mới buộc sửa central conditional tests và fixtures cũ

Variation đã có extension point đúng chưa?

LSP

Cùng contract suite thất bại với một subtype/adapter

Implementation có giữ precondition, postcondition và observable behavior không?

ISP

Stub phải khai báo nhiều method test không dùng

Client đang phụ thuộc contract rộng hơn nhu cầu không?

DIP

Business rule test cần database/vendor/container hoặc không thể thay clock

Volatile detail có đang định hình policy không?

Constructor có tám parameters là tín hiệu SRP đáng xem nhưng không phải bằng chứng. Một orchestration use case phức tạp hợp lệ có thể cần nhiều ports; tách class chỉ để test constructor ngắn hơn làm flow khó hiểu. Mock phải stub nhiều method có thể do interface rộng, cũng có thể do test đang chọn sai SUT. Luôn đối chiếu production usage, change history và business risk trước khi refactor.

Tests cũng có thể tạo pressure thiết kế sai. Nếu framework chỉ mock interface, team có thể tạo XxxService/XxxServiceImpl cho mọi helper. Nếu test yêu cầu gọi private method, team có thể public hóa nó. Công cụ đang dẫn architecture thay vì business boundary. Testability tốt bảo vệ encapsulation và observable contract, không yêu cầu mọi implementation detail trở thành seam.


Những anti-patterns khi thiết kế cho test

Public hóa private method chỉ để test. Private method là chi tiết của public behavior. Nếu logic đủ quan trọng để có contract riêng, extract một cohesive object vì responsibility của production design; nếu không, test qua public boundary.

Tạo interface cho mọi class chỉ để mock. Interface đáng giá tại role/variation/volatile boundary. Pure helper hoặc Value Object thường dùng thật trong test rõ hơn và ít indirection hơn.

Mock domain objects. Mock DueEnrollment rồi stub từng getter khiến test phụ thuộc structure và có thể tạo state mà constructor thật cấm. Real immutable records rẻ, valid và diễn đạt scenario tốt hơn.

Verify mọi interaction. verifyNoMoreInteractions() có giá trị khi việc gửi thêm message là lỗi business, chẳng hạn charge hai lần. Dùng nó theo phản xạ biến mọi collaborator call thành contract và làm refactor khó khăn.

Deep stubs cho call chain. provider.client().messages().request().send() bị mock bằng deep stubs làm test lặp vendor structure. Đây thường là dấu hiệu cần adapter boundary, không phải lý do bật RETURNS_DEEP_STUBS rộng hơn.

Shared mutable fixture và test order. Test B chỉ pass sau Test A tạo dữ liệu là test không Independent. Failure khi chạy song song hoặc đảo thứ tự làm diagnosis khó và che state leak.

Full context nhưng mock mọi infrastructure. Test trả startup cost của integration nhưng không nhận production parity. Hãy tách policy unit test và adapter integration tests; giữ full context cho wiring hoặc vertical slice thật sự.

Coverage là mục tiêu duy nhất. Coverage cho biết code nào đã được thực thi, không cho biết assertion có ý nghĩa hay risk nào được kiểm chứng. Một test gọi method không assert gì vẫn tăng coverage. Coverage hữu ích để tìm vùng mù, không phải chứng nhận design quality.

Ghi chú

Testability là khả năng kiểm chứng behavior qua boundary ổn định. Nó không phải khả năng truy cập, mock và verify mọi chi tiết nội bộ.

Khi nào đầu tư mức nào — testing theo rủi ro

Testing cũng có cost of change. Mỗi test thêm code phải đọc, data phải dựng, environment phải vận hành và failure phải điều tra. Vì vậy, “càng nhiều test càng tốt” không chính xác hơn “càng nhiều interface càng tốt”. Phần thực dụng của chương này là chọn test nhỏ nhất tạo đủ confidence cho rủi ro cụ thể, rồi bổ sung boundary tests nơi assumption vượt ra ngoài phạm vi đó.

Case nên đầu tư sâu: enrollment reminder có nhiều failure boundary

Reminder batch có time boundary, selection semantics, outgoing effect và hai infrastructure adapters. Sai cửa sổ có thể làm phiền học viên hoặc bỏ lỡ hạn; lỗi query có thể im lặng trả danh sách rỗng; lỗi mapping provider có thể làm toàn batch thất bại. Đây là case hợp lý cho nhiều unit scenarios, JPA integration tests, sender adapter tests và một wiring/critical-flow test có chọn lọc.

Mức bảo vệ tương xứng hiện tại gồm: plain unit tests cho policy và edge cases; contract tests giữ Fake/JPA semantics; @DataJpaTest với production-like database khi query dùng feature riêng; adapter test với stub server/sandbox cho SMS; một context smoke test xác nhận scheduler/configuration chọn đúng beans. Không cần biến mọi scenario business thành E2E.

Case không nên over-mock: formatter thuần và Value Object

EnrollmentReferenceFormatter ghép course code, student code và ngày đăng ký; EnrollmentReference kiểm tra format bằng constructor. Chúng deterministic, không I/O, không clock và dùng JDK/domain types. Vài plain parameterized tests với real objects tạo đủ confidence. Tạo mock cho formatter, fake factory, Spring context và contract hierarchy chỉ tăng test code mà không kiểm chứng thêm risk.

Trigger thay đổi quyết định xuất hiện nếu format khác theo tenant, gọi external localization service, chịu regulatory rule hoặc từng có regressions ở tổ hợp locale/timezone. Khi đó parameterized scenarios có thể mở rộng; external call cần port và adapter tests. Quyết định hiện tại không đóng vĩnh viễn thiết kế — nó chỉ tránh trả chi phí trước bằng chứng.

Rủi ro cần phát hiện

Test nhỏ nhất tạo confidence

Khi nâng cấp boundary

Rule, branch, time boundary

Plain unit test

Khi rule thực sự phụ thuộc framework/I/O

SQL, mapping, constraint

@DataJpaTest

Khi dialect/transaction cần production-like Testcontainers

HTTP mapping, JSON, validation

@WebMvcTest

Khi servlet/server behavior cần full integration

Bean wiring và configuration

Context smoke test

Khi critical vertical slice cần real components

External provider protocol

Adapter test với stub/sandbox

Khi critical outcome cần ít E2E/production verification

Chi phí cần tính gồm test maintenance, fixture/data setup, context/container runtime, flakiness, sandbox cost và false confidence. Testcontainers giảm environment drift nhưng cần Docker và startup. Mock giảm I/O nhưng có thể lệch production. E2E bao phủ rộng nhưng failure khó định vị. Không công cụ nào loại bỏ trade-off; portfolio cân bằng các loại evidence.

Passing unit suite không chứng minh production integration đúng. Đây là correctness boundary không thể làm mềm bằng câu “tùy dự án”: nếu một feature phụ thuộc SQL, serialization hoặc provider protocol, phải có evidence phù hợp ở boundary ấy trước khi khẳng định feature đã được kiểm chứng. Phần tùy bối cảnh là độ sâu, số scenario và cadence dựa trên consequence/likelihood.

Các trigger cần xem xét lại strategy gồm regression lặp lại ở cùng boundary; framework/provider upgrade; Fake lệch production; suite chậm hoặc flaky đến mức bị bỏ qua; refactor giữ behavior nhưng làm nhiều tests hỏng; team không dám thay code; critical flow tăng consequence. Strategy test là design sống, không phải sơ đồ được chốt một lần.

Ghi chú

Câu hỏi định hướng: rủi ro cụ thể nào test này phải phát hiện, và boundary nhỏ nhất nào tạo đủ confidence mà không khóa chặt implementation?

Dùng tests làm safety net cho refactor SOLID

Refactoring thay đổi structure mà giữ observable behavior. Không có automated tests, developer phải xác minh behavior thủ công sau từng bước hoặc chấp nhận rủi ro regression. Fowler đặt self-testing code làm điều kiện nền cho quy trình refactor nhỏ, liên tục: thay một bước, chạy tests, rồi mới đi tiếp.

Legacy code chưa có seam không cần chờ một unit suite hoàn hảo. Team có thể bắt đầu bằng tests ở boundary đang tiếp cận được — API hoặc service với test database — để ghi lại observable behavior quan trọng. Sau khi tách clock, repository và provider khỏi policy, business scenarios được chuyển xuống unit level; một số integration tests vẫn giữ lại để bảo vệ assumptions bên ngoài.

Không nên viết tests khẳng định structure mà ta sắp thay. Nếu mục tiêu là tách SMS SDK khỏi service, verify service gọi vendorClient.messages().create() sẽ khóa coupling cũ. Test nên assert user-relevant result và outgoing application command ở boundary dự kiến ổn định. Safety net phải cho phép structure di chuyển.

Red–green–refactor là một feedback loop hữu ích: viết một test fail vì behavior thiếu, thêm code tối thiểu để pass, rồi cải thiện design trong khi suite xanh. Chương này không yêu cầu strict TDD cho mọi task. Giá trị cần giữ là test được viết đủ sớm để tác động đến boundary, thay vì chỉ thêm sau cùng cho coverage.


Từ SOLID đến một testing portfolio có cấu trúc

SRP làm fixture tập trung vào một actor và responsibility. OCP cho phép thêm variation rồi chạy lại contract/scenario tests mà không sửa core policy. LSP biến behavioral contract thành test suite chạy trên mọi implementation. ISP giữ Test Doubles nhỏ, không phải stub method vô nghĩa. DIP tách volatile infrastructure để core tests chạy nhanh và adapters được kiểm chứng riêng.

Testability vì thế là một kết quả có thể quan sát của thiết kế SOLID, nhưng không phải thước đo duy nhất. Code dễ mock vẫn có thể có abstraction sai; code concurrency khó test không tự động là code kém; suite xanh không chứng minh production. Test difficulty là dữ liệu thiết kế, cần được đọc cùng domain complexity, operational risk và cost of change.

Chương 9 sẽ nâng cùng tư duy lên cấp module và kiến trúc. Application, domain, incoming adapters và outgoing adapters sẽ có dependency rules rõ; architecture tests có thể ngăn import sai hướng; capstone sẽ kết hợp unit, contract và integration evidence để chứng minh module enrollment không chỉ có sơ đồ đẹp mà còn thay đổi được an toàn.


Tóm tắt

  • Testability gồm khả năng kiểm soát input/dependency, quan sát output, cô lập phạm vi và lặp lại kết quả; coverage không thay thế bốn khả năng này.

  • Test là system component và cũng tạo coupling. Hãy coupling với observable behavior, tránh structural coupling vào internal call graph.

  • Unit test trong sách là test nhanh, deterministic, không external I/O; unit có thể là một object hoặc cụm core objects cohesive.

  • Test Double là nhóm gồm Dummy, Stub, Fake, Spy và Mock. Mockito là công cụ; cách sử dụng quyết định vai trò của double.

  • Plain JUnit kiểm tra application policy; test slices và integration tests kiểm tra framework/adapters; số ít full-context/E2E tests bảo vệ wiring và critical journeys.

  • @MockitoBean dùng để override bean trong Spring TestContext; Mockito @Mock dùng cho test không context. @MockBean là API legacy được deprecated từ Boot 3.4.

  • Fake và mocks tạo tốc độ nhưng không chứng minh production parity. Contract và adapter integration tests lấp đúng khoảng trống đó.

  • Test Pyramid là heuristic về granularity/feedback cost, không phải quota; mỗi tầng phải phát hiện một risk khác.

  • Khó viết test là tín hiệu điều tra SRP/OCP/LSP/ISP/DIP, không phải phán quyết tự động.

  • Chọn test boundary nhỏ nhất tạo đủ confidence, rồi nâng mức khi assumption hoặc consequence vượt boundary hiện tại.


Bài tập cuối chương

Phần bài tập cuối chương yêu cầu bạn phân loại Test Doubles, chọn plain JUnit, test slice hoặc full context theo risk, sửa test bị over-mock, thiết kế contract suite và bảo vệ quyết định không tạo mock cho Value Object ổn định.

Hỏi đáp

Đăng nhập để đặt câu hỏi và tham gia thảo luận.