📂 목차
📚 본문
TDD
테스트를 먼저 작성하고 테스트를 통과하는 코드를 구현하는 개발 방법론이다.
- 코드 품질 향상
- 버그 조기 발견
- 설계 단순화 및 유지보수 용이
- 코딩 목표의 명확성
Red-Green-Refactor
테스팅의 상태는 다음과 같이 흘러가게 된다.
- Red: 실패하는 테스트 작성
- 아직 기능이 구현되지 않았으므로 테스트 실패
- Green: 최소한의 코드 작성으로 테스트 통과
- 기능 구현
- Refactor: 코드 리팩토링
- 테스트가 통과하므로 안전하게 구조 개선 가능
단위 테스트
프로그램의 가장 작은 단위(주로 메서드) 를 독립적으로 테스트, 즉 Bottom-Up 방법이며, 다음 특징이 있다.
- 독립적이어야 함
- 빠르게 실행 가능
- 작은 단위에 집중하여 가장 비용이 적음
따라서 보통 개발 단계에서 채택할 수 있는 테스트이다.
Test Coverage
최소 수준 부터 100% 의 테스트 범위 까지 매 수준마다 단위 테스트를 실시해야 한다.
가장 최소 수준의 Coverage 는 메서드/클래스 단위일 것이다.
통합 테스트
여러 모듈/컴포넌트가 함께 동작하는지를 테스트 한다.
- 모듈 혹은 클래스 간 상호작용 확인
- 실제 서비스 환경과 유사하게 검증해야 한다
시스템 테스트
전체 시스템을 테스트하며, 어플리케이션 전체가 coverage 가 된다. 이때는 실제 사용 시나리오를 기반으로 하여 테스트를 하기 때문에 시나리오 명세서를 통해 하나하나 테스트가 진행되는 듯하다.
이 외에도 다양한 테스트들이 있기 때문에 찾아보기를 바란다.
SpringBootTest
스프링 프레임워크를 사용하면, 스프링 자체의 어플리케이션 컨텍스트가 생성되게 되는데, 이는 어플리케이션 전반에서 사용되게 된다.
이를 테스트 환경에서도 작성하기 위해 또 테스트 코드와 실제 개발 코드를 분리시키기 위해 Spring Initializer 에서 생성된 프로젝트에는 src/test 도 있었다. 여기서는 java 쪽 폴더와 데칼코마니처럼 클래스들을 생성하여 테스트 코드를 작성할 수 있도록 해놨다.
하지만 이렇게 테스트할 때 우리는 컨텍스트를 들고와야 한다. 테스트 쪽에서는 스프링 어플리케이션을 실행하는 코드가 어디에도 없지만, 어노테이션 하나만으로 우리가 java 폴더에 개발한 해당 Spring Bean 들을 다 들고 온다.
@SpringBootTest
class UserServiceIntegrationTest {
@Autowired
UserService userService;
@Autowired
UserRepository userRepository;
@Test
void createUserTest() {
User user = userService.createUser("hong", "hong@email.com");
assertNotNull(userRepository.findById(user.getId()));
}
}특징
- 모든 Spring Bean 을 로드 -> 의존성 주입이 가능
- 실제 DB, JPA Repository, Service 등등 을 포함한 통합 테스트도 가능
- 일반적인 단위 테스트(
@Test) 보다 느릴 수 있음
단순히 단위 테스트만 하고 싶다면 굳이 사용할 필요는 없고, Bean 의 범위를 줄여주는
@WebMvcTest,@DataJpaTest등으로 범위를 좁힌다.
따라서 @SpringBootTest 는 클래스 수준의 어노테이션으로 작성되며, webEnvironment 속성을 지원한다. 이 webEnvironment 는 다음 4가지 옵션이 있다:
MOCK<- 기본값RANDOM_PORTDEFINED_PORTNONE
MOCK 기능은 예를 들어 웹 환경이 어플리케이션의 클래스 경로에 있는 경우에만 내장 서버를 시작하는 대신 mock 웹 환경을 활용하게 된다. 하지만 웹 기능이 없다면 일반 ApplicationContext 를 로딩하게 된다.
이 외에도 다른 옵션에는 RANDOM_PORT 는 웹 어플리케이션 컨텍스트를 로딩하고 내장서버를 시작하여 사용 가능한 임의의 포트에 노출된 실제 웹 환경을 제공, DEFINED_PORT 는 프로퍼티에 정의된 포트를 사용하게 된다.
보통은 NONE 을 더 많이 사용했을텐데, 이 값이 바로 모의 웹 환경 이나 웹 환경이 전혀 없는 ApplicationContext 가 생성되는 것이고 내장 서버도 시작되지 않는다.
SpringBoot Web Test
위에서 SpringBootTest.WebEnvironment.MOCK 을 사용하여 모의 웹 환경을 토대로 실행할 수 있게 됨을 보았다. 보통 이는 다음과 같은 클래스 수준 어노테이션과 같이 쓴다.
@AutoConfigureMockMVC: Spring MVC 웹 계층을 Mock 환경에서 테스트할 때 사용- 이걸 붙이면 실제 서버를 띄우지 않고 DispatcherServlet, 컨트롤러, 필터, 인터셉터 등 MVC 구성을 테스트 할 수 있음
- 리엑티브는 지원하지 않으며, Spring MVC 에서만 사용
@AutoConfigureWebTestClient:- Spring WebFlux 환경에서 비동기 웹 계층을 테스트
- 실제 서버 또는 WebFlux 컨텍스트를 띄워서 WebTestClient 로 요청/응답 검증을 한다고 한다(자세한건 모른다).
- MVC 기반에서는 사용하지 않으며, Spring WebFlux 에서만 사용
즉 두 어노테이션의 사용은 각자 리액티브냐, MVC 냐에 달려있다. 아래는 예시이다.
Spring MVC Test
@SpringBootTest
@AutoConfigureMockMvc
class UserControllerTest {
@Autowired
private MockMvc mockMvc;
@Test
void getUserTest() throws Exception {
mockMvc.perform(get("/users/1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("hong"));
}
}Spring WebFlux Test
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureWebTestClient
class UserControllerWebFluxTest {
@Autowired
private WebTestClient webTestClient;
@Test
void getUserTest() {
assert webTestClient.get().uri("/users/1")
.exchange()
.expectStatus().isOk()
.expectBody()
.jsonPath("$.name").isEqualTo("hong");
}
@Test
void test(@Autowired WebTestClient client) {
assert client.get() // GET 요청 보내겠다.
.uri("/aircraft") // 해당 URI 로 엔드포인트 설정
.exchange() // request / response 의 교환이 일어났다면,
.expectStatus().isOk() // HTTP 200 의 응답을 확인 후 Response Body 반환
.expectBody(Iterable.class) // 바디에 Iterable 이 포함됐는지 확인
.returnResult() // Iterable 에 해당하는 response 회수
.getResponseBody() // response 의 응답 바디 반환
.iterator() // iterator 로 형 변환
.hasNext(); // 값이 하나라도 있다면 true
}
}Flux 까지는 보지 않고, 우선 JUnit 5 을 사용하는 것을 익히자.
JUnit 5
테스트 코드를 작성하기 위한 패키지이다. 검증하는 코드는 주피터 엔진이 포함된 JUnit 5를 통해 테스트 코드를 작성할 수 있으며, 다음 기능들을 제공한다:
- 이전 버전에 비해 개선된 코틀린 코드 테스트
@BeforeAll,@AfterAll사용하여 인스턴스화/설정 을 한꺼번에 가능- JUnit 4 코드도 테스트에 지원
JUnit 은 3가지 모듈로 구성되는데 JUnit Platform, JUnit Jupiter, JUnit Vintage 로 JUnit 5 는 Jupiter + Platform 이 되겠다(Vintage 는 JUnit 3, 4 테스트 호환 모듈이라고 보면 된다).
주요 어노테이션
기본적으로 @SpringBootTest 를 통해 해당 클래스를 실행할때 테스트 클래스 임을 설정하고, 컨텍스트를 불러올 수 있다. 내부의 메서드들은 전부 @Test 를 사용하여 메서드 수준의 테스트를 작성할 수 있다. 여기서는 그 외의 다양한 어노테이션을 본다.
@RepeatedTest
동일한 기능을 데이터만 바꿔가면서 실행하고 싶을 수 있다.
class RepeatedTestExample {
@RepeatedTest(5)
@DisplayName("반복 테스트 기본 예시")
void repeatFiveTimes() {
System.out.println("테스트 실행 중...");
assertTrue(Math.random() >= 0); // 단순 검증
}
}@ParameterizedTest
위와 반복 실행은 똑같지만, 다양한 테스트 케이스를 넣도록 할 수 있다.
@ValueSource 로 테스트 케이스들 넣기
@ParameterizedTest
@ValueSource(ints = {1, 2, 3})
void testPositiveNumbers(int number) {
assertTrue(number > 0);
}지원 타입:
ints,longs,doubles,strings,classes, 모르면 내부 보자
@EnumSource 로 테스트 케이스들 넣기
enum Color { RED, GREEN, BLUE }
@ParameterizedTest
@EnumSource(Color.class)
void testEnum(Color color) {
assertNotNull(color);
}@CsvSource 로 테스트 케이스들 넣기
@ParameterizedTest
@CsvSource({
"apple, 1",
"banana, 2",
"orange, 3"
})
void testFruit(String name, int quantity) {
assertNotNull(name);
assertTrue(quantity > 0);
}@CsvFileSource 로 테스트 케이스들 넣기
@ParameterizedTest
@CsvSource({
"apple, 1",
"banana, 2",
"orange, 3"
})
void testFruit(String name, int quantity) {
assertNotNull(name);
assertTrue(quantity > 0);
}@MethodSource 로 테스트 케이스들 넣기
static Stream<Arguments> provideNumbers() {
return Stream.of(
Arguments.of(1, 2, 3),
Arguments.of(2, 3, 5)
);
}
@ParameterizedTest
@MethodSource("provideNumbers")
void testAddition(int a, int b, int expected) {
assertEquals(expected, a + b);
}장점: 동적, 복잡한 객체 제공 가능
@ArgumentsSource 로 테스트 케이스들 넣기
class MyArgumentsProvider implements ArgumentsProvider {
@Override
public Stream<? extends Arguments> provideArguments(ExtensionContext context) {
return Stream.of(Arguments.of(1,2), Arguments.of(3,4));
}
}
@ParameterizedTest
@ArgumentsSource(MyArgumentsProvider.class)
void testCustomProvider(int a, int b) {
assertTrue(a < b);
}장점: 매우 유연, 외부 API 연동 가능
장점을 이용하기 위해서 ArgumentsSource, MethodSource 를 사용하여 넣어주자. 나머지는 다양하게 넣는거 뿐이다.
@TestFactory
실행 시점에 테스트 케이스를 동적으로 생성하는 메서드에 붙이는 어노테이션이며, 여러 개의 테스트 데이터들을 넣어 결과로 Collection 혹은 Stream 의 형태로 반환되게 된다. 예시를 보자.
class DynamicTestExample {
@TestFactory
Stream<DynamicTest> dynamicTestsFromStream() {
return Stream.of(1, 2, 3)
.map(n -> dynamicTest("test for " + n,
() -> assertTrue(n > 0)));
}
@TestFactory
Stream<DynamicTest> dynamicStringTests() {
String[] words = {"apple", "banana", "cherry"};
return Stream.of(words)
.map(word -> dynamicTest("Length > 0 for " + word,
() -> assertTrue(word.length() > 0)));
}
@TestFactory
Stream<DynamicTest> dynamicTestsFromRandom() {
return Stream.generate(() -> (int)(Math.random()*100))
.limit(5)
.map(n -> dynamicTest("test for " + n,
() -> assertTrue(n >= 0)));
}
}dynamicTest("이름", Executable) 을 통해 테스트 이름과 로직을 지정하며, 이를 통해 테스트 이름을 런타임에 동적으로 지정할 수 있게 되며, 랜덤과 함께 사용한다면 더 강력한 테스트 범위까지 확장시킬 수 있고 데이터가 동적이게 된다.
라이프사이클 관련
모든 것은 함수 수준이다.
@BeforeEach: non-static 으로 각 테스트 전에 실행@AfterEach: non-static 으로 각 테스트 후에 실행@BeforeAll: static 메서드로 클래스 전체 테스트 전 한 번 실행@AfterAll: static 메서드로 클래스 전체 테스트 후 한 번 실행
조건부 실행
@EnabledOnOs,@DisabledOnOs: 특정 OS 에서만 활성화 / 비활성화@EnabledOnJre,@DisabledOnJre: 특정 JRE 에서만 실행@EnabledIf,@DisabledIf: 커스텀 조건SpEL에 따라 실행/비활성화
예외 및 시간 지정
@Timeout:@Timeout(500, unit = TimeUnit.MILLISECONDS)assertThrows
테스트 그룹화
@Tag("fast"): 테스트 그룹을 지정하며, 특정 지정 태그만 실행이 가능하도록 할 수 있다.
Assertions
Assertions 클래스는 다양한 검증을 수행할 수 있는 기능을 제공하는 클래스이다.
assertEquals(expected, actual);
assertNotEquals(expected, actual);
assertTrue(condition);
assertFalse(condition);
assertNull(object);
assertNotNull(object);
assertThrows(Exception.class, () -> {...});
assertAll(() -> {...}, () -> {...}); // 여러 검증 동시에
assertArrayEquals(expected, actual);
assertIterableEquals(expected, actual);
assertLinesMatch(expectedLines, actualLines); // 문자열 라인 단위 비교여기서 주의할 점은 expected 가 우리가 기대할 값이 들어가야 하지 실제 값이 들어가면 안된다. 둘은 자리가 정해져 있다. 이런 메서드들을 @Test 의 단위에 맞게 넣어주어 사용하면 된다.
Mockito
Spring 테스트에서 자주 쓰이는 가짜 객체 기능을 제공하는 패키지이다. 테스트를 할 대상 객체가 의존하는 다른 외부 객체를 필요로 할 때 우리는 테스트를 섣불리 못하게 되지만, Mockito 를 활용하면 가짜 객체를 부여하여 테스트를 독립적으로 할 수 있다.
@Mock
가짜 객체를 생성하는 어노테이션이며, 필드에 붙여주게 된다. 클래스에 @ExtendWith(MockitoExtension.class) 어노테이션을 붙여주면 Mockito 를 사용할 수 있게 된다.
Mock 은 말 그대로 가짜이기 때문에 특징으로는 다음과 같다:
- Mock 객체는 실제 구현체를 호출하지 않고 테스트에서 지정한 동작만 수행
- Mock 객체는
when(...).thenReturn(...)또는doReturn(...).when(...)같은 구문을 사용하여 호출 시 반환값과 동작을 정의 가능 - Mock 객체의 메서드 호출 여부, 호출 횟수 등을
verify(...)메서드로 검증 가능
@InjectMocks
Mock 객체를 주입할 대상 클래스를 지정해야 한다. 즉 위의 @Mock 만 사용해서는 안되며, 이를 어디에 주입시켜줄지를 지정해줘야 하는데 이를 @InjectMocks 로 한다. 그래서 보통 클래스에 하나만 이 어노테이션을 사용하는게 일반적이며, 여기에 @Mock, @Spy 가 자동 주입이 되게 된다.
예시
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@Mock
private UserRepository userRepository; // Mock 객체 생성
@InjectMocks
private UserService userService; // Mock 주입
@Test
void createUserTest() {
User mockUser = new User("hong");
when(userRepository.save(any(User.class))).thenReturn(mockUser);
User user = userService.createUser("hong");
assertEquals("hong", user.getName());
verify(userRepository).save(any(User.class)); // 호출 여부 검증
}
}@Spy
실제 객체를 사용하면서 일부 메서드만 Mock 처리를 한다. 가짜로 처리하고 싶은 메서드는 when(...).thenReturn(...) 으로 가짜 동작을 덮어씌울 수 있다.
@Spy
private UserService userService = new UserService();
@Test
void testSpy() {
when(userService.getUserName()).thenReturn("mocked name");
// getUserName 은 Mock 동작, 다른 메서드는 실제로 실행됨
assertEquals("mocked name", userService.getUserName());
}Stub: 호출되면 정해진 값을 돌려주는 가짜 객체
@Captor
ArgumentCaptor 를 생성하는 어노테이션이다. 테스트에서 메서드 호출 시 전달된 인자(argument) 를 캡처(capture)하여 검증하고 싶을 때 사용한다.
- 메서드가 호출될 때 전달된 실제 인자 값을 꺼내서 확인할 수 있음
verify()와 함께 사용- 여러 인자를 순서대로 검증할 수 있음
@Mock
private UserRepository userRepository;
@InjectMocks
private UserService userService;
@Captor
private ArgumentCaptor<User> userCaptor; // 인자 캡쳐용
@Test
void createUser_capturesArgument() {
userService.createUser("hong");
verify(userRepository).save(userCaptor.capture()); // save()에 전달된 User 객체 캡처
User capturedUser = userCaptor.getValue();
assertEquals("hong", capturedUser.getName()); // 전달된 객체의 값 검증
}