테스트 전략에서 단위 테스트와 통합 테스트의 차이점과 의존성 관리
TDD를 도입해서 개발을 하고 있지만 여전히 단위 테스트와 통합 테스트의 차이를 명확하게 알지 못해 테스트 코드를 작성하는데 오랜 시간이 걸렸다. 한번 두 개의 차이점을 제대로 이해하고 넘어가 볼 필요가 있어서 내가 이해한 대로 최대한 정리해 봤다.
단위 테스트 (Unit Test)
단위 테스트는 개별 함수나 클래스가 독립적으로 올바르게 작동하는지 검증하는 데 중점을 둔다. 이 과정을 통해 각 컴포넌트가 예상대로 기능하는지 확인할 수 있다.
모의 객체(Mock) 사용
단위 테스트에서는 의존하는 외부 시스템이나 클래스를 모의 객체로 대체하여, 테스트 대상 코드의 기능을 독립적으로 검증한다. 이렇게 함으로써, 실제 의존성이 아니라 테스트 대상 코드의 동작에 초점을 맞출 수 있다.
격리된 테스트
단위 테스트의 핵심은 테스트 대상이 외부 요인으로부터 독립적으로 작동하는 것을 확인하는 것이다. 이를 통해 코드의 특정 부분이 문제없이 작동함을 보장할 수 있다.
자동화 및 반복 가능
단위 테스트는 자동화되어 있으며, 코드 변경이 있을 때마다 쉽게 반복 실행할 수 있어야 한다. 이는 개발 과정에서 코드가 지속적으로 업데이트되고 개선됨에 따라, 새로운 버그가 없는지 빠르게 확인할 수 있게 해 준다.
Service 클래스를 단위 테스트할 때는 내부에서 사용하는 의존성(예: Port, Repository)을 모의 객체로 대체한다. 이는 Service의 내부 로직만을 격리하여 테스트하는 데 중요하다. Service 객체 자체는 실제 구현체를 사용할 수도 있고, 필요에 따라 모의 객체로 만들 수도 있다.
서비스 클래스를 단위 테스트할 때는 해당 서비스의 메서드들이 올바르게 동작하는지 확인하는 데 집중한다. 이 과정에서 서비스 클래스가 의존하는 외부 컴포넌트(예: Port, Repository 등)는 모의 객체(Mock)로 대체되며, 이 모의 객체들이 특정 상황에서 예상하는 반환 값을 제공하도록 설정해야 한다.
이를 위해 Mockito와 같은 모킹 라이브러리를 사용하여, 서비스가 의존하는 컴포넌트가 특정 입력에 대해 특정 출력을 반환하도록 설정할 수 있다.
예를 들어, 서비스가 Port의 메서드를 호출하고 그 결과를 바탕으로 추가 처리를 하는 경우, when().thenReturn() 구문을 사용하여 해당 메서드 호출에 대한 응답을 모의할 수 있다.
다음은 서비스 클래스의 단위 테스트 예시 코드다:
import static org.mockito.Mockito.*;
import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.DisplayName;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.junit.jupiter.api.extension.ExtendWith;
@ExtendWith(MockitoExtension.class)
public class MyServiceTest {
@Mock
private MyPort myPort;
@InjectMocks
private MyService myService;
@DisplayName("MyService의 someServiceMethod()가 올바른 값을 반환하는지 검증")
@Test
void testServiceMethod() {
// given: MyPort의 someMethod()가 특정 상황에서 "expectedValue"를 반환한다고 설정
when(myPort.someMethod(any())).thenReturn("expectedValue");
// when: MyService의 someServiceMethod() 실행
String result = myService.someServiceMethod();
// then: 결과가 "expectedValue"와 일치하는지 확인
assertEquals("expectedValue", result);
// then: MyPort의 someMethod()가 실제로 호출되었는지 확인
verify(myPort).someMethod(any());
}
}
이 예시에서 MyService 클래스는 MyPort에 의존하고 있으며, MyService의 someServiceMethod() 메서드는 MyPort의 someMethod() 메서드를 호출한다.
단위 테스트에서는 when().thenReturn() 구문을 사용하여 MyPort의 someMethod()가 호출될 때 "expectedValue"를 반환하도록 설정한다. 그런 다음 MyService의 someServiceMethod()를 실행하고, 이 메서드가 기대하는 결과를 반환하는지 검증한다.
when()과 thenReturn()은 Mockito 라이브러리를 사용하는 Java 단위 테스트에서 매우 중요한 메서드다. 이들을 사용하여 모의 객체(Mock)의 특정 메소드 호출에 대한 반환 값을 설정할 수 있다. 이를 통해 단위 테스트 시 실제 의존성 대신 특정 행동을 모방하는 모의 객체를 사용할 수 있다.
when(myPort.someMethod(any())).thenReturn("expectedValue");
- when()은 Mockito에서 제공하는 메서드로, 특정 조건에서 특정 결과를 반환하도록 모의 객체(Mock)를 설정하는 데 사용된다.
- myPort.someMethod(any())는 myPort의 someMethod가 어떤 인자로 호출되더라도 적용되는 조건을 정의한다.
- thenReturn("expectedValue")는 위에서 정의한 조건이 충족될 때 "expectedValue"라는 값을 반환하도록 설정한다.
any()
- any()는 Mockito에서 제공하는 아규먼트 매처(argument matcher)이다. 이는 메서드가 어떤 타입의 인자로 호출되더라도 매치되도록 한다.
즉, someMethod 메서드에 어떤 인자가 전달되든 간에, when().thenReturn()에 정의된 행동을 수행하도록 설정한다.
🔥 이 구문 전체는 myPort.someMethod가 호출될 때 예상되는 결과를 설정하는데, 이는 테스트 실행 전에 필요한 상황을 준비하는 given 절에 속한다. 그것과 별개로 when과 then은 각각 테스트를 수행하는 동작과 결과를 검증하는 부분을 의미한다.
@ExtendWith(MockitoExtension.class)는 JUnit 5 테스트 프레임워크에서 사용되는 어노테이션으로, 테스트 클래스가 Mockito 프레임워크의 기능을 사용할 수 있도록 확장하는 데 사용된다.
용도
- JUnit 5에서 제공하는 @ExtendWith 어노테이션은 테스트 실행 환경을 커스텀 확장할 수 있게 해 준다. 이는 테스트 클래스에 추가적인 기능을 제공하거나, 특정한 설정을 적용하는 데 사용된다.
- MockitoExtension.class는 Mockito 프레임워크와 관련된 확장 기능을 나타낸다. 이를 통해 Mockito의 모의 객체 생성 및 주입 기능을 테스트 클래스에서 활용할 수 있다.
기능 1: 자동 모의 객체 생성
@Mock으로 어노테이션 된 필드에 자동으로 모의 객체(Mock)를 생성하고 주입한다.
기능 2: 의존성 주입
@InjectMocks를 사용한 필드에 모의 객체를 자동으로 주입한다. 이는 테스트 대상 클래스가 필요로 하는 의존성을 모의 객체로 채워 넣는 데 사용된다.
기능 3: 테스트 간결화
테스트 클래스에서 별도의 Mockito 초기화 코드 없이 바로 Mockito의 기능을 사용할 수 있게 해 준다.
통합 테스트 (Integration Test)
통합 테스트는 여러 컴포넌트나 시스템이 함께 어떻게 작동하는지 검증한다. 시스템의 다양한 부분이 함께 올바르게 작동하는지 확인하는 과정이다.
실제 의존성 사용
통합 테스트의 주된 목적은 실제 운영 환경에서 시스템이 어떻게 동작하는지 확인하는 것이다. 이를 위해 실제 의존성을 사용하는 것이 중요하다.
전체 시스템의 일부를 테스트
통합 테스트는 일반적으로 전체 시스템이나 애플리케이션의 큰 부분을 포함한다. 이는 시스템의 서로 다른 컴포넌트들이 어떻게 상호작용하는지 확인하는 데 초점을 맞춘다. 예를 들어, 단위 테스트와 달리, 통합 테스트에서는 서로 다른 컴포넌트(예: 서비스, 데이터베이스, 네트워크 레이어 등)의 상호작용을 검증한다. 이는 서비스가 데이터베이스와 어떻게 통신하는지, 혹은 서로 다른 마이크로서비스 간의 API 통신 등을 포함할 수 있다.
환경 설정의 중요성
통합 테스트를 위한 테스트 환경은 실제 운영 환경과 가능한 유사하게 구성되어야 한다. 이는 테스트 결과가 실제 환경에서의 시스템 동작을 반영할 수 있도록 하는 데 중요하다.
@SpringBootTest 어노테이션을 사용하면 전체 Spring Boot 애플리케이션 컨텍스트가 로드되고, 모든 빈이 실제로 생성되며 관리된다. 이는 Adapter 같은 클래스를 테스트할 때 해당 클래스가 사용하는 Repository와 같은 의존성을 자동으로 실제 구현체로 주입한다. 이를 통해 별도로 의존성을 설정할 필요 없이 통합 테스트 환경을 구축할 수 있다.
코드로 예시를 들어보겠다.
@SpringBootTest 어노테이션 사용
@SpringBootTest를 사용하면, Spring Boot는 전체 애플리케이션 컨텍스트를 로드한다. 이 경우, 테스트 대상인 클래스(예: Adapter)에 필요한 모든 의존성은 Spring 컨테이너에 의해 자동으로 주입된다. 따라서 별도로 Repository 같은 의존성을 명시적으로 선언할 필요가 없다.
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
@SpringBootTest
public class AdapterIntegrationTest {
@Autowired
private Adapter adapter;
@Test
public void testAdapterMethod() {
// 실제 데이터베이스와의 상호작용을 포함한 테스트
Data result = adapter.someAdapterMethod();
assertNotNull(result);
// 추가적인 검증 로직
}
}
@SpringBootTest 어노테이션 미사용
@SpringBootTest 어노테이션 없이 통합 테스트를 진행하는 경우, 전체 애플리케이션 컨텍스트는 로드되지 않는다. 이런 경우, 테스트에 필요한 의존성을 수동으로 설정해야 하며, 이는 보통 단위 테스트에서 사용되는 방식이다. 통합 테스트에서 모의 객체(Mock)를 사용하는 것은 일반적이지 않으며, 이는 통합 테스트의 목적에 부합하지 않을 수 있다.
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.junit.jupiter.api.extension.ExtendWith;
import static org.junit.jupiter.api.Assertions.*;
@ExtendWith(MockitoExtension.class)
public class AdapterIntegrationTest {
@InjectMocks
private Adapter adapter;
@Mock
private Repository repository;
@Test
public void testAdapterMethod() {
// 이 경우는 주로 단위 테스트에 가깝다
Data result = adapter.someAdapterMethod();
assertNotNull(result);
// 추가적인 검증 로직
}
}
요약
단위 테스트는 개별 기능의 정확성을 모의 객체를 사용하여 독립적으로 검증한다. Service에서 사용하는 의존성을 모의 객체로 대체하며, Service 자체는 실제 객체로 사용하거나 필요에 따라 모의 객체로 사용할 수 있다.
통합 테스트는 컴포넌트 간 상호작용을 실제 의존성을 사용하여 전체 시스템의 일부로서 검증한다. @SpringBootTest를 사용하면 Adapter와 같은 클래스의 의존성을 자동으로 실제 구현체로 주입받아 테스트를 수행할 수 있다.
단위 테스트와 통합 테스트는 각각 소프트웨어 개발의 중요한 측면을 다루며, 서로 보완적인 역할을 한다. 단위 테스트는 각 기능의 정확성을 확인하는 반면, 통합 테스트는 이러한 기능들이 전체 시스템 내에서 어떻게 상호작용하는지 검증한다.
마무리
지금까지 너무 헷갈렸던 테스트 코드 내용에 대해서 대략적으로 정리를 해봤다. 이 글을 정리하면서 나름대로 머릿속에서 애매했던 개념들이 명확히 정리된 것 같아서 다행이다.
아직 완벽하게 이해한 건 아니라 각각의 테스트를 어떻게 진행해야 할지 실제 테스트 코드를 작성해 보면서 틈틈이 정리해야겠다.
🔻 헥사고날 아키텍처에서 각 레이어 별로 어떤 테스트 전략을 사용하는지 궁금하다면? 🔻
헥사고날 아키텍처와 TDD로 가는 길 (1) - 레이어 별 단위 테스트, 통합 테스트 전략 선택
'Spring > Spring 테스트코드' 카테고리의 다른 글
스프링 테스트 코드에서 실제 호출 @SpyBean으로 확인하기 (3) | 2023.12.31 |
---|---|
스프링 테스트 코드: @MockBean과 @Mock의 차이 (+ @InjectMocks) (1) | 2023.12.31 |
테스트 코드의 가독성 향상 - BDD 방법론과 @DisplayName 어노테이션 활용 (2) | 2023.12.26 |
헥사고날 아키텍처와 TDD로 가는 길 (1) - 레이어 별 단위 테스트, 통합 테스트 전략 선택 (4) | 2023.12.24 |
회원가입 컨트롤러 테스트(Controller Test) - RequestDto @NotBlank 필드 검증 (1) | 2023.12.22 |