private static Stream<Arguments> validCardSumCases() {
return Stream.of(
Arguments.of(new String[]{"1", "2", "3", "J"}, 16),
Arguments.of(new String[]{"J", "Q", "K"}, 30),
);
}
@DisplayName("카드의 합계 계산")
@ParameterizedTest(name = "뽑은 숫자={0}, 예상 결과={1}")
@MethodSource("validCardSumCases")
void calculateSum(String[] cards, int expected) {
int score = Main.calculateSum(cards);
assertThat(score).isEqualTo(expected);
}
내가 작성했던 테스트 코드
@ParameterizedTest를 하고 싶었다.@MethodSource를 통해 테스트 케이스에 사용할 여러 값들을 주입받는 방법이 있다는 것을 알게 되었는데, 타입이 다른 수많은 인자들을 넣어도 알아서 테스트 메서드의 매개변수에 맞는 타입으로 변환되어서 바인딩되는 과정이 궁금했다.먼저 Arguments.of()라는 메소드를 처음 봐서 해당 메소드가 어떤 메소드인지 부터 알아봤다.
static Arguments of(Object... arguments) {
Preconditions.notNull(arguments, "argument array must not be null");
return () -> arguments;
}
Arguments.of() 메소드
내부에서 유효성 검증을 통해 전달된 인자가 null인지 여부만 체크하고 바로 리턴하는 구조였다.
그러면 람다를 통해 반환될 때 람다에서 타입 추론 기능을 통해 Arguments 객체에 Object 배열들을 담고 생성해서 반환한다.
Arguments.of() 메소드 자체만 봐서는 이 메소드가 왜 필요한지, 하는 역할은 뭔지를 알기가 쉽지 않았다.
처음 보는 클래스여서 먼저 공식 문서를 통해 알아가 보았다.
공식 문서 링크 : Arguments (JUnit 5.0.1 API)
@API(status = STABLE, since = "5.7")
public interface Arguments {
Object[] get();
static Arguments of(Object... arguments) {
Preconditions.notNull(arguments, "argument array must not be null");
return () -> arguments;
}
static Arguments arguments(Object... arguments) {
return of(arguments);
}
}
Arguments 인터페이스
Arguments is an abstraction that provides access to an array of objects to be used for invoking a @ParameterizedTest method.
공식 문서 원문, Arguments는 @ParameterizedTest에서 객체들의 배열을 사용하기 위해 제공되는 추상화
이 말은 @ParameterizedTest에서 사용할 파라미터 값들을 전달하기 위해서 Arguments로 모든 객체들을 추상화 시켜야 한다는 의미
코드에서 볼 수 있듯이 Arguments 자체를 생성할 때에는 of()나 arguments() 를 통해 생성할 수 있다.
그리고 Arguments 클래스 자체는 인터페이스로 내부에 단순히 Object[]를 배열로서 감싸고, Object[]를 노출하는 인터페이스 역할만을 한다.
그러면 Object[]를 바로 사용하는 단순한 구조로도 쓸 수 있겠지만, 의도를 명확하게 하고 밑에서 설명할 ArgumentProvider에서 일관되게 처리를 하기 위해서 Wrapper 클래스의 역할을 하는 Arguments를 사용하는 것!
A Stream of such Arguments will typically be provided by an ArgumentsProvider.
공식 문서 원문, 여기서 말하는 건 Arguments가 담긴 Stream은 ArgumentsProvider에 의해서 제공된다는 의미.
좀 더 깊게 파고 들어가면 다음과 같다.
package org.junit.jupiter.params.provider;
@API(status = STABLE, since = "5.7")
public interface ArgumentsProvider {
Stream<? extends Arguments> provideArguments(ExtensionContext context) throws Exception;
}
ArgumentsProvider 인터페이스
여기서 @ParameterizedTest에서 @MethodSource를 통해 인자를 받게 되면 아래의 Provider 구현체가 사용된다.
package org.junit.jupiter.params.provider;
class MethodArgumentsProvider extends AnnotationBasedArgumentsProvider<MethodSource> {
@Override
protected Stream<? extends Arguments> provideArguments(ExtensionContext context, MethodSource methodSource) {}
}
ArgumentsProvider를 구현한 MethodArgumentsProvider

