JUnit 5를 이용한 애플리케이션 테스트 방법

김대협·2022년 12월 8일
0

Testing

목록 보기
1/1

Youtube로 보기 - JUnit 5를 이용한 애플리케이션 테스트 방법

소프트웨어 테스트

테스트 기법

수동 테스트 (Manual Test): QA 담당자가 UI를 사용해 기능을 검증 (인적 리소스를 활용, 인수 테스트 방식에서 사용)

단점:
1) 테스트 실행 비용이 높고 결과의 변동이 큼  
2) 수동 테스트로 테스트 비용을 감당하기 어려움

소프트웨어 회귀 (Software regression)
원래 동작하던 기능이 어떠한 시점으로 부터 동작하지 않는 현상을 의미한다.

회귀 테스트 대상은 시간이 갈수록 증가한다.

테스트 자동화 (Automation Test): 기능을 검증하는 코드를 작성

장점: 
1) 테스트 코드 작성 비용이 소비되지만 실행 비용이 현저히 낮고 결과의 신뢰도가 높다.  
(단, 테스트 코드의 작성과 관리가 프로그래머 역량에 크게 영향 받음)

테스트 종류

인수 테스트 (Acceptance Test): 인수하는 과정에 이뤄지는 테스트 / 배치된 시스템을 대상으로 검증

장점: 
1) 전체 시스템 이상 여부 신뢰도가 높음  

단점: 
1) 높은 비용이 발생 (작성 비용 / 관리 비용 / 실행 비용)
2) 프로그래머 입장에서 피드백 품질이 낮음 (현상은 드러나지만 원인은 숨겨짐)

단위 테스트 (Unit Test): 시스템의 일부인 하위 시스템을 대상으로 검증

장점:
1) 낮은 비용 ( 작성 비용 / 관리 비용 / 실행 비용)  
2) 프로그래머 입장에서 높은 피드백 품질  

단점:
1) 전체 시스템 이상 여부 신뢰도가 낮음  

통합 테스트 (Integration Test): 단위 모듈을 연결하여 집합된 협력 관계를 검증

점진적 상, 하향식 방법으로 모듈 결합간 문제 여부를 테스트한다.

JUnit 5

What's the JUnit 5

자바를 사용하는 개발자가 가장 많이 사용하는 테스팅 프레임워크다.
(2021년 JetBrains 사 조사에 의하면 85% 비율로 사용) - JetBrains unit-testing frameworks used rate

기본적으로 JDK 8 이상에서 동작되는 환경이지만, JUnit 3, 4를 Dependency 한다.

공식 문서: JUnit5 User Guide
대체 프레임워크: TestNG, Spock


목차

JUnit5 기본 구조
기본 테스트 방법과 테스트명 표기
Assertion
조건에 따라 테스트 실행하기
태그와 필터링
테스트 반복하기
테스트 인스턴스
테스트 순서 보장
확장 모델(Extension Model)


기본 구조

JUnit Platform: TestEngine API 제공, JUnit Test Code Launcher
JUnit Jupiter: TestEngine 구현체 제공
JUnit Vintage: TestEngine 구현체 제공 (for JUnit3, 4)

1) SpringBoot 2.2부터 기본 테스팅 프레임워크로 내장
2) JUnit5 부터 모듈화가 되어있음.


기본 테스트 방법

@Test 애노테이션을 메소드 상위에 입력하여 테스트 대상 메소드로 지정할 수 있다.

  • JUnit5 부터는 public class, public method가 아니더라도 가능하다.
  • IDEs에서 테스트 실행 시 커서 위치에 따라 실행 단위가 바뀐다. (특정 버전 이하)
AnnotationDescriptionFormer
@Test테스트 지정 시 사용@Test
@BeforeAll모든 테스트 실행 이전 1회만 실행 (static 지정 사용 필요)@BeforeClass
@AfterAll모든 테스트 실행 이후 1회만 실행 (static 지정 사용 필요)@AfterClass
@BeforeEach각 테스트 이전 매번 1회 실행@Before
@AfterEach각 테스트 이후 매번 1회 실행@After

