본문 바로가기

프로그래밍/test

테스트 코드 학습 (junit5) - 개념 및 간단한 사용법

반응형

junit5 가 나온지 2년이 넘어가고 있습니다. TDD로 프로젝트를 개발하기 위해선 당연히 Test Code를 작성할 줄 알아야합니다. 이번 글에는 Test Code에 대해서 개념과 간단한 사용법에 대해서 정리하겠습니다. 

 

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을 선언하고 생성자를 만들면 적용됩니다.

반응형