Developer.

[멋사 백엔드 19기] TIL 44일차 JUnit

📂 목차


📚 본문

TDD

테스트를 먼저 작성하고 테스트를 통과하는 코드를 구현하는 개발 방법론이다.

  • 코드 품질 향상
  • 버그 조기 발견
  • 설계 단순화 및 유지보수 용이
  • 코딩 목표의 명확성

Red-Green-Refactor

테스팅의 상태는 다음과 같이 흘러가게 된다.

  1. Red: 실패하는 테스트 작성
    • 아직 기능이 구현되지 않았으므로 테스트 실패
  2. Green: 최소한의 코드 작성으로 테스트 통과
    • 기능 구현
  3. 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_PORT
  • DEFINED_PORT
  • NONE

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()); // 전달된 객체의 값 검증
}