2개의 @Test 지정 시 테스트의 실행 순서


테스트명 표기 방법

1) 테스트명 기본 표기 방식은 선언된 메소드 이름을 따른다.

2) DisplayNameGeneration 지정 방식
클래스 상위에 @DisplayNameGeneration(전략 설정) 을 지정하여 사용한다.

StrategyDescription
ReplaceUnderscores_ 언더스코어 표기를 공백으로 대체
IndicativeSentences클래스명, 메소드 이름으로 표기
Simple불필요 표기 제거 () 등과 같은 문자
Standard표준 표기 (JUnit Jupiter 5.0 release 이후)

3) 테스트 이름을 별도로 부여하고 싶은 경우 @DisplayName("이름")을 통해 사용한다.

단, DisplayNameGeration은 DisplayName보다 낮은 순위로 적용 받는다.
@DisplayName을 통해 테스트 이름이 지정된 경우 적용받지 않는다.


Assertions

JDK 1.4부터 도입된 assert라는 예약어를 통해 사용할 수 있다.
기본 환경에서는 활성되지 않으며 기본 옵션을 부여해야 사용할 수 있다.
Assertions는 단언문이라고 칭하며 false로 평가될 경우 AssertionError를 발생시킨다.

assert [condition]
assert [condition] : [expression]

// for example
int num = -1;
assert num > 0 : "variable num is not greater than 0";

VM Options: -ea (전체 활성화)
-ea:[class name or package name] -da:[class name or package name] (지정 제외 활성 및 비활성)

  • 동일하게 Throwable을 상속 받지만, Error와 Exception의 구분은 필요하다.
  • assert 사용 시 AssertionError 발생으로 Runtime 용도로 사용하지 않음.
@Test
void multiplyTest() {
    assertEquals( 18, multiply( 9, 2 ), "9 x 2 값은 18 입니다. 결과 값 비정상 출력!!" );
    
    // Supplier 를 사용한 메시지 핸들링 
    assertEquals( 12, multiply( 3, 4 ), () -> "3 x 4 값은 12 입니다. 결과 값 비정상 출력!!" );
}

private int multiply( int x, int y ) {
    return x * y;
}

Expected: 기대 값, Actual: 실행 값

  • 역순으로 사용해도 동일한 결과를 반환하지만 순서에 맞는 할당을 진행할 것
  • Message Handling 과정에서 Supplier 를 사용할 수 있다. (생성 비용 이슈 해결)
@Test
@DisplayName("JUnit Exception 발생 테스트")
void throwsException() {
    Throwable throwable = assertThrows( IllegalArgumentException.class, () -> new Study( 101, "kim" ) );
    System.out.println( throwable.getMessage() );
}

@Test
@DisplayName("JUnit doesNotThrowsException 테스트")
void doesNotThrowsException() {
    assertDoesNotThrow( () -> new Study( 101, "kim" ) );
}

// Assertj 의 표현 방식
@Test
@DisplayName("Assertj Exception 발생 테스트")
void throwsExceptionForAssertj() {
    assertThatThrownBy( () -> new Study( 101, "kim" ) )
            .isInstanceOf( IllegalArgumentException.class );
}

assertThrow: 예외 발생 테스트
assertDoesNotThrow: 예외 미발생 테스트

// 제한 시간 이후에도 모든 루틴의 수행을 보장
@Test
@DisplayName("기본 Timedout 테스트")
void timeoutTest() {
    assertTimeout( Duration.ofSeconds( 1 ), () -> {
        TimeUnit.SECONDS.sleep( 5 );
    });
}

// 제한 시간 이후에는 루틴을 수행하지 않음.
@Test
@DisplayName("기본 Timedout Preemptively 테스트")
void timeoutPreemptivelyTest() {
    assertTimeoutPreemptively( Duration.ofSeconds( 1 ), () -> {
        TimeUnit.SECONDS.sleep( 10 );
    });
}

