https://www.inflearn.com/course/the-java-application-test
강의 내용 정리
JUnit 5 소개
JUnit은 자바 개발자가 가장 많이 사용하는 테스팅 프레임워크이다. 단위 테스트를 작성하는 자바 개발자 중 무려 93%가 JUnit을 사용한다.참고로 JUnit은 자바 8 이상을 필요로 한다.
강의에서는 가장 최신 버전인 JUnit 5를 집중적으로 알아볼 것이다. JUnit 5는 다음과 같이 여러 모듈로 모듈화 되어 있다.
JUnit Platform이 있고 그 위에 Jupiter와 Vintage를 올려서 사용하는 구조다.
JUnit Platform은 테스트 코드를 실행해주는 런처를 제공한다. 테스트 실행에 필요한 TestEngine API를 제공한다.
Jupiter와 Vintage는 TestEngine API의 구현체로서 Jupiter는 JUnit 5의 구현체이고, Vintage는 JUnit 3, 4의 구현체이다.
JUnit 5 시작하기
스프링 프로젝트 만들기
우선 간단한 스프링부트 프로젝트를 만든다. 2.2+ 버전의 스프링 부트 프로젝트를 만든다면 기본적으로 JUnit 5 의존성이 추가된다.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
이렇게 pom.xml에 spring-boot-starter-test가 dependency에 포함되어 있다면, JUnit 5를 사용할 수 있다.
+) 만약 스프링 부트 프로젝트를 사용하지 않는다면 JUnit 5를 직접 의존성에 추가하자.
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
<version>5.5.2</version>
<scope>test</scope>
</dependency>
기본 애노테이션
간단한 예제를 통해 JUnit 5 맛보기를 해보자.
<Study>
public class Study {
}
<StudyTest>
class StudyTest {
@Test
void create() {
Study study = new Study();
assertNotNull(study);
}
}
+) 참고로 테스트를 원하는 클래스에 커서를 대고 "command + shift + T" 단축키를 누르면 테스트를 생성할 수 있다.
테스트는 예제와 같이 @Test 애노테이션을 사용해서 작성한다. 참고로 JUnit 4 때와는 달리 클래스나 메서드에 public을 붙일 필요가 없게 되었다.
다른 유용한 애노테이션들도 보자.
class StudyTest {
@Test
void create() {
Study study = new Study();
assertNotNull(study);
System.out.println("create");
}
@Test
void create1() {
System.out.println("create1");
}
@BeforeAll
static void beforeAll() {
System.out.println("before all");
}
@AfterAll
static void afterAll() {
System.out.println("after all");
}
@BeforeEach
void beforeEach() {
System.out.println("before each");
}
@AfterEach
void afterEach() {
System.out.println("after each");
}
}
<결과>
before all
before each
create
after each
before each
create1
after each
after all
@BeforeAll, @AfterAll은 전체 테스트 시작과 끝에 수행할 코드를 작성하면 되고,
@BeforeEach, @AfterEach는 각각의 테스트 시작과 끝에 수행할 코드를 작성하면 된다.
+) @BeforeAll, @AfterAll은 반드시 메서드에 static void를 붙여주어야 한다.
@Disabled는 수행하고 싶지 않은 테스트에 대해 붙여주면 JUnit이 해당 테스트는 무시하게 된다.
@Test
@Disabled
void create1() {
System.out.println("create1");
}
JUnit 5 테스트 이름 표시하기
테스트 이름은 기본적으로 메서드 이름으로 지정된다. 그래서 보통 다음과 같이 메서드 이름에 Snake Case를 사용한다. Camel Case에 비해 알아보기 쉽기 때문이다.
@Test
void create_new_study() {
Study study = new Study();
assertNotNull(study);
System.out.println("create");
}
@Test
void create_new_study_again() {
System.out.println("create1");
}
<결과>
이 역시 메서드가 너무 길어지면 알아보기 힘들기 때문에 테스트 이름을 직접 지정하는 방법을 알아보자.
@DisplayNameGeneration
@DisplayNameGeneration은 메서드와 클래스 모두에 지정할 수 있고, 레퍼런스를 사용해서 테스트 이름을 표기하는 방법을 설정한다.
기본 구현체로 ReplaceUnderscores를 제공한다.
@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class)
class StudyTest {
@Test
void create_new_study() {
Study study = new Study();
assertNotNull(study);
System.out.println("create");
}
@Test
void create_new_study_again() {
System.out.println("create1");
}
}
<결과>
Snake Case의 언더스코어('_')가 사라진 것을 볼 수 있다.
@DisplayName
테스트 이름을 보다 쉽게 표현할 수 있는 방법을 제공하는 애노테이션이다.
@DisplayNameGeneration보다 우선 순위가 높다.
@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class)
class StudyTest {
@Test
@DisplayName("스터디 만들기 \uD83D\uDE31")
void create_new_study() {
Study study = new Study();
assertNotNull(study);
System.out.println("create");
}
@Test
void create_new_study_again() {
System.out.println("create1");
}
}
<결과>
이렇게 직접 테스트 이름을 지정할 수 있다. 이모티콘도 직접 넣을 수 있다.
보통 @DisplayName 방식을 권장한다. 아무래도 테스트의 양이 증가함에 따라 직접 하나하나 이름을 지정해 주는 것이 더욱 편리하기 때문이다.
JUnit 5 Assertion
Assertion은 Jupiter가 제공하는 테스트를 위한 라이브러리이다.
org.junit.jupiter.api.Assertions.*
다양한 테스트를 위한 메서드들 중 자주 사용하는 메서드들을 알아보자.
실제 값이 기대한 값과 같은지 확인 | assertEqulas(expected, actual) |
값이 null이 아닌지 확인 | assertNotNull(actual) |
다음 조건이 참(true)인지 확인 | assertTrue(boolean) |
모든 확인 구문 확인 | assertAll(executables...) |
예외 발생 확인 | assertThrows(expectedType, executable) |
특정 시간 안에 실행이 완료되는지 확인 | assertTimeout(duration, executable) |
assertEqual
먼저 테스트를 위해 몇 가지 파일을 추가하겠다.
<StudyStatus>
public enum StudyStatus {
DRAFT, STARTED, ENDED
}
<Study>
public class Study {
private StudyStatus studyStatus;
public StudyStatus getStudyStatus() {
return studyStatus;
}
}
<StudyTest>
@Test
@DisplayName("스터디 만들기")
void create_new_study() {
Study study = new Study();
assertEquals(StudyStatus.DRAFT, study.getStudyStatus(), "스터디를 처음 만들면 " + StudyStatus.DRAFT + " 상태다.");
}
asserEquals()는 메서드의 첫 번째 인자와 두 번째 인자가 같은지 확인한다.
첫 번째 인자와 두 번째 인자는 순서가 바껴도 테스트를 진행하는데 영향을 주지는 않지만 엄연히 순서가 있다. assertEquals()의 API 문서를 보자.
public static void assertEquals(Object expected, Object actual, String message) {
AssertEquals.assertEquals(expected, actual, message);
}
첫 번째 인자가 expected, 기대하는 값(StudyStatus.DRAFT)이고, 두 번째 인자가 actual, 실제 값(study.getStudyStatus)으로 명세되어 있다.
이처럼 순서가 테스트 진행에는 상관이 없다 하더라도 API 의도에 맞게 파라미터를 넣어주는 것이 테스트의 통일성을 유지하는 데 좋겠다.
세 번째 인자는 message로 필수적으로 넣어야 하는 인자는 아니지만, assertEquals()가 실패했을 때, 출력되는 메시지이다.
실제로 위 테스트를 실행하면, 현재 Study 인스턴스에 아무런 StudyStatus 값이 들어가 있지 않기 때문에, 테스트를 실패한다.
여기서 하나 알아둘 점은 세 번째 인자인 message는 예시처럼 String으로 입력해도 되지만, Supplier 클래스를 인자로 취하는 방법이 있다.
@Test
@DisplayName("스터디 만들기")
void create_new_study() {
Study study = new Study();
assertEquals(StudyStatus.DRAFT, study.getStudyStatus(), new Supplier<String>() {
@Override
public String get() {
return "스터디를 처음 만들면 " + StudyStatus.DRAFT + " 상태다.";
}
});
}
이 역시 String 때와 동일하게 테스트 실패시 get() 메서드의 반환값인 문자열을 출력한다.
다만, String 때와의 차이점으로 성능상의 이점을 들 수 있겠다. assertEquals()는 에러가 발생했을 때, 메시지를 만드는 방법이 복잡하다. 만약 message를 단순 String으로 입력한다면 테스트가 실패하든 성공하든 문자열 연산을 무조건 수행하게 된다. 위 예시로 보면 문자열 더하기(+) 연산을 무조건 수행하는 것이다. 그러나 Supplier를 사용하면 테스트가 실패했을 때만, 문자열 연산이 수행되기 때문에, 성능의 이점을 가져갈 수 있다.
가능하면 Supplier를 권장하고 코드가 복잡해질 것 같다면 람다식으로 코드를 줄일 수 있겠다.
@Test
@DisplayName("스터디 만들기")
void create_new_study() {
Study study = new Study();
assertEquals(StudyStatus.DRAFT, study.getStudyStatus(), () -> "스터디를 처음 만들면 " + StudyStatus.DRAFT + " 상태다.");
}
assertNotNull
assertNotNull()은 말 그대로 인자가 null이 아니면 테스트를 통과한다.
@Test
@DisplayName("스터디 만들기")
void create_new_study() {
Study study = new Study();
assertNotNull(study);
}
assertTrue
assertTrue()는 인자가 True이면 테스트를 통과한다.
@Test
@DisplayName("스터디 만들기")
void create_new_study() {
assertTrue(1 < 2);
}
assertAll
Study 코드에 다음 부분을 추가하자.
<Study>
public class Study {
private StudyStatus studyStatus;
private int limit;
public Study(int limit) {
this.limit = limit;
}
public StudyStatus getStudyStatus() {
return studyStatus;
}
public int getLimit() {
return limit;
}
}
<StudyTest>
@Test
@DisplayName("스터디 만들기")
void create_new_study() {
Study study = new Study(-10);
assertNotNull(study);
assertEquals(StudyStatus.DRAFT, study.getStudyStatus(), () -> "스터디를 처음 만들면 " + StudyStatus.DRAFT + " 상태다.");
assertTrue(study.getLimit() > 0, () -> "스터디 최대 참석 가능 인원은 0보다 커야 한다.");
}
이렇게 세 assert문을 사용할 때, StudyStatus가 null이고, limit도 -10인 상황이기 때문에, assertEquals(), assertTrue() 두 가지 상황에 대해서 error message가 출력될 것을 기대할 수 있다.
<결과>
그러나 실제로 결과는 더 앞선 코드인 assertEquals()의 오류만 출력된다. JUnit 5는 에러가 발생했을 때, 아래 코드를 무시하고 종료하기 때문이다.
만약 전체 assert문들의 성공 유무를 확인하고 싶다면 어떻게 해야 할까? 바로 asserAll()을 사용하면 된다.
@Test
@DisplayName("스터디 만들기")
void create_new_study() {
Study study = new Study(-10);
assertAll(
() -> assertNotNull(study),
() -> assertEquals(StudyStatus.DRAFT, study.getStudyStatus(), () -> "스터디를 처음 만들면 " + StudyStatus.DRAFT + " 상태다."),
() -> assertTrue(study.getLimit() > 0, () -> "스터디 최대 참석 가능 인원은 0보다 커야 한다.")
);
}
<결과>
assertAll()을 안에 여러 assert문들을 람다식으로 표현하면, 전체 상황을 한 번에 파악할 수 있다.
assertThrows
<Study>
public class Study {
private StudyStatus studyStatus;
private int limit;
public Study(int limit) {
if (limit < 0) {
throw new IllegalArgumentException("limit은 0보다 커야 한다.");
}
this.limit = limit;
}
public StudyStatus getStudyStatus() {
return studyStatus;
}
public int getLimit() {
return limit;
}
}
이번엔 Study의 생성자에서 exception을 던지도록 설정했다.
<StudyTest>
@Test
@DisplayName("스터디 만들기")
void create_new_study() {
assertThrows(IllegalArgumentException.class, () -> new Study(-10));
}
이처럼 assertThrows()는 특정 exception이 발생할 것으로 기대되는 상황에 대해서 테스트하는 메서드이다.
실제로 new Study(-10)으로 limit 값을 음수로 주었기 때문에 위 테스트는 성공한다.
혹은 assertThrows()의 반환값으로 exception을 받아서 또 다른 테스트에 활용할 수 있다.
@Test
@DisplayName("스터디 만들기")
void create_new_study() {
IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> new Study(-10));
assertEquals("limit은 0보다 커야 한다.", exception.getMessage());
}
assertTimeout
assertTimeout()은 특정 로직이 특정 시간 안에 수행되도록 기대하는 테스트이다.
@Test
@DisplayName("스터디 만들기")
void create_new_study() {
assertTimeout(Duration.ofMillis(100), () -> {
new Study(10);
Thread.sleep(300);
});
}
예제는 해당 로직이 100밀리세컨 안에 돌아갈 것으로 기대하는 테스트이다. 그러나 Thread.sleep(300)으로 300밀리세컨동안 로직이 끝나지 않기 때문에 테스트는 실패한다.
그런데 테스트 실행 시간을 보면 346ms로 테스트 로직의 시간만큼 테스트가 끝나지 않는다는 것을 알 수 있다.
즉, 어차피 테스트에 실패할꺼, 100ms가 끝나면 바로 테스트를 종료하게끔은 할 수 없을까?
@Test
@DisplayName("스터디 만들기")
void create_new_study() {
assertTimeoutPreemptively(Duration.ofMillis(100), () -> {
new Study(10);
Thread.sleep(300);
});
}
assertTimeoutPreemptively()을 사용하면 된다.
100ms 근처로 테스트가 종료된 것을 볼 수 있다.
+) 그러나 assertTimeoutPreemptively() 사용에는 주의해야 한다. 만약 테스트 로직이 ThreadLocal한 데이터들을 가지고 있다면 로직 중간에 테스트가 종료되어 트랜잭션 롤백이 안되는 등의 문제가 발생할 수 있다. assertTimeoutPreemptively()는 스레드와 관련없는 로직일 때 사용하는 것이 좋겠다.
지금까지 기초적인 Assertion 라이브러리 사용법을 알아보았다. 이 밖에 AssertJ, Hemcrest, Truth 등 다양한 테스트를 위한 라이브러리가 있다. 여기서 다루지는 않겠지만 Assertion을 잘 익혀놓는다면 다른 라이브러리를 익히는 것은 어렵지 않은 일이다.
+) 참고로 spring-boot-starter-test를 의존하면 AssertJ, Hemcrest는 자동으로 의존성을 주입받게 된다.
JUnit 5 조건에 따라 테스트 실행하기
이번에는 특정 조건에 따라 테스트를 실행하는 방법을 알아보자.
Assumptions
@Test
@DisplayName("스터디 만들기")
void create_new_study() {
String test_env = System.getenv("TEST_ENV");
assumeTrue("LOCAL".equalsIgnoreCase(test_env));
}
예제는 Jupiter가 제공하는 테스트 라이브러리 중 하나인 Assumptions의 assumTrue()를 사용하는 상황이다.
"TEST_ENV"라는 환경 변수가 "LOCAL"일 때 테스트를 성공하는 로직이다.
Assumptions를 활용해서 특정 조건에 따라 테스트를 실행하는 방법을 알아보자. 바로 assumingThat()을 사용하면 된다.
@Test
@DisplayName("스터디 만들기")
void create_new_study() {
String test_env = System.getenv("TEST_ENV");
assumingThat("LOCAL".equalsIgnoreCase(test_env), () -> {
System.out.println("local");
});
assumingThat("REMOTE".equalsIgnoreCase(test_env), () -> {
System.out.println("remote");
});
}
마치 if문처럼 환경변수의 값에 따라 다른 테스트 로직을 수행하도록 설정할 수 있다.
@Enabled와 @Disabled
코드로 조건을 사용하는 것이 지저분하다면 애노테이션을 사용할 수도 있다.
@Enabled___, @Disabled___
- OnOS
- OnJre
- IfEnvironmentVariable
각 애노테이션 뒤에 필요한 조건을 붙여서 특정 상황에 대해서 테스트를 실행할지 실행하지 않을지 결정할 수 있다.
@EnabledOnOs, @DisabledOnOs는 특정 OS에서만 테스트를 수행하도록 할 수 있다.
@Test
@DisplayName("스터디 만들기")
@EnabledOnOs({OS.MAC, OS.LINUX})
void create_new_study() {
System.out.println("study");
}
@Test
@DisabledOnOs(OS.MAC)
void create_new_study_again() {
System.out.println("again");
}
위 예제에서는 현재 OS가 MAC이라면 첫 번째 테스트는 수행하고 두 번째 테스트는 수행하지 않는다. OS는 { } 배열로 여러 개를 지정할 수도 있다.
@EnabledOnJre는 특정 자바 버전에 대해 테스트 코드를 수행한다.
@Test
@DisplayName("스터디 만들기")
@EnabledOnOs({OS.MAC, OS.LINUX})
@EnabledOnJre({JRE.JAVA_8,JRE.JAVA_9, JRE.JAVA_10, JRE.JAVA_11})
void create_new_study() {
System.out.println("study");
}
@Test
@DisabledOnOs(OS.MAC)
@EnabledOnJre(JRE.OTHER)
void create_new_study_again() {
System.out.println("again");
}
자바 8, 9, 10, 11버전의 경우 첫 번째 테스트를 수행하고, 그 밖의 경우 두 번째 테스트를 수행한다.
@EnabledEnvironmentVariable은 환경 변수에 따라 테스트를 수행한다.
@Test
@DisplayName("스터디 만들기")
@EnabledOnOs({OS.MAC, OS.LINUX})
@EnabledOnJre({JRE.JAVA_8,JRE.JAVA_9, JRE.JAVA_10, JRE.JAVA_11})
@EnabledIfEnvironmentVariable(named = "TEST_ENV", matches = "LOCAL")
void create_new_study() {
System.out.println("study");
}
@Test
@DisabledOnOs(OS.MAC)
@EnabledOnJre(JRE.OTHER)
@EnabledIfEnvironmentVariable(named = "TEST_ENV", matches = "remote")
void create_new_study_again() {
System.out.println("again");
}
앞서 언급한 "TEST_ENV" 환경 변수가 "LOCAL"이면 첫 번째 테스트를, "remote"면 두 번째 테스트를 수행한다.
이처럼 다양한 조건에 따라 테스트 수행 여부를 결정할 수 있다.
JUnit 5 태깅과 필터링
태깅은 테스트 그룹을 만들고 원하는 테스트 그룹만 실행할 수 있도록 하는 기능이다. 다음 예제를 보자.
@Test
@DisplayName("스터디 만들기 fast")
@Tag("fast")
void create_new_study() {
System.out.println("fast");
}
@Test
@DisplayName("스터디 만들기 slow")
@Tag("slow")
void create_new_study_again() {
System.out.println("slow");
}
@Tag를 사용해서 각각의 value 값을 다르게 설정했다.
예를 들어, 첫 번째 테스트는 빠르게 끝낼 수 있어서 fast 태그를, 두 번째 테스트는 시간이 많이 소요되서 slow 태그를 달았다고 하자.
Intellij
Intellij에서는 프로그램 실행시 태그를 설정할 수 있다.
1. 프로젝트 우측 상단에 "Edit Configurations" 메뉴를 클릭한다.
2. Build and run -> Tags 선택 -> 우측 블록에 태그 value 값 설정
이후 실행 항목을 이전에 설정한 Tag로 변경하고 실행 버튼을 클릭하면 된다.
<결과>
fast
@Tag("fast")인 테스트만 실행되는 것을 확인할 수 있다.
Maven
콘솔에서 Maven으로 실행할 때, 태그를 설정하는 방법이다.
아무 설정없이 그냥 실행하면 @Tag에 관계없이 모든 테스트가 실행된다.
./mvnw test
<결과>
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0
태그 설정을 위해 pom.xml에 다음 내용을 추가하자.
<profiles>
<profile>
<id>default</id>
<activation>
<activeByDefault>true</activeByDefault>
</activation>
<build>
<plugins>
<plugin>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<groups>fast</groups>
</configuration>
</plugin>
</plugins>
</build>
</profile>
<profile>
<id>ci</id>
<build>
<plugins>
<plugin>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<groups>fast | slow</groups>
</configuration>
</plugin>
</plugins>
</build>
</profile>
</profiles>
profile을 각 태그 별로 설정한다.
default로는 fast 태그를 사용하고, 만약 프로파일 id가 ci인 옵션을 사용한다면 fast | slow 태그 모두를 허용하는 설정이다.
이제 Maven을 실행하면 fast 태그에 대한 테스트만 수행하게 된다.
만약 프로파일 id가 ci인 프로파일을 사용하고 싶다면 Maven 실행시 -P 옵션을 주면 된다.
./mvnw test -P ci
JUnit 5 커스텀 태그
JUnit 5는 애노테이션을 조합하여 커스텀 태그를 만들 수 있다.
<FastTest>
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Test
@Tag("fast")
public @interface FastTest {
}
<SlowTest>
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Test
@Tag("slow")
public @interface SlowTest {
}
@Target, @Retetion은 애노테이션을 만들 때 사용하는 메타 애노테이션이고, 중요한건 @Test, @Tag를 붙임으로써 기존에 여러 애노테이션으로 설정했던 부분을 하나의 애노테이션으로 설정한다는 것이다.
<StudyTest>
@FastTest
@DisplayName("스터디 만들기 fast")
void create_new_study() {
System.out.println("fast");
}
@SlowTest
@DisplayName("스터디 만들기 slow")
void create_new_study_again() {
System.out.println("slow");
}
위 예제는 이전 예제와 같은 코드가 된다.
JUnit 5 테스트 반복하기
@RepeatedTest
특정 테스트를 반복할 수 있다. 다음 예제는 반복 횟수를 10회로 설정했다.
@RepeatedTest(10)
void repeatTest() {
System.out.println("test");
}
<결과>
@RepeatedTest는 반복 테스트의 이름을 설정할 수 있고, RepetitionInfo 인자를 받아서 반복 횟수를 출력할 수 있다.
@DisplayName("스터디 만들기")
@RepeatedTest(value = 10, name = "{displayName}, {currentRepetition}/{totalRepetitions}")
void repeatTest(RepetitionInfo repetitionInfo) {
System.out.println("test" + repetitionInfo.getCurrentRepetition() + "/" + repetitionInfo.getTotalRepetitions());
}
<결과>
@RepeatedTest(name= "")에 여러 변수들을 사용해서 반복 테스트의 이름을 설정할 수 있고,
RepetitionInfo 인자를 받아서 현재 반복 횟수를 알아낼 수 있다.
@ParameterizedTest
@DisplayName("스터디 만들기")
@ParameterizedTest(name = "{index} {displayName} message={0}")
@ValueSource(strings = {"날씨가", "많이", "추워지고", "있네요."})
void parameterizedTest(String message) {
System.out.println(message);
}
<결과>
@ParameterizedTest는 @ValueSource에 있는 배열의 요소들을 하나하나 불러와 반복하는 테스트이다.
@RepeatedTest와 마찬가지로 name 옵션으로 이름을 지정할 수 있다.
@ParameterizedTest는 @ValueSource와 같이 다양한 인자 값들의 소스를 지원한다.
- @NullSource, @EmptySource, @NullAndEmptySource
- @ValueSource
- @CsvSource
@NullSource, @EmptySource는 각각 null, 빈 String을 인자로 추가한다.
@DisplayName("스터디 만들기")
@ParameterizedTest(name = "{index} {displayName} message={0}")
@ValueSource(strings = {"날씨가", "많이", "추워지고", "있네요."})
@NullSource
@EmptySource
void parameterizedTest(String message) {
System.out.println(message);
}
<결과>
합쳐서 @NullAndEmptySource를 사용해도 된다.
@DisplayName("스터디 만들기")
@ParameterizedTest(name = "{index} {displayName} message={0}")
@ValueSource(strings = {"날씨가", "많이", "추워지고", "있네요."})
@NullAndEmptySource
void parameterizedTest(String message) {
System.out.println(message);
}
@ValueSource는 앞서 보았듯이 여러 타입의 인자 값에 대한 소스를 제공한다.
@DisplayName("스터디 만들기")
@ParameterizedTest(name = "{index} {displayName} message={0}")
@ValueSource(ints = {10, 20, 40})
void parameterizedTest(Integer limit) {
System.out.println(limit);
}
<결과>
여기서 한 가지 심화된 버전을 알아보자. 만약 Integer limit을 Study 클래스에 넣고 Study 클래스를 인자로 받을 수는 없을까?
즉, Integer를 Study로 타입 변환을 하고 싶은 상황이다.
다음 예제를 보자.
@DisplayName("스터디 만들기")
@ParameterizedTest(name = "{index} {displayName} message={0}")
@ValueSource(ints = {10, 20, 40})
void parameterizedTest(Study study) {
System.out.println(study.getLimit());
}
자동으로 생성자에 Integer값을 넣어주면 좋겠지만 실제로는 위 테스트는 converting 오류가 발생한다. 따라서 이런 경우 명시적으로 타입 변환을 위한 구현체를 생성해야 한다.
@DisplayName("스터디 만들기")
@ParameterizedTest(name = "{index} {displayName} message={0}")
@ValueSource(ints = {10, 20, 40})
void parameterizedTest(@ConvertWith(StudyConverter.class) Study study) {
System.out.println(study.getLimit());
}
static class StudyConverter extends SimpleArgumentConverter {
@Override
protected Object convert(Object o, Class<?> aClass) throws ArgumentConversionException {
assertEquals(Study.class, aClass, "Can only convert to Study");
return new Study(Integer.parseInt(o.toString()));
}
}
SimpleArgumentConverter를 상속받은 static class를 정의한다. 리턴 값으로 인자(Integer limit)이 포함된 Study 객체를 반환한다.
테스트 코드에서는 인자에 @ConvertWith를 사용하면 이제 알아서 @ValueSource를 Integer 값에 대해 Study 객체를 받을 수 있게 된다.
@CsvSource는 여러 타입의 인자를 한 번에 받을 수 있다.
@DisplayName("스터디 만들기")
@ParameterizedTest(name = "{index} {displayName} message={0}")
@CsvSource({"10, '자바 스터디'", "20, 스프링"})
void parameterizedTest(Integer limit, String name) {
System.out.println(new Study(limit, name));
}
이 역시 인자들을 조합하여 한 번에 Study 객체로 받을 수 있는 방법은 없을까 라는 자연스러운 궁금증이 생긴다.
간편한 방법으로는 ArgumentsAccessor로 인자를 받는 방법이 있다.
@DisplayName("스터디 만들기")
@ParameterizedTest(name = "{index} {displayName} message={0}")
@CsvSource({"10, '자바 스터디'", "20, 스프링"})
void parameterizedTest(ArgumentsAccessor argumentsAccessor) {
System.out.println(new Study(argumentsAccessor.getInteger(0), argumentsAccessor.getString(1)));
}
그러나 이 방법 역시 직접 Study로 값을 받지는 못한다. 여러 값들로 한 번에 객체를 만들기 위해서는 또 새로운 static class가 필요하다.
@DisplayName("스터디 만들기")
@ParameterizedTest(name = "{index} {displayName} message={0}")
@CsvSource({"10, '자바 스터디'", "20, 스프링"})
void parameterizedTest(@AggregateWith(StudyAggregator.class) Study study) {
System.out.println(study);
}
static class StudyAggregator implements ArgumentsAggregator {
@Override
public Object aggregateArguments(ArgumentsAccessor argumentsAccessor, ParameterContext parameterContext) throws ArgumentsAggregationException {
return new Study(argumentsAccessor.getInteger(0), argumentsAccessor.getString(1));
}
}
ArgumentsAggregator를 구현한 static class를 정의한다. 이 때, Study 객체의 생성자를 통해 값을 입력 받고 리턴한다.
이제 @AggregateWith를 사용해서 인자 값을 조합하여 한 번에 Study 객체를 만들 수 있다.
+) 참고
이처럼 static class를 만들어서 인자 값 타입을 변환하거나, 인자 값을 조합하는 경우 반드시 이너 static 클래스를 사용해야 하고, 매칭하는 객체는 public class 여야 한다.
JUnit 5 테스트 인스턴스
JUnit은 테스트 메서드마다 테스트 인스턴스를 새로 만드는 것이 기본 전략이다. 이는 테스트 메서드를 독립적으로 실행하여 예상치 못한 부작용을 방지하기 위함이다. 다음 예제를 보자.
class StudyTest {
int value = 1;
@Test
void create_new_study() {
System.out.println(this);
System.out.println(value++);
}
@Test
void create_new_study_again() {
System.out.println(this);
System.out.println(value++);
}
}
<결과>
me.danuri.inflearnthejavatest.StudyTest@74235045
1
me.danuri.inflearnthejavatest.StudyTest@2d1ef81a
1
얼핏 보면 value 값을 두 테스트 메서드에서 공유할 것 같지만 실제로는 서로 다른 인스턴스가 각각 value 값을 갖기 때문에, 위와 같은 결과가 나온다.
이 기본 전략을 JUnit 5에서 변경할 수 있다.
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class StudyTest {
int value = 1;
@BeforeAll
void beforeAll() {
System.out.println("before all");
}
@AfterAll
void afterAll() {
System.out.println("after all");
}
@Test
void create_new_study() {
System.out.println(this);
System.out.println(value++);
}
@Test
void create_new_study_again() {
System.out.println(this);
System.out.println(value++);
}
}
<결과>
before all
me.danuri.inflearnthejavatest.StudyTest@7c7a06ec
1
me.danuri.inflearnthejavatest.StudyTest@7c7a06ec
2
after all
@TestInstance(Lifecycle.PER_CLASS) 애노테이션을 사용하면 메서드가 아닌 테스트 클래스당 인스턴스를 하나만 만들어서 사용한다.
인스턴스를 클래스에 하나만 사용하기 때문에 @BeforeAll, @AfterAll 메서드도 더 이상 static 메서드일 필요가 없다.
결과를 보면 두 테스트 메서드에서 하나의 value 값을 사용하는 것을 볼 수 있다.
JUnit 5 테스트 순서
테스트 메서드는 JUnit 내부의 특정한 순서에 의해 실행되지만 어떻게 그 순서를 정하는지는 분명히 하지 않는다.
순서를 명확하게 드러내지 않는 이유는 하나의 단위 테스트는 다른 단위 테스트에 대해 독립적으로 수행 가능하도록 의도한 것이다. 즉, 단위 테스트 간에는 서로 의존하지 않아야 하기 때문에 순서에도 의존하면 안된다.
그러나 경우에 따라, 특정 순서대로 테스트를 실행하고 싶을 때가 있다. 통합 테스트나 시나리오 테스트 등이 있을 것이다. 이런 경우 원하는 순서에 따라 실행하도록 @TestMethodOrder를 사용할 수 있다.
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class StudyTest {
@Test
@Order(2)
void create_new_study() {
System.out.println("first test");
}
@Test
@Order(1)
void create_new_study_again() {
System.out.println("second test");
}
}
<결과>
second test
first test
@TestMethodOrder에는 여러 value 값을 넣어 줄 수 있지만, 여기서는 OrderAnnotation을 사용하겠다.
각 테스트 메서드마다 @Order에 value값을 넣어 순서를 매길 수 있다. value 값이 낮을 수록 더 높은 우선순위를 갖는다.
+) JUnit이 제공하는 @Order와 스프링이 제공하는 @Order를 헷갈리지 말자.
+) 만약 @Order가 같을 때는, 같은 우선순위에 대해 JUnit 나름의 순서를 부여하는 듯 하다. -> 가급적 다른 value 값을 주도록 하자.
@TestMethodOrder는 꼭 @TestInstance와 같이 사용할 필요는 없지만 메서드 간에 순서가 부여된 만큼 서로의 상태를 공유하면서 테스트를 짜고 싶다면 함께 작성하는 것이 좋다.
JUnit 5 junit-platform.properties
junit-platform.properties는 JUnit 설정 파일로, "src/test/resources/" 에 넣어두면 적용된다.
다음은 junit-platform.properties에 대한 몇 가지 설정들을 소개한다.
테스트 인스턴스 라이프사이클 설정
junit.jupiter.testinstance.lifecycle.default = per_class
== @TestInstance(TestInstance.Lifecycle.PER_CLASS)
@Disabled 무시하고 실행하기
junit.jupiter.conditions.deactivate = org.junit.*DisabledCondition
-> @Disabled가 적용된 테스트 메서드들도 실행한다.
테스트 이름 표기 전략 설정
junit.jupiter.displayname.generator.default = org.junit.jupiter.api.DisplayNameGenerator$ReplaceUnderscores
== @DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class)
JUnit 5 확장 모델
JUnit 4의 확장 모델은 @Runwith, TestRule 등 다양하지만
JUnit 5의 확장 모델은 Extension 단 하나이다.
간단한 확장 모델을 만들어보자. 다음은 시간이 많이 소요되는 테스트에 대해 @SlowTest를 붙이도록 권장하는 확장 모델이다.
<FindSlowTestExtension>
public class FindSlowTestExtension implements BeforeTestExecutionCallback, AfterTestExecutionCallback {
private static final long THRESHOLD = 1000L;
@Override
public void beforeTestExecution(ExtensionContext extensionContext) throws Exception {
ExtensionContext.Store store = getStore(extensionContext);
store.put("START_TIME", System.currentTimeMillis());
}
@Override
public void afterTestExecution(ExtensionContext extensionContext) throws Exception {
Method requiredTestMethod = extensionContext.getRequiredTestMethod();
SlowTest annotation = requiredTestMethod.getAnnotation(SlowTest.class);
String testMethodName = requiredTestMethod.getName();
ExtensionContext.Store store = getStore(extensionContext);
long start_time = store.remove("START_TIME", long.class);
long duration = System.currentTimeMillis() - start_time;
if (duration > THRESHOLD && annotation == null) {
System.out.printf("Please consider mark method [%s] with @SlowTest.\n", testMethodName);
}
}
private ExtensionContext.Store getStore(ExtensionContext extensionContext) {
String testClassName = extensionContext.getRequiredTestClass().getName();
String testMethodName = extensionContext.getRequiredTestMethod().getName();
return extensionContext.getStore(ExtensionContext.Namespace.create(testClassName, testMethodName));
}
}
BeforeTestExecutionCallback, AfterTestExecutionCallback 인터페이스를 구현하면 된다.
각 메서드의 인자인 ExtensionContext가 제공하는 getStore() 메서드를 통해 저장소를 만들어서 before와 after간의 자원을 공유할 수 있다.
afterTestExecution 코드는 테스트 시간이 1000ms 이상인데다가 @SlowTest가 붙지 않은 테스트 메서드에 대해 경고 메시지를 출력하는 역할을 한다.
@ExtendWith
실제로 테스트 코드를 돌려보자.
@ExtendWith(FindSlowTestExtension.class)
class StudyTest {
@Test
void create_new_study_again() throws InterruptedException {
Thread.sleep(1005L);
}
}
<결과>
Please consider mark method [create_new_study_again] with @SlowTest.
확장 모델을 사용하기 위해 @ExtendWith()를 사용했다. 1005ms가 소요되는 테스트에 대해 @SlowTest를 붙이지 않았음으로 경고 메시지를 출력한다.
@RegisterExtension
만약 테스트 시간 측정의 기준인 THRESHOLD를 확장 모델을 사용할 때마다 그때그때 다르게 하고 싶다면 어떨까?
public class FindSlowTestExtension implements BeforeTestExecutionCallback, AfterTestExecutionCallback {
private long THRESHOLD;
public FindSlowTestExtension(long THRESHOLD) {
this.THRESHOLD = THRESHOLD;
}
...
}
이 때, @ExtendWith를 사용하면 확장 모델 생성자에 값을 넣을 수 있는 방법이 없기 때문에 오류가 발생한다.
이런 경우 @RegisterExtension을 사용해서 직접 필드에서 확장 모델을 사용하는 방법이 있다.
class StudyTest {
@RegisterExtension
static FindSlowTestExtension findSlowTestExtension = new FindSlowTestExtension(1000L);
@Test
void create_new_study_again() throws InterruptedException {
Thread.sleep(1005L);
}
}
이렇게 명시적으로 인자 값을 부여할 수 있다.
JUnit 5 마이그레이션
junit-vintage-engine을 의존성으로 추가하면, JUnit 5의 junit-platform으로 JUnit 3과 4로 작성된 테스트를 실행할 수 있다.
<dependency>
<groupId>org.junit.vintage</groupId>
<artifactId>junit-vintage-engine</artifactId>
<version>5.8.1</version>
<scope>test</scope>
</dependency>
<StudyJUnit4Test>
public class StudyJUnit4Test {
@Before
public void before() {
System.out.println("before");
}
@Test
public void createTest() {
System.out.println("test");
}
}
JUnit 4를 사용하지 말아야 할 이유는 없으나, 굳이 vintage를 의존해서 JUnit 4를 사용하는 것 보다 최신 버전인 JUnit 5를 사용하는 것이 났겠다.
이런 경우, 과거 JUnit 4 테스트 코드들을 JUnit 5로 마이그레이션 해야 하는데, 이건 예시를 들어가며 설명하는 것보다 직접 해보는 것이 더 효율적이기 때문에 JUnit 4에서 5로 넘어가면서 변경된 대표적인 애노테이션을 언급하면서 마무리하겠다.
JUnit 4 | JUnit 5 |
@Category(Class) | @Tag(String) |
@RunWith, @Rule, @ClassRule | @ExtendWith, @RegisterExtension |
@Ignore | @Disabled |
@Before, @After, @BeforeClass, @AfterClass |
@BeforeEach, @AfterEach, @BeforeAll, @AfterAll |
'java > java' 카테고리의 다른 글
[Java] 람다식 (0) | 2021.09.28 |
---|---|
[Java] Mockito (0) | 2021.09.28 |
[Java] 제네릭 (1) | 2021.09.19 |
[Java] I/O (1) | 2021.09.08 |
[Java] 애노테이션 (1) | 2021.09.05 |