🎯 19주차 학습 커리큘럼 — 테스트 심화

18주차(Spring Security) 이후 Claude가 임의로 구성한 학습 경로.
시니어 면접 단골이지만 4년차 개발자가 가장 약한 영역인 테스트 를 정복한다.

  • JUnit5 깊이 (6주차 입문의 본격 확장)
  • Mockito 본격 (단위 테스트의 실전)
  • Spring 테스트 슬라이스 (계층별 테스트 전략)
  • MockMvc (Controller 테스트)
  • Testcontainers (현대 통합 테스트 표준)
  • 테스트 전략과 TDD

시니어 백엔드 개발자가 "테스트를 작성한다"가 아니라 "테스트로 설계한다" 의 단계로 가는 주차.


🤔 왜 19주차에 테스트인가

1~18주차의 테스트 위치:

주차테스트 등장
6주차JUnit 입문 (assertEquals 정도)
그 외거의 없음

문제 의식:

  • 4년차 풀스택 개발자가 "테스트 어떻게 작성하시나요?" 에 명확히 답할 수 있는가?
  • "통합 테스트 경험은?" "Testcontainers 써보셨나요?" 에 자신 있게?

왜 이 시점에 결정적인가:

  1. 시니어 면접 단골 — "테스트 전략은?" "TDD 해보셨나요?" "Mock과 Stub 차이는?"
  2. 18주차까지의 모든 것을 검증하는 도구 — Spring/JPA/Security 모두 테스트 대상
  3. 실무 코드 품질의 척도 — 4년차의 진짜 차별화
  4. 리팩토링의 안전망 — 12주차 N+1 수정 같은 작업은 테스트 없이 위험

ILIC 관점:

  • 102 테이블, 431 API → 테스트 커버리지가 곧 안정성
  • 빈약한 테스트 = 신규 기능 추가의 두려움
  • 견고한 테스트 = 자신 있는 리팩토링 + 빠른 출시

📊 학습 경로 한눈에 보기

[Phase 1] 테스트 철학 — 왜, 무엇을, 어떻게
   ↓
[Phase 2] JUnit5 본격 (6주차 확장)
   ↓
[Phase 3] Mockito 깊이 (Mock vs Stub vs Spy) ◄ 정점 1
   ↓
[Phase 4] Spring 테스트 슬라이스 (@DataJpaTest, @WebMvcTest 등)
   ↓
[Phase 5] MockMvc로 Controller 테스트
   ↓
[Phase 6] 통합 테스트와 Testcontainers ◄ 정점 2
   ↓
[Phase 7] 테스트 전략 (피라미드, TDD, BDD)
   ↓
[Phase 8] 테스트 품질 (Coverage, Mutation, ArchUnit)

총 8 Phase × 27 Unit — 정점 2개를 가진 단일 주차.

🔗 1~19주차 흐름 정리

주차주제의미
1~18주차기능 구현 (Java/Spring/JPA/DB/Security)만들기
19주차 (지금)테스트검증하기

핵심 통찰:

"1~18주차는 무엇을 만들지, 19주차는 만든 것을 어떻게 보장할지"


🗓️ 권장 학습 일정 (압축 7일)

DayPhase학습 목표
1일차Phase 1 + 2철학 + JUnit5 본격
2일차Phase 3Mockito 깊이 (★)
3일차Phase 4Spring 테스트 슬라이스
4일차Phase 5MockMvc Controller 테스트
5일차Phase 6Testcontainers (★)
6일차Phase 7테스트 전략 + TDD
7일차Phase 8 + 종합Coverage + ArchUnit + 자기 점검

여유 일정 (10일): Phase 3, 6에 +1일씩. 직접 ILIC 코드에 테스트 작성하며 학습 권장.


📚 Phase 1 — 테스트 철학

목표: "왜, 무엇을, 어떻게" 라는 근본 질문에 답한다.

Unit 1.1 — 왜 테스트를 작성하는가

선수 지식: 없음

핵심 질문: "왜 테스트를 작성해야 하는가?"

4가지 답 ⭐ :

1. 회귀 방지 (Regression Prevention) — 가장 중요

시나리오:

  • 운임 견적 로직 수정 → 다른 곳이 깨짐
  • 발견은 운영 환경에서 → 사고

테스트가 있다면:

  • 로컬에서 실패하는 테스트 → 즉시 발견
  • 배포 전 차단

2. 설계 도구 (Design Tool)

TDD의 본질:

  • "테스트하기 어렵다" = "설계가 안 좋다"
  • 테스트 작성하면서 의존성/책임 분리 강제됨

:

  • 한 메서드에 5가지 일을 한다 → 테스트 5개 필요
  • → 분리 압력

3. 살아있는 문서 (Living Documentation)

테스트 = 사용 예시:

@Test
void 로그인_성공시_JWT_토큰을_반환한다() {
    // 이 코드가 곧 사용 방법
}

→ 주석은 낡지만 테스트는 깨짐 = 항상 최신

4. 자신감 (Confidence)

  • 리팩토링 두려움 ↓
  • "건드리면 깨질 것 같다" → "테스트가 있으니 괜찮다"
  • 시니어가 가장 가치 있게 여기는 부분

테스트 ROI 곡선:

  • 초기 비용: 테스트 작성 시간 ↑
  • 장기 이익: 버그 수정 시간 ↓ + 자신감 ↑
  • 손익분기: 보통 3-6개월

ILIC 시나리오:

  • 견적 로직 변경 시 결제 로직이 깨질 수 있나? → 테스트가 답
  • 신규 개발자 온보딩 → 테스트 = 학습 자료

자기 점검

  • "테스트가 있어서 살았다" 사례를 본인 경험에서 찾을 수 있는가?
  • "테스트 작성에 시간이 너무 걸린다"는 반론에 어떻게 답할까?

Unit 1.2 — 좋은 테스트의 5가지 특성 (FIRST + 가독성)

선수 지식: Unit 1.1

FIRST 원칙 ⭐ :

원칙의미
Fast빠름 — ms 단위
Isolated독립적 — 순서/외부 의존 X
Repeatable반복 가능 — 같은 결과
Self-validating자체 검증 — Pass/Fail 명확
Timely시기적절 — 코드와 함께

Fast ⭐ :

  • 단위 테스트: ms
  • 통합 테스트: 1-2초
  • 전체 테스트 스위트: 분 단위
  • 느린 테스트는 안 돌리게 됨 = 효과 X

왜 느려지나:

  • DB 매번 연결
  • 외부 API 호출
  • @SpringBootTest 남발

해결:

  • 단위 테스트는 Mockito
  • 통합 테스트는 Testcontainers + 재사용

Isolated (독립적):

  • 테스트 간 순서 무관
  • 한 테스트가 다른 테스트에 영향 X

위반 사례:

@Test
void test1() { user = createUser(); }  // 공유 변수 변경

@Test
void test2() { 
    assertNotNull(user);  // test1 실행 안 되면 깨짐 ❌
}

해결:

  • @BeforeEach 로 매번 초기화
  • @DirtiesContext (Spring)
  • @Transactional + 자동 롤백

Repeatable:

  • 시간/환경 무관
  • 같은 입력 = 같은 결과

위반 사례:

@Test
void 주말에는_실패한다() {
    LocalDate today = LocalDate.now();  // 시간 의존 ❌
    assertFalse(isWeekend(today));
}

해결:

  • 시간 주입 (Clock)
  • 외부 시스템 Mock

Self-validating:

  • 사람이 결과를 해석 X
  • assertion으로 자동 검증

나쁜 예:

System.out.println(result);  // ❌ — 사람이 봐야 함

좋은 예:

assertThat(result).isEqualTo(expected);  // ✅

Timely:

  • TDD: 코드 전에
  • 최소: 코드와 함께
  • 절대 X: "나중에 추가"

가독성 (FIRST에 추가) ⭐ :

Given-When-Then 패턴:

@Test
void 잔액이_부족하면_결제가_실패한다() {
    // Given (준비)
    Account account = new Account(1000);
    Payment payment = new Payment(2000);
    
    // When (행동)
    PaymentResult result = paymentService.process(account, payment);
    
    // Then (검증)
    assertThat(result.isSuccess()).isFalse();
    assertThat(result.getReason()).isEqualTo("INSUFFICIENT_BALANCE");
}

