Tất cả bài viết

Code không test được thì chưa bao giờ là Clean Code

Khả năng test được là thuộc tính thiết kế, không phải afterthought. Code khó test là code có quá nhiều coupling và quá nhiều responsibility.

clean-codetestingtestability

Có một bài kiểm tra nhanh tao hay dùng để đánh giá chất lượng code: thử viết unit test cho nó. Không cần chạy — chỉ cần thử viết.

Nếu mày phải spin up database để test business logic — code có vấn đề. Nếu mày phải mock mười dependency khác nhau để test một function hai mươi dòng — code có vấn đề. Nếu mày không thể nghĩ ra cách test nó mà không cần cả hệ thống chạy lên — code có vấn đề nghiêm trọng.

Testability không phải là attribute tách rời của code. Nó là triệu chứng.

Tại sao code không test được thường là code xấu

Code khó test vì nó vi phạm một trong những nguyên tắc cơ bản: dependency phải được inject, không phải được tạo ra bên trong.

// ❌ Vấn đề — impossible to unit test
public class AppointmentService {

    public BigDecimal calculatePatientShare(Long appointmentId) {
        // Hard dependency — không thể mock
        AppointmentRepository repo = new AppointmentRepository();
        InsuranceService insuranceService = new InsuranceService();
        NotificationService notificationService = new NotificationService();

        Appointment appointment = repo.findById(appointmentId).orElseThrow();
        InsuranceCoverage coverage = insuranceService.calculate(appointment.getPatientId());
        BigDecimal share = appointment.getTotalFee().subtract(coverage.getCoveredAmount());

        // Side effect trong business logic — sai về thiết kế
        notificationService.sendPaymentBreakdown(appointment, share);

        return share;
    }
}

Để test calculatePatientShare, mày cần database thật, insurance service thật, notification service thật. Đây không phải unit test nữa — đây là integration test ngẫu nhiên bị mắc kẹt trong service layer.

Code testable trông như thế nào

// ✅ Tốt hơn — dependencies được inject, side effect tách ra
@Service
@RequiredArgsConstructor
public class AppointmentService {

    private final AppointmentRepository appointmentRepository;
    private final InsuranceService insuranceService;
    // NotificationService không còn ở đây — notification là side effect,
    // nên được trigger qua event, không phải gọi trực tiếp từ calculation logic

    public BigDecimal calculatePatientShare(Long appointmentId) {
        Appointment appointment = appointmentRepository.findById(appointmentId)
            .orElseThrow(() -> new AppointmentNotFoundException(appointmentId));
        return calculatePatientShare(appointment);
    }

    // Overload với Appointment object — testable mà không cần database
    public BigDecimal calculatePatientShare(Appointment appointment) {
        InsuranceCoverage coverage = insuranceService.calculate(appointment.getPatientId());
        return appointment.getTotalFee().subtract(coverage.getCoveredAmount());
    }
}

Bây giờ test trở nên straightforward:

@ExtendWith(MockitoExtension.class)
class AppointmentServiceTest {

    @Mock
    private AppointmentRepository appointmentRepository;

    @Mock
    private InsuranceService insuranceService;

    @InjectMocks
    private AppointmentService appointmentService;

    @Test
    void calculatePatientShare_shouldSubtractCoveredAmountFromTotalFee() {
        // Arrange
        Appointment appointment = buildAppointment(
            BigDecimal.valueOf(500_000) // total fee
        );
        InsuranceCoverage coverage = InsuranceCoverage.of(BigDecimal.valueOf(200_000));
        when(insuranceService.calculate(appointment.getPatientId())).thenReturn(coverage);

        // Act
        BigDecimal patientShare = appointmentService.calculatePatientShare(appointment);

        // Assert
        assertThat(patientShare).isEqualByComparingTo(BigDecimal.valueOf(300_000));
    }

    @Test
    void calculatePatientShare_whenNoInsurance_shouldReturnFullFee() {
        Appointment appointment = buildAppointment(BigDecimal.valueOf(500_000));
        InsuranceCoverage noInsurance = InsuranceCoverage.none();
        when(insuranceService.calculate(appointment.getPatientId())).thenReturn(noInsurance);

        BigDecimal patientShare = appointmentService.calculatePatientShare(appointment);

        assertThat(patientShare).isEqualByComparingTo(BigDecimal.valueOf(500_000));
    }
}

Không cần database. Không cần Spring context. Chạy trong milliseconds. Test business logic thuần túy.

Ba dấu hiệu code không testable

1. New operator trong business logic. Bất cứ khi nào business logic dùng new SomeService() hay new SomeRepository() — đó là hard dependency. Mày không thể thay thế nó bằng mock.

2. Static method calls với side effect. LocalDateTime.now(), UUID.randomUUID() — những cái này ổn ở mức độ nào đó. Nhưng ExternalPaymentGateway.charge() là static với side effect — không thể test mà không gọi thật.

3. Logic bị trộn lẫn với I/O. Database call, HTTP call, file system — tất cả là I/O và nên được tách ra khỏi business logic. Logic thuần túy (tính toán, validation, state transition) phải test được mà không cần I/O.

Điều quan trọng hơn test coverage

Sinh viên hay hỏi: “Cần bao nhiêu phần trăm coverage?” Câu hỏi đó đặt sai. Coverage cao không đồng nghĩa với test tốt. Mày có thể đạt 90% coverage với những test không assert gì, hoặc chỉ test happy path mà bỏ qua toàn bộ edge case.

Câu hỏi đúng là: Test có thể bắt được bug thật không?

Và để test bắt được bug thật, mày phải test:

@Test
void calculatePatientShare_whenInsuranceServiceFails_shouldThrowProperException() {
    Appointment appointment = buildAppointment(BigDecimal.valueOf(500_000));
    when(insuranceService.calculate(any()))
        .thenThrow(new InsuranceServiceUnavailableException("Service timeout"));

    assertThatThrownBy(() -> appointmentService.calculatePatientShare(appointment))
        .isInstanceOf(InsuranceVerificationFailedException.class);
}

Testability là design feedback

Đây là insight quan trọng nhất của bài này: khi code khó test, đó là design đang phàn nàn với mày.

Code dễ test thường là code với:

Code khó test thường là code vi phạm những nguyên tắc trên. Testability không phải là mục đích — nó là hệ quả của design tốt. Và nếu mày không thể test được, đó là signal để nhìn lại design.

Takeaway

Chọn một service method quan trọng trong HMS — confirmAppointment, processPayment, bất kỳ method nào mà nếu sai thì nghiêm trọng. Thử viết một unit test cho nó. Xem mày gặp khó khăn ở đâu — đó chính là điểm yếu trong design của mày.


Bài tiếp theo: Refactor là gì — và khi nào thì nên làm