Code/Test

[단위 테스트] 9. 목 처리에 대한 모범 사례

noahkim_ 2025. 3. 1. 06:54

블라디미르 코리코프 님의 "단위 테스트" 책을 정리한 포스팅입니다.


목에 대해 리팩터링 내성과 회귀 방지를 최대화해서 최대 가치의 통합 테스트 개발하기

 

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");
}