JUnit 5 파라미터화 테스트

JUnit 5 파라미터화 테스트

같은 테스트를 입력값만 바꿔 여러 번 돌려야 할 때가 있죠. 예를 들어 회문(palindrome) 검사 로직을 "racecar", "radar", "able was I ere I saw elba" 세 값으로 확인한다고 해 볼게요. 로직은 하나인데 입력만 여러 개예요. 이럴 때 쓰는 게 바로 파라미터화 테스트예요. 이 페이지에서는 @ParameterizedTest@ParameterizedClass를 중심으로 인자 소스, 변환, 집계 방법까지 차근차근 살펴볼게요.

출처: JUnit 5 User Guide — Parameterized Classes and Tests

본문

파라미터화 테스트는 테스트 메서드를 서로 다른 인자로 여러 번 실행할 수 있게 해줘요. 일반적인 @Test 메서드처럼 선언하되 @Test 대신 @ParameterizedTest 애노테이션을 쓰면 돼요.

파라미터화 클래스는 테스트 클래스 전체(중첩 테스트 포함)를 서로 다른 인자로 여러 번 실행할 수 있게 해줘요. 일반 테스트 클래스처럼 선언하고, @ParameterizedTest를 포함한 지원되는 모든 테스트 메서드 타입을 담을 수 있되 @ParameterizedClass 애노테이션을 붙이면 되죠.

@ParameterizedClass는 현재 실험적 기능이에요. 한번 써 보고 JUnit 팀에 피드백을 주면 이 기능이 개선되어 정식 채택되는 데 도움이 돼요.

테스트 메서드를 파라미터화하든 테스트 클래스를 파라미터화하든, 두 가지는 반드시 해야 해요. 첫째, 각 호출에 인자를 제공할 소스(source) 를 하나 이상 선언하고, 둘째, 파라미터화된 메서드나 클래스에서 그 인자를 소비(consume) 해야 하죠.

아래는 @ValueSource 애노테이션으로 String 배열을 인자 소스로 지정한 파라미터화 테스트예요.

@ParameterizedTest
@ValueSource(strings = { "racecar", "radar", "able was I ere I saw elba" })
void palindromes(String candidate) {
	assertTrue(StringUtils.isPalindrome(candidate));
}

위 테스트 메서드를 실행하면 각 호출이 따로 보고돼요. 예를 들어 ConsoleLauncher는 다음과 비슷한 출력을 내보내요.

palindromes(String) ✔
├─ [1] candidate = "racecar" ✔
├─ [2] candidate = "radar" ✔
└─ [3] candidate = "able was I ere I saw elba" ✔

같은 @ValueSource 애노테이션을 @ParameterizedClass의 인자 소스로도 쓸 수 있어요.

@ParameterizedClass
@ValueSource(strings = { "racecar", "radar", "able was I ere I saw elba" })
class PalindromeTests {

	@Parameter
	String candidate;

	@Test
	void palindrome() {
		assertTrue(StringUtils.isPalindrome(candidate));
	}

	@Test
	void reversePalindrome() {
		String reverseCandidate = new StringBuilder(candidate).reverse().toString();
		assertTrue(StringUtils.isPalindrome(reverseCandidate));
	}
}

위 파라미터화 테스트 클래스를 실행해도 각 호출이 따로 보고돼요.

PalindromeTests ✔
├─ [1] candidate = "racecar" ✔
│  ├─ palindrome() ✔
│  └─ reversePalindrome() ✔
├─ [2] candidate = "radar" ✔
│  ├─ palindrome() ✔
│  └─ reversePalindrome() ✔
└─ [3] candidate = "able was I ere I saw elba" ✔
   ├─ palindrome() ✔
   └─ reversePalindrome() ✔

필요한 설정

파라미터화 클래스나 테스트를 쓰려면 junit-jupiter-params 아티팩트에 대한 의존성을 추가해야 해요. 자세한 내용은 Dependency Metadata를 참고하면 돼요.

인자 소비하기

파라미터화 테스트

파라미터화 테스트 메서드는 구성된 소스에서 인자를 직접 소비해요. 이때 인자 소스 인덱스와 메서드 파라미터 인덱스는 일대일 대응돼요. 다만 소스의 인자 여러 개를 하나의 객체로 모아 메서드에 넘기는 인자 집계(argument aggregation) 도 선택할 수 있고, ParameterResolver가 추가 인자를 제공할 수도 있어요(TestInfo, TestReporter 인스턴스를 얻는 경우처럼요). 정리하면 파라미터화 테스트 메서드의 형식 파라미터는 다음 규칙에 따라 선언해야 해요.

  • 인덱스 파라미터(indexed parameter) 를 0개 이상 먼저 선언해요.
  • 그다음 aggregator 를 0개 이상 선언해요.
  • 마지막으로 ParameterResolver가 공급하는 인자를 0개 이상 선언해요.

여기서 인덱스 파라미터란 ArgumentsProvider가 제공하는 Arguments에서 특정 인덱스에 해당하는 인자를 말해요. 이 인자는 메서드 형식 파라미터 목록의 같은 인덱스에 전달되죠. aggregator는 ArgumentsAccessor 타입의 파라미터 또는 @AggregateWith가 붙은 파라미터를 말해요.

파라미터화 클래스

파라미터화 클래스는 구성된 소스에서 인자를 직접 소비해요. 방식은 두 가지예요. 유일한 생성자를 통한 생성자 주입 또는 필드 주입이죠. 파라미터화 클래스나 그 상위 클래스에 @Parameter가 붙은 필드가 선언되어 있으면 필드 주입을 쓰고, 아니면 생성자 주입을 써요.

생성자 주입

생성자 주입은 (기본값인) PER_METHOD 테스트 인스턴스 라이프사이클 모드에서만 쓸 수 있어요. PER_CLASS 모드에서는 필드 주입을 쓰는 게 좋아요.

생성자 주입에도 위에서 파라미터화 테스트에 대해 정의한 것과 같은 규칙이 적용돼요. 아래 예시는 테스트 클래스 생성자에 인자 두 개를 주입해요.

@ParameterizedClass
@CsvSource({ "apple, 23", "banana, 42" })
class FruitTests {

	final String fruit;
	final int quantity;

	FruitTests(String fruit, int quantity) {
		this.fruit = fruit;
		this.quantity = quantity;
	}

	@Test
	void test() {
		assertFruit(fruit);
		assertQuantity(quantity);
	}

	@Test
	void anotherTest() {
		// ...
	}
}

테스트 클래스 생성자를 선언하는 보일러플레이트 코드를 피하려면 record로 파라미터화 클래스를 구현할 수도 있어요.

@ParameterizedClass
@CsvSource({ "apple, 23", "banana, 42" })
record FruitTests(String fruit, int quantity) {

	@Test
	void test() {
		assertFruit(fruit);
		assertQuantity(quantity);
	}

	@Test
	void anotherTest() {
		// ...
	}
}
필드 주입

필드 주입에서는 @Parameter가 붙은 필드에 다음 규칙이 적용돼요.

  • 인덱스 파라미터를 0개 이상 선언할 수 있고, 각각의 @Parameter(index)에 고유한 인덱스를 지정해야 해요. 인덱스 파라미터가 하나뿐이면 인덱스를 생략할 수 있어요. 인덱스 파라미터 선언이 두 개 이상이면 0부터 가장 큰 선언 인덱스까지 모든 인덱스에 대한 선언이 있어야 해요.
  • aggregator 를 0개 이상 선언할 수 있고, 각각은 @Parameter에 인덱스를 지정하지 않아요.
  • @Parameter가 붙지 않은 다른 필드는 평소처럼 0개 이상 선언할 수 있어요.

여기서 인덱스 파라미터란 ArgumentsProvider가 제공하는 Arguments에서 특정 인덱스에 해당하는 인자가 @Parameter(index)가 붙은 필드에 주입되는 것을 말해요. aggregator는 ArgumentsAccessor 타입의 @Parameter 필드 또는 @AggregateWith가 붙은 필드를 말하죠.

아래 예시는 파라미터화 클래스에서 필드 주입으로 여러 인자를 소비하는 방법이에요.

@ParameterizedClass
@CsvSource({ "apple, 23", "banana, 42" })
class FruitTests {

	@Parameter(0)
	String fruit;

	@Parameter(1)
	int quantity;

	@Test
	void test() {
		assertFruit(fruit);
		assertQuantity(quantity);
	}

	@Test
	void anotherTest() {
		// ...
	}
}

