Study/test

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

에디개발자 2021. 3. 15. 07:00
반응형

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

 

사용된 모든 소스는 Github에 올려두었습니다.

 

참고 영상 : www.inflearn.com/course/the-java-application-test/dashboard

 

더 자바, 애플리케이션을 테스트하는 다양한 방법 - 인프런 | 강의

자바 프로그래밍 언어를 사용하고 있거나 공부하고 있는 학생 또는 개발자라면 반드시 알아야 하는 애플리케이션을 테스트하는 다양한 방법을 학습합니다., 그냥 개발자를 넘어 '더 나은 개발

www.inflearn.com

나를 닮았다고 한다...

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

반응형