테스트 메서드 이름 ⭐ :

  • 한글 권장 (실무 표준화 됨)
  • "조건상황결과" 형식
  • 예: 잔액이_부족하면_결제가_실패한다

자기 점검

  • ILIC의 테스트가 FIRST 5가지를 만족하는가?
  • 가장 위반하기 쉬운 원칙은? (힌트: Isolated — 공유 상태)

Unit 1.3 — 테스트 종류와 피라미드

선수 지식: Unit 1.2

테스트 분류 ⭐ :

단위 테스트 (Unit Test)

  • 한 클래스/메서드 검증
  • Mock으로 의존성 격리
  • ms 단위, 매우 많음

통합 테스트 (Integration Test)

  • 여러 컴포넌트 협력 검증
  • 실제 DB, 외부 시스템 일부 사용
  • 초 단위, 적당히

E2E 테스트 (End-to-End)

  • 전체 시스템 시나리오
  • 실제 사용자 흐름
  • 분 단위, 매우 적음

컴포넌트/슬라이스 테스트

  • Spring 슬라이스 (예: @WebMvcTest)
  • 단위와 통합 사이

테스트 피라미드 ⭐⭐ :

        [E2E]           ← 적게 (느림, 비쌈)
       /     \
      /       \
   [통합 테스트]        ← 적당히
   /            \
  /              \
[단위 테스트]          ← 많이 (빠름, 쌈) ⭐

원칙:

  • 70% 단위
  • 20% 통합
  • 10% E2E

왜 피라미드?:

  • 단위 테스트는 빠르고 명확 → 많이
  • E2E는 느리고 fragile → 적게
  • 비용 대비 효과 최적화

역피라미드 (Anti-pattern) ⚠️ :

[E2E 많음]              ← ❌ 잘못
   |
[통합]
   |
[단위 적음]

증상:

  • 테스트가 느림 (전체 30분+)
  • Flaky (가끔 실패)
  • 유지보수 어려움
  • 결국 테스트를 안 돌림

아이스크림 콘 (Anti-pattern):

  • 수동 테스트 위주 + UI 테스트만
  • 자동화 거의 없음
  • 회귀 발생 → 발견 늦음

ILIC 시나리오:

  • 운임 계산 로직 → 단위 테스트 (많이)
  • Repository 쿼리 → 슬라이스 테스트
  • 결제 + 알림 통합 시나리오 → 통합 테스트
  • 사용자 회원가입~결제 전체 → E2E (적게)

자기 점검

  • ILIC의 현재 테스트 분포는? (실제 평가)
  • 어떤 영역이 부족한가?

📚 Phase 2 — JUnit5 본격

목표: 6주차 입문을 넘어 JUnit5의 모든 핵심 기능을 활용한다.

Unit 2.1 — JUnit5 구조와 어노테이션

선수 지식: 6주차 Phase 5

JUnit5 모듈 구조:

  • Jupiter — 새 API (@Test 등)
  • Vintage — JUnit 4 호환
  • Platform — 실행 엔진

핵심 어노테이션 ⭐ :

어노테이션용도
@Test테스트 메서드
@BeforeEach매 테스트 전
@AfterEach매 테스트 후
@BeforeAll클래스 시작 전 (한 번)
@AfterAll클래스 종료 후 (한 번)
@DisplayName표시 이름
@Disabled비활성화
@Nested중첩 클래스
@Tag태그 (필터링)
@RepeatedTest반복
@ParameterizedTest파라미터화

기본 구조:

class FareCalculatorTest {
    
    private FareCalculator calculator;
    
    @BeforeEach
    void setUp() {
        calculator = new FareCalculator();
    }
    
    @Test
    @DisplayName("기본 운임을 정확히 계산한다")
    void calculateBasicFare() {
        // Given
        Distance distance = Distance.of(100);
        
        // When
        Money fare = calculator.calculate(distance);
        
        // Then
        assertThat(fare).isEqualTo(Money.of(50000));
    }
    
    @Nested
    @DisplayName("할인 적용 시")
    class WhenDiscount {
        
        @Test
        void VIP는_20퍼센트_할인된다() { ... }
        
        @Test
        void 학생은_10퍼센트_할인된다() { ... }
    }
}

@Nested 활용 ⭐ :

  • 같은 컨텍스트의 테스트 그룹화
  • @BeforeEach 도 컨텍스트별로 가능
  • 가독성 ↑

자기 점검

  • JUnit 4의 @Before와 JUnit 5의 @BeforeEach의 차이는? (힌트: 정적 import 등)
  • @BeforeAll은 왜 static? (힌트: 인스턴스 생성 전에 실행)

Unit 2.2 — AssertJ로 풍부한 검증 ⭐

선수 지식: Unit 2.1

JUnit 기본 vs AssertJ:

// JUnit 기본 ❌ (가독성 ↓)
assertEquals(expected, actual);
assertTrue(list.contains("apple"));

// AssertJ ⭐ (자연스러움)
assertThat(actual).isEqualTo(expected);
assertThat(list).contains("apple");

Spring Boot에 기본 포함 — 추가 설정 불필요.


필수 패턴 ⭐ :

객체 검증

assertThat(user)
    .isNotNull()
    .extracting("name", "email")
    .containsExactly("Alice", "alice@example.com");

컬렉션 검증

assertThat(users)
    .hasSize(3)
    .extracting(User::getName)
    .containsExactly("Alice", "Bob", "Charlie")
    .doesNotContain("Eve");

예외 검증 ⭐

assertThatThrownBy(() -> service.delete(999L))
    .isInstanceOf(EntityNotFoundException.class)
    .hasMessageContaining("999");

// 또는
assertThatExceptionOfType(BusinessException.class)
    .isThrownBy(() -> service.process(invalidInput))
    .withMessage("잘못된 입력");

숫자/문자열

assertThat(amount).isPositive().isBetween(0, 10000);
assertThat(name).startsWith("Mr.").containsIgnoringCase("alice");

Optional

assertThat(repository.findById(1L))
    .isPresent()
    .get()
    .extracting(User::getName)
    .isEqualTo("Alice");

시간

assertThat(timestamp).isAfter(yesterday).isBefore(tomorrow);

Soft Assertion ⭐ :

  • 일반 assertion: 첫 실패에 멈춤
  • Soft assertion: 모든 검증 후 결과
SoftAssertions softly = new SoftAssertions();
softly.assertThat(user.getName()).isEqualTo("Alice");
softly.assertThat(user.getAge()).isEqualTo(25);
softly.assertThat(user.getEmail()).contains("@");
softly.assertAll();  // 모든 실패 한 번에 보고

언제: 한 객체의 여러 속성 검증 시


ILIC 활용:

@Test
void 운임_견적이_정확히_생성된다() {
    Fare fare = fareService.create(request);
    
    assertThat(fare)
        .isNotNull()
        .satisfies(f -> {
            assertThat(f.getCustomerId()).isEqualTo(request.getCustomerId());
            assertThat(f.getStatus()).isEqualTo(FareStatus.DRAFT);
            assertThat(f.getTotalAmount()).isPositive();
        });
}

자기 점검

  • AssertJ와 JUnit 기본 assertion 중 무엇을 쓸까? (힌트: AssertJ — 거의 항상)
  • Soft Assertion이 필요한 시나리오는? (힌트: DTO 검증, 필드 여러 개)

Unit 2.3 — Parameterized Test로 다중 케이스

선수 지식: Unit 2.2

문제:

  • 같은 로직을 여러 입력으로 테스트 → 중복 코드

해결 — @ParameterizedTest:

@ParameterizedTest
@ValueSource(ints = {1, 5, 10, 100})
void 양수는_유효하다(int number) {
    assertThat(validator.isValid(number)).isTrue();
}

다양한 소스 ⭐ :

@ValueSource — 단일 값

@ParameterizedTest
@ValueSource(strings = {"alice@example.com", "bob@test.co.kr"})
void 유효한_이메일을_검증한다(String email) {
    assertThat(emailValidator.isValid(email)).isTrue();
}

@CsvSource — 여러 값 ⭐

@ParameterizedTest
@CsvSource({
    "1, 1, 2",
    "2, 3, 5",
    "10, 20, 30"
})
void 더하기_검증(int a, int b, int expected) {
    assertThat(calculator.add(a, b)).isEqualTo(expected);
}

