티스토리 뷰

728x90

10주간 항해를 마치고 부족한 부분을 다시 공부하고자 항해 그 후 스터디에 참여를 하여 스터디를 진행을 하였습니다.

스터디를 진행을 하면서 개인적으로 공부를 했던것을 기록하려고 합니다.

 

해당 내용은 항해에서 배웠던 10주간 챕터들 중에서 항해하면서 부족하고 추가로 공부할 필요가 있는 것을 키워드 별로 공부를 진행 했습니다.

 

런던파와 고전파에 대한 이해와 본인의 견해 수립

런턴파와 고전파의 가장 큰 차이는 테스트에서의 격리(isolation)를 어떻게 정의하고 구현하느냐이다.

런던파

런던파는 테스트 대상 코드가 다른 객체/클래스에 의존할 경우, 이 의존성을 모두 테스트 대역(test double) 으로 대체해야 한다고 본다.

테스트 대역은 실제 객체 대신 사용하는 ‘단순화된 대체’ 객체로, 복잡성을 줄이고 테스트를 격리한다.

특징

  • 테스트 대상 코드(Class)를 협력자(Collaborator)로부터 완전히 분리
  • 실제 객체 대신 mock(모의 객체) 을 적극적으로 사용
  • 테스트 실패 시 문제가 반드시 테스트 대상 코드에 있음이 확인됨
  • 객체 간 상호작용(interaction) 를 검증하는 방식 중심
  • 하나의 클래스만 독립적으로 테스트 하는 구조를 지향
public class PaymentService {
    private final PaymentGateway paymentGateway;
    private final OrderRepository orderRepository;

    public PaymentService(PaymentGateway paymentGateway,
                          OrderRepository orderRepository) {
        this.paymentGateway = paymentGateway;
        this.orderRepository = orderRepository;
    }

    public void pay(long orderId) {
        Order order = orderRepository.find(orderId);
        paymentGateway.charge(order.getAmount());
        orderRepository.markPaid(orderId);
    }
}
@ExtendWith(MockitoExtension.class)
class PaymentServiceLondonTest {

    @Mock
    PaymentGateway paymentGateway;

    @Mock
    OrderRepository orderRepository;

    @InjectMocks
    PaymentService paymentService;

    @Test
    void 결제하면_PG와_Repository가_정확히_호출된다() {
        Order order = new Order(1L, 100);
        when(orderRepository.find(1L)).thenReturn(order);

        paymentService.pay(1L);

        verify(orderRepository).find(1L);
        verify(paymentGateway).charge(100);
        verify(orderRepository).markPaid(1L);
    }
}

⇒ 객체 간 협력 구조가 테스트 대상

고전파

고전파는 단위 테스트에서 모든 의존성을 대역으로 바꾸는 것을 지양한다.

특징

  • 공유 상태(shared state) 를 유발할 수 있는 외부 의존성(예: DB, 파일 시스템)만 테스트 대역을 쓴다
  • 나머지 협력 객체는 실제 객체(real instance) 를 사용해 테스트한다
  • 테스트는 서로 독립적으로 실행되도록 만들고 격리하지만, 의존성 자체는 교체하지 않는다
class FakePaymentGateway implements PaymentGateway {
    @Override
    public void charge(int amount) {
        // 아무것도 안 함 (항상 성공)
    }
}

class InMemoryOrderRepository implements OrderRepository {
    private final Map<Long, Order> store = new HashMap<>();

    public InMemoryOrderRepository() {
        store.put(1L, new Order(1L, 100, "CREATED"));
    }

    @Override
    public Order find(long id) {
        return store.get(id);
    }

    @Override
    public void markPaid(long id) {
        store.get(id).setStatus("PAID");
    }
}
class PaymentServiceClassicalTest {

    @Test
    void 결제하면_주문상태가_PAID가_된다() {
        OrderRepository repo = new InMemoryOrderRepository();
        PaymentGateway gateway = new FakePaymentGateway();

        PaymentService service = new PaymentService(gateway, repo);

        service.pay(1L);

        Order order = repo.find(1L);
        assertEquals("PAID", order.getStatus());
    }
}