필드 주입을 쓰면 생성자 파라미터는 소스의 인자로 해석되지 않아요. 다만 다른 ParameterResolver 확장이 평소처럼 생성자 파라미터를 해석할 수는 있어요.

라이프사이클 메서드

@BeforeParameterizedClassInvocation@AfterParameterizedClassInvocationinjectArguments 속성이 true(기본값)면 인자를 소비하는 데 쓸 수 있어요. 그 경우 메서드 시그니처는 파라미터화 테스트에 대해 정의된 것과 같은 규칙을 따라야 하고, 추가로 파라미터화 테스트 클래스의 인덱스 파라미터와 같은 파라미터 타입을 써야 해요. 자세한 내용은 @BeforeParameterizedClassInvocation@AfterParameterizedClassInvocation의 Javadoc과 Lifecycle 섹션의 예시를 참고하면 돼요.

AutoCloseable 인자. java.lang.AutoCloseable(이를 확장하는 java.io.Closeable 포함)을 구현하는 인자는 파라미터화 클래스나 테스트 호출이 끝난 뒤 자동으로 닫혀요.

이를 막으려면 @ParameterizedTestautoCloseArguments 속성을 false로 설정해요. 특히 AutoCloseable을 구현하는 인자를 같은 파라미터화 클래스나 테스트 메서드의 여러 호출에서 재사용한다면, @ParameterizedClass@ParameterizedTestautoCloseArguments = false를 지정해서 호출 사이에 인자가 닫히지 않게 해야 해요.

다른 확장

다른 확장은 Store에서 ParameterInfo 객체를 꺼내 파라미터화 테스트나 클래스의 파라미터와 해석된 인자에 접근할 수 있어요. 자세한 내용은 ParameterInfo의 Javadoc을 참고하면 돼요.

인자 소스

JUnit Jupiter는 몇 가지 인자 소스 애노테이션을 기본 제공해요. 각 하위 섹션마다 개요와 예시를 하나씩 볼게요. 추가 정보는 org.junit.jupiter.params.provider 패키지의 Javadoc을 참고하면 돼요.

이 섹션의 모든 소스 애노테이션은 @ParameterizedClass@ParameterizedTest 양쪽에 적용돼요. 간결함을 위해 예시는 @ParameterizedTest 메서드로만 보여줄게요.

@ValueSource

@ValueSource는 가장 단순한 소스 중 하나예요. 리터럴 값 배열 하나를 지정할 수 있고, 파라미터화 테스트 호출당 단일 인자만 제공할 수 있어요.

@ValueSource가 지원하는 리터럴 값 타입은 다음과 같아요: short, byte, int, long, float, double, char, boolean, java.lang.String, java.lang.Class.

예를 들어 아래 @ParameterizedTest 메서드는 각각 1, 2, 3 값으로 세 번 호출돼요.

@ParameterizedTest
@ValueSource(ints = { 1, 2, 3 })
void testWithValueSource(int argument) {
	assertTrue(argument > 0 && argument < 4);
}

null과 빈 소스

소프트웨어가 잘못된 입력을 받았을 때 올바르게 동작하는지 경계 케이스를 확인하려면 null이나 빈 값을 파라미터화 테스트에 공급해 보는 게 유용해요. 아래 애노테이션들은 단일 인자를 받는 파라미터화 테스트에 null과 빈 값의 소스로 제공돼요.

  • @NullSource — 애노테이션이 붙은 @ParameterizedClass@ParameterizedTestnull 인자 하나를 제공해요.
  • @NullSource는 기본 타입(primitive)의 파라미터에는 쓸 수 없어요.
  • @EmptySource — 애노테이션이 붙은 @ParameterizedClass@ParameterizedTest에 빈 인자 하나를 제공해요. 대상 타입은 java.lang.String, java.lang.Iterable, java.util.Iterator, java.util.ListIterator, java.util.Collection(public 무인자 생성자가 있는 구체 하위 타입 포함), java.util.List, java.util.Set, java.util.SortedSet, java.util.NavigableSet, java.util.Map(public 무인자 생성자가 있는 구체 하위 타입 포함), java.util.SortedMap, java.util.NavigableMap, 기본 배열(예: int[], char[][]), 객체 배열(예: String[], Integer[][])이에요.
  • @NullAndEmptySource@NullSource@EmptySource의 기능을 결합한 합성 애노테이션이에요.

여러 종류의 다양한 공백 문자열을 파라미터화 클래스나 테스트에 공급해야 한다면 @ValueSource로 해결할 수 있어요. 예: @ValueSource(strings = {" ", " ", "\t", "\n"}).

@NullSource, @EmptySource, @ValueSource를 조합하면 null, 빈, 공백 입력을 더 넓게 테스트할 수 있어요. 문자열에 대해 이렇게 하면 됩니다.

@ParameterizedTest
@NullSource
@EmptySource
@ValueSource(strings = { " ", "   ", "\t", "\n" })
void nullEmptyAndBlankStrings(String text) {
	assertTrue(text == null || text.isBlank());
}

합성 @NullAndEmptySource를 쓰면 위 코드를 다음과 같이 단순화할 수 있어요.

@ParameterizedTest
@NullAndEmptySource
@ValueSource(strings = { " ", "   ", "\t", "\n" })
void nullEmptyAndBlankStrings(String text) {
	assertTrue(text == null || text.isBlank());
}

두 방식의 nullEmptyAndBlankStrings(String) 파라미터화 테스트 메서드는 모두 6번 호출돼요. null 1번, 빈 문자열 1번, 그리고 @ValueSource로 공급한 명시적 공백 문자열 4번이죠.

@EnumSource

@EnumSourceEnum 상수를 편리하게 사용하게 해줘요.

@ParameterizedTest
@EnumSource(ChronoUnit.class)
void testWithEnumSource(TemporalUnit unit) {
	assertNotNull(unit);
}

애노테이션의 value 속성은 선택이에요. 생략하면 첫 번째 파라미터의 선언 타입을 사용해요. 그 타입이 enum이 아니면 테스트는 실패하죠. 위 예시에서 value 속성이 필요한 이유는 메서드 파라미터가 TemporalUnit으로 선언되어 있기 때문이에요. ChronoUnit이 구현한 인터페이스이고 enum 타입이 아니죠. 메서드 파라미터 타입을 ChronoUnit으로 바꾸면 다음과 같이 애노테이션에서 명시적 enum 타입을 생략할 수 있어요.

@ParameterizedTest
@EnumSource
void testWithEnumSourceWithAutoDetection(ChronoUnit unit) {
	assertNotNull(unit);
}

애노테이션은 선택적 names 속성을 제공해요. 어떤 상수를 쓸지 지정할 수 있죠.

@ParameterizedTest
@EnumSource(names = { "DAYS", "HOURS" })
void testWithEnumSourceInclude(ChronoUnit unit) {
	assertTrue(EnumSet.of(ChronoUnit.DAYS, ChronoUnit.HOURS).contains(unit));
}

names 외에 fromto 속성으로 상수 범위를 지정할 수도 있어요. 범위는 from 속성의 상수에서 시작해 enum 상수의 자연 순서에 따라 to 속성의 상수를 포함해 그까지의 모든 후속 상수를 포함해요.

fromto를 생략하면 각각 enum 타입의 첫 번째와 마지막 상수가 기본값이 돼요. names, from, to를 모두 생략하면 모든 상수를 써요. 아래 예시는 상수의 범위를 지정하는 방법이에요.

@ParameterizedTest
@EnumSource(from = "HOURS", to = "DAYS")
void testWithEnumSourceRange(ChronoUnit unit) {
	assertTrue(EnumSet.of(ChronoUnit.HOURS, ChronoUnit.HALF_DAYS, ChronoUnit.DAYS).contains(unit));
}

@EnumSource에는 선택적 mode 속성도 있어서 어떤 상수를 테스트 메서드에 넘길지 세밀하게 제어할 수 있어요. 예를 들어 enum 상수 풀에서 이름을 제외하거나 정규 표현식을 지정할 수 있죠.

@ParameterizedTest
@EnumSource(mode = EXCLUDE, names = { "ERAS", "FOREVER" })
void testWithEnumSourceExclude(ChronoUnit unit) {
	assertFalse(EnumSet.of(ChronoUnit.ERAS, ChronoUnit.FOREVER).contains(unit));
}
@ParameterizedTest
@EnumSource(mode = MATCH_ALL, names = "^.*DAYS$")
void testWithEnumSourceRegex(ChronoUnit unit) {
	assertTrue(unit.name().endsWith("DAYS"));
}