assertTimeout: 특정 duration 안에 끝나야 하는지 테스트
assertTimeoutPreemptively: 실제 타임아웃 시간을 기다려야 하는 단점을 극복할 수 있지만, 다른 스레드에서 실행하기 때문에 ThreadLocal에 의존하는 경우 예상치 못한 결과가 발생하는 문제가 있음.

// 중간에 테스트를 실패하는 경우 다음 테스트는 실행 중지됨.
@Test
@DisplayName("여러 개의 테스트가 중첩될 때 중간에 실패하는 경우")
void failWhileTest() throws InterruptedException {
    Person person = new Person( "Kim", "1984.11.05" );
    assertNotNull( person, () -> getHighCostString() );
    assertEquals( "Lee", person.getName() ); // expected fail case.
    assertEquals( 55, person.getAge() ); // expected fail case.
}

// 연관된 테스트를 한 번에 실행하는 방법
@Test
@DisplayName("assertAll을 이용한 모든 테스트 실행하기")
void executionAllTest() {
    Person person = new Person( "Kim", "1984.11.05" );
    assertAll(
            () -> assertNotNull( person, () -> getHighCostString() ),
            () -> assertEquals( "Lee", person.getName() ),
            () -> assertEquals( 55, person.getAge() ) );
}

assertAll: 하나의 테스트에서 중간 과정에서 실패하는 경우, 수정 및 반복을 통해 확인해야하는 단점을 극복할 수 있음. (오류 케이스를 한 번에 확인 가능)

AssertJ, Hamcrest, Truth 등 다양한 타입의 Assertion 을 사용할 수 있음.

// for Gradle users (using the Maven Central Repository)
testCompile("org.assertj:assertj-core:3.23.1")

// for JDK 7
testCompile("org.assertj:assertj-core:2.9.1")
// assertj example
@Test
void additionTest() {
    // actual, expected
    assertThat( addition( 3, 4 ) ).isEqualTo( 7 );
}

private int addition( int x, int y ) {
    return x + y;
}

조건에 따라 테스트 실행하기

assumeTrue를 사용하여 조건에 만족하는 경우만 다음 테스트를 수행하거나
assumingThat을 사용하여 조건별 테스트를 다르게 수행할 수 있음.

@Test
@DisplayName("조건에 따른 테스트 실행 - AssumeTrue 테스트")
void assumeTest() {
    int a = 10;
    String runningEnv = System.getProperty( "running.env" );
    assumeTrue( "DEV".equalsIgnoreCase( runningEnv ) );
    assertTrue( a <= 10, () -> "is not less than 10" );
}

@Test
@DisplayName("조건에 따른 테스트 실행 - assumingThat 테스트")
void assumingThatTest() {
    int a = 10;
    String runningEnv = System.getProperty( "running.env" );

    assumingThat( "DEV".equalsIgnoreCase( runningEnv ), () -> {
        System.out.println( "executed lambda area." );
        assertTrue( a <= 10, () -> "is not less than 10" );
    } );
}
AnnotationDescription
@Disabled지정 테스트를 비활성
@EnabledOnOs특정 OS 에서만 테스트
@EnabledOnJre특정 JRE 에서만 테스트
@EnabledForJreRange특정 범위에 JRE 에서만 테스트
@EnabledIfSystemPropertyproperty matches에 따른 테스트
@EnabledIfEnvironmentVariableenv.var matches에 따른 테스트
@EnabledIf사용자 커스텀 클래스의 반환값에 따른 테스트

각 테스트는 Disabled를 통해 반대의 개념도 가짐.

public class ConditionalTest {
    @Test
    @EnabledIfSystemProperty(named = "running.env", matches = "DEV")
    void systemPropertyTest() {
        System.out.println( "systemPropertyTest method executed." );
    }

    @Test
    @EnabledOnOs({ OS.MAC, OS.WINDOWS })
    void doTestForOS() {
        System.out.println( "doTestForOS method executed." );
    }

    @Test
    @EnabledForJreRange(min = JRE.JAVA_8, max = JRE.JAVA_11)
    void doTestForJRE() {
        System.out.println( "doTestForJRE method executed." );
    }

