자바 개발자라면 단위 테스트를 작성할 때 가장 먼저 손이 가는 게 바로 JUnit다. JUnit5는 JUnit4에 비해 많은 변화가 있었고, 특히 스프링 부트 2.2버전부터는 스프링 부트 스타터를 통해 별다른 의존성 설정 없이 바로 JUnit5를 쓸 수 있게 해줬다. (물론 스프링 부트를 안 써도 JUnit 의존성만 추가하면 바로 가능하다.)
Junit5를 쓰면서 굳이 public 접근 제어자를 붙일 필요도 없다. 이는 테스트 메서드를 찾고 실행할 때 리플렉션(Reflection)을 활용하기 때문인데, 이 덕분에 접근제어자 관련 제약이 줄어든다.
Reflection: 자바 프로그램이 실행 중에 클래스 구조나 메서드 정보를 파악하고, 접근이 불가능할 것 같은 private 멤버에도 접근할 수 있는 기술이다. JUnit5는 이 기술을 바탕으로 테스트 메서드를 public으로 안해도 알아서 찾아내서 실행한다.
아래는 가장 기본적인 예제:
@Test
void create() {
Study study = new Study();
assertNotNull(study);
}
딱히 public 안 붙여도 잘 돌아간다.
기본 어노테이션들
JUnit5에는 테스트 라이프사이클에 관련된 여러 어노테이션이 있다.
- @BeforeAll: 모든 테스트 전에 딱 한 번 실행 (static 메서드여야 함)
- @AfterAll: 모든 테스트 후에 딱 한 번 실행 (static 메서드여야 함)
- @BeforeEach: 각 테스트 시작 전 실행
- @AfterEach: 각 테스트 종료 후 실행
- @Test: 테스트 메서드
- @Disabled: 해당 테스트 비활성화
예제:
class studyTest {
@BeforeAll
static void beforeAll() {
System.out.println("beforeAll");
System.out.println("딱 한 번만 호출 (static 이어야 하고, private 불가)");
}
@AfterAll
static void afterAll() {
System.out.println("afterAll");
}
@BeforeEach
void beforeEach() {
System.out.println("각 테스트 시작 전에 실행");
}
@AfterEach
void afterEach() {
System.out.println("각 테스트 종료 후에 실행");
}
@Test
void create() {
Study study = new Study();
assertNotNull(study);
}
@Test
@Disabled
void create1() {
System.out.println("이 테스트는 실행 안됨");
}
}
테스트 이름 표시하기
@DisplayName, @DisplayNameGeneration을 통해 테스트 이름을 가독성 좋게 바꿀 수 있다. 스네이크 케이스를 공백으로 바꾸는 등 전략 지정도 가능하고, 이모지 같은 특수문자도 가능하다.
@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class)
class studyTest {
@Test
@DisplayName("스터디 만들기")
void create_new_study() {
Study study = new Study();
assertNotNull(study);
}
@Test
@DisplayName("스터디 다시 만들기")
void create_new_study_again() {
System.out.println("create1");
}
}
Assertion (검증)
테스트 결과를 검증하는데 필요한 도구들이다. assertEquals, assertTrue, assertNotNull 등 다양한 메서드가 있는데, 기대값(expected), 실제값(actual) 순서와 메세지 전달 등에 유의하면 테스트 결과를 더 명확하게 알 수 있다.
assertEquals(StudyStatus.DRAFT, study.getStatus(), "처음 생성 시 DRAFT 상태여야 한다.");
메시지를 람다로 제공할 수도 있어서, 필요할 때만 메시지 생성을 하게끔 할 수도 있다:
assertEquals(StudyStatus.DRAFT, study.getStatus(), () -> "DRAFT여야 함");
AssertAll
여러 검증문(assertion)을 한 번에 묶어서 실행하고 싶다면 assertAll을 쓸 수 있다. 이를 통해 첫 번째 assertion에서 실패해도 나머지 assertion들을 확인할 수 있다.
assertAll(
() -> assertNotNull(study),
() -> assertEquals(StudyStatus.DRAFT, study.getStatus()),
() -> assertTrue(study.getLimit() > 0)
);
예외 검증
assertThrows로 특정 코드를 실행했을 때 기대하는 예외가 발생하는지 검증할 수 있다.
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> new Study(-10));
assertEquals("limit는 0보다 커야한다.", ex.getMessage());
시간 제한 검증
assertTimeout을 사용하면 특정 시간 안에 테스트가 끝나는지 확인할 수 있다. assertTimeoutPreemptively는 지정한 시간 안에 안 끝나면 즉시 테스트를 중단하지만, ThreadLocal 사용 시 주의해야 한다.
assertTimeout(Duration.ofSeconds(10), () -> new Study(10));
assertTimeoutPreemptively(Duration.ofMillis(100), () -> {
new Study(10);
Thread.sleep(300);
});
왜 조심해야할까?
테스트가 일정 시간 안에 완료되어야 하는 경우 assertTimeout이나 assertTimeoutPreemptively를 사용할 수 있다.
- assertTimeout(Duration): 지정한 시간 안에 테스트 코드가 완료되지 않으면 테스트 실패 처리. 하지만 테스트 코드 블록이 끝날 때까지는 실행을 계속한다.
- assertTimeoutPreemptively(Duration): 지정한 시간 안에 완료되지 않으면 바로 테스트 실행을 중단한다.
여기서 주의해야 할 점은 assertTimeoutPreemptively를 사용할 때 별도 스레드에서 테스트를 실행하고, 시간이 초과되면 해당 스레드를 강제로 중단하거나 종료해버린다는 것이다.
문제는 ThreadLocal을 사용하는 코드와 충돌이 발생할 수 있다는 점이다. ThreadLocal은 스레드별로 독립적인 변수 저장소를 제공하는데, 스레드가 강제 종료되면 ThreadLocal에 저장되어 있던 데이터가 정리되지 않은 상태로 남을 수 있다. 이로 인해 예기치 못한 일관성 문제나 리소스 누수가 발생할 가능성이 있다. 즉, assertTimeoutPreemptively는 테스트의 안정성에 영향을 줄 수 있으므로 ThreadLocal 사용 시 신중하게 고려해야 한다.
조건부 실행
특정 환경 변수나 OS, JRE 버전에 따라 테스트를 실행할 수 있다.
- assumeTrue, assumingThat 을 사용하거나
- @EnabledOnOs, @EnabledOnJre, @EnabledIfEnvironmentVariable 등을 사용해서 조건부 실행이 가능하다.
@EnabledOnOs(OS.MAC)
@EnabledOnJre({JRE.JAVA_8, JRE.JAVA_11})
void testOnlyOnSpecificEnv() {
// 특정 OS와 특정 JRE에서만 실행
}
테스트 태깅과 필터링
@Tag를 붙여서 테스트들을 그룹핑할 수 있다. Gradle이나 Maven, IDE 설정에서 특정 태그를 가진 테스트만 골라 실행 가능하다.
@Tag("fast")
@Test
void fastTest() { ... }
커스텀 태그 어노테이션을 만들어 사용할 수도 있다.
반복 테스트와 파라미터화 테스트
@RepeatedTest로 테스트를 반복 수행할 수 있다.
@RepeatedTest(10)
void repeatTest() {
System.out.println("반복 실행");
}
@ParameterizedTest와 @ValueSource, @CsvSource 등으로 다양한 입력값을 테스트할 수 있다. 인자를 컨버팅하거나, 여러 인자를 한 번에 객체로 묶어서 테스트하는 것도 가능하다.
@ParameterizedTest
@ValueSource(strings = {"test1", "test2", "test3"})
void parameterizedTest(String input) {
System.out.println(input);
}
컨버터나 Aggregator를 통해 인자를 변환:
@ParameterizedTest
@CsvSource({"10, '자바 스터디'", "20, 스프링"})
void parameterizedTest(@AggregateWith(StudyAggregator.class) Study study) {
System.out.println(study);
}
테스트 인스턴스 라이프사이클
JUnit5 기본 설정에서는 각 테스트마다 새로운 인스턴스를 만든다. 이로 인해 테스트 간 공유 상태를 제거하고 독립적으로 테스트할 수 있다. 하지만 필요하다면 @TestInstance(TestInstance.Lifecycle.PER_CLASS)를 사용해 테스트 클래스당 하나의 인스턴스를 공유할 수도 있다. 이 경우 @BeforeAll, @AfterAll에 static 필요 없음.
테스트 순서
기본적으로 JUnit5는 테스트 실행 순서를 보장하지 않는다. 순서 의존적 테스트는 추천하지 않지만, 불가피하다면 @TestMethodOrder와 @Order를 사용하면 된다.
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class OrderedTests {
@Order(1)
@Test
void firstTest() { ... }
@Order(2)
@Test
void secondTest() { ... }
}
최근 테스트 코드 작성에 너무 무관심했던 기억이 떠올라 약점을 보완하고자 열심히 다시 학습중인데 아무래도 갈길이 구만리인것같다.
다시 한번 꾸준히 내실을 다져보자
'코딩딩 > Spring' 카테고리의 다른 글
테스트 코드, 어디까지 작성해야 할까? (5) | 2024.12.11 |
---|---|
Mockito를 활용한 단위 테스트 정리 (3) | 2024.12.11 |
th:object (0) | 2024.01.18 |
Entity의 Id값을 Long으로 주는 이유가 뭘까? (0) | 2024.01.02 |
JPA 엔티티 컬렉션 성능 최적화 (0) | 2023.12.30 |