Code/Test

[단위 테스트] 5-2. 목과 테스트 취약성: 통신

noahkim_ 2025. 1. 24. 21:53

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


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

 

목을 사용한 동작 검증

  • 목 객체는 주로 애플리케이션의 경계를 넘는 상호작용이나 외부 의존성의 동작을 검증할 때 사용됩니다.
  • 클라이언트 목표에 부합하는 동작이 제대로 이루어졌는지 + 상호작용의 부작용이 제대로 처리되었는지를 검증하는 것이 핵심입니다.