테스트 코드 학습 (junit5) - 개념 및 간단한 사용법
junit5 가 나온지 2년이 넘어가고 있습니다. TDD로 프로젝트를 개발하기 위해선 당연히 Test Code를 작성할 줄 알아야합니다. 이번 글에는 Test Code에 대해서 개념과 간단한 사용법에 대해서 정리하겠습니다.
사용된 모든 소스는 Github에 올려두었습니다.
참고 영상 : www.inflearn.com/course/the-java-application-test/dashboard
JUnit 이란?
Java Unit Testing. 자바에서 단위 테스트 작성 도구입니다. 로직을 구현하고 테스트를 하려면 서비스를 띄우고 직접 행위를 해야만 테스트를 할 수 있었으나 JUnit을 사용하여 서비스를 띄우지 않고도 로직에 대한 테스트를 실행할 수 있게되었습니다.
Junit4에서는 1개의 jar파일로 구성되어 있었고 그 외 기능을 구현하려면 다른 Library를 추가하는 방식으로 구현했어야합니다. JUnit5로 넘어오면서 자체적으로 여러 모듈로 구성되어있습니다.
기본적으로 Java8 이상 부터 지원하고 있고 Spring boot로 프로젝트를 생성하면 기본적으로 JUnit5를 사용할 수 있는 환경이 갖춰집니다.
JUnit Platform
Java Unit Test를 하기 위한 Launcher를 제공합니다. TestEngine API를 제공합니다.
Jupiter
Junit5 TestEngine API 구현체입니다.
Vintage
Junit3, 4를 지원하는 TestEngine 구현체입니다. 이 글에서는 Junit5 기준으로 다루기 때문에 사용하지 않습니다. exclude!!
기본 Annotation
JUnit Platform이 어떤 메서드를 대상으로 테스트를 실행할 지 타겟을 지정해야하고 테스트를 실행할 때 부가적으로 다양한 액션을 실행할 수 있습니다. 여기서 가장 기본적인 Annotation에 대해서 알아보겠습니다.
@Test
테스트 대상이 되는 메서드에 선언합니다.
@Target({ ElementType.ANNOTATION_TYPE, ElementType.METHOD }) // Method에 선언 가능
@Retention(RetentionPolicy.RUNTIME)
@Documented
@API(status = STABLE, since = "5.0")
@Testable
public @interface Test {
}
@BeforeEach, @AfterEach
테스트들이 실행될 때 이전 혹은 이후에 수행할 액션을 작성할 수 있는 Annotation입니다.
@BeforeAll, @AfterAll
Each 와 다른점은 클래스 내에서 실행하는 테스트 전에 한 번만 실행될 수 있도록 도와주는 Annotation입니다. 위 Annotation과 다른점은 static 메서드여야합니다. 각 메서드는 실행될 때 새로운 인스턴스로 작동되기 때문에 독립적으로 실행됩니다. 그리하여 모든 메서드 전에 실행되는 All 메서드는 static으로 적용되어야 됩니다.
@Disabled
사용하지 않는 테스트 메서드에 선언하면 실행하지 않습니다.
적용해보자
전체 코드를 작성해보고 실행하여 결과값을 보면 이해하는데 도움이 될 것입니다! :)
class StudyTest {
@Test
void test1() {
Study study = new Study();
assertNotNull(study);
System.out.println("test1");
}
@Test
void test2() {
System.out.println("test2");
}
@Test
@Disabled
void test3() {
System.out.println("test3");
}
@BeforeAll
static void beforeAll() {
System.out.println("beforeAll 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");
}
}
beforeAll all
before each
test1
after each
before each
test2
after each
after all
Test Name 전략
@DisplayNameGeneration
class에 선언하여 class 내 모든 테스트 명에 적용이 됩니다.
@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class)
class StudyNameTest {
}
@DisplayName
메서드에 선언하여 테스트 명을 적용합니다.
@Test
@DisplayName("테스트 성공")
void test_success() {
System.out.println("test success");
}
Assert
테스트 코드에서 검증 역할을 합니다.
assert에는 다양한 종류가 있습니다. 여기서는 assertEquals만 보겠습니다. 파라미터의 2개의 값을 비교합니다. 하나는 로직 실행 후 실제 결과값과 내가 예상하는.. 희망하는 값을 넣어주고 값이 같으면 테스트가 성공합니다.
class StoreTest {
@Test
@DisplayName("매장 오픈 상태 테스트")
void open_test() {
Store store = new Store();
StoreStatus status = store.open();
assertEquals(status, StoreStatus.CLOSE, "매장 오픈 시 상태값은 OPEN 입니다!!");
}
@Test
@DisplayName("매장 클로즈 상태 테스트")
void close_test() {
Store store = new Store();
StoreStatus status = store.close();
assertEquals(status, StoreStatus.CLOSE, "매장 클로즈 시 상태값은 CLOSE 입니다!!");
}
}
여기서 팁!
message만 선언하는 경우 성공 여부에 상관없이 message 를 실행합니다. 하지만 람다로 변경하면 실패 했을 경우에만 실행됩니다.
optional에서 orElseGet과 비슷하네요.
assertEquals(status, StoreStatus.CLOSE, "매장 오픈 시 상태값은 OPEN 입니다!!");
assertEquals(status, StoreStatus.CLOSE, () -> "매장 오픈 시 상태값은 OPEN 입니다!!");
AssertAll()
assert는 순차적으로 체크합니다. 1 ~ 10개의 assert가 있는데 2번째에서 에러가 발생하면 나머지 8개는 실행하지 않습니다. 난 10개 모두 보고싶은데?? 할때 사용합니다.
@Test
@DisplayName("매장 클로즈 상태 테스트")
void close_test() {
Store store = new Store("대박사업장");
StoreStatus status = store.close();
assertAll(
() -> assertNotNull(status),
() -> assertEquals(status, StoreStatus.OPEN, () -> "매장 클로즈 시 상태값은 CLOSE 입니다!!"),
() -> assertEquals(store.getName(), "대박사업장1", () -> "사업장 이름이 다르다!!!")
);
}
AssertThrows
exception도 체크할 수 있습니다.
@Test
@DisplayName("사업장 이름 없는 경우")
void store_name_null() {
IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> new Store(null));
assertEquals(exception.getMessage(), "사업장 이름은 필수입니다!!");
}
AssertTimeOut
timeOut도 체크 가능합니다.
@Test
@DisplayName("사업장 판매 타임아웃 발생 - 계속 기다린다.")
void store_sell_timeout_wait() {
Store store = new Store("사업");
assertTimeout(Duration.ofMillis(3000L), () -> {
store.sell();
});
}
@Test
@DisplayName("사업장 판매 타임아웃 발생 - 안 기다린다.")
void store_sell_timeout_right_now() {
Store store = new Store("사업");
assertTimeoutPreemptively(Duration.ofMillis(3000L), () -> {
store.sell();
});
}
조건적으로 테스트코드 실행
assume
assume의 조건에 맞는다면 assume 하위 코드를 실행하고 아니면 실행하지 않습니다.
@Test
@DisplayName("조건 적으로 테스트 실행하기")
void store_assume_test() {
Store store = new Store("사업");
assumeTrue(() ->
store.getName().equals("사업1")
);
// 하위 코드 실행하지 않음
assertEquals(store.open(), StoreStatus.CLOSE, () -> "사업장 오픈 시 상태값은 OPEN입니다!!");
}
@Test
@DisplayName("조건 적으로 테스트 실행하기")
void store_assume_test() {
Store store = new Store("사업");
assumingThat(() -> store.getName().equals("사업1"), () -> {
assertEquals(store.open(), StoreStatus.CLOSE, () -> "사업장 오픈 시 상태값은 OPEN입니다!!");
});
}
메서드 위에 @Enable 관련 어노테이션을 선언하여 위와 같은 기능을 할 수 있다.
Test Group
@Tag를 사용하여 Test를 Group화 시킬 수 있습니다.
class StoreTest {
@Test
@DisplayName("케이스1에 대한 테스트")
@Tag("case1")
void store_case1_test1() {
Store store = new Store("사업");
assertEquals(store.getName(), "사업");
}
@Test
@DisplayName("케이스1에 대한 테스트")
@Tag("case1")
void store_case1_test2() {
Store store = new Store("사업");
assertEquals(store.getName(), "사업");
}
@Test
@DisplayName("케이스2에 대한 테스트")
@Tag("case2")
void store_case2_test1() {
Store store = new Store("사업");
assertEquals(store.getName(), "사업");
}
@Test
@DisplayName("케이스2에 대한 테스트")
@Tag("case2")
void store_case2_test2() {
Store store = new Store("사업");
assertEquals(store.getName(), "사업");
}
}
그리고 Tag값에 따라 테스트의 실행 여부를 설정할 수도 있습니다.
build.gradle 의 설정값을 바꿔 원하는 태그값 혹은 원하지 않는 태그값을 설정하여 테스트할 수 있습니다.
// build.gradle
test {
useJUnitPlatform{
includeTags 'case1'
excludeTags 'case2'
}
}
테스트 루프
테스트 코드를 작성하고 내가 원하는 만큼 루프 테스트를 하고 싶은 경우도 있습니다. 그 때 사용하는 테스트 관련 Annotation입니다.
RepeatedTest
단순하게 내가 원하는 만큼 loop 테스트를 진행합니다.
class StaffTest {
@DisplayName("repeated를 이용해서 loop만큼 테스트")
@RepeatedTest(value = 10, name = "{displayName}, {currentRepetition}, {totalRepetition}")
void repeated_test(RepetitionInfo repetitionInfo) {
System.out.println("current : " + repetitionInfo.getCurrentRepetition() + ", total : " + repetitionInfo.getTotalRepetitions());
}
}
ParameterizedTest
테스트하고 싶은 파라미터를 설정하고 loop 테스트를 진행합니다.
ValueSource
원하는 type을 설정하고 원하는 loop 만큼 파라미터를 입력합니다.
먼저 입력한 데이터를 단순하게 파라미터로 설정하여 사용하는 경우입니다.
class Staff {
@DisplayName("parameterized를 이용해서 loop만큼 테스트 - valueSource")
@ParameterizedTest(name = "{displayName}, {index}, message={0}")
@ValueSource(strings = {"TDD를", "향한", "첫걸음", "가자"})
@NullAndEmptySource
void parameterized_test(String param) {
System.out.println(param);
}
}
파라미터를 내가 원하는 객체에 매핑하여 사용하는 경우입니다.
파라미터로 설정한 값을 객체에 매핑할 수 있게 도와주는 SimpleArgumentConverter를 상속받은 Converter 클래스와 생성한 Converter로 Convert할 수 있도록 지정하는 @ConvertWith 어노테이션을 사용합니다.
SimpleArgumentConverter는 1개의 파라미터 배열에 대해서만 적용이 가능합니다.
public class StaffSimpleArgumentConverter extends SimpleArgumentConverter {
@Override
protected Object convert(Object source, Class<?> targetType) throws ArgumentConversionException {
assertEquals(Staff.class, targetType, "Staff 클래스만 올 수 있습니다!!");
return new Staff(source.toString(), null);
}
}
class Staff {
@DisplayName("parameterized를 이용해서 loop만큼 테스트 - SimpleArgumentConvert")
@ParameterizedTest(name = "{displayName}, {index}, message={0}")
@ValueSource(strings = {"TDD를", "향한", "첫걸음", "가자"})
void parameterized_simple_argument_convert_test(@ConvertWith(StaffSimpleArgumentConverter.class) Staff staff) {
System.out.println(staff.getName());
}
}
CsvSource
설정한 파라미터 배열에 대해서 loop 테스트 할때 사용됩니다. ( 2개 이상 가능 )
단순하게 파라미터를 ArgumentsAccessor를 사용하여 한 개씩 가져옵니다.
class StaffTest {
@DisplayName("parameterized를 이용해서 loop만큼 테스트 - ArgumentsAccessor")
@ParameterizedTest(name = "{displayName}, {index}, message={0}")
@CsvSource({"'TDD를', 1", "'향한', 2", "'첫걸음', 3", "'가자' ,4"})
void parameterized_arguments_accessor_convert_test(ArgumentsAccessor argumentsAccessor) {
Staff staff = new Staff(argumentsAccessor.getString(0), argumentsAccessor.getInteger(1));
System.out.println("staff name: " + staff.getName() + ", staff age : " + staff.getAge());
}
}
파라미터가 2개 이상일 경우 내가 원하는 객체에 매핑하여 사용하는 경우입니다.
파라미터로 설정한 값을 객체에 매핑할 수 있게 도와주는 ArgumentsAggregator를 implements 받은 클래스와 생성한 Converter로 Convert할 수 있도록 지정하는 @AggregateWith 어노테이션을 사용합니다.
public class StaffArgumentsAggregator implements ArgumentsAggregator {
@Override
public Object aggregateArguments(ArgumentsAccessor accessor, ParameterContext context) throws ArgumentsAggregationException {
return new Staff(accessor.getString(0), accessor.getInteger(1));
}
}
class StaffTest {
@DisplayName("parameterized를 이용해서 loop만큼 테스트 - ArgumentsAggregator")
@ParameterizedTest(name = "{displayName}, {index}, message={0}")
@CsvSource({"'TDD를', 1", "'향한', 2", "'첫걸음', 3", "'가자' ,4"})
void parameterized_arguments_aggregator_convert_test(@AggregateWith(StaffArgumentsAggregator.class) Staff staff) {
System.out.println("staff name: " + staff.getName() + ", staff age : " + staff.getAge());
}
}
테스트 Life Cycle
앞서 많은 예제들을 살펴보면 Class안에 여러 메서드에 @Test를 달고 각 메서드별로 실행하고 있습니다. 각 메서드는 독립적인 존재입니다. 좀 더 자세히 정리하자면 하나의 메서드를 실행할 때마다 Test Class를 생성합니다. 그래서 테스트 대상이 되는 메서드의 클래스는 모두 다릅니다.
메서드는 독립적이므로 @BeforeAll, @AfterAll의 메서드는 static이 붙어야한다.
하지만 이렇게 사용하고 싶지 않을 수 있습니다. 매번 메서드를 만들때마다 새로운 인스턴스를 추가하기 싫을 경우! 예를 들어 너무 리소스 비용이 낭비되는게 싫다하시면 Life Cycle 정책을 Method별이 아닌 Class 별로 설정할 수 있습니다.
클래스에 Annotation TestInstance를 선언하고 LifeCycle을 PER_CLASS로 설정하여 변경이 가능합니다.
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class StaffTest {
}
테스트 순서
단위 테스트가 아닌 시나리오 테스트를 할 때 테스트의 순서는 중요하다고 생각합니다. 그럴 경우 테스트 순서를 어떻게 작성할 것인지에 대해 알아보겠습니다.
먼저 클래스에 @TestMethodOrder를 선언합니다. 속성값으론 OrderAnnotation으로 설정합니다. 그리고 각 테스트 메서드마다 Order 어노테이션을 붙히면 원하는 순서대로 실행이 가능합니다.
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class BagTest {
@Order(2)
@Test
@DisplayName("두번째로 테스트 실행")
void order2_test() {
System.out.println(2);
}
@Order(3)
@Test
@DisplayName("세번째로 테스트 실행")
void order3_test() {
System.out.println(3);
}
@Order(1)
@Test
@DisplayName("첫번째로 테스트 실행")
void order1_test() {
System.out.println(1);
}
}
테스트 확장
테스트 코드를 작성할 때 기본적으로 확장 기능을 제공해줍니다. 코드로 살펴보겠습니다.
제가 원하는 기능은 테스트 코드가 case 별로 나눠져 있다고 가정합니다. 그리고 case별로 @Tag를 이용하여 Group화 하여 상황에 맞게 실행하고 싶습니다. 그리고 메서드 이름에 prefix로 case를 명시하고 어노테이션을 선언하기로 약속했습니다. 하지만 어노테이션을 선언하지 않은 경우를 찾아 명시해달라는 메시지를 보여주도록 하겠습니다.
public class CaseTest {
@Case1
void case1_temp1_test() {
System.out.println("case1 temp1");
}
@Case1
void case1_temp2_test() {
System.out.println("case1 temp2");
}
@Test
void case1_temp3_test() {
System.out.println("case1 temp3");
}
@Test
void case1_temp4_test() {
System.out.println("case1 temp4");
}
}
이 코드에서는 3, 4번 째 메서드는 @Case1 을 명시하지 않았네요. 해당하는 메서드를 찾아 어노테이션을 선언하라고 알려줄 것입니다.
@ExtendWith
public class FindCaseTestExtension1 implements AfterTestExecutionCallback {
@Override
public void afterTestExecution(ExtensionContext context) throws Exception {
Method requiredTestMethod = context.getRequiredTestMethod();
Case1 case1 = requiredTestMethod.getAnnotation(Case1.class);
String methodName = requiredTestMethod.getName();
if (methodName.startsWith("case1") && case1 == null) {
System.out.printf("Method [%s] 에 @Case1을 선언해주세요.\n", methodName);
}
}
}
AfterTestExecutionCallback 을 implements하여 메서드를 오버라이딩 합니다. 블록 내 로직은 @Test가 달린 메서드에 @Case1의 메서드가 있는지 확인합니다. 그리고 메서드 명 prefix에 'case1' 이 있는지 확인합니다. 두 조건에 충족하면 메시지로 선언요청을 합니다.
@ExtendWith(FindCaseTestExtension1.class)
public class CaseTest {
....
}
위 메시지 요청 대상 클래스에 위와 같이 선언하고 실행하면 됩니다!
@RegisterExtension
위 처럼 작성했을 경우 동적인 파라미터를 받을 수 있는 방법은 존재하지 않습니다.
어노테이션에는 상수값만 설정 가능
하지만 난 케이스 별로 동적인 파라미터를 넘겨주는 어노테이션을 만들어 확장하고 싶습니다. 이럴 경우 RegisterExtension을 사용합니다.
public class FindCaseTestExtension2 implements AfterTestExecutionCallback {
private final String FIND_CASE_NAME;
public FindCaseTestExtension2(String FIND_CASE_NAME) {
this.FIND_CASE_NAME = FIND_CASE_NAME;
}
@Override
public void afterTestExecution(ExtensionContext context) throws Exception {
Method requiredTestMethod = context.getRequiredTestMethod();
Case1 case1 = requiredTestMethod.getAnnotation(Case1.class);
String methodName = requiredTestMethod.getName();
if (methodName.startsWith(FIND_CASE_NAME) && case1 == null) {
System.out.printf("Method [%s] 에 @Case1을 선언해주세요.\n", methodName);
}
}
}
생성자에 동적으로 할당하고 싶은 변수를 입력받도록 수정합니다.
public class CaseTestByRegisterExtension {
@RegisterExtension
static FindCaseTestExtension2 findCaseTestExtension2 = new FindCaseTestExtension2("case1");
...
}
static 변수에 @RegisterExtension을 선언하고 생성자를 만들면 적용됩니다.