블라디미르 코리코프 님의 "단위 테스트" 책을 정리한 포스팅입니다.
목에 대해 리팩터링 내성과 회귀 방지를 최대화해서 최대 가치의 통합 테스트 개발하기
1. 목의 가치를 극대화하기
예제
더보기
public class UserController {
    private final Database database;
    private final EventDispatcher eventDispatcher;
    public UserController(Database database, IMessageBus messageBus, IDomainLogger domainLogger) {
        this.database = database;
        this.eventDispatcher = new EventDispatcher(messageBus, domainLogger);
    }
    public String changeEmail(int userId, String newEmail) {
        Object[] userData = database.getUserById(userId);
        User user = UserFactory.create(userData);
        String error = user.canChangeEmail();
        if (error != null) return error;
        Object[] companyData = database.getCompany();
        Company company = CompanyFactory.create(companyData);
        user.changeEmail(newEmail, company);
        database.saveCompany(company);
        database.saveUser(user);
        eventDispatcher.dispatch(user.getDomainEvents());
        return "OK";
    }
}public class EventDispatcher {
    private final IMessageBus messageBus;
    private final IDomainLogger domainLogger;
    public EventDispatcher(IMessageBus messageBus, IDomainLogger domainLogger) {
        this.messageBus = messageBus;
        this.domainLogger = domainLogger;
    }
    public void dispatch(List<IDomainEvent> events) {
        for (IDomainEvent event : events) {
            dispatch(event);
        }
    }
    private void dispatch(IDomainEvent event) {
        if (event instanceof EmailChangedEvent) {
            EmailChangedEvent emailChangedEvent = (EmailChangedEvent) event;
            messageBus.sendEmailChangedMessage(
                emailChangedEvent.getUserId(),
                emailChangedEvent.getNewEmail()
            );
        } else if (event instanceof UserTypeChangedEvent) {
            UserTypeChangedEvent userTypeChangedEvent = (UserTypeChangedEvent) event;
            domainLogger.userTypeHasChanged(
                userTypeChangedEvent.getUserId(),
                userTypeChangedEvent.getOldType(),
                userTypeChangedEvent.getNewType()
            );
        }
    }
}
테스트
더보기
@Test
public void changingEmailFromNonCorporateToCorporate() {
    Database db = new Database("ConnectionString"); // 연결 문자열은 예시입니다.
    User user = TestDataFactory.createUser("user@gmail.com", UserType.EMPLOYEE, db);
    TestDataFactory.createCompany("mycorp.com", 1, db);
    IMessageBus messageBusMock = mock(IMessageBus.class);
    IDomainLogger loggerMock = mock(IDomainLogger.class);
    UserController sut = new UserController(db, messageBusMock, loggerMock);
    String result = sut.changeEmail(user.getUserId(), "new@gmail.com");
    assertEquals("OK", result);
    Object[] userData = db.getUserById(user.getUserId());
    User userFromDb = UserFactory.create(userData);
    assertEquals("new@gmail.com", userFromDb.getEmail());
    assertEquals(UserType.CUSTOMER, userFromDb.getType());
    Object[] companyData = db.getCompany();
    Company companyFromDb = CompanyFactory.create(companyData);
    assertEquals(0, companyFromDb.getNumberOfEmployees());
    verify(messageBusMock, times(1)).sendEmailChangedMessage(user.getUserId(), "new@gmail.com");
    verify(loggerMock, times(1)).userTypeHasChanged(user.getUserId(), UserType.EMPLOYEE, UserType.CUSTOMER);
}
시스템 끝에서 상호 작용 검증하기
IBus
public interface IBus {
    void Send(string message);
}| 항목 | 설명 | 
| 기술 세부 사항 캡슐화 | 메시지를 보내는 구체적인 기술을 숨기고, 추상화된 방법으로 처리하여, 내부 구현 변경을 방지. | 
| 컨트롤러와 메시지 버스의 연결 | 메시지 전송의 마지막 단계로, 컨트롤러와 메시지 버스를 연결하는 고리 역할을 함. | 
| 목킹 | - 회귀 방지: 끝단에 있으므로 거치는 클래스가 더 많아짐 - 하위 호환성 | 
IMessageBus
public interface IMessageBus {
    void SendEmailChangedMessage(int userId, string newEmail);
}
public class MessageBus : IMessageBus {
    private readonly IBus _bus;
    
