"같은 로직을 값만 바꿔 여러 번 돌린다"는 점만 보면 @ParameterizedTest는 for문과 비슷해 보인다. 그런데 실패가 났을 때 완전히 다르게 행동한다. 이 차이(실패 격리)와, 그걸 가능하게 하는 ArgumentsProvider SPI, 그리고 enum 상태 전이 검증에 이걸 어떻게 응용할지를 정리했다.
Intro
@ValueSource,@CsvSource,@MethodSource,@EnumSource는 전부ArgumentsProviderSPI를 트리거하는 메타 애노테이션이다.@ParameterizedTest가 데이터 N개를 받으면 JUnit은 이를 N개의 독립 invocation으로 취급한다.for문은 첫 실패에서 즉시 중단돼 뒤 케이스가 검증 안 되지만,@ParameterizedTest는 3번째가 깨져도 4, 5번째를 계속 돌리고 개별로 리포트한다. 이게 핵심 차이다.- 복잡한 타입(
LocalDateTime조합)은@CsvSource문자열 파싱 대신@MethodSource로 타입 안전하게 만든다. - enum의 상태 전이처럼 "전수 불변식"을 검증할 땐
@EnumSource가 강력하다.
겉보기엔 for문과 비슷하다
@ParameterizedTest는 "같은 테스트 로직을 서로 다른 입력값으로 반복 실행"하는 JUnit5 기능이다. for문으로 케이스를 도는 것과 비슷해 보이지만, 동작 방식과 실패 보고 방식이 근본적으로 다르다. 그 "근본적으로 다른" 지점이 이 글의 전부라고 해도 된다.
ArgumentsProvider SPI
JUnit5 공식 User Guide는 파라미터화 테스트의 핵심을 ArgumentsProvider라는 SPI(Service Provider Interface)로 설명한다. @ValueSource, @CsvSource, @MethodSource, @EnumSource, @CsvFileSource는 전부 이 ArgumentsProvider 구현체를 트리거하는 메타 애노테이션이다.
@ValueSource:int/long/double/String/Class등 리터럴 배열을 그대로 인자로 공급. 가장 단순.@EnumSource: 지정한 enum의 상수들을 인자로 공급. 이름 패턴이나 포함/제외 규칙으로 필터링 가능.@MethodSource:Stream/Collection/Iterable을 리턴하는 팩토리 메서드를 참조. 여러 인자 조합이 필요하면Arguments.of(...)로 감싼다.@CsvSource: 애노테이션 안에 콤마로 구분된 문자열을 직접 나열. 외부 파일 없이 데이터가 코드에 바로 보인다.@CsvFileSource:@CsvSource와 같되 데이터를 외부 CSV에서 읽음. 대량 데이터에 적합.
인자 변환은 세 단계다. 암묵적 확장 변환(int → long), 암묵적 내장 변환기(문자열 → UUID/File/LocalDate 등 자동 변환), 명시적 변환(@ConvertWith로 커스텀 컨버터). JUnit은 이 순서로 변환을 시도해 실제 파라미터 타입에 맞춘다.
참고: https://docs.junit.org/5.10.3/user-guide/
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
docs.junit.org
핵심 차이: 실패 격리
@ParameterizedTest가 데이터 N개를 받으면 JUnit5 엔진은 이를 N개의 독립된 invocation으로 취급한다. 각 invocation은 자신만의 성공/실패 결과를 갖는다.
반면 한 @Test 안에서 for문을 돌며 여러 값을 검증하면, AssertJ의 assertThat이 첫 실패에서 즉시 예외를 던지고 메서드가 중단된다. 그 뒤에 남은 케이스들은 검증조차 안 된 채 테스트가 끝나버린다. 3번째 데이터가 깨지면 4, 5번째는 통과하는지 실패하는지 알 수 없다.
@ParameterizedTest는 이 문제가 없다. 3번째에서 실패해도 4, 5번째는 별도로 계속 실행되고 리포트에 개별로 표시된다. "몇 번째 입력에서 몇 개가 깨졌는가"를 정확히 알려준다. 이게 문법 설탕이 아니라 도구를 바꿀 이유다.
실제 사용 예
@CsvSource: 데이터가 코드에 바로 보인다
@ParameterizedTest
@CsvSource({
"00:00:22, 22",
"1:30:23, 5423",
"00:00:00, 0",
"23:59:59, 86399"
})
void 시간_문자열을_초로_변환한다(String timeString, long expectedSeconds) {
long result = DateTimeUtil.timeStringToSeconds(timeString, false);
assertThat(result).isEqualTo(expectedSeconds);
}
@MethodSource: 복잡한 타입은 타입 안전하게
@ParameterizedTest
@MethodSource("outOfRangeDateCases")
void 시작일과_종료일의_날짜_비교로_범위_이탈_여부를_판단한다(
LocalDateTime start, LocalDateTime end, boolean expected) {
boolean result = DateTimeUtil.isOutOfRangeDate(start, end);
assertThat(result).isEqualTo(expected);
}
static Stream<Arguments> outOfRangeDateCases() {
return Stream.of(
Arguments.of(LocalDateTime.of(2026,1,1,0,0), LocalDateTime.of(2026,1,2,0,0), false), // 정상 범위
Arguments.of(LocalDateTime.of(2026,1,1,0,0), LocalDateTime.of(2026,1,1,23,59), true), // 같은 날짜(경계값)
Arguments.of(LocalDateTime.of(2026,1,2,0,0), LocalDateTime.of(2026,1,1,0,0), true), // 시작 > 종료
Arguments.of(LocalDateTime.of(2026,1,1,23,59),LocalDateTime.of(2026,1,2,0,0), false) // 자정 경계(경계값)
);
}
@MethodSource의 문자열은 JUnit이 리플렉션으로 해당 정적 메서드를 찾아 호출하는 참조 문법이다. 같은 클래스면 메서드 이름만 써도 되고, 다른 클래스면 "FQCN#methodName"으로 명시한다.
@NullAndEmptySource: 경계값을 코드 한 줄로
@ParameterizedTest
@NullAndEmptySource
void null_빈_문자열_허용이면_0을_반환한다(String timeString) {
long result = DateTimeUtil.timeStringToSeconds(timeString, true);
assertThat(result).isZero();
}
@NullAndEmptySource는 @NullSource + @EmptySource를 합친 편의 메타 애노테이션으로 null과 "" 두 경계값을 자동 공급한다. "경계값 3종 필수" 원칙을 코드 한 줄로 구현하는 예다.
응용: enum 상태 전이 검증
우리 도메인엔 BackupProgressManagedStateEnum.canTransitionTo(target), RecoveryProgressStateEnum.canTransit(state)처럼 내부에 Map<Enum, Set<Enum>> transitionMap을 두고 "이 상태에서 저 상태로 갈 수 있는가"를 판별하는 enum들이 있다. 조사 시점 기준 이런 상태 전이 전용 테스트가 없었고, @EnumSource + @MethodSource 조합이 딱 맞는 후보다.
// 신규 작성 시 참고용 스케치 (실제 파일 아님)
class BackupProgressManagedStateEnumTest {
@ParameterizedTest
@MethodSource("legalTransitions")
void 정의된_전이는_true를_반환한다(
BackupProgressManagedStateEnum from, BackupProgressManagedStateEnum to) {
assertThat(from.canTransitionTo(to)).isTrue();
}
@ParameterizedTest
@EnumSource(BackupProgressManagedStateEnum.class)
void 종단_상태로는_전이할_수_없다(BackupProgressManagedStateEnum from) {
// 종단 상태를 target으로 넣으면 어느 상태에서도 false여야 한다
assertThat(from.canTransitionTo(BackupProgressManagedStateEnum.DELETION)).isFalse();
}
static Stream<Arguments> legalTransitions() {
return Stream.of(
Arguments.of(SUBMISSION_REQUEST, REQUEST_REGISTRATION),
Arguments.of(REQUEST_REGISTRATION, TASK_IN_PROGRESS),
Arguments.of(TASK_IN_PROGRESS, TASK_COMPLETION)
// ... transitionMap에 정의된 나머지 조합
);
}
}
두 방식은 상호 보완적이다. @EnumSource는 "이 enum의 모든 값에 대해 어떤 불변식이 성립하는가"(예: 정의되지 않은 전이는 전부 false)를 전수 검사할 때 강력하고, @MethodSource는 "이 특정 조합들은 반드시 true여야 한다"처럼 transitionMap에 명시된 값을 하드코딩해 회귀를 잡을 때 유용하다. 둘을 같이 쓰면 "정의된 것은 되고, 정의 안 된 나머지 전부는 안 된다"를 양쪽에서 조인다.
Mermaid로 보는 실행 흐름