modefrom, to, names와 조합하면 상수 범위를 정의하면서 그 범위에서 특정 값을 제외할 수도 있어요.

@ParameterizedTest
@EnumSource(from = "HOURS", to = "DAYS", mode = EXCLUDE, names = { "HALF_DAYS" })
void testWithEnumSourceRangeExclude(ChronoUnit unit) {
	assertTrue(EnumSet.of(ChronoUnit.HOURS, ChronoUnit.DAYS).contains(unit));
	assertFalse(EnumSet.of(ChronoUnit.HALF_DAYS).contains(unit));
}

@MethodSource

@MethodSource는 테스트 클래스 또는 외부 클래스의 팩토리 메서드 하나 이상을 참조하게 해줘요.

테스트 클래스 안의 팩토리 메서드는 테스트 클래스에 @TestInstance(Lifecycle.PER_CLASS)가 붙어 있지 않는 한 static이어야 해요. 외부 클래스의 팩토리 메서드는 항상 static이어야 하죠.

각 팩토리 메서드는 인자의 스트림을 생성해야 해요. 스트림 안의 각 인자 집합은 애노테이션이 붙은 @ParameterizedClass@ParameterizedTest의 개별 호출에 물리적 인자로 제공돼요. 일반적으로 ArgumentsStream(즉 Stream<Arguments>)으로 해석되지만, 실제 구체 반환 타입은 여러 가지가 될 수 있어요. 여기서 "스트림"은 JUnit이 Stream으로 안정적으로 변환할 수 있는 모든 것을 말해요. Stream, DoubleStream, LongStream, IntStream, Collection, Iterator, Iterable, 객체나 기본 타입의 배열, 또는 iterator(): Iterator 메서드를 제공하는 모든 타입(예: kotlin.sequences.Sequence)이 해당하죠. 스트림 안의 "인자"는 Arguments 인스턴스, 객체 배열(예: Object[]), 또는 파라미터화 클래스나 테스트 메서드가 단일 인자를 받는다면 단일 값으로 공급할 수 있어요.

반환 타입이 Stream이나 기본 스트림 중 하나라면, JUnit은 BaseStream.close()를 호출해 제대로 닫아줘요. 그래서 Files.lines() 같은 리소스를 안전하게 쓸 수 있죠.

단일 파라미터만 필요하면 파라미터 타입의 인스턴스 Stream을 반환할 수 있어요.

@ParameterizedTest
@MethodSource("stringProvider")
void testWithExplicitLocalMethodSource(String argument) {
	assertNotNull(argument);
}

static Stream<String> stringProvider() {
	return Stream.of("apple", "banana");
}

@ParameterizedClass에서는 @MethodSource로 팩토리 메서드 이름을 제공하는 것이 필수예요. @ParameterizedTest에서는 팩토리 메서드 이름을 명시하지 않으면, JUnit Jupiter가 현재 @ParameterizedTest 메서드와 같은 이름의 팩토리 메서드를 관례에 따라 찾아요.

@ParameterizedTest
@MethodSource
void testWithDefaultLocalMethodSource(String argument) {
	assertNotNull(argument);
}

static Stream<String> testWithDefaultLocalMethodSource() {
	return Stream.of("apple", "banana");
}

기본 타입용 스트림(DoubleStream, IntStream, LongStream)도 지원돼요.

@ParameterizedTest
@MethodSource("range")
void testWithRangeMethodSource(int argument) {
	assertNotEquals(9, argument);
}

static IntStream range() {
	return IntStream.range(0, 20).skip(10);
}

파라미터화 클래스나 테스트 메서드가 여러 파라미터를 선언하면 아래처럼 Arguments 인스턴스나 객체 배열의 컬렉션·스트림·배열을 반환해야 해요(지원 반환 타입의 자세한 내용은 @MethodSource Javadoc 참고). arguments(Object…)Arguments 인터페이스에 정의된 static 팩토리 메서드예요. Arguments.of(Object…)arguments(Object…) 대신 쓸 수도 있어요.

@ParameterizedTest
@MethodSource("stringIntAndListProvider")
void testWithMultiArgMethodSource(String str, int num, List<String> list) {
	assertEquals(5, str.length());
	assertTrue(num >=1 && num <=2);
	assertEquals(2, list.size());
}

static Stream<Arguments> stringIntAndListProvider() {
	return Stream.of(
		arguments("apple", 1, Arrays.asList("a", "b")),
		arguments("lemon", 2, Arrays.asList("x", "y"))
	);
}

외부 static 팩토리 메서드는 정규화된 메서드 이름으로 참조할 수 있어요.

package example;

import java.util.stream.Stream;

import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;

class ExternalMethodSourceDemo {

	@ParameterizedTest
	@MethodSource("example.StringsProviders#tinyStrings")
	void testWithExternalMethodSource(String tinyString) {
		// test with tiny string
	}
}

class StringsProviders {

	static Stream<String> tinyStrings() {
		return Stream.of(".", "oo", "OOO");
	}
}

팩토리 메서드는 파라미터를 선언할 수 있어요. 등록된 ParameterResolver 확장 API 구현이 이 파라미터를 제공하죠. 아래 예시에서는 테스트 클래스에 그런 이름의 메서드가 하나뿐이라 팩토리 메서드를 이름으로 참조해요. 같은 이름의 로컬 메서드가 여러 개라면 파라미터로 구분할 수도 있어요. 예: @MethodSource("factoryMethod()") 또는 @MethodSource("factoryMethod(java.lang.String)"). 또는 정규화된 메서드 이름으로 참조할 수도 있어요. 예: @MethodSource("example.MyTests#factoryMethod(java.lang.String)").

@RegisterExtension
static final IntegerResolver integerResolver = new IntegerResolver();

@ParameterizedTest
@MethodSource("factoryMethodWithArguments")
void testWithFactoryMethodWithArguments(String argument) {
	assertTrue(argument.startsWith("2"));
}

static Stream<Arguments> factoryMethodWithArguments(int quantity) {
	return Stream.of(
			arguments(quantity + " apples"),
			arguments(quantity + " lemons")
	);
}

static class IntegerResolver implements ParameterResolver {

	@Override
	public boolean supportsParameter(ParameterContext parameterContext,
			ExtensionContext extensionContext) {

		return parameterContext.getParameter().getType() == int.class;
	}

	@Override
	public Object resolveParameter(ParameterContext parameterContext,
			ExtensionContext extensionContext) {

		return 2;
	}

}

@FieldSource

@FieldSource는 테스트 클래스 또는 외부 클래스의 필드 하나 이상을 참조하게 해줘요.

테스트 클래스 안의 필드는 테스트 클래스에 @TestInstance(Lifecycle.PER_CLASS)가 붙어 있지 않는 한 static이어야 해요. 외부 클래스의 필드는 항상 static이어야 하죠.

각 필드는 인자의 스트림을 공급할 수 있어야 해요. "스트림" 안의 각 "인자" 집합은 애노테이션이 붙은 @ParameterizedClass@ParameterizedTest의 개별 호출에 물리적 인자로 제공돼요.

여기서 "스트림"은 JUnit이 Stream으로 안정적으로 변환할 수 있는 모든 것을 말해요. 실제 구체 필드 타입은 여러 형태가 될 수 있어요. 일반적으로 Collection, Iterable, 스트림(Stream, DoubleStream, LongStream, IntStream)의 Supplier, IteratorSupplier, 객체나 기본 타입의 배열, 또는 iterator(): Iterator 메서드를 제공하는 모든 타입(예: kotlin.sequences.Sequence)으로 해석돼요. "스트림" 안의 각 "인자" 집합은 Arguments 인스턴스, 객체 배열(예: Object[], String[]), 또는 파라미터화 클래스나 테스트 메서드가 단일 인자를 받는다면 단일 값으로 공급할 수 있어요.

@MethodSource 팩토리 메서드의 지원 반환 타입과 달리, @FieldSource 필드의 값은 Stream, DoubleStream, LongStream, IntStream, Iterator 인스턴스일 수 없어요. 이런 타입의 값은 처음 처리할 때 소비되기 때문이죠. 그래도 이런 타입을 쓰고 싶다면 Supplier로 감싸면 돼요. 예: Supplier<IntStream>.

Supplier 반환 타입이 Stream이나 기본 스트림 중 하나라면, JUnit은 BaseStream.close()를 호출해 제대로 닫아줘요. 그래서 Files.lines() 같은 리소스를 안전하게 쓸 수 있죠.