    @Test
    @EnabledIf("com.ntigo.junit5.demo.ExternalCondition#customCondition")
    void doTestForCustomCondition() {
        System.out.println( "doTestForCustomCondition method executed." );
    }

    @Test
    @Disabled("#123 이슈가 종료되기 전까지 테스트 중지")
    void doDisabledTest() {
        System.out.println( "doDisabledTest method executed." );
    }
}

class ExternalCondition {
    static boolean customCondition() {
        return true;
    }
}

태그와 필터링

테스트별 태그를 부여하고, 선별적인 테스트만 진행할 수 있음.

  • Fast, Slow 테스트 분리
  • Integration Test 분리
  • Local과 CI서버 분리

태그 명명 규칙

  • 공백이나 null은 비허용
  • ISO 제어 문자, 문자열 , ( ) | ! & 비허용

커스텀 태그

  • Meta Annotation을 사용할 수 있음.

  • 사용자가 Custom Annotaiton을 만들어 Compose한 형태로 사용 가능

    @Target(ElementType.METHOD)
    @Retention(RetentionPolicy.RUNTIME)
    @Test
    @DisplayName("CustomTag로 실행되는 테스트")
    @Tag("Integration")
    public @interface CustomTag {
    }
    // for Gradle users
    test {
    		useJUnitPlatform { 
         includeTags 'integration', 'fast'
         // excludeTags 'slow', 'ci' 
         includeEngines 'junit-jupiter' 
         // excludeEngines 'junit-vintage' }
    }

테스트 반복하기

RepaeatedTest
@RepeatedTest(반복 숫자)를 표기하여 사용

  • RepetitionInfo 객체를 인수로 받아 반복 정보를 활용
  • Placeholder를 테스트명에 사용할 수 있음.
@RepeatedTest(10)
@DisplayName("단순 반복 테스트")
void repeatedTest() {
    Random random = new Random();
    int rand = random.nextInt( 10 ) + 1;
    System.out.println( rand );
    assertTrue( rand >= 1 && rand <= 10, () -> "is not valid number." );
}

@RepeatedTest(value = 10, name = "{displayName}: {currentRepetition}/{totalRepetitions}")
@DisplayName("단순 반복 테스트")
void repeatedTest( RepetitionInfo repetitionInfo ) {
    Random random = new Random();
    int rand = random.nextInt( 10 ) + 1;
    System.out.println( rand + " " + repetitionInfo.getCurrentRepetition() + "/"
            + repetitionInfo.getTotalRepetitions() );
    assertTrue( rand >= 1 && rand <= 10, () -> "is not valid number." );
}

ParameterizedTest
JUnit 5부터 사용 가능
DataSource를 주입하여, 여러 가지 값에 따른 테스트를 진행하는 방식

// for Gradle users
testImplementation 'org.junit.jupiter:junit-jupiter-params:5.9.1'
AnnotationDescription
@ValueSource단일 개의 지정된 타입으로 value 제공
@CsvSource복수 개의 복수 타입으로 value 제공
@CsvFileSourceCSV 파일에 정의된 value 제공
@ArgumentSourceArgumentsProvider를 구현하는 Stream 제공
@MethodSourceStream을 제공하는 메소드를 사용
@NullSourcenull 문자열 제공
@EmptySource비어있는 문자열 제공
@NullAndEmptySourcenull, empty 문자열 제공

Implicit Conversion

// Value Source
@ParameterizedTest(name = "{displayName}: ({index}) {0}")
@ValueSource(strings = { "a", "b", "c" })
void paramTest( String input ) {
    System.out.println( input );
}

// CSV Source
@ParameterizedTest(name = "{displayName}: ({index})")
@CsvSource({ "'Kim', '1984'", "'Lee', '1985'", "'Park', '1986'" })
@DisplayName("CSV Source 사용하기 - params")
void paramsFromCsvSrcTest( String name, String birth ) {
    Person person = new Person( name, birth );
    System.out.println( person.getName() + " / " + person.getBirth() );
}

// CSV File Source
@ParameterizedTest(name = "{displayName}: ({index})")
@CsvFileSource(files = "src/test/resources/data.csv")
@DisplayName("CSV File Source 사용하기")
void argumentsAccessorFromCsvFileSrcTest( ArgumentsAccessor argumentsAccessor ) {
    System.out.println( argumentsAccessor.getString( 0 ) + " / " + argumentsAccessor.getString( 1 ) );
}

// Method Source
@ParameterizedTest
@MethodSource("argumentProvider")
void testWithExplicitLocalMethodSource( String argument, int sequence ) {
    System.out.println( sequence + " " + argument );
    assertNotNull( argument );
}

static Stream<Arguments> argumentProvider() {
    return Stream.of(
            Arguments.arguments( "apple", 1 ),
            Arguments.arguments( "banana", 2 ) );
}

// Argument Source
@ParameterizedTest
@ArgumentsSource( CustomArgsProvider.class )
void testWithCustomArgsProvider( String args ) {
    System.out.println( args );
}

static class CustomArgsProvider implements ArgumentsProvider {
    @Override
    public Stream<? extends Arguments> provideArguments( ExtensionContext context ) throws Exception {
        return Stream.of( "apple", "banana" ).map( Arguments::of );
    }
}

타입 변환하여 주입받기
1개의 Argument
SimpleArgumentConverter를 상속받아 구현하고 파라미터에 @ConvertWith(class)로 지정하여 사용

하나의 타입을 다른 타입으로 변환할 때 사용

@ParameterizedTest(name = "{displayName}: ({index})")
@ValueSource(ints = { 10, 20, 30 })
@DisplayName("int를 객체에 주입하여 객체로 전달 받기")
void paramIntTest( @ConvertWith(SinglePersonConverter.class) Person person ) {
    System.out.println( person.getWeight() );
}

static class SinglePersonConverter extends SimpleArgumentConverter {

    @Override
    protected Object convert( Object source, Class<?> targetType ) throws ArgumentConversionException {
        assertEquals( Person.class, targetType, () -> "Can only convert to Person" );
        Person person = new Person( "Kim", "1984.11.05" );
        person.setWeight( Integer.parseInt( source.toString() ) );
        return person;
    }
}

2개 이상 Arguments
ArgumentsAggregator를 구현하고
@AggregateWith(class)로 지정하여 사용

static inner or public class로 구성하여야 한다.

@ParameterizedTest(name = "{displayName}: ({index})")
@CsvSource({ "'Kim', '1984'", "'Lee', '1985'", "'Park', '1986'" })
@DisplayName("CSV Source 사용하기 - Object")
void objectFromCsvSrcTest( @AggregateWith(PersonConverter.class) Person person ) {
    System.out.println( person.getName() + " / " + person.getBirth() );
}

static class PersonConverter implements ArgumentsAggregator {

    @Override
    public Object aggregateArguments( ArgumentsAccessor accessor, ParameterContext context ) throws ArgumentsAggregationException {
        return new Person( accessor.getString( 0 ), accessor.getString( 1 ) );
    }
}

테스트 인스턴스

JUnit 동작원리는 각 테스트(메소드별) 마다 매번 인스턴스가 생성된다.
이는 테스트간에 의존성을 없애기 위해 다른 객체를 사용하는 것이다.

테스트간 같은 인스턴스를 사용해야 하는 경우가 있다.
stateful한 테스팅이 필요한 상태를 들 수 있다.

@TestInstance(TestInstance.Lifecycle.PER_CLASS)

@TestInstance( TestInstance.Lifecycle.PER_CLASS )
public class TestInstanceTest {

    private int value = 1;

    @BeforeAll
    void beforeAllTest() {
        value++;
        System.out.println( value );
        System.out.println("메소드 선언이 static이 아니어도 실행됨");
    }

    @AfterAll
    void afterAllTest() {
        value++;
        System.out.println( value );
        System.out.println("메소드 선언이 static이 아니어도 실행됨");
    }

    @Test
    void createTest() {
        value++;
        System.out.println( value );
    }
}

위 예제 코드와 같이 하나의 인스턴스로 동작하는 테스트를 생성할 수 있고 이때 @BeforeAll, @AfterAll 애노테이션을 사용하는 경우 static 메소드로 생성하지 않아도 실행됨을 확인할 수 있다.


테스트 순서 보장

대부분의 경우 선언된 순서대로 테스트가 실행되어 순서가 보장된다고 착각할 수 있지만 테스트 순서는 선언 순서와 무관하다.

Integration Test 등 원하는 순서에 따라 테스트를 작성이 필요한 순간이 있다.

@TestMethodOrder(MethodOrderer.OrderAnnotation.class)

@TestMethodOrder( MethodOrderer.OrderAnnotation.class )
public class OrderedTest {

    @Order( 1 )
    @Test
    @DisplayName( value = "첫 번째로 실행되어야 하는 테스트" )
    void firstTest() {
    }
}

위 예제 코드와 같이 @TestMethodOrder( MethodOrderer.OrderAnnotation.class )를 선언하고 명시적으로 @Order(우선순위) 를 부여하여 사용할 수 있다.

우선순위는 숫자가 낮을수록 높은 우선순위를 부여 받는다.
동일한 Order를 갖는 경우 같은 우선 순위로 취급되고 그 안에서 순서는 보장되지 않는다.


확장 모델(Extension Model)

기존) @RunWith, TestRule, MethodRule
변경) Extension Model