@CsvFileSource — 외부 CSV

@ParameterizedTest
@CsvFileSource(resources = "/test-data.csv", numLinesToSkip = 1)
void 대량_데이터_검증(String input, String expected) { ... }

@MethodSource — 메서드 ⭐

@ParameterizedTest
@MethodSource("provideFareData")
void 운임_계산(Distance distance, Money expected) {
    assertThat(calculator.calculate(distance)).isEqualTo(expected);
}

private static Stream<Arguments> provideFareData() {
    return Stream.of(
        Arguments.of(Distance.of(0), Money.ZERO),
        Arguments.of(Distance.of(100), Money.of(50000)),
        Arguments.of(Distance.of(500), Money.of(200000))
    );
}

@EnumSource — Enum

@ParameterizedTest
@EnumSource(FareStatus.class)
void 모든_상태를_처리한다(FareStatus status) {
    assertThat(handler.canHandle(status)).isTrue();
}

// 일부만
@EnumSource(value = FareStatus.class, names = {"DRAFT", "SUBMITTED"})

ILIC 활용:

@ParameterizedTest
@CsvSource({
    "0, 0",
    "100, 50000",
    "500, 200000",
    "1000, 350000"  // 할인 적용
})
void 거리별_운임_계산(int distance, int expectedFare) {
    Money fare = calculator.calculate(Distance.of(distance));
    assertThat(fare).isEqualTo(Money.of(expectedFare));
}

자기 점검

  • Parameterized vs 별도 테스트 메서드 — 언제 어떤 걸? (힌트: 같은 로직 다른 입력 → Parameterized)
  • @MethodSource를 외부 클래스로 분리하는 방법은? (힌트: 별도 클래스의 정적 메서드)

📚 Phase 3 — Mockito 깊이 (★ 정점 1)

목표: 단위 테스트의 핵심 도구 — Mock의 세계를 본격 정복.

Unit 3.1 — Mock vs Stub vs Spy vs Fake ⭐⭐⭐

선수 지식: Phase 2

Test Double 5가지 ⭐ :

종류의미
Dummy인자 채우기용 (사용 X)
Stub미리 정한 값 반환
Mock호출 검증
Spy진짜 객체 + 일부 가짜
Fake단순화된 진짜 구현

Stub — "값 반환"

UserRepository repo = mock(UserRepository.class);
when(repo.findById(1L)).thenReturn(Optional.of(new User("Alice")));

// 메서드 호출 시 미리 정한 값 반환
User user = repo.findById(1L).get();

용도: 의존성이 특정 값을 반환하도록


Mock — "호출 검증"

EmailService emailService = mock(EmailService.class);

userService.signup(request);

// 호출되었는지 검증
verify(emailService).sendWelcome(eq("alice@example.com"));
verify(emailService, times(1)).sendWelcome(any());
verify(emailService, never()).sendError(any());

용도: "이 메서드가 호출되었나?" 검증

Stub과 Mock의 차이 ⭐ :

  • Stub: 상태 검증 (반환값)
  • Mock: 행동 검증 (호출 자체)

Spy — "부분 가짜"

List<String> spyList = spy(new ArrayList<>());

spyList.add("real");  // 실제 동작
when(spyList.size()).thenReturn(100);  // 일부만 가짜

assertThat(spyList).contains("real");
assertThat(spyList).hasSize(100);  // ← 가짜

용도: 진짜 객체에 일부만 stub
주의: 남용 시 위험 (테스트 의도 불명확)


Fake — "단순 구현"

class FakeUserRepository implements UserRepository {
    private Map<Long, User> users = new HashMap<>();
    
    @Override
    public Optional<User> findById(Long id) {
        return Optional.ofNullable(users.get(id));
    }
    
    @Override
    public User save(User user) {
        users.put(user.getId(), user);
        return user;
    }
}

용도: 메모리 내 DB, 임시 구현
: H2 DB, In-Memory Cache


비교 매트릭스 ⭐ :

StubMockSpyFake
검증 대상상태행동둘 다상태
진짜 동작XX일부단순화
사용 빈도매우 흔함매우 흔함가끔가끔

Mockito는 Mock + Stub 모두 지원:

  • when().thenReturn() → Stub
  • verify() → Mock

언제 무엇을 쓸까 ⭐ :

상황추천
의존성이 값을 반환해야 함Stub
"이 메서드가 호출되었는가?"Mock
일부만 진짜로Spy
DB/외부 시스템 단순화Fake
인자만 채우기Dummy

자기 점검

  • "Mock과 Stub의 차이는?" — 면접 답변 30초 안에?
  • ILIC에서 Spy를 써야 하는 시나리오를 들 수 있는가?

Unit 3.2 — Mockito 기본 사용 ⭐

선수 지식: Unit 3.1

필수 어노테이션:

@ExtendWith(MockitoExtension.class)
class FareServiceTest {
    
    @Mock
    private FareRepository fareRepository;
    
    @Mock
    private NotificationService notificationService;
    
    @InjectMocks
    private FareService fareService;
    
    @Test
    void 운임_생성_시_알림이_발송된다() {
        // Given
        Fare fare = new Fare(1L, "test");
        when(fareRepository.save(any())).thenReturn(fare);
        
        // When
        fareService.create(request);
        
        // Then
        verify(notificationService).send(eq(1L), anyString());
    }
}

@InjectMocks ⭐ :

  • 테스트 대상 객체 생성
  • @Mock 들을 자동 주입

핵심 메서드 ⭐ :

Stubbing

when(repo.findById(1L)).thenReturn(Optional.of(user));
when(repo.findById(1L)).thenThrow(new RuntimeException());

// 여러 호출에 다른 응답
when(repo.findById(1L))
    .thenReturn(Optional.of(user1))
    .thenReturn(Optional.of(user2))
    .thenReturn(Optional.empty());

void 메서드 stub

doNothing().when(emailService).send(any());
doThrow(new RuntimeException()).when(emailService).send(any());

Verify

verify(repo).save(user);  // 1번 호출
verify(repo, times(2)).save(any());  // 2번
verify(repo, never()).delete(any());  // 0번
verify(repo, atLeast(1)).findAll();
verify(repo, atMost(3)).save(any());

Argument Matchers ⭐

verify(repo).save(any());           // 모든 인자
verify(repo).save(any(User.class)); // User 타입
verify(repo).save(eq(user));        // equals 매칭
verify(repo).save(argThat(u -> u.getName().equals("Alice")));

⚠️ 주의 — 인자 매처 일관성:

// ❌ 안 됨 — eq() 또는 매처를 모두 써야
verify(service).method(eq(1L), "string");

// ✅
verify(service).method(eq(1L), eq("string"));

ArgumentCaptor — 인자 검증 ⭐ :

@Test
void 알림_메시지_내용_검증() {
    fareService.create(request);
    
    ArgumentCaptor<NotificationRequest> captor = 
        ArgumentCaptor.forClass(NotificationRequest.class);
    verify(notificationService).send(captor.capture());
    
    NotificationRequest captured = captor.getValue();
    assertThat(captured.getMessage()).contains("운임");
    assertThat(captured.getRecipient()).isEqualTo("alice@example.com");
}

자기 점검

  • @MockMockito.mock()의 차이는? (힌트: 어노테이션 기반 vs 메서드 — 결과 동일)
  • ArgumentCaptor를 쓰는 이유는? (힌트: 호출 시점의 인자 상세 검증)

Unit 3.3 — Mockito 함정과 Best Practice

선수 지식: Unit 3.2

흔한 함정 ⚠️ :

1. final 클래스 / 메서드

public final class ExternalApiClient { ... }  // ❌ Mockito 기본 X

해결:

  • Mockito 3.4+: mockito-inline 의존성 추가
  • 또는 인터페이스 추출

2. 정적 메서드 (Static Method)

LocalDate.now();  // ❌ 일반 Mockito X

해결:

  • Mockito.mockStatic(LocalDate.class)
  • 또는 Clock 주입 (권장) ⭐
@Service
class FareService {
    private final Clock clock;
    
    public Fare create() {
        LocalDate today = LocalDate.now(clock);  // 주입된 Clock
    }
}

// 테스트
@Mock Clock clock;
when(clock.instant()).thenReturn(...);