객체의 1차원 배열을 "인자" 집합으로 공급하면 다른 타입의 인자와 다르게 처리된다는 점에 유의하세요. 객체 1차원 배열의 모든 요소가 @ParameterizedClass@ParameterizedTest에 개별 물리적 인자로 전달돼요. 자세한 내용은 @FieldSource Javadoc을 참고하면 돼요.

@ParameterizedClass에서는 @FieldSource로 필드 이름을 제공하는 것이 필수예요. @ParameterizedTest에서는 필드 이름을 명시하지 않으면, JUnit Jupiter가 테스트 클래스에서 현재 @ParameterizedTest 메서드와 같은 이름의 필드를 관례에 따라 찾아요. 아래 예시가 그 방식이에요. 이 테스트 메서드는 "apple""banana" 두 값으로 두 번 호출돼요.

@ParameterizedTest
@FieldSource
void arrayOfFruits(String fruit) {
	assertFruit(fruit);
}

static final String[] arrayOfFruits = { "apple", "banana" };

아래 예시는 @FieldSource로 명시적 필드 이름 하나를 제공하는 방법이에요. 역시 "apple""banana" 두 값으로 두 번 호출돼요.

@ParameterizedTest
@FieldSource("listOfFruits")
void singleFieldSource(String fruit) {
	assertFruit(fruit);
}

static final List<String> listOfFruits = Arrays.asList("apple", "banana");

아래 예시는 @FieldSource로 명시적 필드 이름 여러 개를 제공하는 방법이에요. 이전 예시의 listOfFruits 필드와 additionalFruits 필드를 사용하죠. 그래서 이 테스트 메서드는 "apple", "banana", "cherry", "dewberry" 네 값으로 네 번 호출돼요.

@ParameterizedTest
@FieldSource({ "listOfFruits", "additionalFruits" })
void multipleFieldSources(String fruit) {
	assertFruit(fruit);
}

static final Collection<String> additionalFruits = Arrays.asList("cherry", "dewberry");

@FieldSource 필드를 통해 Stream, DoubleStream, IntStream, LongStream, Iterator를 인자 소스로 제공할 수도 있어요. 단, 스트림이나 이터레이터를 java.util.function.Supplier로 감싸야 하죠. 아래 예시는 이름 있는 인자의 StreamSupplier로 제공하는 방법이에요. 이 테스트 메서드는 "apple""banana" 두 값으로 두 번 호출되고, 표시 이름은 각각 "Apple""Banana"예요.

@ParameterizedTest
@FieldSource
void namedArgumentsSupplier(String fruit) {
	assertFruit(fruit);
}

static final Supplier<Stream<Arguments>> namedArgumentsSupplier = () -> Stream.of(
	arguments(named("Apple", "apple")),
	arguments(named("Banana", "banana"))
);

arguments(Object…)org.junit.jupiter.params.provider.Arguments 인터페이스에 정의된 static 팩토리 메서드예요. named(String, Object)org.junit.jupiter.api.Named 인터페이스에 정의된 static 팩토리 메서드죠.

파라미터화 클래스나 테스트 메서드가 여러 파라미터를 선언하면 해당 @FieldSource 필드는 아래처럼 Arguments 인스턴스나 객체 배열의 컬렉션·스트림 supplier·배열을 제공할 수 있어야 해요.

@ParameterizedTest
@FieldSource("stringIntAndListArguments")
void testWithMultiArgFieldSource(String str, int num, List<String> list) {
	assertEquals(5, str.length());
	assertTrue(num >=1 && num <=2);
	assertEquals(2, list.size());
}

static List<Arguments> stringIntAndListArguments = Arrays.asList(
	arguments("apple", 1, Arrays.asList("a", "b")),
	arguments("lemon", 2, Arrays.asList("x", "y"))
);

arguments(Object…)org.junit.jupiter.params.provider.Arguments 인터페이스에 정의된 static 팩토리 메서드예요.

외부 static @FieldSource 필드는 정규화된 필드 이름으로 참조할 수 있어요.

@ParameterizedTest
@FieldSource("example.FruitUtils#tropicalFruits")
void testWithExternalFieldSource(String tropicalFruit) {
	// test with tropicalFruit
}

@CsvSource

@CsvSource는 인자 목록을 콤마로 구분된 값(즉 CSV String 리터럴)으로 표현하게 해줘요. @CsvSourcevalue 속성으로 제공되는 각 문자열은 CSV 레코드 하나를 나타내고, 파라미터화 클래스나 테스트의 호출 한 번을 만들어내요. 첫 번째 레코드는 선택적으로 CSV 헤더를 공급할 수 있어요(useHeadersInDisplayName 속성의 Javadoc 참고).

@ParameterizedTest
@CsvSource({
	"apple,         1",
	"banana,        2",
	"'lemon, lime', 0xF1",
	"strawberry,    700_000"
})
void testWithCsvSource(String fruit, int rank) {
	assertNotNull(fruit);
	assertNotEquals(0, rank);
}

기본 구분자는 콤마(,)예요. delimiter 속성을 설정하면 다른 문자를 쓸 수 있어요. 또는 delimiterString 속성으로 단일 문자 대신 String 구분자를 쓸 수도 있어요. 단, 두 구분자 속성을 동시에 설정할 수는 없어요.