런던파 vs 고전파

구분 런던파 고전파

관심사 객체 간 호출 관계 최종 상태 / 결과
Mock 매우 많이 씀 최소한만
테스트 실패 이유 “A가 B를 안 불렀음” “결과가 틀림”
리팩토링 깨지기 쉬움 비교적 안정
속도 빠름 느릴 수 있음
  • 두 분파 차이의 핵심은 “격리”해석에 있다.
  • 런던파는 의존성을 강제로 대역으로 바꿔 테스트를 더 격리시킨다.
  • 고전파는 실제 의존 객체를 활용하여 테스트의 현실성을 유지한다.
  • 어느 쪽이 옳다기보다 목적에 따라 선택해 사용하는 것이 중요하다.
참고

 

 

테스트 대역(Test Double)에 대한 이해

테스트에서 진짜 객체 대신 쓰는 가짜 객체들을 통칭하는 말

(DB, 외부 API, 결제 모듈 같은 “의존성”을 진짜로 쓰면 테스트가 느리고 불안정해지니까 대신 넣는 것)

“진짜 대신 써서 테스트를 안정적으로 만들기 위한 모든 가짜들” = 테스트 대역

1. Dummy

아무 역할도 없는 채우기용 객체

Dummy 객체는 테스트 대상 코드가 정상적으로 실행되도록 타입과 의존성만 충족시키는 용도의 객체이다.

Dummy는 어떤 행동도 수행하지 않으며, 호출되더라도 의미 있는 결과를 만들지 않는다. 또한 테스트의 검증 대상이 아니며, 결과값이나 호출 여부를 확인하는 데 사용되지 않는다.

 

(예시)

주문을 취소하는 서비스가 있고, 이 서비스는 EmailService와 OrderRepository를 의존한다.

여기서 cancelOrder()가 delete를 호출하는지만 테스트를 하고 싶은 상황

public class OrderService {

    private final OrderRepository orderRepository;
    private final EmailService emailService;

    public OrderService(OrderRepository orderRepository,
                        EmailService emailService) {
        this.orderRepository = orderRepository;
        this.emailService = emailService;
    }

    public void cancelOrder(long orderId) {
        orderRepository.delete(orderId);
        // emailService는 여기서 안 쓰임
    }
}

 

Dummy 구현

public class DummyEmailService implements EmailService {
    @Override
    public void sendCancelEmail(long orderId) {
        // 아무것도 하지 않음
    }
}

⇒ 이 객체는 컴파일을 위해 필요하지만, 실행 결과에는 아무런 영향도 주지 않는다.

 

test code

@Test
void cancelOrder_calls_delete() {
    OrderRepository fakeRepo = new FakeOrderRepository();
    EmailService dummyEmailService = new DummyEmailService();

    OrderService service = new OrderService(fakeRepo, dummyEmailService);

    service.cancelOrder(10L);

    assertTrue(fakeRepo.isDeleted(10L));
}

 

여기서 dummyEmailService는 호출되든 말든 중요하지 않고, 테스트의 검증 대상이 아니다.

 

2. Stub

“정해진 값”만 돌려주는 객체 Dummy 가 아무것도 하지 않는 객체라면, Stub 은 필요한 값만 돌려주는 객체이다.

 

Stub은 테스트 대상이 외부 의존성(DB, API, 시간, 환경 등)에 의존하지 않도록 하기 위해 사용된다.

즉,테스트 대상 로직이 특정 상황에 있다고 믿게 만들기 위해 사용하는 객체이다.

 

특징

  1. 호출되면 미리 정해진 값을 반환한다.
  2. 내부 로직은 없다.
  3. 호출 횟수나 인자는 검증하지 않는다.
  4. 테스트는 Stub 이 만든 결과값을 기반으로 판단한다.

(예시)

public class DiscountService {

    private final UserRepository userRepository;

    public DiscountService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    public int getDiscount(long userId) {
        User user = userRepository.findById(userId);

        if (user.isVip()) {
            return 30;
        }
        return 10;
    }
}

 

