18주차(Spring Security) 이후 Claude가 임의로 구성한 학습 경로.
시니어 면접 단골이지만 4년차 개발자가 가장 약한 영역인 테스트 를 정복한다.
- JUnit5 깊이 (6주차 입문의 본격 확장)
- Mockito 본격 (단위 테스트의 실전)
- Spring 테스트 슬라이스 (계층별 테스트 전략)
- MockMvc (Controller 테스트)
- Testcontainers (현대 통합 테스트 표준)
- 테스트 전략과 TDD
시니어 백엔드 개발자가 "테스트를 작성한다"가 아니라 "테스트로 설계한다" 의 단계로 가는 주차.
1~18주차의 테스트 위치:
| 주차 | 테스트 등장 |
|---|---|
| 6주차 | JUnit 입문 (assertEquals 정도) |
| 그 외 | 거의 없음 |
문제 의식:
왜 이 시점에 결정적인가:
ILIC 관점:
[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~18주차 | 기능 구현 (Java/Spring/JPA/DB/Security) | 만들기 |
| 19주차 (지금) | 테스트 | 검증하기 |
핵심 통찰:
"1~18주차는 무엇을 만들지, 19주차는 만든 것을 어떻게 보장할지"
| Day | Phase | 학습 목표 |
|---|---|---|
| 1일차 | Phase 1 + 2 | 철학 + JUnit5 본격 |
| 2일차 | Phase 3 | Mockito 깊이 (★) |
| 3일차 | Phase 4 | Spring 테스트 슬라이스 |
| 4일차 | Phase 5 | MockMvc Controller 테스트 |
| 5일차 | Phase 6 | Testcontainers (★) |
| 6일차 | Phase 7 | 테스트 전략 + TDD |
| 7일차 | Phase 8 + 종합 | Coverage + ArchUnit + 자기 점검 |
여유 일정 (10일): Phase 3, 6에 +1일씩. 직접 ILIC 코드에 테스트 작성하며 학습 권장.
목표: "왜, 무엇을, 어떻게" 라는 근본 질문에 답한다.
선수 지식: 없음
핵심 질문: "왜 테스트를 작성해야 하는가?"
4가지 답 ⭐ :
시나리오:
테스트가 있다면:
TDD의 본질:
예:
테스트 = 사용 예시:
@Test
void 로그인_성공시_JWT_토큰을_반환한다() {
// 이 코드가 곧 사용 방법
}
→ 주석은 낡지만 테스트는 깨짐 = 항상 최신
테스트 ROI 곡선:
ILIC 시나리오:
자기 점검
선수 지식: Unit 1.1
FIRST 원칙 ⭐ :
| 원칙 | 의미 |
|---|---|
| Fast | 빠름 — ms 단위 |
| Isolated | 독립적 — 순서/외부 의존 X |
| Repeatable | 반복 가능 — 같은 결과 |
| Self-validating | 자체 검증 — Pass/Fail 명확 |
| Timely | 시기적절 — 코드와 함께 |
Fast ⭐ :
왜 느려지나:
해결:
Isolated (독립적):
위반 사례:
@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)Self-validating:
나쁜 예:
System.out.println(result); // ❌ — 사람이 봐야 함
좋은 예:
assertThat(result).isEqualTo(expected); // ✅
Timely:
가독성 (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");
}
테스트 메서드 이름 ⭐ :
잔액이_부족하면_결제가_실패한다자기 점검
선수 지식: Unit 1.2
테스트 분류 ⭐ :
@WebMvcTest)테스트 피라미드 ⭐⭐ :
[E2E] ← 적게 (느림, 비쌈)
/ \
/ \
[통합 테스트] ← 적당히
/ \
/ \
[단위 테스트] ← 많이 (빠름, 쌈) ⭐
원칙:
왜 피라미드?:
역피라미드 (Anti-pattern) ⚠️ :
[E2E 많음] ← ❌ 잘못
|
[통합]
|
[단위 적음]
증상:
아이스크림 콘 (Anti-pattern):
ILIC 시나리오:
자기 점검
목표: 6주차 입문을 넘어 JUnit5의 모든 핵심 기능을 활용한다.
선수 지식: 6주차 Phase 5
JUnit5 모듈 구조:
@Test 등)핵심 어노테이션 ⭐ :
| 어노테이션 | 용도 |
|---|---|
@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 도 컨텍스트별로 가능자기 점검
@Before와 JUnit 5의 @BeforeEach의 차이는? (힌트: 정적 import 등)@BeforeAll은 왜 static? (힌트: 인스턴스 생성 전에 실행)선수 지식: 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");
assertThat(repository.findById(1L))
.isPresent()
.get()
.extracting(User::getName)
.isEqualTo("Alice");
assertThat(timestamp).isAfter(yesterday).isBefore(tomorrow);
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();
});
}
자기 점검
선수 지식: 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));
}
자기 점검
목표: 단위 테스트의 핵심 도구 — Mock의 세계를 본격 정복.
선수 지식: Phase 2
Test Double 5가지 ⭐ :
| 종류 | 의미 |
|---|---|
| Dummy | 인자 채우기용 (사용 X) |
| Stub | 미리 정한 값 반환 |
| Mock | 호출 검증 |
| Spy | 진짜 객체 + 일부 가짜 |
| Fake | 단순화된 진짜 구현 |
UserRepository repo = mock(UserRepository.class);
when(repo.findById(1L)).thenReturn(Optional.of(new User("Alice")));
// 메서드 호출 시 미리 정한 값 반환
User user = repo.findById(1L).get();
용도: 의존성이 특정 값을 반환하도록
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의 차이 ⭐ :
List<String> spyList = spy(new ArrayList<>());
spyList.add("real"); // 실제 동작
when(spyList.size()).thenReturn(100); // 일부만 가짜
assertThat(spyList).contains("real");
assertThat(spyList).hasSize(100); // ← 가짜
용도: 진짜 객체에 일부만 stub
주의: 남용 시 위험 (테스트 의도 불명확)
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
비교 매트릭스 ⭐ :
| Stub | Mock | Spy | Fake | |
|---|---|---|---|---|
| 검증 대상 | 상태 | 행동 | 둘 다 | 상태 |
| 진짜 동작 | X | X | 일부 | 단순화 |
| 사용 빈도 | 매우 흔함 | 매우 흔함 | 가끔 | 가끔 |
Mockito는 Mock + Stub 모두 지원:
when().thenReturn() → Stubverify() → Mock언제 무엇을 쓸까 ⭐ :
| 상황 | 추천 |
|---|---|
| 의존성이 값을 반환해야 함 | Stub |
| "이 메서드가 호출되었는가?" | Mock |
| 일부만 진짜로 | Spy |
| DB/외부 시스템 단순화 | Fake |
| 인자만 채우기 | Dummy |
자기 점검
선수 지식: 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 들을 자동 주입핵심 메서드 ⭐ :
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());
doNothing().when(emailService).send(any());
doThrow(new RuntimeException()).when(emailService).send(any());
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());
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");
}
자기 점검
@Mock과 Mockito.mock()의 차이는? (힌트: 어노테이션 기반 vs 메서드 — 결과 동일)선수 지식: Unit 3.2
흔한 함정 ⚠️ :
public final class ExternalApiClient { ... } // ❌ Mockito 기본 X
해결:
mockito-inline 의존성 추가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(...);
public Fare create() {
Fare fare = new Fare(); // ❌ 이걸 mock 못함
}
해결:
Best Practice ⭐ :
// ❌ 한 테스트가 너무 많이 검증
@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());
}
Mock 폭증 신호:
자기 점검
목표: 계층별 테스트 어노테이션을 활용해 빠르고 정확한 테스트를 작성한다.
선수 지식: Phase 3, 6주차 Phase 5
@SpringBootTest — 전체 컨텍스트:
@SpringBootTest
class FullApplicationTest {
@Autowired
private FareService service;
// 모든 빈 + DB + 외부 통합
}
문제 ⚠️ :
해결 — 필요한 것만 로딩:
Spring 테스트 슬라이스 ⭐ :
| 어노테이션 | 로딩 범위 |
|---|---|
@SpringBootTest | 전체 |
@WebMvcTest | Controller, Filter, Interceptor |
@DataJpaTest | JPA Repository, EntityManager |
@JsonTest | Jackson |
@RestClientTest | RestTemplate / WebClient |
@DataRedisTest | Redis |
효과: 같은 테스트가 10초 → 1초
자기 점검
선수 지식: Unit 4.1, 11-12주차 JPA
@DataJpaTest :
@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() 의 중요성 ⭐ :
em.persist() 직후 findById() → 캐시에서 반환 (DB 안 침)em.clear() 로 캐시 비우면 DB 조회 강제테스트 DB 결정 ⭐ :
H2 In-Memory (기본):
Testcontainers (Phase 6에서):
@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 활용:
자기 점검
선수 지식: Unit 4.2, 15주차 (Spring MVC)
@WebMvcTest :
@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 컨텍스트 |
| 주입 | @InjectMocks | Spring DI |
| 사용 | 단위 테스트 | 슬라이스/통합 테스트 |
자기 점검
목표: HTTP 레벨에서 Controller를 검증한다.
선수 지식: Phase 4
MockMvc 본질:
기본 패턴 ⭐ :
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());
핵심 메서드:
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(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(print()) // 요청/응답 콘솔 출력 (디버깅) ⭐
.andDo(MockMvcRestDocumentation.document("create-fare")) // 문서화
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());
}
자기 점검
선수 지식: 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
))
자기 점검
선수 지식: 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 시나리오:
자기 점검
목표: 현대 통합 테스트의 표준 — 실제 인프라를 컨테이너로 띄워 테스트한다.
선수 지식: Phase 4
H2 In-Memory (전통):
호환성 문제 사례:
Testcontainers ⭐ :
"실제 DB/Redis/Kafka 등을 Docker 컨테이너로 자동 실행"
효과:
비용:
선택 가이드 ⭐ :
| 상황 | 추천 |
|---|---|
| 표준 SQL만 사용 | H2 OK |
| 운영 DB 특화 기능 사용 | Testcontainers ⭐ |
| Redis/Kafka 통합 테스트 | Testcontainers |
| CI 환경 | Testcontainers (Docker 필수) |
현대 트렌드 ⭐ :
"Testcontainers가 사실상 표준"
ILIC가 MySQL을 쓴다면 H2 테스트는 운영과 다른 위험.
자기 점검
선수 지식: 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)
다양한 컨테이너:
@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 시나리오:
자기 점검
선수 지식: 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 시나리오 ⭐ :
자기 점검
선수 지식: Unit 6.3
통합 테스트의 범위 결정 ⭐ :
핵심 시나리오 — 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 라이브러리:
테스트 격리 전략 ⭐ :
@Transactional // 자동 롤백
class FareIntegrationTest { ... }
@AfterEach
void cleanUp() {
fareRepository.deleteAll();
customerRepository.deleteAll();
}
@Sql(scripts = "/test-data.sql")
@Sql(scripts = "/clean-up.sql", executionPhase = AFTER_TEST_METHOD)
void test() { ... }
자기 점검
목표: 테스트를 작성하는 방법론을 익힌다.
선수 지식: 1~6 Phase
TDD 사이클 — Red, Green, Refactor:
1. Red: 실패하는 테스트 작성
↓
2. Green: 테스트를 통과시키는 최소 코드
↓
3. Refactor: 코드 개선 (테스트는 계속 통과)
↓
(반복)
예시 — 운임 계산기 TDD:
@Test
void 거리가_0이면_요금은_0() {
FareCalculator calculator = new FareCalculator();
assertThat(calculator.calculate(0)).isEqualTo(0);
}
→ 컴파일 실패 (FareCalculator 없음)
class FareCalculator {
int calculate(int distance) { return 0; } // 최소 코드
}
→ 통과
@Test
void 거리가_100이면_요금은_50000() {
assertThat(calculator.calculate(100)).isEqualTo(50000);
}
→ 실패
int calculate(int distance) {
return distance * 500;
}
→ 통과
TDD의 장점 ⭐ :
TDD의 단점/오해 :
TDD 적합 영역:
TDD 부적합:
Inside-Out vs Outside-In ⭐ :
Outside-In (London School):
Inside-Out (Chicago School):
ILIC 권장:
자기 점검
선수 지식: Unit 7.1
BDD (Behavior-Driven Development):
"비즈니스 행동을 자연어처럼 표현"
TDD vs 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원이 된다
현실:
ILIC 활용:
자기 점검
선수 지식: Unit 3.1, 7.1, 7.2
전략적 결정 — "언제 Mock, 언제 진짜?"
Mock 남용의 경고 신호 ⚠️ :
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;
}
자기 점검
목표: 테스트의 효과를 측정하고 보장한다.
선수 지식: 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 Coverage | if/else 분기 비율 |
| Method Coverage | 호출된 메서드 비율 |
Coverage의 함정 ⚠️ :
@Test
void uselessTest() {
service.method(); // 호출만 — 실패해도 통과
}
→ Line coverage 100%, 실제 검증 0%
현실적 목표:
Coverage 설정:
jacocoTestCoverageVerification {
violationRules {
rule {
limit {
counter = 'LINE'
minimum = 0.70
}
}
}
}
ILIC 권장:
자기 점검
선수 지식: 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:
언제 활용?:
비용:
ILIC 시나리오:
자기 점검
선수 지식: 8주차 Phase 3 (Spring Aware Annotation), 17주차 Phase 8 (Bounded Context)
문제:
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();
@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 강제
...
}
자기 점검
★★★ 면접 단골 (반드시):
★★ 매우 권장:
Phase 3 (Mockito):
Phase 6 (Testcontainers):
"테스트는 어떻게 작성하시나요?" 답변 구조:
"저는 테스트 피라미드를 따라 단위 70%, 통합 20%, E2E 10% 비율을 목표로 합니다.
단위 테스트는 Mockito로 의존성을 격리하고, 도메인 로직과 Service 레이어를 검증합니다.
중요한 비즈니스 로직(운임 계산 같은)은 Given-When-Then 패턴으로 의도를 명확히 합니다.
통합 테스트는 Testcontainers로 실제 MySQL을 띄워서 운영과 동일한 환경에서 검증합니다.
H2를 쓰지 않는 이유는 [본인 경험 — 호환성 문제 등].
가장 신경 쓰는 부분은 [구체 사례]이고,
TDD를 [구체 영역]에 적용해서 [효과]를 봤습니다.
지금이라면 [개선점 — 예: ArchUnit 도입]을 했을 것 같습니다."
이번 주차는 반드시 ILIC 코드에 직접 테스트 작성:
이 6가지를 거치면 면접 답변이 자연스러워지고, ILIC 코드 품질도 즉시 향상됩니다.
거의 마지막에 도달했습니다:
| 영역 | 주차 | 깊이 |
|---|---|---|
| Java/Spring/JPA | 1-12 | ★★★ |
| DB | 13-14 | ★★★ |
| Spring MVC | 15 | ★★★ |
| 분산 시스템 | 16-17 | ★★★ |
| Spring Security | 18 | ★★★ |
| 테스트 | 19 | ★★★ |