통합 테스트 — 실제로 연결해서 확인하기
단위 테스트가 아무리 잘 통과해도 프로덕션에서 버그가 난다. 대부분의 경우 원인은 컴포넌트 간 연결 지점에 있다. ORM이 컬럼명을 잘못 매핑했거나, SQL 쿼리의 JOIN 조건이 틀렸거나, HTTP 응답의 JSON 필드가 예상과 달랐거나. 이런 버그는 단위 테스트로는 절대 잡을 수 없다. 통합 테스트가 필요한 이유다.
단위 테스트가 못 잡는 것들
// 단위 테스트 — 이 코드는 완벽하게 통과한다
describe('OrderRepository', () => {
it('주문을 저장하고 조회한다', () => {
const mockDb = { query: jest.fn().mockResolvedValue([{ id: 1, status: 'PENDING' }]) };
const repo = new OrderRepository(mockDb);
const result = await repo.findById(1);
expect(result.status).toBe('PENDING');
});
});-- 그런데 실제 DB 스키마에서 컬럼명은 order_status다
CREATE TABLE orders (
id BIGINT PRIMARY KEY,
order_status VARCHAR(20) NOT NULL -- 'status'가 아니라 'order_status'
);Mock이 status를 반환하도록 설정되어 있으니 단위 테스트는 통과한다. 하지만 실제 DB에서는 order_status 컬럼이 status로 매핑되지 않아서 null을 반환한다. 이 버그는 실제 DB와 연결해야만 발견된다.
비슷한 사례들:
- JPA
@Column(name = "...")오타 - TypeORM 엔티티 정의와 실제 스키마 불일치
findByUserIdAndStatus()가 기대한 SQL을 생성하는지- Redis TTL 설정이 실제로 적용되는지
- Kafka 컨슈머 그룹 설정이 올바른지
Narrow vs Broad Integration Test
통합 테스트에도 범위가 있다.
Narrow Integration Test
- 하나의 통합 지점만 테스트
- DB와의 통합만, 또는 HTTP 클라이언트와의 통합만
- 빠르고 디버깅이 쉬움
- Spring의 슬라이스 테스트가 여기에 해당
Broad Integration Test
- 여러 컴포넌트가 함께 동작하는 시나리오
- HTTP 요청 → 서비스 → DB → 응답 전체 흐름
- 더 실제에 가깝지만 느리고 설정이 복잡
- 테스트 실패 원인을 찾기 어려움
실무 권장: Narrow Integration Test를 기본으로, 핵심 시나리오는 Broad Integration Test로 보완.
Spring 슬라이스 테스트
Spring Boot는 애플리케이션의 일부 레이어만 로드하는 슬라이스 테스트를 지원한다. 전체 컨텍스트를 로드하는 @SpringBootTest보다 훨씬 빠르다.
@DataJpaTest — 데이터 레이어만
@DataJpaTest
class OrderRepositoryTest {
@Autowired
private OrderRepository orderRepository;
@Autowired
private TestEntityManager em;
@Test
@DisplayName("사용자 ID와 상태로 주문을 조회한다")
void findByUserIdAndStatus() {
// given
User user = em.persist(User.builder().email("user@example.com").build());
em.persist(Order.builder().user(user).status(OrderStatus.PENDING).build());
em.persist(Order.builder().user(user).status(OrderStatus.PAID).build());
em.flush();
// when
List<Order> pendingOrders = orderRepository.findByUserIdAndStatus(
user.getId(), OrderStatus.PENDING
);
// then
assertThat(pendingOrders).hasSize(1);
assertThat(pendingOrders.get(0).getStatus()).isEqualTo(OrderStatus.PENDING);
}
}@DataJpaTest는 JPA 관련 빈만 로드한다. H2 인메모리 DB를 기본으로 쓰지만, TestContainers를 연결하면 실제 DB로 테스트할 수 있다.
@WebMvcTest — 웹 레이어만
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private OrderService orderService;
@Test
@DisplayName("주문 생성 요청이 올바른 응답을 반환한다")
void createOrder() throws Exception {
// given
given(orderService.createOrder(any())).willReturn(
OrderResponse.builder().orderId(1L).status("PENDING").build()
);
// when & then
mockMvc.perform(post("/orders")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"items": [{"productId": 1, "quantity": 2}]
}
"""))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.orderId").value(1L))
.andExpect(jsonPath("$.status").value("PENDING"));
}
@Test
@DisplayName("주문 항목이 없으면 400을 반환한다")
void createOrderWithEmptyItems() throws Exception {
mockMvc.perform(post("/orders")
.contentType(MediaType.APPLICATION_JSON)
.content("""{"items": []}"""))
.andExpect(status().isBadRequest());
}
}@WebMvcTest는 웹 레이어(Controller, Filter, HandlerInterceptor)만 로드한다. OrderService는 @MockBean으로 대체된다. HTTP 직렬화, 유효성 검증, 응답 상태 코드를 빠르게 테스트할 수 있다.
TypeScript — supertest로 API 통합 테스트
Node.js 환경에서는 supertest로 HTTP 레이어를 테스트한다.
// tests/integration/orders.test.ts
import request from 'supertest';
import { createApp } from '../../src/app';
import { DataSource } from 'typeorm';
import { setupTestDatabase, teardownTestDatabase } from '../helpers/database';
describe('POST /orders', () => {
let app: Express.Application;
let dataSource: DataSource;
beforeAll(async () => {
dataSource = await setupTestDatabase();
app = createApp({ dataSource });
});
afterAll(async () => {
await teardownTestDatabase(dataSource);
});
afterEach(async () => {
await dataSource.query('TRUNCATE TABLE orders CASCADE');
});
it('유효한 주문 요청은 201과 주문 ID를 반환한다', async () => {
const response = await request(app)
.post('/orders')
.send({ items: [{ productId: 1, quantity: 2 }] })
.expect(201);
expect(response.body.orderId).toBeDefined();
expect(response.body.status).toBe('PENDING');
});
it('주문이 실제로 DB에 저장된다', async () => {
const response = await request(app)
.post('/orders')
.send({ items: [{ productId: 1, quantity: 2 }] });
const saved = await dataSource.query(
'SELECT * FROM orders WHERE id = $1',
[response.body.orderId]
);
expect(saved).toHaveLength(1);
expect(saved[0].status).toBe('PENDING');
});
});TestContainers — 실제 인프라와 테스트
H2 인메모리 DB는 편하지만 실제 PostgreSQL과 동작이 다르다. JSONB 타입, 특정 인덱스 힌트, DB 함수들이 H2에서는 지원되지 않는다. TestContainers는 테스트 중에 실제 Docker 컨테이너를 띄워서 이 문제를 해결한다.
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
class OrderRepositoryWithRealDbTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@Autowired
private OrderRepository orderRepository;
@Test
@DisplayName("JSONB 타입의 메타데이터를 저장하고 조회한다")
void saveAndFindWithJsonbMetadata() {
Order order = Order.builder()
.metadata(Map.of("source", "mobile", "campaign", "spring-sale"))
.build();
orderRepository.save(order);
Order found = orderRepository.findById(order.getId()).orElseThrow();
assertThat(found.getMetadata()).containsEntry("source", "mobile");
}
}TestContainers는 테스트 실행 시 Docker 이미지를 자동으로 pull하고, 컨테이너를 시작하고, 테스트가 끝나면 정리한다. Redis, Kafka, MongoDB도 동일하게 쓸 수 있다.
// Redis 통합 테스트
@Container
static GenericContainer<?> redis = new GenericContainer<>("redis:7")
.withExposedPorts(6379);
// Kafka 통합 테스트
@Container
static KafkaContainer kafka = new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.4.0"));WireMock — 외부 HTTP API 모킹
외부 결제 API, 배송 조회 API 같은 외부 HTTP 서비스는 테스트에서 실제로 호출하면 안 된다. 비용이 발생하고, 외부 서비스 장애가 테스트에 영향을 준다. WireMock은 외부 HTTP API를 로컬에서 모킹한다.
@SpringBootTest
@AutoConfigureWireMock(port = 0) // 랜덤 포트
class PaymentServiceTest {
@Autowired
private PaymentService paymentService;
@Test
@DisplayName("결제 API 호출 성공 시 트랜잭션 ID를 반환한다")
void chargeSuccess() {
stubFor(post(urlEqualTo("/v1/charges"))
.willReturn(aResponse()
.withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody("""
{
"id": "ch_test_001",
"status": "succeeded",
"amount": 50000
}
""")));
PaymentResult result = paymentService.charge("card_001", 50_000);
assertThat(result.getTransactionId()).isEqualTo("ch_test_001");
assertThat(result.isSuccessful()).isTrue();
}
@Test
@DisplayName("결제 API 타임아웃 시 PaymentTimeoutException을 던진다")
void chargeTimeout() {
stubFor(post(urlEqualTo("/v1/charges"))
.willReturn(aResponse()
.withFixedDelay(5_000))); // 5초 지연
assertThatThrownBy(() -> paymentService.charge("card_001", 50_000))
.isInstanceOf(PaymentTimeoutException.class);
}
}통합 테스트 속도 관리
통합 테스트는 단위 테스트보다 느리지만 관리할 수 있다.
Spring 컨텍스트 재사용
@SpringBootTest는 컨텍스트를 한 번 로드하면 같은 설정의 테스트끼리 재사용한다. 테스트마다 컨텍스트를 다르게 설정하면 재사용이 안 되니 주의한다.
// 컨텍스트가 재사용되는 패턴 — 설정을 공통 기반 클래스로 모음
@SpringBootTest
@Testcontainers
abstract class IntegrationTestBase {
@Container
static PostgreSQLContainer<?> postgres = ...; // static으로 공유
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) { ... }
}
class OrderRepositoryTest extends IntegrationTestBase { ... }
class UserRepositoryTest extends IntegrationTestBase { ... }
// 두 테스트가 같은 컨텍스트와 컨테이너를 재사용TestContainers 컨테이너 재사용
Testcontainers.reuse(true) 설정으로 컨테이너를 테스트 실행 간에 재사용할 수 있다. 컨테이너 시작 시간을 줄인다.
병렬 실행 JUnit 5의 병렬 실행 기능으로 통합 테스트를 병렬로 돌릴 수 있다. 테스트 간 데이터 충돌에 주의해야 한다.
언제 통합 테스트가 단위 테스트보다 나은가
단위 테스트로 Mock을 잔뜩 만들어도 “이 코드가 실제로 동작하는가”를 확신하기 어려운 경우가 있다. 그럴 때는 통합 테스트가 더 낫다.
- Repository 레이어: 쿼리 결과가 예상대로인지 실제 DB로 확인
- Controller 레이어: JSON 직렬화/역직렬화, 유효성 검증, 응답 코드
- 외부 서비스 클라이언트: HTTP 요청/응답 처리, 재시도, 타임아웃
- 이벤트 기반 처리: Kafka 메시지가 실제로 발행되고 소비되는지
단위 테스트가 더 나은 경우는 도메인 로직이다. DB 없이 검증할 수 있는 계산, 검증, 상태 전이는 단위 테스트가 빠르고 명확하다.
두 테스트는 경쟁 관계가 아니다. 각각이 커버하는 영역이 다르다.