테스트 코드를 짜다 보면, 한 개의 메소드에 대해서 여러 개의 테스트를 수행해야 하는 경우를 생긴다. 예를 들어, 다음과 같은 User 클래스가 있다고 하자.
public class User {
private String name;
public User(String name) {
validateName(name);
this.name = name;
}
public String getName() {
return name;
}
private void validateName(String name) {
validateNotNull(name);
validateLength(name);
}
private void validateNotNull(String name) {
if (name == null || name.isBlank()) {
throw new IllegalArgumentException("이름은 공백일 수 없습니다.");
}
}
private void validateLength(String name) {
if (name.length > 10) {
throw new IllegalArgumentException("이름은 10자 이하여야 합니다.");
}
}
}
이 User 클래스는 이름이 공백이거나 10자를 넘어가면 IllegalArgumentException을 throw 하도록 하는 규칙을 가지고 있다. 이 검증 과정을 모두 테스트하려면 어떻게 해야 할까?
@Test
@DisplayName("이름으로 null 값 예외처리")
void createUserFromNullName() {
assertThatThrownBy(() -> new User(null))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
@DisplayName("공백 이름 예외처리")
void createUserFromBlankName() {
assertThatThrownBy(() -> new User(""))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
@DisplayName("10자를 초과하는 이름 예외처리")
void createUserFromGreaterThan10() {
assertThatThrownBy(() -> new User("이 이름은 10자를 넘어가는 이름입니다"))
.isInstanceOf(IllegalArgumentException.class);
}
세 경우 모두 기본 로직 자체는 User 객체를 생성하고, 그 과정에서 IllegalArgumetException을 던지는지를 검증하는 과정인데, 같은 로직임에도 불구하고 테스트가 세 가지나 생겨버렸다.
만약 이름의 규칙이 더 늘어난다면 어떨까? 규칙이 늘어날 때 마다 새로 테스트 메소드를 작성해줘야 할 것이다.
규칙이 많아지는 경우 뿐 아니라 단순히 테스트하고 싶은 값이 많을 경우를 생각해보자.
@Test
@DisplayName("1~10자의 이름으로 자동차 생성")
void createUserTest() {
assertThat(new User("1"));
assertThat(new User("12"));
...
assertThat(new User("1234567890");
}
(이런 식으로 극단적으로 테스트 하는 개발자는 없겠지만 예시니 그 부분은 넘어가자.)
assertThat을 10줄이나 작성해야 한다. 이런 코드가 과연 좋은 테스트 코드일 수 있을까?
JUnit에는 이렇게 여러 개의 테스트를 한번에 작성하기 위한 @ParameterizedTest 라는 어노테이션을 제공한다. 기본적인 사용 방법은 @Test 대신 @ParameterizedTest라는 어노테이션을 사용하는 것 외에는 동일하다.
이 때 파라미터로 넘겨줄 값들을 지정해주어야 하는데, 이 역시 어노테이션을 사용해서 테스트에 주입해줄 수 있다.
@ParameterizedTest
@DisplayName("ValueSource를 이용한 User 생성 테스트")
@ValueSource(strings = {"", " "})
void createUserException(String text) {
assertThatThrownBy(() -> new User(text))
.isInstanceOf(IllegalArgumentException.class);
}
테스트에 주입할 값을 어노테이션에 배열로 지정한다.
테스트를 실행하면 배열을 순회하면서, 테스트 메소드에 인수로 배열에 지정된 값들을 주입해서 테스트한다. 이 때, 하나의 테스트에는 하나의 인수(argumnet)만 전달할 수 있다.
@ValueSource에 사용할 수 있는 자료형은 다음과 같다.
@ValueSource 로는 하나의 인수만 전달할 수 있고, 해당 인수에 따른 결과 값을 테스트할 수 없다. 다음의 코드를 보자.
int multiplyBy2(int number) {
return number * 2;
}
@ParameterizedTest
@DisplayName("1 곱하기 2는 2")
void multiply1By2() {
assertThat(multiplyBy2(1)).isEqualTo(2);
}
@ParameterizedTest
@DisplayName("2 곱하기 2는 4")
void multiply2By2() {
assertThat(multiplyBy2(2)).isEqualTo(4);
}
여기서 두 테스트는 모든 로직과 사용하는 메소드가 같다. 단지 테스트하고자 하는 값과 기대하는 결과값이 다를 뿐이다. 이 경우에 위에서 알아본 @ValueSource만으로는 테스트가 불가능하다. 이럴 때 @CsvSource를 사용할 수 있다.
int multiplyBy2(int number) {
return number * 2;
}
@ParameterizedTest
@CsvSource(value = {"1,2", "2,4", "3,6"})
void multiplyBy2Test(int input, int expected) {
assertThat(multiplyBy2(input)).isEqualTo(expected);
}
이런 식으로 작성해주면 input에 따라 expected 값이 다르게 나오는 케이스를 여러 개 테스트할 수 있다. value에 값들을 넣어주는데, 이 때 "{input},{expected}" 의 형태로 구분자가 있는 문자열을 입력해주어야 한다. 기본 구분자는 콤마(',')인데, 이는 CSV(Comma Sperated Value)라는 이름을 생각해보면 쉽게 이해할 수 있다.
@ParameterizedTest
@CsvSource(value = {"1:2", "2:4", "3:6"}, delimiter = ':')
void multiplyBy2Test(int input, int expected) {
assertThat(multiplyBy2(input)).isEqualTo(expected);
}
물론 value 다음에 delimiter 값을 직접 정의해서 넣어줘서 커스텀 구분자를 사용할 수도 있다. 단 이 때 주의할 것은 delimiter 값은 String이 아닌 char 값이기 때문에 반드시 단일 문자를 넣어주어야 한다는 점이다. 만약 String을 넣어주고 싶다면
@ParameterizedTest
@CsvSource(value = {"1//2", "2//4", "3//6"}, delimiterString = "//")
void multiplyBy2Test(int input, int expected) {
assertThat(multiplyBy2(input)).isEqualTo(expected);
}
와 같이 delimiterString 값을 지정해서 넣어주면 된다.
@ParameterizedTest
@DisplayName("null 값 또는 empty 값으로 User 생성 테스트")
@NullAndEmptySource
void createUserExceptionFromNullOrEmpty(String text) {
assertThatThrownBy(() -> new User(text))
.isInstanceOf(IllegalArgumentException.class);
}
@NullSource는 테스트 메소드에 인수로 null을, @EmptySource는 빈 값을, @NullAndEmptySource는 null과 빈 값을 모두 주입한다.
@ParameterizedTest
@DisplayName("null 값 또는 empty 값으로 User 생성 테스트")
@NullAndEmptySource
@ValueSource(strings = {""," "})
void createUserExceptionFromNullOrEmpty(String text) {
assertThatThrownBy(() -> new User(text))
.isInstanceOf(IllegalArgumentException.class);
}
@ParameterizedTest
@DisplayName("6, 7월이 31일까지 있는지 테스트")
@EnumSource(value = Month.class, names = {"JUNE", "JULY"})
void isJuneAndJuly31(Month month) {
assertThat(month.minLength()).isEqualTo(31);
}
Enum 클래스의 모든 값을 사용하려면 @EnumSource 안에 Enum 클래스만 전달해주면 되고, 특정 값만 필요할 경우 value에 Enum 클래스를 넣어주고, names에 선택할 값의 이름을 전달해주면 된다.
이 때 names까지 값을 넣으면 추가로 mode 값을 넣어줄 수 있다. 지원하는 mode는 다음과 같다.
표로 정리하면 다음과 같다.
인수 종류 | 타입 | 설명 |
---|---|---|
value | Class< ? extends Enum<?>> | 테스트 할 Enum class를 넣는다. Optional이기 때문에 지정하지 않을 경우 메소드에 인수로 선언된 Enum이 들어간다. |
names | String | mode에서 검색에 쓸 문자열 또는 정규식 |
mode | Mode | INCLUDE: names.contains(name) (name과 일치하는 모든 Enum 값) // default EXCLUDE: !names.contains(name)) (name을 제외한 모든 Enum 값) MATCH_ANY: patterns.stream().anyMatch(name::matches) (조건을 하나라도 만족하는 Enum 값) MATCH_ANY:patterns.stream().allMatch(name::matches) (조건을 모두 만족하는 Enum 값) |
여태까지 위에서 보았던 source들은 분명 유용하지만, 복잡한 object를 전달하는 것이 불가능하다. 이 때 method를 인수로 전달해주면 복잡한 인수를 전달할 수 있다. 백문이 불여일견, 일단 기본 사용 예제를 보고 넘어가자.
@ParameterizedTest
@MethodSource("provideStringsForIsBlank")
void isBlank_ShouldReturnTrueForBlankStrings(String input, boolean expected) {
assertThat(input.isBlank()).isEqualTo(expected);
}
private static Stream<Arguments> provideStringsForIsBlank() {
return Stream.of(
Arguments.of("", true),
Arguments.of(" ", true),
Arguments.of("not blank", false)
);
}
provideStringsForIsBlank() 라는 메소드를 정의한 뒤, 이 메소드를 @MethodSource를 이용하여 테스트 메소드에 넘겨주는 기본 예제다. 위 예제를 보면서 @MethodSource의 기본 규칙에 대해 알아보자.
@ParameterizedTest
@MethodSource // 메소드 명을 적지 않았지만 같은 메소드를 찾아서 실행
void isBlank_ShouldReturnTrueForBlankStringsOneArgument(String input) {
assertThat(input.isBlank()).isTrue();
}
private static Stream<String> isBlank_ShouldReturnTrueForBlankStringsOneArgument() {
return Stream.of("", " ");
}
argument source method는 Stream<Arguments>를 반환하는 것이 기본이나, List와 같은 컬렉션이나 인터페이스를 반환할 수도 있다.
정규화된 이름(FQN#methodName의 형식)으로 외부 정적 메소드를 참조할 수 있다.
class StringsUnitTest {
@ParameterizedTest
@MethodSource("parameterizedtest.StringParams#blankStrings")
void isBlank_ShouldReturnTrueForBlankStringsExternalSource(String input) {
assertThat(input.isBlank()).isTrue();
}
}
// StringParams 클래스는 parameterizedtest 패키지에 있다고 가정
public class StringParams {
static Stream<String> blankStrings() {
return Stream.of("", " ");
}
}
@ParameterizedTest를 이용하면 여러 개의 테스트 케이스를 사용할 수 있다.
- @ValueSource: 한 개의 인수 입력 시 사용
- @CsvSource: 한 개의 인수와 해당 인수를 넣었을 때의 결과값 입력 시 사용
- @NullSource, @EmptySource, @NullOrEmptySource: null 또는 공백값에 대한 테스트 시 사용
- @EnumSource: Enum 값에 대한 테스트 시 사용
- @MethodSource: 테스트에 복잡한 인수를 제공하고자 할 때 메소드를 만들어서 사용
더 자세한 내용은 링크 참조
안녕하세요 7기 프리코스 참가생입니다 ㅎㅎ
밑에 이 부분은 혹시 오타인가요?
MATCH_ANY:patterns.stream().allMatch(name::matches)
(조건을 모두 만족하는 Enum 값)
글 정말 잘 보고 갑니다 설명이 아주 잘 되어있어서 많이 배우고 갑니다.