# 개요
서비스 계층 테스트를 작성하다 보면 순수 Mockito 단위 테스트로는 검증할 수 없는 시나리오가 생긴다. 대표적인 예가 "DB에 저장한 뒤 다시 조회"하는 흐름이다. 이때 @SpringBootTest + H2 통합 테스트가 필요하다. 단위 테스트와 혼용하면서 생기는 흔한 구조적 실수와 올바른 작성법을 정리한다.
# 단위 테스트로는 안 되는 시나리오
authService.signInWithApple("test token"); // 신규가입 → DB 저장
authService.signInWithApple("test token"); // 재로그인 → 기존 유저 조회
Mockito @Mock은 상태를 저장하지 않는다. 첫 번째 호출에서 userRepository.save()를 Mock에 호출해도 실제로 저장되지 않아, 두 번째 호출의 findUser 조회에서 여전히 기본값(Optional.empty())을 반환한다. 실제 DB가 있어야 이 시나리오가 성립한다.
# 흔한 실수 — @SpringBootTest와 @Mock 혼용
// 잘못된 구조
@ExtendWith(MockitoExtension.class)
@SpringBootTest
class AuthServiceIntegrationTest {
@InjectMocks // ❌
private AuthService authService;
@Mock // ❌
private UserRepository userRepository;
@MockitoBean
private AppleAuthClient appleAuthClient;
}
@SpringBootTest는 Spring 컨텍스트를 전체 로드한다. 이때 AuthService는 Spring이 직접 빈을 만들어 의존성을 주입한다. @Mock으로 만든 껍데기 객체는 Spring이 모르기 때문에 AuthService에 주입되지 않는다. @InjectMocks도 @SpringBootTest 환경에서는 무효다.
또한 @ExtendWith(MockitoExtension.class)는 @SpringBootTest에 이미 포함되어 있어 중복 선언이다.
Repository를 @Mock으로 두면 안 되는 이유
Repository를 @Mock으로 선언하면 save()가 실제로 저장하지 않는다. H2 실제 빈을 써야 DB 적재 시나리오가 검증된다.
# 올바른 통합 테스트 구조
@SpringBootTest
@Transactional
class AuthServiceIntegrationTest {
@Autowired // Spring이 H2 기반 실제 빈 주입
private AuthService authService;
@MockitoBean // @PostConstruct에서 외부 네트워크 요청 → Mock 대체
private AppleAuthClient appleAuthClient;
@MockitoBean // JWT_SECRET 환경변수 없으면 빈 생성 실패 → Mock 대체
private JwtProvider jwtProvider;
@Test
@DisplayName("동일 Apple 계정 재로그인 - 기존 유저 조회")
void signInWithApple_재로그인() {
given(appleAuthClient.verifyIdentityToken(any()))
.willReturn(new AppleIdentity("test@gmail.com", "test-apple-id"));
given(jwtProvider.generateAccessToken(any())).willReturn("test-access-token");
given(jwtProvider.generateRefreshToken(any())).willReturn("test-refresh-token");
given(jwtProvider.getAccessTokenExpiration()).willReturn(3600000L);
given(jwtProvider.getRefreshTokenExpiration()).willReturn(2592000000L);
authService.signInWithApple("test token"); // 신규가입 → H2 DB 저장
AuthTokenResponse response = authService.signInWithApple("test token"); // 재로그인
assertThat(response.accessToken()).isEqualTo("test-access-token");
}
}
Repository는 선언하지 않으면 Spring이 H2를 바라보는 실제 빈을 주입한다. @Transactional은 테스트 종료 후 자동 롤백해서 테스트 간 DB 상태를 격리한다.
# Mock 고정값으로 인한 unique 제약 위반
같은 서비스 메서드를 한 테스트 안에서 여러 번 호출할 때 주의할 점이 있다. Mock이 항상 같은 고정값을 반환하면 unique 제약이 걸린 컬럼에 동일한 값이 중복 INSERT되어 에러가 발생한다.
Unique index or primary key violation: REFRESH_TOKEN_HASH
jwtProvider.generateRefreshToken()이 항상 "test-refresh-token"을 반환하면, 두 번째 호출에서 같은 해시값을 INSERT하려다 unique 제약에 걸린다.
willReturn을 체이닝하면 호출 순서마다 다른 값을 반환시킬 수 있다.
given(jwtProvider.generateRefreshToken(any()))
.willReturn("test-refresh-token-1") // 첫 번째 호출
.willReturn("test-refresh-token-2"); // 두 번째 호출
# @MockitoBean을 쓰는 이유
AppleAuthClient:@PostConstruct에서 Apple JWKS 엔드포인트 네트워크 요청 발생 → 테스트 환경에서 불필요JwtProvider:@PostConstruct에서JWT_SECRET환경변수로SecretKey초기화 → 환경변수 없으면 빈 생성 자체가 실패
# @MockitoBean도 given()으로 동작한다
@MockitoBean은 Mockito Mock 객체를 Spring 컨텍스트에 등록하는 것이다. 내부적으로 Mockito Mock이므로 given()이 동일하게 동작한다.
@MockitoBean
private AppleAuthClient appleAuthClient;
given(appleAuthClient.verifyIdentityToken(any()))
.willReturn(new AppleIdentity(...)); // 정상 동작
# H2 설정
src/test/resources/application.properties에 추가한다.
spring.datasource.url=jdbc:h2:mem:testdb;MODE=PostgreSQL
spring.datasource.driver-class-name=org.h2.Driver
spring.jpa.hibernate.ddl-auto=create-drop
build.gradle에 H2 의존성도 추가한다.
runtimeOnly 'com.h2database:h2'
# 통합 테스트 트랜잭션 주의사항
# @Transactional 없으면 dirty checking 안 됨
테스트 클래스에 @Transactional이 없으면 userRepository.findAll() 같은 Repository 호출이 내부적으로 트랜잭션을 열고 조회 후 즉시 닫는다. 트랜잭션이 닫히는 순간 엔티티가 detached 상태가 되어 이후 필드를 변경해도 더티체킹이 동작하지 않고 flush()도 효과가 없다.
// @Transactional 없을 때
User user = userRepository.findAll().getFirst(); // 조회 후 트랜잭션 종료 → detached
user.deleteUser(); // 필드 변경
userRepository.flush(); // 아무 효과 없음 — 영속성 컨텍스트 없음
테스트 클래스에 @Transactional을 추가하면 테스트 전체가 하나의 트랜잭션 안에서 실행되어 엔티티가 managed 상태로 유지된다.
@SpringBootTest
@Transactional // 테스트 전체를 하나의 트랜잭션으로
class AuthServiceIntegrationTest {
...
User user = userRepository.findAll().getFirst(); // managed 상태 유지
user.deleteUser(); // 더티체킹 동작
userRepository.flush(); // UPDATE 쿼리 발생
}
# 서비스 계층과 트랜잭션 공유
테스트에 @Transactional이 있으면 서비스 메서드(@Transactional(propagation = REQUIRED))가 기존 트랜잭션에 참여한다. 테스트 → 서비스 → Repository가 모두 하나의 트랜잭션을 공유하므로 같은 영속성 컨텍스트를 사용한다.
테스트 종료 시 트랜잭션이 롤백되어 DB가 자동으로 원상복구된다. 테스트 간 데이터 격리가 보장된다.
# Repository는 given()으로 제어하지 않는다
단위 테스트와 통합 테스트의 Repository 다루는 방식이 다르다.
| 단위 테스트 | 통합 테스트 | |
|---|---|---|
| Repository | @Mock 선언 | 선언 없음 |
| 동작 | given()으로 반환값 지정 | H2에 실제 저장/조회 |
| 이유 | 실제 DB 없음 | 실제 DB처럼 동작해야 함 |
통합 테스트에서 Repository를 @MockitoBean으로 막으면 save()가 아무것도 저장하지 않아 단위 테스트와 동일한 상황이 된다. Repository 필드 선언을 아예 하지 않으면 Spring이 H2 기반 실제 빈을 AuthService에 주입한다.
테스트 코드에서 Repository를 직접 호출할 일이 없기 때문에 필드 선언 자체가 필요 없다. authService.signInWithApple()을 호출하면 서비스 내부에서 알아서 Repository를 쓴다.
# 초기 데이터 세팅 — @BeforeEach
특정 데이터가 미리 DB에 있어야 하는 시나리오는 @BeforeEach로 넣어준다.
@Autowired
private UserRepository userRepository;
@BeforeEach
void setUp() {
userRepository.save(User.create("test@gmail.com", "digger_test"));
}
이때는 테스트 클래스에서 Repository를 직접 사용하므로 @Autowired로 필드 선언이 필요하다. @Transactional이 있으면 각 테스트 종료 후 롤백되어 테스트 간 데이터가 격리된다.
# 두 테스트 클래스의 의존성 중복
같은 서비스에 대해 단위 테스트와 통합 테스트를 분리하면 의존성 선언이 중복된다.
AuthServiceTest.java
@Mock AppleAuthClient
@Mock JwtProvider
@Mock Repository 3개
AuthServiceIntegrationTest.java
@MockitoBean AppleAuthClient
@MockitoBean JwtProvider
(Repository는 선언 안 함)
중복처럼 보이지만 목적이 다른 테스트라 분리를 유지하는 게 맞다. 단위 테스트에서 로직 분기 대부분을 커버하고, 통합 테스트는 DB 적재 시나리오만 다룬다.