JAVA · 심층 가이드
Java 테스트 완전 정리
JUnit 5의 생명주기와 파라미터화부터 Mockito 검증, Testcontainers 통합 테스트와 ArchUnit까지 Java 테스트 코드를 19개 주제로 설계합니다.
테스트를 늘리는 일보다 어려운 건 테스트를 빠르고 안 깨지게 유지하는 일입니다. Java 진영은 이 문제를 계층으로 풀어 왔습니다. 순수 단위 테스트는 밀리초 단위로 돌고, 스프링 컨텍스트를 띄우는 순간 초 단위가 되며, 도커 컨테이너를 올리면 십 초 단위가 됩니다. 처음 배우는 사람이 흔히 놓치는 지점은 모든 테스트를 같은 층에 몰아넣는 것입니다. 무엇을 mock으로 끊고 무엇을 실제로 띄울지 결정하는 감각이, 어떤 어설션 메서드를 쓰는지보다 훨씬 오래 남습니다.
JUnit 5 심화에서 생명주기와 조건부 실행을 먼저 잡고, JUnit 5 파라미터화 테스트로 반복되는 케이스를 데이터로 밀어내는 방법을 익힙니다. @Nested 테스트는 그렇게 늘어난 케이스를 상황별로 묶어 읽히게 만드는 장치입니다. 협력 객체 쪽은 Mockito 심화와 ArgumentCaptor가 짝을 이룹니다. 반환값이 아니라 '무엇을 인자로 넘겼는가'를 검증해야 할 때 캡터가 필요합니다. 실제 의존성이 필요해지면 슬라이스 테스트 (@WebMvcTest)와 Testcontainers로 넘어갑니다.
JUnit 5는 기본적으로 테스트 메서드마다 클래스 인스턴스를 새로 만듭니다. 그래서 필드에 상태를 담아 메서드 간에 공유하려는 시도는 조용히 실패합니다. 공유가 정말 필요하면 @TestInstance(Lifecycle.PER_CLASS)를 명시해야 하고, 그 순간 테스트 간 격리를 스스로 책임져야 합니다. Mockito에서도 비슷한 함정이 있습니다. verify를 빠뜨린 when(...)은 아무것도 검증하지 않으며, 스텁만 잔뜩 쌓인 테스트는 구현을 바꿀 때마다 깨지면서도 버그는 못 잡습니다.
01JUnit 5 & Mockito
JUnit 5의 현대적 테스트 기능과 Mockito로 외부 의존성을 격리합니다.
Java code
import org.junit.jupiter.api.*;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.*;
import org.mockito.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
// 테스트 대상
class UserService {
private final UserRepository repo;
UserService(UserRepository repo) { this.repo = repo; }
User findUser(int id) {
return repo.findById(id)
.orElseThrow(() -> new NotFoundException("User", id));
}
}
// JUnit 5 테스트 클래스
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@Mock UserRepository mockRepo;
@InjectMocks UserService userService;
@BeforeEach
void setUp() {
System.out.println("각 테스트 전 실행");
}
@Test
@DisplayName("존재하는 사용자 조회 성공")
void findUser_whenExists_returnsUser() {
var expected = new User(1, "Alice");
when(mockRepo.findById(1)).thenReturn(Optional.of(expected));
var result = userService.findUser(1);
assertThat(result.name()).isEqualTo("Alice");
verify(mockRepo, times(1)).findById(1);
}
@Test
@DisplayName("없는 사용자 → NotFoundException")
void findUser_whenNotExists_throws() {
when(mockRepo.findById(99)).thenReturn(Optional.empty());
assertThatThrownBy(() -> userService.findUser(99))
.isInstanceOf(NotFoundException.class)
.hasMessageContaining("User");
}
// 파라미터화 테스트
@ParameterizedTest(name = "{0} → {1}")
@CsvSource({
"90, A",
"80, B",
"70, C",
"60, D",
"50, F",
})
void gradeTest(int score, String expected) {
assertThat(Calculator.grade(score)).isEqualTo(expected);
}
@Nested
@DisplayName("경계값 테스트")
class BoundaryTests {
@Test void minScore() { assertThat(Calculator.grade(0)).isEqualTo("F"); }
@Test void maxScore() { assertThat(Calculator.grade(100)).isEqualTo("A"); }
}
}AssertJ의 assertThat()은 JUnit의 assertEquals()보다 훨씬 읽기 쉬운 실패 메시지를 제공합니다. 현대 Java 프로젝트에서 표준입니다.
@ExtendWith(MockitoExtension.class) 없이 @Mock을 사용하면 Mock이 null로 남습니다. JUnit 5에서는 반드시 Extension을 추가하세요.
02TestContainers & 통합 테스트
Docker 컨테이너로 실제 DB/서비스를 띄워 통합 테스트를 작성합니다.
Java code
import org.junit.jupiter.api.*;
import org.testcontainers.containers.*;
import org.testcontainers.junit.jupiter.*;
import org.testcontainers.utility.DockerImageName;
import java.sql.*;
// TestContainers — 실제 PostgreSQL 컨테이너
@Testcontainers
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class UserRepositoryIT {
@Container
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>(DockerImageName.parse("postgres:15"))
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
Connection connection;
@BeforeAll
void setup() throws SQLException {
connection = DriverManager.getConnection(
postgres.getJdbcUrl(),
postgres.getUsername(),
postgres.getPassword());
// 스키마 생성
connection.createStatement().execute("""
CREATE TABLE users (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
email VARCHAR(255) UNIQUE
)
""");
}
@Test
@DisplayName("사용자 저장 & 조회")
void saveAndFind() throws SQLException {
// Given
var insert = connection.prepareStatement(
"INSERT INTO users (name, email) VALUES (?, ?)");
insert.setString(1, "Alice");
insert.setString(2, "alice@test.com");
insert.executeUpdate();
// When
var select = connection.prepareStatement(
"SELECT * FROM users WHERE email = ?");
select.setString(1, "alice@test.com");
var rs = select.executeQuery();
// Then
assertTrue(rs.next());
assertEquals("Alice", rs.getString("name"));
}
@AfterAll
void teardown() throws SQLException {
if (connection != null) connection.close();
}
}
// Redis 컨테이너 예시:
// @Container
// static GenericContainer<?> redis =
// new GenericContainer<>("redis:7")
// .withExposedPorts(6379);TestContainers는 @Container가 붙은 컨테이너를 테스트 시작 전 자동으로 시작하고 종료 시 정리합니다. Docker 데몬이 실행 중이어야 합니다.
@Container를 static으로 선언하면 클래스 내 모든 테스트가 같은 컨테이너를 공유합니다. 각 테스트가 독립적인 컨테이너가 필요하면 인스턴스 필드로 선언하세요.
03Switch Pattern Matching 가드
when 조건 가드로 복잡한 패턴 분기
Java code
<span class="cm">// Switch Pattern Matching 가드 예제
// data/prompts.js의 생성 프롬프트로 상세 코드 생성 가능</span>
fun main() { println("Switch Pattern Matching 가드") }JAVA 공식 문서를 함께 참고하세요.
자주 발생하는 실수에 주의하세요.
04JUnit 5 파라미터화 테스트
JUnit 5의 @ParameterizedTest로 하나의 테스트 메서드를 다양한 입력 데이터로 반복 실행합니다. CSV, Enum, Method 소스 등 다양한 데이터 공급 방식을 지원합니다.
Java code
import org.junit.jupiter.api.*;
import org.junit.jupiter.params.*;
import org.junit.jupiter.params.provider.*;
import static org.junit.jupiter.api.Assertions.*;
import java.util.stream.Stream;
class CalculatorTest {
// 1. @ValueSource — 단일 값 배열
@ParameterizedTest(name = "{0}은 양수")
@ValueSource(ints = {1, 5, 100, Integer.MAX_VALUE})
void isPositive(int number) {
assertTrue(number > 0);
}
// 2. @CsvSource — CSV 형태로 여러 파라미터
@ParameterizedTest(name = "{0} + {1} = {2}")
@CsvSource({
"1, 2, 3",
"0, 0, 0",
"-1, 1, 0",
"100, -50, 50"
})
void testAdd(int a, int b, int expected) {
assertEquals(expected, a + b);
}
// 3. @EnumSource — Enum 값 순회
@ParameterizedTest
@EnumSource(value = java.time.Month.class,
names = {"APRIL", "JUNE", "SEPTEMBER", "NOVEMBER"})
void has30Days(java.time.Month month) {
assertEquals(30, month.maxLength());
}
// 4. @MethodSource — 메서드에서 테스트 데이터 제공
@ParameterizedTest(name = "palindrome: {0}")
@MethodSource("palindromeProvider")
void testPalindrome(String input, boolean expected) {
String reversed = new StringBuilder(input).reverse().toString();
assertEquals(expected, input.equals(reversed));
}
static Stream<Arguments> palindromeProvider() {
return Stream.of(
Arguments.of("racecar", true),
Arguments.of("hello", false),
Arguments.of("madam", true),
Arguments.of("", true)
);
}
}name 속성으로 각 테스트 케이스의 표시 이름을 커스터마이징하면 테스트 리포트가 읽기 쉬워집니다. @NullAndEmptySource를 @ValueSource와 함께 사용하면 null, 빈 문자열 테스트를 자동 추가합니다.
@MethodSource의 메서드는 반드시 static이어야 합니다(테스트 클래스가 @TestInstance(PER_CLASS)가 아닌 경우). 메서드 이름을 잘못 지정하면 No tests found 오류가 발생합니다.
05JUnit 5 심화
JUnit 5의 생명주기, 표시 이름, 조건부 실행을 다룹니다.
Java code
// import org.junit.jupiter.api.*;
// import static org.junit.jupiter.api.Assertions.*;
// JUnit 5 사용법 (의사코드 — 테스트 실행 시 의존성 필요)
class CalculatorTest {
// Calculator calc;
// @BeforeAll // 클래스당 1번 (static)
// static void setupAll() { System.out.println("전체 초기화"); }
// @BeforeEach // 각 테스트 전
// void setup() { calc = new Calculator(); }
// @Test
// @DisplayName("덧셈: 양수 + 양수")
// void addPositiveNumbers() {
// assertEquals(5, calc.add(2, 3));
// }
// @Test
// @Disabled("버그 수정 후 활성화")
// void brokenTest() { fail("아직 구현 안 됨"); }
// @RepeatedTest(5)
// @DisplayName("랜덤 테스트")
// void repeatedTest(RepetitionInfo info) {
// System.out.println("반복: " + info.getCurrentRepetition());
// }
// @ParameterizedTest
// @ValueSource(ints = {1, 2, 3, 4, 5})
// void isPositive(int number) {
// assertTrue(number > 0);
// }
// @AfterEach // 각 테스트 후
// void tearDown() { calc = null; }
// @AfterAll // 클래스당 1번 (static)
// static void tearDownAll() { System.out.println("전체 정리"); }
public static void main(String[] args) {
System.out.println("JUnit 5 = Jupiter API + Platform + Vintage(JUnit 4 호환)");
}
}@ParameterizedTest로 같은 로직을 여러 입력값에 대해 테스트하면 코드 중복을 줄이고 커버리지를 높입니다.
@BeforeAll과 @AfterAll 메서드는 기본적으로 static이어야 합니다. @TestInstance(Lifecycle.PER_CLASS)를 사용하면 비정적으로 만들 수 있습니다.
06Assertion 심화
JUnit 5의 다양한 어설션 메서드와 활용법입니다.
Java code
// import org.junit.jupiter.api.*;
// import static org.junit.jupiter.api.Assertions.*;
// Assertion 패턴 (의사코드)
class AssertionAdvanced {
// 기본 어설션
// assertEquals(expected, actual, "메시지");
// assertNotEquals(a, b);
// assertTrue(condition);
// assertFalse(condition);
// assertNull(obj);
// assertNotNull(obj);
// 예외 어설션
// assertThrows(IllegalArgumentException.class, () -> {
// new User("", "invalid");
// });
// Exception ex = assertThrows(RuntimeException.class, () -> divide(1, 0));
// assertEquals("0으로 나눌 수 없음", ex.getMessage());
// assertDoesNotThrow(() -> new User("valid", "a@b.com"));
// 복합 어설션 (모두 실행 후 결과 수집)
// assertAll("사용자 검증",
// () -> assertEquals("홍길동", user.getName()),
// () -> assertNotNull(user.getEmail()),
// () -> assertTrue(user.getAge() > 0)
// );
// 타임아웃 어설션
// assertTimeout(Duration.ofMillis(100), () -> {
// // 100ms 내에 완료되어야 함
// Thread.sleep(50);
// });
// assertTimeoutPreemptively(Duration.ofMillis(100), () -> {
// // 100ms 초과 시 즉시 중단 (별도 스레드)
// });
public static void main(String[] args) {
System.out.println("assertAll은 실패해도 나머지를 계속 실행합니다.");
System.out.println("개별 assert는 첫 실패에서 멈춥니다.");
}
}assertAll()은 모든 검증을 실행한 후 실패를 모아서 보고합니다. 객체의 여러 필드를 한 번에 검증할 때 유용합니다.
assertTimeout은 같은 스레드에서 실행하고, assertTimeoutPreemptively는 별도 스레드에서 실행합니다. ThreadLocal을 사용하는 코드에서는 차이가 있습니다.
07@Nested 테스트
중첩 클래스로 테스트를 계층적으로 구조화합니다.
Java code
// import org.junit.jupiter.api.*;
// import static org.junit.jupiter.api.Assertions.*;
// @Nested 테스트 구조 (의사코드)
// @DisplayName("스택 테스트")
// class StackTest {
// Stack<String> stack;
//
// @BeforeEach
// void createStack() { stack = new Stack<>(); }
//
// @Test
// @DisplayName("새 스택은 비어있다")
// void isEmpty() { assertTrue(stack.isEmpty()); }
//
// @Nested
// @DisplayName("요소를 push한 후")
// class AfterPush {
// @BeforeEach
// void pushElement() { stack.push("element"); }
//
// @Test
// @DisplayName("비어있지 않다")
// void isNotEmpty() { assertFalse(stack.isEmpty()); }
//
// @Test
// @DisplayName("peek은 요소를 제거하지 않는다")
// void peekDoesNotRemove() {
// assertEquals("element", stack.peek());
// assertFalse(stack.isEmpty());
// }
//
// @Nested
// @DisplayName("pop한 후")
// class AfterPop {
// @BeforeEach
// void popElement() { stack.pop(); }
//
// @Test
// @DisplayName("다시 비어있다")
// void isEmpty() { assertTrue(stack.isEmpty()); }
// }
// }
// }
class Main {
public static void main(String[] args) {
System.out.println("@Nested는 Given-When-Then 구조를 자연스럽게 표현합니다.");
System.out.println("외부 클래스의 @BeforeEach가 먼저 실행됩니다.");
}
}@Nested는 BDD의 Given-When-Then을 자연스럽게 표현합니다. 각 중첩 레벨이 추가 컨텍스트(Given)가 됩니다.
@Nested 클래스에서는 @BeforeAll/@AfterAll을 사용할 수 없습니다(기본 설정). @TestInstance(PER_CLASS)로 해결 가능합니다.
08@TestFactory와 동적 테스트
런타임에 테스트를 동적으로 생성하는 @TestFactory입니다.
Java code
// import org.junit.jupiter.api.*;
// import java.util.*;
// import java.util.stream.*;
// @TestFactory 동적 테스트 (의사코드)
// class DynamicTestDemo {
//
// @TestFactory
// @DisplayName("동적 테스트 — 팩토리")
// Collection<DynamicTest> dynamicTests() {
// return List.of(
// DynamicTest.dynamicTest("1 + 1 = 2",
// () -> assertEquals(2, 1 + 1)),
// DynamicTest.dynamicTest("2 * 3 = 6",
// () -> assertEquals(6, 2 * 3))
// );
// }
//
// @TestFactory
// @DisplayName("스트림 기반 동적 테스트")
// Stream<DynamicTest> streamTests() {
// return Stream.of("racecar", "level", "kayak")
// .map(word -> DynamicTest.dynamicTest(
// word + "은 회문이다",
// () -> {
// String reversed = new StringBuilder(word).reverse().toString();
// assertEquals(word, reversed);
// }
// ));
// }
//
// @TestFactory
// @DisplayName("중첩 동적 컨테이너")
// Stream<DynamicNode> dynamicContainers() {
// return Stream.of("Math", "String")
// .map(category -> DynamicContainer.dynamicContainer(
// category,
// Stream.of(
// DynamicTest.dynamicTest("테스트1", () -> assertTrue(true)),
// DynamicTest.dynamicTest("테스트2", () -> assertNotNull(category))
// )
// ));
// }
// }
class Main {
public static void main(String[] args) {
System.out.println("@TestFactory는 데이터 기반 테스트에 적합합니다.");
System.out.println("외부 파일, DB, API에서 테스트 케이스를 동적 생성할 수 있습니다.");
}
}@TestFactory는 Collection, Stream, Iterable, Iterator를 반환할 수 있습니다. 데이터 기반 테스트에 강력합니다.
@TestFactory 메서드는 @Test와 달리 @BeforeEach/@AfterEach가 동적 테스트 개별이 아닌 팩토리 메서드 단위로 실행됩니다.
09Mockito 심화
Mockito로 의존성을 모킹하고 행위를 검증하는 고급 기법입니다.
Java code
// Mockito 의존성: org.mockito:mockito-core:5.8+
// import static org.mockito.Mockito.*;
// import static org.mockito.ArgumentMatchers.*;
// Mockito 패턴 (의사코드)
// class UserServiceTest {
// UserRepository repo = mock(UserRepository.class);
// EmailService email = mock(EmailService.class);
// UserService service = new UserService(repo, email);
//
// @Test
// void createUser_success() {
// // Given: 스텁 설정
// when(repo.save(any(User.class)))
// .thenReturn(new User("1", "홍길동"));
// when(repo.existsByEmail("hong@test.com"))
// .thenReturn(false);
//
// // When: 실행
// User user = service.createUser("홍길동", "hong@test.com");
//
// // Then: 검증
// assertNotNull(user);
// verify(repo).save(any(User.class));
// verify(email).sendWelcome(eq("hong@test.com"), anyString());
// verify(repo, never()).deleteById(anyString());
// verifyNoMoreInteractions(email);
// }
//
// @Test
// void createUser_duplicateEmail() {
// when(repo.existsByEmail("dup@test.com")).thenReturn(true);
//
// assertThrows(DuplicateException.class,
// () -> service.createUser("이름", "dup@test.com"));
//
// verify(repo, never()).save(any());
// }
// }
class Main {
public static void main(String[] args) {
System.out.println("Mockito: mock() -> when().thenReturn() -> verify()");
System.out.println("ArgumentMatchers: any(), eq(), anyString(), argThat()");
}
}verify()로 메서드 호출 횟수(times(), never(), atLeast())를 검증하세요. 행위 검증이 Mockito의 핵심입니다.
when(mock.method())에서 any()와 실제 값을 혼용하면 에러입니다. 모든 인자를 matcher로 통일하거나 eq()로 감싸세요.
10ArgumentCaptor
ArgumentCaptor로 메서드에 전달된 인자를 캡처하여 상세 검증합니다.
Java code
// import org.mockito.*;
// import static org.mockito.Mockito.*;
// ArgumentCaptor 패턴 (의사코드)
// class NotificationServiceTest {
// EmailSender sender = mock(EmailSender.class);
// NotificationService service = new NotificationService(sender);
//
// @Test
// void sendOrderConfirmation() {
// // When
// service.confirmOrder("ORD-123", "hong@test.com");
//
// // Then: 인자 캡처
// ArgumentCaptor<Email> captor = ArgumentCaptor.forClass(Email.class);
// verify(sender).send(captor.capture());
//
// Email captured = captor.getValue();
// assertEquals("hong@test.com", captured.getTo());
// assertTrue(captured.getSubject().contains("ORD-123"));
// assertTrue(captured.getBody().contains("주문 확인"));
// }
//
// @Test
// void sendMultipleEmails() {
// service.notifyAll(List.of("a@test.com", "b@test.com"));
//
// ArgumentCaptor<Email> captor = ArgumentCaptor.forClass(Email.class);
// verify(sender, times(2)).send(captor.capture());
//
// List<Email> allEmails = captor.getAllValues();
// assertEquals(2, allEmails.size());
// assertEquals("a@test.com", allEmails.get(0).getTo());
// }
// }
class Main {
public static void main(String[] args) {
System.out.println("ArgumentCaptor: 복잡한 객체 인자의 내부 상태를 검증합니다.");
System.out.println("getValue()=마지막 캡처, getAllValues()=모든 캡처");
}
}ArgumentCaptor는 복잡한 객체가 올바르게 구성되었는지 검증할 때 유용합니다. DTO나 이벤트 객체 검증에 자주 쓰입니다.
capture()를 when() 스텁에서 사용하면 예상치 못한 동작이 발생합니다. verify()에서만 사용하세요.
11BDD 스타일 테스트 (given/when/then)
BDD 스타일로 테스트를 작성하여 가독성과 의도를 명확히 합니다.
Java code
// BDD with Mockito (의사코드)
// import static org.mockito.BDDMockito.*;
// class OrderServiceBDDTest {
// OrderRepository repo = mock(OrderRepository.class);
// PaymentGateway payment = mock(PaymentGateway.class);
// OrderService service = new OrderService(repo, payment);
//
// @Test
// @DisplayName("유효한 주문이면 결제 후 저장한다")
// void shouldProcessValidOrder() {
// // Given (준비)
// Order order = new Order("ORD-1", "노트북", 1500000);
// given(payment.charge(anyLong())).willReturn(true);
// given(repo.save(any(Order.class))).willReturn(order);
//
// // When (실행)
// Order result = service.placeOrder(order);
//
// // Then (검증)
// then(payment).should().charge(1500000L);
// then(repo).should().save(order);
// assertThat(result.getId()).isEqualTo("ORD-1");
// }
//
// @Test
// @DisplayName("결제 실패 시 주문을 저장하지 않는다")
// void shouldNotSaveWhenPaymentFails() {
// // Given
// given(payment.charge(anyLong())).willReturn(false);
//
// // When & Then
// assertThrows(PaymentException.class,
// () -> service.placeOrder(new Order("ORD-2", "키보드", 80000)));
// then(repo).should(never()).save(any());
// }
// }
class Main {
public static void main(String[] args) {
System.out.println("BDD Mockito: given() -> when(실행) -> then().should()");
System.out.println("일반 Mockito: when() -> 실행 -> verify()");
}
}BDD 스타일(given/when/then)은 비개발자도 테스트 의도를 이해할 수 있습니다. BDDMockito를 사용하면 코드와 용어가 일치합니다.
given()과 when()을 혼용하면 가독성이 떨어집니다. 프로젝트에서 하나의 스타일을 통일하세요.
12Testcontainers
Docker 컨테이너로 실제 인프라 통합 테스트를 수행합니다.
Java code
// Testcontainers 의존성: org.testcontainers:testcontainers:1.19+
// import org.testcontainers.containers.PostgreSQLContainer;
// import org.testcontainers.junit.jupiter.*;
// @Testcontainers
// class DatabaseIntegrationTest {
// @Container
// static PostgreSQLContainer<?> postgres =
// new PostgreSQLContainer<>("postgres:16-alpine")
// .withDatabaseName("testdb")
// .withUsername("test")
// .withPassword("test");
//
// @Test
// void shouldConnectToDatabase() throws Exception {
// String url = postgres.getJdbcUrl();
// try (Connection conn = DriverManager.getConnection(
// url, "test", "test")) {
// assertFalse(conn.isClosed());
//
// Statement stmt = conn.createStatement();
// stmt.execute("CREATE TABLE test (id INT, name VARCHAR(50))");
// stmt.execute("INSERT INTO test VALUES (1, '홍길동')");
//
// ResultSet rs = stmt.executeQuery("SELECT * FROM test");
// assertTrue(rs.next());
// assertEquals("홍길동", rs.getString("name"));
// }
// }
// }
class Main {
public static void main(String[] args) {
System.out.println("Testcontainers 지원 컨테이너:");
System.out.println("- PostgreSQL, MySQL, MongoDB, Redis");
System.out.println("- Kafka, RabbitMQ, Elasticsearch");
System.out.println("- 모든 Docker 이미지 사용 가능");
}
}@Container와 static을 함께 쓰면 테스트 클래스당 한 번만 컨테이너를 시작합니다. 테스트 시간을 크게 줄일 수 있습니다.
Testcontainers는 Docker가 실행 중이어야 합니다. CI/CD에서 Docker-in-Docker(DinD) 설정을 확인하세요.
13Spring 테스트 기초
@SpringBootTest로 Spring 컨텍스트 기반 통합 테스트를 수행합니다.
Java code
// Spring Boot Test 의존성: spring-boot-starter-test
// @SpringBootTest
// class UserServiceIntegrationTest {
// @Autowired
// UserService userService;
//
// @Autowired
// UserRepository userRepository;
//
// @BeforeEach
// void cleanup() { userRepository.deleteAll(); }
//
// @Test
// @DisplayName("사용자 생성 통합 테스트")
// void createUser() {
// UserDTO dto = new UserDTO("홍길동", "hong@test.com");
// User created = userService.create(dto);
//
// assertNotNull(created.getId());
// assertEquals("홍길동", created.getName());
//
// // DB에 실제 저장 확인
// Optional<User> found = userRepository.findById(created.getId());
// assertTrue(found.isPresent());
// }
//
// @Test
// @DisplayName("중복 이메일 예외")
// void duplicateEmail() {
// userService.create(new UserDTO("A", "dup@test.com"));
// assertThrows(DuplicateException.class,
// () -> userService.create(new UserDTO("B", "dup@test.com")));
// }
// }
class Main {
public static void main(String[] args) {
System.out.println("@SpringBootTest: 전체 컨텍스트 로드");
System.out.println("@WebMvcTest: 웹 레이어만");
System.out.println("@DataJpaTest: JPA 레이어만");
}
}@SpringBootTest는 전체 컨텍스트를 로드하므로 느립니다. 슬라이스 테스트(@WebMvcTest, @DataJpaTest)를 먼저 고려하세요.
@Transactional을 테스트에 붙이면 자동 롤백됩니다. 편리하지만 실제 커밋 동작을 테스트하지 못하는 함정이 있습니다.
14슬라이스 테스트 (@WebMvcTest)
웹 레이어만 로드하여 컨트롤러를 빠르게 테스트합니다.
Java code
// @WebMvcTest(UserController.class)
// class UserControllerTest {
// @Autowired
// MockMvc mockMvc;
//
// @MockBean // Spring 컨텍스트에 Mock 주입
// UserService userService;
//
// @Test
// @DisplayName("GET /users/{id} — 200 OK")
// void getUser_found() throws Exception {
// given(userService.findById("1"))
// .willReturn(new UserDTO("1", "홍길동", "hong@test.com"));
//
// mockMvc.perform(get("/users/1")
// .accept(MediaType.APPLICATION_JSON))
// .andExpect(status().isOk())
// .andExpect(jsonPath("$.name").value("홍길동"))
// .andExpect(jsonPath("$.email").value("hong@test.com"));
// }
//
// @Test
// @DisplayName("POST /users — 201 Created")
// void createUser() throws Exception {
// String json = "{\"name\":\"홍길동\",\"email\":\"hong@test.com\"}";
//
// mockMvc.perform(post("/users")
// .contentType(MediaType.APPLICATION_JSON)
// .content(json))
// .andExpect(status().isCreated())
// .andExpect(header().exists("Location"));
// }
//
// @Test
// @DisplayName("GET /users/{id} — 404 Not Found")
// void getUser_notFound() throws Exception {
// given(userService.findById("999"))
// .willThrow(new NotFoundException("사용자 없음"));
//
// mockMvc.perform(get("/users/999"))
// .andExpect(status().isNotFound());
// }
// }
class Main {
public static void main(String[] args) {
System.out.println("@WebMvcTest: Controller + Filter + ControllerAdvice만 로드");
System.out.println("Service, Repository는 @MockBean으로 대체");
}
}@WebMvcTest는 @SpringBootTest보다 10배 이상 빠릅니다. 컨트롤러 로직 검증에는 항상 슬라이스 테스트를 사용하세요.
@MockBean은 Spring 컨텍스트의 빈을 대체합니다. @Mock(Mockito)과 다르니 혼동하지 마세요.
15성능 테스트 (JMH)
Java Microbenchmark Harness로 정확한 성능 측정을 수행합니다.
Java code
// JMH 의존성: org.openjdk.jmh:jmh-core:1.37
// import org.openjdk.jmh.annotations.*;
// import java.util.concurrent.TimeUnit;
// @BenchmarkMode(Mode.AverageTime)
// @OutputTimeUnit(TimeUnit.NANOSECONDS)
// @Warmup(iterations = 3, time = 1)
// @Measurement(iterations = 5, time = 1)
// @Fork(1)
// @State(Scope.Benchmark)
// public class StringBenchmark {
//
// @Param({"10", "100", "1000"})
// int size;
//
// @Benchmark
// public String concatenation() {
// String result = "";
// for (int i = 0; i < size; i++) {
// result += "a"; // 매번 새 객체
// }
// return result;
// }
//
// @Benchmark
// public String stringBuilder() {
// StringBuilder sb = new StringBuilder(size);
// for (int i = 0; i < size; i++) {
// sb.append("a");
// }
// return sb.toString();
// }
//
// // mvn clean install
// // java -jar target/benchmarks.jar
// }
class Main {
public static void main(String[] args) {
System.out.println("JMH는 JIT 최적화, GC, 워밍업을 고려한 정확한 벤치마크를 제공합니다.");
System.out.println("System.nanoTime()으로 직접 측정하면 부정확합니다.");
}
}@Warmup으로 JIT 컴파일 후에 측정하고, @Fork로 별도 JVM에서 실행하여 간섭을 제거합니다.
System.currentTimeMillis()로 벤치마크하면 JIT 최적화, GC 일시정지, OS 스케줄링 등의 영향을 받아 부정확합니다.
16아키텍처 테스트 (ArchUnit)
ArchUnit으로 아키텍처 규칙을 코드로 검증합니다.
Java code
// ArchUnit 의존성: com.tngtech.archunit:archunit-junit5:1.2+
// import com.tngtech.archunit.junit.*;
// import com.tngtech.archunit.lang.*;
// import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.*;
// import static com.tngtech.archunit.library.Architectures.*;
// @AnalyzeClasses(packages = "com.example")
// class ArchitectureTest {
//
// // 레이어 규칙
// @ArchTest
// static final ArchRule layerRule = layeredArchitecture()
// .consideringAllDependencies()
// .layer("Controller").definedBy("..controller..")
// .layer("Service").definedBy("..service..")
// .layer("Repository").definedBy("..repository..")
// .whereLayer("Controller").mayOnlyBeAccessedByLayers()
// .whereLayer("Service").mayOnlyBeAccessedByLayers("Controller")
// .whereLayer("Repository").mayOnlyBeAccessedByLayers("Service");
//
// // 네이밍 규칙
// @ArchTest
// static final ArchRule controllerNaming = classes()
// .that().resideInAPackage("..controller..")
// .should().haveSimpleNameEndingWith("Controller");
//
// // 의존성 규칙
// @ArchTest
// static final ArchRule noCyclic = slices()
// .matching("com.example.(*)..")
// .should().beFreeOfCycles();
//
// // 어노테이션 규칙
// @ArchTest
// static final ArchRule serviceAnnotation = classes()
// .that().resideInAPackage("..service..")
// .should().beAnnotatedWith(org.springframework.stereotype.Service.class);
// }
class Main {
public static void main(String[] args) {
System.out.println("ArchUnit: 아키텍처 규칙을 단위 테스트로 검증합니다.");
System.out.println("CI에서 실행하여 아키텍처 위반을 자동으로 감지합니다.");
}
}ArchUnit을 CI 파이프라인에 포함하면 아키텍처 규칙 위반을 자동으로 감지합니다. 팀 전체의 아키텍처 규칙 준수를 강제할 수 있습니다.
너무 많은 규칙을 한 번에 추가하면 기존 코드에서 대량의 위반이 발견됩니다. 점진적으로 규칙을 추가하세요.
17뮤테이션 테스트
코드를 의도적으로 변경하여 테스트의 품질을 검증하는 뮤테이션 테스트입니다.
Java code
// PIT 의존성: org.pitest:pitest-maven:1.15+
// 뮤테이션 테스트 개념:
// 1. 원본 코드에 "변이(mutation)"를 주입
// 2. 변이 예: if (a > b) -> if (a >= b), return a+b -> return a-b
// 3. 테스트가 변이를 감지(kill)하면 좋은 테스트
// 4. 감지 못하면(survived) 테스트 부족
// 원본 코드
class Calculator {
int add(int a, int b) { return a + b; }
int max(int a, int b) { return a > b ? a : b; }
boolean isPositive(int n) { return n > 0; }
}
// 테스트
// class CalculatorTest {
// Calculator calc = new Calculator();
//
// @Test void testAdd() { assertEquals(5, calc.add(2, 3)); }
// @Test void testMax() {
// assertEquals(3, calc.max(3, 1));
// assertEquals(3, calc.max(1, 3));
// assertEquals(3, calc.max(3, 3)); // 경계값!
// }
// @Test void testIsPositive() {
// assertTrue(calc.isPositive(1));
// assertFalse(calc.isPositive(0)); // 경계값!
// assertFalse(calc.isPositive(-1));
// }
// }
// 실행: mvn org.pitest:pitest-maven:mutationCoverage
class Main {
public static void main(String[] args) {
System.out.println("뮤테이션 종류:");
System.out.println("- 조건 변이: > -> >=, == -> !=");
System.out.println("- 수학 변이: + -> -, * -> /");
System.out.println("- 반환값 변이: return true -> return false");
System.out.println("- 제거 변이: 메서드 호출 제거");
}
}라인 커버리지 100%여도 뮤테이션 테스트에서 살아남는 변이가 있을 수 있습니다. 경계값 테스트가 부족한 경우가 많습니다.
뮤테이션 테스트는 실행 시간이 오래 걸립니다(코드량 * 변이 수만큼). CI에서는 변경된 코드만 대상으로 실행하세요.
18계약 테스트
서비스 간 API 계약을 검증하는 Consumer-Driven Contract 테스트입니다.
Java code
// Spring Cloud Contract 또는 Pact 사용
// 계약 테스트 개념:
// Consumer(소비자)가 기대하는 API 형태를 계약으로 정의
// Provider(제공자)가 해당 계약을 만족하는지 검증
// 1. Consumer 측 계약 정의 (Pact 형식)
// {
// "consumer": "order-service",
// "provider": "user-service",
// "interactions": [{
// "description": "사용자 조회",
// "request": {
// "method": "GET",
// "path": "/users/1"
// },
// "response": {
// "status": 200,
// "body": {
// "id": "1",
// "name": "홍길동",
// "email": "hong@test.com"
// }
// }
// }]
// }
// 2. Provider 측 검증
// @Provider("user-service")
// @PactFolder("pacts")
// class UserServicePactTest {
// @TestTemplate
// @ExtendWith(PactVerificationInvocationContextProvider.class)
// void verifyPact(PactVerificationContext context) {
// context.verifyInteraction();
// }
// }
class Main {
public static void main(String[] args) {
System.out.println("계약 테스트 흐름:");
System.out.println("1. Consumer가 계약(Pact) 생성");
System.out.println("2. Pact Broker에 계약 공유");
System.out.println("3. Provider가 계약 검증");
System.out.println("4. 계약 위반 시 배포 차단");
}
}계약 테스트는 E2E 테스트보다 빠르고 안정적으로 서비스 간 호환성을 검증합니다. Pact Broker로 계약을 중앙 관리하세요.
Provider가 계약을 깨는 변경을 배포하면 Consumer가 장애를 겪습니다. CI에서 계약 검증을 필수 단계로 넣으세요.
19E2E 테스트 전략
전체 시스템을 관통하는 E2E 테스트의 전략과 모범 사례입니다.
Java code
// E2E 테스트 전략
// 테스트 피라미드:
// [E2E 테스트] ← 적게 (5-10%)
// [통합 테스트] ← 중간 (20-30%)
// [단위 테스트] ← 많이 (60-70%)
// E2E 테스트 작성 원칙
class E2ETestStrategy {
// 1. 핵심 시나리오만 E2E로 테스트
// @Test void 주문_전체_흐름() {
// // 사용자 로그인
// // -> 상품 검색
// // -> 장바구니 추가
// // -> 결제
// // -> 주문 확인
// }
// 2. 테스트 데이터 격리
// @BeforeEach void setup() {
// testData = TestDataFactory.createIsolatedData();
// }
// @AfterEach void cleanup() {
// testData.cleanup();
// }
// 3. 재시도 전략 (불안정한 외부 의존)
// @RetryingTest(3)
// void flakyExternalService() { ... }
// 4. 환경 설정
// @SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
// @Testcontainers
public static void main(String[] args) {
System.out.println("E2E 모범 사례:");
System.out.println("1. 핵심 비즈니스 흐름만 테스트");
System.out.println("2. 테스트 데이터 격리 필수");
System.out.println("3. 비결정적 테스트(flaky) 즉시 수정");
System.out.println("4. 실행 시간 5분 이내 유지");
System.out.println("5. 실패 시 스크린샷/로그 자동 수집");
}
}E2E 테스트는 가장 비용이 높으므로 핵심 비즈니스 흐름(happy path)에 집중하세요. 엣지 케이스는 단위/통합 테스트로 커버합니다.
E2E 테스트가 다른 테스트의 데이터에 의존하면 순서에 따라 결과가 달라집니다. 각 테스트는 독립적으로 데이터를 생성/정리해야 합니다.
정리하며
- 단위·슬라이스·통합 테스트의 실행 비용 차이를 인식하고 케이스를 아래층으로 밀어냅니다
- 반복 케이스는 테스트 메서드 복제 대신 @ParameterizedTest의 데이터 소스로 표현합니다
- 부수효과 검증에는 ArgumentCaptor를 써서 실제 전달된 인자를 확인합니다
- JUnit 5의 메서드별 인스턴스 생성 규칙을 전제로 테스트 간 상태 공유를 설계합니다
더 깊이 들어가고 싶다면 Java 학습 라이브러리에서 다른 주제 가이드를 이어서 보거나, 언어 비교에서 같은 개념이 다른 언어에서 어떻게 표현되는지 확인해 보세요.