Stub 구현

public class VipUserRepositoryStub implements UserRepository {

    @Override
    public User findById(long userId) {
        return new User(userId, true); // 항상 VIP 유저 반환
    }
}

 

⇒ DB 를 조회하지 않고 항상 vip 유저를 반환하여 테스트 환경을 강제로 vip 상태로 만든다.

 

test code

@Test
void vipUser_gets_30_percent_discount() {
    UserRepository stub = new VipUserRepositoryStub();
    DiscountService service = new DiscountService(stub);

    int discount = service.getDiscount(1L);

    assertEquals(30, discount);
}

 

⇒ findById()가 호출됐는지 관심 없으며, 오직 반환된 discount 값만 검증한다.

 

3. Spy

호출 여부를 기록하는 객체 Stub 이 값을 통제했다면, Spy 는 무슨 일이 일어났는지 기록 한다.

 

메서드의 실제 동작은 그대로 수행하면서 이 메서드가 호출되었는가 / 몇번 호출되었는가 / 어떤 인자로 호출되었는가를 확인하고 싶을때 사용된다.

 

특징

  1. 실제 구현을 감싸거나 위임한다.
  2. 호출 기록을 저장한다.
  3. 결과값은 실제 로직에 따른다.
  4. 테스트는 호출여부 + 결과 둘 다 볼 수 있다.

 

(예시)

public class EmailServiceSpy implements EmailService {

    private int sendCount = 0;

    @Override
    public void sendCancelEmail(long orderId) {
        sendCount++;
        System.out.println("메일 발송됨"); // 실제 동작
    }

    public int getSendCount() {
        return sendCount;
    }
}

 

test code

@Test
void cancelOrder_sends_email() {
    EmailServiceSpy spy = new EmailServiceSpy();
    OrderRepository dummyRepo = new DummyOrderRepository();

    OrderService service = new OrderService(dummyRepo, spy);

    service.cancelOrder(1L);

    assertEquals(1, spy.getSendCount());
}

 

⇒ 실제 내부 로직이 실행이 되고, 몇번 호출됐는지도 검증한다.

 

4. Mock

행위까지 검증하는 객체 Stub 은 값을 검증하고, Mock 은 행동을 검증한다.

 

특징

  1. 실제 동작 없다.
  2. 호출 규칙을 미리 정의한다.
  3. 테스트가 끝나면 자동으로 검증을 한다.
  4. 행위기반(Behavior-based) 테스트

 

(예시)

EmailService emailMock = mock(EmailService.class);

OrderService service =
    new OrderService(dummyRepo, emailMock);

service.cancelOrder(1L);

verify(emailMock).sendCancelEmail(1L);

 

⇒ 메일이 실제로 보내졌는지가 아니라 오직 sendCancelEmail 이 호출됐는지만 검증을 한다.

 

5. Fake

진짜처럼 동작하지만 단순한 구현

 

실제 구현(DB, Redis, 외부 API)이 너무 무겁거나 느릴 때, 테스트에서 “진짜처럼” 여러 로직을 실행해야 할 때 Fake 가 사용된다.

 

특징

  1. 실제와 유사한 로직을 가진다.
  2. 상태를 저장한다.
  3. 여러 메서드가 서로 영향을 준다.
  4. Mock 처럼 호출 규칙을 검증하지 않는다.
  5. Stub 보다 훨씬 많은 로직을 가진다.

 

(예시)

public interface UserRepository {
    void save(User user);
    User findById(long id);
}

 

Fake

public class FakeUserRepository implements UserRepository {

    private Map<Long, User> store = new HashMap<>();

    @Override
    public void save(User user) {
        store.put(user.getId(), user);
    }

    @Override
    public User findById(long id) {
        return store.get(id);
    }
}

 

⇒ 실제 DB 가 없고 메모리 Map 을 사용하며 동작은 진짜 Repository 와 동일하다.

 

test code

@Test
void user_can_be_saved_and_loaded() {
    UserRepository fakeRepo = new FakeUserRepository();
    UserService service = new UserService(fakeRepo);

    User user = new User(1L, "user");
    service.register(user);

    User loaded = service.find(1L);

    assertEquals("user", loaded.getName());
}
참고

 

