블라디미르 코리코프 님의 "단위 테스트" 책을 정리한 포스팅입니다.
목에 대해 리팩터링 내성과 회귀 방지를 최대화해서 최대 가치의 통합 테스트 개발하기
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 |