STUDY NOTE · 개념 정리
유닛 테스트 개념 정리: 테스트 피라미드, Test Double, 좋은 테스트의 조건
Backend
유닛 테스트는 가장 작은 단위(보통 클래스·메서드 하나)를 외부 의존성과 분리해서 의도대로 동작하는지 자동으로 확인하는 테스트다. 빠르고 실패 원인이 명확한 게 핵심이다.
1. 테스트를 쓰는 이유
- 회귀 방지: 기능을 고치다가 기존 기능을 깨뜨리는 것을 바로 알 수 있다.
- 리팩터링의 안전망: 동작을 보장하는 테스트가 있으면 내부 구조를 자신 있게 바꿀 수 있다.
- 살아있는 문서: 테스트 이름과 내용이 "이 코드가 무엇을 해야 하는지"를 보여준다.
- 설계 피드백: 테스트하기 어려운 코드는 대개 의존성이 강하게 묶인 코드다.
2. 테스트의 종류
| 구분 | 유닛 테스트 | 통합 테스트 | E2E 테스트 |
| 범위 | 클래스·메서드 하나 | 여러 컴포넌트 + 실제 DB 등 | 사용자 시나리오 전체 |
| 외부 의존성 | 가짜(Test Double)로 대체 | 실제 또는 실제와 유사한 환경 | 실제 환경 |
| 속도 | 매우 빠름 (ms) | 느림 (초) | 매우 느림 |
| 실패 원인 파악 | 쉬움 | 보통 | 어려움 |
| 개수 | 가장 많이 | 중간 | 적게 |
3. 테스트 피라미드
아래로 갈수록 많이, 위로 갈수록 적게 둔다.
- 맨 아래 (유닛): 비즈니스 규칙, 계산, 분기를 촘촘하게
- 중간 (통합): DB 쿼리, 외부 연동, 계층 간 연결
- 맨 위 (E2E): 결제·로그인처럼 깨지면 안 되는 핵심 시나리오 몇 개
위쪽 테스트만 많으면 느리고, 자주 깨지고(flaky), 실패했을 때 어디가 문제인지 알기 어렵다.
4. 테스트 구조: Given-When-Then (AAA)
| 단계 | 다른 이름 | 하는 일 |
| Given | Arrange | 테스트에 필요한 상태·입력 준비 |
| When | Act | 테스트할 동작 실행 |
| Then | Assert | 결과 검증 |
@Test
void 연속_5회_실패하면_계정이_잠긴다() {
// given
given(historyRepository.countFailuresSinceLastSuccess("user1")).willReturn(4);
// when
loginService.recordFailure("user1");
// then
verify(accountRepository).lock("user1");
}
5. Test Double: 가짜 객체의 종류
실제 의존 객체 대신 테스트용으로 바꿔 끼우는 객체를 통틀어 Test Double(대역)이라고 한다.
| 종류 | 설명 | 예시 |
| Dummy | 자리만 채우고 사용되지 않음 | 생성자에 넘기는 null 대신 객체 |
| Stub | 정해진 값을 돌려줌 (상태 검증용) | 조회하면 항상 회원 1명 반환 |
| Spy | 진짜처럼 동작하면서 호출 기록을 남김 | 메일 발송 횟수 기록 |
| Mock | 기대하는 호출을 미리 정하고 실제로 호출됐는지 검증 (행위 검증용) | lock()이 한 번 호출됐는지 확인 |
| Fake | 실제로 동작하지만 단순화된 구현 | 메모리 Map으로 만든 리포지토리 |
Mockito는 Stub과 Mock 역할을 함께 한다. given(...).willReturn(...)이 Stub, verify(...)가 Mock 검증이다.
6. 상태 검증 vs 행위 검증
| 구분 | 상태 검증 | 행위 검증 |
| 확인하는 것 | 실행 후 결과값, 객체 상태 | 의존 객체의 메서드가 호출됐는지 |
| 도구 | assertThat | verify |
| 장점 | 내부 구현이 바뀌어도 잘 안 깨짐 | 반환값 없는 동작(발송, 저장) 확인 가능 |
| 단점 | 부수효과만 있는 동작은 확인 어려움 | 구현에 묶여 리팩터링 때 자주 깨짐 |
가능하면 상태 검증을 우선하고, 외부로 나가는 중요한 호출(결제 요청, 메일 발송)만 행위 검증한다.
7. 좋은 유닛 테스트의 조건: FIRST
| 원칙 | 의미 |
| Fast | 빠르다. 수백 개가 몇 초 안에 끝나야 자주 돌린다 |
| Independent | 서로 독립적이다. 실행 순서나 다른 테스트의 결과에 의존하지 않는다 |
| Repeatable | 언제 어디서 돌려도 결과가 같다. 시간·랜덤·네트워크에 좌우되지 않는다 |
| Self-validating | 성공·실패가 자동으로 판정된다. 로그를 눈으로 볼 필요가 없다 |
| Timely | 적시에 작성한다. 기능 구현과 함께, 버그 수정 시 재현 테스트부터 |
8. 스프링에서의 테스트 범위
| 방식 | 띄우는 범위 | 용도 |
| 순수 JUnit + Mockito | 스프링 없음 | 서비스·도메인 로직 유닛 테스트 |
| @WebMvcTest | 웹 계층(컨트롤러, 필터)만 | 요청 매핑, 검증, 응답 코드 |
| @DataJpaTest / @MybatisTest | 영속성 계층만 | 쿼리, 매핑 |
| @SpringBootTest | 전체 컨텍스트 | 통합 테스트 |
슬라이스 테스트에서 빈을 가짜로 바꿀 때는 Spring Boot 3.4부터 @MockBean 대신 @MockitoBean을 쓴다.
9. 실제로 어떻게 적용되나
- 경계값 테스트: "5회 실패 시 잠금" 규칙이면 4회(안 잠김)와 5회(잠김)를 모두 테스트한다. 버그는 대부분 경계에서 나온다.
@ParameterizedTest로 여러 입력을 한 번에 검증할 수 있다. - 시간에 의존하는 로직: 쿠폰 만료처럼 현재 시각이 필요한 코드는
LocalDateTime.now()를 직접 부르지 않고Clock을 주입받는다. 테스트에서 시각을 고정할 수 있어 Repeatable 원칙을 지킨다. - 외부 API를 쓰는 서비스: 결제사 클라이언트를 Mock으로 바꿔 "승인 실패 시 주문 상태가 실패로 바뀌는지" 같은 분기를 실제 결제 없이 검증한다.
- 쿼리 검증: 리포지토리를 Mock한 유닛 테스트는 SQL이 맞는지 보장하지 않는다. 쿼리는 슬라이스 테스트로 실제(또는 컨테이너) DB에서 따로 확인한다.
10. 테스트하기 쉬운 코드의 특징
- 의존성을 생성자로 주입받는다 (메서드 안에서
new하지 않음) - 시간, 랜덤, 외부 호출 같은 비결정적 요소가 분리돼 있다
- 하나의 메서드가 하나의 일만 한다
- static 상태나 전역 상태에 의존하지 않는다
11. 면접 질문으로 정리
- Q. 유닛 테스트와 통합 테스트의 차이는? 유닛 테스트는 하나의 단위를 외부 의존성 없이 빠르게 검증하고, 통합 테스트는 여러 컴포넌트와 DB 같은 실제 의존성을 함께 붙여 연결이 올바른지 검증한다.
- Q. Mock과 Stub의 차이는? Stub은 정해진 응답을 돌려줘 테스트 대상의 결과(상태)를 검증하는 데 쓰고, Mock은 기대한 메서드가 호출됐는지(행위)를 검증하는 데 쓴다.
- Q. 좋은 테스트의 조건은? FIRST 원칙(빠르고, 독립적이고, 반복 가능하고, 자동으로 판정되고, 적시에 작성됨)을 만족해야 한다.
- Q. 테스트 피라미드란? 빠르고 저렴한 유닛 테스트를 가장 많이, 통합 테스트를 중간, 느리고 비싼 E2E 테스트를 가장 적게 두는 테스트 구성 전략이다.