3. private 메서드

  • Mockito는 private 메서드 mock X
  • 메서드를 mock해야 한다 = 설계가 잘못됐다
  • → 메서드를 별도 클래스로 추출하라

4. 새로 만든 객체

public Fare create() {
    Fare fare = new Fare();  // ❌ 이걸 mock 못함
}

해결:

  • Factory 패턴
  • Spring 빈으로 분리

Best Practice ⭐ :

1. 한 테스트 = 한 검증

// ❌ 한 테스트가 너무 많이 검증
@Test
void everything() {
    verify(repo).save(any());
    verify(notification).send(any());
    verify(audit).log(any());
    assertThat(...);
}

// ✅ 한 가지에 집중
@Test
void 운임_저장시_DB에_저장된다() {
    verify(repo).save(any());
}

@Test
void 운임_저장시_알림이_발송된다() {
    verify(notification).send(any());
}

2. Behavior 우선, State 보조

  • 행동 검증 (Mock) 위주
  • 상태 검증 (Stub의 결과)는 보조

3. Mock은 인터페이스에

  • 구현체 직접 mock → 결합도 ↑
  • 인터페이스 mock → 추상화 우선

4. Mock을 너무 많이 쓰지 마라 ⭐

Mock 폭증 신호:

  • 한 테스트에 5개 이상 Mock
  • → "통합 테스트가 필요한 시점" 또는 "설계 문제"

자기 점검

  • private 메서드를 mock하고 싶다면 어떻게? (힌트: 추출, 그리고 mock)
  • LocalDate.now()를 직접 호출하는 코드의 테스트 어려움 해결은? (힌트: Clock 주입)

📚 Phase 4 — Spring 테스트 슬라이스

목표: 계층별 테스트 어노테이션을 활용해 빠르고 정확한 테스트를 작성한다.

Unit 4.1 — @SpringBootTest의 함정과 슬라이스의 등장

선수 지식: Phase 3, 6주차 Phase 5

@SpringBootTest — 전체 컨텍스트:

@SpringBootTest
class FullApplicationTest {
    @Autowired
    private FareService service;
    
    // 모든 빈 + DB + 외부 통합
}

문제 ⚠️ :

  • 느림 — 전체 컨텍스트 로딩 (수 초 ~ 수십 초)
  • 남발 시 테스트 스위트 느려짐 → 안 돌리게 됨
  • 단위 테스트가 통합 테스트 흉내

해결 — 필요한 것만 로딩:

  • Repository 테스트 → DB만
  • Controller 테스트 → MVC만
  • 슬라이스 테스트

Spring 테스트 슬라이스 ⭐ :

어노테이션로딩 범위
@SpringBootTest전체
@WebMvcTestController, Filter, Interceptor
@DataJpaTestJPA Repository, EntityManager
@JsonTestJackson
@RestClientTestRestTemplate / WebClient
@DataRedisTestRedis

효과: 같은 테스트가 10초 → 1초

자기 점검

  • "모든 테스트를 @SpringBootTest로 작성한다"의 문제는?
  • 슬라이스 테스트와 단위 테스트의 차이는? (힌트: 단위는 Spring 자체 X)

Unit 4.2 — @DataJpaTest로 Repository 테스트 ⭐

선수 지식: Unit 4.1, 11-12주차 JPA

@DataJpaTest :

  • JPA Repository + EntityManager만 로딩
  • 자동으로 트랜잭션 + 롤백
  • H2 In-Memory DB 자동 사용 (기본)
@DataJpaTest
class FareRepositoryTest {
    
    @Autowired
    private FareRepository fareRepository;
    
    @Autowired
    private TestEntityManager em;  // JPA 헬퍼
    
    @Test
    void 고객_ID로_조회한다() {
        // Given
        Customer customer = em.persist(new Customer("Alice"));
        em.persist(new Fare(customer, 50000));
        em.persist(new Fare(customer, 30000));
        em.flush();
        em.clear();  // 1차 캐시 비움 — 진짜 DB 조회 검증
        
        // When
        List<Fare> fares = fareRepository.findByCustomerId(customer.getId());
        
        // Then
        assertThat(fares).hasSize(2);
    }
}

em.clear() 의 중요성 ⭐ :

  • 11주차 영속성 컨텍스트 1차 캐시
  • em.persist() 직후 findById() → 캐시에서 반환 (DB 안 침)
  • → 진짜 SELECT 쿼리 검증 안 됨
  • em.clear() 로 캐시 비우면 DB 조회 강제

테스트 DB 결정 ⭐ :

H2 In-Memory (기본):

  • 빠름
  • 그러나 실제 DB와 다름 (MySQL 함수 등)
  • → 운영 DB 의존 기능 테스트 어려움

Testcontainers (Phase 6에서):

  • 실제 MySQL 컨테이너
  • 약간 느리지만 실제와 동일

@AutoConfigureTestDatabase:

@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class FareRepositoryTest { ... }

→ H2 자동 교체 X, application.yml 설정 그대로


N+1 문제 테스트 ⭐ :

@Test
void N_플러스_1_검증() {
    em.persist(new Customer("Alice"));
    em.persist(new Customer("Bob"));
    em.flush();
    em.clear();
    
    // 쿼리 카운터 활성화 (별도 라이브러리)
    SQLStatementCountValidator.reset();
    
    List<Customer> customers = customerRepository.findAll();
    customers.forEach(c -> c.getFares().size());  // Lazy 로딩
    
    SQLStatementCountValidator.assertSelectCount(3);  // 1 + 2 = 3 (N+1)
    // 또는 1 (fetch join 사용 시)
}

ILIC 활용:

  • 모든 커스텀 Repository 메서드에 테스트
  • N+1 문제를 테스트로 잡기

자기 점검

  • 왜 @DataJpaTest는 자동 롤백? (힌트: 격리 — 다음 테스트에 영향 X)
  • @DataJpaTest로 외부 API를 mock 할 수 있는가? (힌트: 그 빈은 로딩 안 됨 — 단순함이 장점)

Unit 4.3 — @WebMvcTest로 Controller 테스트 (입문)

선수 지식: Unit 4.2, 15주차 (Spring MVC)

@WebMvcTest :

  • Controller, Filter, Interceptor만 로딩
  • Service/Repository는 로딩 X → Mock 필요
  • MockMvc 자동 주입
@WebMvcTest(FareController.class)
class FareControllerTest {
    
    @Autowired
    private MockMvc mockMvc;
    
    @MockBean  // Spring 컨텍스트에 mock 주입
    private FareService fareService;
    
    @Test
    void 운임_조회_API() throws Exception {
        // Given
        Fare fare = new Fare(1L, "test");
        when(fareService.findById(1L)).thenReturn(fare);
        
        // When + Then
        mockMvc.perform(get("/api/fares/1"))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.id").value(1));
    }
}

Phase 5에서 MockMvc 본격적으로 다룸.


@MockBean vs @Mock ⭐ :

@Mock@MockBean
환경Mockito 단독Spring 컨텍스트
주입@InjectMocksSpring DI
사용단위 테스트슬라이스/통합 테스트

자기 점검

  • @WebMvcTest에서 Repository를 직접 쓸 수 있는가? (힌트: NO — 로딩 안 됨)
  • @MockBean을 남발하면 어떤 문제? (힌트: 매번 컨텍스트 새로 — 느림)

📚 Phase 5 — MockMvc로 Controller 테스트

목표: HTTP 레벨에서 Controller를 검증한다.

Unit 5.1 — MockMvc 기본 사용

선수 지식: Phase 4

MockMvc 본질:

  • 진짜 HTTP 서버 X
  • DispatcherServlet 시뮬레이션 ⭐
  • 빠름 + Spring 흐름 검증

기본 패턴 ⭐ :

mockMvc.perform(post("/api/fares")
        .contentType(MediaType.APPLICATION_JSON)
        .content(objectMapper.writeValueAsString(request)))
    .andExpect(status().isCreated())
    .andExpect(jsonPath("$.id").exists())
    .andExpect(jsonPath("$.amount").value(50000))
    .andDo(print());

핵심 메서드:

perform — 요청 실행

mockMvc.perform(get("/api/fares/1"));
mockMvc.perform(post("/api/fares").content(...).contentType(...));
mockMvc.perform(put("/api/fares/1").content(...));
mockMvc.perform(delete("/api/fares/1"));