ArgumentsProvider가 동작해서 @MethodSource에 인자로 전달한 메소드 이름을 기준으로findFactoryMethod()를 통해 해당 메소드를 찾고validateFactoryMethod()를 통해 유효성 검증을 하고.invoke())해서 인자들을 Stream으로 정규화해서 반환저렇게 MethodArgumentsProvider에서 최종적으로 Stream으로 감싸서 반환하는 것 같은데, 왜 테스트 코드에서는 또 Stream으로 감싸서 반환해줘야 할까?
❗ 잘못된 부분
ArgumentsProvider내부에서는 그냥 아무렇게나 나열되어 전달된 값을Stream으로 감싸주는 게 아니라,
stream으로 변환 가능한 컨테이너 타입(Stream,List,Iterator, 배열 등)을 풀어서
하나의Stream으로 정규화해@ParameterizedTest에 전달해준다.따라서 컨테이너 타입으로 전달된 객체들을
Stream으로 재조립해주는 것이지,
아무렇게나 나열된 값을Stream으로 묶어주는 것은 아니다.
@Override
protected Stream<? extends Arguments> provideArguments(ExtensionContext context, MethodSource methodSource) {
Class<?> testClass = context.getRequiredTestClass();
Method testMethod = context.getRequiredTestMethod();
Object testInstance = context.getTestInstance().orElse(null);
String[] methodNames = methodSource.value();
return stream(methodNames)
.map(factoryMethodName -> findFactoryMethod(testClass, testMethod, factoryMethodName))
.map(factoryMethod -> validateFactoryMethod(factoryMethod, testInstance))
.map(factoryMethod -> context.getExecutableInvoker().invoke(factoryMethod, testInstance))
.flatMap(CollectionUtils::toStream)
.map(ArgumentsUtils::toArguments);
}
MethodArgumentsProvider의 provideArguments 메서드
MethodArgumentsProvider 내부 메서드에서 마지막 즈음에 Stream으로 정규화 하는 과정에서 .flatMap()을 사용해 전달된 요소들을 한번 펼쳐주는 것을 확인할 수 있다.
이 과정이 Iterable한 객체 요소 내부에 들어있는 값을 전부 꺼내서 Stream에 담아서 반환해주는것!!!!
그래서 List, 배열, Stream 등등을 전부 사용할 수 있지만 어차피 MethodArgumentsProvider 내부 로직에서 Stream으로 바꿔서 반환해주게 되기 때문에 많은 사람들이 보통은 일관성을 위해 Stream을 사용한다.
private static List<Arguments> validCardSumCases() {
return List.of(
Arguments.of(new String[]{"1", "2", "3", "J"}, 16),
Arguments.of(new String[]{"J", "Q", "K"}, 30),
);
}
List<>로 담은 경우
private static Arguments[] invalidCardCases() {
return new Arguments[]{
Arguments.of((Object) new String[]{"K", "W", "10"}),
Arguments.of((Object) new String[]{"A", "10", "2"}),
Arguments.of((Object) new String[]{"7", "B", "3"})
};
}
배열로 담은 경우
String[] 배열 1개만 들어있는데 굳이 Object로 캐스팅 해준 이유는 마지막에 알아본다.private static Arguments[] validCardSumCases() {
return new Arguments[]{
Arguments.of(new String[]{"1", "2", "3", "J"}, 16),
Arguments.of(new String[]{"J", "Q", "K"}, 30),
};
}
배열 + 다른 값들을 Arguments[]에 담은 경우
이런식으로 List, 배열에 담아도 전부 똑같이 정상 실행된다!!
그 이유가 내부적으로 어차피 다 Stream으로 바꿔서 실행해주기 때문!
위의 과정을 통해서 반환된 여러 테스트 케이스에 사용될 값들이 Stream에 담겨서 오면, @ParameterizedTest가 붙은 메소드의 인자에 맞춰서 자동으로 차례대로 매칭된다.
이렇게 해서 여러 타입의 인자들을 테스트 코드 메서드의 파라미터에 매핑을 시켜서 편리하게 사용할 수 있는 것!!!
그런데 Stream으로 전달된 값들이 어떻게 @ParameterizedTest가 붙은 메소드의 인자에 맞춰서 자동으로 차례대로 매칭되는걸까??
private static Stream<Arguments> validCardSumCases() {
return Stream.of(
Arguments.of(new String[]{"1", "2", "3", "J"}, 16),
Arguments.of(new String[]{"J", "Q", "K"}, 30),
);
}
@DisplayName("카드의 합계 계산")
@ParameterizedTest(name = "뽑은 숫자={0}, 예상 결과={1}")
@MethodSource("validCardSumCases")
void calculateSum(String[] cards, int expected) {
int score = Main.calculateSum(cards);
assertThat(score).isEqualTo(expected);
}
String[] cards 와 int expected에 값이 매칭되는 방법?package org.junit.jupiter.params;
class ParameterizedTestExtension implements TestTemplateInvocationContextProvider {
@Override
public boolean supportsTestTemplate(ExtensionContext context) {...}
@Override
public Stream<TestTemplateInvocationContext> provideTestTemplateInvocationContexts(
ExtensionContext extensionContext) {...}
}
ParameterizedTestExtension 클래스
과정이 너무 복잡해서 내부 로직을 하나하나 뜯어볼 수는 없었지만…
ParameterizedTestExtension 클래스의 두번째 메소드 provideTestTemplateInvocationContexts 메소드에서 그 동작 방식을 살짝이나마 엿볼 수 있었다.

