레이어 분리와 의존성 역전이 적용된 아키텍처에서 어떤 레이어를 어떻게 테스트할 것인가, 그리고 Mockito와 Spring 테스트 도구를 어떤 의도로 사용할 것인가를 정리한 실전 노트.
이 프로젝트에서는 레이어를 interfaces / application / domain / infrastructure로 나누되, infrastructure가 domain을 향해 의존하도록 의존성을 역전시킨 구조를 전제로 합니다. 이는 헥사고날 아키텍처 또는 클린 아키텍처에서 제시하는 방식이에요.
interfaces (controller)
↓
application (facade)
↓
domain (service, entity)
↑
infrastructure (db, external apis)
여기서 핵심은 infrastructure에서 domain으로 향한 화살표입니다. 전통적인 레이어드 아키텍처에서는 의존성이 위에서 아래로 흐르지만(Domain이 Infrastructure를 알고 있음), 이 구조에서는 Domain이 인터페이스(예: MemberRepository)만 정의하고 Infrastructure가 그 인터페이스를 구현합니다.
1. 비즈니스 규칙과 기술의 관심사를 분리하기 위해서
비즈니스 규칙과 기술 선택은 다루는 관심사가 다릅니다. 비즈니스는 도메인 전문가의 어휘로 표현되고 기술은 엔지니어가 성능·표준·생태계 같은 기준으로 결정해요. 두 영역은 변하는 이유도, 결정 주체도, 검증 방식도 다릅니다. Domain이 Infrastructure를 직접 의존하면 Infrastructure의 변경이 도메인 언어를 침범해서 도메인의 표현력과 안정성이 무너져요. 의존성을 역전시키면 두 관심사가 분리되어 각자의 이유로 독립적으로 변할 수 있습니다.
2. 도메인의 표현력을 지키기 위해서
Domain 코드에 JpaRepository<Member, Long>이 끼어 있으면 비즈니스 어휘가 기술 어휘에 가려져요. MemberRepository라는 인터페이스만 있으면 도메인은 저장소에 요구하는 행위(save, findById 등)만 알면 됩니다. "어떻게 보관하는가"는 Infrastructure의 몫이에요.
3. 의존 방향과 정책의 방향을 일치시키기 위해서
아키텍처에서 "무엇이 무엇을 결정하는가"가 중요한 질문입니다. 비즈니스가 기술을 결정해야지, 기술이 비즈니스를 결정해서는 안 돼요. Domain이 인터페이스를 정의하고 Infrastructure가 그것을 구현하는 구조는 "비즈니스가 요구하는 능력을 기술이 채워준다"는 정책의 방향을 코드 의존성으로 그대로 표현합니다. 이게 Robert Martin의 클린 아키텍처가 말하는 "의존성은 안쪽(고수준 정책)을 향해야 한다"의 의미예요.
"The hexagonal architecture allows an application to equally be driven by users, programs, automated test or batch scripts, and to be developed and tested in isolation from its eventual run-time devices and databases."
인용에서 "users, programs, automated test, batch scripts"가 모두 대등하게 애플리케이션을 구동할 수 있다는 게 핵심이에요. 도메인이 어느 한쪽(예: 웹 프레임워크나 DB)에 묶여 있지 않다는 것 — 이게 헥사고날의 본질입니다.
Domain이 인터페이스에만 의존하기 때문에 그 인터페이스를 만족하는 어떤 구현체든 끼워 넣을 수 있습니다. 테스트는 운영용 구현체(예: JpaMemberRepository)를 Mock이나 Fake로 치환할 수 있고, 그 결과 DB나 외부 시스템으로부터 격리된 상태에서 도메인 로직만 빠르게 검증할 수 있습니다.
각 레이어가 의존하는 대상이 다르기 때문에 테스트 도구와 무거움도 자연스럽게 달라지는 것입니다. 다음 섹션의 테스트 피라미드는 이 결과를 정량적으로 표현한 것이에요.
각 레이어는 책임이 다르므로 같은 방식으로 테스트하면 안 됩니다. 어떤 레이어는 빠른 테스트가 적합하고, 어떤 레이어는 실제 의존성을 띄운 테스트가 가치 있습니다.
Mike Cohn이 제안한 테스트 피라미드는 테스트의 비중을 시각화한 모델입니다. Martin Fowler도 이 개념을 정리하며 다음과 같이 강조했어요.
"The test pyramid... argues that you should have many more low-level unit tests than high level end-to-end tests. ... End-to-end tests are slow and brittle, so a test suite dominated by them is a recipe for slow feedback and frustrating maintenance."
╱────────────╲
╱ E2E ╲ ← Controller, 핵심 플로우 (소수)
╱────────────────╲
╱ 통합 ╲ ← Facade, 주요 유즈케이스 (중간)
╱────────────────────╲
╱ 단위 ╲ ← Domain Service, Entity (다수)
╱────────────────────────╲
구체적으로 프로젝트 아키텍처가 만들어내는 테스트 전략은 다음과 같습니다.
@SpringBootTest 진행| 레이어 | Spring 컨텍스트 | 도구 |
|---|---|---|
| Controller (interfaces) | 띄움 | @SpringBootTest + TestRestTemplate |
| Controller (interfaces) | 일부 띄움 | @WebMvcTest |
| Facade (application) | 띄움 | @SpringBootTest + 실제 빈 |
| Service (domain) | 안 띄움 | JUnit + Mockito |
| Entity (domain) | 안 띄움 | JUnit + AssertJ |
단위 테스트는 빠르고 많이, 통합 테스트는 핵심만, E2E는 최소한으로.
테스트에서 실제 객체 대신 사용하는 가짜 객체들을 통칭해서 Test Double이라고 합니다. 영화의 스턴트 더블에서 따온 말이에요.
단위 테스트는 작은 단위의 로직을 빠르고 결정적으로 검증하는 것이 목적입니다. 하지만 현실의 객체는 이 조건을 만족시키기 어려운 의존성을 가지고 있어요.
[todo] 아래 이메일/SMS가 왜 SPY인지 모르겠음. Mock아닌가? 결제게이트웨이가 Mock이라서 엄격?
5가지 테스트 더블 용어 정리 다시해야할 듯.
| 협력자 유형 | 테스트에서의 문제 | 더블이 해결하는 방식 |
|---|---|---|
| 데이터베이스 | 느림, 환경 구축 필요, 테스트간 상태 간섭 | 인메모리 Fake 또는 Stub으로 대체 |
| 외부 API | 네트워크 의존, 요금 발생, 응답 불안정 | Stub으로 응답 고정 |
| 시간 · 랜덤 | 호출할 때마다 결과가 다름 | Stub으로 고정 값 반환 |
| 이메일 · SMS | 실제 발송되면 안 됨 (부수효과) | Spy로 호출 기록만 확인 |
| 결제 게이트웨이 | 실제 결제 발생, 계약 엄격 | Mock으로 엄격한 호출 검증 |
이런 문제들을 해결하기 위해 Gerard Meszaros가 xUnit Test Patterns(2007)에서 다섯 가지 더블을 정립했고, Martin Fowler가 Mocks Aren't Stubs로 대중화했어요. 각 더블은 해결하려는 문제가 조금씩 다릅니다.
| 종류 | 설명 |
|---|---|
| Dummy | 그냥 자리 채우기용. 호출되지 않음. |
| Stub | 정해진 값을 반환하도록 미리 세팅된 가짜 객체. |
| Mock | Stub 기능 + 호출 검증까지 가능. |
| Spy | 실제 객체를 감싸서 진짜 동작은 그대로 두되, 호출을 감시하거나 일부만 가로챔. |
| Fake | 단순화된 실제 구현 (예: 인메모리 Repository). |
모든 메서드가 기본값(null/0/false) 반환. 진짜 동작은 일절 안 함. 미리 stub해둔 것만 답함.
평소처럼 일을 하지만, 누가 무엇을 시켰는지 모두 기록됨. 필요하면 특정 업무만 가로챌 수 있음.
Stub은 영어로 "잘린 토막", "쪽지" 같은 뜻인데, 프로그래밍에서는 "본격적인 구현 대신 임시로 끼워둔 가짜 응답"이라는 의미로 굳어졌어요.
// 이 한 줄이 "stubbing"
when(memberRepository.findById(1L)).thenReturn(Optional.of(member));
// ↑ 이렇게 호출되면 ↑ 이걸 반환해라
해석하면: "findById(1L)이 호출되면, 진짜 동작 대신 미리 준비한 Optional.of(member)를 반환하도록 세팅한다."
본격적인 레이어별 테스트로 들어가기 전에, 사용할 도구를 먼저 정리합니다. 테스트에서 가짜 객체(Mock/Spy)를 만들 때 쓸 수 있는 도구는 크게 두 종류로 나뉘어요. 어떤 도구를 선택하느냐가 테스트의 성격을 결정합니다.
Mockito 기반의 테스트 더블 생성 방식과 Spring Framework의 컨텍스트에서 Bean을 교체하는 방식이 있습니다.
Mockito 라이브러리가 제공하는 도구입니다. Spring 컨텍스트 없이 Mock/Spy 객체를 만듭니다. 어노테이션 방식과 정적 메서드 방식이 있는데, 둘은 동등한 일을 해요.
@Mock
private MemberRepository repo;
@Spy
private List<String> list = new ArrayList<>();
MemberRepository repo =
mock(MemberRepository.class);
List<String> list =
spy(new ArrayList<>());
Mockito 공식 문서는 어노테이션 방식을 다음과 같이 권장합니다.
"Annotation @Mock is shorthand that allows to minimize repetitive mock creation code, makes the test class more readable, makes the verification error easier to read because the field name is used to identify the mock."
출처: javadoc.io/doc/org.mockito/mockito-core/latest/org/mockito/Mockito.html
여러 테스트에서 재사용할 Mock에는 어노테이션이 깔끔하고, 한 번만 쓰는 임시 Mock에는 정적 메서드가 자연스러워요. 어노테이션을 활성화하려면 @ExtendWith(MockitoExtension.class)가 필요합니다.
Mockito 도구는 버전과 거의 무관하게 어디서나 사용 가능합니다. mock(), spy(), @Mock, @Spy는 Mockito 1.x부터 존재했고 문법이 거의 변하지 않았어요. 한 가지 작은 변경만 알아두면 됩니다.
// 4.10.0 이전
LinkedList list = mock(LinkedList.class);
// 4.10.0+ (타입 추론)
LinkedList list = mock();
Mockito 도구는 Spring 컨텍스트 없이 Mock 객체를 만들기 때문에 테스트 대상 객체에 의존성을 직접 주입해줘야 합니다. Mockito는 @InjectMocks로 자동 주입하는 기능을 제공하지만, 수동 생성이 더 안전해요.
@InjectMocks
private MemberService memberService;
// 새 의존성이 추가되어도
// 컴파일 에러 없이 null 주입
// → NPE는 런타임에 발생
@BeforeEach
void setUp() {
memberService = new MemberService(
memberRepository, passwordEncoder);
}
// 의존성 추가 시 컴파일 에러로
// 즉시 발견 가능
@InjectMocks는 리플렉션으로 동작해서 의존성 매칭 규칙이 복잡하고, 의도치 않은 null 주입이 일어나도 즉시 알기 어려워요. new로 명시적으로 생성하면 의존성 변경이 컴파일 시점에 드러나 안전하고, 코드 의도도 더 명확합니다.
new MemberService(repo, encoder)는 의존성이 추가되면 즉시 컴파일 에러가 납니다. @InjectMocks는 런타임 NPE로 드러나요.@InjectMocks는 리플렉션으로 동작합니다. final 필드, 제네릭, 동일 타입 의존성 등에서 예측 불가능한 동작이 발생할 수 있어요. new는 평범한 Java 문법이라 모든 게 명시적입니다.이 권장은 단순한 의견이 아니라 Mockito 공식 Javadoc이 직접 명시한 동작에 근거합니다.
"Mockito will try to inject mocks only either by constructor injection, setter injection, or property injection in order and as described below. If any of the following strategy fail, then Mockito won't report failure; i.e. you will have to provide dependencies yourself."
Mockito는 생성자/세터/필드 주입을 순서대로 시도하지만, 실패해도 알려주지 않습니다. 의존성이 주입되지 않은 상태에서도 테스트는 계속 진행되고, 실제 사용 시점에 NullPointerException으로 드러나요.
출처: javadoc.io/doc/org.mockito/mockito-core/latest/org/mockito/InjectMocks.html
같은 문서에서 또 하나 중요한 단서가 있어요.
"If arguments can not be found, then null is passed. If non-mockable types are wanted, then constructor injection won't happen. In these cases, you will have to satisfy dependencies yourself."
인자를 찾을 수 없으면 그 자리에 null이 전달됩니다. final 클래스(Integer 등)나 primitive 타입처럼 Mock으로 만들 수 없는 타입이 생성자 인자에 끼어 있으면, Mockito는 그냥 null을 넣고 넘어가요. 객체는 깨진 상태로 만들어집니다.
Mockito 컨트리뷰터도 GitHub 이슈에서 이 동작을 명확히 인정했습니다.
"Yes it fails silently, because Mockito is not able to confirm an object is correctly initialized or not when this object relies on fields/setters... The bottom line being @InjectMocks feature in mockito was never designed to be a full featured dependency injection mechanism and will probably never be. At best it's shorthand way to inject mocks, but that's all."
한 사례를 보면 이 위험이 실무에서 어떻게 드러나는지 명확합니다. "실제 클래스에 비어있지 않은 생성자가 있었는데, 그 안에 Integer 타입 인자가 있어 Mockito가 Mock으로 만들 수 없었다. Mockito는 그 자리에 null을 넘기고 그냥 넘어갔고, 객체 안의 다른 의존성도 주입하지 않았다. 결과적으로 객체 사용 시 NPE가 터졌다."(DZone 사례)
이 사례에서 가장 위험한 점은 다음 한 줄입니다.
"The problem is that the tests who successfully run using this mechanism could one day fail just because someone decided to add another constructor."
잘 돌아가던 테스트가 누군가 새 생성자를 추가했다는 이유만으로 어느 날 갑자기 깨질 수 있습니다. @InjectMocks는 어떤 생성자를 쓸지 리플렉션으로 자동 선택하기 때문에, 코드 변경이 주입 전략을 바꾸면서 테스트를 흔들 수 있어요.
출처: dzone.com/articles/when-mockitos-injectmocks-does-not-inject-mocks
Spring Framework가 제공하는 도구입니다. Spring 컨텍스트 안의 빈을 Mock 또는 Spy로 교체합니다. @SpringBootTest처럼 컨텍스트가 떠 있어야 동작해요.
| 어노테이션 | 전략 | 동작 |
|---|---|---|
@MockitoBean | REPLACE_OR_CREATE | 빈을 Mock으로 교체. 빈이 없으면 새로 생성. |
@MockitoSpyBean | WRAP | 원본 빈을 Spy로 감쌈. 빈이 반드시 존재해야 함. |
이 어노테이션은 Spring Framework 6.2 / Spring Boot 3.4부터 도입되었습니다. 그 이전 버전에서는 @MockBean, @SpyBean을 사용해요(현재는 deprecated).
| Spring Boot | Spring Framework | 어노테이션 |
|---|---|---|
| ~ 3.3 | ~ 6.1 | @MockBean, @SpyBean |
| 3.4 ~ | 6.2 ~ | @MockitoBean, @MockitoSpyBean |
각 어노테이션의 작동 원리(REPLACE_OR_CREATE vs WRAP 전략)와 유의점(특히 Spy + doReturn 권장)은 CHAPTER 07에서 자세히 다룹니다.
선택의 핵심 기준은 단 하나입니다. 이 테스트가 Spring 컨텍스트를 띄우는가?
Spring 컨텍스트를 띄우는가?
│
┌───────┴───────┐
YES NO
│ │
Spring 도구 Mockito 도구
@MockitoBean @Mock, mock()
@MockitoSpyBean @Spy, spy()
두 도구의 본질적 차이는 "Mock을 만든다" vs "빈을 교체한다"입니다.
| 항목 | Mockito 도구 | Spring 도구 |
|---|---|---|
| 소속 | Mockito 라이브러리 | Spring Framework |
| Spring 컨텍스트 | 필요 없음 | 반드시 필요 |
| 하는 일 | Mock 객체를 만든다 | 컨텍스트의 빈을 교체한다 |
| 객체 주입 | 내가 직접 new | Spring이 자동 주입 |
| 속도 | 매우 빠름 (~10ms) | 느림 (컨텍스트 로딩) |
| 적합한 테스트 | 도메인 단위 테스트 | 통합 테스트, E2E |
@SpringBootTest 안에서 @Mock을 쓰면 의도대로 동작하지 않습니다. 새 Mock이 만들어지긴 하지만 컨텍스트에 등록되지 않아서, Spring이 다른 빈에 주입한 진짜 빈은 그대로 남아 있거든요. "빈을 교체"하려면 반드시 Spring 도구가 필요합니다.
도메인 레이어에는 두 종류의 객체가 있습니다. Entity는 비즈니스 데이터와 그 데이터 자체에 대한 규칙을 표현하고, Domain Service는 Entity와 Repository를 조합해서 도메인 흐름을 완성합니다. 단일 Entity가 자기 데이터만으로는 알 수 없는 검증(예: loginId 중복 검사)이나, 조회 후 분기(예: 회원 없으면 NOT_FOUND) 같은 얇은 조정 로직이 여기 자리해요. 두 영역은 책임이 다르므로 테스트 방식도 다릅니다.
| 대상 | 의존성 | 테스트 방식 |
|---|---|---|
| Entity | 거의 없음 | JUnit + AssertJ만으로 충분 |
| Domain Service | Repository 등 인터페이스 | Mockito 도구로 의존성을 Mock |
Entity는 보통 외부 의존성이 없거나 매우 적습니다. 따라서 Mock 도구가 필요 없고, 순수 JUnit + AssertJ만으로 비즈니스 규칙을 검증할 수 있어요. 가장 단순하고 빠른 형태의 단위 테스트입니다.
Entity의 본질은 자기 데이터에 대한 규칙을 책임지는 것입니다. 즉 "이 데이터가 만들어질 때 어떤 조건을 충족해야 하는가", "이 데이터가 어떤 상태로 변할 수 있는가", "어떤 작업이 허용되고 어떤 작업이 거부되는가" 같은 규칙들이에요. Entity 테스트는 이 규칙들이 의도대로 동작하는지를 검증합니다.
구체적으로 다음 네 가지가 주된 검증 대상이에요.
charge로 잔액이 누적되는가, changePassword로 인코딩된 값이 저장되는가)Entity가 던지는 예외의 메시지보다 ErrorType(또는 예외 클래스)을 검증하세요. 메시지는 자연어라 자주 바뀌지만 ErrorType은 enum이라 변경 시 컴파일 에러로 잡힙니다.
// ✅ 권장 — ErrorType으로 검증
assertThatThrownBy(() -> point.charge(Money.of(0L)))
.isInstanceOfSatisfying(CoreException.class, e ->
assertThat(e.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST));
// ❌ 메시지 부분 일치는 깨지기 쉬움
assertThatThrownBy(() -> point.charge(Money.of(0L)))
.hasMessageContaining("충전 금액은 1 이상");
Entity의 검증 규칙을 테스트할 때는 정상 / 경계 / 위반 세 영역을 모두 다루는 게 안전합니다. 한 영역만 검증하면 다른 영역의 버그가 통과해버려요. 예를 들어 Point.use(amount)의 검증 규칙은 다음과 같이 케이스를 나눌 수 있습니다.
| 입력 | 검증 의도 |
|---|---|
0 < amount < balance | 정상: 잔액 감소 |
amount == 0 | 경계: 0 거부 (BAD_REQUEST) |
amount == balance | 경계: 잔액 정확히 0이 됨 |
amount > balance | 위반: CONFLICT 예외 |
amount < 0 | 위반: Money 단계에서 BAD_REQUEST |
특히 경계값(0, 잔액과 같은 값, 최대값 등)에서 분기가 자주 어긋나므로 반드시 별도 케이스로 다룹니다. 입력만 다른 케이스를 한 테스트에 묶지 말고 @ParameterizedTest로 분리하는 것도 좋아요.
@ParameterizedTest
@DisplayName("0 이하의 금액 충전 시 BAD_REQUEST")
@ValueSource(longs = {0L, -1L, Long.MIN_VALUE})
void throwsBadRequest_whenAmountIsNotPositive(long amount) {
assertThatThrownBy(() -> point.charge(Money.of(amount)))
.isInstanceOfSatisfying(CoreException.class, e ->
assertThat(e.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST));
}
Entity가 협력자(예: PasswordEncoder, Clock)를 받는 경우, 가벼운 Fake를 직접 만들어 넘기는 게 가장 깔끔합니다. Mockito까지 동원할 필요 없어요.
class MemberTest {
// stateless한 도구는 static final 상수로
private static final PasswordEncoder PASSWORD_ENCODER = raw -> "ENC:" + raw;
@Test
@DisplayName("create 시 비밀번호가 인코더로 해시되어 저장된다")
void create_encodesPassword() {
Member member = Member.create("testId", "secret", PASSWORD_ENCODER);
assertThat(member.getPassword()).isEqualTo("ENC:secret");
}
}
이 패턴이 좋은 이유는 두 가지예요.
when().thenReturn()으로 흩어진 stubbing보다 의도가 명확해요.static final로 두면 "이건 변하지 않는 도구"라는 의도까지 표현돼요.Entity 테스트에서 굳이 작성하지 않아도 되는 케이스도 있어요. 언어가 보장하는 사실이나 자명한 동작은 테스트로 다시 확인할 가치가 적습니다.
final 필드와 새 객체 반환 패턴은 컴파일러가 보장하므로 별도 테스트 불필요Entity의 비즈니스 규칙(불변식, 상태 전이, 검증 로직)은 가능한 한 Entity 자체에 두고 Entity 단위 테스트로 검증하세요. Entity가 자기 데이터로 알 수 있는 모든 규칙이 여기서 검증되어야, 다음 레이어(Domain Service, Application)의 테스트가 자기 책임에만 집중할 수 있습니다.
도메인에는 Entity 외에 Value Object(VO)도 있습니다. Money, Email, Address 같은 객체들이에요. Entity와 VO는 정체성을 결정하는 방식이 근본적으로 다르고, 그 차이가 테스트 방향을 바꿉니다.
| 구분 | Entity | Value Object |
|---|---|---|
| 정체성 기준 | ID (식별자) | 값 자체 |
| 같다고 판단하는 기준 | 같은 ID면 같음 | 모든 값이 같으면 같음 |
| 가변성 | 상태가 변할 수 있음 | 불변 (변경하려면 새 객체) |
| 예시 | Member, Order |
Money, Email, Period |
VO 테스트의 가장 중요한 검증 대상은 동등성(value equality)입니다. "값이 같으면 같다"는 약속은 VO의 정체성 그 자체이고, 이게 깨지면 VO를 컬렉션의 키나 비교의 기준으로 쓸 수 없게 돼요.
VO의 동등성은 비즈니스가 정의한 계약입니다. "어떤 필드가 같으면 두 객체를 같다고 봐야 하는가"는 도메인이 답해야 할 질문이고 그 답이 코드에 충실히 반영되었는지는 테스트로 검증할 가치가 있어요
class MoneyTest {
@Nested
@DisplayName("equals")
class Equals {
@Test
@DisplayName("amount가 같으면 두 Money는 같다")
void equal_whenAmountIsSame() {
Money a = Money.of(1_000L);
Money b = Money.of(1_000L);
assertThat(a).isEqualTo(b);
}
@Test
@DisplayName("amount가 다르면 두 Money는 다르다")
void notEqual_whenAmountDiffers() {
Money a = Money.of(1_000L);
Money b = Money.of(2_000L);
assertThat(a).isNotEqualTo(b);
}
@Test
@DisplayName("같은 값이면 hashCode도 같다")
void hashCode_consistentWithEquals() {
Money a = Money.of(1_000L);
Money b = Money.of(1_000L);
assertThat(a.hashCode()).isEqualTo(b.hashCode());
}
}
}
마지막 hashCode 테스트가 특히 중요해요. Java의 equals/hashCode 계약은 다음을 요구합니다.
a.equals(b)가 true면 a.hashCode() == b.hashCode()여야 함이 계약이 깨지면 HashSet, HashMap에 VO를 넣었을 때 같은 값인데도 중복으로 들어가거나, 조회가 안 되는 비극이 생깁니다.
VO도 Entity와 마찬가지로 생성 시점에 도메인 규칙을 가집니다. 그리고 VO는 한 번 만들어지면 변하지 않으니까, 생성 검증이 곧 "이 객체의 모든 인스턴스가 항상 유효하다"는 보장이 돼요. 이게 VO가 도메인 어휘를 강력하게 만드는 이유입니다.
class MoneyTest {
@Nested
@DisplayName("of")
class Of {
@Test
@DisplayName("0 이상의 값으로 Money를 만들 수 있다")
void createNormal() {
Money money = Money.of(1_000L);
assertThat(money.getAmount()).isEqualTo(1_000L);
}
@Test
@DisplayName("음수로 Money를 만들면 BAD_REQUEST 예외")
void throwsBadRequest_whenAmountIsNegative() {
assertThatThrownBy(() -> Money.of(-1L))
.isInstanceOfSatisfying(CoreException.class, e ->
assertThat(e.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST));
}
}
}
VO에 산술 연산이나 변환 메서드가 있다면, 원본을 변경하지 않고 새 객체를 반환하는 것이 표준 패턴입니다. 이걸 테스트로 작성하여 직접 필드를 수정하는 것을 잡을 수 있어요.
@Test
@DisplayName("add는 새 Money를 반환하고 원본은 변하지 않는다")
void add_returnsNewInstance() {
Money original = Money.of(1_000L);
Money result = original.add(Money.of(500L));
assertThat(result).isEqualTo(Money.of(1_500L));
assertThat(original).isEqualTo(Money.of(1_000L)); // 원본 보존
assertThat(result).isNotSameAs(original); // 새 인스턴스
}
isNotSameAs는 참조 동일성(!=) 비교예요. 연산 결과가 원본과 다른 인스턴스라는 점을 명시해서 add가 원본을 직접 수정하지 않고 새 객체를 반환한다는 불변성 규약을 코드로 드러냅니다.
VO에서 가장 먼저 작성할 테스트는 동등성(equals/hashCode)입니다. 그다음 생성 불변식(음수 거부 같은 도메인 규칙), 마지막으로 연산 메서드(add, subtract 등). 동등성이 깨지면 VO가 도메인 어휘로 작동하지 않으니 이게 가장 우선이에요.
Domain Service는 도메인 흐름을 조정합니다. Repository로부터 데이터를 받아, 도메인 객체에게 작업을 위임하고, 결과를 호출자에게 돌려주는 일이에요. Service가 책임지는 건 도메인 흐름의 조율과 응답의 정확성 입니다. 그러므로 Service 테스트도 두 축을 함께 봐요 — 어떤 협력자를 어떻게 호출했는가, 그리고 호출자가 받는 결과가 의도한 상태인가.
협력자가 약속된 응답을 돌려준다는 가정 위에서 이 두 가지를 검증하면 되니 진짜 DB나 외부 시스템 없이 Spring 컨텍스트에 독립적으로 테스트를 작성할 수 있어요.
의도의 명확성. 이 테스트를 읽는 사람은 "도메인 규칙이 이렇게 동작한다"만 알면 됩니다. Spring이나 DB나 트랜잭션 같은 부가 정보가 없어 코드 의도가 또렷하게 드러나요. "인터페이스가 약속한 행위가 이렇게 응답할 때, 도메인이 어떻게 동작하는가"를 작성합니다.
행위 흉내 내기 — Mock의 의미. 인터페이스가 정의한 행위를 Mockito로 흉내 내는 것입니다. MemberRepository가 existsByLoginId, save, findById 같은 행위를 약속한다면, 테스트에서는 그 행위들이 어떤 입력에 어떤 응답을 돌려주는지만 정의하면 돼요.
진짜 DB가 어떻게 데이터를 보관하는지는 도메인의 관심사가 아니니, 테스트에서도 신경 쓸 필요가 없습니다. -> 이 부분은 False Negative가 발생할 수 있는지 테스트 작성 시 고민해보면 좋다.
// Repository는 인터페이스 — 행위의 집합
public interface MemberRepository {
boolean existsByLoginId(String loginId);
Member save(Member member);
Optional<Member> findById(Long id);
}
// 테스트에서는 행위의 응답 정의
when(memberRepository.existsByLoginId("testId")).thenReturn(false);
when(memberRepository.save(any(Member.class)))
.thenAnswer(inv -> inv.getArgument(0));
Mockito 도구로 Service 테스트를 작성할 때는 다음 5단계를 따릅니다.
@ExtendWith(MockitoExtension.class)로 Mockito 활성화 (Spring 안 띄움)@Mock으로 의존성 인터페이스를 가짜로 생성@BeforeEach에서 new로 테스트 대상을 직접 생성하고 Mock 주입given().willReturn()으로 Mock의 행위 응답 정의verify() 또는 then().should()로 호출 검증코드 안에서 클래식 스타일과 BDD 스타일이 어떻게 다른지 한눈에 비교할 수 있도록, 같은 시나리오를 두 스타일로 나란히 작성했어요. 실제 작성할 때는 한 클래스에서 한 스타일로 통일해야 합니다.
@ExtendWith(MockitoExtension.class) // 1) Mockito 활성화
class MemberServiceTest {
@Mock // 2) 의존성 인터페이스를 Mock으로
private MemberRepository memberRepository;
@Mock
private PasswordEncoder passwordEncoder;
private MemberService memberService;
@BeforeEach
void setUp() {
// 3) 직접 생성하고 Mock 주입
memberService = new MemberService(memberRepository, passwordEncoder);
}
@Test
void 중복되지_않는_loginId면_정상_저장된다() {
// given - 4) 입력과 Mock의 행위 응답 정의
CreateCommand command = new CreateCommand("testId", "rawPw");
// === 클래식 스타일 ===
when(memberRepository.existsByLoginId("testId")).thenReturn(false);
when(passwordEncoder.encode("rawPw")).thenReturn("ENCODED");
when(memberRepository.save(any(Member.class)))
.thenAnswer(inv -> inv.getArgument(0));
// === BDD 스타일 (위 클래식과 동등)===
//given(memberRepository.existsByLoginId("testId")).willReturn(false);
//given(passwordEncoder.encode("rawPw")).willReturn("ENCODED");
//given(memberRepository.save(any(Member.class)))
// .willAnswer(inv -> inv.getArgument(0));
// when
Member result = memberService.create(command);
// then - 5) 결과와 호출 검증
assertThat(result.getLoginId()).isEqualTo("testId");
assertThat(result.getPassword()).isEqualTo("ENCODED");
// === 클래식 스타일 ===
verify(memberRepository).save(any(Member.class));
// === BDD 스타일 (위 클래식과 동등)===
//then(memberRepository).should().save(any(Member.class));
}
}
두 스타일의 차이는 stub 등록과 호출 검증 두 군데에서만 나타납니다. 나머지(@Test, @Mock, given/when/then 주석, AssertJ)는 모두 동일해요. 자세한 비교는 CHAPTER 11 — Verify 문법을 참고하세요.
위 패턴에서 @BeforeEach에 new로 직접 객체를 생성하는 이유, 그리고 @InjectMocks를 권장하지 않는 근거는 CHAPTER 03 — 3.1 Mockito 도구의 "의존성 주입: @InjectMocks 대신 수동 생성을 권장"에서 자세히 다뤘습니다.
Domain Service가 의존하는 대상은 인터페이스입니다. 도메인 레이어는 인프라 레이어를 모르는 게 의존성 역전 원칙(DIP)의 핵심이에요.
domain/
├─ MemberService
└─ MemberRepository (interface) ← Service가 의존하는 추상
infrastructure/
└─ MemberJpaRepository ← 구현체 (Service는 모름)
implements MemberRepository
테스트에서 구현체(MemberJpaRepository)를 Mock하면 다음 문제가 생깁니다.
Service가 의존하는 그 인터페이스를 Mock하세요. 그게 Service의 관점에서 자연스럽고, 구현체 교체에도 안전합니다.
Service 테스트를 작성할 때 가장 자주 마주치는 고민은 "무엇을 어디까지 검증할 것인가"입니다. 너무 적게 검증하면 버그를 놓치고, 너무 많이 검증하면 다른 레이어의 책임을 침범해서 fragile test가 돼요. 이 절은 그 균형점을 잡는 원칙을 다룹니다.
4.2에서 정리했듯, Domain Service의 책임은 도메인 흐름의 조율과 응답의 정확성 두 가지예요. Service 테스트도 이 두 축을 함께 검증합니다.
두 축 중 어느 하나만 검증하면 빈틈이 생겨요. 흐름만 보면 *"호출은 됐는데 결과는 엉터리"*인 버그를 못 잡고, 응답만 보면 *"결과는 맞는데 부수효과는 엉뚱한"* 버그를 못 잡습니다.
응답을 검증하다 자주 빠지는 함정이 있어요. 다음 두 패턴을 보세요.
// 시나리오: getMemberProfile 메서드 검증
// public MemberProfile getMemberProfile(GetMemberCommand command) {
// return memberRepository.findById(command.id())
// .orElseThrow(...)
// .toProfile();
// }
@Test
void returnsProfile_whenMemberExists() {
Member member = MemberFixture.aMember();
when(memberRepository.findById(anyLong())).thenReturn(Optional.of(member));
MemberProfile result = memberService.getMemberProfile(...);
// ❌ 패턴 1 — 객체 통째 비교
assertThat(result).isEqualTo(member.toProfile());
// ❌ 패턴 2 — 모든 필드 매핑 검증
assertThat(result.loginId()).isEqualTo(member.getLoginId());
assertThat(result.email()).isEqualTo(member.getEmail().getValue());
// ... 다른 필드들
}
두 패턴 모두 통과는 하지만, 실제로 검증하는 게 거의 없어요. 이유를 풀어볼게요.
패턴 1의 함정 — 동어반복
Service 내부에서 호출한 member.toProfile()과 테스트가 호출한 member.toProfile()을 비교하는 거라, 이 두 값은 toProfile()이 어떻게 구현되든 항상 같습니다. toProfile()에 버그가 있어도 두 호출 모두 같은 잘못된 결과를 돌려주니 통과해버려요. 검증하는 건 사실상 "Service가 toProfile()을 호출했다"는 사실 정도뿐입니다.
패턴 2의 함정 — 다른 레이어 책임 침범
여기서 result.loginId()와 member.getLoginId()의 비교는 "Member의 loginId가 MemberProfile의 loginId로 정확히 매핑되었는가"를 검증합니다. 그런데 이건 Member.toProfile()의 책임이에요. Service의 책임이 아닙니다. 이렇게 작성하면 Member의 필드가 늘어나거나 toProfile의 매핑 규칙이 바뀔 때마다 Service 테스트가 함께 깨져요 — fragile test의 전형적 패턴입니다.
두 함정의 공통 원인을 짚어보면 같은 자리에 있어요. 기대값이 테스트 입력에서 파생된 값이라는 점이에요.
// 입력: member (테스트 input)
// 기대값: member.toProfile() 또는 member.getLoginId()
// ↑ 둘 다 동일한 input에서 파생된 값
//
// → 양쪽이 같은 출처라 동어반복
좋은 검증의 원칙은 정반대예요.
기대값은 테스트가 통제하는 명시적 값이어야 합니다. 코드 실행 결과나 입력에서 파생된 값이 아니라, 테스트 작성자가 직접 박아둔 리터럴 또는 픽스처 상수여야 해요. 기대값과 실제값의 출처가 같으면 검증은 동어반복이 되고, 버그가 통과해도 알아차릴 수 없습니다.
이 원칙을 적용하면 검증은 다음과 같이 바뀝니다.
// ✅ 기대값이 픽스처의 명시적 상수
assertThat(result.loginId()).isEqualTo(MemberFixture.DEFAULT_LOGIN_ID);
// 입력: member (DEFAULT_LOGIN_ID로 만들어진 Member)
// 기대값: MemberFixture.DEFAULT_LOGIN_ID (테스트가 통제하는 상수)
// ↑ 양쪽이 다른 출처 — 진짜 검증
이렇게 하면 *"입력이 X일 때 출력이 Y가 된다"*는 매핑이 코드에 명시되어, Service가 정말로 그 결과를 호출자에게 전달하는지 직접적으로 검증할 수 있어요.
위 원칙을 Service 테스트의 정상 케이스에 적용하면, 검증은 의외로 가벼워도 충분합니다.
@Test
@DisplayName("회원이 존재하면 MemberProfile 을 반환한다.")
void returnsProfile_whenMemberExists() {
Member member = MemberFixture.aMember();
when(memberRepository.findById(anyLong())).thenReturn(Optional.of(member));
MemberProfile result = memberService.getMemberProfile(
new MemberServiceDto.GetMemberCommand(1L)
);
assertThat(result).isNotNull();
assertThat(result.loginId()).isEqualTo(MemberFixture.DEFAULT_LOGIN_ID);
}
이 두 줄이 검증하는 것:
result != null — Service가 정상 흐름을 끝까지 통과해 결과를 만들어냄. NOT_FOUND 분기로 빠지지 않았다는 보장loginId가 명시적 상수와 일치 — 변환이 일어났고, 호출자가 받은 결과가 *"그 회원의 데이터"*임을 통제된 값으로 확인왜 한 필드만으로 충분한가? Service의 책임이 *"toProfile 결과를 그대로 전달"*이지, 모든 필드의 매핑 정확성이 아니거든요. 한 필드만 확인해도 *"전달이 일어났다"*는 사실은 충분히 증명됩니다.
위 패턴이 가능한 이유는 변환의 정확성을 다른 레이어에서 책임지기 때문이에요. 다음과 같이 책임이 분리되어야 합니다.
| 검증 대상 | 책임 위치 | 이유 |
|---|---|---|
| Service가 협력자를 호출했는가 | Service 테스트 | 흐름 조율은 Service의 책임 |
| 분기 조건이 정확한가 (NOT_FOUND, CONFLICT 등) | Service 테스트 | 분기 제어는 Service의 책임 |
| 호출자가 결과를 받는가 (null 아닌지) | Service 테스트 | 응답 흐름의 완결성 |
Member.toProfile()의 매핑 정확성 |
MemberTest |
변환 메서드는 Member의 책임 |
Member Entity의 비즈니스 규칙 |
MemberTest |
도메인 규칙은 Entity의 책임 |
| JPA 매핑 · Repository 쿼리 | @DataJpaTest |
인프라 검증은 슬라이스 테스트 |
이렇게 책임을 분리하면 각 테스트가 자기 영역만 검증하니, 한 곳의 변경이 여러 테스트를 동시에 깨뜨리지 않아요. toProfile()의 매핑 규칙을 바꿔도 MemberTest만 깨지고 Service 테스트는 영향받지 않습니다.
Service 테스트의 정상 케이스는 가볍게 — 흐름이 끝까지 통과했음(isNotNull) + 호출자가 받은 결과가 그 회원의 데이터임(픽스처 상수와 한 필드 비교). 매핑·변환의 정확성은 도메인 테스트로 위임. 이 분리가 "기대값은 테스트가 통제하는 값이어야 한다"는 원칙을 자연스럽게 따르게 만들어요.
Facade는 비즈니스 로직을 담기보다는 여러 도메인 서비스와 인프라 컴포넌트를 오케스트레이션하는 역할입니다. 트랜잭션 경계, 이벤트 발행, 외부 시스템 호출 순서, 여러 Aggregate 간 협력 같은 것들이 여기서 조립됩니다.
Facade는 여러 도메인 서비스와 인프라 컴포넌트를 엮어 하나의 비즈니스 흐름을 완성하는 게 책임이에요. 그러므로 Facade 테스트가 검증해야 할 것은 개별 협력자의 동작이 아니라, 이들이 함께 실행됐을 때 만들어내는 유기적 결과입니다.
구체적으로 다음을 관찰해야 합니다.
이런 관찰은 실제 빈을 띄우고 진짜 DB(또는 Testcontainers)와 연결한 통합 테스트에서만 가능해요. 단위 테스트로 의존성을 모두 Mock 처리하면 협력의 결과 대신 약속된 호출 순서를 확인하게 되어, 실제 통합에서 일어나는 문제를 잡을 수 없습니다. 외부 API처럼 통제 불가능한 의존성만 선택적으로 Mock으로 대체하고 나머지는 실제 빈으로 엮어 검증합니다.
UseCase 통합 테스트는 원칙적으로 모든 빈을 실제로 띄우고 검증합니다. 그런데 어떤 시나리오는 실제 빈으로는 만들기 어려워요. 대표적인 게 "외부 협력자가 실패할 때 우리 UseCase는 어떻게 동작하는가"입니다.
@MockitoSpyBean
private PointService pointService;
@Test
@DisplayName("포인트 생성 실패 시, 회원도 생성되지 않는다.")
void doesNotCreatedMember_whenCreatePointFail() {
doThrow(new CoreException(ErrorType.CONFLICT, "포인트 생성 실패"))
.when(pointService).createInitialPoint(any());
assertThatThrownBy(() -> memberUseCase.signUp(info))
.isInstanceOf(CoreException.class);
assertThat(memberRepository.existsByLoginId(info.loginId())).isFalse();
}
이 테스트의 의도는 "PointService가 실패하면 Member도 함께 롤백되는가"를 확인하는 거예요. 그러려면 PointService가 실패하는 상황을 만들어야 하는데, 실제 PointService는 정상 동작이 기본이라 실패를 자연스럽게 일으킬 방법이 없습니다. 이때가 바로 의도적인 시나리오 조작이 필요한 자리예요.
여기서 @MockitoBean이 아닌 @MockitoSpyBean을
선택한 이유가 핵심입니다.
@MockitoBean은 빈 전체를 Mock으로 교체 — stub하지
않은 메서드는 모두 기본값(null/0/false)을 반환합니다. PointService에 다른 메서드들이
있다면 그것들이 모두 빈 동작이 되어, 통합의 의미가 사라져요.@MockitoSpyBean은 실제 빈을 감싸기만 함 —
명시적으로 doThrow(...).when(...)로 지정한 메서드만 동작이 바뀌고,
나머지는 실제 빈 그대로 동작합니다. 그래서 "createInitialPoint만 실패하고,
다른 PointService 동작은 정상"이라는 좁은 시나리오 조작이 가능해요.즉 @MockitoSpyBean은 통합의 진짜 동작은 유지하면서
필요한 한 메서드만 의도적으로 stub하고 싶을 때 쓰는 도구입니다. 실패
주입처럼 정상 흐름으로는 만들 수 없는 시나리오를 검증할 때 가장 자연스러워요.
UseCase 통합 테스트에서 협력자를 다룰 때:
(1) 실제 동작을 그대로 쓰고 싶다 → 그냥 실제 빈 (아무 어노테이션 없음).
(2) 한 두 메서드만 시나리오를 위해 비틀고 싶다 → @MockitoSpyBean.
(3) 빈 전체를 가짜로 대체하고 싶다 → @MockitoBean.
다만 (3)을 선택하면 통합의 의미가 약해지니, UseCase 테스트에서는 보통 피합니다.
Facade 통합 테스트의 책임은 유즈케이스 흐름의 결과를 보는 것이지, 도메인 규칙 자체를 보는 게 아닙니다. 두 영역을 명확히 나누면 다음 4가지가 통합 테스트의 자리예요.
유즈케이스가 끝났을 때 DB에 여러 도메인 객체가 일관되게 저장되었는가를 봅니다. 이게 통합 테스트의 핵심 가치예요. 검증은 호출 여부가 아니라 실제 DB 상태로 합니다.
@Test
void 회원가입_시_Member와_초기_Point가_함께_생성된다() {
SignUpInfo info = new SignUpInfo("testId", "rawPw");
SignUpResult result = memberUseCase.signUp(info);
Member savedMember = memberRepository.findById(result.memberId()).orElseThrow();
Point savedPoint = pointRepository.findByMemberId(savedMember.getId()).orElseThrow();
assertThat(savedMember.getLoginId()).isEqualTo("testId");
assertThat(savedPoint.getMemberId()).isEqualTo(savedMember.getId());
}
여기서 짚을 게 있어요. "데이터 일관성"이 정확히 무엇을 의미하는지 정의해야 무엇을 검증할지가 분명해집니다. 유즈케이스의 본질은 "여러 도메인 객체와 협력자를 조합해서 하나의 비즈니스 트랜잭션을 완성한다"예요. 그 결과로 시스템 전체 상태가 모순 없이 정합한가가 데이터 일관성입니다.
이를 구체적인 검증 포인트로 풀면 네 가지가 됩니다.
유즈케이스가 여러 엔티티를 동시에 만들어야 한다면, 모두 만들어졌는지 확인합니다.
Member savedMember = memberRepository.findById(result.memberId()).orElseThrow();
Point savedPoint = pointRepository.findByMemberId(result.memberId()).orElseThrow();
assertThat(savedMember).isNotNull();
assertThat(savedPoint).isNotNull();
왜 이게 유즈케이스의 책임인가: 도메인 단위 테스트는 각 객체의 생성 로직만 봅니다. 두 객체가 실제로 같이 만들어진다는 사실은 어디서도 검증되지 않아요. 유즈케이스가 pointService.createInitial() 호출 한 줄을 빼먹어도 도메인 테스트는 모두 통과합니다. 이 누락은 통합 테스트만이 잡을 수 있어요.
여러 엔티티가 만들어졌더라도 서로 올바르게 연결되어야 합니다.
assertThat(savedPoint.getMemberId()).isEqualTo(savedMember.getId());
왜 별도 검증이 필요한가: 회원 A의 가입 흐름에서 다른 회원 ID로 포인트가 만들어지는 버그가 있어도, "회원이 만들어졌고 포인트도 만들어졌다"까지만 보면 통과해버립니다. 연관이 정확한가는 별도의 검증 포인트예요. 특히 동시에 여러 회원을 만드는 시나리오에서 ID가 뒤바뀌어도 모르는 채 통과할 수 있습니다.
새로 생성된 엔티티의 초기값이 비즈니스 규칙에 부합해야 합니다.
assertThat(savedPoint.getBalance().isZero()).isTrue();
왜 유즈케이스가 책임지는가: "회원 가입 시 포인트 0원"은 이 시스템의 비즈니스 규칙입니다. 도메인 객체(Point)는 받은 값을 잘 저장할 뿐, "유즈케이스가 어떤 초기값을 넣을지"는 책임지지 않아요. 유즈케이스가 잘못된 초기값(예: 1000원 기본 지급)을 넣어도 도메인 테스트는 모두 통과합니다. 초기값의 정확성은 유즈케이스 레이어의 책임이에요.
상태 변경이 일어나는 흐름은 변경 결과가 정말 반영되었는지 확인합니다. 0이 아닌 초기값에서 검증하는 게 핵심이에요.
@Test
void 충전_시_잔액이_누적된다() {
Long memberId = aMemberWithBalance(5_000L); // 미리 5000원 있는 회원
pointUseCase.charge(new ChargeInfo(memberId, 1_000L));
Point updated = pointRepository.findByMemberId(memberId).orElseThrow();
assertThat(updated.getBalance().getAmount()).isEqualTo(6_000L);
}
0에서 1000을 충전해서 1000을 검증하는 건 위험합니다. 누적 동작과 덮어쓰기 동작 모두 결과가 1000이라 두 가지를 구별 못해요. "유즈케이스가 도메인의 charge() 대신 단순 setter를 호출한다"는 버그도 0이 아닌 초기값에서만 잡힙니다.
유즈케이스 테스트가 떠안으면 안 되는 검증도 명확해야 합니다. 잘못 넣으면 책임이 다른 곳의 변경에도 함께 깨지는 fragile test가 돼요.
// ❌ Member 도메인의 책임 — MemberTest에서 검증
assertThat(savedMember.getEmail().getValue()).isEqualTo(info.email());
assertThat(savedMember.getBirthDate()).isEqualTo(info.birthDate());
assertThat(savedMember.getGender()).isEqualTo(info.gender());
// ❌ JPA 매핑의 책임 — @DataJpaTest에서 검증
assertThat(savedMember.getCreatedAt()).isNotNull();
// ❌ DTO 변환의 책임 — DTO 단위 테스트에서 검증
assertThat(result.email()).isEqualTo(info.email());
assertThat(result.gender()).isEqualTo(info.gender());
도메인 객체의 필드 매핑이 늘어날 때마다 유즈케이스 테스트가 함께 깨진다면 책임 경계가 무너진 신호예요. "이 검증이 깨질 만한 변경이 어느 레이어에서 일어나는가"를 한 번 더 묻고, 그 레이어의 테스트로 옮겨야 합니다.
| 검증 영역 | UseCase 테스트 | 다른 곳 |
|---|---|---|
| 여러 엔티티의 동시 생성 | ✓ | — |
| 엔티티 간 연관관계 설정 | ✓ | — |
| 초기 상태가 도메인 규칙 따름 | ✓ | — |
| 상태 변경의 누적·반영 | ✓ | — |
| 트랜잭션 경계 (실패 시 롤백) | ✓ | — |
| 도메인 필드 매핑 | ✗ | 도메인 단위 테스트 |
| 도메인 비즈니스 규칙 (음수 거부 등) | ✗ | 도메인 단위 테스트 |
| JPA 매핑 · SQL 동작 | ✗ | @DataJpaTest |
| HTTP 응답 형식 | ✗ | Controller 슬라이스 |
| 단순 DTO 변환 | ✗ | DTO 단위 테스트 |
도메인 단위 테스트는 객체 단위로 격리되어 있어요. MemberTest는 Member만, PointTest는 Point만 봅니다. 두 객체가 함께 만들어지고 서로 연결되는 흐름은 어디서도 검증되지 않아요. 유즈케이스 통합 테스트만이 "객체들 간 상호작용의 결과로 만들어지는 시스템 차원의 정합성"을 볼 수 있습니다. 이게 통합 테스트의 존재 이유이자, 유즈케이스 레이어가 책임지는 데이터 일관성의 본질이에요.
orElseThrow 같은 Facade 코드 자체에서 던지는 예외를 검증합니다. 도메인 Service는 Optional.empty()만 반환하고, "NOT_FOUND라는 의사결정"은 Facade의 책임이에요.
@Test
void 존재하지_않는_회원_조회_시_NOT_FOUND_예외() {
assertThatThrownBy(() -> memberUseCase.me(new MeInfo(999L)))
.isInstanceOf(CoreException.class)
.extracting("errorType")
.isEqualTo(ErrorType.NOT_FOUND);
}
Facade의 가장 중요한 책임 중 하나입니다. 어느 한 Service가 실패했을 때 다른 Service가 만든 데이터까지 함께 롤백되는가를 검증해요. 이건 DB 상태로만 검증할 수 있고, 단위 테스트로는 절대 잡을 수 없는 영역입니다.
@Test
void 포인트_생성_실패_시_회원도_생성되지_않는다() {
doThrow(new CoreException(ErrorType.CONFLICT, "포인트 생성 실패"))
.when(pointService).createInitialPoint(any());
assertThatThrownBy(() -> memberUseCase.signUp(info))
.isInstanceOf(CoreException.class);
// ↓ 이 검증이 통합 테스트의 진짜 가치
assertThat(memberRepository.existsByLoginId(info.loginId())).isFalse();
}
이 테스트가 실패한다면 Facade에 @Transactional이 누락된 신호일 가능성이 큽니다. 회원과 포인트가 같은 트랜잭션 안에서 일어나야 일관성이 보장돼요.
이메일 발송, 결제 게이트웨이 같은 외부 시스템이 올바르게 호출됐는지 검증합니다. 이런 협력자만 @MockitoBean으로 막고 verify로 호출을 확인해요.
@MockitoBean
private EmailService emailService;
@Test
void 회원가입_시_환영_이메일이_발송된다() {
memberUseCase.signUp(info);
then(emailService).should().sendWelcomeEmail(eq("testId@example.com"));
}
한 가지 자주 헷갈리는 점이 있어요. 예를 들어 "중복된 loginId로 회원가입하면 예외가 발생한다"는 검증을 어디에서 해야 할까요? 이건 도메인 단위 테스트의 책임입니다.
| 테스트 종류 | 검증 대상 | 예시 |
|---|---|---|
| 도메인 Service 단위 테스트 | 도메인 규칙 자체 | "loginId 중복 시 예외 발생" |
| Facade 통합 테스트 | 유즈케이스 흐름의 결과 | "회원가입 시 회원+포인트가 함께 생성" |
MemberServiceTest가 "loginId 중복 거부 규칙"을 보장한다면, MemberUseCaseTest는 그 규칙을 다시 확인할 필요가 없어요. 이미 검증된 부품들이 어떻게 협력해서 유즈케이스를 완성하는가만 보면 됩니다.
단위 테스트는 "규칙이 지켜지는가"를, 통합 테스트는 "여러 규칙이 협력했을 때의 결과가 일관된가"를 본다.
@SpringBootTest
@Transactional
class OrderFacadeTest {
@Autowired
private OrderFacade orderFacade;
@Autowired
private OrderRepository orderRepository;
@MockitoBean // 외부 API는 Mock
private PaymentGatewayClient paymentGatewayClient;
@Test
void 주문_생성시_재고_차감과_결제_요청이_수행된다() {
// given
given(paymentGatewayClient.request(any())).willReturn(PaymentResult.success());
OrderCommand command = new OrderCommand(userId, productId, 2);
// when
OrderResult result = orderFacade.placeOrder(command);
// then
Order saved = orderRepository.findById(result.orderId()).orElseThrow();
assertThat(saved.getStatus()).isEqualTo(OrderStatus.PAID);
then(paymentGatewayClient).should().request(any());
}
}
Facade 테스트인데 도메인 Service나 Repository까지 @MockitoBean으로 바꾸면 그냥 단위 테스트로 쓰는 게 낫습니다. 외부 API 클라이언트, 메일 발송기처럼 통제 불가능한 의존성만 Mock으로 대체하세요.
Facade에 분기 로직이 많아지고 있다면 설계 신호로 봐야 합니다. 본래 Facade는 얇아야 하고, 조건문이나 계산 로직은 도메인으로 내려가야 해요. Facade를 단위 테스트하고 싶어지는 순간이 오면 "이 로직이 Facade에 있는 게 맞나?"를 먼저 의심해보세요.
Controller는 비즈니스 로직을 담는 곳이 아니라, HTTP 세계와 도메인 세계를 잇는 변환 지점이에요. 이 책임에 맞는 테스트 도구가 따로 있고, 어떤 도구를 쓰느냐에 따라 검증할 수 있는 영역이 달라집니다.
구체적인 컨트롤러 코드를 예로 들어볼게요.
@RequiredArgsConstructor
@RequestMapping("/api/v1/members")
@RestController
public class MemberV1Controller implements MemberV1ApiSpec {
private final MemberUseCase memberUsecase;
@PostMapping
public ApiResponse<MemberV1Dto.SignUpResponse> signUp(
@Valid @RequestBody MemberV1Dto.SignUpRequest signUpRequest) {
MemberUseCaseDto.SignUpResult result = memberUsecase.signUp(signUpRequest.toCommand());
return ApiResponse.success(MemberV1Dto.SignUpResponse.from(result));
}
@GetMapping("/me")
public ApiResponse<MemberV1Dto.MeResponse> me(
@RequestHeader(name = "X-USER-ID", required = true) Long id) {
MemberUseCaseDto.MeResult meResult = memberUsecase.me(new MemberUseCaseDto.MeInfo(id));
return ApiResponse.success(MemberV1Dto.MeResponse.from(meResult));
}
}
이 컨트롤러가 책임지는 것을 정리하면 다음과 같아요.
| 책임 | 구체적 메커니즘 |
|---|---|
| HTTP → 도메인 변환 | @RequestBody로 JSON 역직렬화, toCommand()로 도메인 명령 변환 |
| 유즈케이스 위임 | memberUsecase.signUp() 호출 |
| 도메인 → HTTP 변환 | 결과를 SignUpResponse로 변환, ApiResponse로 감쌈 |
| 요청 검증 | @Valid로 DTO 검증 트리거 |
| 헤더 처리 | @RequestHeader(required = true) |
| HTTP 메서드/경로 매핑 | POST /api/v1/members, GET /api/v1/members/me |
이 책임들이 깨지면 클라이언트가 API를 사용할 수 없어요. 비즈니스 규칙이 아무리 정확해도 HTTP 변환이 어긋나면 의미가 없거든요. 그래서 컨트롤러는 자기만의 검증이 필요합니다.
인터페이스 레이어 테스트에는 두 가지 도구를 사용해요. 각 도구는 자기만의 검증 영역이 있고, 그 영역이 겹치지 않도록 책임을 명확히 나누는 게 핵심입니다.
| 도구 | 구동 환경 | 검증 영역 |
|---|---|---|
@WebMvcTest + MockMvc |
웹 레이어만 (서블릿 컨테이너 없음) | HTTP 처리 책임 (URL, 검증, 직렬화, 헤더, 응답) |
@SpringBootTest(RANDOM_PORT) + TestRestTemplate |
실제 톰캣 + 진짜 HTTP | 운영 환경 동작 (필터/인터셉터, 네트워크, 핵심 시나리오) |
"내가 작성한 코드만 테스트한다. 프레임워크의 기본 동작은 테스트하지 않는다."
Spring을 못 믿고 검증하기 시작하면 테스트가 끝없이 늘어납니다. 우리가 검증할 것은 우리가 추가한 어노테이션(@NotBlank, @RequestHeader(required = true)), 우리가 작성한 변환 로직(@ExceptionHandler), 우리가 정의한 응답 구조 세 가지면 충분해요.
@WebMvcTest는 웹 레이어 빈만 로드하는 슬라이스 테스트예요. Controller, ControllerAdvice, Filter, MessageConverter 같은 MVC 인프라만 활성화되고 Service, Repository는 로드되지 않아요. UseCase는 반드시 Mock으로 대체해야 합니다.
컨트롤러의 HTTP 처리 책임만 격리해서 검증해요. UseCase 이하는 Mock으로 막고, 컨트롤러가 그 책임을 잘 수행하는지만 봅니다.
다만 모든 항목을 다 검증할 필요는 없어요. 내가 작성한 코드만 테스트하고, 프레임워크의 기본 동작은 테스트하지 않는다는 원칙으로 우선순위를 가립니다.
@Valid 검증 — @NotBlank, @Size 같은 어노테이션은 우리가 직접 붙이는 책임입니다. 누가 실수로 빼면 잘못된 데이터가 도메인까지 흘러가요.@RequestHeader(required = true)는 우리가 정한 정책입니다. 변경되면 NPE로 이어질 수 있어 안전망이 필요해요.즉, 실패케이스 위주로 확인한다. 성공케이스는 의존성까지 로딩되어야 하므로 E2E 테스트에서 확인합니다.
@JsonDeserializer나 LocalDateTime 포맷 같은 특수 변환이 있다면 검증 가치가 있어요.위에서 정리한 2가지 필수 검증을 차례로 작성해볼게요.
@WebMvcTest(MemberV1Controller.class)
class MemberV1ControllerWebMvcTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@MockitoBean
private MemberUseCase memberUseCase;
// ─── @Valid 검증 ─────────────────────────────
@Test
@DisplayName("signUp 요청에 loginId가 누락되면 400 Bad Request")
void signUp_validation_blank_loginId() throws Exception {
SignUpRequest invalid = new SignUpRequest(null, "rawPwd123", "test@test.com");
mockMvc.perform(post("/api/v1/members")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(invalid)))
.andExpect(status().isBadRequest());
}
@Test
@DisplayName("signUp 요청 비밀번호가 8자 미만이면 400 Bad Request")
void signUp_validation_short_password() throws Exception {
SignUpRequest invalid = new SignUpRequest("testId", "short", "test@test.com");
mockMvc.perform(post("/api/v1/members")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(invalid)))
.andExpect(status().isBadRequest());
}
// ─── 헤더 추출 정책 ──────────────────────────
@Test
@DisplayName("me 조회 시 X-USER-ID 헤더가 없으면 400 Bad Request")
void me_missing_header() throws Exception {
mockMvc.perform(get("/api/v1/members/me"))
.andExpect(status().isBadRequest());
}
}
@WebMvcTest(MemberV1Controller.class)는 Web 슬라이스만
로드합니다. Controller, Filter, ExceptionHandler, MessageConverter 등
HTTP 처리에 필요한 빈만 컨텍스트에 올라오고, UseCase나 Service 같은 도메인 빈은
처음부터 등록되지 않아요.
그래서 컨트롤러가 의존하는 UseCase는 테스트가 직접 가짜 빈을
등록해야 합니다. 이때 쓰는 것이 @MockitoBean이에요.
@MockitoSpyBean은 실제 빈을 감싸는 도구인데, 슬라이스
테스트에는 감쌀 실제 빈이 처음부터 없으니 적절한 선택이 아닙니다.
@WebMvcTest(MemberV1Controller.class)
class MemberV1ControllerTest {
@Autowired MockMvc mockMvc;
@MockitoBean // ← 컨텍스트에 없는 UseCase를 가짜로 등록
MemberUseCase memberUseCase;
}
이 구조의 자연스러운 결과로, 컨트롤러 슬라이스 테스트는 HTTP 경계의 관심사만 검증하는 자리가 됩니다.
구체적으로 어떤 빈이 로드되고 어떤 빈이 로드되지 않는지 정리하면 다음과 같아요. 이 차이가 @WebMvcTest의 빠른 속도와 격리를 만드는 핵심입니다.
| 로드되는 빈 | 로드되지 않는 빈 |
|---|---|
@Controller, @RestController | @Service, @Component (UseCase 등) |
@ControllerAdvice | @Repository |
Filter, HandlerInterceptor | JPA 설정 |
WebMvcConfigurer | DataSource |
실제 톰캣을 띄우고 진짜 HTTP 요청을 보내서 운영 환경과 가장 유사하게 검증하는 도구입니다. 비용이 가장 비싸지만 다른 도구가 못 잡는 영역을 검증할 수 있어요.
간략하게 정리하면 아래와 같습니다.
각 영역에서 어떤 구체적 케이스가 검증되는지 보면 "왜 굳이 진짜 환경이 필요한가"가 분명해져요.
전체 시스템이 진짜로 돌아가는지 한 번 확인하는 안전망. 회원가입, 결제 같은 핵심 흐름 1~2개를 진짜 환경에서 처음부터 끝까지 돌려봅니다.
E2E의 가장 정당한 사용처예요. 필터와 인터셉터는 톰캣 환경에서 동작 방식이 미묘하게 달라서, 진짜 환경에서 검증하는 게 안전망 역할을 합니다.
Origin 헤더에 따라 CORS 헤더가 응답에 설정되는지. 브라우저-서버 통신 정책이라 진짜 HTTP 환경이 필수X-Request-Id 같은 헤더 전파, MDC 등록, OpenTelemetry 컨텍스트request.getRemoteAddr() 같은 메서드는 진짜 톰캣 환경에서만 정확한 값을 반환서블릿 컨테이너가 직접 처리하는 영역. MockMvc는 컨테이너 없이 동작하니 검증할 수 없어요.
server.tomcat.max-http-form-post-size 같은 설정으로 큰 요청을 톰캣이 차단하는 흐름server.compression.enabled=true 설정 시 진짜 압축이 이루어지는지MockMvc는 같은 JVM 안에서 객체로 시뮬레이션하지만, E2E는 진짜 HTTP 패킷이 오갑니다. 인코딩이나 Content-Type 협상 같은 영역에서 차이가 드러나요.
Accept 헤더에 따라 응답 포맷(JSON/XML) 결정User-Agent, Accept-Encoding 같은 헤더의 진짜 동작가장 흔한 사용처인 핵심 시나리오 스모크 테스트와 인증 필터 검증 두 가지를 보여드릴게요.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class MemberV1ControllerE2ETest {
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private MemberRepository memberRepository;
@Autowired
private DatabaseCleanUp databaseCleanUp;
@AfterEach
void tearDown() {
databaseCleanUp.truncateAllTables();
}
// ─── 핵심 시나리오 스모크 테스트 ─────────────────
@Test
@DisplayName("회원가입 E2E - 진짜 HTTP로 전체 흐름 검증")
void signUp_e2e() {
SignUpRequest request = new SignUpRequest("testId", "rawPwd123", "testId@test.com");
ResponseEntity<ApiResponse<SignUpResponse>> response = restTemplate.exchange(
"/api/v1/members",
HttpMethod.POST,
new HttpEntity<>(request),
new ParameterizedTypeReference<>() {}
);
assertAll(
() -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK),
() -> assertThat(response.getBody().getData().loginId()).isEqualTo("testId"),
() -> assertThat(memberRepository.existsByLoginId("testId")).isTrue()
);
}
// ─── 인증 필터 검증 (Spring Security + JWT) ───────
@Test
@DisplayName("유효한 JWT로 보호된 엔드포인트에 접근하면 200")
void authenticated_request_succeeds() {
Member saved = memberRepository.save(MemberFixture.createMember());
String validToken = jwtTokenProvider.createToken(saved.getId());
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(validToken);
ResponseEntity<ApiResponse<MeResponse>> response = restTemplate.exchange(
"/api/v1/members/me",
HttpMethod.GET,
new HttpEntity<>(headers),
new ParameterizedTypeReference<>() {}
);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
}
@Test
@DisplayName("만료된 JWT로 접근하면 401")
void expired_token_returns_401() {
String expiredToken = createExpiredToken();
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(expiredToken);
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/members/me",
HttpMethod.GET,
new HttpEntity<>(headers),
String.class
);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
}
// ─── 분산 트레이싱 헤더 검증 ─────────────────
@Test
@DisplayName("모든 응답에 X-Request-Id 헤더가 있다")
void response_includes_request_id_header() {
ResponseEntity<String> response = restTemplate.getForEntity(
"/api/v1/members/me", String.class);
assertThat(response.getHeaders().getFirst("X-Request-Id"))
.isNotNull()
.matches("[0-9a-f-]{36}"); // UUID 형식
}
}
웹 레이어부터 DB까지를 톰캣 없이 묶어 테스트하는 옵션도 존재해요. 하지만 이 테스트가 검증하는 영역은 대부분 다른 테스트들이 이미 커버합니다. URL/검증/직렬화는 @WebMvcTest가, 도메인 협력의 결과(DB 일관성)는 CHAPTER 05의 Facade 통합 테스트가, 진짜 환경 검증은 E2E가 잡아요. 명확한 동기 없이 추가하면 비용만 늘고 새 결함을 잡지 못해서, 권장 도구에서 제외했습니다.
각 테스트는 자기만의 검증 영역을 가져야 합니다. 도메인 단위 테스트는 비즈니스 규칙을, Facade 통합 테스트는 도메인 협력의 결과를, @WebMvcTest는 HTTP 처리를, E2E는 진짜 환경을 검증해요. 이 4계층이 협력하면 빠른 피드백과 안전성을 모두 얻을 수 있습니다.
중복된 검증은 비용(실행 시간, 유지 비용)만 늘리고 얻는 안전성은 늘지 않아요. 같은 코드 변경이 여러 테스트를 한꺼번에 깨면 디버깅도 어려워지고요. 각 도구가 어떤 결함을 잡는지를 명확히 알고 선택하는 것이 좋은 테스트 전략입니다.
JUnit 5 + Spring TestContext는 생성자 주입을 지원합니다. 클래스 또는 프로퍼티에 설정만 추가하면 @Autowired 없이도 깔끔하게 의존성을 받을 수 있어요.
@SpringBootTest
@TestConstructor(autowireMode = AutowireMode.ALL)
class MemberMeApiE2ETest {
private final TestRestTemplate testRestTemplate;
private final MemberJpaRepository memberJpaRepository;
private final DatabaseCleanUp databaseCleanUp;
// @Autowired 없이도 주입됨
MemberMeApiE2ETest(
TestRestTemplate testRestTemplate,
MemberJpaRepository memberJpaRepository,
DatabaseCleanUp databaseCleanUp
) {
this.testRestTemplate = testRestTemplate;
this.memberJpaRepository = memberJpaRepository;
this.databaseCleanUp = databaseCleanUp;
}
}
또는 application-test.yml에 다음을 설정하면 모든 테스트에 일괄 적용됩니다.
spring:
test:
constructor:
autowire:
mode: all
@Mock, @Spy 같은 Mockito 어노테이션은 Mockito가 필드에 직접 Mock 객체를 할당하는 방식이라 생성자 파라미터로는 동작하지 않습니다. @MockitoBean, @MockitoSpyBean 같은 Spring 빈 오버라이드 어노테이션도 마찬가지로 필드에만 적용 가능해요. 처리 주체와 메커니즘은 다르지만, 둘 다 생성자 파라미터로는 쓸 수 없습니다.
// @Mock은 생성자 파라미터로 못 씀
MemberServiceTest(
@Mock MemberRepository repo
) { ... }
@Mock
private MemberRepository repo;
@BeforeEach
void setUp() {
service = new MemberService(repo);
}
CHAPTER 03에서 두 어노테이션의 존재와 기본 용도를 간단히 다뤘어요. 이번 챕터에서는 작동 원리, 전략의 차이, Spy 사용 시 주의사항을 깊이 들여다봅니다. 통합 테스트나 E2E를 작성하면서 막히는 지점은 대부분 이 챕터의 내용이에요.
Spring Framework 6.2 / Spring Boot 3.4부터 새로운 어노테이션이 도입되었습니다. 기존 @MockBean, @SpyBean은 deprecated 처리되었어요.
| 구버전 (deprecated) | 신버전 | 용도 |
|---|---|---|
@MockBean | @MockitoBean | 빈을 Mock으로 교체 |
@SpyBean | @MockitoSpyBean | 빈을 Spy로 감쌈 |
Spring 공식 문서는 다음과 같이 명시합니다. "@MockitoBean and @MockitoSpyBean can be used to override a bean with a Mockito mock or spy, respectively. In the latter case, an early instance of the original bean is captured and wrapped by the spy."
| 항목 | @MockitoBean | @MockitoSpyBean |
|---|---|---|
| 전략 | REPLACE_OR_CREATE | WRAP |
| 동작 | 빈을 Mock으로 교체 | 원본 빈을 Spy로 감쌈 |
| 빈 부재 시 | 새로 생성 | 에러 (반드시 존재해야 함) |
| 기본 메서드 동작 | 모든 메서드 빈 응답 | 실제 메서드 그대로 동작 |
| 권장 stub 문법 | when().thenReturn() | doReturn().when() |
| 사용 목적 | 외부 의존성을 완전히 가짜로 | 실제 동작 살리되 일부만 가로채기 |
@SpringBootTest
class OrderFacadeTest {
@MockitoBean
private PaymentGatewayClient paymentGatewayClient;
@Test
void 결제_요청_검증() {
// 외부 API를 완전히 가짜로 만들어 격리된 테스트
when(paymentGatewayClient.request(any()))
.thenReturn(PaymentResult.success());
...
}
}
@SpringBootTest
class MemberServiceTest {
@MockitoSpyBean
private MemberRepository memberRepository;
@Test
void 중복_loginId_검증() {
// 다른 메서드는 실제 DB 동작, existsByLoginId만 강제로 true
doReturn(true).when(memberRepository).existsByLoginId("testId");
...
}
}
"As stated in the documentation for Mockito, there are times when using Mockito.when() is inappropriate for stubbing a spy — for example, if calling a real method on a spy results in undesired side effects."
Spy에서 when()을 쓰면 stub 등록 전에 실제 메서드가 호출되어 진짜 DB 쿼리가 발생할 수 있습니다. doReturn().when()을 쓰세요.
일반 통합 테스트에서는 @Transactional을 붙이면 메서드 종료 시 자동 롤백됩니다. 하지만 E2E 테스트에서는 이게 동작하지 않아요. 왜 그런지를 이해하려면 트랜잭션이 어떻게 작동하는지부터 차근차근 봐야 합니다.
Spring에서 트랜잭션은 DB 작업의 묶음이에요. @Transactional이 붙으면 그 메서드의 시작과 끝에 다음 두 가지가 일어납니다.
BEGIN)COMMIT), 예외 발생이면 롤백(ROLLBACK)커밋되면 변경 사항이 DB에 영구 반영되고, 롤백되면 변경 사항이 모두 사라져요.
먼저 E2E가 아닌 평범한 통합 테스트를 봅시다.
@SpringBootTest
@Transactional // ← 테스트 메서드를 트랜잭션으로 감쌈
class MemberServiceTest {
@Autowired
private MemberService memberService;
@Test
void test() {
memberService.create(command); // ① INSERT 실행
// ...
}
// 메서드 종료 → Spring이 자동 롤백
// ② DB에는 데이터가 남지 않음
}
여기서 일어나는 일을 시간순으로 보면:
memberService.create(command) 호출@Transactional은 이미 열려 있는 트랜잭션 A에 참여 (Propagation.REQUIRED 기본값)핵심은 테스트 메서드와 Service가 같은 트랜잭션을 공유한다는 거예요. 그래서 마지막에 한 번 롤백하면 모든 변경이 사라집니다.
이제 E2E 테스트를 봅시다. TestRestTemplate이 등장합니다.
@SpringBootTest(webEnvironment = RANDOM_PORT)
@Transactional // ← 효과 없음!
class MemberApiE2ETest {
@Autowired
private TestRestTemplate testRestTemplate;
@Test
void test() {
testRestTemplate.postForEntity("/members", body, Void.class);
// ↑ 이게 어떻게 동작하는지가 핵심
}
}
TestRestTemplate.postForEntity(...)가 호출되면 실제 HTTP 요청이 일어납니다. 같은 JVM 안이긴 하지만 네트워크 소켓을 통해 진짜 HTTP 패킷이 오가요. 그러면 다음과 같은 일이 일어납니다.
[테스트 스레드: T1] [톰캣 워커 스레드: T2]
───────────────── ──────────────────
@Transactional 시작
트랜잭션 A 열림 (T1 소속)
testRestTemplate.post(...)
─────HTTP 요청 (POST /members)─────→
Controller 진입
MemberFacade.create() 호출
@Transactional 만남
→ 활성 트랜잭션 없음
→ 트랜잭션 B 새로 엶 (T2 소속)
INSERT 실행
트랜잭션 B 커밋 (DB 반영!) ✓
응답 준비
←─────HTTP 응답 (200 OK)─────────────
testRestTemplate가 응답 받음
테스트 메서드 종료
트랜잭션 A 롤백
↑ 그런데 이건 T1의 트랜잭션
↑ T2가 만든 트랜잭션 B와 무관
↑ B는 이미 커밋되어 DB에 반영됨
서버 쪽 Controller/Service/Facade는 HTTP 요청을 전혀 새로운 작업의 시작으로 봅니다. 누가 호출했는지, 그 호출자가 어떤 트랜잭션을 가지고 있었는지 알 수 없어요. 왜냐하면:
ThreadLocal에 저장됨 — 스레드가 다르면 트랜잭션도 다른 세계@Transactional 정보는 톰캣 워커 스레드에서 보이지 않음그래서 서버 쪽 @Transactional은 "현재 트랜잭션 없음"으로 판단하고 새 트랜잭션을 처음부터 엽니다. 이게 "자기 트랜잭션을 연다"의 의미예요. 테스트가 만든 트랜잭션과 분리된, 서버만의 독자적 트랜잭션이라는 뜻입니다.
그리고 그 트랜잭션은 HTTP 요청이 끝날 때 커밋됩니다(예외가 없다면). 응답이 200 OK로 돌아왔다는 건 이미 DB에 INSERT가 영구 반영되었다는 뜻이에요.
@Transactional을 테스트에 붙인 사람의 기대는 보통 이렇습니다.
"테스트 끝나면 알아서 롤백되겠지. 다음 테스트는 깨끗한 DB로 시작."
그런데 실제로 일어나는 일은 다릅니다.
테스트 메서드의 트랜잭션 A는 롤백되지만, HTTP 요청이 만든 트랜잭션 B는 이미 커밋되어 DB에 데이터가 남음. 다음 테스트는 이전 테스트의 흔적을 안고 시작.
이게 누적되면 테스트 간 격리가 깨져서 다음과 같은 문제가 생겨요.
| 항목 | 일반 통합 테스트 | E2E 테스트 |
|---|---|---|
| 호출 방식 | Service를 직접 호출 | HTTP 요청 |
| 처리 스레드 | 테스트 스레드 | 톰캣 워커 스레드 |
| 트랜잭션 공유 | 가능 (같은 스레드) | 불가능 (다른 스레드) |
@Transactional 효과 | 자동 롤백 작동 | 자동 롤백 작동 안 함 |
| 데이터 정리 | 자동 (롤백) | 수동 (DatabaseCleanUp 필요) |
같은 JVM에서 실행되는데 왜 스레드가 다른가? Spring Boot의 @SpringBootTest(webEnvironment = RANDOM_PORT)는 실제 톰캣 서버를 띄웁니다. 그래서 HTTP 요청은 진짜 네트워크를 통해 톰캣 워커 스레드 풀로 전달돼요. 같은 프로세스 안이지만 스레드는 분리됩니다.
MOCK 환경이라면? webEnvironment = MOCK(기본값)에서 MockMvc를 쓰면 HTTP를 흉내내지만 실제 네트워크와 톰캣을 거치지 않아요. 이 경우엔 같은 스레드에서 처리되어 트랜잭션이 공유될 수 있습니다. 다만 이건 진짜 E2E라기보다 컨트롤러 슬라이스 테스트에 가까워요.
그럼 왜 굳이 E2E를 RANDOM_PORT로 하나? 진짜 환경에 가장 가깝게 검증하기 위해서예요. 트랜잭션 동작, 필터/인터셉터, 시리얼라이즈, HTTP 헤더 처리 등이 실제 배포 환경과 똑같이 일어납니다. 그 대가가 "자동 롤백 안 됨"이라는 제약이고요.
앞 섹션은 E2E에서 @Transactional이 동작하지 않는 이유를 다뤘습니다. 그렇다면 일반 통합 테스트에서 @Transactional을 붙이지 않는 경우는 어떨까요? 다음 같은 테스트에서 em.flush()나 em.clear()를 부르지 않아도 검증이 잘 동작하는 이유가 궁금할 수 있어요.
@SpringBootTest // ← @Transactional 없음
class PointUseCaseTest {
@Autowired private MemberRepository memberRepository;
@Autowired private PointRepository pointRepository;
@Autowired private PointUseCase pointUseCase;
@Test
void charge_addsToBalance() {
Member member = memberRepository.save(MemberFixture.aMember());
pointRepository.save(PointFixture.anInitialPoint(member.getId()));
pointUseCase.charge(PointFixture.aChargeInfo(member.getId(), 1000L));
// flush()/clear() 없이도 검증이 동작
Point found = pointRepository.findByMemberId(member.getId()).orElseThrow();
assertThat(found.getBalance().getAmount()).isEqualTo(1000L);
}
}
핵심 답: 각 save()·charge()가 자기만의 트랜잭션을 열고 닫기 때문입니다. 트랜잭션이 끝날 때 영속성 컨텍스트가 자동으로 flush되고 닫히므로, 테스트 코드가 별도로 동기화를 해줄 필요가 없어요. 이걸 이해하려면 영속성 컨텍스트의 생명주기를 알아야 합니다.
JPA의 영속성 컨텍스트(Persistence Context)는 관리되는 엔티티의 1차 캐시이자 변경 추적 영역입니다. 그리고 그 생명주기는 기본적으로 트랜잭션과 묶여 있어요.
@PersistenceContext(type = EXTENDED)로 명시해야 함. 일반 Spring 빈에서는 거의 쓰이지 않음.일반적인 Spring 환경에서는 모두 transaction-scoped예요. 즉 "트랜잭션이 곧 영속성 컨텍스트의 생명주기"라고 봐도 됩니다.
위 테스트의 memberRepository.save(...) 한 줄이 호출될 때 다음 일이 순서대로 일어납니다.
memberRepository.save(member) 호출
│
├─ SimpleJpaRepository.save()에는 @Transactional이 붙어 있음
│ → 활성 트랜잭션 없으니 새 트랜잭션 A를 엶
│ → 새 영속성 컨텍스트 A 생성
│
├─ 엔티티를 영속성 컨텍스트 A에 영속화 (persist)
│
└─ save() 메서드 리턴 직전
→ 트랜잭션 A 커밋
→ 커밋 직전 자동 flush (INSERT SQL이 DB로 나감)
→ 영속성 컨텍스트 A 닫힘
→ member는 detached 상태가 되어 반환됨
그다음 pointRepository.save(...)도 마찬가지로 새 트랜잭션 B와 새 영속성 컨텍스트 B를 엽니다. pointUseCase.charge(...) 역시 자기만의 트랜잭션 C에서 동작해요.
결과적으로 테스트 메서드 한 번에 여러 개의 독립된 영속성 컨텍스트가 생겼다 사라집니다. 각 컨텍스트는 자기 트랜잭션이 끝날 때 알아서 flush+close되므로, 테스트 코드에서 em.flush()나 em.clear()를 직접 부를 필요가 없어요.
테스트 클래스/메서드에 @Transactional이 붙으면 상황이 완전히 달라집니다.
@SpringBootTest
@Transactional // ← 이게 붙으면
class SomeTest {
@PersistenceContext EntityManager em;
@Test
void test() {
memberRepository.save(member); // 1차 캐시에만 있음 (flush 안 됨)
pointRepository.save(point); // 같은 영속성 컨텍스트
// 여기서 SELECT를 해도 1차 캐시에서 가져옴 → DB 동작 검증 불가
em.flush(); // SQL 강제 발사
em.clear(); // 1차 캐시 비우기 → 이후 조회는 DB에서 새로 로드
}
}
일어나는 일을 보면:
repository.save() 내부의 @Transactional은 이미 열린 트랜잭션에 참여(Propagation.REQUIRED 기본값) — 새 트랜잭션을 만들지 않음이때는 flush()로 SQL을 강제로 내보내고 clear()로 1차 캐시를 비워야 "DB에 진짜로 어떻게 저장되는가"를 검증할 수 있어요.
| 항목 | 테스트에 @Transactional 없음 | 테스트에 @Transactional 있음 |
|---|---|---|
| 트랜잭션 경계 | 각 save()·서비스 호출마다 별도 |
테스트 메서드 전체가 하나의 트랜잭션 |
| 영속성 컨텍스트 | 호출마다 새 컨텍스트 → 자동 flush+close | 하나의 컨텍스트에 모든 엔티티 누적 |
flush()·clear() 필요 |
불필요 | DB 동기화 검증하려면 필요 |
| 1차 캐시 영향 | 거의 없음 (컨텍스트가 짧음) | 큼 — 같은 컨텍스트 안에서는 캐시 hit |
| 테스트 종료 후 DB 상태 | 실제로 커밋되어 데이터가 남음 | 자동 롤백되어 데이터 안 남음 |
| 데이터 정리 | 수동 (DatabaseCleanUp 필요) |
자동 (롤백) |
@Transactional 없는 방식은 실제 운영 동작에 가장 가깝게 검증됩니다. 각 save가 진짜로 커밋되고, 다음 호출은 새 영속성 컨텍스트에서 DB로부터 다시 로드하기 때문이에요. 운영에서 일어나는 일을 그대로 시뮬레이션합니다.
그 대가로 데이터가 진짜로 남기 때문에 @AfterEach의 DatabaseCleanUp이 필수가 됩니다. 이게 다음 섹션에서 다루는 패턴이에요.
"테스트에 @Transactional을 쓰지 말라"는 권장이 있습니다. 이유는 운영 환경과의 괴리예요. 테스트가 자동 롤백되면 lazy 로딩이 운영에서 깨지는 문제를 못 잡고, 자동 dirty-checking 때문에 명시적 save() 호출 누락도 안 보입니다. 테스트는 통과하는데 운영에서 터지는 비극이 일어날 수 있어요. 그래서 본 가이드는 UseCase 통합 테스트에 @Transactional을 쓰지 않고 DatabaseCleanUp으로 정리하는 패턴을 기본으로 합니다.
@Transactional 없는 방식에서 save()의 반환 객체는 이미 detached 상태예요. 트랜잭션이 끝났으니까요. 따라서 다음을 조심해야 합니다.
Member member = memberRepository.save(MemberFixture.aMember());
// 이 시점에 member는 detached
member.getId(); // ✅ 안전 — 이미 ID가 채워져 있음
member.getLazyField(); // ❌ LazyInitializationException 가능
member.setName("changed"); // ❌ dirty checking 동작 안 함 — DB 반영 안 됨
일반적인 fixture 사용 패턴(save 후 getId()만 꺼내 다음 호출에 넘김)에서는 문제가 없습니다. 다만 lazy 필드를 건드리거나 detached 객체에 setter를 호출하는 코드는 운영에서는 잘 동작하지만 이 테스트 방식에서는 깨질 수 있어요. 필요하다면 findById로 다시 조회해서 영속 상태로 만든 뒤 사용합니다.
해결책은 매 테스트가 끝날 때 모든 테이블을 TRUNCATE하는 것입니다. 다음 패턴이 일반적이에요.
@Component
public class DatabaseCleanUp implements InitializingBean {
@PersistenceContext
private EntityManager entityManager;
private final List<String> tableNames = new ArrayList<>();
@Override
public void afterPropertiesSet() {
entityManager.getMetamodel().getEntities().stream()
.filter(e -> e.getJavaType().getAnnotation(Entity.class) != null)
.map(e -> e.getJavaType().getAnnotation(Table.class).name())
.forEach(tableNames::add);
}
@Transactional
public void truncateAllTables() {
entityManager.flush();
entityManager.createNativeQuery("SET FOREIGN_KEY_CHECKS = 0").executeUpdate();
for (String table : tableNames) {
entityManager.createNativeQuery("TRUNCATE TABLE `" + table + "`").executeUpdate();
}
entityManager.createNativeQuery("SET FOREIGN_KEY_CHECKS = 1").executeUpdate();
}
}
@AfterEach
void tearDown() {
databaseCleanUp.truncateAllTables();
}
@MockitoSpyBean은 stub하지 않은 메서드에서 실제 DB를 호출합니다. 따라서 Spy를 쓰는 테스트에서도 DatabaseCleanUp이 필요해요.
테스트 격리는 DB 차원에서만 일어나는 게 아닙니다. JVM 메모리 상의 객체도 테스트 간 깨끗이 분리되어야 해요. 픽스처(테스트용 객체)를 어디에 두느냐가 이 격리에 직접 영향을 줍니다.
같은 일을 하는 두 가지 방식이 있어요.
// 방식 A — @BeforeEach에서 할당
private Member member;
@BeforeEach
void setUp() {
this.member = MemberFixture.aMember();
}
// 방식 B — 필드 선언과 동시에 할당
private Member member = MemberFixture.aMember();
둘 다 대부분의 경우 같은 결과를 냅니다. 이유는 JUnit 5의 라이프사이클이에요.
JUnit 5는 기본적으로 테스트 메서드마다 테스트 클래스의 인스턴스를 새로 만듭니다.
test1() 실행 → 인스턴스 A 생성 → @BeforeEach 실행 → test1 실행
test2() 실행 → 인스턴스 B 생성 → @BeforeEach 실행 → test2 실행
test3() 실행 → 인스턴스 C 생성 → @BeforeEach 실행 → test3 실행
매번 새 인스턴스가 만들어질 때 필드 초기화도 같이 일어나기 때문에, 방식 B도 사실상 매 테스트마다 MemberFixture.aMember()가 새로 호출됩니다. 그래서 단순한 케이스에선 두 방식이 동등해 보여요.
1. 의존성 주입을 받아야 할 때
Mockito나 Spring 컨텍스트에서 주입받는 객체를 픽스처 생성에 써야 한다면, 필드 초기화 시점에는 아직 의존성이 없습니다.
@ExtendWith(MockitoExtension.class)
class MemberServiceTest {
@Mock MemberRepository memberRepository;
// ❌ 이 시점엔 memberRepository가 아직 null
private Member member = memberRepository.save(MemberFixture.aMember());
// └─ NullPointerException
// ✅ @BeforeEach는 의존성 주입 후에 실행됨
private Member member;
@BeforeEach
void setUp() {
this.member = memberRepository.save(MemberFixture.aMember());
}
}
이 경우 @BeforeEach가 유일한 답입니다.
2. @TestInstance(Lifecycle.PER_CLASS)를 쓸 때
드물게 인스턴스를 한 번만 만들어 공유하는 모드에서는 필드 직접 할당이 위험해집니다.
@TestInstance(Lifecycle.PER_CLASS)
class MyTest {
// ❌ 인스턴스가 한 번만 생성 → 모든 테스트가 같은 member를 공유
// 한 테스트가 member.charge()로 상태를 바꾸면 다음 테스트에 영향
private Member member = MemberFixture.aMember();
// ✅ 매 테스트 전 새로 생성
private Member member;
@BeforeEach
void setUp() { this.member = MemberFixture.aMember(); }
}
일반적으로 PER_CLASS는 권장되지 않지만, 쓰는 경우라면 격리를 위해 @BeforeEach가 필수예요.
3. setup 코드가 늘어날 때
처음엔 한 줄이지만 곧 추가 setup이 따라오는 경우가 많습니다.
// 처음엔 단순
private Member member = MemberFixture.aMember();
// 곧 이렇게 변함 — 어차피 메서드로 옮겨야 함
private Member member;
@BeforeEach
void setUp() {
this.member = MemberFixture.aMember();
this.member.charge(Money.of(1_000L)); // 추가 setup
memberRepository.save(this.member); // 또 추가
}
지금 당장은 단순한 한 줄이라도, 상태를 가진 도메인 객체는 @BeforeEach에 두는 것을 기본 자리로 삼습니다. 이유를 정리하면:
@BeforeEach는 setup 코드의 표준 자리. 거기 있는 코드는 setup이라는 신호가 명확상태가 없고 변하지 않는 객체(PasswordEncoder, Clock, 입력 DTO 등)는 static final 상수로 두는 것이 더 자연스러워요.
class MemberTest {
// ✅ stateless한 도구는 상수로
private static final PasswordEncoder PASSWORD_ENCODER = new BCryptPasswordEncoder();
// ✅ 변하지 않는 입력 DTO도 상수로
private static final RegisterCommand COMMAND = new RegisterCommand(
"testId", "test@test.com", LocalDate.of(2000, 1, 1), Gender.MALE, "secret"
);
@Test
void create_시_비밀번호가_해시된다() {
Member member = Member.create(COMMAND, PASSWORD_ENCODER);
assertThat(PASSWORD_ENCODER.matches("secret", member.getPasswordHash())).isTrue();
}
}
이런 도구는 모든 테스트가 같은 인스턴스를 공유해도 격리가 깨지지 않습니다. 오히려 @BeforeEach에 두면 "이건 매번 새로 만들어야 하는 것"이라는 잘못된 신호를 줘요. static final은 "이건 변하지 않는 도구"라는 의도를 명확히 드러냅니다.
| 객체의 성격 | 권장 위치 | 이유 |
|---|---|---|
상태를 가진 도메인 객체 (Member, Point) |
@BeforeEach |
매 테스트 fresh, 일관성, 변경 안전성 |
의존성 주입이 필요한 객체 (save한 결과 등) |
@BeforeEach |
필드 시점엔 의존성이 null |
| Mockito mock | @Mock 또는 @BeforeEach |
주입/stubbing이 매번 필요 |
상태 없는 도구 (PasswordEncoder, Clock) |
private static final |
공유해도 안전, 의도가 명확 |
불변 입력 DTO (RegisterCommand 등) |
private static final |
상수성을 final로 표현 |
| 여러 테스트 클래스에서 공유되는 픽스처 | XxxFixture.static method |
한 곳에 모아 재사용 |
"상태가 있고 매 테스트마다 fresh가 필요한가?"를 묻습니다. 그렇다면 @BeforeEach, 아니라면 static final 상수. 이 한 가지 질문으로 대부분의 픽스처 위치 결정이 끝납니다.
Mockito는 stubbing을 위한 세 가지 문법 스타일을 제공합니다. 모두 같은 일을 하지만 사용 맥락이 달라요.
| 스타일 | 문법 | 특징 |
|---|---|---|
| Classic | when(x).thenReturn(y) | 가장 일반적. 타입 안전. |
| BDD | given(x).willReturn(y) | given/when/then 흐름과 자연스러움. |
| Do family | doReturn(y).when(x) | Spy, void 메서드 등 특수 상황 전용. |
가장 일반적이고 권장되는 형태입니다.
when(memberRepository.findById(1L)).thenReturn(Optional.of(member));
when(passwordEncoder.encode("raw")).thenReturn("ENCODED");
기능은 동일하고, given/when/then 패턴과 어울려요.
given(memberRepository.findById(1L)).willReturn(Optional.of(member));
given(passwordEncoder.encode("raw")).willReturn("ENCODED");
내부 구현은 when().thenReturn()을 그대로 호출합니다. 선호 차이일 뿐 동작은 같아요.
doReturn("ENCODED").when(passwordEncoder).encode("raw");
doThrow(new RuntimeException()).when(memberRepository).save(any());
when().thenReturn()이 실제 메서드를 한 번 호출하는 것과 달리, doReturn()은 호출 없이 바로 stub만 등록합니다. 언제 써야 하는지는 다음 섹션에서 자세히 다뤄요.
when()은 Mockito에게 "방금 호출된 메서드를 stub할 거야"라고 알리는 명령입니다. 그리고 이어지는 .thenReturn()이 구체적 동작을 등록해요.
when(memberRepository.findById(1L)).thenReturn(Optional.of(member));
// ↑ "이 메서드 호출을" ↑ "이렇게 동작하게 stub해라"
핵심 차이는 "stub을 등록하기 전에 실제 메서드를 호출하는가"입니다.
// when() 동작 순서
when(spyRepo.existsByLoginId("testId")).thenReturn(true);
// 1) spyRepo.existsByLoginId("testId") 실제 호출 → DB 쿼리 발생!
// 2) 그 결과를 when()에 넘김
// 3) stub 등록
// doReturn() 동작 순서
doReturn(true).when(spyRepo).existsByLoginId("testId");
// 1) "true 반환하는 stub을 만들 거야" 선언
// 2) "이 mock에" 대상 지정
// 3) "이 메서드 호출에 적용" — 실제 호출은 일어나지 않음
Mock과 Spy의 기본 동작 차이를 다시 떠올려 보세요.
Mock에서는 when()이 내부적으로 메서드를 호출해도 실제 로직이 실행되지 않고 그냥 기본값을 돌려줄 뿐이에요. 부작용이 없으니 안전합니다. 하지만 Spy에서는 진짜 동작이 일어나서 다양한 부작용이 생겨요.
1. 의도치 않은 DB 호출. 테스트에서 DB를 안 타게 하려고 stub을 거는 건데, 정작 stub을 등록하느라 DB를 한 번 호출하는 모순이 생깁니다.
2. 예외 발생. 실제 메서드가 예외를 던지는 상황이라면 stub 자체가 등록되기 전에 테스트가 죽어버립니다.
// 실제 메서드가 NullPointerException을 던진다면?
when(realService.someMethod()).thenReturn("safe"); // ← NPE로 여기서 죽음
3. 검증 카운트 오염. 실제 호출이 일어났으니 verify 카운트에 포함되어 검증 결과가 어긋납니다.
when(spy.save(any())).thenReturn(member); // 여기서 save가 1번 호출됨
spy.save(member); // 여기서 또 호출 → 총 2번
verify(spy, times(1)).save(any()); // ❌ 실패: 실제로는 2번
doReturn()은 메서드를 실제로 호출하지 않으므로 이 모든 문제가 없어요.
"when(Object)는 항상 권장되지만, Spy와 함께 사용할 때는 doReturn()을 써야 한다. 그렇지 않으면 실제 메서드가 호출되어 부작용이 발생할 수 있다."
void 메서드에는 when() 자체를 쓸 수 없어요. 반환값이 없으니 when()의 인자 자리에 들어갈 수 없거든요.
when(emailSender.send(email)).thenReturn(...); // ❌ 컴파일 에러: void는 인자로 못 씀
doNothing().when(emailSender).send(email); // ✅ OK
doThrow(new MailException()).when(emailSender).send(email); // ✅ OK
이론상 doReturn()이 더 안전해 보일 수 있지만, when()이 더 권장되는 이유도 있습니다. 타입 안전성이에요.
// when() - 타입 안전
when(repo.findById(1L)).thenReturn(Optional.of(member));
// ↑ findById의 반환 타입이 Optional<Member>임을 컴파일러가 검증
// doReturn() - 타입 안전성이 약함
doReturn(Optional.of(member)).when(repo).findById(1L);
// ↑ doReturn의 인자가 Object 타입이라 컴파일러가 타입 체크 못 함
// 잘못된 타입을 넣어도 런타임에야 오류 발견
Mock에서 잘못된 반환 타입을 넣으면 when()은 컴파일 시점에 잡아주지만, doReturn()은 런타임에야 알 수 있어요. 그래서 Mock에는 when()을, Spy에는 doReturn()을 쓰는 게 표준 컨벤션입니다.
| 상황 | 권장 문법 |
|---|---|
| Mock 객체에 stub | when().thenReturn() 또는 BDD given().willReturn() |
| Spy 객체에 stub | doReturn().when() |
| void 메서드 stub | doNothing() / doThrow() |
| 이미 stub된 메서드를 다시 stub | doReturn() |
| 메서드 | 용도 |
|---|---|
thenReturn(value) / willReturn(value) | 값 반환 |
thenReturn(a, b, c) | 호출마다 순서대로 반환 |
thenThrow(exception) / willThrow(exception) | 예외 던지기 |
thenAnswer(invocation -> ...) / willAnswer(...) | 동적 응답 |
thenCallRealMethod() / willCallRealMethod() | 실제 메서드 호출 |
doNothing().when(mock).voidMethod() | void 메서드를 아무 동작 안 하게 |
// 1. 단순 반환
when(repo.findById(1L)).thenReturn(Optional.of(member));
// 2. 호출마다 다른 값
when(counter.next()).thenReturn(1, 2, 3);
// 첫 호출 → 1, 두 번째 → 2, 세 번째 → 3 (이후로는 계속 3)
// 3. 예외 던지기
when(repo.save(any())).thenThrow(new DataIntegrityViolationException("dup"));
// 4. 입력값을 그대로 반환 (save 메서드 stub에 자주 씀)
when(repo.save(any(Member.class)))
.thenAnswer(inv -> inv.getArgument(0));
// 5. 인자 매처
when(repo.findByLoginId(anyString())).thenReturn(Optional.empty());
when(repo.findByLoginId(eq("testId"))).thenReturn(Optional.of(member));
// 6. void 메서드
doNothing().when(emailSender).send(any());
doThrow(new MailException("fail")).when(emailSender).send(any());
// 7. Spy stub
@MockitoSpyBean
private MemberRepository spy;
doReturn(true).when(spy).existsByLoginId("testId"); // ← 일반 when() 쓰면 실제 호출됨
위 패턴 4번에서 보았던 한 줄을 다시 봅시다.
when(repo.save(any(Member.class)))
.thenAnswer(inv -> inv.getArgument(0));
이 짧은 코드 안에 Mockito의 동적 stubbing 구조가 압축되어 있습니다. 한 부분씩 풀어볼게요.
thenReturn은 고정된 값을 돌려줍니다. 호출할 때마다 같은 객체가 반환되죠.
when(repo.save(any())).thenReturn(fixedMember);
// 어떤 인자가 들어와도 fixedMember만 반환
thenAnswer는 호출이 일어나는 순간 실행되는 람다를 받습니다. 그래서 호출 시점의 인자를 보고 동적으로 반환값을 만들 수 있어요.
when(repo.save(any())).thenAnswer(inv -> { /* 호출될 때마다 실행됨 */ });
람다의 매개변수 inv는 InvocationOnMock 타입입니다. "방금 일어난 mock 호출"을 표현하는 객체로, 호출에 관한 모든 정보를 담고 있어요.
| 메서드 | 반환 |
|---|---|
inv.getArgument(0) | 첫 번째 인자 (0부터 시작) |
inv.getArgument(1) | 두 번째 인자 |
inv.getArgument(0, Member.class) | 타입 명시 — 추론이 모호할 때 |
inv.getArguments() | 모든 인자 배열 |
inv.getMethod() | 호출된 Method 객체 |
inv.getMock() | mock 자기 자신 |
그러니까 inv.getArgument(0)은 "이 호출에 첫 번째 인자로 들어온 객체를 꺼내라"는 뜻이에요. save(member)로 호출됐다면 그 member가 반환됩니다.
when(repo.save(any(Member.class)))
.thenAnswer(inv -> inv.getArgument(0));
Member input = Member.create(...);
Member result = repo.save(input);
// result == input (같은 인스턴스)
JPA의 save()가 저장한 엔티티 자체를 반환하는 동작을 흉내 낼 때 자주 씁니다. 테스트 작성 시점에는 어떤 객체가 들어올지 모르니, "들어온 걸 그대로 돌려준다"는 일반화된 규칙으로 stub을 정의하는 거예요.
받은 인자를 그대로 돌려주는 것 자체로는 검증의 의미가 약합니다. 인자를 가공하거나 분기할 때 진가가 드러나요.
(1) JPA save가 ID를 부여하는 동작 흉내
when(repo.save(any(Member.class)))
.thenAnswer(inv -> {
Member m = inv.getArgument(0);
// 실제 JPA처럼 ID와 타임스탬프 부여
ReflectionTestUtils.setField(m, "id", 1L);
return m;
});
ReflectionTestUtils는 private 필드를 강제로 건드리는 도구입니다. 엔티티가 setter를 노출하지 않을 때만 어쩔 수 없이 쓰는 마지막 수단이에요. 대부분의 경우 더 깔끔한 대안이 있습니다.
대안 ①: 그냥 패스스루 — 가장 흔한 답
이 테스트가 반환된 객체의 ID를 사용하지 않는다면, ID 부여 자체가 불필요한 setup입니다. 인자를 그대로 돌려주기만 하면 돼요.
when(repo.save(any(Member.class)))
.thenAnswer(inv -> inv.getArgument(0));
// ID는 null인 채로 반환되지만, 테스트가 ID를 안 쓰면 문제없음
"save 후 member.getId()를 호출하는 후속 로직이 있는가?"를 먼저 따져보세요. 없으면 굳이 ID를 부여할 이유가 없습니다.
대안 ②: 테스트용 정적 팩토리 메서드
엔티티에 ID를 받는 테스트 전용 팩토리를 두면 reflection 없이 깔끔하게 해결됩니다.
// 엔티티에 추가
public class Member extends BaseEntity {
// ... 기존 코드 ...
// 테스트 픽스처용 — 패키지 가시성으로 외부 노출은 막음
static Member withId(Long id, MemberServiceDto.RegisterCommand command, PasswordEncoder encoder) {
Member member = Member.create(command, encoder);
member.id = id;
return member;
}
}
// 테스트
when(repo.save(any(Member.class)))
.thenAnswer(inv -> {
Member input = inv.getArgument(0);
return Member.withId(1L, /* 같은 command */, encoder);
});
다만 도메인 객체에 테스트 전용 코드가 끼어드는 점은 트레이드오프예요. 작은 프로젝트에서는 부담스러울 수 있습니다.
대안 ③: protected setter 활용
JPA 엔티티는 보통 @NoArgsConstructor(access = PROTECTED)를 쓰니, 같은 맥락으로 setId를 protected로 두고 같은 패키지의 테스트에서 호출하는 방법도 있어요.
// 엔티티
protected void setId(Long id) {
this.id = id;
}
// 같은 패키지의 테스트에서
when(repo.save(any(Member.class)))
.thenAnswer(inv -> {
Member m = inv.getArgument(0);
m.setId(1L); // protected지만 같은 패키지면 접근 가능
return m;
});
이 방법은 테스트가 엔티티와 같은 패키지에 있을 때만 동작합니다. Maven/Gradle의 표준 구조(src/main/java와 src/test/java가 같은 패키지를 공유)에서는 자연스럽게 사용 가능해요.
대안 ④: 빌더가 있다면 빌더로 새 객체
엔티티에 Lombok @Builder가 적용되어 있다면 가장 깔끔합니다.
when(repo.save(any(Member.class)))
.thenAnswer(inv -> {
Member input = inv.getArgument(0);
return input.toBuilder().id(1L).build();
});
우선순위는 ① 패스스루 → ② 빌더 → ③ 정적 팩토리 → ④ protected setter → ⑤ ReflectionTestUtils 순서입니다. 대부분의 단위 테스트는 ①에서 끝나요. 필요해 보이면 다음 단계로 한 칸씩 내려가되, ReflectionTestUtils는 정말 다른 방법이 없을 때만 쓰세요. private 필드를 reflection으로 건드리면 엔티티의 캡슐화가 깨지고, 필드명 변경 시 컴파일러가 잡아주지 못합니다.
(2) 인자에 따라 다른 응답
when(gateway.charge(anyLong(), anyInt()))
.thenAnswer(inv -> {
int amount = inv.getArgument(1);
return amount > 100_000
? PaymentResult.fail("한도 초과")
: PaymentResult.success();
});
이 두 경우는 thenReturn으로 표현할 수 없는 동적 동작이에요. 인자를 보고 결정해야 하는 응답이 필요할 때 thenAnswer + getArgument 조합이 빛납니다.
한편 다음 코드는 사실상 Mockito 자체를 테스트하는 셈입니다.
when(repo.save(any(Member.class)))
.thenAnswer(inv -> inv.getArgument(0));
Member input = new Member(...);
Member result = repo.save(input);
assertThat(result).isSameAs(input); // ← 당연히 같음. Mockito 동작을 확인하는 것
그렇다고 이 stub이 무의미한 건 아닙니다. "통과시키기 위한 setup"으로 가치가 있어요. save()의 반환을 stub하지 않으면 mock은 null을 돌려주는데, 서비스가 그 결과를 후속 로직(예: member.getId())에서 쓰면 NPE가 납니다.
public Member register(...) {
Member member = repo.save(...);
eventPublisher.publish(new MemberRegisteredEvent(member.getId())); // ← null이면 NPE
return member;
}
즉 이 stubbing은 검증 도구가 아니라 환경 조성입니다. "save가 정상적으로 동작한다"는 무대를 깔아두는 역할이에요.
비슷한 목적으로 자주 등장하는 ArgumentCaptor와는 작동 시점이 달라요. 헷갈리지 않게 정리해 둡니다.
| 도구 | 작동 시점 | 주된 용도 |
|---|---|---|
thenAnswer + getArgument |
mock 호출 시점 (응답 생성) | 인자 보고 동적으로 반환값 결정 |
ArgumentCaptor |
호출 이후 (사후 검증) | 들어왔던 인자를 꺼내 필드 검증 |
// thenAnswer + getArgument — 호출 시점에 반응
when(repo.save(any())).thenAnswer(inv -> {
Member m = inv.getArgument(0);
return m.withId(1L);
});
// ArgumentCaptor — 호출 후 검증
ArgumentCaptor<Member> captor = ArgumentCaptor.forClass(Member.class);
verify(repo).save(captor.capture());
assertThat(captor.getValue().getLoginId()).isEqualTo("testId");
"인자를 봐야 한다"는 같은 말이지만 응답을 만들기 위해서인지, 검증하기 위해서인지가 다릅니다. 응답을 만들 거면 thenAnswer, 검증할 거면 ArgumentCaptor를 쓰세요.
thenReturn으로 충분하면 thenReturn을 쓰세요. thenAnswer + getArgument가 진짜로 빛나는 건 (1) 인자를 가공해서 반환할 때, (2) 인자에 따라 분기할 때입니다. "받은 걸 그대로 돌려주기"는 단순히 NPE 회피용 setup이라는 것을 인지하고, 그게 정말 필요한지 한 번 더 따져보세요.
getArgument는 제네릭 메서드라 좌변 타입으로 추론됩니다. 추론이 모호하거나 명시적으로 보여주고 싶다면 두 번째 인자로 클래스를 넘기세요.
Member m = inv.getArgument(0); // 추론
Member m = inv.getArgument(0, Member.class); // 명시
람다 안에서 var로 받거나, 오버로드된 메서드를 stub할 때 캐스팅 에러가 모호하게 나면 두 번째 방식이 안전합니다.
Mockito는 호출 검증을 위해 두 가지 스타일을 지원합니다. 결론부터 말하면 두 스타일은 동등합니다. 같은 일을 다르게 표현할 뿐이에요.
// BDD 스타일 (BDDMockito)
then(memberRepository).should().existsByLoginId("testId");
// 클래식 스타일 (Mockito)
verify(memberRepository).existsByLoginId("testId");
// 횟수까지 명시한 클래식 스타일
verify(memberRepository, times(1)).existsByLoginId("testId");
should()는 내부적으로 verify()를 호출합니다. should() 안에 횟수를 안 적으면 기본값이 times(1)이라서 위 세 줄은 모두 동일하게 동작해요.
| Classic (Mockito) | BDD (BDDMockito) | 의미 |
|---|---|---|
when(repo.find()).thenReturn(x) | given(repo.find()).willReturn(x) | stub 등록 |
verify(repo).save(x) | then(repo).should().save(x) | 호출 1번 검증 |
verify(repo, times(2)).save(x) | then(repo).should(times(2)).save(x) | 호출 2번 검증 |
verify(repo, never()).save(x) | then(repo).should(never()).save(x) | 호출 안 됨 검증 |
verifyNoInteractions(repo) | then(repo).shouldHaveNoInteractions() | 어떤 호출도 없음 |
BDD 스타일은 given/when/then 패턴과 어울리도록 설계되었습니다.
@Test
void create_success() {
// given
given(memberRepository.existsByLoginId("testId")).willReturn(false);
// when
memberService.create(command);
// then
then(memberRepository).should().save(any());
}
이렇게 쓰면 given → when → then 흐름이 코드의 키워드와 그대로 일치해서 가독성이 좋습니다. 영어 문장처럼 자연스럽게 읽혀요. "Then the member repository should save..."
클래식 스타일은 더 짧고 직관적입니다.
@Test
void create_success() {
when(memberRepository.existsByLoginId("testId")).thenReturn(false);
memberService.create(command);
verify(memberRepository).save(any());
}
다만 when이라는 단어가 BDD의 "when 단계"와 헷갈릴 수 있어요. stub 등록인데 마치 실행 단계처럼 보이거든요.
verify는 두 가지 의도로 쓸 수 있어요. 헷갈리지 말아야 할 부분입니다.
// "이 메서드가 호출되었음을 확인" (긍정 검증)
verify(repo).save(any()); // 정확히 1번 호출됨
verify(repo, times(2)).save(any()); // 정확히 2번 호출됨
// "이 메서드가 호출되지 않았음을 확인" (부정 검증)
verify(repo, never()).save(any()); // 한 번도 호출 안 됨
verify(repo, times(0)).save(any()); // 위와 같음
예외 발생 시나리오에서는 그 뒤 로직이 실행되지 않아야 합니다. never()로 검증해야지, 일반 verify()로 검증하면 "호출되었어야 하는데 안 됐다"는 에러가 발생합니다.
@Test
void 중복_loginId면_save가_호출되지_않는다() {
doReturn(true).when(memberRepository).existsByLoginId("dup");
assertThatThrownBy(() -> memberService.create(command))
.isInstanceOf(CoreException.class);
// ✅ 예외 발생 후엔 save가 호출되지 않아야 함
verify(memberRepository, never()).save(any(Member.class));
}
둘 다 정답입니다. 팀 컨벤션을 따르세요. 다만 한 가지 원칙이 있어요.
BDD와 클래식을 섞어 쓰면 코드 흐름이 끊겨 가독성이 떨어집니다.
given(repo.find(1L)).willReturn(member); // BDD
verify(repo).save(any()); // 클래식
given(repo.find(1L)).willReturn(member);
then(repo).should().save(any());
// ✓ 클래식으로 통일도 OK
when(repo.find(1L)).thenReturn(member);
verify(repo).save(any());
둘 중 고른다면 BDD 스타일(given/then/should)을 권합니다. 이유는 when이라는 단어가 stub 등록과 실행 단계 양쪽에 쓰이는 모호함을 BDD는 피해가기 때문이에요. given/when/then 주석과 메서드 이름이 일관되니 코드를 처음 보는 사람도 흐름을 따라가기 쉽습니다. 특히 팀에 신입이 들어오거나 코드 리뷰를 자주 한다면 이 일관성이 큰 가치를 발휘해요.
다만 이미 코드베이스가 클래식 스타일로 통일되어 있다면 굳이 바꾸지 마세요. 일관성이 스타일 선택보다 더 중요합니다.
ArgumentCaptor는 Mock 객체에 전달된 인자를 캡처해서 나중에 검증할 수 있게 해주는 도구입니다.
verify(repo).save(any())로는 "save가 호출되었다"까지만 검증할 수 있습니다. 그런데 가끔은 "save에 어떤 Member 객체가 넘어갔는지"까지 확인하고 싶을 때가 있어요. 특히 다음 두 상황에서 유용합니다.
public void register(String loginId, String rawPassword) {
String encoded = passwordEncoder.encode(rawPassword);
Member member = Member.create(loginId, encoded); // 내부에서 생성
memberRepository.save(member); // 반환값도 안 씀
}
여기서 "save에 들어간 Member의 password가 정말 인코딩된 값인가?"를 검증하려면, save에 전달된 인자를 잡아내야 합니다. 메서드 외부에서는 그 Member 객체를 볼 방법이 없거든요.
eq()나 equals() 비교로는 부족하고, 여러 필드를 각각 따로 단언하고 싶을 때 유용해요.
@ExtendWith(MockitoExtension.class)
class MemberServiceTest {
@Mock
private MemberRepository memberRepository;
@Mock
private PasswordEncoder passwordEncoder;
private MemberService memberService;
@BeforeEach
void setUp() {
memberService = new MemberService(memberRepository, passwordEncoder);
}
@Test
void 회원가입시_인코딩된_비밀번호로_저장된다() {
// given
given(passwordEncoder.encode("rawPw")).willReturn("ENCODED_PW");
given(memberRepository.existsByLoginId("testId")).willReturn(false);
given(memberRepository.save(any(Member.class)))
.willAnswer(inv -> inv.getArgument(0));
// when
memberService.create(new CreateCommand("testId", "rawPw"));
// then
ArgumentCaptor<Member> captor = ArgumentCaptor.forClass(Member.class);
then(memberRepository).should().save(captor.capture());
Member captured = captor.getValue();
assertThat(captured.getLoginId()).isEqualTo("testId");
assertThat(captured.getPassword()).isEqualTo("ENCODED_PW");
}
}
핵심 흐름은 다음 4단계입니다.
ArgumentCaptor.forClass(Member.class)로 캡처 도구 생성verify 또는 should() 안에서 captor.capture()를 인자로 사용captor.getValue()로 캡처된 객체 꺼내기매번 ArgumentCaptor.forClass(...)를 쓰는 게 번거롭다면 어노테이션으로 줄일 수 있어요.
@ExtendWith(MockitoExtension.class)
class MemberServiceTest {
@Mock
private MemberRepository memberRepository;
@Captor
private ArgumentCaptor<Member> memberCaptor;
@Test
void 회원가입시_인코딩된_비밀번호로_저장된다() {
// ...
then(memberRepository).should().save(memberCaptor.capture());
Member captured = memberCaptor.getValue();
assertThat(captured.getLoginId()).isEqualTo("testId");
}
}
제네릭 타입 정보가 자동으로 들어가서 더 깔끔합니다. 특히 ArgumentCaptor<List<Member>>처럼 제네릭이 중첩될 때 어노테이션 방식이 편해요.
같은 메서드가 여러 번 호출되었다면 getAllValues()로 전부 가져올 수 있습니다.
@Test
void 여러_회원_생성_검증() {
// given ...
// when
memberService.create(commandA);
memberService.create(commandB);
// then
ArgumentCaptor<Member> captor = ArgumentCaptor.forClass(Member.class);
then(memberRepository).should(times(2)).save(captor.capture());
List<Member> allCaptured = captor.getAllValues();
assertThat(allCaptured)
.hasSize(2)
.extracting(Member::getLoginId)
.containsExactly("memberA", "memberB");
// 또는 마지막 호출만
Member last = captor.getValue(); // getAllValues().get(last)와 동일
}
getValue()는 마지막 캡처값을 반환한다는 점이 중요해요. 여러 번 호출되었다면 반드시 getAllValues()를 쓰세요. 첫 번째 호출의 인자를 검증하려고 getValue()를 쓰면 마지막 호출의 인자가 반환되어 디버깅이 어려운 버그가 됩니다.
// ❌ 나쁨 - given에 capture 사용
given(memberRepository.save(captor.capture())).willReturn(member);
이렇게 쓰면 stub 단계에서도 캡처가 일어나고, 실제 호출 시에도 캡처가 누적되어 동작이 헷갈립니다. capture()는 항상 verify / should() 안에서만 쓰세요.
대부분의 경우 eq(), argThat(), 또는 AssertJ로 충분합니다. 캡처는 정말 필요할 때만 쓰세요.
// 단순 비교라면 eq로 충분
verify(memberRepository).save(eq(expectedMember));
// 조건 검증이면 argThat
verify(memberRepository).save(argThat(m -> m.getLoginId().equals("testId")));
둘 다 인자 검증 도구지만 성격이 다릅니다.
| 항목 | argThat | ArgumentCaptor |
|---|---|---|
| 검증 시점 | verify 시점에 즉시 | verify 후 별도 단언 |
| 실패 메시지 | "조건 불일치"만 표시 | AssertJ로 구체적 표시 |
| 가독성 | 단순한 조건에 좋음 | 복잡한 검증에 좋음 |
| 권장 상황 | 1~2개 필드, 간단한 boolean | 여러 필드를 각각 단언 |
verify(repo).save(argThat(m ->
m.getLoginId().equals("testId")
&& m.getStatus() == ACTIVE
));
ArgumentCaptor<Member> captor =
ArgumentCaptor.forClass(Member.class);
verify(repo).save(captor.capture());
Member m = captor.getValue();
assertThat(m.getLoginId()).isEqualTo("testId");
assertThat(m.getStatus()).isEqualTo(ACTIVE);
assertThat(m.getCreatedAt()).isNotNull();
argThat의 단점은 검증 실패 시 "조건이 false였다"만 나와서 어느 필드가 잘못됐는지 알기 어렵습니다. 그래서 여러 필드를 검증할 때는 ArgumentCaptor + AssertJ가 디버깅하기 훨씬 좋아요.
ArgumentCaptor는 Mock에 전달된 인자를 꺼내서 검증하는 도구입니다. 메서드 내부에서 만들어져 외부로 안 보이는 객체나 여러 필드를 깊이 검증해야 할 때 쓰세요. 단순 비교로 충분하다면 eq()나 argThat()이 더 가볍고, 정말 필요할 때만 캡처를 쓰는 것이 깔끔한 테스트 코드의 비결입니다.
본문에서 인용한 모든 자료를 한곳에 모았어요. 카테고리별로 분류했습니다.
4.4의 "Service 테스트의 검증 깊이"에서 다룬 원칙들의 출처입니다. 검증의 책임 경계, false negative 방어, 동어반복(tautological) 안티패턴에 관한 권위 있는 자료들이에요.
1. "기대값과 실제값이 같은 출처에서 나오면 안 된다" — Tautological Test
2. "도구가 아닌 행위에 묶여 fragile해지지 마라" — Behavior vs Implementation
3. "각 테스트는 자기 책임만 검증해야 한다" — Single Responsibility for Tests
@Transactional의 propagation 동작과 ThreadLocal 기반 트랜잭션 컨텍스트. docs.spring.io/spring-framework/reference/data-access/transaction.htmlMockito 공식 Javadoc은 한 페이지에 모든 내용이 챕터 번호로 정리되어 있어요. 처음 읽을 때 다음 순서로 훑으면 빠르게 핵심을 잡을 수 있습니다.
Mock에는 when().thenReturn(), Spy에는 doReturn().when(). 도메인은 단위 테스트, Facade는 통합 테스트, E2E는 핵심 플로우만. 한 테스트 클래스 안에서는 한 스타일(Classic 또는 BDD)로 통일. 검증 의도와 verify 방향 일치시키기.