확장 모델 사용 예시

  • 테스트 실행 조건
  • 테스트 인스턴스 팩토리
  • 테스트 인스턴스 후 처리기
  • 테스트 매개변수 Resolution
  • 테스트 라이프사이클 콜백
  • 예외 처리

BeforeTestExecutionCallback, AfterTestExecutionCallback 구현하여 사용

public class ExtensionModel implements BeforeTestExecutionCallback, AfterTestExecutionCallback {
    private static final long THRESHOLD = 1000L;

    @Override
    public void beforeTestExecution( ExtensionContext context ) throws Exception {
        ExtensionContext.Store store = getStore( context );
        store.put( "StartTime", System.currentTimeMillis() );
    }

    @Override
    public void afterTestExecution( ExtensionContext context ) throws Exception {
        Method requiredTestMethod = context.getRequiredTestMethod();
        String methodName = requiredTestMethod.getName();

        Slow annotation = requiredTestMethod.getAnnotation( Slow.class );

        ExtensionContext.Store store = getStore( context );
        long startTime = store.remove( "StartTime", long.class );
        long duration = System.currentTimeMillis() - startTime;
        if ( duration > THRESHOLD && annotation == null ) {
            System.out.printf( "Please consider mark method [%s] with @SlowTest.\n", methodName );
        }
    }

    private static ExtensionContext.Store getStore( ExtensionContext context ) {
        String clazzName = context.getRequiredTestClass().getName();
        String methodName = context.getRequiredTestMethod().getName();
        return context.getStore( ExtensionContext.Namespace.create( clazzName, methodName ) );
    }
}

확장 모델 등록하기

1) 선언적 등록 @ExtendWith(class)

@ExtendWith( ExtensionModel.class )
public class ExtensionTest {
}

2) 프로그래밍 등록 @RegisterExtension

// 선언적 등록 방식에서는 인수를 사용할 수 없음.
// 유연하게 생성 등록할 수 있도록 @RegisterExtension 제공
public class ExtensionTest {
    @RegisterExtension
    static ExtensionModel extensionModel = new ExtensionModel(2000);
}

3) 자동 등록 ServiceLoader로 등록

junit.jupiter.extensions.autodetection.enabled=true  
- junit.platform.properties

JUnit 5 예제 코드:

$ git clone https://github.com/ntigo/JUnit5-Demonstration.git

© 2022.11 Written by Boseong Kim.
profile
기록하는 개발자

0개의 댓글