블라디미르 코리코프 님의 "단위 테스트" 책을 정리한 포스팅입니다.
3. 목과 테스트 취약성 간의 관계
육각형 아키텍처
- 도메인을 중심으로, 포트(Port)와 어댑터(Adapter)를 통해 외부와의 상호작용을 처리하는 구조
구성 요소 | 설명 |
도메인 |
애플리케이션의 중심부.
비즈니스 로직이 포함됨. |
애플리케이션 서비스 |
도메인 계층 위에 위치.
도메인 계층과 외부 시스템과의 상호 작용을 처리 도메인 계층을 호출하고 외부와의 통신을 조정함 |
포트 |
애플리케이션이 외부 시스템과 상호작용할 수 있도록 하는 인터페이스
입력 포트 / 출력 포트로 구분됨 |
어댑터 | 실제 외부 시스템과 상호작용을 담당하는 부분 포트를 구현 |
예제) Order
더보기
도메인
public class Order {
private String orderId;
private String customerId;
private List<Item> items;
private OrderStatus status;
public Order(String orderId, String customerId, List<Item> items) {
this.orderId = orderId;
this.customerId = customerId;
this.items = items;
this.status = OrderStatus.PENDING;
}
public void addItem(Item item) {
this.items.add(item);
}
public void completeOrder() {
this.status = OrderStatus.COMPLETED;
}
}
public enum OrderStatus {
PENDING, COMPLETED, CANCELED;
}
애플리케이션 서비스
public class OrderService {
private OrderRepository orderRepository;
public OrderService(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
public void createOrder(String customerId, List<Item> items) {
// 도메인 로직을 사용하여 주문 생성
Order order = new Order(UUID.randomUUID().toString(), customerId, items);
orderRepository.save(order);
}
public void completeOrder(String orderId) {
// 도메인 로직을 사용하여 주문 완료 처리
Order order = orderRepository.findById(orderId);
order.completeOrder();
orderRepository.save(order);
}
}
포트
public interface OrderRepository {
void save(Order order);
Order findById(String orderId);
}
어댑터
public class JdbcOrderRepository implements OrderRepository {
private DataSource dataSource;
public JdbcOrderRepository(DataSource dataSource) {
this.dataSource = dataSource;
}
@Override
public void save(Order order) {
// JDBC를 사용하여 데이터베이스에 주문을 저장
String sql = "INSERT INTO orders (order_id, customer_id, status) VALUES (?, ?, ?)";
try (Connection conn = dataSource.getConnection()) {
try (PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setString(1, order.getOrderId());
ps.setString(2, order.getCustomerId());
ps.setString(3, order.getStatus().name());
ps.executeUpdate();
}
} catch (SQLException e) {
throw new RuntimeException("Error saving order", e);
}
}
@Override
public Order findById(String orderId) {
// JDBC를 사용하여 데이터베이스에서 주문 조회
String sql = "SELECT * FROM orders WHERE order_id = ?";
try (Connection conn = dataSource.getConnection()) {
try (PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setString(1, orderId);
ResultSet rs = ps.executeQuery();
if (rs.next()) {
return new Order(rs.getString("order_id"), rs.getString("customer_id"), null);
}
}
} catch (SQLException e) {
throw new RuntimeException("Error fetching order", e);
}
return null;
}
}
특징
구성 요소 | 설명 |
계층간 관심사 분리 | 애플리케이션은 도메인과 애플리케이션 서비스라는 두 계층으로 구성 책임을 알맞게 변환하여 처리. |
단방향 통신 |
애플리케이션 서비스 계층에서 도메인 계층으로 의존성 흐름이 존재.
애플리케이션 서비스 계층은 도메인 계층을 알고 있으며, 도메인 계층은 외부 환경과 격리됨. |
프렉탈 |
전체와 부분이 동일한 패턴이나 구조를 가진다는 개념
계층마다 동일한 테스트 목표를 유지하며, 비즈니스 요구사항 중심으로 테스트. 테스트가 비즈니스 요구사항과 연결되어 추적성이 높아짐. 테스트 구조가 일관되어 유지보수와 가독성이 향상됨. |
장점
장점 | 설명 |
유연성 |
- 외부 시스템이나 기술 스택이 변경되더라도 도메인 계층은 영향을 받지 않음.
- 새로운 기술을 도입할 때 애플리케이션 서비스 계층과 어댑터만 수정하면 됨. |
테스트 용이성 |
- 도메인 계층은 외부 시스템과 완전히 분리되어 비즈니스 로직을 독립적으로 테스트할 수 있음.
- 외부 시스템의 동작을 모킹(Mock)하여 도메인 로직을 순수하게 테스트 가능. |
비즈니스 로직의 독립성 |
- 비즈니스 로직이 외부 환경과 분리되어 있어 애플리케이션의 핵심 기능이 안정적이고 독립적으로 유지될 수 있음.
|
유지보수 용이성 |
- 각 계층이 독립적으로 관리되어 코드 변경 시 다른 부분에 미치는 영향을 최소화할 수 있음.
- 시스템의 특정 부분만 변경하거나 교체할 수 있어 유지보수가 용이함. |
시스템 내부 통신 vs 시스템 간 통신
구분 | 시스템 내부 통신 | 시스템 간 통신 |
설명 | 애플리케이션 내 클래스 간의 통신. 식별할 수 있는 동작이 아님. |
해당 시스템의 식별할 수 있는 동작을 나타냄 (계약).
항상 외부 애플리케이션이 이해할 수 있도록 유지되어야 함. |
성격 | 구현 세부 사항, 내부적인 동작에 해당. |
외부 시스템과의 명확한 계약과 동작 규약에 해당.
|
목표 | 클라이언트의 목표와 직접적인 관계가 없음 시스템 내부에서만 사용됨. |
클라이언트 또는 외부 애플리케이션의 목표와 연결됨
외부와의 상호작용에 중점을 둠. |
호환성 | 별도 애플리케이션과 함께 성장하는 방식. 하위 호환성이 지켜져야 함. |
시스템 간 동작 계약을 준수해야 함. 외부 시스템과의 호환성을 유지해야 함 |
4. 단위 테스트의 고전파와 런던파 재고
런던파
- 불변 의존성을 제외한 모든 의존성에 목 사용 권장
- 시스템 내 통신과 시스템 간 통신을 구분하지 않음
고전파
- 공유 의존성을 피할 것을 권고함
- 테스트가 실행 컨텍스트를 서로 방해하여, 병렬 처리를 할 수 없게 하기 때문
모든 프로세스 외부 의존성을 목으로 해야 하는 것은 아니다
상황 | 설명 | 적절한 테스트 전략 |
외부 의존성과의 통신이 구현 세부사항인 경우 | - 외부 의존성과의 통신은 비즈니스 요구사항과 무관한 구현 세부사항 - 통신 패턴 변경 시에도 비즈니스 요구사항은 영향을 받지 않음. |
- 외부 통신에 대한 의존성을 격리시켜 테스트. - 외부 API 통신 인터페이스를 Mocking - 프록시 클래스를 사용하여 외부 시스템과의 통신을 추상화. |
외부 시스템과 통신하는 패턴이 비즈니스 요구사항인 경우 | 외부 의존성과의 통신 패턴이 비즈니스 요구사항의 일환으로 설계된 경우 | - 통합 테스트로 외부 시스템과의 통신이 정상적으로 이루어지는지 확인. - 외부 의존성의 하위 호환성 검증 필요. |
외부 의존성이 독립적인 시스템일 경우 | 외부 의존성이 독립적인 시스템일 경우, 의존성의 상태가 변경될 때 목(mock) 테스트가 깨지기 쉬움. | - 실제 시스템에 대한 통합 테스트를 수행하여 시스템 동작 확인. - Mock 테스트는 적합하지 않음. |
예제) 외부 의존성과의 통신이 구현 세부사항인 경우
더보기
// 비즈니스 로직: OrderService
public class OrderService {
private PaymentApiProxy paymentApiProxy;
public OrderService(PaymentApiProxy paymentApiProxy) {
this.paymentApiProxy = paymentApiProxy;
}
public String processOrder(Order order) {
boolean isPaymentSuccessful = paymentApiProxy.makePayment(order);
if (isPaymentSuccessful) {
return "주문이 성공적으로 처리되었습니다.";
} else {
return "결제 실패. 주문을 다시 시도해주세요.";
}
}
}
// 프록시 클래스: PaymentApiProxy
public class PaymentApiProxy {
private ExternalPaymentApi externalPaymentApi;
public PaymentApiProxy(ExternalPaymentApi externalPaymentApi) {
this.externalPaymentApi = externalPaymentApi;
}
// 실제 외부 API와의 통신을 숨김
public boolean makePayment(Order order) {
return externalPaymentApi.processPayment(order.getAmount());
}
}
// 외부 통신 객체: ExternalPaymentApi
public class ExternalPaymentApi {
public boolean processPayment(double amount) {
System.out.println("외부 결제 시스템과 통신 중... 결제 금액: " + amount);
return true; // 성공적으로 결제했다고 가정
}
}
// 주문 클래스: Order
public class Order {
private double amount;
public Order(double amount) {
this.amount = amount;
}
public double getAmount() {
return amount;
}
}
public class OrderServiceTest {
@Test
public void testProcessOrderSuccess() {
// 1. 외부 결제 API 객체 모킹
ExternalPaymentApi mockPaymentApi = mock(ExternalPaymentApi.class);
when(mockPaymentApi.processPayment(100.0)).thenReturn(true); // 결제 성공 시나리오
// 2. 프록시 객체 생성 (모킹된 결제 API를 사용)
PaymentApiProxy paymentApiProxy = new PaymentApiProxy(mockPaymentApi);
// 3. 주문 서비스 객체 생성
OrderService orderService = new OrderService(paymentApiProxy);
// 4. 주문 처리
Order order = new Order(100.0);
String result = orderService.processOrder(order);
// 5. 결과 검증
assertEquals("주문이 성공적으로 처리되었습니다.", result);
}
@Test
public void testProcessOrderFailure() {
// 1. 외부 결제 API 객체 모킹
ExternalPaymentApi mockPaymentApi = mock(ExternalPaymentApi.class);
when(mockPaymentApi.processPayment(200.0)).thenReturn(false); // 결제 실패 시나리오
// 2. 프록시 객체 생성 (모킹된 결제 API를 사용)
PaymentApiProxy paymentApiProxy = new PaymentApiProxy(mockPaymentApi);
// 3. 주문 서비스 객체 생성
OrderService orderService = new OrderService(paymentApiProxy);
// 4. 주문 처리
Order order = new Order(200.0);
String result = orderService.processOrder(order);
// 5. 결과 검증
assertEquals("결제 실패. 주문을 다시 시도해주세요.", result);
}
}
예제) 외부 시스템과 통신하는 패턴이 비즈니스 요구사항인 경우
더보기
public class PaymentService {
private final PaymentGateway paymentGateway;
public PaymentService(PaymentGateway paymentGateway) {
this.paymentGateway = paymentGateway;
}
public String processPayment(double amount) {
// 외부 결제 시스템과의 통신은 비즈니스 요구사항의 핵심
return paymentGateway.makePayment(amount);
}
}
public interface PaymentGateway {
String makePayment(double amount);
}
@Test
public void testPaymentProcessing() {
PaymentGateway paymentGateway = new RealPaymentGateway(); // 실제 결제 API 사용
PaymentService paymentService = new PaymentService(paymentGateway);
String response = paymentService.processPayment(100.0);
// 실제 결제 시스템의 응답을 확인
assertEquals("Payment Successful", response);
}
예제) 외부 의존성이 독립적인 시스템일 경우
더보기
public class SmsService {
private final SmsGateway smsGateway;
public SmsService(SmsGateway smsGateway) {
this.smsGateway = smsGateway;
}
public boolean sendSms(String phoneNumber, String message) {
return smsGateway.send(phoneNumber, message);
}
}
public interface SmsGateway {
boolean send(String phoneNumber, String message);
}
@Test
public void testSendSmsIntegration() {
SmsGateway smsGateway = new RealSmsGateway(); // 실제 SMS 발송 API 사용
SmsService smsService = new SmsService(smsGateway);
boolean result = smsService.sendSms("123-456-7890", "Test Message");
// 실제 SMS 발송 시스템에 대한 응답 확인
assertTrue(result);
}
목을 사용한 동작 검증
- 목 객체는 주로 애플리케이션의 경계를 넘는 상호작용이나 외부 의존성의 동작을 검증할 때 사용됩니다.
- 클라이언트 목표에 부합하는 동작이 제대로 이루어졌는지 + 상호작용의 부작용이 제대로 처리되었는지를 검증하는 것이 핵심입니다.
'Code > Test' 카테고리의 다른 글
[단위 테스트] 6-2. 단위 테스트 스타일: 함수형 아키텍처 (0) | 2025.01.29 |
---|---|
[단위 테스트] 6-1. 단위 테스트 스타일: 단위 테스트 스타일 (0) | 2025.01.25 |
[단위 테스트] 5-1. 목과 테스트 취약성: 목 (0) | 2025.01.24 |
[단위 테스트] 4-2. 좋은 단위 테스트의 4대 요소: 이상적인 테스트 (0) | 2025.01.24 |
[단위 테스트] 4-1. 좋은 단위 테스트의 4대 요소: 4대 요소 (0) | 2025.01.19 |