기본적으로 @CsvSource는 인용 문자로 단일 따옴표(')를 써요. quoteCharacter 속성으로 바꿀 수 있죠. 위 예시의 'lemon, lime' 값과 아래 표를 함께 보면 이해가 빨라요. 빈 인용 값('')은 emptyValue 속성이 설정되어 있지 않으면 빈 String이 돼요. 반면 완전히 빈 값은 null 참조로 해석돼요. nullValues를 하나 이상 지정하면 사용자 지정 값을 null 참조로 해석할 수 있어요(아래 표의 NIL 예시 참고). null 참조의 대상 타입이 기본 타입이면 ArgumentConversionException이 던져져요.

인용된 문자열 밖의 비인용 빈 값은 nullValues 속성으로 구성된 사용자 지정 값과 무관하게 항상 null 참조로 변환돼요.

인용 문자열 안을 제외하면, CSV 컬럼의 앞뒤 공백은 기본적으로 제거돼요. ignoreLeadingAndTrailingWhitespace 속성을 true로 설정하면 이 동작을 바꿀 수 있어요.

예시 입력 결과 인자 목록
@CsvSource({ "apple, banana" }) "apple", "banana"
@CsvSource({ "apple, 'lemon, lime'" }) "apple", "lemon, lime"
@CsvSource({ "apple, ''" }) "apple", ""
@CsvSource({ "apple, " }) "apple", null
@CsvSource(value = { "apple, banana, NIL" }, nullValues = "NIL") "apple", "banana", null
@CsvSource(value = { " apple , banana" }, ignoreLeadingAndTrailingWhitespace = false) " apple ", " banana"

사용하는 프로그래밍 언어가 Java 텍스트 블록이나 이에 상응하는 여러 줄 문자열 리터럴을 지원한다면, @CsvSourcetextBlock 속성을 쓸 수 있어요. 텍스트 블록 안의 각 레코드는 CSV 레코드를 나타내고 파라미터화 클래스나 테스트의 호출 한 번을 만들어내요. 아래 예시처럼 useHeadersInDisplayName 속성을 true로 설정하면 첫 번째 레코드가 선택적으로 CSV 헤더를 공급할 수 있어요.

텍스트 블록을 쓰면 앞선 예시를 이렇게 구현할 수 있어요.

@ParameterizedTest
@CsvSource(useHeadersInDisplayName = true, textBlock = """
	FRUIT,         RANK
	apple,         1
	banana,        2
	'lemon, lime', 0xF1
	strawberry,    700_000
	""")
void testWithCsvSource(String fruit, int rank) {
	// ...
}

앞선 예시로 생성된 표시 이름에는 CSV 헤더 이름이 포함돼요.

[1] FRUIT = "apple", RANK = "1"
[2] FRUIT = "banana", RANK = "2"
[3] FRUIT = "lemon, lime", RANK = "0xF1"
[4] FRUIT = "strawberry", RANK = "700_000"

value 속성으로 공급되는 CSV 레코드와 달리, 텍스트 블록은 주석을 담을 수 있어요. commentCharacter 속성의 값(기본 #)으로 시작하는 줄은 주석으로 취급되어 무시돼요. 단, 인용 필드 안에 주석 문자가 나타나면 특별한 의미를 잃는다는 예외가 있어요.

주석 문자는 앞 공백 없이 줄의 첫 번째 문자여야 해요. 그래서 닫는 텍스트 블록 구분자(""")를 마지막 입력 줄의 끝이나 다음 줄에, 나머지 입력과 왼쪽 정렬로 두는 게 권장돼요(아래 예시처럼 표와 비슷한 형식으로 만들면 보기 좋아요).

@ParameterizedTest
@CsvSource(delimiter = '|', quoteCharacter = '"', textBlock = """
	#-----------------------------
	#    FRUIT     |     RANK
	#-----------------------------
	     apple     |      1
	#-----------------------------
	     banana    |      2
	#-----------------------------
	  "lemon lime" |     0xF1
	#-----------------------------
	   strawberry  |    700_000
	#-----------------------------
	""")
void testWithCsvSource(String fruit, int rank) {
	// ...
}

Java의 텍스트 블록 기능은 코드가 컴파일될 때 부수적 공백을 자동으로 제거해요. 하지만 Groovy나 Kotlin 같은 다른 JVM 언어는 그렇지 않아요. 그래서 Java가 아닌 언어를 쓰면서 텍스트 블록 안에 인용 문자열 내부의 주석이나 새 줄이 있다면, 텍스트 블록 안에 앞 공백이 없도록 신경 써야 해요.

@CsvFileSource

@CsvFileSource는 클래스패스나 로컬 파일 시스템의 CSV 파일을 사용하게 해줘요. CSV 파일의 각 레코드는 파라미터화 클래스나 테스트의 호출 한 번을 만들어내요. 첫 번째 레코드는 선택적으로 CSV 헤더를 공급할 수 있어요. numLinesToSkip 속성으로 헤더를 건너뛰도록 JUnit에 지시할 수 있죠. 표시 이름에 헤더를 쓰려면 useHeadersInDisplayName 속성을 true로 설정하면 돼요. 아래 예시들은 numLinesToSkipuseHeadersInDisplayName의 사용법이에요.

기본 구분자는 콤마(,)예요. delimiter 속성으로 다른 문자를 쓸 수 있고, delimiterString 속성으로 단일 문자 대신 String 구분자를 쓸 수도 있어요. 단, 두 구분자 속성은 동시에 설정할 수 없어요.

CSV 파일의 주석. commentCharacter 속성의 값(기본 #)으로 시작하는 줄은 주석으로 해석되어 무시돼요.

@ParameterizedTest
@CsvFileSource(resources = "/two-column.csv", numLinesToSkip = 1)
void testWithCsvFileSourceFromClasspath(String country, int reference) {
	assertNotNull(country);
	assertNotEquals(0, reference);
}

@ParameterizedTest
@CsvFileSource(files = "src/test/resources/two-column.csv", numLinesToSkip = 1)
void testWithCsvFileSourceFromFile(String country, int reference) {
	assertNotNull(country);
	assertNotEquals(0, reference);
}

@ParameterizedTest
@CsvFileSource(resources = "/two-column.csv", useHeadersInDisplayName = true)
void testWithCsvFileSourceAndHeaders(String country, int reference) {
	assertNotNull(country);
	assertNotEquals(0, reference);
}

two-column.csv:

COUNTRY, REFERENCE
Sweden, 1
Poland, 2
"United States of America", 3
France, 700_000

위의 처음 두 파라미터화 테스트 메서드로 생성된 표시 이름은 다음과 같아요.

[1] country = "Sweden", reference = "1"
[2] country = "Poland", reference = "2"
[3] country = "United States of America", reference = "3"
[4] country = "France", reference = "700_000"

CSV 헤더 이름을 쓰는 마지막 파라미터화 테스트 메서드로 생성된 표시 이름은 다음과 같아요.

[1] COUNTRY = "Sweden", REFERENCE = "1"
[2] COUNTRY = "Poland", REFERENCE = "2"
[3] COUNTRY = "United States of America", REFERENCE = "3"
[4] COUNTRY = "France", REFERENCE = "700_000"

@CsvSource의 기본 구문과 달리, @CsvFileSource는 기본적으로 이중 따옴표(")를 인용 문자로 써요. quoteCharacter 속성으로 바꿀 수 있죠. 위 예시의 "United States of America" 값을 참고하면 돼요. 빈 인용 값("")은 emptyValue 속성이 설정되어 있지 않으면 빈 String이 돼요. 반면 완전히 빈 값은 null 참조로 해석돼요. nullValues를 하나 이상 지정하면 사용자 지정 값을 null 참조로 해석할 수 있어요. null 참조의 대상 타입이 기본 타입이면 ArgumentConversionException이 던져져요.

인용 문자열 밖의 비인용 빈 값은 nullValues 속성으로 구성된 사용자 지정 값과 무관하게 항상 null 참조로 변환돼요.

인용 문자열 안을 제외하면, CSV 컬럼의 앞뒤 공백은 기본적으로 제거돼요. ignoreLeadingAndTrailingWhitespace 속성을 true로 설정하면 이 동작을 바꿀 수 있어요.

@ArgumentsSource

@ArgumentsSource는 사용자 지정 재사용 가능한 ArgumentsProvider 를 지정할 때 써요. ArgumentsProvider 구현은 최상위 클래스 또는 static 중첩 클래스로 선언해야 해요.

@ParameterizedTest
@ArgumentsSource(MyArgumentsProvider.class)
void testWithArgumentsSource(String argument) {
	assertNotNull(argument);
}
public class MyArgumentsProvider implements ArgumentsProvider {

	@Override
	public Stream<? extends Arguments> provideArguments(ParameterDeclarations parameters,
			ExtensionContext context) {
		return Stream.of("apple", "banana").map(Arguments::of);
	}
}

어노테이션도 소비하는 사용자 지정 ArgumentsProvider(내장 provider인 ValueArgumentsProviderCsvArgumentsProvider처럼요)를 구현하고 싶다면, AnnotationBasedArgumentsProvider 클래스를 확장할 수 있어요.

또한 ArgumentsProvider 구현은 등록된 ParameterResolver가 해석해야 하는 생성자 파라미터를 선언할 수 있어요.

public class MyArgumentsProviderWithConstructorInjection implements ArgumentsProvider {

	private final TestInfo testInfo;

	public MyArgumentsProviderWithConstructorInjection(TestInfo testInfo) {
		this.testInfo = testInfo;
	}

	@Override
	public Stream<? extends Arguments> provideArguments(ParameterDeclarations parameters,
			ExtensionContext context) {
		return Stream.of(Arguments.of(testInfo.getDisplayName()));
	}
}

반복 가능한 애노테이션으로 여러 소스 지정

반복 가능한(repeatable) 애노테이션은 서로 다른 provider에서 여러 소스를 지정하는 편리한 방법을 제공해요.

@DisplayName("A parameterized test that makes use of repeatable annotations")
@ParameterizedTest
@MethodSource("someProvider")
@MethodSource("otherProvider")
void testWithRepeatedAnnotation(String argument) {
	assertNotNull(argument);
}

static Stream<String> someProvider() {
	return Stream.of("foo");
}

static Stream<String> otherProvider() {
	return Stream.of("bar");
}

위 파라미터화 테스트에 따라 인자마다 하나씩 테스트 케이스가 실행돼요.

[1] foo
[2] bar

반복 가능한 애노테이션은 다음과 같아요: @ValueSource, @EnumSource, @MethodSource, @FieldSource, @CsvSource, @CsvFileSource, @ArgumentsSource.

인자 개수 검증

기본적으로 인자 소스가 테스트 메서드가 필요한 것보다 많은 인자를 제공하면, 그 추가 인자는 무시되고 테스트는 평소처럼 실행돼요. 이렇게 되면 인자가 파라미터화 클래스나 메서드에 결코 전달되지 않는 버그로 이어질 수 있어요.

이를 막으려면 인자 개수 검증을 strict로 설정할 수 있어요. 그러면 추가 인자가 있을 때 오류가 발생하게 되죠.

모든 테스트에 대해 이 동작을 바꾸려면 junit.jupiter.params.argumentCountValidation 구성 파라미터를 strict로 설정해요. 단일 파라미터화 클래스나 테스트 메서드에 대해서만 바꾸려면 @ParameterizedClass@ParameterizedTest 애노테이션의 argumentCountValidation 속성을 쓰면 돼요.

@ParameterizedTest(argumentCountValidation = ArgumentCountValidationMode.STRICT)
@CsvSource({ "42, -666" })
void testWithArgumentCountValidation(int number) {
	assertTrue(number > 0);
}

인자 변환

확대 변환

JUnit Jupiter는 @ParameterizedClass@ParameterizedTest에 공급되는 인자에 대해 기본 타입 확대 변환(Widening Primitive Conversion)을 지원해요. 예를 들어 @ValueSource(ints = { 1, 2, 3 })가 붙은 파라미터화 클래스나 테스트 메서드는 int뿐 아니라 long, float, double 타입의 인자도 받도록 선언할 수 있어요.

암시적 변환

@CsvSource 같은 사용 사례를 지원하기 위해 JUnit Jupiter는 여러 내장 암시적 타입 변환기를 제공해요. 변환 과정은 각 메서드 파라미터의 선언 타입에 따라 달라지죠.

예를 들어 @ParameterizedClass@ParameterizedTestTimeUnit 타입의 파라미터를 선언하고, 선언된 소스가 실제 공급하는 타입이 String이라면, 그 문자열은 자동으로 해당 TimeUnit enum 상수로 변환돼요.

@ParameterizedTest
@ValueSource(strings = "SECONDS")
void testWithImplicitArgumentConversion(ChronoUnit argument) {
	assertNotNull(argument.name());
}

String 인스턴스는 다음 대상 타입으로 암시적으로 변환돼요.

  • 10진수, 16진수, 8진수 String 리터럴은 정수 타입(byte, short, int, long 및 그 박싱된 타입)으로 변환돼요.
대상 타입 예시
boolean/Boolean "true"true ('true' 또는 'false' 값만 받으며 대소문자 무시)
byte/Byte "15", "0xF", "017"(byte) 15
char/Character "o"'o'
short/Short "15", "0xF", "017"(short) 15
int/Integer "15", "0xF", "017"15
long/Long "15", "0xF", "017"15L
float/Float "1.0"1.0f
double/Double "1.0"1.0d
Enum 하위 클래스 "SECONDS"TimeUnit.SECONDS
java.io.File "/path/to/file"new File("/path/to/file")
java.lang.Class "java.lang.Integer"java.lang.Integer.class (중첩 클래스는 $ 사용: "java.lang.Thread$State")
java.lang.Class "byte"byte.class (기본 타입 지원)
java.lang.Class "char[]"char[].class (배열 타입 지원)
java.math.BigDecimal "123.456e789"new BigDecimal("123.456e789")
java.math.BigInteger "1234567890123456789"new BigInteger("1234567890123456789")
java.net.URI "https://junit.org/"URI.create("https://junit.org/")
java.net.URL "https://junit.org/"URI.create("https://junit.org/").toURL()
java.nio.charset.Charset "UTF-8"Charset.forName("UTF-8")
java.nio.file.Path "/path/to/file"Path.of("/path/to/file")
java.time.Duration "PT3S"Duration.ofSeconds(3)
java.time.Instant "1970-01-01T00:00:00Z"Instant.ofEpochMilli(0)
java.time.LocalDateTime "2017-03-14T12:34:56.789"LocalDateTime.of(2017, 3, 14, 12, 34, 56, 789_000_000)
java.time.LocalDate "2017-03-14"LocalDate.of(2017, 3, 14)
java.time.LocalTime "12:34:56.789"LocalTime.of(12, 34, 56, 789_000_000)
java.time.MonthDay "--03-14"MonthDay.of(3, 14)
java.time.OffsetDateTime "2017-03-14T12:34:56.789Z"OffsetDateTime.of(2017, 3, 14, 12, 34, 56, 789_000_000, ZoneOffset.UTC)
java.time.OffsetTime "12:34:56.789Z"OffsetTime.of(12, 34, 56, 789_000_000, ZoneOffset.UTC)
java.time.Period "P2M6D"Period.of(0, 2, 6)
java.time.YearMonth "2017-03"YearMonth.of(2017, 3)
java.time.Year "2017"Year.of(2017)
java.time.ZonedDateTime "2017-03-14T12:34:56.789Z"ZonedDateTime.of(2017, 3, 14, 12, 34, 56, 789_000_000, ZoneOffset.UTC)
java.time.ZoneId "Europe/Berlin"ZoneId.of("Europe/Berlin")
java.time.ZoneOffset "+02:30"ZoneOffset.ofHoursMinutes(2, 30)
java.util.Currency "JPY"Currency.getInstance("JPY")
java.util.Locale "en-US"Locale.forLanguageTag("en-US")
java.util.UUID "d043e930-7b3b-48e3-bdbe-5a3ccfb833db"UUID.fromString("d043e930-7b3b-48e3-bdbe-5a3ccfb833db")
대체 문자열-객체 변환

위 표에 나열된 대상 타입으로 문자열에서 자동 변환하는 것 외에도, JUnit Jupiter는 대상 타입이 아래에 정의된 적합한 팩토리 메서드나 팩토리 생성자를 선언하면 String에서 특정 대상 타입으로 자동 변환하는 대체 메커니즘을 제공해요.

  • 팩토리 메서드 — 대상 타입에 선언된 비-private static 메서드로, String 인자 하나 또는 CharSequence 인자 하나를 받고 대상 타입의 인스턴스를 반환해요. 메서드 이름은 임의적이며 특정 관례를 따를 필요는 없어요.
  • 팩토리 생성자 — 대상 타입에서 String 인자 하나 또는 CharSequence 인자 하나를 받는 비-private 생성자예요. 대상 타입은 최상위 클래스 또는 static 중첩 클래스로 선언해야 해요.

팩토리 메서드나 팩토리 생성자가 여러 개 있으면 다음과 같은 순서로 매칭해요.

  • String 인자를 받는 단일 팩토리 메서드.
  • String 인자를 받는 단일 팩토리 생성자.
  • CharSequence 인자를 받는 단일 팩토리 메서드.
  • CharSequence 인자를 받는 단일 팩토리 생성자.
  • 모든 @Deprecated 팩토리 메서드를 고려 대상 집합에서 제거한 뒤 남는 String 인자를 받는 단일 팩토리 메서드.
  • 모든 @Deprecated 팩토리 메서드를 고려 대상 집합에서 제거한 뒤 남는 CharSequence 인자를 받는 단일 팩토리 메서드.

예를 들어 아래 @ParameterizedTest 메서드에서 Book 인자는 Book.fromTitle(String) 팩토리 메서드를 호출하고 "42 Cats"를 책 제목으로 넘겨서 생성돼요.

@ParameterizedTest
@ValueSource(strings = "42 Cats")
void testWithImplicitFallbackArgumentConversion(Book book) {
	assertEquals("42 Cats", book.getTitle());
}
public class Book {

	private final String title;

	private Book(String title) {
		this.title = title;
	}

	public static Book fromTitle(String title) {
		return new Book(title);
	}

	public String getTitle() {
		return this.title;
	}
}

명시적 변환

암시적 인자 변환에 의존하는 대신, @ConvertWith 애노테이션으로 특정 파라미터에 사용할 ArgumentConverter를 명시적으로 지정할 수 있어요. ArgumentConverter 구현은 최상위 클래스 또는 static 중첩 클래스로 선언해야 해요.

@ParameterizedTest
@EnumSource(ChronoUnit.class)
void testWithExplicitArgumentConversion(
		@ConvertWith(ToStringArgumentConverter.class) String argument) {

	assertNotNull(ChronoUnit.valueOf(argument));
}
public class ToStringArgumentConverter extends SimpleArgumentConverter {

	@Override
	protected Object convert(Object source, Class<?> targetType) {
		assertEquals(String.class, targetType, "Can only convert to String");
		if (source instanceof Enum<?> constant) {
			return constant.name();
		}
		return String.valueOf(source);
	}
}

변환기가 한 타입을 다른 타입으로만 변환하기 위한 것이라면, TypedArgumentConverter를 확장해 보일러플레이트 타입 검사를 피할 수 있어요.

public class ToLengthArgumentConverter extends TypedArgumentConverter<String, Integer> {

	protected ToLengthArgumentConverter() {
		super(String.class, Integer.class);
	}

	@Override
	protected Integer convert(String source) {
		return (source != null ? source.length() : 0);
	}

}

명시적 인자 변환기는 테스트와 확장 작성자가 구현하도록 설계된 것이에요. 그래서 junit-jupiter-params는 참조 구현으로도 쓸 수 있는 단일 명시적 인자 변환기 JavaTimeArgumentConverter만 제공해요. 이 변환기는 합성 애노테이션 JavaTimeConversionPattern을 통해 사용해요.

@ParameterizedTest
@ValueSource(strings = { "01.01.2017", "31.12.2017" })
void testWithExplicitJavaTimeConverter(
		@JavaTimeConversionPattern("dd.MM.yyyy") LocalDate argument) {

	assertEquals(2017, argument.getYear());
}

어노테이션도 소비하는 사용자 지정 ArgumentConverter(JavaTimeArgumentConverter처럼요)가 필요하다면 AnnotationBasedArgumentConverter 클래스를 확장할 수 있어요.

인자 집계

기본적으로 @ParameterizedClass@ParameterizedTest에 제공되는 각 인자는 메서드 파라미터 하나에 대응돼요. 그 결과, 많은 수의 인자를 공급할 것으로 예상되는 인자 소스는 각각 거대한 생성자나 메서드 시그니처로 이어질 수 있죠.

그럴 때 여러 파라미터 대신 ArgumentsAccessor를 쓸 수 있어요. 이 API를 쓰면 테스트 메서드에 전달되는 단일 인자를 통해 제공된 인자에 접근할 수 있어요. 게다가 앞서 인자 변환에서 다룬 것처럼 타입 변환도 지원돼요.

또한 ArgumentsAccessor.getInvocationIndex()로 현재 테스트 호출 인덱스를 가져올 수 있어요.

@ParameterizedTest
@CsvSource({
	"Jane, Doe, F, 1990-05-20",
	"John, Doe, M, 1990-10-22"
})
void testWithArgumentsAccessor(ArgumentsAccessor arguments) {
	Person person = new Person(
							arguments.getString(0),
							arguments.getString(1),
							arguments.get(2, Gender.class),
							arguments.get(3, LocalDate.class));

	if (person.getFirstName().equals("Jane")) {
		assertEquals(Gender.F, person.getGender());
	}
	else {
		assertEquals(Gender.M, person.getGender());
	}
	assertEquals("Doe", person.getLastName());
	assertEquals(1990, person.getDateOfBirth().getYear());
}

ArgumentsAccessor 인스턴스는 ArgumentsAccessor 타입의 어떤 파라미터에도 자동으로 주입돼요.

사용자 지정 aggregator

ArgumentsAccessor@ParameterizedClass@ParameterizedTest의 인자에 직접 접근하는 것 외에도, JUnit Jupiter는 사용자 지정 재사용 가능한 aggregator 사용을 지원해요.

사용자 지정 aggregator를 쓰려면 ArgumentsAggregator 인터페이스를 구현하고, @ParameterizedClass@ParameterizedTest의 호환 파라미터에 @AggregateWith 애노테이션으로 등록하면 돼요. 집계 결과는 파라미터화 테스트가 호출될 때 해당 파라미터의 인자로 제공되죠. ArgumentsAggregator 구현은 최상위 클래스 또는 static 중첩 클래스로 선언해야 해요.

@ParameterizedTest
@CsvSource({
	"Jane, Doe, F, 1990-05-20",
	"John, Doe, M, 1990-10-22"
})
void testWithArgumentsAggregator(@AggregateWith(PersonAggregator.class) Person person) {
	// perform assertions against person
}
public class PersonAggregator extends SimpleArgumentsAggregator {
	@Override
	protected Person aggregateArguments(ArgumentsAccessor arguments, Class<?> targetType,
			AnnotatedElementContext context, int parameterIndex) {
		return new Person(
						arguments.getString(0),
						arguments.getString(1),
						arguments.get(2, Gender.class),
						arguments.get(3, LocalDate.class));
	}
}

코드베이스 곳곳의 여러 파라미터화 클래스나 메서드에 @AggregateWith(MyTypeAggregator.class)를 반복해서 선언하게 된다면, @AggregateWith(MyTypeAggregator.class)를 메타 애노테이션으로 가진 @CsvToMyType 같은 사용자 지정 합성 애노테이션을 만드는 게 좋아요. 아래 예시는 사용자 지정 @CsvToPerson 애노테이션으로 그렇게 한 모습이에요.

@ParameterizedTest
@CsvSource({
	"Jane, Doe, F, 1990-05-20",
	"John, Doe, M, 1990-10-22"
})
void testWithCustomAggregatorAnnotation(@CsvToPerson Person person) {
	// perform assertions against person
}
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.PARAMETER)
@AggregateWith(PersonAggregator.class)
public @interface CsvToPerson {
}

표시 이름 커스터마이징

기본적으로 파라미터화 클래스나 테스트 호출의 표시 이름에는 호출 인덱스와 해당 호출의 모든 인자의 String 표현이 쉼표로 구분된 목록이 들어 있어요. 바이트코드에 파라미터 이름이 있으면 각 인자 앞에 파라미터 이름과 등호가 붙어요(ArgumentsAccessorArgumentAggregator로만 얻을 수 있는 인자 제외). 예: firstName = "Jane".

바이트코드에 파라미터 이름이 있도록 하려면 Java는 -parameters 컴파일러 플래그, Kotlin은 -java-parameters 컴파일러 플래그로 테스트 코드를 컴파일해야 해요.

하지만 @ParameterizedClass@ParameterizedTest 애노테이션의 name 속성으로 호출 표시 이름을 커스터마이징할 수 있어요.

@DisplayName("Display name of container")
@ParameterizedTest(name = "{index} ==> the rank of {0} is {1}")
@CsvSource({ "apple, 1", "banana, 2", "'lemon, lime', 3" })
void testWithCustomDisplayNames(String fruit, int rank) {
}

ConsoleLauncher로 위 메서드를 실행하면 다음과 비슷한 출력을 볼 수 있어요.

Display name of container ✔
├─ 1 ==> the rank of "apple" is "1" ✔
├─ 2 ==> the rank of "banana" is "2" ✔
└─ 3 ==> the rank of "lemon, lime" is "3" ✔

nameMessageFormat 패턴이라는 점에 유의하세요. 그래서 단일 따옴표(')는 표시되려면 이중 단일 따옴표('')로 표현해야 해요.

커스텀 표시 이름 안에서 지원되는 플레이스홀더는 다음과 같아요.

플레이스홀더 설명
{displayName} 메서드의 표시 이름
{index} 현재 호출 인덱스 (1부터 시작)
{arguments} 완전한 쉼표 구분 인자 목록
{argumentsWithNames} 파라미터 이름이 포함된 완전한 쉼표 구분 인자 목록
{argumentSetName} 인자 집합의 이름
{argumentSetNameOrArgumentsWithNames} 인자 공급 방식에 따라 {argumentSetName} 또는 {argumentsWithNames}
{0}, {1}, … 개별 인자

표시 이름에 인자가 포함될 때, 그 문자열 표현이 구성된 최대 길이를 초과하면 잘려요. 이 한도는 junit.jupiter.params.displayname.argument.maxlength 구성 파라미터로 설정할 수 있고 기본값은 512자예요.

@MethodSource, @FieldSource, @ArgumentsSource를 쓸 때 개별 인자에 커스텀 이름을 주거나 인자 집합 전체에 커스텀 이름을 줄 수 있어요.

Named API로 개별 인자에 커스텀 이름을 줄 수 있어요. 그 커스텀 이름은 인자가 호출 표시 이름에 포함되면 사용돼요.

@DisplayName("A parameterized test with named arguments")
@ParameterizedTest(name = "{index}: {0}")
@MethodSource("namedArguments")
void testWithNamedArguments(File file) {
}

static Stream<Arguments> namedArguments() {
	return Stream.of(
		arguments(named("An important file", new File("path1"))),
		arguments(named("Another file", new File("path2")))
	);
}

ConsoleLauncher로 위 메서드를 실행하면 다음과 비슷한 출력을 볼 수 있어요.

A parameterized test with named arguments ✔
├─ 1: An important file ✔
└─ 2: Another file ✔

arguments(Object…)org.junit.jupiter.params.provider.Arguments 인터페이스에 정의된 static 팩토리 메서드예요. named(String, Object)org.junit.jupiter.api.Named 인터페이스에 정의된 static 팩토리 메서드죠.

ArgumentSet API로 인자 집합 전체에 커스텀 이름을 줄 수 있어요. 그 커스텀 이름이 표시 이름으로 사용되죠.

@DisplayName("A parameterized test with named argument sets")
@ParameterizedTest
@FieldSource("argumentSets")
void testWithArgumentSets(File file1, File file2) {
}

static List<Arguments> argumentSets = Arrays.asList(
	argumentSet("Important files", new File("path1"), new File("path2")),
	argumentSet("Other files", new File("path3"), new File("path4"))
);

ConsoleLauncher로 위 메서드를 실행하면 다음과 비슷한 출력을 볼 수 있어요.

A parameterized test with named argument sets ✔
├─ [1] Important files ✔
└─ [2] Other files ✔

argumentSet(String, Object…)org.junit.jupiter.params.provider.Arguments 인터페이스에 정의된 static 팩토리 메서드예요.

인용된 텍스트 기반 인자

JUnit Jupiter 6.0부터 파라미터화 테스트의 표시 이름에서 텍스트 기반 인자는 기본적으로 인용돼요. 여기서 CharSequence(String처럼요)나 Character는 모두 텍스트로 간주돼요. CharSequence는 이중 따옴표(")로, Character는 단일 따옴표(')로 감싸요.

특수 문자는 인용된 텍스트에서 이스케이프돼요. 예를 들어 캐리지 리턴과 줄 바꿈은 각각 \\r\\n으로 이스케이프되죠.

이 기능은 @ParameterizedClass@ParameterizedTestquoteTextArguments 속성을 false로 설정해 끌 수 있어요.

예를 들어 문자열 인자 "line 1\nline 2"가 주어지면 표시 이름에서 물리 표현은 "\"line 1\\nline 2\""이 되고 "line 1\nline 2"로 출력돼요. 마찬가지로 문자열 인자 "\t"가 주어지면 표시 이름에서 물리 표현은 "\"\\t\""이 되고, 빈 문자열이나 보이지 않는 탭 문자 대신 "\t"로 출력돼요. 문자 인자 '\t'에도 같은 규칙이 적용돼요. 표시 이름에서 물리 표현은 "'\\t'"가 되고 '\t'로 출력되죠.

구체적인 예시로, 위 Null and Empty Sources 섹션의 첫 번째 nullValuesAndBlankStrings(String text) 파라미터화 테스트 메서드를 실행하면 다음 표시 이름이 생성돼요.

[1] text = null
[2] text = ""
[3] text = " "
[4] text = "   "
[5] text = "\t"
[6] text = "\n"

@CsvSource 섹션의 첫 번째 testWithCsvSource(String fruit, int rank) 파라미터화 테스트 메서드를 실행하면 다음 표시 이름이 생성돼요.

[1] fruit = "apple", rank = "1"
[2] fruit = "banana", rank = "2"
[3] fruit = "lemon, lime", rank = "0xF1"
[4] fruit = "strawberry", rank = "700_000"

원본 소스 인자는 표시 이름을 생성할 때 인용되고, 이는 암시적·명시적 인자 변환이 수행되기 전에 일어나요.

예를 들어 파라미터화 테스트가 입력 문자열 "3.14"에서 변환된 float 인자 3.14를 받는다면, 표시 이름에는 3.14 대신 "3.14"가 나타나요. 위 예시의 rank 값에서 그 효과를 확인할 수 있어요.

기본 표시 이름 패턴

프로젝트의 모든 파라미터화 클래스와 테스트에 대해 기본 이름 패턴을 설정하고 싶다면, junit-platform.properties 파일에서 junit.jupiter.params.displayname.default 구성 파라미터를 선언하면 돼요(다른 옵션은 Configuration Parameters 참고).

junit.jupiter.params.displayname.default = {index}

우선순위 규칙

파라미터화 클래스나 테스트의 표시 이름은 다음 우선순위 규칙에 따라 결정돼요.

  • @ParameterizedClass@ParameterizedTestname 속성(있다면).
  • junit.jupiter.params.displayname.default 구성 파라미터의 값(있다면).
  • org.junit.jupiter.params.ParameterizedInvocationConstants에 정의된 DEFAULT_DISPLAY_NAME 상수.

라이프사이클과 상호 운용성

파라미터화 테스트

파라미터화 테스트의 각 호출은 일반적인 @Test 메서드와 같은 라이프사이클을 가져요. 예를 들어 @BeforeEach 메서드는 각 호출 전에 실행돼요. Dynamic Tests와 비슷하게, 호출은 IDE의 테스트 트리에 하나씩 나타나요. 같은 테스트 클래스 안에서 일반 @Test 메서드와 @ParameterizedTest 메서드를 자유롭게 섞을 수 있어요.

@ParameterizedTest 메서드에 ParameterResolver 확장을 쓸 수 있어요. 다만 인자 소스로 해석되는 메서드 파라미터는 파라미터 목록에서 앞에 와야 해요. 테스트 클래스는 파라미터 목록이 다른 일반 테스트와 파라미터화 테스트를 함께 담을 수 있으므로, 인자 소스의 값은 라이프사이클 메서드(예: @BeforeEach)와 테스트 클래스 생성자에는 해석되지 않아요.

@BeforeEach
void beforeEach(TestInfo testInfo) {
	// ...
}

@ParameterizedTest
@ValueSource(strings = "apple")
void testWithRegularParameterResolver(String argument, TestReporter testReporter) {
	testReporter.publishEntry("argument", argument);
}

@AfterEach
void afterEach(TestInfo testInfo) {
	// ...
}

파라미터화 클래스

파라미터화 클래스의 각 호출은 일반 테스트 클래스와 같은 라이프사이클을 가져요. 예를 들어 @BeforeAll 메서드는 모든 호출 전에 한 번 실행되고, @BeforeEach 메서드는 각 테스트 메서드 호출 전에 실행돼요. Dynamic Tests와 비슷하게, 호출은 IDE의 테스트 트리에 하나씩 나타나요.

@ParameterizedClass 생성자에 ParameterResolver 확장을 쓸 수 있어요. 다만 생성자 주입을 쓴다면 인자 소스로 해석되는 생성자 파라미터는 파라미터 목록에서 앞에 와야 해요. 인자 소스의 값은 일반 라이프사이클 메서드(예: @BeforeEach)에는 해석되지 않아요.

일반 라이프사이클 메서드 외에도, 파라미터화 클래스는 파라미터화 클래스의 각 호출 전후에 한 번씩 호출되는 @BeforeParameterizedClassInvocation@AfterParameterizedClassInvocation 라이프사이클 메서드를 선언할 수 있어요. 이 메서드는 파라미터화 클래스가 @TestInstance(Lifecycle.PER_CLASS)를 쓰도록 구성되지 않는 한 static이어야 해요.

이 라이프사이클 메서드는 선택적으로 injectArguments 애노테이션 속성 설정에 따라 해석되는 파라미터를 선언할 수 있어요. false로 설정하면 파라미터는 다른 등록된 ParameterResolver 확장이 해석해야 해요. true(기본값)로 설정하면 메서드는 파라미터화 클래스의 인자와 일치하는 파라미터를 선언할 수 있어요. 이는 예를 들어 사용된 인자를 초기화하는 데 쓸 수 있어요.

파라미터화 클래스 라이프사이클 메서드 사용하기:

@ParameterizedClass
@MethodSource("textFiles")
class TextFileTests {

	static List<TextFile> textFiles() {
		return List.of(
			new TextFile("file1", "first content"),
			new TextFile("file2", "second content")
		);
	}

	@Parameter
	TextFile textFile;

	@BeforeParameterizedClassInvocation
	static void beforeInvocation(TextFile textFile, @TempDir Path tempDir) throws Exception {
		var filePath = tempDir.resolve(textFile.fileName); (1)
		textFile.path = Files.writeString(filePath, textFile.content);
	}

	@SuppressWarnings("DataFlowIssue")
	@AfterParameterizedClassInvocation
	static void afterInvocation(TextFile textFile) throws Exception {
		var actualContent = Files.readString(textFile.path); (3)
		assertEquals(textFile.content, actualContent, "Content must not have changed");
		// Custom cleanup logic, if necessary
		// File will be deleted automatically by @TempDir support
	}

	@SuppressWarnings("DataFlowIssue")
	@Test
	void test() {
		assertTrue(Files.exists(textFile.path)); (2)
	}

	@Test
	void anotherTest() {
		// ...
	}

	static class TextFile {

		final String fileName;
		final String content;
		Path path;

		TextFile(String fileName, String content) {
			this.fileName = fileName;
			this.content = content;
		}

		@Override
		public String toString() {
			return fileName;
		}
	}
}
  • (1) 파라미터화 클래스의 각 호출 전 인자 초기화
  • (2) 테스트 메서드에서 이전에 초기화한 인자 사용
  • (3) 파라미터화 클래스의 각 호출 후 인자 검증 및 정리

더 알아보기