.map(Argument::get)을 통해서 Stream에 담긴 각 Arguments들의 값을 꺼내와서 
Arguments들에 대해서 Invocation Context를 만든다.Invocation Context들이 내부에서 ParameterizedTestParameterResolver가 동작해서 파라미터 순서대로 값을 주입하게 된다.
Invocation Context를 통해서 ParameterizedTestParameterResolver가 들어있는 곳을 찾아가보기
위와 같은 과정들을 통해서 @MethodSource를 사용해서 테스트 코드 메서드의 파라미터에 여러 값들을 바인딩 할 수 있게 되는 것이었다.
이때에는 꼭 Object로 타입 캐스팅이 필요하다.
@DisplayName("예외 경우")
@Nested
class failure {
private static Stream<Arguments> invalidCardCases() {
return Stream.of(
Arguments.of((Object) new String[]{"K", "W", "10"}),
Arguments.of((Object) new String[]{"A", "10", "2"}),
Arguments.of((Object) new String[]{"7", "B", "3"})
);
}
@DisplayName("J/Q/K 제외 알파벳 카드가 있으면 예외 발생")
@ParameterizedTest(name = "잘못된 카드 입력={0}")
@MethodSource("invalidCardCases")
void calculateSum_Exception(String[] cards) {
assertThatThrownBy(() -> Main.calculateSum(cards))
.isInstanceOf(NumberFormatException.class);
}
}
내가 작성했던 테스트 코드
Arguments.of() 로직에서 파라미터가 가변인자(VarArgs)로 설정되어있다.
바로 이 지점에서 컴파일러의 VarArgs 처리 속 문제 발생의 여지가 생긴다.
컴파일러가 가변인자인 Object… 에 파라미터 바인딩을 하려는 과정 속에서 String[]와 같은 1차원 배열 단 1개만 인자로 들어가있는 경우 이를 펼쳐서 Object[]로 바꾸려는 시도를 하게 될 수도 있다.
⇒ 원래 의도 : String[] 자체가 1개의 객체로 Object[] 안에 들어간다.
⇒ 컴파일러의 실제 행동 : String[] 내부의 각 값이 Object[] 내부에 각각의 인덱스에 1개씩 들어갈 수도 있다.
Arguments.of(new String[]{"K", "W", "10"})
Arguments.of((Object) new String[]{"K", "W", "10"})
Object로 캐스팅을 해주지 않으면 컴파일러가 각각의 값을 Object[] 안에 1개씩 인덱스 안에 넣게 되는 수가 있다.Object로 캐스팅을 해주어야 각 값들이 1개의 1차원 배열로 묶여서 제대로 파라미터 바인딩이 이루어진다.내가 처음으로 써본 블로그 글이다.
내부 동작을 직접 타고타고 들어가보면서 어떻게 동작하는지를 알아본게 상당히 뿌듯했다.
어떻게 보면 되게 쉬운 것을 했다고도 볼 수 있다.
하지만 테스트 코드의 중요성을 알게 된 후 테스트 코드 작성을 잘하고 싶은 욕망이 생겼다.
그래서 테스트 코드 작성 시 사용하는 JUnit Jupiter의 각종 어노테이션들 중 제대로된 사용법을 모르는 것들은 그 사용법을 단순히 알기보다는 동작원리부터 알아가보고 싶다는 생각에 지금 처럼 파고 들게 되었다.
내부 동작 원리를 정말 딥하게 하나씩 뜯어봤다고 생각하지는 않지만 그래도 전체 작동 방식에 대한 개념을 잡게 되어 왜 @MethodSource 사용 시에는 Arguments를 써야 하는지, 왜 Stream과 같은 컨테이너에 Arguments를 담아서 전달해주어야 하는지를 알게되었다.