본문으로 건너뛰기

통합 테스트, 모듈이 만날 때 - 사용자 대상 소프트웨어 테스트 가이드 ep.04

각자 잘 동작하는 것만으로는 부족하다. 만나는 지점에서 약속이 지켜지는지를 보아야 한다.

사용자가 마주하는 문제

단위 테스트는 모두 통과합니다. 로컬에서 화면도 잘 뜹니다. 그런데 스테이징에 올리면 로그인이 되지 않습니다. 원인을 따라가보니, 백엔드 API가 응답 형식을 살짝 바꿨는데 프론트엔드는 옛 형식을 기대하고 있었습니다.

이런 문제는 단위 테스트가 잡지 못합니다. 양쪽 모듈은 각자 자기 일을 잘 하고 있었으니까요. 문제는 둘 사이의 약속에 있었습니다. 통합 테스트는 그 약속이 지켜지는지를 검증합니다.


무엇을 검증하나

통합 테스트가 다루는 경계는 보통 다음과 같습니다.

  • 모듈 간 결합 — 한 도메인 안에서 여러 클래스/모듈이 함께 동작할 때
  • API 호출 — 프론트엔드와 백엔드, 또는 마이크로서비스끼리
  • 데이터베이스 — 쿼리, 트랜잭션, 마이그레이션이 의도대로 동작하는지
  • 외부 서비스 — 결제, 이메일, 알림, 인증 등 서드파티

핵심은 외부 의존을 진짜로 다루되, 안전한 방식으로 한다는 점입니다. 단위 테스트는 외부를 잘라냈지만, 통합 테스트는 외부를 마주하고 약속을 검증합니다.

통합 테스트가 다루는 결합 지점들. 가운데에 우리 애플리케이션이 있고, 그것이 네 방향으로 다른 시스템과 만난다. 내부 모듈 간 결합, 백엔드와 프론트엔드 사이의 API 경계, 데이터베이스와의 쿼리·트랜잭션 경계, 그리고 결제·이메일·인증 같은 외부 서비스와의 경계가 그것이다. 각 경계마다 통합 테스트가 약속을 검증한다는 흐름이 표시되어 있다.


테스트 더블 - 외부를 다루는 네 가지 방식

통합 테스트의 어려움 절반은 “외부를 어떻게 다루는가”에 있습니다. 진짜 외부 서비스를 매번 호출하면 느리고 불안정하고 돈도 듭니다. 그래서 테스트 더블(Test Double)이라는 개념이 있습니다.

  • Dummy — 자리 채우기용. 호출되지 않는다는 전제
  • Stub — 정해진 응답만 돌려준다. 호출은 검증하지 않는다
  • Mock — 정해진 응답을 돌려주면서, 호출 자체도 검증한다
  • Fake — 진짜에 가깝지만 가벼운 구현. 예: 인메모리 데이터베이스

흔한 함정은 mock을 과하게 쓰는 것입니다. 모든 외부를 mock으로 막아버리면 통합 테스트가 사실상 단위 테스트가 되고, 진짜 통합 문제는 못 잡습니다. 가능하면 fake나 진짜 인스턴스를 격리된 환경에 띄우는 쪽이 신뢰도가 높습니다.

테스트 더블의 네 가지 종류를 비교한 표. 왼쪽부터 Dummy, Stub, Mock, Fake 순으로 정렬되어 있다. Dummy는 자리 채우기용으로 호출되지 않는 전제이고, Stub은 정해진 응답만 돌려주며, 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분 걸리면 아무도 안 돌립니다. 병렬화, 의존성 격리, 무거운 시나리오는 야간 배치로 분리해야 합니다.
  • 단위 테스트의 영역을 통합으로 검증한다 — 할인율 계산 같은 순수 로직을 통합 테스트로 검증하면, 느리기만 하고 가치는 같습니다. 가능한 한 단위로 내려야 합니다.

참고 자료


다음 편 예고

ep.05 - E2E 테스트, 사용자 흐름 따라가기

함수와 컴포넌트, 그리고 모듈 간 결합까지 검증했다면 마지막 단계가 남았습니다. 진짜 사용자가 화면을 보고 클릭하고 입력하면서 핵심 시나리오를 끝까지 마칠 수 있는지. 다음 편에서 다룹니다.