// 헤더
mockMvc.perform(get("/api/fares")
    .header("Authorization", "Bearer xxx")
    .param("page", "0")
    .param("size", "10"));

andExpect — 응답 검증

.andExpect(status().isOk())                    // 200
.andExpect(status().isCreated())               // 201
.andExpect(status().isBadRequest())            // 400
.andExpect(status().isNotFound())              // 404

.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andExpect(content().string(containsString("Alice")))

.andExpect(jsonPath("$.name").value("Alice"))
.andExpect(jsonPath("$.id").exists())
.andExpect(jsonPath("$.deletedAt").doesNotExist())

.andExpect(header().string("Location", "/api/fares/1"))

andDo — 부가 작업

.andDo(print())  // 요청/응답 콘솔 출력 (디버깅) ⭐
.andDo(MockMvcRestDocumentation.document("create-fare"))  // 문서화

andReturn — 결과 추출

MvcResult result = mockMvc.perform(...).andReturn();
String content = result.getResponse().getContentAsString();

JSON 검증 — JsonPath ⭐ :

// 배열
.andExpect(jsonPath("$").isArray())
.andExpect(jsonPath("$.length()").value(3))
.andExpect(jsonPath("$[0].name").value("Alice"))

// 중첩 객체
.andExpect(jsonPath("$.customer.email").value("alice@example.com"))

// 조건
.andExpect(jsonPath("$.amount", greaterThan(0)))
.andExpect(jsonPath("$.tags", hasItem("urgent")))

ILIC 시나리오:

@Test
void 운임_생성_API_정상_동작() throws Exception {
    FareRequest request = new FareRequest(...);
    Fare created = new Fare(1L, ...);
    when(fareService.create(any())).thenReturn(created);
    
    mockMvc.perform(post("/api/fares")
            .contentType(MediaType.APPLICATION_JSON)
            .content(objectMapper.writeValueAsString(request)))
        .andExpect(status().isCreated())
        .andExpect(header().string("Location", "/api/fares/1"))
        .andExpect(jsonPath("$.id").value(1))
        .andDo(print());
}

자기 점검

  • MockMvc는 진짜 HTTP 서버를 띄우는가? (힌트: NO — DispatcherServlet 직접 호출)
  • @WebMvcTest 없이 MockMvc만 쓸 수 있는가? (힌트: 가능 — @AutoConfigureMockMvc + @SpringBootTest)

Unit 5.2 — Validation과 예외 처리 테스트

선수 지식: Unit 5.1, 15주차 (Bean Validation)

Validation 실패 테스트 ⭐ :

@Test
void 잘못된_요청은_400을_반환한다() throws Exception {
    // 이름이 비어있는 잘못된 요청
    FareRequest invalid = new FareRequest("", 0, null);
    
    mockMvc.perform(post("/api/fares")
            .contentType(MediaType.APPLICATION_JSON)
            .content(objectMapper.writeValueAsString(invalid)))
        .andExpect(status().isBadRequest())
        .andExpect(jsonPath("$.code").value("VALIDATION_FAILED"))
        .andExpect(jsonPath("$.errors").isArray())
        .andExpect(jsonPath("$.errors[*].field", hasItems("name", "amount")));
}

ControllerAdvice 동작 검증 ⭐ :

@Test
void 존재하지_않는_운임_조회시_404() throws Exception {
    when(fareService.findById(999L))
        .thenThrow(new EntityNotFoundException("Fare 999 not found"));
    
    mockMvc.perform(get("/api/fares/999"))
        .andExpect(status().isNotFound())
        .andExpect(jsonPath("$.code").value("NOT_FOUND"))
        .andExpect(jsonPath("$.message").value("Fare 999 not found"));
}

ControllerAdvice를 MockMvc 테스트에 포함 ⭐ :

  • @WebMvcTest(FareController.class) → Controller만, Advice 별도 등록 필요
  • 보통 자동 등록되지만 명시 필요 시:
@WebMvcTest(controllers = FareController.class,
    includeFilters = @ComponentScan.Filter(
        type = FilterType.ASSIGNABLE_TYPE,
        value = GlobalExceptionHandler.class
    ))

자기 점검

  • 18주차의 인증 실패(401)를 MockMvc로 테스트하려면? (힌트: SecurityFilter도 로딩)
  • @ControllerAdvice가 테스트에 포함 안 되면 어떤 응답? (힌트: 500)

Unit 5.3 — Spring Security와의 통합 테스트

선수 지식: Unit 5.2, 18주차 (Spring Security)

@WithMockUser ⭐ :

@Test
@WithMockUser(roles = "USER")
void 인증된_사용자는_조회_가능() throws Exception {
    mockMvc.perform(get("/api/fares/1"))
        .andExpect(status().isOk());
}

@Test
@WithMockUser(roles = "ADMIN")
void 관리자는_삭제_가능() throws Exception {
    mockMvc.perform(delete("/api/fares/1"))
        .andExpect(status().isNoContent());
}

@Test
void 인증_없이_접근시_401() throws Exception {
    mockMvc.perform(get("/api/fares/1"))
        .andExpect(status().isUnauthorized());
}

커스텀 사용자:

@Test
@WithUserDetails("alice@example.com")
void 특정_사용자로_테스트() throws Exception { ... }

→ UserDetailsService에서 실제 사용자 조회


JWT 토큰 테스트 (커스텀):

@Test
void 유효한_JWT로_접근_가능() throws Exception {
    String token = jwtProvider.createToken("alice@example.com");
    
    mockMvc.perform(get("/api/fares/1")
            .header("Authorization", "Bearer " + token))
        .andExpect(status().isOk());
}

Security를 슬라이스에 포함:

@WebMvcTest(FareController.class)
@Import(SecurityConfig.class)
class FareControllerTest { ... }

ILIC 시나리오:

  • 모든 Controller 테스트에 인증 시나리오 포함
  • 권한 부족(403), 인증 부재(401) 시나리오

자기 점검

  • @WithMockUser와 실제 JWT 테스트 중 무엇이 더 좋은가? (힌트: @WithMockUser는 빠르고 충분)
  • ILIC의 어떤 보안 시나리오를 테스트해야 할까?

📚 Phase 6 — 통합 테스트와 Testcontainers (★ 정점 2)

목표: 현대 통합 테스트의 표준 — 실제 인프라를 컨테이너로 띄워 테스트한다.

Unit 6.1 — H2 vs Testcontainers의 결정

선수 지식: Phase 4

H2 In-Memory (전통):

  • 매우 빠름
  • 메모리 → 격리 자동
  • 실제 DB와 호환성 한계 ⚠️

호환성 문제 사례:

  • MySQL의 JSON 함수 → H2 미지원
  • PostgreSQL의 ARRAY → H2 다른 문법
  • 인덱스/락 동작 차이
  • 결국 운영에서 다르게 동작

Testcontainers ⭐ :

"실제 DB/Redis/Kafka 등을 Docker 컨테이너로 자동 실행"

효과:

  • 운영과 동일한 인프라
  • 컴퓨터에 DB 설치 불필요
  • 자동 정리

비용:

  • 약간 느림 (컨테이너 시작 시간)
  • Docker 필요

선택 가이드 ⭐ :

상황추천
표준 SQL만 사용H2 OK
운영 DB 특화 기능 사용Testcontainers ⭐
Redis/Kafka 통합 테스트Testcontainers
CI 환경Testcontainers (Docker 필수)

현대 트렌드 ⭐ :

"Testcontainers가 사실상 표준"

ILIC가 MySQL을 쓴다면 H2 테스트는 운영과 다른 위험.

자기 점검

  • ILIC에서 H2로 테스트했을 때 운영에서 깨진 적 있는가?
  • Testcontainers의 가장 큰 비용은? (힌트: Docker, 시작 시간)

Unit 6.2 — Testcontainers 기본 사용 ⭐

선수 지식: Unit 6.1

의존성:

testImplementation 'org.testcontainers:junit-jupiter:1.19.0'
testImplementation 'org.testcontainers:mysql:1.19.0'

기본 패턴:

@SpringBootTest
@Testcontainers
class FareIntegrationTest {
    
    @Container
    static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0")
        .withDatabaseName("test")
        .withUsername("test")
        .withPassword("test");
    