단위 테스트와 통합 테스트의 차이와 장단점에 대한 이해

단위테스트(Unit Test)

클래스 하나를 고립시켜서 로직만 검증한다.

 

구분 내용 실무 의미

장점 실행 속도가 매우 빠름 수백~수천 개도 CI에서 몇 초
  실패 원인이 명확함 어떤 클래스/메서드가 문제인지 바로 앎
  리팩토링 안전망 내부 구조 바꿔도 기능 깨졌는지 바로 확인
  Spring 없이 실행 가능 IDE에서 바로 실행 가능
  TDD에 적합 설계가 자연스럽게 좋아짐
단점 DB, JPA, 트랜잭션 검증 불가 쿼리, 매핑 오류는 잡지 못함
  실제 Bean 구성과 다를 수 있음 @Autowired, @Qualifier 오류 못 잡음
  Mock이 많아질수록 테스트가 복잡 유지보수 비용 증가
  “실제 서비스 동작” 보장은 못함 통과했는데 API는 깨질 수 있음

 

서비스코드

public class OrderService {
    private final DiscountPolicy discountPolicy;

    public OrderService(DiscountPolicy discountPolicy) {
        this.discountPolicy = discountPolicy;
    }

    public int calculatePrice(int price) {
        return price - discountPolicy.discount(price);
    }
}

 

test code

@ExtendWith(MockitoExtension.class)
class OrderServiceTest {

    @Mock
    DiscountPolicy discountPolicy;

    @InjectMocks
    OrderService orderService;

    @Test
    void 할인정책이_적용된_가격을_계산한다() {
        when(discountPolicy.discount(10000)).thenReturn(1000);

        int result = orderService.calculatePrice(10000);

        assertThat(result).isEqualTo(9000);
    }
}

 

통합테스트(Integration Test)

Spring Boot 전체를 띄우고 실제 빈, 실제 DB와 함께 테스트

 

구분 내용 실무 의미

장점 실제 Spring Bean 조합 검증 설정 오류, DI 오류를 잡아줌
  JPA, 쿼리, 매핑 검증 실무 장애의 대부분을 미리 차단
  트랜잭션 동작 확인 commit/rollback 문제 검증
  API 단위 검증 가능 프론트와의 계약 테스트
단점 실행 속도가 느림 Spring + DB 기동
  실패 원인 추적이 어려움 어느 계층이 문제인지 불명확
  테스트 데이터 관리 필요 초기화, 롤백, 시드 필요
  CI 비용 큼 Testcontainer, Docker 필요할 수 있음
@SpringBootTest
@Transactional
class OrderServiceIntegrationTest {

    @Autowired
    OrderService orderService;

    @Autowired
    OrderRepository orderRepository;

    @Test
    void 주문이_DB에_저장된다() {
        Order order = new Order(10000);

        orderService.placeOrder(order);

        Order saved = orderRepository.findById(order.getId()).get();
        assertThat(saved.getPrice()).isEqualTo(10000);
    }
}

 

좋은 테스트 코드에 대한 이해

https://toss.tech/article/test-strategy-server

 

좋은 테스트

  1. Fast: 테스트는 빠르게 동작하여 자주 돌릴 수 있어야 함
  2. Independent: 각각의 테스트는 독립적이며 서로 의존해서는 안 됨
  3. Repeatable: 어느 환경에서도 반복 가능해야 함
  4. Self-Validating: 테스트는 성공 또는 실패로 bool 값으로 결과를 내어 자체적으로 검증되어야 함
  5. Timely: 테스트는 적시에 즉, 테스트하려는 실제 코드를 구현하기 직전에 구현해야 함

https://tech.inflab.com/20230404-test-code/#테스트-코드를-왜-작성하는-것인가

https://jojoldu.tistory.com/674

728x90

'Coding > Dev Study' 카테고리의 다른 글

[항해 복귀 스터디] 2주차 설계 + 아키텍처 패턴  (0) 2026.02.02