소프트웨어 개발을 하다 보면 테스트의 중요성을 실감하게 된다. TDD와 같은 테스트 주도 개발 방법론까지 나온 걸 보면 그만큼 중요하다는 것을 알 수 있다. 소프트웨어 개발에서 테스트 코드는 애플리케이션이 동작하는데 이상이 없는지를 확인하는 신뢰성을 담보하는 작업인 만큼 중요하다.
Spring Boot는 이러한 테스트 코드를 손쉽게 작성하는 데 있어서 강력한 도구들을 제공한다. JUnit 5, AssertJ, Mockito, TextContext Framework와 같은 도구들 덕분에 복잡한 설정 없이도 애플리케이션의 모든 계층에 대해서 단위 테스트부터 통합 테스트까지 단계적으로 검증할 수도 있다.
이번 포스팅에서는 Spring Boot에서 기본 테스트 엔진으로 사용되는 JUnit5에서 지원하는 각종 어노테이션에 대해서 정리하고자 한다. 샘플 코드는 Spring Boot 3.5.7 버전에서 작성하였다. 어노테이션에 제공되는 속성은 Spring Boot 버전에 따라 다를 수 있다.
Spring Boot 테스트 환경 구성
Spring Boot에서는 spring-boot-starter-test 의존성 하나로 대부분의 테스트 도구를 바로 사용할 수 있다.
dependencies {
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
spring-boot-starter-test 의존성에는 다음과 같은 모듈을 포함시킨다.
- JUnit Jupiter - JUnit5 기반 테스트 API
- Mockito - Mock 객체 생성
- AssertJ - 가독성 높은 단언문 제공 (ex. Assertions.assertEquals(...))
- Spring Test - TestContex Framework 제공
- Hamcrest / JSONassert - 조건 기반 검증 / JSON 비교 지원
JUnit5 구조
Spring Boot 3.x는 JUnit5를 기본 테스트 프레임워크로 채택했다.
다음과 같은 구성 요소를 지닌다.
- JUnit Platform - org.junit.platform - 테스트 런처와 엔진 관리
- JUnit Jupiter - org.junit.jupiter - JUnit5 API (@Test, @BeforEach 등)
- JUnit Vintage - org.junit.vintage - JUnit 3,4 테스트 호환 실행
JUnit Platrform은 테스트를 실행하기 위한 기반 플랫폼이고 Launcher를 통해서 실제로 테스트를 실행하고 결과를 수집한다.
IntelliJ와 같은 IDE환경에서 테스트를 실행할 때는 IDE에 내장된 Launcher를 통해서 테스트 엔진을 실행한다.
JUnit5 기본 어노테이션
Spring Boot 3.x에서 사용하는 JUnit Jupiter 엔진에서 제공하는 기본 어노테이션은 다음과 같다.
| 어노테이션 | 적용 대상 | 실행 시점 | 설명 |
| @Test | 메서드 | 테스트 실행 시 | 단일 테스트 메서드를 표시 |
| @BeforeEach | 메서드 | 각 단위 테스트 실행 전 | 단위 테스트 준비(초기화 등) |
| @AfterEach | 메서드 | 각 단위 테스트 실행 후 | 단위 테스트 종료 후 정리 |
| @BeforeAll | static 메서드 | 테스트 클래스 실행 전 한 번 | 모든 단위 테스트에 적용 |
| @AfterAll | static 메서드 | 테스트 클래스 실행 후 한 번 | 모든 단위 테스트에 적용 |
| @DisplayName | 클래스/메서드 | - | 해당 테스트 이름 지정 |
| @Disabled | 클래스/메서드 | - | 해당 테스트 비활성화 |
| @Tag | 클래스/메서드 | - | 테스트 그룹핑 (필터) |
| @Nested | 내부 클래스 | - | 테스트를 논리적 그룹으로 구성 |
| @RepetedTest | 메서드 | - | 동일테스트 반복 실행 |
| @ParameterizedTest | 메서드 | - | 여러 입력값으로 반복 테스트 |
| @TestInstance | 클래스 | - | 테스트 인스턴스 생성 전략 변경 |
| @TestMethodOrder | 클래스 | - | 테스트 실행 순서 지정 |
| @Order | 메서드 | - | 테스트 실행 순서 지정 |
테스트 실행 전/후 실행되는 어노테이션 적용 순서는 다음과 같다.
@BeforeAll -> [ (@BeforeEach -> @Test -> @AfterEach) 반복 ] -> @AfterAll
@Test
- 리턴 타입은 void 타입이어야 한다.
- 예외가 발생하면 테스트 실패로 간주한다.
@Test
void additionShouldReturnCorrectResult() {
int result = calculator.add(2, 3);
assertEquals(5, result);
}
@BeforeEach / @AfterEach
- 각 단위 테스트 실행 전/후 수행되는 메서드를 지정한다.
- 테스트 간 공통 준비/정리 로직을 분리할 수 있다.
private Calculator calculator;
@BeforeEach
void setUp() {
calculator = new Calculator();
}
@Test
void calculator_test() {
int result = calculator.add(2, 3);
assertEquals(5, result);
}
@AfterEach
void tearDown() {
calculator = null;
}
만약 @Test 어노테이션이 지정된 메서드가 여러개 존재하는 경우 각 테스트 메서드 실행 시마다 @BeforeEach, @AfterEach가 실행된다.
@BeforeAll / @AfterAll
- 클래스 단위로 한 번만 실행되는 메서드를 지정한다.
- 공용 리소스(DB 연결, 외부 API 초기화 등) 초기화에 주로 사용한다.
- 반드시 static 메서드여야 한다.
@BeforeAll
static void initAll() {
System.out.println("테스트 전체 시작 전 한 번 실행");
}
@AfterAll
static void tearDownAll() {
System.out.println("테스트 전체 종료 후 한 번 실행");
}
동일 클래스 내에 @Test 메서드가 여러개 있더라도 @BeforeAll, @AfterAll은 한 번만 실행된다.
@DisplayName
- 테스트 이름을 지정한다.
- 테스트 리포트나 IDE 실행 화면에서 그대로 표시된다.
@DisplayName("2와 3을 더하면 5가 되어야 한다")
@Test
void additionTest() {
assertEquals(5, calculator.add(2, 3));
}
@Disabled
- 해당 테스트 코드를 비활성화할 때 사용한다.
- CI 환경에서 특정 테스트 코드를 잠시 제외할 때 유용하다.
@Disabled("API 서버 점검 중이므로 임시 비활성화")
@Test
void externalApiCallTest() {
// 테스트 실행 안 됨
}
@Tag
- 테스트 그룹화를 위한 메타데이터 태그를 붙인다.
public class SampleTest {
@Tag("test-group1")
@Test
void test1() {
System.out.println("test group 1 Test 1");
}
@Tag("test-group1")
@Test
void test2() {
System.out.println("test group 1 test 2");
}
@Tag("test-group2")
@Test
void test3() {
System.out.println("test group 2 Test 3");
}
@Tag("test-group2")
@Test
void test4() {
System.out.println("test group 2 test 4");
}
}
test-group1, test-group2 두 개의 테스트 그룹을 @Tag로 지정 후 build.gradle에서 다음과 같이 설정한다.
tasks.named('test') {
useJUnitPlatform {
includeTags 'test-group1'
}
}
SampleTest 클래스를 실행시키면 다음과 같은 결과가 출력된다.
test group 1 Test 1
test group 1 test 2
@Tag("test-group1")이 지정된 테스트 코드만 실행됨을 확인할 수 있다. 하지만 includeTags를 사용하는 경우 태그 지정이 없는 다른 테스트 클래스 역시 실행이 안된다. 만약 test-gropu2 태그를 제외한 테스트 코드를 실행하려면 excludeTags를 지정하는 것이 좋다.
@Tag 문법 규칙
@Tag 이름을 지정할 때 정해진 규칙은 다음과 같다.
- 빈 문자열을 사용할 수 없음
- 양쪽 Trim 한 태그에는 공백 문자가 포함될 수 없음
- 양쪽 Trim한 태그에는 ISO 제어 문자를 포함할 수 없음. (ex. \n, \t..)
- ', (, ), &, `, !' 문자는 사용할 수 없음
태그명은 소문자 혹은 케밥 케이스(ex. unit-test, test-group,..) 형태로 작성하는 것이 좋다.
@Nested
@Nested는 JUnit5에서 새롭게 추가된 기능으로 테스트의 구조적 표현을 개선시켜 준다. 단순히 내부 클래스 테스트용이 아닌 테스트 시나리오를 논리적으로 계층화하여 표현하는 수단 역할을 한다.
@Nested는 테스트 클래스 안에 또 다른 테스트 클래스(내부 클래스)를 정의할 때 사용한다.
public class BankAccountTest {
private BankAccount account;
@BeforeEach
void setUp() {
account = new BankAccount();
}
@Nested
@DisplayName("입금 기능")
class DepositTests {
@Test
@DisplayName("정상 입금 시 잔액이 증가해야 한다")
void depositShouldIncreaseBalance() {
account.deposit(1000);
assertEquals(1000, account.getBalance());
}
@Test
@DisplayName("0 이하 금액 입금 시 예외 발생")
void depositShouldFailForNegativeAmount() {
assertThrows(IllegalArgumentException.class,
() -> account.deposit(-500));
}
}
@Nested
@DisplayName("출금 기능")
class WithdrawTests {
@BeforeEach
void initBalance() {
account.deposit(1000);
}
@Test
@DisplayName("정상 출금 시 잔액이 감소해야 한다")
void withdrawShouldDecreaseBalance() {
account.withdraw(300);
assertEquals(700, account.getBalance());
}
@Test
@DisplayName("잔액보다 많은 금액 출금 시 예외 발생")
void withdrawShouldFailForInsufficientFunds() {
assertThrows(IllegalStateException.class,
() -> account.withdraw(2000));
}
}
public static class BankAccount {
private int balance;
int getBalance() {
return balance;
}
void deposit(int amount) {
if (amount < 0) {
throw new IllegalArgumentException("Amount cannot be negative");
}
balance += amount;
}
void withdraw(int amount) {
if (amount > balance) {
throw new IllegalStateException("Insufficient funds");
}
balance -= amount;
}
}
}
BankAccountTest 클래스를 실행하면 결과는 다음과 같이 출력된다.

위 샘플은 BankAccount 클래스 테스트에 대해서 입금과 출력으로 분리하여 구조화된 테스트 결과를 얻을 수 있다.
@Nested 클래스 제약 조건
- non-static - 내부 클래스는 반드시 인스턴스 클래스여야 한다.
- 단일 인스턴스 - 각 내부 클래스 테스트 실행 시 외부 클래스 인스턴스가 재사용된다.
- DisplayName 지원 - @DisplayName을 활용해 계층적 구조를 명확히 표현 가능하다.
@BeforeEach 중첩 규칙
@Nested 클래스에서는 상위 클래스의 @BeforeEach도 함께 실행된다.
상위클래스 @BeforeEach -> Nested 클래스 @BeforeEach -> @Test 실행
@RepeatedTest
- 동일한 테스트를 여러 번 반복할 때 사용한다.
- 테스트 결과가 가변적일 때 유용하다.
@RepeatedTest(3)
void repeatedTestExample() {
assertTrue(random.nextInt(10) >= 0);
}
@ParameterizedTest
여러 입력 값으로 동일한 테스트를 반복 실행한다.
ParameterizedTest를 위해서 제공되는 어노테이션은 다음과 같다.
- @ValueSource - 단일 값 리스트
- @EnumSource - Enum 상수 집합
- @FieldSource - static 필드 값 리스트
- @CsvSource - 지정된 구분자를 사용하는 CSV값
- @CsvFileSource - Csv 파일로부터 입력
- @MethodSource - Java 메서드에서 Stream 제공
- @ArgumentsSource - 사용자 정의 Provider 사용
위 제공되는 어노테이션 리스트를 지정할 수 있도록 @XXXXSources를 제공한다.
@ValueSource
단일 값 리스트를 주입한다. 문자열, 숫자, boolean, char 등의 단일 타입만 지원한다.
@ParameterizedTest
@ValueSource(strings = {"apple", "banana", "cherry"})
void testFruits(String fruit) {
assertNotNull(fruit);
}
apple, banana.cherry 값이 testFruits의 파라미터로 전달되어 테스트를 실행한다.
@EnumSource
Enum 타입의 값을 인자로 전달할 때 유용하다.
enum Day { MON, TUE, WED, THU, FRI, SAT, SUN }
@ParameterizedTest
@EnumSource(Day.class)
void testAllDays(Day day) {
assertNotNull(day);
}
다음과 같이 필터링이 가능하다.
@ParameterizedTest
@EnumSource(value = Day.class, names = {"MON", "FRI"})
void testSelectedDays(Day day) { ... }
@ParameterizedTest
@EnumSource(value = Day.class, mode = EnumSource.Mode.EXCLUDE, names = {"SAT", "SUN"})
void testWeekdaysOnly(Day day) { ... }
mode 속성은 디폴트로 INCLUDE이다. EXCLUDE 모드인 경우 names에 지정된 값을 제외한 값이 파라미터로 전달된다.
@ParameterizedTest
@EnumSource(value = Day.class, mode = EnumSource.Mode.MATCH_ALL, names = {"S.*"})
void testMatchAllDays(Day day) {
assertNotNull(day);
}
mode가 MATCH_ALL인 경우 names에 지정된 정규표현식을 모두 만족하는 enum 값을 전달한다.
반대로 MATCH_ANY는 names에 지정된 정규표현식을 하나라도 만족하는 enum 값을 전달한다.
@CsvSource
CSV 형식의 데이터를 파싱 하여 파라미터로 주입한다. 구분자는 디폴트로 ', '를 사용한다.
@ParameterizedTest
@CsvSource(value = {
"apple, 5",
"banana, 7",
"cherry, 10"
})
void testWithCsvSource(String fruit, int length) {
assertEquals(length, fruit.length());
}
테스트 결과
[1] fruit=apple, length=5
[2] fruit=banana, length=6
[3] fruit=cherry, length=6
@CsvSource 어노테이션은 다음 속성을 지원한다.
- value - 가장 기본적인 속성으로 CSV 형식의 테스트 데이터를 문자열 배열로 제공한다.
- delimiter (char) - CSV 값을 구분하는 구분자로 지정한다. 디폴트로 ',' 문자를 사용한다.
- delimiterString (String) - 문자가 아닌 문자열을 구분자로 사용한다. delimiter과 함께 사용할 수 없다.
- maxCharsPerColumn (int) - CSV 값의 최대 길이를 지정한다. 디폴트는 4096.
- emptyValue (String) - quoted empty 문자열을 대체할 값을 지정한다. CSV에서 빈 값을 지정된 문자열로 변환한다.
maxCharsPerColumn
@ParameterizedTest
@CsvSource(value = {
"John, '', Doe", // 중간 값이 빈 문자열
"Jane, EMPTY, Smith"
}, emptyValue = "EMPTY")
void testName(String first, String middle, String last) {
// 첫번쨰 record의 middle 파라미터는 "EMPTY"로 전달됨
}
결과
[1] first=John, middle=EMPTY, last=Doe
[2] first=Jane, middle=EMPTY, last=Smith
- nullValues (String[]) - 지정된 문자열을 null로 처리한다. "N/A", "NIL"과 같은 값을 null로 처리할 때 유용하다.
@ParameterizedTest
@CsvSource(value = {
"product1, 100, NULL",
"product2, 200, NIL",
"product3, 300, valid-description"
}, nullValues = {"NULL", "NIL"})
void testProduct(String name, int price, String description) {
// NULL과 NIL은 null로 변환됨
}
결과
[1] name=product1, price=100, description=null
[2] name=product2, price=200, description=null
[3] name=product3, price=300, description=valid-description
- quotedCharacter (char) - CSV값을 감싸는 따옴표 문자를 지정한다. 기본값은 작은따옴표 <'>이다.
@ParameterizedTest
@CsvSource(value = {
"'Hello, World', 'contains, comma'",
"\"Another, test\", \"with, commas\""
}, quoteCharacter = '"')
void testQuotedStrings(String first, String second) {
// 큰따옴표로 감싸진 문자열 내의 쉼표는 구분자로 처리되지 않음
}
결과
[1] first='Hello, second=World'
[2] first=Another, test, second=with, commas
- ignoreLeadingAndTrailingWhitespace (boolean) - CSV 값의 앞뒤 공백을 trim 여부를 지정한다. 기본값은 true다.
@ParameterizedTest
@CsvSource(value = {
" trim , spaces ", // 공백이 제거됨
"keep,clean"
}, ignoreLeadingAndTrailingWhitespace = true)
void testTrimming(String first, String second) {
System.out.println( first + " " + second);
}
결과
[1] first=trim, second=spaces
[2] first=keep, second=clean
- textBlock (String) - Java15 이상에서 사용 가능한 텍스트 블록을 이용하여 여러 줄의 CSV 데이터를 지정한다.
@ParameterizedTest
@CsvSource(textBlock = """
apple, 5, 1.99
banana, 3, 0.99
orange, 7, 2.49
""", ignoreLeadingAndTrailingWhitespace = true)
void testFruitPrices(String fruit, int quantity, double price) {
// 텍스트 블록을 사용한 가독성 높은 테스트 데이터
}
결과
[1] fruit=apple, quantity=5, price=1.99
[2] fruit=banana, quantity=3, price=0.99
[3] fruit=orange, quantity=7, price=2.49
ignoreLeadingAndTrailingWhitespace를 false로 지정한 경우 quantity, price의 앞 공백이 포함되는 것을 주의해야 한다.
- useHeadersInDisplayName (boolean) - CSV 데이터의 첫 번째 행을 헤더로 간주하고 테스트 표시 이름에 헤더 정보를 포함할지 여부를 지정한다. 디폴트는 false다.
@ParameterizedTest
@CsvSource(
value = {
"SERVICENAME, REPLICAS, PORT, PROFILE",
"user-service, 3, 8080, production",
"order-service, 2, 8081, staging",
"payment-service, 5, 8082, production"
},
useHeadersInDisplayName = true
)
void testServiceConfiguration(String serviceName, int replicas, int port, String profile) {
System.out.println(serviceName + ", " + replicas + ", " + port + ", " + profile);
}
결과
[1] serviceName=SERVICENAME = user-service, replicas=REPLICAS = 3, port=PORT = 8080, profile=PROFILE = production
[2] serviceName=SERVICENAME = order-service, replicas=REPLICAS = 2, port=PORT = 8081, profile=PROFILE = staging
[3] serviceName=SERVICENAME = payment-service, replicas=REPLICAS = 5, port=PORT = 8082, profile=PROFILE = production
테스트 결과 창에 파라미터 값에 헤더명도 함께 표시가 된다.
@CsvFileSource
외부 CVS파일로부터 데이터를 제공하고자 할 때 사용한다. test/resources 혹은 지정된 파일 경로에 있는 CSV 파일을 읽어 온다.
src/test/resources/fruits.csv 파일 내용은 다음과 같다.
fruit,length
apple,5
banana,6
cherry,6
@ParameterizedTest
@CsvFileSource(resources = "/fruits.csv", numLinesToSkip = 1)
void testWithCsvFile(String fruit, int length) {
assertEquals(length, fruit.length());
}
결과
[1] fruit=apple, length=5
[2] fruit=banana, length=6
[3] fruit=cherry, length=6
@CsvFileSource 어노테이션은 @CsvSource 속성과 추가로 다음 속성을 지원한다.
- resources (String[]) - 테스트 class 경로에 있는 csv 파일을 지정한다.
- files (String[]) - 파일 시스템 상의 경로를 지정한다.
resources 혹은 files 속성은 반드시 지정되어야 한다.
- encoding (String) - 파일을 읽을 때 사용할 charset을 지정한다. 디폴트는 UTF-8이다.
- lineSeperator (String) - csv파일의 라인 구분자를 지정한다. (\r, \n, \r\n) 디폴트는 \n이다.
- numLinesToSkip (int) - CSV 파일을 읽을 때 skip 할 라인수를 지정한다. 일반적으로 헤더 줄을 건너뛰는 데 사용된다.
@MethodSource
메서드를 통해서 테스트 데이터를 제공할 수 있다.
@ParameterizedTest
@MethodSource("provideStringsForIsBlank")
void testIsBlank(String input, boolean expected) {
assertEquals(expected, input == null || input.trim().isEmpty());
}
static Stream<Arguments> provideStringsForIsBlank() {
return Stream.of(
Arguments.of(null, true),
Arguments.of("", true),
Arguments.of(" ", true),
Arguments.of("word", false)
);
}
Arguments는 Object[]을 리턴하는 Supplier 함수형 인터페이스다. Arguments에 있는 Object[]들을 순서대로 파라미터로 전달한다.
결과
[1] input=null, expected=true
[2] input=, expected=true
[3] input= , expected=true
[4] input=word, expected=false
Arguments는 Object 타입을 받으므로 primitive 타입 외에도 사용자 정의 클래스를 전달할 수도 있다.
@ParameterizedTest
@MethodSource("provideTestData")
void testWithMethodSource(TestData testData) {
System.out.println( testData );
}
static Stream<Arguments> provideTestData() {
return Stream.of(
Arguments.of(new TestData("kim", 5)),
Arguments.of(new TestData("park", 6))
);
}
static class TestData {
private String name;
@Override
public String toString() {
return "TestData [name=" + name + ", age=" + age + "]";
}
private int age;
public TestData(String name, int age) {
this.name = name;
this.age = age;
}
public String getName() {
return name;
}
public int getAge() {
return age;
}
}
결과
[1] testData=TestData [name=kim, age=5]
[2] testData=TestData [name=park, age=6]
@ArgumentsSource
직접 커스텀 데이터 공급자를 구현할 때 사용한다. ArgumentsProvier 인터페이스를 구현해야 한다.
static class CustomArgumentsProvider implements ArgumentsProvider {
@Override
public Stream<? extends Arguments> provideArguments( ExtensionContext context) {
return Stream.of("A", "B", "C").map(Arguments::of);
}
}
@ParameterizedTest
@ArgumentsSource(CustomArgumentsProvider.class)
void testWithCustomSource(String value) {
assertTrue(value.matches("[A-Z]"));
}
결과
[1] value=A
[2] value=B
[3] value=C
마찬가지로 사용자 정의 타입을 전달할 수 있다.
static class CustomArgumentsProvider implements ArgumentsProvider {
@Override
public Stream<? extends Arguments> provideArguments( ExtensionContext context) {
return Stream.of(
new TestData( "name1", 1 ),
new TestData( "name2", 2 ),
new TestData( "name3", 3 ))
.map(Arguments::of);
}
}
@ParameterizedTest
@ArgumentsSource(CustomArgumentsProvider.class)
void testWithCustomSource(TestData value) {
System.out.println( value );
}
결과
[1] value=TestData [name=name1, age=1]
[2] value=TestData [name=name2, age=2]
[3] value=TestData [name=name3, age=3]
@FieldSource
테스트 클래스의 정적(static) 필드로부터 데이터를 가져오는 데 사용한다.
단순히 상수 배열이나 컬렉션 필드만 정의하면 바로 사용할 수 있다.
static String[] FRUITS = {"apple", "banana", "cherry"};
@ParameterizedTest
@FieldSource("FRUITS")
void testWithFieldSource(String fruit) {
assertTrue(fruit.length() > 0);
}
결과
[1] fruit=apple
[2] fruit=banana
[3] fruit=cherry
끝.
'스프링부트' 카테고리의 다른 글
| Spring Boot 테스트 - MockMvc 알아보기 (0) | 2025.11.22 |
|---|---|
| Spring Boot 3의 선언형 HTTP 클라이언트 - HTTP Interface란? (0) | 2025.10.22 |
| Spring Boot RestClient (0) | 2025.10.15 |
| Spring Boot Actuator - 6. actuator + prometheus + grafana (0) | 2025.10.13 |
| Spring Boot Actuator - 5. 사용자 정의 Endpoint 만들기 (0) | 2025.09.27 |
댓글