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): 단위 모듈을 연결하여 집합된 협력 관계를 검증
점진적 상, 하향식 방법으로 모듈 결합간 문제 여부를 테스트한다.
자바를 사용하는 개발자가 가장 많이 사용하는 테스팅 프레임워크다.
(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
애노테이션을 메소드 상위에 입력하여 테스트 대상 메소드로 지정할 수 있다.
Annotation | Description | Former |
---|---|---|
@Test | 테스트 지정 시 사용 | @Test |
@BeforeAll | 모든 테스트 실행 이전 1회만 실행 (static 지정 사용 필요) | @BeforeClass |
@AfterAll | 모든 테스트 실행 이후 1회만 실행 (static 지정 사용 필요) | @AfterClass |
@BeforeEach | 각 테스트 이전 매번 1회 실행 | @Before |
@AfterEach | 각 테스트 이후 매번 1회 실행 | @After |
2개의 @Test 지정 시 테스트의 실행 순서
1) 테스트명 기본 표기 방식은 선언된 메소드 이름을 따른다.
2) DisplayNameGeneration 지정 방식
클래스 상위에 @DisplayNameGeneration(전략 설정)
을 지정하여 사용한다.
Strategy | Description |
---|---|
ReplaceUnderscores | _ 언더스코어 표기를 공백으로 대체 |
IndicativeSentences | 클래스명, 메소드 이름으로 표기 |
Simple | 불필요 표기 제거 () 등과 같은 문자 |
Standard | 표준 표기 (JUnit Jupiter 5.0 release 이후) |
3) 테스트 이름을 별도로 부여하고 싶은 경우 @DisplayName("이름")
을 통해 사용한다.
단, DisplayNameGeration은 DisplayName보다 낮은 순위로 적용 받는다.
@DisplayName
을 통해 테스트 이름이 지정된 경우 적용받지 않는다.
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] (지정 제외 활성 및 비활성)
@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" );
} );
}
Annotation | Description |
---|---|
@Disabled | 지정 테스트를 비활성 |
@EnabledOnOs | 특정 OS 에서만 테스트 |
@EnabledOnJre | 특정 JRE 에서만 테스트 |
@EnabledForJreRange | 특정 범위에 JRE 에서만 테스트 |
@EnabledIfSystemProperty | property matches에 따른 테스트 |
@EnabledIfEnvironmentVariable | env.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;
}
}
테스트별 태그를 부여하고, 선별적인 테스트만 진행할 수 있음.
태그 명명 규칙
커스텀 태그
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(반복 숫자)
를 표기하여 사용
@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'
Annotation | Description |
---|---|
@ValueSource | 단일 개의 지정된 타입으로 value 제공 |
@CsvSource | 복수 개의 복수 타입으로 value 제공 |
@CsvFileSource | CSV 파일에 정의된 value 제공 |
@ArgumentSource | ArgumentsProvider를 구현하는 Stream 제공 |
@MethodSource | Stream을 제공하는 메소드를 사용 |
@NullSource | null 문자열 제공 |
@EmptySource | 비어있는 문자열 제공 |
@NullAndEmptySource | null, empty 문자열 제공 |
// 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를 갖는 경우 같은 우선 순위로 취급되고 그 안에서 순서는 보장되지 않는다.
기존) @RunWith
, TestRule, MethodRule
변경) Extension Model
확장 모델 사용 예시
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