    @DynamicPropertySource
    static void properties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", mysql::getJdbcUrl);
        registry.add("spring.datasource.username", mysql::getUsername);
        registry.add("spring.datasource.password", mysql::getPassword);
    }
    
    @Test
    void 운임_생성_통합_테스트() {
        // 실제 MySQL과 통신
    }
}

핵심 어노테이션 ⭐ :

  • @Testcontainers — JUnit 통합
  • @Container — 컨테이너 라이프사이클 관리
  • @DynamicPropertySource — 동적 설정

컨테이너 재사용 ⭐ — 성능 최적화:

@Container
static final MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0")
    .withReuse(true);  // 재사용

~/.testcontainers.properties:

testcontainers.reuse.enable=true

→ 테스트 간에 컨테이너 재사용 (수 초 → ms)


다양한 컨테이너:

  • MySQLContainer, PostgreSQLContainer
  • RedisContainer (커뮤니티)
  • KafkaContainer
  • ElasticsearchContainer
  • LocalStackContainer (AWS)
  • 커스텀 GenericContainer
@Container
static GenericContainer<?> redis = new GenericContainer<>("redis:7")
    .withExposedPorts(6379);

Init 스크립트 :

@Container
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0")
    .withInitScript("init.sql");  // 시작 시 실행
-- init.sql
CREATE TABLE fares (...);
INSERT INTO fares VALUES (...);

ILIC 시나리오:

  • 102 테이블 → Flyway 마이그레이션 자동 적용
  • 실제 MySQL 8.0 컨테이너로 모든 통합 테스트

자기 점검

  • 컨테이너 재사용의 이점과 위험은? (힌트: 빠름 vs 상태 누적)
  • ILIC가 MySQL 함수를 쓴다면 Testcontainers가 결정적인 이유는?

Unit 6.3 — Spring Boot 3 + Testcontainers 모던 패턴 ⭐

선수 지식: Unit 6.2

Spring Boot 3.1+ 의 새 기능:

@ServiceConnection ⭐ — 자동 설정

@SpringBootTest
@Testcontainers
class FareIntegrationTest {
    
    @Container
    @ServiceConnection
    static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0");
    
    // @DynamicPropertySource 불필요!
    // Spring이 자동으로 datasource 설정
}

→ 보일러플레이트 대폭 감소.


여러 컨테이너 조합:

@SpringBootTest
@Testcontainers
class FullIntegrationTest {
    
    @Container
    @ServiceConnection
    static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0");
    
    @Container
    @ServiceConnection
    static GenericContainer<?> redis = new GenericContainer<>("redis:7")
        .withExposedPorts(6379);
    
    @Container
    @ServiceConnection
    static KafkaContainer kafka = new KafkaContainer(
        DockerImageName.parse("confluentinc/cp-kafka:latest")
    );
}

→ 한 테스트에서 MySQL + Redis + Kafka 통합 검증.


개발 환경 통합@TestConfiguration:

@Configuration(proxyBeanMethods = false)
public class TestcontainersConfiguration {
    
    @Bean
    @ServiceConnection
    MySQLContainer<?> mysqlContainer() {
        return new MySQLContainer<>("mysql:8.0");
    }
}

// 모든 테스트에 자동 적용

ILIC 시나리오 ⭐ :

  • 운영 인프라 (MySQL + Redis + Kafka) 그대로 테스트
  • 개발 환경 띄울 때도 같은 설정 사용 가능
  • → 운영과 거의 동일한 보장

자기 점검

  • @ServiceConnection 이전과 이후의 코드 차이는?
  • 통합 테스트가 단위 테스트보다 좋은 영역은? (힌트: 트랜잭션, 동시성)

Unit 6.4 — 통합 테스트 전략

선수 지식: Unit 6.3

통합 테스트의 범위 결정 ⭐ :

1. 실제 통합 테스트

  • DB + Service + Controller 전체
  • 실제 시나리오 검증

2. Slice 통합 테스트

  • 한 슬라이스만 (예: Repository만)
  • @DataJpaTest + Testcontainers

3. 외부 시스템과의 통합

  • WireMock으로 외부 API 시뮬레이션
  • 또는 실제 Sandbox API

핵심 시나리오 — ILIC 예 ⭐ :

@SpringBootTest
@Testcontainers
class 운임_견적_생성_통합_테스트 {
    
    @Container @ServiceConnection
    static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0");
    
    @Autowired private TestRestTemplate restTemplate;
    @Autowired private FareRepository fareRepository;
    @Autowired private NotificationRepository notificationRepository;
    
    @Test
    void 운임_생성_후_DB_저장_및_알림_발송_검증() {
        // Given
        FareRequest request = new FareRequest(...);
        
        // When
        ResponseEntity<Fare> response = restTemplate.postForEntity(
            "/api/fares", request, Fare.class
        );
        
        // Then
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
        
        Long fareId = response.getBody().getId();
        
        // DB 검증
        Fare saved = fareRepository.findById(fareId).orElseThrow();
        assertThat(saved.getStatus()).isEqualTo(FareStatus.DRAFT);
        
        // 알림 검증
        await().atMost(2, SECONDS).untilAsserted(() -> {
            assertThat(notificationRepository.findByFareId(fareId))
                .isNotEmpty();
        });
    }
}

await() — Awaitility 라이브러리:

  • 비동기 작업 기다림
  • 폴링으로 조건 만족 확인

테스트 격리 전략 ⭐ :

1. 트랜잭션 롤백

@Transactional  // 자동 롤백
class FareIntegrationTest { ... }
  • 빠르지만 트랜잭션 의존 코드 검증 어려움 (REQUIRES_NEW 등)

2. 매 테스트 후 데이터 정리

@AfterEach
void cleanUp() {
    fareRepository.deleteAll();
    customerRepository.deleteAll();
}
  • 명시적이지만 번거로움

3. @Sql 활용

@Sql(scripts = "/test-data.sql")
@Sql(scripts = "/clean-up.sql", executionPhase = AFTER_TEST_METHOD)
void test() { ... }

자기 점검

  • 통합 테스트에서 @Transactional 롤백의 함정은? (힌트: REQUIRES_NEW, 비동기 처리)
  • ILIC의 가장 중요한 통합 테스트 시나리오 5개를 선언하라

📚 Phase 7 — 테스트 전략 (TDD, BDD)

목표: 테스트를 작성하는 방법론을 익힌다.

Unit 7.1 — TDD (Test-Driven Development) ⭐

선수 지식: 1~6 Phase

TDD 사이클 — Red, Green, Refactor:

1. Red: 실패하는 테스트 작성
       ↓
2. Green: 테스트를 통과시키는 최소 코드
       ↓
3. Refactor: 코드 개선 (테스트는 계속 통과)
       ↓
   (반복)

예시 — 운임 계산기 TDD:

Red 1

@Test
void 거리가_0이면_요금은_0() {
    FareCalculator calculator = new FareCalculator();
    assertThat(calculator.calculate(0)).isEqualTo(0);
}

→ 컴파일 실패 (FareCalculator 없음)

Green 1

class FareCalculator {
    int calculate(int distance) { return 0; }  // 최소 코드
}

→ 통과

Red 2

@Test
void 거리가_100이면_요금은_50000() {
    assertThat(calculator.calculate(100)).isEqualTo(50000);
}

→ 실패

Green 2

int calculate(int distance) {
    return distance * 500;
}

→ 통과

Refactor

  • 매직 넘버 → 상수
  • 의도 표현

TDD의 장점 ⭐ :

  1. 설계 도구 — 사용자 입장에서 API 결정
  2. 회귀 안전망 — 항상 테스트 존재
  3. 과잉 설계 방지 — 필요한 것만 구현
  4. 자신감 — 작은 단계로 진전

TDD의 단점/오해 :

  1. 느려 보임 — 단기 개발 속도 ↓ (장기는 ↑)
  2. 모든 곳에 적용 X — UI, 탐색적 작업은 부적합
  3. 숙련 필요 — 처음에는 어려움

TDD 적합 영역:

  • 비즈니스 로직 (운임 계산, 할인 등)
  • 알고리즘
  • API 설계

TDD 부적합:

  • UI 디자인
  • 탐색적 프로토타입
  • 성능 최적화 (먼저 측정)

Inside-Out vs Outside-In ⭐ :

Outside-In (London School):

  • 외부(컨트롤러)부터 시작
  • Mock으로 의존성 시뮬레이션
  • 점차 안쪽으로