    public void SendEmailChangedMessage(int userId, string newEmail) {
        _bus.Send("Type: User");
    }
}- IBus 위에 있는 래퍼 클래스
- 도메인과 관련된 메시지를 정의
테스트
더보기
public void changingEmailFromNonCorporateToCorporate() {
    Database db = new Database(ConnectionString);
    User user = createUser("user@gmail.com", UserType.EMPLOYEE, db);
    createCompany("mycorp.com", 1, db);
    IBus busMock = mock(IBus.class);
    MessageBus messageBusMock = new MessageBus(busMock);
    IDomainLogger loggerMock = mock(IDomainLogger.class);
    UserController sut = new UserController(db, messageBusMock, loggerMock);
    String result = sut.changeEmail(user.getUserId(), "new@gmail.com");
    assertEquals("OK", result);
    Object[] userData = db.getUserById(user.getUserId());
    User userFromDb = UserFactory.create(userData);
    assertEquals("new@gmail.com", userFromDb.getEmail());
    assertEquals(UserType.CUSTOMER, userFromDb.getType());
    Object[] companyData = db.getCompany();
    Company companyFromDb = CompanyFactory.create(companyData);
    assertEquals(0, companyFromDb.getNumberOfEmployees());
    verify(busMock, times(1)).send("Type: User");
    verify(loggerMock, times(1)).userTypeHasChanged(user.getUserId(), UserType.EMPLOYEE, UserType.CUSTOMER);
}
목을 스파이로 대체하기
| 특징 | 목 (Mock) | 스파이 (Spy) | 
| 목적 | 호출 여부를 테스트 | 실제 실행을 하고, 동작을 검증하는 테스트 대역 | 
| 실행 방식 | 메서드 호출을 시뮬레이션 | 실제 메서드를 실행하고, 그 결과를 확인 | 
| 프레임워크 도움 | 프레임워크의 도움을 받아 작성 | 프레임워크 없이 수동으로 작성 | 
| 장점 | 간단하고, 외부 의존성을 잘 대체할 수 있음 | 실제 메서드 실행을 통해 시스템의 동작을 검증 | 
| 단점 | 실제 동작을 확인할 수 없음, 외부 의존성만 검증 | 코드 재사용을 통해 테스트 크기와 복잡도가 증가할 수 있음 | 
| 검증 방법 | 호출 여부만 체크 | 실행 후 동작 결과를 확인하고 검증 | 
코드) BusSpy
더보기
public class BusSpy implements IBus {
    private List<String> sentMessages = new ArrayList<>();
    
    @Override
    public void send(String message) {
        sentMessages.add(message);
    }
    
    public BusSpy shouldSendNumberOfMessages(int number) {
        assertEquals(number, sentMessages.size());
        return this;
    }
    
