JPA 테스트 전략 — @DataJpaTest와 Testcontainers
테스트에서는 통과했는데 프로덕션에서 쿼리 에러가 납니다. H2로 테스트하면 안 되는 걸까요?
개념 정의
@DataJpaTest 는 JPA 관련 빈(Repository, EntityManager)만 로드하는 슬라이스 테스트 어노테이션입니다. 전체 ApplicationContext를 띄우지 않아 빠릅니다.
3가지 테스트 전략 비교
| 전략 | DB | 속도 | 프로덕션 정합성 | 설정 복잡도 |
|---|---|---|---|---|
| H2 인메모리 | H2 | 매우 빠름 | 낮음 | 없음 |
| Testcontainers | MySQL/PostgreSQL 컨테이너 | 중간 | ** 높음** | 중간 |
| 별도 테스트 DB | 실제 DB 서버 | 느림 | 높음 | 높음 |
전략 1: @DataJpaTest + H2 (빠르지만 위험)
@DataJpaTest
class OrderRepositoryTest {
@Autowired
private OrderRepository orderRepository;
@Test
void 주문_저장_조회() {
Order order = Order.create("user1", 10000);
orderRepository.save(order);
Order found = orderRepository.findById(order.getId()).orElseThrow();
assertThat(found.getUserId()).isEqualTo("user1");
}
}
@DataJpaTest는 기본적으로 H2 인메모리 DB를 사용합니다. 장점은 ** 설정 없이 바로 실행 **된다는 것. 단점은 프로덕션 DB(MySQL)와 ** 동작이 다를 수 있다 **는 것.
H2에서 통과하고 MySQL에서 터지는 예시
-- MySQL에서는 동작하지만 H2에서는 에러
SELECT GROUP_CONCAT(name SEPARATOR ', ') FROM users GROUP BY team_id;
-- H2에서는 동작하지만 MySQL에서는 다르게 동작
SELECT * FROM orders WHERE created_at > DATEADD('DAY', -7, NOW());
전략 2: @DataJpaTest + Testcontainers (권장)
Testcontainers는 테스트할 때 **도커 컨테이너로 실제 DB를 띄웁니다 **.
테스트 클래스에 Testcontainers 어노테이션을 추가하고, H2 자동 교체를 비활성화합니다.
@DataJpaTest
@Testcontainers
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class OrderRepositoryTest {
@Container
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0")
.withDatabaseName("testdb");
@DynamicPropertySource
static void setProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", mysql::getJdbcUrl);
registry.add("spring.datasource.username", mysql::getUsername);
registry.add("spring.datasource.password", mysql::getPassword);
}
@DynamicPropertySource로 컨테이너의 동적 포트/URL을 주입합니다. 이제 실제 MySQL에서 테스트가 실행됩니다.
@Autowired
private OrderRepository orderRepository;
@Test
void 주문_저장_조회() {
Order order = Order.create("user1", 10000);
orderRepository.save(order);
Order found = orderRepository.findById(order.getId()).orElseThrow();
assertThat(found.getUserId()).isEqualTo("user1");
}
}
** 핵심 설정 3가지:**
@Testcontainers— 컨테이너 라이프사이클 관리@AutoConfigureTestDatabase(replace = NONE)— H2 자동 교체 방지@DynamicPropertySource— 컨테이너의 동적 포트/URL을 주입
테스트 격리
@Transactional 자동 롤백
@DataJpaTest는 기본적으로 @Transactional이 적용되어, 각 테스트가 끝나면 ** 자동 롤백 **됩니다. 테스트 간 데이터가 격리됩니다.
@DataJpaTest
class UserRepositoryTest {
@Test
void 테스트_A() {
userRepository.save(new User("alice"));
// 테스트 끝나면 자동 롤백
}
@Test
void 테스트_B() {
// 테스트 A의 alice는 롤백되어 없음
assertThat(userRepository.count()).isEqualTo(0);
}
}
@Sql로 테스트 데이터 준비
@Test
@Sql("/test-data/orders.sql")
void 월별_주문_통계() {
List<OrderStats> stats = orderRepository.findMonthlyStats(2026);
assertThat(stats).hasSize(12);
}
함정 — 이걸 모르면 터진다
@Transactional 롤백 때문에 flush가 안 된다
@DataJpaTest의 자동 롤백은 편리하지만, JPA가 flush를 건너뛸 수 있습니다. 트랜잭션이 커밋되지 않으면 SQL이 실제로 실행되지 않아서, 제약 조건 위반(유니크 키 등)을 테스트에서 놓칩니다.
@Test
void 유니크_제약_조건_테스트() {
userRepository.save(new User("alice"));
userRepository.save(new User("alice")); // 유니크 위반
// ❌ 롤백되어서 실제 INSERT가 안 나감 → 테스트 통과
// ✅ flush 강제로 에러 확인
assertThatThrownBy(() -> entityManager.flush())
.isInstanceOf(PersistenceException.class);
}
Testcontainers가 느리다면 싱글톤 컨테이너 패턴
테스트마다 컨테이너를 새로 띄우면 30초씩 걸립니다. ** 싱글톤 패턴 **으로 한 번만 띄우고 재사용합니다.
// 추상 클래스로 컨테이너 공유
abstract class IntegrationTestBase {
static final MySQLContainer<?> MYSQL;
static {
MYSQL = new MySQLContainer<>("mysql:8.0");
MYSQL.start(); // 한 번만 시작
}
@DynamicPropertySource
static void setProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", MYSQL::getJdbcUrl);
registry.add("spring.datasource.username", MYSQL::getUsername);
registry.add("spring.datasource.password", MYSQL::getPassword);
}
}
정리
| 상황 | 선택 | 이유 |
|---|---|---|
| 빠른 단위 테스트 | H2 + @DataJpaTest | DB 방언 무관한 기본 CRUD |
| 프로덕션 정합성 필요 | Testcontainers | 실제 DB와 동일 환경 |
| 네이티브 쿼리 테스트 | Testcontainers (필수) | H2에서 동작 안 할 수 있음 |
| CI 파이프라인 | Testcontainers 싱글톤 | 속도 + 정합성 균형 |
기본 CRUD는 H2로 빠르게, 네이티브 쿼리나 DB 특화 기능은 Testcontainers로 확실하게.