테스트 전략 완벽 가이드: JUnit5, Mockito부터 Spring Boot 테스트까지
테스트 전략 완벽 가이드: JUnit5, Mockito부터 Spring Boot 테스트까지
면접에서 “테스트 어떻게 작성하세요?”, “단위 테스트와 통합 테스트의 차이는?” 같은 질문은 신입에게도 자주 나온다. 코드를 짤 수 있는지뿐 아니라 품질을 보장할 수 있는지를 확인하는 것이기 때문이다. 이 글은 Java/Spring 백엔드 개발자가 알아야 할 테스트의 전체 그림을 다룬다.
1. 테스트의 종류
1.1 테스트 피라미드
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
┌────────────────────────────────────────────────────────────────────────┐
│ 테스트 피라미드 │
├────────────────────────────────────────────────────────────────────────┤
│ │
│ /\ │
│ / \ E2E / 인수 테스트 (Acceptance) │
│ / E2E\ → 실제 사용자 시나리오 (브라우저, API 호출) │
│ /______\ → 느림, 비용 높음, 적게 │
│ / \ │
│ / 통합 \ 통합 테스트 (Integration) │
│ / 테스트 \ → 여러 컴포넌트 조합 (DB, 외부 API 연동) │
│ /______________\ → 중간 속도, 중간 비용 │
│ / \ │
│ / 단위 테스트 \ 단위 테스트 (Unit) │
│/____________________\ → 하나의 클래스/메서드만 격리 테스트 │
│ → 가장 빠름, 가장 많이 │
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ "단위 테스트를 가장 많이, E2E를 가장 적게" │ │
│ │ │ │
│ │ 비율 (목안): │ │
│ │ 단위 : 통합 : E2E = 70% : 20% : 10% │ │
│ │ │ │
│ │ 단위 테스트가 많을수록: │ │
│ │ • 실행 속도 빠름 → 빠른 피드백 │ │
│ │ • 실패 원인 파악 쉬움 (격리되어 있으므로) │ │
│ │ • 유지보수 비용 낮음 │ │
│ └──────────────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────────────────┘
1.2 각 테스트의 특징 비교
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
┌────────────────────────────────────────────────────────────────────────┐
│ 테스트 유형별 비교 │
├─────────────┬───────────────────┬──────────────────┬──────────────────┤
│ │ 단위 테스트 │ 통합 테스트 │ E2E 테스트 │
├─────────────┼───────────────────┼──────────────────┼──────────────────┤
│ 범위 │ 클래스/메서드 1개 │ 여러 컴포넌트 │ 전체 시스템 │
├─────────────┼───────────────────┼──────────────────┼──────────────────┤
│ 외부 의존성 │ Mock으로 대체 │ 실제 또는 부분 │ 실제 환경 │
│ │ │ (H2, Testcontainer)│ (DB, API 등) │
├─────────────┼───────────────────┼──────────────────┼──────────────────┤
│ 실행 속도 │ ~1ms/개 │ ~100ms-수초/개 │ ~수초-수분/개 │
├─────────────┼───────────────────┼──────────────────┼──────────────────┤
│ Spring 컨텍 │ 불필요 │ 필요 │ 필요 │
│ 스트 로딩 │ │ (@SpringBootTest) │ │
├─────────────┼───────────────────┼──────────────────┼──────────────────┤
│ DB 사용 │ ✗ │ ✓ (H2/TC) │ ✓ (실제 DB) │
├─────────────┼───────────────────┼──────────────────┼──────────────────┤
│ 실패 시 │ 원인 즉시 파악 │ 원인 추적 필요 │ 원인 파악 어려움 │
├─────────────┼───────────────────┼──────────────────┼──────────────────┤
│ 예시 │ Service 로직 검증 │ Service + DB 연동│ API 호출 → │
│ │ (Repository Mock) │ Repository 쿼리 │ DB 저장 → 응답 │
├─────────────┼───────────────────┼──────────────────┼──────────────────┤
│ 도구 │ JUnit + Mockito │ @SpringBootTest │ RestAssured, │
│ │ │ @DataJpaTest │ Selenium │
└─────────────┴───────────────────┴──────────────────┴──────────────────┘
2. JUnit 5 기초
2.1 JUnit 5 구조
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
┌────────────────────────────────────────────────────────────────────────┐
│ JUnit 5 = JUnit Platform + JUnit Jupiter + JUnit Vintage │
├────────────────────────────────────────────────────────────────────────┤
│ │
│ ■ JUnit Platform — 테스트 실행 기반 (TestEngine 인터페이스) │
│ ■ JUnit Jupiter — JUnit 5의 어노테이션, Assertion API │
│ ■ JUnit Vintage — JUnit 3, 4 호환 지원 │
│ │
│ Spring Boot Starter Test에 포함: │
│ spring-boot-starter-test │
│ ├── junit-jupiter (JUnit 5) │
│ ├── mockito-core (Mocking) │
│ ├── mockito-junit-jupiter (Mockito + JUnit 5 통합) │
│ ├── assertj-core (읽기 좋은 Assertion) │
│ ├── hamcrest (Matcher 라이브러리) │
│ └── jsonassert (JSON 비교) │
└────────────────────────────────────────────────────────────────────────┘
2.2 기본 어노테이션
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import org.junit.jupiter.api.*;
import static org.assertj.core.api.Assertions.*;
class UserServiceTest {
@BeforeAll // 전체 테스트 전 1회 실행 (static)
static void beforeAll() {
System.out.println("테스트 클래스 시작");
}
@BeforeEach // 각 테스트 전마다 실행
void setUp() {
// 테스트 데이터 초기화
}
@Test
@DisplayName("사용자 이름이 비어있으면 예외가 발생한다")
void createUser_emptyName_throwsException() {
// given - when - then
}
@Test
@Disabled("회원 등급 기능 구현 전까지 비활성화")
void upgradeUserGrade() {
// 아직 미구현
}
@AfterEach // 각 테스트 후마다 실행
void tearDown() {
// 정리
}
@AfterAll // 전체 테스트 후 1회 실행 (static)
static void afterAll() {
System.out.println("테스트 클래스 종료");
}
}
2.3 Assertion — AssertJ 스타일
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// JUnit 5 기본 Assertion (읽기 어려움)
assertEquals("김철수", user.getName());
assertTrue(user.isActive());
// AssertJ (읽기 쉬움, 체이닝 가능 — 실무 표준)
assertThat(user.getName()).isEqualTo("김철수");
assertThat(user.isActive()).isTrue();
assertThat(user.getAge()).isGreaterThan(18).isLessThan(100);
// 컬렉션 검증
assertThat(users)
.hasSize(3)
.extracting("name")
.containsExactly("김철수", "이영희", "박민수");
// 예외 검증
assertThatThrownBy(() -> userService.createUser(""))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("이름은 비어있을 수 없습니다");
// 예외가 발생하지 않는지 검증
assertThatCode(() -> userService.createUser("김철수"))
.doesNotThrowAnyException();
2.4 매개변수화 테스트
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 여러 입력값으로 같은 로직을 테스트
@ParameterizedTest
@ValueSource(strings = {"", " ", " "})
@DisplayName("빈 문자열이나 공백으로 사용자를 생성하면 예외가 발생한다")
void createUser_blankName_throwsException(String name) {
assertThatThrownBy(() -> userService.createUser(name))
.isInstanceOf(IllegalArgumentException.class);
}
@ParameterizedTest
@CsvSource({
"1, true", // id=1 → 존재
"999, false" // id=999 → 존재하지 않음
})
@DisplayName("사용자 존재 여부를 확인한다")
void existsById(Long id, boolean expected) {
assertThat(userService.existsById(id)).isEqualTo(expected);
}
@ParameterizedTest
@MethodSource("provideInvalidEmails")
@DisplayName("유효하지 않은 이메일 형식이면 예외가 발생한다")
void validateEmail_invalid_throwsException(String email) {
assertThatThrownBy(() -> userService.validateEmail(email))
.isInstanceOf(InvalidEmailException.class);
}
static Stream<String> provideInvalidEmails() {
return Stream.of("abc", "abc@", "@domain.com", "abc@.com");
}
3. Given-When-Then 패턴
테스트 코드의 가독성을 위한 구조화 패턴이다. 면접에서 “테스트를 어떻게 구조화하나요?”라고 물으면 이 패턴을 답한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
┌────────────────────────────────────────────────────────────────────────┐
│ Given - When - Then 패턴 │
├────────────────────────────────────────────────────────────────────────┤
│ │
│ Given (준비): 테스트에 필요한 데이터와 상태를 설정 │
│ When (실행): 테스트할 행동을 수행 │
│ Then (검증): 기대하는 결과를 검증 │
│ │
│ ■ BDD 스타일과 동일한 개념 │
│ Given = Arrange │
│ When = Act │
│ Then = Assert │
└────────────────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Test
@DisplayName("주문 생성 시 재고가 차감된다")
void createOrder_decreasesStock() {
// given — 준비
Product product = new Product("노트북", 10); // 재고 10개
OrderRequest request = new OrderRequest(product.getId(), 3); // 3개 주문
// when — 실행
Order order = orderService.createOrder(request);
// then — 검증
assertThat(order.getStatus()).isEqualTo(OrderStatus.CREATED);
assertThat(product.getStock()).isEqualTo(7); // 10 - 3 = 7
}
4. Test Double — Mock, Stub, Spy
4.1 테스트 더블이란
단위 테스트에서 외부 의존성(DB, 외부 API 등)을 가짜 객체로 대체하는 것이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
┌────────────────────────────────────────────────────────────────────────┐
│ Test Double 종류 │
├────────────────────────────────────────────────────────────────────────┤
│ │
│ ■ Dummy — 전달만 되고 사용되지 않는 객체 │
│ → 메서드 시그니처를 채우기 위한 용도 │
│ │
│ ■ Stub — 미리 정해진 값을 반환하는 객체 │
│ → "이 메서드를 호출하면 이 값을 반환해" 설정 │
│ → when(repo.findById(1L)).thenReturn(user) │
│ │
│ ■ Mock — 호출 여부를 검증할 수 있는 객체 │
│ → "이 메서드가 1번 호출되었는지" 확인 │
│ → verify(repo, times(1)).save(any()) │
│ │
│ ■ Spy — 실제 객체를 감싸서 일부만 교체 │
│ → 실제 로직 수행 + 특정 메서드만 Override │
│ → @Spy + doReturn(value).when(spy).method() │
│ │
│ ■ Fake — 실제 동작을 간소화한 구현체 │
│ → 예: DB 대신 HashMap으로 저장하는 FakeRepository │
│ │
│ 실무에서는 Stub과 Mock을 가장 많이 사용 │
│ Mockito에서 mock()이 Stub과 Mock 역할을 모두 수행 │
└────────────────────────────────────────────────────────────────────────┘
4.2 왜 Mock을 사용하는가
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
┌────────────────────────────────────────────────────────────────────────┐
│ Mock을 쓰는 이유 │
├────────────────────────────────────────────────────────────────────────┤
│ │
│ UserService를 테스트하고 싶은데... │
│ │
│ ┌─────────────┐ 의존 ┌─────────────────┐ │
│ │ UserService │ ──────────→ │ UserRepository │ → DB │
│ └─────────────┘ └─────────────────┘ │
│ │ │
│ │ 의존 ┌─────────────────┐ │
│ └────────→ │ EmailService │ → 외부 SMTP 서버 │
│ └─────────────────┘ │
│ │
│ 문제: │
│ • DB가 필요 → 테스트 환경 복잡, 느림 │
│ • 외부 SMTP 서버 필요 → 실제 이메일 발송?! │
│ • UserRepository 버그 → UserService 테스트도 실패 (격리 안 됨) │
│ │
│ Mock 사용: │
│ ┌─────────────┐ 의존 ┌─────────────────┐ │
│ │ UserService │ ──────────→ │ Mock Repository │ → DB 없음! │
│ └─────────────┘ └─────────────────┘ │
│ │ │
│ │ 의존 ┌─────────────────┐ │
│ └────────→ │ Mock EmailSvc │ → 이메일 안 보냄! │
│ └─────────────────┘ │
│ │
│ → UserService의 로직만 격리하여 테스트 │
│ → DB, 외부 API 없이 밀리초 단위로 실행 │
│ → Repository 버그와 무관하게 Service 로직 검증 │
└────────────────────────────────────────────────────────────────────────┘
5. Mockito 사용법
5.1 기본 사용법
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
@ExtendWith(MockitoExtension.class) // JUnit 5 + Mockito 통합
class UserServiceTest {
@Mock // 가짜 객체 생성
private UserRepository userRepository;
@Mock
private EmailService emailService;
@InjectMocks // Mock을 주입받는 대상
private UserService userService; // userRepository, emailService가 주입됨
@Test
@DisplayName("사용자를 정상적으로 생성한다")
void createUser_success() {
// given — Stub 설정
User user = new User("김철수", "kim@test.com");
when(userRepository.existsByEmail("kim@test.com")).thenReturn(false);
when(userRepository.save(any(User.class))).thenReturn(user);
// when
User result = userService.createUser("김철수", "kim@test.com");
// then — 결과 검증
assertThat(result.getName()).isEqualTo("김철수");
// then — 행위 검증 (Mock)
verify(userRepository, times(1)).save(any(User.class));
verify(emailService, times(1)).sendWelcomeEmail("kim@test.com");
}
@Test
@DisplayName("이미 존재하는 이메일로 가입하면 예외가 발생한다")
void createUser_duplicateEmail_throwsException() {
// given
when(userRepository.existsByEmail("kim@test.com")).thenReturn(true);
// when & then
assertThatThrownBy(() -> userService.createUser("김철수", "kim@test.com"))
.isInstanceOf(DuplicateEmailException.class)
.hasMessage("이미 사용 중인 이메일입니다");
// save가 호출되지 않았는지 검증
verify(userRepository, never()).save(any());
}
}
5.2 Mockito 핵심 메서드
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
┌────────────────────────────────────────────────────────────────────────┐
│ Mockito 핵심 API │
├────────────────────────────────────────────────────────────────────────┤
│ │
│ ■ Stub 설정 (when...thenReturn) │
│ when(mock.method()).thenReturn(value); // 값 반환 │
│ when(mock.method()).thenThrow(exception); // 예외 던지기 │
│ when(mock.method()).thenAnswer(invocation -> ...); // 동적 응답 │
│ │
│ ■ 행위 검증 (verify) │
│ verify(mock).method(); // 1번 호출 검증 │
│ verify(mock, times(2)).method(); // 정확히 2번 │
│ verify(mock, never()).method(); // 호출 안 됨 │
│ verify(mock, atLeastOnce()).method(); // 1번 이상 │
│ │
│ ■ Argument Matcher │
│ when(repo.findById(any())).thenReturn(...); // 아무 값이나 │
│ when(repo.findById(anyLong())).thenReturn(...); // Long 타입 아무 값 │
│ when(repo.findById(eq(1L))).thenReturn(...); // 정확히 1L │
│ verify(mock).save(argThat(user -> user.getName().equals("김철수"))); │
│ │
│ ■ Argument Captor — 전달된 인자 캡처 │
│ @Captor ArgumentCaptor<User> userCaptor; │
│ verify(repo).save(userCaptor.capture()); │
│ User savedUser = userCaptor.getValue(); │
│ assertThat(savedUser.getName()).isEqualTo("김철수"); │
│ │
│ ■ @Spy — 실제 객체 + 부분 교체 │
│ @Spy │
│ private UserService userService = new UserService(repo); │
│ doReturn(true).when(userService).isAdmin(anyLong()); │
│ // isAdmin()만 Mock, 나머지는 실제 로직 실행 │
└────────────────────────────────────────────────────────────────────────┘
6. Spring Boot 테스트 어노테이션
6.1 @SpringBootTest — 통합 테스트
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 전체 Spring 컨텍스트를 로드하는 통합 테스트
@SpringBootTest
@Transactional // 각 테스트 후 롤백 → DB 상태 격리
class OrderServiceIntegrationTest {
@Autowired
private OrderService orderService;
@Autowired
private ProductRepository productRepository;
@Test
@DisplayName("주문 생성 시 재고가 차감되고 DB에 저장된다")
void createOrder_success() {
// given
Product product = productRepository.save(new Product("노트북", 10));
// when
Order order = orderService.createOrder(
new OrderRequest(product.getId(), 3)
);
// then
assertThat(order.getId()).isNotNull();
assertThat(productRepository.findById(product.getId()).get().getStock())
.isEqualTo(7);
}
}
6.2 Slice 테스트 — 필요한 부분만 로드
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
┌────────────────────────────────────────────────────────────────────────┐
│ Spring Boot Slice 테스트 어노테이션 │
├────────────────────────────────────────────────────────────────────────┤
│ │
│ @SpringBootTest │
│ → 전체 컨텍스트 로드 (모든 Bean) │
│ → 느리지만 실제 환경과 가장 유사 │
│ → 통합 테스트에 사용 │
│ │
│ @WebMvcTest(UserController.class) │
│ → Controller + 관련 Bean만 로드 (Service, Repository 미로드) │
│ → MockMvc로 HTTP 요청/응답 테스트 │
│ → Service는 @MockBean으로 대체 │
│ → 빠름 (Controller 레이어만) │
│ │
│ @DataJpaTest │
│ → JPA 관련 Bean만 로드 (Repository, EntityManager) │
│ → 내장 H2 DB 자동 구성 │
│ → @Transactional 자동 적용 (테스트 후 롤백) │
│ → Repository 쿼리 정확성 검증 │
│ │
│ @RestClientTest │
│ → REST 클라이언트 테스트 (RestTemplate, WebClient) │
│ → MockRestServiceServer로 외부 API 응답 Mock │
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ Slice 테스트 선택 기준 │ │
│ │ │ │
│ │ Controller 테스트 → @WebMvcTest │ │
│ │ Repository 테스트 → @DataJpaTest │ │
│ │ Service 단위 테스트 → @ExtendWith(MockitoExtension) │ │
│ │ Service 통합 테스트 → @SpringBootTest │ │
│ │ 전체 흐름 테스트 → @SpringBootTest + TestRestTemplate │ │
│ └──────────────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────────────────┘
6.3 @WebMvcTest — Controller 테스트
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
@WebMvcTest(UserController.class)
class UserControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean // Spring 컨텍스트의 Bean을 Mock으로 대체
private UserService userService;
@Test
@DisplayName("GET /api/users/{id} — 사용자를 조회한다")
void getUser_success() throws Exception {
// given
UserResponse response = new UserResponse(1L, "김철수", "kim@test.com");
when(userService.findById(1L)).thenReturn(response);
// when & then
mockMvc.perform(get("/api/users/{id}", 1L)
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("김철수"))
.andExpect(jsonPath("$.email").value("kim@test.com"))
.andDo(print()); // 요청/응답 로그 출력
}
@Test
@DisplayName("POST /api/users — 이름이 비어있으면 400 에러")
void createUser_emptyName_returns400() throws Exception {
// given
String requestBody = """
{
"name": "",
"email": "kim@test.com"
}
""";
// when & then
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content(requestBody))
.andExpect(status().isBadRequest());
}
@Test
@DisplayName("GET /api/users/{id} — 존재하지 않으면 404 에러")
void getUser_notFound_returns404() throws Exception {
// given
when(userService.findById(999L))
.thenThrow(new UserNotFoundException("사용자를 찾을 수 없습니다"));
// when & then
mockMvc.perform(get("/api/users/{id}", 999L))
.andExpect(status().isNotFound());
}
}
6.4 @DataJpaTest — Repository 테스트
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
@DataJpaTest
class UserRepositoryTest {
@Autowired
private UserRepository userRepository;
@Autowired
private TestEntityManager em; // flush/clear 제어용
@Test
@DisplayName("이메일로 사용자를 찾을 수 있다")
void findByEmail_success() {
// given
User user = new User("김철수", "kim@test.com");
em.persistAndFlush(user);
em.clear(); // 1차 캐시 초기화 → DB에서 실제 조회 보장
// when
Optional<User> found = userRepository.findByEmail("kim@test.com");
// then
assertThat(found).isPresent();
assertThat(found.get().getName()).isEqualTo("김철수");
}
@Test
@DisplayName("활성 사용자만 조회한다")
void findActiveUsers() {
// given
em.persistAndFlush(new User("활성1", "a@test.com", true));
em.persistAndFlush(new User("활성2", "b@test.com", true));
em.persistAndFlush(new User("비활성", "c@test.com", false));
em.clear();
// when
List<User> activeUsers = userRepository.findByActiveTrue();
// then
assertThat(activeUsers).hasSize(2)
.extracting("name")
.containsExactlyInAnyOrder("활성1", "활성2");
}
}
6.5 @MockBean vs @Mock
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
┌────────────────────────────────────────────────────────────────────────┐
│ @Mock vs @MockBean │
├────────────────────────────────────────────────────────────────────────┤
│ │
│ @Mock (Mockito) │
│ → Spring 컨텍스트와 무관한 순수 Mock 객체 │
│ → @ExtendWith(MockitoExtension.class)와 함께 사용 │
│ → @InjectMocks로 수동 주입 │
│ → Spring이 개입하지 않으므로 매우 빠름 │
│ → 단위 테스트에 사용 │
│ │
│ @MockBean (Spring Boot Test) │
│ → Spring ApplicationContext의 실제 Bean을 Mock으로 교체 │
│ → @SpringBootTest, @WebMvcTest 등과 함께 사용 │
│ → Spring이 DI를 통해 자동 주입 │
│ → 컨텍스트를 재로딩할 수 있으므로 상대적으로 느림 │
│ → Slice/통합 테스트에서 특정 Bean만 Mock으로 대체할 때 사용 │
│ │
│ 선택 기준: │
│ Service 로직만 테스트 (DB 불필요) → @Mock + @InjectMocks │
│ Controller 테스트 (MockMvc 필요) → @WebMvcTest + @MockBean │
│ 통합 테스트에서 외부 API만 Mock → @SpringBootTest + @MockBean │
└────────────────────────────────────────────────────────────────────────┘
7. TDD (Test-Driven Development)
7.1 TDD 사이클
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
┌────────────────────────────────────────────────────────────────────────┐
│ TDD 사이클: Red → Green → Refactor │
├────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────┐ │
│ │ RED │ 1. 실패하는 테스트를 먼저 작성 │
│ │ (빨강) │ → 구현 코드가 없으므로 당연히 실패 │
│ └────┬────┘ → "이 기능이 필요하다"를 테스트로 표현 │
│ │ │
│ ▼ │
│ ┌─────────┐ │
│ │ GREEN │ 2. 테스트를 통과하는 최소한의 코드 작성 │
│ │ (초록) │ → 깔끔한 코드가 아니어도 됨 │
│ └────┬────┘ → 테스트가 통과하는 것이 목표 │
│ │ │
│ ▼ │
│ ┌──────────┐ │
│ │ REFACTOR │ 3. 코드를 깔끔하게 리팩토링 │
│ │ (리팩터) │ → 테스트가 계속 통과하는지 확인하며 │
│ └────┬─────┘ → 중복 제거, 네이밍 개선, 구조 개선 │
│ │ │
│ └──────── → RED로 돌아가서 반복 │
│ │
│ 핵심 원칙: │
│ • 구현 코드보다 테스트를 먼저 작성 │
│ • 한 번에 하나의 기능만 추가 │
│ • 테스트가 통과하면 바로 리팩토링 │
└────────────────────────────────────────────────────────────────────────┘
7.2 TDD 예시 — 계산기
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// Step 1: RED — 실패하는 테스트 작성
@Test
void add_twoNumbers_returnsSum() {
Calculator calc = new Calculator();
assertThat(calc.add(2, 3)).isEqualTo(5);
// → 컴파일 에러! Calculator 클래스가 없음
}
// Step 2: GREEN — 최소한의 구현
public class Calculator {
public int add(int a, int b) {
return a + b; // 테스트 통과!
}
}
// Step 3: RED — 다음 기능 테스트
@Test
void add_negativeNumbers_returnsSum() {
assertThat(calc.add(-1, -2)).isEqualTo(-3); // 통과 (이미 구현됨)
}
@Test
void divide_byZero_throwsException() {
assertThatThrownBy(() -> calc.divide(10, 0))
.isInstanceOf(ArithmeticException.class);
// → RED! divide 메서드가 없음
}
// Step 4: GREEN — divide 구현
public int divide(int a, int b) {
if (b == 0) throw new ArithmeticException("0으로 나눌 수 없습니다");
return a / b;
}
7.3 TDD의 장단점
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
┌────────────────────────────────────────────────────────────────────────┐
│ TDD 장단점 │
├────────────────────────────────────────────────────────────────────────┤
│ │
│ 장점: │
│ ✅ 자연스럽게 테스트 커버리지가 높아짐 │
│ ✅ 테스트하기 쉬운 코드 = 좋은 설계 (인터페이스 분리, DI 활용) │
│ ✅ 리팩토링 시 안전망 → 자신 있게 코드 변경 가능 │
│ ✅ 요구사항을 테스트로 먼저 정의 → 구현 범위가 명확 │
│ ✅ 디버깅 시간 감소 → 작은 단위로 검증하므로 버그를 빨리 발견 │
│ │
│ 단점 / 현실: │
│ ⚠️ 초기 개발 속도가 느려질 수 있음 (익숙해지면 개선) │
│ ⚠️ 모든 코드에 TDD를 적용하는 것은 비현실적 │
│ → 비즈니스 로직(Service) 위주로 적용하는 것이 실용적 │
│ → Controller, Entity는 구현 후 테스트 작성도 괜찮음 │
│ ⚠️ 잘못된 TDD → 구현에 종속된 깨지기 쉬운 테스트 │
│ → "어떻게"가 아닌 "무엇을"을 테스트해야 함 │
│ │
│ 면접 팁: │
│ "TDD를 실천하시나요?" 에 대한 현실적인 답변: │
│ "핵심 비즈니스 로직은 TDD로, 나머지는 구현 후 테스트를 작성합니다. │
│ 중요한 것은 TDD 자체보다 테스트가 존재하고 유지되는 것이라고 │
│ 생각합니다." │
└────────────────────────────────────────────────────────────────────────┘
8. 좋은 테스트의 원칙 — FIRST
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
┌────────────────────────────────────────────────────────────────────────┐
│ FIRST 원칙 │
├────────────────────────────────────────────────────────────────────────┤
│ │
│ F — Fast (빠르게) │
│ → 단위 테스트는 밀리초 단위로 실행되어야 한다 │
│ → 느리면 자주 실행하지 않게 되고, 피드백이 늦어진다 │
│ │
│ I — Independent (독립적으로) │
│ → 테스트 간 의존성이 없어야 한다 │
│ → 테스트 A가 실패해도 테스트 B에 영향 없음 │
│ → 실행 순서에 관계없이 같은 결과 │
│ │
│ R — Repeatable (반복 가능하게) │
│ → 언제, 어디서, 몇 번 실행해도 같은 결과 │
│ → 현재 시간, 랜덤 값, 외부 API에 의존하면 안 됨 │
│ → Clock, Random을 주입받아 테스트에서 고정 │
│ │
│ S — Self-Validating (자가 검증) │
│ → 테스트 결과는 성공/실패로 자동 판단 │
│ → console.log로 눈으로 확인하는 것은 테스트가 아님 │
│ │
│ T — Timely (적시에) │
│ → 프로덕션 코드와 함께 (또는 먼저) 작성 │
│ → 나중에 쓰면 안 쓰게 된다 │
└────────────────────────────────────────────────────────────────────────┘
9. 테스트에서 흔히 하는 실수
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
┌────────────────────────────────────────────────────────────────────────┐
│ 테스트 안티 패턴 │
├────────────────────────────────────────────────────────────────────────┤
│ │
│ ❌ 1. 구현에 종속된 테스트 │
│ // 나쁜 예: 내부 메서드 호출 순서를 검증 │
│ verify(repo).findById(1L); // findById를 호출했는지 │
│ verify(repo).save(any()); // save를 호출했는지 │
│ verify(emailSvc).send(any()); // 이메일 보냈는지 │
│ // → 리팩토링하면 테스트가 깨짐 (구현이 바뀌었을 뿐인데) │
│ │
│ // 좋은 예: 결과(행동)를 검증 │
│ assertThat(result.getStatus()).isEqualTo(CREATED); │
│ assertThat(result.getTotalPrice()).isEqualTo(30000); │
│ // → 내부 구현이 바뀌어도 결과가 같으면 테스트 통과 │
│ │
│ ❌ 2. 하나의 테스트에서 너무 많은 것을 검증 │
│ @Test void testEverything() { │
│ // 회원가입 → 로그인 → 주문 → 결제 → 배송 → ... │
│ // 실패 시 어디서 실패했는지 알기 어려움 │
│ } │
│ → 테스트 하나당 하나의 시나리오/행동 검증 │
│ │
│ ❌ 3. 테스트 간 상태 공유 │
│ static List<User> users = new ArrayList<>(); // 공유 상태! │
│ @Test void test1() { users.add(new User("A")); } │
│ @Test void test2() { assertThat(users).hasSize(0); } // 실패! │
│ → @BeforeEach에서 초기화하거나 @Transactional로 롤백 │
│ │
│ ❌ 4. 모든 것을 Mock │
│ // Mock을 너무 많이 쓰면 실제 동작과 달라질 수 있음 │
│ // Repository Mock으로 Service 테스트 → 쿼리 오류를 못 잡음 │
│ → 단위 테스트(Mock) + 통합 테스트(실 DB) 조합이 필요 │
│ │
│ ❌ 5. 테스트 이름이 불명확 │
│ @Test void test1() { ... } │
│ @Test void userTest() { ... } │
│ → @DisplayName("이메일이 중복되면 가입에 실패한다") 사용 │
│ → 메서드명도: createUser_duplicateEmail_throwsException() │
└────────────────────────────────────────────────────────────────────────┘
10. 실무에서의 테스트 전략
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
┌────────────────────────────────────────────────────────────────────────┐
│ 레이어별 테스트 전략 │
├────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─ Controller ─────────────────────────────────────────────────┐ │
│ │ @WebMvcTest + @MockBean │ │
│ │ → HTTP 요청/응답 형식, 상태 코드, Validation 검증 │ │
│ │ → Service 로직은 Mock │ │
│ │ → 빠르고 Controller 역할에 집중 │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─ Service ────────────────────────────────────────────────────┐ │
│ │ ■ 단위 테스트: @Mock + @InjectMocks (Mockito) │ │
│ │ → 비즈니스 로직 검증 (조건 분기, 계산, 예외 처리) │ │
│ │ → Repository, 외부 서비스는 Mock │ │
│ │ → 가장 많이 작성 │ │
│ │ │ │
│ │ ■ 통합 테스트: @SpringBootTest + @Transactional │ │
│ │ → Service + Repository + DB 전체 흐름 검증 │ │
│ │ → 핵심 시나리오에 대해서만 작성 │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─ Repository ─────────────────────────────────────────────────┐ │
│ │ @DataJpaTest │ │
│ │ → 커스텀 쿼리(@Query), 복잡한 조건 검색 검증 │ │
│ │ → JPA 기본 CRUD는 테스트 불필요 (Spring Data JPA가 보장) │ │
│ │ → 네이티브 쿼리는 반드시 테스트 │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─ 전체 흐름 (E2E) ───────────────────────────────────────────┐ │
│ │ @SpringBootTest(webEnvironment = RANDOM_PORT) │ │
│ │ + TestRestTemplate 또는 RestAssured │ │
│ │ → 실제 HTTP 요청 → Controller → Service → DB → 응답 │ │
│ │ → 핵심 Happy Path에 대해서만 (1-2개) │ │
│ └──────────────────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────────────────┘
11. 면접 질문 & 답변
Q1. 단위 테스트와 통합 테스트의 차이를 설명해주세요.
단위 테스트는 하나의 클래스나 메서드를 외부 의존성 없이 격리하여 테스트합니다. Repository는 Mock으로 대체하고, Spring 컨텍스트를 로드하지 않으므로 밀리초 단위로 빠릅니다. 비즈니스 로직의 정확성을 검증하며, 실패 시 원인을 즉시 파악할 수 있습니다.
통합 테스트는 여러 컴포넌트를 조합하여 실제 연동이 잘 되는지 테스트합니다. @SpringBootTest로 Spring 컨텍스트를 로드하고 실제(또는 인메모리) DB를 사용합니다. Service가 Repository를 통해 DB에 정상적으로 저장/조회하는지 등을 검증합니다. 단위 테스트보다 느리지만 실제 환경에 가깝습니다.
실무에서는 단위 테스트를 가장 많이 작성하고(70%), 통합 테스트는 핵심 시나리오에 대해(20%), E2E 테스트는 최소한(10%)으로 하는 테스트 피라미드 전략을 따릅니다.
Q2. Mock과 Stub의 차이는 무엇인가요?
Stub은 미리 정해진 값을 반환하도록 설정하는 것입니다. when(repo.findById(1L)).thenReturn(user) 처럼 “이 메서드가 호출되면 이 값을 반환해”라고 설정합니다. 테스트의 입력을 제어하는 데 사용합니다.
Mock은 메서드가 호출되었는지 여부와 횟수를 검증하는 것입니다. verify(emailService, times(1)).sendWelcomeEmail(any()) 처럼 “이 메서드가 1번 호출되었는지” 확인합니다. 테스트의 출력(부수효과)을 검증하는 데 사용합니다.
Mockito에서는 @Mock 하나로 Stub과 Mock 역할을 모두 수행할 수 있어, 실무에서 굳이 구분하지 않고 사용하는 경우가 많습니다.
Q3. @Mock과 @MockBean의 차이는?
@Mock은 Mockito가 생성하는 순수 Mock 객체로, Spring 컨텍스트와 무관합니다. @ExtendWith(MockitoExtension.class)와 함께 사용하고, @InjectMocks로 수동 주입합니다. Spring을 띄우지 않으므로 매우 빠릅니다.
@MockBean은 Spring ApplicationContext의 실제 Bean을 Mock으로 교체합니다. @SpringBootTest, @WebMvcTest 등과 함께 사용하며, Spring DI로 자동 주입됩니다. 컨텍스트 재로딩이 필요할 수 있어 상대적으로 느립니다.
Service 로직만 테스트할 때는 @Mock(단위 테스트), Controller를 MockMvc로 테스트할 때는 @MockBean(Slice 테스트)을 사용합니다.
Q4. @SpringBootTest와 @WebMvcTest, @DataJpaTest의 차이는?
@SpringBootTest는 전체 Spring 컨텍스트를 로드합니다. 모든 Bean이 생성되므로 가장 실제 환경에 가깝지만 느립니다. 통합 테스트에 사용합니다.
@WebMvcTest(Controller.class)는 Controller 관련 Bean만 로드합니다. MockMvc로 HTTP 요청/응답을 검증하고, Service는 @MockBean으로 대체합니다. Controller의 URL 매핑, Validation, 응답 형식을 테스트합니다.
@DataJpaTest는 JPA 관련 Bean만 로드합니다. 내장 H2 DB가 자동 구성되고, @Transactional이 적용되어 테스트 후 자동 롤백됩니다. Repository의 커스텀 쿼리 정확성을 검증합니다.
Slice 테스트는 필요한 부분만 로드하므로 @SpringBootTest보다 훨씬 빠릅니다.
Q5. TDD란 무엇이고, 실무에서 어떻게 적용하나요?
TDD는 테스트를 먼저 작성하고, 테스트를 통과하는 코드를 구현한 뒤, 리팩토링하는 개발 방법론입니다. Red(실패) → Green(통과) → Refactor(개선)의 짧은 사이클을 반복합니다.
장점은 자연스럽게 높은 테스트 커버리지가 확보되고, 테스트하기 쉬운 코드가 곧 좋은 설계(인터페이스 분리, DI 활용)로 이어진다는 것입니다. 리팩토링 시에도 테스트가 안전망 역할을 합니다.
실무에서는 모든 코드에 TDD를 적용하기보다, 핵심 비즈니스 로직(Service)에 집중적으로 적용합니다. Controller나 Entity는 구현 후 테스트를 작성하는 것도 현실적입니다. 중요한 것은 TDD 자체보다 테스트가 존재하고 지속적으로 유지되는 것입니다.
Q6. 좋은 테스트 코드를 작성하는 원칙은?
FIRST 원칙을 따릅니다. Fast(빠르게 — 밀리초 단위), Independent(독립적 — 테스트 간 의존 없음), Repeatable(반복 가능 — 언제 실행해도 같은 결과), Self-Validating(자가 검증 — 성공/실패 자동 판단), Timely(적시에 — 코드와 함께 작성).
구조적으로는 Given-When-Then 패턴으로 준비-실행-검증을 명확히 구분합니다. 테스트 이름은 @DisplayName으로 한글로 의미를 표현하고, 하나의 테스트에는 하나의 시나리오만 검증합니다.
가장 중요한 것은 구현이 아닌 행동을 테스트하는 것입니다. 내부 메서드 호출 순서를 verify하면 리팩토링 시 깨지는 깨지기 쉬운 테스트가 됩니다. 대신 결과값이나 상태 변화를 검증해야 합니다.
Q7. 테스트에서 DB는 어떻게 처리하나요?
세 가지 방법이 있습니다.
첫째, 인메모리 DB(H2)를 사용합니다. @DataJpaTest는 기본적으로 H2를 자동 구성합니다. 빠르고 간편하지만, 실제 DB(MySQL/PostgreSQL)와 SQL 문법이 다를 수 있습니다.
둘째, Testcontainers로 실제 DB를 Docker 컨테이너로 실행합니다. 실제 MySQL/PostgreSQL을 사용하므로 DB 호환성 문제가 없지만, 테스트 시작이 느립니다(컨테이너 초기화).
셋째, @Transactional을 테스트에 붙여 자동 롤백합니다. 각 테스트가 끝나면 DB를 원래 상태로 되돌려 테스트 간 격리를 보장합니다. 단, @SpringBootTest(webEnvironment = RANDOM_PORT)에서는 실제 HTTP 호출이 별도 스레드에서 이루어져 롤백이 안 될 수 있으므로 주의해야 합니다.
정리
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
┌────────────────────────────────────────────────────────────────────────┐
│ 핵심 요약 │
├────────────────────────────────────────────────────────────────────────┤
│ │
│ 1. 테스트 피라미드: 단위(70%) > 통합(20%) > E2E(10%) │
│ 단위 테스트가 많을수록 빠른 피드백, 쉬운 디버깅 │
│ │
│ 2. Given-When-Then으로 구조화 │
│ 준비 → 실행 → 검증을 명확히 분리 │
│ │
│ 3. Mock으로 외부 의존성 격리 │
│ @Mock + @InjectMocks (단위), @MockBean (Slice/통합) │
│ │
│ 4. Spring Boot Slice 테스트 │
│ Controller → @WebMvcTest │
│ Repository → @DataJpaTest │
│ Service 단위 → Mockito만 (Spring 불필요) │
│ 전체 흐름 → @SpringBootTest │
│ │
│ 5. TDD = Red → Green → Refactor │
│ 핵심 비즈니스 로직 위주로 실용적 적용 │
│ │
│ 6. 구현이 아닌 행동을 테스트 │
│ 내부 호출 순서 verify ❌ → 결과/상태 변화 검증 ✅ │
│ │
│ 7. FIRST 원칙: Fast, Independent, Repeatable, Self-Validating, Timely│
└────────────────────────────────────────────────────────────────────────┘