테스트 전략 완벽 가이드: 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, 응답 형식을 테스트합니다.

@DataJpaTestJPA 관련 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│
└────────────────────────────────────────────────────────────────────────┘