흔한 함정
- "for문의 문법 설탕일 뿐"이라는 오해. 결정적 차이는 실패 격리다.
for문은 3번째가 깨지면 4, 5번째는 검증조차 안 된다.@ParameterizedTest는 각 케이스가 독립 invocation이라 전부 돌리고 몇 개가 실패했는지 정확히 보고한다. @MethodSource문자열 참조 오타."ClassName#methodName"은 컴파일 타임에 체크되지 않는 문자열이라, 메서드명을 리팩토링하면 참조가 깨져도 컴파일러가 못 잡는다(런타임PreconditionViolationException으로 드러남).- 암묵적 타입 변환 과신.
@CsvSource에서LocalDateTime같은 복잡한 타입은 기본 변환기가 지원 안 할 수 있다. 위 예제가LocalDateTime조합에@CsvSource대신@MethodSource를 쓴 것도 이 때문이다. 문자열 파싱에 의존하지 않고 타입 안전하게 객체를 만들 수 있어서다.
언제 쓰고 언제 안 쓰나
- 입력이 다르고 로직/단언 구조는 동일한 케이스가 3개 이상:
@ParameterizedTest로 통합. - enum 전체에 같은 불변식을 검증:
@EnumSource. 상태 전이 테스트에 특히 적합. - 케이스가 1~2개거나, 케이스마다 given 세팅(mock 등)이 판이하게 다른 경우: 억지로 파라미터화하지 말고
{조건}_{기대결과}네이밍을 살린 개별@Test가 더 읽기 쉽다. given이 케이스마다 다르면 파라미터화가 오히려 가독성을 해친다.
다음 글에서는 지금까지 조연으로 스쳐 지나간 @ExtendWith, @Nested, ArgumentCaptor, BDDMockito, 그리고 Strict Stubbing을 한 번에 모아 정리한다.
'Testing' 카테고리의 다른 글
| 테스트 코드 뜯어보기 - 조연들을 한자리에 (0) | 2026.07.07 |
|---|---|
| 테스트 코드 뜯어보기 - MockMvc는 서블릿 컨테이너를 흉내 내는가 (0) | 2026.07.05 |
| 테스트 코드 뜯어보기 - @WebMvcTest만 붙였더니 500이 났다 (0) | 2026.07.05 |
| 테스트 코드 뜯어보기 - @Mock, @MockBean, @InjectMocks는 같은 목이 아니다 (0) | 2026.07.05 |
| 테스트 코드 뜯어보기 - LocalDateTime.now()는 왜 테스트를 괴롭히는가 (3) | 2026.07.05 |