Inside-Out (Chicago School):

  • 내부(도메인)부터 시작
  • 실제 객체 위주
  • 점차 바깥으로

ILIC 권장:

  • 비즈니스 로직 — Inside-Out
  • API 시나리오 — Outside-In

자기 점검

  • TDD 한 번이라도 진지하게 시도해봤는가?
  • "TDD는 시간 낭비"라는 반론에 어떻게 답할까?

Unit 7.2 — BDD와 Given-When-Then

선수 지식: Unit 7.1

BDD (Behavior-Driven Development):

"비즈니스 행동을 자연어처럼 표현"

TDD vs BDD:

  • TDD: 개발자 관점 — 단위 테스트
  • BDD: 비즈니스 관점 — 사용자 시나리오

Given-When-Then 패턴 ⭐ :

@Test
@DisplayName("VIP 고객은 20% 할인을 받는다")
void VIP_할인() {
    // Given - 상황 설정
    Customer vip = Customer.builder()
        .level(VIP)
        .build();
    Fare fare = new Fare(100000);
    
    // When - 행동 발생
    Money discounted = discountService.apply(fare, vip);
    
    // Then - 결과 검증
    assertThat(discounted).isEqualTo(Money.of(80000));
}

왜 좋은가:

  • 의도가 명확
  • 비즈니스 사용자도 이해 가능
  • 일관된 구조

Cucumber (BDD 도구):

Feature: 운임 할인
  
  Scenario: VIP 고객 할인
    Given 고객이 VIP 등급이다
    And 운임이 100000원이다
    When 할인을 적용한다
    Then 운임은 80000원이 된다

현실:

  • Cucumber는 학습 곡선 ↑
  • 실무에서는 Given-When-Then 패턴만 활용하는 경우 多

ILIC 활용:

  • 모든 비즈니스 로직 테스트에 Given-When-Then
  • @DisplayName으로 비즈니스 의도 표현
  • @Nested로 시나리오 그룹화

자기 점검

  • Given-When-Then을 안 쓰면 어떤 문제? (힌트: 가독성, 의도 불명확)
  • ILIC에 Cucumber를 도입할 가치가 있는가?

Unit 7.3 — 테스트 더블 사용 전략

선수 지식: Unit 3.1, 7.1, 7.2

전략적 결정 — "언제 Mock, 언제 진짜?"

Mock 권장

  • 외부 시스템 (HTTP API, 이메일)
  • 느린 작업 (큰 DB 쿼리)
  • 부작용 있는 작업 (결제, 알림)
  • 시간/랜덤 의존

진짜 사용 권장

  • 도메인 객체 (User, Fare 등 — Mock하지 마라)
  • 단순 값 객체
  • Spring Data Repository (@DataJpaTest로 충분)
  • 같은 모듈 내 협력자

Mock 남용의 경고 신호 ⚠️ :

  • 한 테스트에 Mock 5개+
  • 테스트가 구현 디테일에 결합
  • 리팩토링 시 테스트도 매번 수정
  • → 통합 테스트로 검증할 영역

Mock vs Spy vs 진짜 — 의사결정 트리:

의존성이 외부 시스템인가?
├── YES → Mock
└── NO
    │
    의존성이 도메인 객체인가?
    ├── YES → 진짜 사용
    └── NO
        │
        의존성을 통제해야 하는가?
        ├── YES (특정 값 필요) → Stub
        └── NO → 진짜 또는 Fake

ILIC 시나리오 적용:

class FareServiceTest {
    @Mock private NotificationClient notificationClient;  // 외부 → Mock
    @Mock private PaymentApi paymentApi;                  // 외부 → Mock
    
    private DiscountCalculator calculator;  // 도메인 → 진짜
    private FareValidator validator;        // 도메인 → 진짜
    
    @InjectMocks private FareService fareService;
}

자기 점검

  • "도메인 객체를 Mock하면 안 되는 이유는?" (힌트: 비즈니스 로직 검증 안 됨)
  • 어떤 의존성을 Mock하기 시작하면 통합 테스트로 가야 하는가?

📚 Phase 8 — 테스트 품질 도구

목표: 테스트의 효과를 측정하고 보장한다.

Unit 8.1 — Code Coverage (JaCoCo)

선수 지식: 1~7 Phase

Coverage:

"테스트가 실제 코드를 얼마나 실행하는가"

JaCoCo — Java 표준 도구:

plugins {
    id 'jacoco'
}

jacocoTestReport {
    reports {
        html.required = true
    }
}

test {
    finalizedBy jacocoTestReport
}

./gradlew test jacocoTestReport
build/jacoco/test/html/index.html


Coverage 종류 ⭐ :

종류의미
Line Coverage실행된 라인 비율
Branch Coverageif/else 분기 비율
Method Coverage호출된 메서드 비율

Coverage의 함정 ⚠️ :

1. 100%는 환상

  • 단순 라인 실행 ≠ 검증
  • assertion 없어도 coverage 증가
@Test
void uselessTest() {
    service.method();  // 호출만 — 실패해도 통과
}

→ Line coverage 100%, 실제 검증 0%

2. Coverage 목표 강요의 부작용

  • 의미 없는 테스트 양산
  • "Get/Set만 테스트" 같은 시간 낭비
  • 진짜 비즈니스 로직 누락

현실적 목표:

  • 70-80% Line Coverage 적정 ⭐
  • Branch Coverage 60% 이상
  • 핵심 비즈니스 로직 → 90%+
  • DTO/Entity → 측정 제외

Coverage 설정:

jacocoTestCoverageVerification {
    violationRules {
        rule {
            limit {
                counter = 'LINE'
                minimum = 0.70
            }
        }
    }
}

ILIC 권장:

  • 도메인 로직 (운임 계산 등) → 90%
  • Service 레이어 → 80%
  • Controller → 70%
  • DTO/Entity → 측정 제외

자기 점검

  • "Coverage 90%인데 버그가 발생"의 원인은? (힌트: 양 ≠ 질)
  • ILIC의 어떤 클래스가 Coverage 측정에서 제외되어야 할까?

Unit 8.2 — Mutation Testing (PIT)

선수 지식: Unit 8.1

문제:

"Coverage 100%가 진짜 검증인가?"

Mutation Testing:

"의도적으로 코드를 변경(mutate) 한 후 테스트가 깨지는지 확인"


예시:

원본 코드:

if (age >= 18) { ... }

뮤테이션 (의도적 변형):

if (age > 18) { ... }   // >= → >
if (age >= 19) { ... }  // 18 → 19
if (true) { ... }        // 조건 제거

테스트가 모두 깨지면 → Killed (좋은 테스트 ✅)
테스트가 통과하면 → Survived (테스트 부족 ⚠️)


PIT (Java Mutation Testing):

plugins {
    id 'info.solidsoft.pitest' version '1.15.0'
}

pitest {
    targetClasses = ['com.ilic.domain.*']
    threads = 4
    outputFormats = ['HTML']
    timestampedReports = false
    mutators = ['STRONGER']
}

./gradlew pitest


Mutation Score:

  • (Killed Mutations / Total Mutations) × 100
  • 80%+ 권장

언제 활용?:

  • 핵심 비즈니스 로직 (할인 계산, 결제 등)
  • Coverage 100%인데 의심스러울 때
  • 정기 리포트 (CI에서 주 1회 등)

비용:

  • 매우 느림 (수십 배)
  • 일상 빌드에 불적합
  • → 별도 작업으로 실행

ILIC 시나리오:

  • 운임 계산 클래스에 Mutation Test
  • 할인 정책 클래스에 Mutation Test
  • 정기 검사로 약점 발견

자기 점검

  • Coverage와 Mutation Score의 차이는? (힌트: 실행 vs 검증)
  • Mutation Test를 매번 빌드에 넣으면? (힌트: 느려서 실용 X)

Unit 8.3 — ArchUnit으로 아키텍처 검증

선수 지식: 8주차 Phase 3 (Spring Aware Annotation), 17주차 Phase 8 (Bounded Context)

문제:

  • 코드 리뷰로만 아키텍처 규칙 강제 → 한계
  • "Service가 Controller를 직접 import하면 안 됨" 같은 규칙 자동화

ArchUnit:

"아키텍처 규칙을 코드로 표현하고 테스트"

testImplementation 'com.tngtech.archunit:archunit-junit5:1.2.0'