    public BusSpy withEmailChangedMessage(int userId, String newEmail) {
        String message = "Type: User";
        assertTrue(sentMessages.contains(message));
        return this;
    }
}
테스트
더보기
public void changingEmailFromNonCorporateToCorporate() {
    // Arrange
    BusSpy busSpy = new BusSpy();
    MessageBus messageBus = new MessageBus(busSpy);
    IDomainLogger loggerMock = Mockito.mock(IDomainLogger.class);
    Database db = new Database(ConnectionString);
    UserController sut = new UserController(db, messageBus, loggerMock);
    User user = createUser("user@gmail.com", UserType.Employee, db);
    createCompany("mycorp.com", 1, db);
    
    // Act
    String result = sut.changeEmail(user.getUserId(), "new@gmail.com");
    // Assert
    Assert.assertEquals("OK", result);
    
    // Verify busSpy for the email change message
    busSpy
        .shouldSendNumberOfMessages(1)
        .withEmailChangedMessage(user.getUserId(), "new@gmail.com");
}
2. 목 처리에 대한 모범 사례
| 항목 | 설명 | 
| 목의 역할 | 비즈니스 로직과 오케스트레인의 분리에서 비롯된 원칙 도메인 모델과 컨트롤러의 고유 계층으로 나뉘게 됨. 통합 테스트만을 위한 것 | 
| 테스트당 목의 개수 | 테스트에서 단위 동작을 검증하는 데 필요한 목의 개수는 관계가 없음 하나의 테스트에서 여러 목을 사용할 수 있음. | 
| 호출 횟수 검증 | 비관리 의존성은 내가 관리할 수 없으므로 예상치 못한 API 사용을 잘 관리해야 함 - 호출 횟수를 정확하게 검증해야 함. - 예상하는 호출과 예상치 못한 호출 모두 확인해야 함. | 
| - 비관리 의존성 관리 | 비관리 의존성이 업데이트되더라도, 이전 서비스 메서드에서 호출한 것이 호환되어 잘 동작해야 함.  - 호출 횟수나 예상치 못한 기능이 발생했는지 확인해야 함. | 
| 보유 타입만 목으로 처리 | 서드파티 라이브러리 위에 항상 어댑터를 작성하고 기본 타입 대신 어댑터를 목으로 처리해야 함.  - 서드파티 코드가 어떻게 변경될지 알 수 없기 때문. | 
| - 서드파티 라이브러리 어댑터 사용 | 서드파티 코드의 기술 세부 사항까지 필요 없음 이를 추상화하고 애플리케이션 관점에서 라이브러리와의 관계를 정의하기 손상 방지 계층으로 동작. | 
예시) 비관리 의존성 관리
더보기
결제 API
public interface PaymentService {
    void processPayment(String userId, double amount);
}
public class ExternalPaymentServiceV1 implements PaymentService {
    @Override
    public void processPayment(String userId, double amount) {
        // 외부 API V1 호출: 실제 결제 처리 로직
        System.out.println("Processing payment via V1 API...");
    }
}
// 결제 API 버전 2 
public class ExternalPaymentServiceV2 implements PaymentService {
    // 메서드 시그니처 변경됨
    @Override
    public void processPayment(String userId, BigDecimal amount) {
        // 외부 API V2 호출: 실제 결제 처리 로직 (API 변경 사항 반영)
        System.out.println("Processing payment via V2 API...");
    }
}
어댑터 패턴 적용
public class PaymentServiceAdapterV1ToV2 implements PaymentService {
    private final ExternalPaymentServiceV2 paymentServiceV2;
    public PaymentServiceAdapterV1ToV2(ExternalPaymentServiceV2 paymentServiceV2) {
        this.paymentServiceV2 = paymentServiceV2;
    }
    @Override
    public void processPayment(String userId, double amount) {
        // V1과 V2 API 간의 호환성 문제를 해결하는 어댑터 로직
        // V1에서 V2로 호환성 맞추기 위해 double을 BigDecimal로 변환        
        paymentServiceV2.processPayment(userId, BigDecimal.valueOf(amount));
    }
}
테스트
@Test
public void testPaymentProcessing() {
    // Mocking the PaymentService to simulate API behavior
    PaymentService paymentServiceMock = mock(PaymentService.class);
    PaymentController paymentController = new PaymentController(paymentServiceMock);
    // Calling the method under test
    paymentController.makePayment("user123", 100.0);
    // Verifying that the processPayment method was called once
    verify(paymentServiceMock, times(1)).processPayment("user123", 100.0);
}
예시) 서드파티 라이브러리 어댑터 사용
더보기
// 서드파티 라이브러리 인터페이스
public interface EmailService {
    void sendEmail(String email, String message);
}
// 서드파티 라이브러리 어댑터
public class ThirdPartyEmailAdapter implements EmailService {
    private ThirdPartyEmailService thirdPartyEmailService;
    public ThirdPartyEmailAdapter(ThirdPartyEmailService thirdPartyEmailService) {
        this.thirdPartyEmailService = thirdPartyEmailService;
    }
    @Override
    public void sendEmail(String email, String message) {
        thirdPartyEmailService.sendEmail(email, message); // 내부 호출
    }
}
// 애플리케이션에서 어댑터 사용
public class UserController {
    private EmailService emailService;
    public UserController(EmailService emailService) {
        this.emailService = emailService;
    }
    public void sendEmailToUser(String email, String message) {
        emailService.sendEmail(email, message);
    }
}@Test
public void testSendEmail() {
    // EmailService 목 객체 생성
    EmailService emailServiceMock = mock(EmailService.class);
    UserController userController = new UserController(emailServiceMock);
    // 메일 발송 동작 호출
    userController.sendEmailToUser("test@example.com", "Test message");
    // 예상되는 동작 확인
    verify(emailServiceMock).sendEmail("test@example.com", "Test message");
}
'Code > Test' 카테고리의 다른 글
| [더 자바, 애플리케이션을 테스트하는 다양한 방법] 2. Mockito (0) | 2025.04.19 | 
|---|---|
| [더 자바, 애플리케이션을 테스트하는 다양한 방법] 1. JUnit 5 (0) | 2025.04.19 | 
| [단위 테스트] 8-2. 통합 테스트를 하는 이유: 인터페이스 (1) | 2025.03.01 | 
| [단위 테스트] 8-1. 통합 테스트를 하는 이유: 통합 테스트 (0) | 2025.02.28 | 
| [단위 테스트] 7-2. 가치 있는 단위 테스트를 위한 리팩터링: 감사 시스템 (0) | 2025.02.28 |