각자 잘 동작하는 것만으로는 부족하다. 만나는 지점에서 약속이 지켜지는지를 보아야 한다.
사용자가 마주하는 문제
단위 테스트는 모두 통과합니다. 로컬에서 화면도 잘 뜹니다. 그런데 스테이징에 올리면 로그인이 되지 않습니다. 원인을 따라가보니, 백엔드 API가 응답 형식을 살짝 바꿨는데 프론트엔드는 옛 형식을 기대하고 있었습니다.
이런 문제는 단위 테스트가 잡지 못합니다. 양쪽 모듈은 각자 자기 일을 잘 하고 있었으니까요. 문제는 둘 사이의 약속에 있었습니다. 통합 테스트는 그 약속이 지켜지는지를 검증합니다.
무엇을 검증하나
통합 테스트가 다루는 경계는 보통 다음과 같습니다.
- 모듈 간 결합 — 한 도메인 안에서 여러 클래스/모듈이 함께 동작할 때
- API 호출 — 프론트엔드와 백엔드, 또는 마이크로서비스끼리
- 데이터베이스 — 쿼리, 트랜잭션, 마이그레이션이 의도대로 동작하는지
- 외부 서비스 — 결제, 이메일, 알림, 인증 등 서드파티
핵심은 외부 의존을 진짜로 다루되, 안전한 방식으로 한다는 점입니다. 단위 테스트는 외부를 잘라냈지만, 통합 테스트는 외부를 마주하고 약속을 검증합니다.
테스트 더블 - 외부를 다루는 네 가지 방식
통합 테스트의 어려움 절반은 “외부를 어떻게 다루는가”에 있습니다. 진짜 외부 서비스를 매번 호출하면 느리고 불안정하고 돈도 듭니다. 그래서 테스트 더블(Test Double)이라는 개념이 있습니다.
- Dummy — 자리 채우기용. 호출되지 않는다는 전제
- Stub — 정해진 응답만 돌려준다. 호출은 검증하지 않는다
- Mock — 정해진 응답을 돌려주면서, 호출 자체도 검증한다
- Fake — 진짜에 가깝지만 가벼운 구현. 예: 인메모리 데이터베이스
흔한 함정은 mock을 과하게 쓰는 것입니다. 모든 외부를 mock으로 막아버리면 통합 테스트가 사실상 단위 테스트가 되고, 진짜 통합 문제는 못 잡습니다. 가능하면 fake나 진짜 인스턴스를 격리된 환경에 띄우는 쪽이 신뢰도가 높습니다.
도구와 예시
영역에 따라 도구가 다릅니다.
- API 통합 (백엔드) — Vitest/Jest + supertest로 실제 라우터를 호출
- API 통합 (프론트엔드) — MSW(Mock Service Worker)로 네트워크 레이어에서 가로채기
- 데이터베이스 — Testcontainers로 격리된 DB 컨테이너를 띄워 진짜 SQL 실행
- 외부 서비스 — 가능하면 sandbox 환경, 어려우면 MSW나 nock
프론트엔드 API 통합 예시입니다. MSW는 네트워크 레이어에서 fetch를 가로채기 때문에, 코드를 거의 수정하지 않고 가짜 응답을 줄 수 있습니다.
// handlers.ts
import { http, HttpResponse } from "msw";
export const handlers = [
http.get("/api/user/:id", ({ params }) => {
if (params.id === "404") {
return new HttpResponse(null, { status: 404 });
}
return HttpResponse.json({
id: params.id,
name: "파이",
role: "designer-engineer",
});
}),
];
// user.test.ts
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { setupServer } from "msw/node";
import { handlers } from "./handlers";
import { fetchUser } from "./user";
const server = setupServer(...handlers);
beforeAll(() => server.listen());
afterAll(() => server.close());
describe("fetchUser", () => {
it("정상 응답을 파싱한다", async () => {
const user = await fetchUser("1");
expect(user.name).toBe("파이");
});
it("404를 적절히 처리한다", async () => {
await expect(fetchUser("404")).rejects.toThrow("User not found");
});
});
핵심은 API의 응답 형식을 약속으로 잡아두고, 그 약속에 변화가 생기면 즉시 알 수 있게 한다는 점입니다.
계약 테스트 - 양쪽이 같은 약속을 보고 있는가
마이크로서비스나 BFF 구조에서는 한 단계 더 나아간 방식이 있습니다. 계약 테스트(Contract Testing)는 소비자가 기대하는 응답 형식과 제공자가 실제로 주는 응답 형식이 일치하는지를 양쪽 모두에서 검증합니다.
- Pact — 가장 널리 쓰이는 도구. 소비자 측에서 “이런 응답을 받을 것이다”라는 계약 파일을 만들고, 제공자 측이 그 계약을 따르는지 검증
- OpenAPI 기반 — 스키마 자체를 계약으로 보고, 양쪽이 같은 스키마를 따르는지 검증
규모가 작을 때는 오버일 수 있습니다. 서비스가 많아지고, 팀이 분리되어 있을 때부터 가치가 드러납니다.
언제, 어디서 실행하나
- 개발 중 — DB가 필요한 통합 테스트는 로컬 Docker로 띄워서
- CI — 가벼운 통합 테스트는 PR마다, 무거운 것은 머지 후 또는 야간 배치
- 릴리스 전 — 스테이징 환경에서 진짜 외부 서비스까지 포함한 스모크 테스트
통합 테스트는 단위 테스트보다 느립니다. 전체가 5분을 넘기지 않게 관리하는 게 좋고, 그 이상이면 병렬화나 분할 실행을 고민해야 합니다.
도입 체크리스트
- 가장 자주 깨지는 결합 지점 1~2개 식별 (보통 인증 흐름)
- 해당 지점에 통합 테스트 1~3개 작성
- DB가 필요하면 Testcontainers 또는 docker-compose 설정
- 프론트엔드는 MSW 설정 (스토리북 모킹과 공유 가능)
- CI 워크플로우에 통합 테스트 단계 추가
- 테스트 환경의 데이터 초기화 전략 합의 (매 테스트마다 vs 트랜잭션 롤백)
흔한 함정
- mock으로 모든 것을 막아버린다 — 통합 테스트가 사실상 단위 테스트가 됩니다. 결합 문제는 못 잡으면서 단위 테스트보다 느립니다.
- 테스트가 서로 영향을 준다 — DB나 전역 상태를 공유하면, 테스트 순서에 따라 결과가 달라집니다. 각 테스트는 자기 데이터를 만들고 정리해야 합니다.
- 외부 서비스에 진짜로 의존한다 — 결제 서비스가 잠시 느려져도 CI가 빨갛게 변합니다. sandbox나 fake를 만들어두는 게 안전합니다.
- 속도를 포기한다 — 통합 테스트가 30분 걸리면 아무도 안 돌립니다. 병렬화, 의존성 격리, 무거운 시나리오는 야간 배치로 분리해야 합니다.
- 단위 테스트의 영역을 통합으로 검증한다 — 할인율 계산 같은 순수 로직을 통합 테스트로 검증하면, 느리기만 하고 가치는 같습니다. 가능한 한 단위로 내려야 합니다.
참고 자료
- Martin Fowler. “Test Double”, https://martinfowler.com/bliki/TestDouble.html
- MSW. 공식 문서. https://mswjs.io
- Testcontainers. 공식 문서. https://testcontainers.com
- Pact. 공식 문서. https://docs.pact.io
다음 편 예고
함수와 컴포넌트, 그리고 모듈 간 결합까지 검증했다면 마지막 단계가 남았습니다. 진짜 사용자가 화면을 보고 클릭하고 입력하면서 핵심 시나리오를 끝까지 마칠 수 있는지. 다음 편에서 다룹니다.