서론
우아한 테크코스 프리코스 미션을 하면서 테스트 코드를 작성하던 도중, 테스트 코드를 다음과 같이 작성했었습니다. (최종 코드는 검증 로직을 다르게 했기 때문에 지금은 이 테스트 코드가 아예 있지 않습니다.)
@Test
@ParameterizedTest
@ValueSource(strings = {"崔はлن", "?-bob"})
void 한글과_알파벳_숫자를_제외한_모든_문자는_안된다(final String value) {
// given & when
Throwable exception = Assertions.assertThrows(IllegalArgumentException.class, () -> {
Name name = Name.from(value);
});
// then
assertThat(exception.getMessage()).isEqualTo(CAR_NAMV_VALUE_EXCEPTION.toString());
}
이렇게 하니, 예상치 못한 예외를 발견할 수 있었습니다.
왜 이런 예외가 발생된 것일까요? 지금부터 알아보겠습니다.
1. @Test와 @ParameterizedTest는 함께 쓰면 안 된다.
위의 사진을 보면, 한글과_알파벳_숫자를_제외한_모든_문자는_안된다가 두 개로 쪼개진 뒤 첫 번째는 실패, 두 번째는 성공하였음을 볼 수 있습니다.
이는 @Test와 @ParameterizedTest를 함께 썼기 때문에 발생한 현상입니다. @Test와 @ParameterizedTest는 각각 한 번 테스트하는지, 파라미터들을 넣어보며 테스트하는지일 때 사용하는 어노테이션들이므로, 이들을 같이 쓰면 저렇게 분리가 됩니다.
2. @Test, @ParameterizedTest, @ValueSource 차이
사용된 어노테이션들의 사용법을 더 자세히 알아보기 위해, 작성된 문서를 직접 번역해 보면 다음과 같습니다.
@Test
@Target({ElementType.ANNOTATION_TYPE, ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) @Documented @API(status = Status.STABLE, since = "5.0") @Testable public @interface Test extends java.lang.annotation.Annotation
@Test는 어노테이션이 달린 메서드가 테스트 메서드임을 나타냅니다. @Test 메서드는 private이나 static이면 안 되며, 값을 반환해서도 안 됩니다. @Test 메서드는 ParameterResolvers에 의해 해결되는 매개변수를 선택적으로 선언할 수 있습니다. @Test는 또한 @Test의 의미를 상속하는 사용자 정의 조합 어노테이션을 만들기 위해 메타 어노테이션으로 사용될 수 있습니다.
테스트 실행 순서
기본적으로 테스트 메서드는 결정적이지만 일부러 명백하지 않은 알고리즘을 사용하여 정렬됩니다. 이는 테스트 스위트의 후속 실행에서 테스트 메서드를 동일한 순서로 실행하여 반복 가능한 빌드를 가능케 합니다. 여기서 테스트 메서드는 직접적으로 @Test, @RepeatedTest, @ParameterizedTest, @TestFactory 또는 @TestTemplate으로 어노테이션이 달린 인스턴스 메서드인 어떤 것이든 됩니다. 진정한 단위 테스트는 일반적으로 실행 순서에 의존해서는 안 되지만, 때로는 명시적으로 특정한 테스트 메서드 실행 순서를 강제해야 하는 경우가 있습니다. — 예를 들어, 시퀀스가 중요한 통합 테스트 또는 기능 테스트를 작성할 때, 특히 @TestInstance(Lifecycle.PER_CLASS)와 함께 사용될 때입니다. 테스트 메서드의 실행 순서를 제어하려면 테스트 클래스 또는 테스트 인터페이스에 @TestMethodOrder를 어노테이션으로 달고 원하는 MethodOrderer 구현을 지정하면 됩니다.
@ParameterizedTest
@Target({ElementType.ANNOTATION_TYPE, ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) @Documented @API(status = Status.STABLE, since = "5.7") @TestTemplate @ExtendWith({org.junit.jupiter.params.ParameterizedTestExtension.class}) public @interface ParameterizedTest extends java.lang.annotation.Annotation
@ParameterizedTest는 어노테이션이 달린 메서드가 매개변수화된 테스트 메서드임을 나타냅니다. 이러한 메서드는 private이나 static이어서는 안 됩니다.
인자 제공 및 소스
@ParameterizedTest 메서드는 @ArgumentsSource 또는 해당하는 구성 어노테이션(예: @ValueSource, @CsvSource 등)을 통해 적어도 하나의 ArgumentsProvider를 지정해야 합니다. 이 프로바이더는 매개변수화된 테스트 메서드를 호출하는 데 사용될 Argument의 스트림을 제공하는 역할을 합니다.
형식 매개변수 목록
@ParameterizedTest 메서드는 메서드의 매개변수 목록 끝에 다른 ParameterResolver들에 의해 해결될 추가적인 매개변수를 선언할 수 있습니다. 구체적으로 매개변수화된 테스트 메서드는 다음 규칙에 따라 형식 매개변수를 선언해야 합니다.
하나 이상의 인덱싱된 인자는 먼저 선언되어야 합니다. 그다음에 하나 이상의 어그리게이터가 선언되어야 합니다. 다른 ParameterResolver 구현에서 제공된 하나 이상의 인자는 마지막에 선언되어야 합니다.
이 문맥에서 인덱싱된 인자는 ArgumentsProvider에 의해 제공된 Arguments의 주어진 인덱스에 대한 인자를 나타내며, 이는 매개변수화된 메서드의 형식 매개변수 목록에서 동일한 인덱스에 대한 인수로 전달됩니다. 어그리게이터는 ArgumentsAccessor 형식의 매개변수 또는 @AggregateWith로 어노테이션이 달린 매개변수입니다.
인자 변환
메서드 매개변수는 명시적 ArgumentConverter를 지정하기 위해 @ConvertWith 또는 해당 구성 어노테이션으로 달릴 수 있습니다. 그렇지 않으면 JUnit Jupiter는 대상 형식으로의 암시적 변환을 자동으로 시도할 것입니다(자세한 내용은 사용자 가이드를 참조하세요).
조합된 어노테이션
@ParameterizedTest는 @ParameterizedTest의 의미를 상속하는 사용자 정의 조합 어노테이션을 만들기 위해 메타 어노테이션으로도 사용될 수 있습니다.
테스트 실행 순서
기본적으로 테스트 메서드는 결정적이지만 일부러 명백하지 않은 알고리즘을 사용하여 정렬됩니다. 이는 테스트 스위트의 후속 실행에서 테스트 메서드를 동일한 순서로 실행하여 반복 가능한 빌드를 가능케 합니다. 여기서 테스트 메서드는 직접적으로 @Test, @RepeatedTest, @ParameterizedTest, @TestFactory, 또는 @TestTemplate으로 어노테이션이 달린 인스턴스 메서드인 어떤 것이든 됩니다. 진정한 단위 테스트는 일반적으로 실행되는 순서에 의존해서는 안 되지만, @TestMethodOrder를 사용하여 테스트 메서드의 실행 순서를 제어할 수 있습니다. 특히 @TestInstance(Lifecycle.PER_CLASS)와 함께 사용되는 통합 테스트나 기능 테스트를 작성할 때 테스트의 순서가 중요한 경우가 있습니다. 테스트 메서드의 실행 순서를 제어하려면 테스트 클래스 또는 테스트 인터페이스에 @TestMethodOrder를 어노테이션으로 달고 원하는 MethodOrderer 구현을 지정하면 됩니다.
@ValueSource
@Target({ElementType.ANNOTATION_TYPE, ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) @Documented @API(status = Status.STABLE, since = "5.7") @ArgumentsSource(org.junit.jupiter.params.provider.ValueArgumentsProvider.class) public @interface ValueSource extends java.lang.annotation.Annotation
@ValueSource는 리터럴 값의 배열에 액세스 하는 ArgumentsSource입니다. 지원되는 유형은 short, byte, int, long, float, double, char, boolean, String 및 Class를 포함합니다. 그러나 @ValueSource 선언당 하나의 지원되는 유형만 지정할 수 있습니다. 제공된 리터럴 값은 어노테이션이 달린 @ParameterizedTest 메서드에 인수로 제공됩니다.
다소 설명이 길지만, 핵심을 정리해 보면 다음과 같다고 할 수 있습니다.
- @Test 메서드는 ParameterResolver에 의해 해결되는 매개변수를 선택적으로 선언할 수 있습니다.
- @ParameterizedTest를 사용할 때는 @ValueSource, @CsvSource 등을 통해 적어도 하나의 ArgumentsProvider를 지정해야 합니다.
- @ValueSource는 @Parameterized에 인자를 제공하기 위해 사용됩니다.
3. 테스트 코드의 내부적 흐름
이제 핵심으로 들어가보겠습니다! 내장된 테스트 코드를 분석해 보면, ParameterResolver의 구현체들에서 supportsParameter가 호출됩니다. 이는 위에 있던 ParameterResolver에 의해 해결되는 매개변수를 선택적으로 선언할 수 있습니다.에 의해 실행됩니다.
이것을 실행할 수 있도록 하는 것은 ExecutableInvoker 덕분입니다.
ExecutableInvoker
ExecutableInvoker가 resolveParameters 메서드로 사용자가 입력한 각각의 파라미터마다 지원되는 리졸버가 있는지 resolveParameter를 호출함을 확인할 수 있습니다.
resolveParameter는 비워져 있을 시 예외가 발생합니다. 또한 지원되는 파라미터 리졸버가 두 개 이상일 경우에도 예외가 발생합니다.
다시 흐름을 보겠습니다!
ParameterContext, Executable executable, ExtensionContext, ExtensionRegistry 등이 보입니다. 디버깅을 해 보면 다음과 같은 값들이 저장되어 있습니다.
즉, extensionRegistry에서 stream 메서드를 실행한 뒤 matchingResolvers의 사이즈가 비어있으면 우리가 접했던 예외가 발생한다는 뜻입니다.
resolveParameter 함수에서 지정된 extensionRegistry의 값으로 MutableExtensionRegistry가 들어있음을 확인할 수 있습니다.
그러면 이제 MutableExtensionRegistry를 보겠습니다.
MutableExtensionRegistry
코드를 뜯어보면, ExtensionRegistry의 구현체로는 MutableExtensionRegistry만 있음을 알 수 있습니다.
아래는 ExtensionRegistry에 정의된 stream 메서드의 용법입니다.
Extension을 상속받은 클래스들 중, extensionType에 해당되는 것들을 스트림으로 가져옵니다.
그런데 아까 위에서 보면, extensionType이 ParameterResolver였습니다.
따라서 ParameterResolver의 구현체 (ParameterResolver 자체가 Extension을 상속받습니다.)들을 가져옵니다.
이 구현체들에게 전부 supportsParameter(parameterContext, extensionContext)를 물어봅니다. 그리고 true로 응답하는 구현체들을 리스트로 취합한 뒤 비어 있다면 예외가 발생합니다.
4. 각 ParameterResolver 구현체의 supportsParameter 판정 방식 알아보기
대표적으로 TempDirectory, TestInfoParameterResolver, TestReporterParameterResolver, ParameterizedTestParameterResolver의 원리를 알아보겠습니다.
TempDirectory
TempDirectory의 내부 코드는 아래와 같습니다.
// Determine if the Parameter in the supplied ParameterContext is annotated with @TempDir.
@Override
public boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext) {
boolean annotated = parameterContext.isAnnotated(TempDir.class);
if (annotated && parameterContext.getDeclaringExecutable() instanceof Constructor) {
throw new ParameterResolutionException(
"@TempDir is not supported on constructor parameters. Please use field injection instead.");
}
return annotated;
}
우리는 void 한글과_알파벳_숫자를_제외한_모든_문자는_안된다(final String value)와 같이 파라미터에 단순 String을 넣었었습니다. @TempDir이 달려있지 않기에, 위의 supportsParameter에서는 false가 반환됩니다.
여담으로, TempDir을 아래처럼 인자에 넣을 수도 있습니다.
그리고 getDeclaringExecutable을 보면 메서드인지 생성자인지를 구별하는 데 사용됨을 알 수 있습니다.
TestInfoParameterResolver
TestInfoParameterResolver는 다음과 같습니다.
parameterContext.getParameter는 Parameter를 가져오며, 이것의 getType 메서드를 보면 다음과 같습니다.
tmp에 String이 들어간 것이 보이시나요? 이는 우리가 인자에 String을 넣어놨기 때문입니다.
즉, String은 TestInfo.class가 아니기 때문에 이 또한 false가 됩니다.
TestReporterParameterResolver
TestReporterParameterResolver가 안 되는 이유도 비슷합니다. TestReporter.class가 아니기 때문에 false로 됩니다.
ParameterizedTestParameterResolver, RepetitionInfoParameterResolver, SoftAssertionsExtension, TypeBasedParameterResolver은 디버그 브레이크 포인트를 걸어도 호출되지 않았습니다. 아마 @Test일 때는 호출되지 않는 것 같은데, 이 부분은 더 알아봐야 할 듯합니다.
지금까지 @Test를 붙인 뒤 인자에 String 등 일반적인 타입을 작성해 두면 matchingResolvers가 비워진 상태라 예외가 발생한 것임을 알 수 있었습니다. 그렇다면 마지막으로 ParameterizedTestParameterResolver는 어떤지 보겠습니다.
ParameterizedTestParameterResolver
declaringExecutable, testMethod, parameterIndex는 다음과 같이 저장됩니다.
보는 바와 같이, declaringExecutable과 testMethod가 같으므로 통과되며, 그 이후의 조건들도 문제없이 통과됩니다. (Aggrregator에 대해서는 @ParameterizedTest의 설명에 기록되어 있긴 하지만, 정확히 무엇인지 더 알아봐야 할 듯합니다. 관련 클래스는 ParameterizedTestMethodContext입니다.)
@ParameterizedTest를 올바르게 사용하였을 경우, 마지막의 return 문에 도달하여 true를 반환합니다.
결론
개인적인 추측이었지만, 지금까지의 시도로 미루어봤을 때는 테스트 코드 실행 시 작성된 각각의 파라미터의 종류에 따라 지원 가능한 ParameterResolver들이 나오고, 아예 가능한 게 없다면 예외가 터진다는 점을 알아냈습니다.
supportsParameter의 인자에서 ParameterContext (파라미터 정보)와 ExtensionContext (테스트 실행 중 사용 가능한 확장 콘텍스트 정보)가 쓰인다는 점에서, 각종 조합에 따른 답이 있는 듯합니다.
그리고, @Test와 @ParameterizedTest임에 따라서 String 인자를 붙여도 되고, 붙이지 않아도 되는 게 나뉘기 때문에 자연스럽게 @Test와 @ParameterizedTest를 혼용하면 안 된다는 점도 알게 되었습니다. (@Test일 때는 String 인자를 붙이면 안 되지만, @ParameterizedTest에서는 붙여도 되죠!)
스트림과 제네릭, Aggregator 등 아직도 배울 게 많다는 것을 새삼 느꼈고, 작게나마 JUnit5의 이러한 내부 원리를 파악하게 된 것 같아 유익한 경험이었습니다.
오류를 해결하기 위해 기록하다 보니 틀린 정보들이 있을 수 있습니다. 고칠 점이 있다면 바로 수정하도록 하겠습니다!