개요
특정 조건에 따라 분기하는 기능이 있을 때, 해당 조건에 대한 경계값 테스트를 작성하여 테스트의 신뢰도를 높일 수 있습니다. 경계값 테스트란, 어떤 조건의 경계에 해당하는 값을 테스트하여 특정 조건일 때 기능이 원하는 대로 동작하는지 확인하기 위해 작성하는 테스트입니다. 예를 들어 어떤 물건을 한 번에 주문할 수 있는 수량이 2개라고 했을 때, 2개를 주문하면 주문에 성공하고 3개를 주문하면 주문에 실패하는 테스트를 작성할 수 있습니다.
이와 같이, 테스트를 작성하다보면 동일한 검증 코드에서 여러 값에 대해 테스트를 수행해야 할 경우가 생깁니다. 이번 포스팅에서는 다음의 문자열 검증 유틸 클래스를 통해 @ParameterizedTest에 대해서 설명해보겠습니다.
public abstract class CustomFormatValidator {
private CustomFormatValidator() {
throw new AssertionError();
}
private static final Pattern NICKNAME_PATTERN = Pattern.compile("^[가-힣\\s]{2,10}$");
public static boolean validateNicknameFormat(final String nickname) {
return NICKNAME_PATTERN.matcher(nickname).matches();
}
}
본 유틸 클래스의 validateNicknameFormat() 은 닉네임의 형식을 검증합니다. 주어진 닉네임이 2글자 이상 10글자 이하의 한글 텍스트인지 판별하고 형식에 맞다면 true, 형식에 어긋난다면 false를 반환합니다.
@ParameterizedTest 없이 테스트 코드를 작성한다면 다음과 같이 작성하게 됩니다.
class CustomFormatValidatorTest {
@DisplayName("주어진 닉네임이 2글자 한글 닉네임이면 true를 반환한다.")
@Test
void nicknameLength2AndKorean() {
// given
final String nickname = "철수";
// when
boolean result = CustomFormatValidator.validateNicknameFormat(nickname);
// then
assertThat(result).isTrue();
}
@DisplayName("주어진 닉네임이 띄어 쓰기가 포함된 10글자 한글 닉네임이면 true를 반환한다.")
@Test
void nicknameLength10ContainsWhiteSpaceAndKorean() {
// given
final String nickname = "김 철 수 철 수 ";
// when
boolean result = CustomFormatValidator.validateNicknameFormat(nickname);
// then
assertThat(result).isTrue();
}
// ... true 를 반환하는 여러개의 테스트
@DisplayName("주어진 닉네임이 11글자 한글 닉네임이면 false를 반환한다.")
@Test
void nicknameLength11AndKorean() {
// given
final String nickname = "열한글자닉네임추천좀요";
// when
boolean result = CustomFormatValidator.validateNicknameFormat(nickname);
// then
assertThat(result).isFalse();
}
@DisplayName("주어진 닉네임이 3글자 영어면 false를 반환한다.")
@Test
void nicknameLength3AndEnglish() {
// given
final String nickname = "Kai";
// when
boolean result = CustomFormatValidator.validateNicknameFormat(nickname);
// then
assertThat(result).isFalse();
}
}
같은 응답 값에 대해 검증하고자 하는 메서드는 하나인데, 여러 개의 테스트를 작성할 수밖에 없습니다. 물론, 하나의 테스트에 여러 개의 Assertion 구문을 넣어 테스트할 수도 있지만, 코드의 길이가 길어질뿐더러 어떤 검증을 위한 테스트인지 명확해지지 않을 수 있습니다.
이와 같은 문제를 @ParameterizedTest로 해결할 수 있습니다.
@ParameterizedTest
JUnit5에서 제공하는 @ParameterizedTest는 하나의 테스트에서 여러 인수에 대한 검증을 수행하는 검증 어노테이션입니다. 기본 @Test 대신, @ParameterizedTest 어노테이션을 붙여주면 되며, 테스트에 사용되는 값을 Source로 주입시켜주어야 합니다.
♦️ Source
Source는 @ParameterizedTest에 사용될 값을 넣어주는 객체입니다. JUnit에서 여러 개의 Source 어노테이션을 제공하며, 본 포스팅에서는 4가지 Source에 대해서만 설명하겠습니다.
ValueSource
ValueSource는 가장 기본적인 Source 어노테이션으로, 한 번의 테스트에 하나의 인수를 넣어줄 수 있습니다.
ValueSource를 사용하여 위 테스트 코드를 리팩토링 해보겠습니다.
class CustomFormatValidatorTest {
@DisplayName("주어진 닉네임이 형식에 맞으면 true를 반환한다.")
@ValueSource(strings = {"김 철 수 철 수 ", "철수", "철 수 짱", "열글자닉네임추천좀요"})
@ParameterizedTest
void validateNicknameFormatWithValidString(String nickname) {
// when
boolean result = CustomFormatValidator.validateNicknameFormat(nickname);
// then
assertThat(result).isTrue();
}
@DisplayName("주어진 닉네임이 형식에 맞지 않으면 false를 반환한다.")
@ValueSource(strings = {"김", "K", "kai", "띄 어 쓰 기 포 함", "열한글자닉네임추천좀요"})
@ParameterizedTest
void validateNicknameFormatWithInvalidString(String nickname) {
// when
boolean result = CustomFormatValidator.validateNicknameFormat(nickname);
// then
assertThat(result).isFalse();
}
}
다음과 같이, 하나의 테스트 코드에서 여러 값에 대한 테스트를 수행할 수 있습니다. @ValueSource에 정의된 값들이 메서드의 파라미터로 주입되어 한 번씩 테스트가 수행됩니다.
ValueSource에 넣어줄 수 있는 값에 대한 type은 다음과 같습니다.
- short
- byte
- int
- long
- float
- double
- char
- boolean
- java.lang.String
- java.lang.Class
위 테스트 코드를 실행시키면 다음과 같이 2개의 테스트 코드에서 실질적으로 9번의 테스트가 발생한 것을 확인할 수 있습니다.
@CsvSource
@ValueSource를 사용하여 하나의 테스트 코드에서 여러 값에 대한 테스트를 수행할 수 있었습니다. 하지만 여전히 응답 값 (true, false)을 기대하는 2개의 테스트가 존재합니다. @CsvSource를 사용하면 하나의 테스트로 합칠 수 있습니다.
@ValueSource는 한 번의 테스트에 하나의 인수만 넣어줄 수 있는 반면, @CsvSource는 여러 인수를 넣어줄 수 있습니다.
class CustomFormatValidatorTest {
@DisplayName("주어진 닉네임의 형식에 맞다면 true, 아니라면 false를 반환한다.")
@CsvSource({
"김, false",
"K, false",
"kai, false",
"띄 어 쓰 기 포 함, false",
"열한글자닉네임추천좀요, false",
"김 철 수 철 수, true",
"철수, true"
})
@ParameterizedTest
void validateNicknameFormat(String nickname, boolean expected) {
// when
boolean result = CustomFormatValidator.validateNicknameFormat(nickname);
// then
assertThat(result).isEqualTo(expected);
}
}
다음과 같이 입력에 따라 기대하는 값이 다른 테스트를 하나의 테스트 코드에서 모두 검증할 수 있습니다. 각 테스트는 ""로 분리되고 세미콤마 ', '로 각 파라미터를 구분하여 값을 선언해 주면 됩니다.
@CsvSource에 여러 인수를 넣다 보면 가독성이 떨어질 수 있습니다. 공식 문서에서는 다음과 같은 형태로도 source를 지정할 수 있다고 안내하고 있습니다.
class CustomFormatValidatorTest {
@ParameterizedTest
@CsvSource(delimiter = '|', quoteCharacter = '"', textBlock = """
#-----------------------------
# FRUIT | RANK
#-----------------------------
apple | 1
#-----------------------------
banana | 2
#-----------------------------
"lemon lime" | 0xF1
#-----------------------------
strawberry | 700_000
#-----------------------------
"""
)
void testWithCsvSource(String fruit, int rank) {
// ...
}
}
@MethodSource
@MethodSource는 Stream 형태의 반환 타입을 갖는 메서드에 source를 지정해 놓고 사용할 수 있도록 합니다. 다음과 같이 사용할 수 있습니다.
class CustomFormatValidatorTest {
public static Stream<Arguments> provideNicknameForValidateFormat() {
return Stream.of(
Arguments.of("김", false),
Arguments.of("K", false),
Arguments.of("kai", false),
Arguments.of("띄 어 쓰 기 포 함", false),
Arguments.of("열한글자닉네임추천좀요", false),
Arguments.of("김 철 수 철 수", true),
Arguments.of("철수", true)
);
}
@DisplayName("주어진 닉네임의 형식에 맞다면 true, 아니라면 false를 반환한다.")
@MethodSource("provideNicknameForValidateFormat")
@ParameterizedTest
void validateNicknameFormat(String nickname, boolean expected) {
// when
boolean result = CustomFormatValidator.validateNicknameFormat(nickname);
// then
assertThat(result).isEqualTo(expected);
}
}
Stream <Arguments>를 반환하는 메서드의 이름을 @MethodSource 의 인자로 넣어주면 됩니다.
메서드의 반환 타입은 Stream으로 변환할 수 있는 어떤 타입이든 상관없으며 (IntStream, Collection, Iterator, Iterable..), Arguments는 Object [] 형과 단일 값만 가능합니다. 단, 메서드는 반드시 static 이여야 합니다. 해당 메서드는 외부 클래스에 정의하여 사용할 수도 있습니다.
@EnumSource
@EnumSource는 말 그대로 Enum을 source에 넣어주는 어노테이션입니다. 지원하는 mode에 따라 테스트에 포함시킬 상수를 지정할 수 있습니다.
class CustomFormatValidatorTest {
@DisplayName("모든 enum 상수를 사용한다.")
@EnumSource
@ParameterizedTest
void enumSource(AcceptStatus status) {
boolean contains = EnumSet.allOf(AcceptStatus.class).contains(status);
assertThat(contains).isTrue();
}
@DisplayName("모든 enum 상수에서 특정 상수를 제외한다.")
@EnumSource(mode = EXCLUDE, names = {"ACCEPT", "REJECT"})
@ParameterizedTest
void enumSourceExclude(AcceptStatus status) {
boolean contains = EnumSet.of(AcceptStatus.EXPIRED, AcceptStatus.PENDING).contains(status);
assertThat(contains).isTrue();
}
@DisplayName("특정 enum 상수만 사용한다.")
@EnumSource(mode = INCLUDE, names = {"ACCEPT", "REJECT"})
@ParameterizedTest
void enumSourceInclude(AcceptStatus status) {
boolean contains = EnumSet.of(AcceptStatus.ACCEPT, AcceptStatus.REJECT).contains(status);
assertThat(contains).isTrue();
}
@DisplayName("정규식에 맞는 enum 상수만 사용한다.")
@EnumSource(mode = MATCH_ALL, names = "^.*JECT$")
@ParameterizedTest
void enumSourceMatchAll(AcceptStatus status) {
assertThat(status).isEqualTo(AcceptStatus.REJECT);
}
}
정리
🔸 @ParameterizedTest: 하나의 테스트 코드에서 여러 값에 대한 검증 가능
🔹 @ValueSource: 한 번의 테스트에 하나의 인수 사용
🔹 @CsvSource: 한 번의 테스트에 여러 개의 인수 사용
🔹 @MethodSource: 넣고자 하는 인수를 Method로 분리할 때 사용
🔹 @EnumSource: Enum 타입의 인수를 넣고 싶을 때 사용
더 자세한 내용은 공식문서에서 확인하실 수 있습니다.
https://junit.org/junit5/docs/current/user-guide/#writing-tests-parameterized-tests
JUnit 5 User Guide
Although the JUnit Jupiter programming model and extension model do not support JUnit 4 features such as Rules and Runners natively, it is not expected that source code maintainers will need to update all of their existing tests, test extensions, and custo
junit.org