예시 — 레이어 의존성 규칙:

@AnalyzeClasses(packages = "com.ilic")
class ArchitectureTest {
    
    @ArchTest
    static final ArchRule controller는_service만_접근한다 = 
        classes()
            .that().resideInAPackage("..controller..")
            .should().onlyDependOnClassesThat()
            .resideInAnyPackage("..controller..", "..service..", "..dto..", "java..");
    
    @ArchTest
    static final ArchRule service는_controller에_접근하지_않는다 = 
        noClasses()
            .that().resideInAPackage("..service..")
            .should().dependOnClassesThat()
            .resideInAPackage("..controller..");
    
    @ArchTest
    static final ArchRule repository는_entity만_반환한다 = 
        methods()
            .that().areDeclaredInClassesThat()
            .resideInAPackage("..repository..")
            .should().haveRawReturnType(String.class)
            .orShould().haveRawReturnType(Long.class);  // 또는 Entity
}

유용한 규칙들:

어노테이션 사용 강제

@ArchTest
static final ArchRule 모든_repository는_Repository_어노테이션 = 
    classes()
        .that().resideInAPackage("..repository..")
        .and().areInterfaces()
        .should().beAnnotatedWith(Repository.class);

명명 규칙

@ArchTest
static final ArchRule 컨트롤러는_Controller로_끝남 = 
    classes()
        .that().areAnnotatedWith(RestController.class)
        .should().haveSimpleNameEndingWith("Controller");

순환 참조 방지 ⭐

@ArchTest
static final ArchRule 패키지간_순환참조_금지 = 
    slices()
        .matching("com.ilic.(*)..")
        .should().beFreeOfCycles();

Spring 빈 규칙

@ArchTest
static final ArchRule field_injection_금지 = 
    noFields()
        .should().beAnnotatedWith(Autowired.class);
        // 생성자 주입 강제

ILIC 활용 ⭐ :

@AnalyzeClasses(packages = "com.ilic")
class IlicArchitectureTest {
    
    @ArchTest
    static final ArchRule modular_monolith_규칙 = 
        slices()
            .matching("com.ilic.(*)..")
            .should().beFreeOfCycles();
    
    @ArchTest
    static final ArchRule fare_module은_payment_module을_직접_참조하지_않는다 = 
        noClasses()
            .that().resideInAPackage("..fare..")
            .should().dependOnClassesThat()
            .resideInAPackage("..payment..internal..");
            // public API만 허용
    
    @ArchTest
    static final ArchRule entity_컬렉션은_Lazy() = 
        // 11-12주차의 Lazy 강제
        ...
}

자기 점검

  • ArchUnit이 코드 리뷰를 대체할 수 있는가? (힌트: 일부만 — 자동화 영역만)
  • ILIC에 적용할 만한 아키텍처 규칙 5가지를 선언하라

🎓 종합 자기 점검 (19주차 졸업 시험)

테스트 철학

  1. 테스트를 작성하는 4가지 이유는?
  2. FIRST 원칙 5가지를 설명하라
  3. 테스트 피라미드의 3계층과 권장 비율은?
  4. Given-When-Then 패턴의 의미는?

JUnit5

  1. @BeforeEach와 @BeforeAll의 차이는?
  2. AssertJ가 JUnit 기본 assertion보다 좋은 이유는?
  3. Soft Assertion이 필요한 시나리오는?
  4. @ParameterizedTest의 5가지 소스 종류는?

Mockito (★)

  1. Mock, Stub, Spy, Fake의 차이를 표로 정리하라
  2. @Mock과 @MockBean의 차이는?
  3. ArgumentCaptor를 쓰는 이유는?
  4. private 메서드를 mock하고 싶을 때 진짜 해결책은?
  5. Mock 남용의 경고 신호 3가지는?

Spring 테스트

  1. @SpringBootTest를 남발하면 안 되는 이유는?
  2. @DataJpaTest의 자동 동작 2가지는?
  3. @WebMvcTest로 어떤 빈이 로딩되는가?
  4. @MockBean이 @Mock과 다른 이유는?

MockMvc

  1. MockMvc는 진짜 HTTP 서버를 띄우는가?
  2. JsonPath로 배열 검증하는 방법은?
  3. @WithMockUser와 실제 JWT 테스트의 차이는?

Testcontainers (★)

  1. H2와 Testcontainers 중 언제 무엇을?
  2. @ServiceConnection의 효과는?
  3. 컨테이너 재사용의 이점과 위험은?

TDD

  1. Red-Green-Refactor 사이클을 설명하라
  2. TDD의 4가지 장점은?
  3. Inside-Out과 Outside-In의 차이는?

테스트 품질

  1. Coverage 100%가 환상인 이유는?
  2. Mutation Testing의 본질은?
  3. ArchUnit이 해결하는 문제는?

면접 모의 답변 (실전)

  1. "테스트 전략을 어떻게 가져가시나요?" (5분)
  2. "Mock과 Stub의 차이는?" (2분)
  3. "TDD 경험이 있으신가요?" (3분)
  4. "통합 테스트는 어떻게 작성하시나요?" (3분)
  5. "테스트 가능한 코드를 작성하기 위한 원칙은?" (3분)

📌 학습 운영 팁

9-섹션 마스터 프롬프트로 깊이 파야 할 Unit

★★★ 면접 단골 (반드시):

  • Unit 1.3 — 테스트 피라미드
  • Unit 3.1 — Mock vs Stub vs Spy vs Fake
  • Unit 4.2 — @DataJpaTest
  • Unit 6.2 — Testcontainers 기본
  • Unit 7.1 — TDD 사이클

★★ 매우 권장:

  • Unit 1.2 — FIRST 원칙
  • Unit 3.2 — Mockito 기본
  • Unit 5.1 — MockMvc 기본
  • Unit 6.3 — @ServiceConnection
  • Unit 7.3 — Mock 사용 전략
  • Unit 8.3 — ArchUnit

두 정점

Phase 3 (Mockito):

  • 단위 테스트의 핵심
  • 면접에서 "Mock과 Stub의 차이는?" 100% 출제
  • Test Double 5가지를 그림과 함께 설명 가능해야

Phase 6 (Testcontainers):

  • 현대 통합 테스트 표준
  • "H2와 Testcontainers 중 무엇?" 답변 가능해야
  • ILIC 운영 인프라(MySQL + Redis)로 직접 적용 권장

ILIC 면접 답변 준비

"테스트는 어떻게 작성하시나요?" 답변 구조:

"저는 테스트 피라미드를 따라 단위 70%, 통합 20%, E2E 10% 비율을 목표로 합니다.

단위 테스트는 Mockito로 의존성을 격리하고, 도메인 로직과 Service 레이어를 검증합니다.
중요한 비즈니스 로직(운임 계산 같은)은 Given-When-Then 패턴으로 의도를 명확히 합니다.

통합 테스트는 Testcontainers로 실제 MySQL을 띄워서 운영과 동일한 환경에서 검증합니다.
H2를 쓰지 않는 이유는 [본인 경험 — 호환성 문제 등].

가장 신경 쓰는 부분은 [구체 사례]이고, 
TDD를 [구체 영역]에 적용해서 [효과]를 봤습니다.

지금이라면 [개선점 — 예: ArchUnit 도입]을 했을 것 같습니다."

학습 시 주의 — 직접 ILIC에 적용

이번 주차는 반드시 ILIC 코드에 직접 테스트 작성:

  1. 운임 계산 도메인 클래스 1개 → 단위 테스트 + TDD 시도
  2. Repository 1개 → @DataJpaTest로 검증
  3. Controller 1개 → MockMvc로 검증 (인증 포함)
  4. 통합 시나리오 1개 → Testcontainers로 검증
  5. JaCoCo 리포트 → 핵심 클래스 Coverage 확인
  6. ArchUnit 규칙 1개 → 본인 프로젝트에 적용

이 6가지를 거치면 면접 답변이 자연스러워지고, ILIC 코드 품질도 즉시 향상됩니다.

1~19주차 학습 여정

거의 마지막에 도달했습니다:

영역주차깊이
Java/Spring/JPA1-12★★★
DB13-14★★★
Spring MVC15★★★
분산 시스템16-17★★★
Spring Security18★★★
테스트19★★★

profile
Software Developer

0개의 댓글