콘텐츠로 이동

Testing Strategy

분류: Layer 9 - 아키텍처 & 설계 패턴

소프트웨어가 의도한 대로 동작하는지 자동으로 검증하는 체계적 방법론으로, TDD와 테스트 피라미드를 기반으로 Unit → Integration → E2E 계층별로 테스트를 구성한다.


코드는 처음 작성할 때보다 읽고 수정하는 시간이 훨씬 더 많다. 테스트가 없으면 다음 문제가 발생한다:

  • 회귀 버그(Regression Bug): 새 기능을 추가했더니 기존 기능이 깨진다. 언제 깨졌는지 모른다.
  • 리팩터링 공포: “이 코드를 바꾸면 어디가 터질지 모르니 그냥 두자” 심리가 생긴다.
  • 배포 두려움: 수동으로 확인해야 하는 항목이 많아져서 배포 속도가 느려진다.
  • 온보딩 어려움: 새로운 팀원이 코드의 의도를 파악하기 어렵다.
가치설명
빠른 피드백코드 변경 즉시 수백 개의 테스트가 자동 실행된다
살아있는 문서테스트 코드는 “이 함수가 어떻게 동작해야 하는지” 설명하는 예시다
설계 개선테스트하기 어려운 코드 = 결합도가 높은 코드. 테스트가 설계를 개선한다
배포 자신감CI에서 모든 테스트 통과 확인 후 배포하면 두려움이 줄어든다

선행 방식의 한계 — TDD와 테스트 피라미드가 등장한 이유

섹션 제목: “선행 방식의 한계 — TDD와 테스트 피라미드가 등장한 이유”

테스트 전략이 없던 시기의 기본값은 “개발 후 수동 QA” 또는 “브라우저 조작을 녹화한 E2E 테스트”였다. 이 방식은 처음에는 쉽지만, UI 문구·DOM 구조·인증 플로우가 조금만 바뀌어도 녹화 스크립트를 다시 만들고, 실패가 프론트·백엔드·테스트 환경 중 어디서 시작됐는지 추적해야 한다. Martin Fowler는 이런 UI 중심 자동화가 느리고 깨지기 쉬워 빌드 시간을 늘리며, 그래서 낮은 수준의 테스트를 더 많이 두는 피라미드가 필요하다고 설명한다 (출처: https://martinfowler.com/bliki/TestPyramid.html).

정량적으로 보면 문제는 “테스트가 있느냐”가 아니라 “피드백이 고칠 수 있을 만큼 빠르고 신뢰 가능한가”다. Google Testing Blog의 E2E 중심 사례에서는 릴리스 전 90% 통과 기준을 두었지만, 환경 장애·파트너 서비스 실패·큰 버그가 작은 버그를 가리는 현상 때문에 팀이 마일스톤을 1주 늦게 끝냈고 개발자는 수정 결과를 다음 날에야 확인했다 (출처: https://testing.googleblog.com/2015/04/just-say-no-to-more-end-to-end-tests.html). 같은 블로그의 flaky 테스트 분석에서는 전체 테스트 실행의 약 1.5%가 flaky였고, 테스트의 약 16%가 어떤 수준의 flakiness를 보였으며, pass → fail 전환의 약 84%가 flaky 테스트와 관련됐다 (출처: https://testing.googleblog.com/2016/05/flaky-tests-at-google-and-how-we.html). 즉 코드 변경 시 회귀 버그 위험은 “수동 확인을 더 열심히 한다”로 해결되지 않는다.

TDD와 테스트 피라미드는 이 한계를 두 단계로 푼다. TDD는 Red 단계에서 요구사항을 먼저 실패하는 테스트로 고정해 “무엇을 만들지”와 “어떻게 만들지”를 분리하고, 테스트 피라미드는 빠르고 격리된 Unit 테스트를 바닥에 많이 두어 대부분의 회귀를 수 초~수 분 안에 잡는다. Integration/E2E는 사라지는 것이 아니라, Unit이 놓치는 모듈 경계와 사용자 흐름만 검증하는 두 번째 방어선이 된다. 이 문서의 lineage_oneliner처럼 핵심 흐름은 코드 변경 시 회귀 버그 위험 → TDD와 테스트 피라미드로 자동 검증이다.


프론트엔드 브릿지: 프론트에서 Jest + RTL(React Testing Library)로 컴포넌트를 테스트했다면, 백엔드 테스트는 그것과 동일한 Jest 위에서 동작한다. render(<Button />) 대신 request(app).post('/users')로 API를 호출하고, screen.getByText() 대신 expect(response.body).toEqual()로 결과를 검증한다. 도구 체계는 같고 테스트 대상이 컴포넌트에서 HTTP 엔드포인트로 바뀐 것이다.

일반적인 개발은 집을 짓고 나서 “이게 맞나?” 확인한다. TDD는 설계도(테스트)를 먼저 그리고, 그 설계도에 맞춰 집을 짓는다. 설계도가 있으면 어디까지 지어야 하는지 명확하고, 과설계(필요 이상으로 복잡하게 짓기)도 방지된다.

원리: Red → Green → Refactor 사이클

섹션 제목: “원리: Red → Green → Refactor 사이클”
Red → 실패하는 테스트를 먼저 작성한다
(아직 구현이 없으므로 당연히 실패)
Green → 테스트를 통과시키는 최소한의 코드를 작성한다
(깔끔하지 않아도 좋다, 일단 통과가 목표)
Refactor → 동작을 바꾸지 않으면서 코드 품질을 개선한다
(중복 제거, 명명 개선, 구조 개선)

이 사이클이 중요한 이유는:

  • Red 단계: “내가 무엇을 만들어야 하는가”를 테스트로 명확히 정의한다. 요구사항이 코드가 된다.
  • Green 단계: 최소한의 코드만 작성하므로 불필요한 기능(과설계)이 추가되지 않는다.
  • Refactor 단계: 테스트가 보호망이 되어 안심하고 코드를 개선할 수 있다.

”왜 TDD가 인지적으로 효과적인가” — Red-Green-Refactor의 심리학

섹션 제목: “”왜 TDD가 인지적으로 효과적인가” — Red-Green-Refactor의 심리학”

TDD가 단순한 습관이 아니라 **인지 부하(Cognitive Load)**를 줄이는 과학적 근거가 있다.

일반적인 개발에서는 “무엇을 만들지 + 어떻게 만들지 + 잘 동작하는지”를 동시에 생각해야 한다. TDD는 이 세 가지 사고를 세 단계로 분리한다.

일반 개발의 인지 부하:
┌─────────────────────────────────────────────┐
│ "무엇을?" + "어떻게?" + "맞나?" 동시에 사고 │ ← 높은 인지 부하
└─────────────────────────────────────────────┘
TDD의 인지 부하 분산:
Red: "무엇을?"만 생각 (테스트 = 명세서 작성) ← 1가지만 집중
Green: "어떻게?"만 생각 (최소 구현) ← 1가지만 집중
Refactor: "더 좋게"만 생각 (구조 개선) ← 1가지만 집중

이 분리가 주는 추가 이점:

  • 명확한 완료 기준: 테스트가 초록불이면 “됐다”. 완료 판단에 모호함이 없다.
  • 즉각적 피드백: 매 사이클(수 분 단위)마다 성공/실패 피드백을 받아 **몰입 상태(Flow State)**를 유지한다.
  • 과설계 방지: Green 단계에서 “테스트를 통과하는 최소 코드”만 작성하므로 YAGNI(You Ain’t Gonna Need It) 원칙이 자연스럽게 지켜진다.

📖 더 보기: TDD with NestJS — Trilon — NestJS 창시자 팀이 작성한 TDD 실전 가이드. Red-Green-Refactor를 NestJS 서비스에 적용하는 과정 (중급)

상황문제대안
UI/UX 탐색 단계 (주 1~2회 요구사항 변경)Red-Green 유지 비용 > 이득Spike 먼저, 확정 후 테스트 작성
알고리즘 미확정 단계인터페이스가 없어 Red를 쓸 수 없음Prototype 후 TDD
레거시 코드 수정의존성 없이 Red 작성 불가Characterization Test 먼저 작성
CI 테스트 실행 시간 > 10분Red-Green 피드백이 너무 느림Slow Test 격리 또는 병렬화
프로토타입/PoC (1주 이내 폐기 예정)테스트 ROI 없음프로토타입은 TDD 생략 허용

커버리지 경계 조건: 80% 이상을 목표로 하되, Business Logic(Service)은 100%, 설정 파일(app.module.ts, main.ts)은 제외. 커버리지가 60% 미만이면 회귀 감지 신뢰도가 낮아 TDD 도입 효과가 반감된다.

위 기준이 실제 결정으로 이어진 사례 — “CI 10분” 임계 적용 (사내 추정 사례): Integration 테스트가 30개 추가되며 PR 빌드가 7분 → 14분으로 늘어나 표의 “CI 테스트 실행 시간 > 10분” 행이 트리거됐다고 가정한다. 대응: PR CI는 jest --testPathIgnorePatterns=integration으로 Unit만 5분 안에 끝내고, nightly CI에서 Integration 30개를 14분에 묶어 돌리는 식으로 분할. 결과적으로 PR-merge 사이클은 평균 12분 → 7분으로 줄지만, integration 회귀 발견이 다음 날 아침으로 늦춰지는 trade-off가 생긴다 — 이 비용을 감수할 수 있는 팀(주 1~2회 prod 배포)에서만 통하고, 시간당 배포하는 팀은 오히려 PR에서 Integration까지 돌려야 한다. 즉 표의 정량 임계는 “단독으로 깨면 끝”이 아니라 배포 빈도와 함께 읽어야 하는 trade-off 좌표다.

TDD 적용 결정 기준: 요구사항이 “정산 금액은 음수가 될 수 없다”, “주문 상태는 paid → shipped → delivered 순서만 허용한다”처럼 불변식으로 표현되면 TDD를 먼저 적용한다. 반대로 UX 탐색처럼 인터페이스가 하루에도 여러 번 바뀌는 작업은 Spike로 화면·API 모양을 먼저 고정한 뒤 테스트를 붙인다. TDD가 조용히 실패하는 대표 사례는 Mock이 구현 세부사항만 검증해서 실제 버그를 놓치는 경우다. 예를 들어 expect(repository.save).toHaveBeenCalledTimes(1)만 있고 반환 DTO나 DB 제약을 검증하지 않으면, 저장 전 금액 반올림 로직을 삭제해도 테스트가 초록색일 수 있다. 이때는 먼저 다음 명령으로 실패가 “행동”이 아니라 “호출 횟수”에 묶였는지 확인한다.

Terminal window
npx jest src/order/order.service.spec.ts --runInBand --verbose

예상 출력이 Expected number of calls: 1 같은 호출 검증 실패뿐이라면, 다음 단계는 expect(saved.amount).toBe(15000) 같은 결과 검증을 추가하거나 Testcontainers 기반 Integration 테스트로 DB 제약까지 확인하는 것이다. 이 판단은 NestJS가 공식 문서에서 제공하는 overrideProvider()/overrideGuard() 같은 테스트 더블 기능과도 연결된다. 교체는 빠른 피드백을 위한 수단이지, 실제 상호작용 검증을 영구히 생략하라는 뜻은 아니다 (출처: https://docs.nestjs.com/fundamentals/testing).

Step 1: 서비스 생성 (spec 파일이 자동 생성됨)

src/user/user.service.ts
nest g service user
# 생성되는 파일:
# src/user/user.service.spec.ts ← 이것부터 먼저 작성!

Step 2: Red - 실패하는 테스트 작성

src/user/user.service.spec.ts
import { Test, TestingModule } from "@nestjs/testing";
import { UserService } from "./user.service";
describe("UserService", () => {
let service: UserService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [UserService],
}).compile();
service = module.get<UserService>(UserService);
});
// 아직 구현 안 된 메서드 테스트 → Red (실패)
describe("getUserById", () => {
it("존재하는 ID로 조회하면 유저를 반환해야 한다", async () => {
const user = await service.getUserById(1);
expect(user).toBeDefined();
expect(user.id).toBe(1);
expect(user.name).toBe("홍길동");
});
it("존재하지 않는 ID로 조회하면 null을 반환해야 한다", async () => {
const user = await service.getUserById(999);
expect(user).toBeNull();
});
});
});
Terminal window
npx jest user.service.spec.ts

예상 출력 (Red 단계):

FAIL src/user/user.service.spec.ts
UserService
getUserById
✕ 존재하는 ID로 조회하면 유저를 반환해야 한다 (12ms)
TypeError: service.getUserById is not a function
✕ 존재하지 않는 ID로 조회하면 null을 반환해야 한다 (1ms)
TypeError: service.getUserById is not a function
Test Suites: 1 failed, 1 total
Tests: 2 failed, 0 total

Step 3: Green - 테스트를 통과시키는 최소한의 구현

src/user/user.service.ts
import { Injectable } from "@nestjs/common";
@Injectable()
export class UserService {
// 일단 하드코딩으로 통과시킨다 (DB 연결은 나중에)
private readonly users = [
{ id: 1, name: "홍길동", email: "[email protected]" },
];
async getUserById(id: number) {
return this.users.find((u) => u.id === id) ?? null;
}
}
Terminal window
npx jest user.service.spec.ts

예상 출력 (Green 단계):

PASS src/user/user.service.spec.ts
UserService
getUserById
✓ 존재하는 ID로 조회하면 유저를 반환해야 한다 (8ms)
✓ 존재하지 않는 ID로 조회하면 null을 반환해야 한다 (1ms)
Test Suites: 1 passed, 1 total
Tests: 2 passed, 2 total
Time: 1.234s

Step 4: Refactor - DB 연동으로 실제 구현으로 개선

// src/user/user.service.ts (리팩터 후)
import { Injectable, NotFoundException } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { User } from "./user.entity";
@Injectable()
export class UserService {
constructor(
@InjectRepository(User)
private readonly userRepository: Repository<User>,
) {}
async getUserById(id: number): Promise<User | null> {
return this.userRepository.findOne({ where: { id } });
}
}

피라미드는 바닥이 넓고 꼭대기가 좁다. 테스트도 마찬가지다. 바닥의 Unit Test가 가장 많고(빠르고 저렴), 꼭대기의 E2E Test가 가장 적다(느리고 비싸다). 피라미드를 뒤집으면(E2E가 많으면) 테스트 실행이 느려지고 유지보수가 어려워진다.

/\
/E2E\ 10% - API 전체 흐름 (느림, 비쌈)
/------\
/Integrat\ 20% - 모듈 간 연동 (중간)
/----------\
/ Unit Test \ 70% - 함수/클래스 단위 (빠름, 저렴)
/--------------\

”왜 피라미드를 뒤집으면 안 되는가” — 테스트 비용의 경제학

섹션 제목: “”왜 피라미드를 뒤집으면 안 되는가” — 테스트 비용의 경제학”

E2E 테스트가 많은 팀(역삼각형 구조)은 다음과 같은 악순환에 빠진다.

역삼각형 (E2E 많음):
/--------------\
\ E2E(60%) / ← CI가 30분 이상 소요
\-----------/ ← 실패 시 원인 파악에 시간 낭비
\Integ(30%)/ ← DB, 네트워크 상태에 따라 불안정(Flaky)
\--------/
\Unit(10%)/ ← 빠른 피드백 부족
\------/
결과:
- CI가 느려서 개발자들이 "일단 머지하고 나중에 고치자" 문화 형성
- Flaky 테스트 → 실패를 무시하는 습관 → 실제 버그도 무시
- 테스트 유지보수 비용이 기하급수적으로 증가
올바른 피라미드:
Unit(70%): 실행 1초, 실패 시 정확한 함수/메서드 특정 가능
Integration(20%): 실행 10초, 모듈 간 연동 검증
E2E(10%): 실행 수 분, 핵심 시나리오만 커버

같은 곡선이 testing 밖에서도 작동한다

섹션 제목: “같은 곡선이 testing 밖에서도 작동한다”

피라미드가 가리키는 진짜 원리는 “빠른·격리된·다수 신호가 바닥, 느린·통합된·소수 신호가 꼭대기”라는 것이다. 이 비용·피드백 곡선은 testing 전용이 아니다.

  • 모니터링/observability — Hibri Marzook(Contino Engineering)은 production monitoring을 “TDD의 outer loop”라고 부른다. unit-level metric alert (CPU, mem 단일 신호 — 초 단위 평가) → service-level SLO alert (서비스 간 의존성, p99 latency — 분 단위) → end-user funnel alert (주문→결제 시나리오 통과율 — 분~시간). E2E funnel alert만 두면 “사용자가 결제를 못 한다는 것은 알지만 어느 layer가 깨졌는지는 모른다” 상태가 되고, unit metric만 두면 “각 컴포넌트는 정상인데 funnel은 깨졌다”는 silent failure가 발생한다. (출처: https://medium.com/contino-engineering/knowthe-testing-pyramid-42a4b3573988)
  • progressive delivery / 배포 게이트 — Ring 0(내부 dogfood) → Ring 1(canary 15%) → Ring 2(beta 1025%) → Ring 3(GA 100%)의 비용·신호 비율이 피라미드와 같다. Ring 0를 건너뛰고 canary부터 가는 팀은 testing 피라미드 뒤집은 팀과 같은 증상(원인 특정 비용 폭증, 빠른 피드백 부재)을 겪는다.
  • 데이터 검증 — schema constraint (NOT NULL, FK — DB가 1ms 안에 강제) → cross-row invariant (집계 sum 일치 — 초 단위) → cross-system reconciliation (외부 결제사 vs 내부 ledger — 일 단위 배치). 이 비율이 뒤집히면 “매 row마다 외부 호출하는 응답 지연” 또는 “회계 마감 때마다 차이가 발견되는 silent skew” 양극단으로 갈린다.

BackOps 적용 사례 (Cross-pollination이 본문 결정으로 이어진 부분): 위 §3.6의 SQS 기반 주문 파이프라인을 설계할 때 테스트 피라미드와 모니터링 alert 피라미드를 같이 그려서 결정한다. unit alert는 SQS publish 성공률 / DLQ 진입률(컴포넌트 단일 신호), integration alert는 “publish → consume → DB write 평균 지연”(서비스 간 SLO), E2E alert는 “주문 생성 → 결제 승인 funnel 통과율”(사용자 시나리오). 이렇게 깔면 incident 발생 시 alert 단계에서 이미 어느 layer에서 깨졌는지 좁혀져 진단 시간이 줄어든다. 같은 70/20/10 비율을 테스트와 모니터링 양쪽에 적용하는 결정은 단순 미러링이 아니라 위에서 본 “비용·피드백 곡선”이 두 도메인에 같이 적용되기 때문이다 (구체 단축 수치는 팀별 환경에 따라 다름; 추정).

테스트 유형실행 시간실패 원인 특정유지보수 비용안정성(Flaky 위험)
Unit~1ms/건매우 쉬움낮음높음(안정적)
Integration~1s/건보통중간중간
E2E~5s/건어려움높음낮음(불안정)

위 trade-off를 근거로 한 팀이 결정을 바꾼 사례 (Google Testing Blog, 2015/2017/2024): Google은 2015년 글에서 Unit 테스트는 0.1초도 느린 편에 속할 수 있는 반면, E2E는 빌드·배포·전체 실행을 기다려야 하고 실패 원인이 제품 어디에나 있을 수 있다고 설명했다. 그래서 첫 추정값으로 70/20/10(Unit/Integration/E2E)을 제안하되, 팀마다 정확한 비율은 달라도 피라미드 모양은 유지하라고 했다 (출처: https://testing.googleblog.com/2015/04/just-say-no-to-more-end-to-end-tests.html). 2017년 분석에서는 Google의 small/medium/large 테스트 중 flaky 비율이 각각 0.5% / 1.6% / 14%였고, RAM 사용량과 flakiness의 상관 r2가 0.76, 바이너리 크기와 flakiness의 상관 r2가 0.82로 나타났다 (출처: https://testing.googleblog.com/2017/04/where-do-our-flaky-tests-come-from.html). 2024년 “SMURF” 후속 글은 단일 비율 권고의 한계를 인정하고 Speed·Maintainability·Utilization·Reliability·Fidelity를 함께 보는 프레임으로 확장했다 (출처: https://testing.googleblog.com/2024/10/smurf-beyond-test-pyramid.html). 즉 위 표의 4개 컬럼은 한 팀의 결정에서 동시에 저울질해야 할 좌표다. “E2E를 10%로 맞췄는가”보다 “큰 테스트가 늘면서 flaky 비율·CI 대기·원인 특정 시간이 함께 증가하는가”가 진짜 결정 변수다.

무엇을 테스트하는가: 하나의 함수, 하나의 클래스 메서드. 외부 의존성(DB, 외부 API)은 모두 Mock으로 대체한다.

왜 Mock을 쓰는가: DB가 없어도 테스트할 수 있고, 실행이 밀리초 단위로 빠르다. 테스트 결과가 DB 상태에 영향을 받지 않으므로 격리가 보장된다.

// src/user/user.service.spec.ts (Unit Test - DB Mock 사용)
import { Test, TestingModule } from "@nestjs/testing";
import { getRepositoryToken } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { UserService } from "./user.service";
import { User } from "./user.entity";
describe("UserService (Unit)", () => {
let service: UserService;
let userRepository: jest.Mocked<Repository<User>>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
UserService,
{
provide: getRepositoryToken(User),
// Repository의 모든 메서드를 Mock 함수로 대체
useValue: {
findOne: jest.fn(),
save: jest.fn(),
delete: jest.fn(),
},
},
],
}).compile();
service = module.get<UserService>(UserService);
userRepository = module.get(getRepositoryToken(User));
});
describe("getUserById", () => {
it("DB에서 유저를 찾으면 반환해야 한다", async () => {
const mockUser: User = {
id: 1,
name: "홍길동",
} as User;
// DB 조회 결과를 Mock으로 설정
userRepository.findOne.mockResolvedValue(mockUser);
const result = await service.getUserById(1);
// 반환값 검증
expect(result).toEqual(mockUser);
// DB 조회가 올바른 조건으로 호출됐는지 검증
expect(userRepository.findOne).toHaveBeenCalledWith({ where: { id: 1 } });
});
it("DB에 유저가 없으면 null을 반환해야 한다", async () => {
userRepository.findOne.mockResolvedValue(null);
const result = await service.getUserById(999);
expect(result).toBeNull();
});
});
});
Terminal window
npx jest user.service.spec.ts --verbose

예상 출력:

PASS src/user/user.service.spec.ts
UserService (Unit)
getUserById
✓ DB에서 유저를 찾으면 반환해야 한다 (5ms)
✓ DB에 유저가 없으면 null을 반환해야 한다 (1ms)
Test Suites: 1 passed, 1 total
Tests: 2 passed, 2 total
Time: 0.892s

Integration Test (통합 테스트) — 20%

섹션 제목: “Integration Test (통합 테스트) — 20%”

무엇을 테스트하는가: 실제 DB/Redis와 연동된 모듈 동작. Service + Repository + DB가 실제로 함께 잘 동작하는지 확인한다.

왜 실제 DB를 쓰는가: Mock은 “내가 Mock을 잘못 만들었을 때” 버그를 잡지 못한다. 예를 들어, Mock에서 findOne()이 객체를 반환하도록 설정했지만 실제 TypeORM에서는 where 조건 오류로 null을 반환하는 경우가 있다. Integration Test는 이런 Mock과 실제 동작의 괴리를 잡아낸다.

📖 더 보기: Testcontainers Node.js 공식 문서 — PostgreSQL, Redis, MySQL 등 컨테이너별 설정 방법과 NestJS 연동 예제 (중급)

// src/user/user.integration.spec.ts (Testcontainers로 실제 PostgreSQL 사용)
import { Test, TestingModule } from "@nestjs/testing";
import { TypeOrmModule } from "@nestjs/typeorm";
import {
PostgreSqlContainer,
StartedPostgreSqlContainer,
} from "@testcontainers/postgresql";
import { UserService } from "./user.service";
import { UserModule } from "./user.module";
import { User } from "./user.entity";
describe("UserService (Integration)", () => {
let service: UserService;
let container: StartedPostgreSqlContainer;
let module: TestingModule;
// 테스트 전 Docker로 PostgreSQL 컨테이너 시작
beforeAll(async () => {
container = await new PostgreSqlContainer()
.withDatabase("test_db")
.withUsername("test")
.withPassword("test")
.start();
module = await Test.createTestingModule({
imports: [
TypeOrmModule.forRoot({
type: "postgres",
host: container.getHost(),
port: container.getPort(),
username: container.getUsername(),
password: container.getPassword(),
database: container.getDatabase(),
entities: [User],
synchronize: true, // 테스트 환경에서만 사용
}),
UserModule,
],
}).compile();
service = module.get<UserService>(UserService);
}, 60000); // 컨테이너 시작 대기 60초
afterAll(async () => {
await module.close();
await container.stop(); // 테스트 후 컨테이너 종료
});
it("유저를 저장하고 조회할 수 있어야 한다", async () => {
const saved = await service.createUser({
name: "홍길동",
});
const found = await service.getUserById(saved.id);
expect(found).toBeDefined();
expect(found?.name).toBe("홍길동");
});
});
Terminal window
npx jest user.integration.spec.ts --testTimeout=60000

예상 출력:

PASS src/user/user.integration.spec.ts
UserService (Integration)
✓ 유저를 저장하고 조회할 수 있어야 한다 (3421ms)
Test Suites: 1 passed, 1 total
Tests: 1 passed, 1 total
Time: 8.234s ← Unit Test보다 느리다

E2E Test (엔드투엔드 테스트) — 10%

섹션 제목: “E2E Test (엔드투엔드 테스트) — 10%”

무엇을 테스트하는가: 실제 HTTP 요청부터 응답까지 전체 흐름. 클라이언트 입장에서 API가 제대로 동작하는지 확인한다.

왜 비율이 낮은가: 실행 속도가 느리고, 설정이 복잡하며, 실패 원인 파악이 어렵다. 핵심 API 경로만 커버한다.

// test/user.e2e-spec.ts (Supertest + 실제 NestJS 앱)
import { Test, TestingModule } from "@nestjs/testing";
import { INestApplication, ValidationPipe } from "@nestjs/common";
import * as request from "supertest";
import { AppModule } from "../src/app.module";
describe("UserController (E2E)", () => {
let app: INestApplication;
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule], // 전체 앱 모듈 로드
}).compile();
app = moduleFixture.createNestApplication();
app.useGlobalPipes(new ValidationPipe()); // 실제 앱과 동일한 설정
await app.init();
});
afterAll(async () => {
await app.close();
});
describe("GET /users/:id", () => {
it("200: 유저 조회 성공", () => {
return request(app.getHttpServer())
.get("/users/1")
.expect(200)
.expect((res) => {
expect(res.body).toHaveProperty("id", 1);
expect(res.body).toHaveProperty("name");
});
});
it("404: 존재하지 않는 유저 조회", () => {
return request(app.getHttpServer())
.get("/users/99999")
.expect(404)
.expect((res) => {
expect(res.body.message).toContain("찾을 수 없습니다");
});
});
});
describe("POST /users", () => {
it("201: 유저 생성 성공", () => {
return request(app.getHttpServer())
.post("/users")
.send({ name: "테스트유저", email: "[email protected]" })
.expect(201)
.expect((res) => {
expect(res.body).toHaveProperty("id");
expect(res.body.name).toBe("테스트유저");
});
});
it("400: 이메일 누락 시 유효성 검사 실패", () => {
return request(app.getHttpServer())
.post("/users")
.send({ name: "테스트유저" }) // email 빠짐
.expect(400);
});
});
});
Terminal window
npx jest --config ./test/jest-e2e.json

예상 출력:

PASS test/user.e2e-spec.ts
UserController (E2E)
GET /users/:id
✓ 200: 유저 조회 성공 (156ms)
✓ 404: 존재하지 않는 유저 조회 (43ms)
POST /users
✓ 201: 유저 생성 성공 (89ms)
✓ 400: 이메일 누락 시 유효성 검사 실패 (38ms)
Test Suites: 1 passed, 1 total
Tests: 4 passed, 4 total
Time: 4.521s

NestJS가 기본으로 사용하는 JavaScript 테스트 프레임워크. 주요 기능:

기능메서드설명
테스트 그룹화describe()관련 테스트를 묶는다
개별 테스트it() / test()하나의 시나리오를 작성한다
사전 설정beforeEach() / beforeAll()테스트 전 공통 로직 실행
Mock 함수jest.fn()빈 Mock 함수 생성
Mock 반환값mockResolvedValue()Promise 반환 Mock 설정
호출 검증toHaveBeenCalledWith()Mock 호출 여부/인수 검증
스냅샷toMatchSnapshot()출력 구조가 바뀌었는지 검증
// jest.config.js (NestJS 기본 설정)
module.exports = {
moduleFileExtensions: ["js", "json", "ts"],
rootDir: "src",
testRegex: ".*\\.spec\\.ts$",
transform: { "^.+\\.(t|j)s$": "ts-jest" },
collectCoverageFrom: ["**/*.(t|j)s"],
coverageDirectory: "../coverage",
testEnvironment: "node",
};

실제 HTTP 서버 없이도 HTTP 요청을 시뮬레이션할 수 있는 라이브러리. NestJS E2E 테스트에서 표준으로 사용한다.

Terminal window
npm install --save-dev supertest @types/supertest

Testcontainers (실제 DB/Redis Integration 테스트)

섹션 제목: “Testcontainers (실제 DB/Redis Integration 테스트)”

Docker 컨테이너를 코드로 제어해서 테스트용 실제 DB를 실행한다. 테스트가 끝나면 자동으로 컨테이너를 종료한다.

Terminal window
npm install --save-dev @testcontainers/postgresql @testcontainers/redis
// Redis Testcontainer 예시
import { RedisContainer } from "@testcontainers/redis";
const redisContainer = await new RedisContainer().start();
const redisUrl = redisContainer.getConnectionUrl(); // redis://localhost:32768

3.6 AWS SQS/SNS 연동 코드의 테스트 전략

섹션 제목: “3.6 AWS SQS/SNS 연동 코드의 테스트 전략”

NestJS 서비스가 AWS SQS 메시지를 수신·발행하는 코드를 테스트할 때 실제 AWS 계정에 의존하면 세 가지 문제가 생긴다: 테스트 비용 발생, 네트워크 의존으로 인한 Flaky 테스트, CI 환경에서 IAM 권한 관리 복잡도. 이를 해결하는 방법은 두 가지다.

방법 1: aws-sdk-client-mock (단위 테스트 권장)

Terminal window
npm install --save-dev @aws-sdk/client-sqs aws-sdk-client-mock
order-event.publisher.spec.ts
import { Test, TestingModule } from "@nestjs/testing";
import { SQSClient, SendMessageCommand } from "@aws-sdk/client-sqs";
import { mockClient } from "aws-sdk-client-mock";
import { OrderEventPublisher } from "./order-event.publisher";
const sqsMock = mockClient(SQSClient);
describe("OrderEventPublisher", () => {
let publisher: OrderEventPublisher;
beforeEach(async () => {
sqsMock.reset(); // 각 테스트 전 Mock 초기화
const module: TestingModule = await Test.createTestingModule({
providers: [OrderEventPublisher],
}).compile();
publisher = module.get<OrderEventPublisher>(OrderEventPublisher);
});
it("주문 생성 이벤트를 SQS에 발행해야 한다", async () => {
// Arrange: SQS 응답 Mock 설정
sqsMock.on(SendMessageCommand).resolves({
MessageId: "mock-message-id-12345",
});
// Act
const result = await publisher.publishOrderCreated({
orderId: "order-001",
userId: "user-abc",
amount: 15000,
});
// Assert: SQS가 올바른 메시지 바디로 호출됐는지 검증
const calls = sqsMock.commandCalls(SendMessageCommand);
expect(calls).toHaveLength(1);
const sentBody = JSON.parse(calls[0].args[0].input.MessageBody);
expect(sentBody).toMatchObject({
type: "OrderCreated",
orderId: "order-001",
});
expect(result.messageId).toBe("mock-message-id-12345");
});
it("SQS 발행 실패 시 에러를 throw해야 한다", async () => {
// Arrange: SQS 에러 응답 Mock
sqsMock.on(SendMessageCommand).rejects(new Error("SQS connection failed"));
// Act & Assert
await expect(
publisher.publishOrderCreated({
orderId: "order-001",
userId: "user-abc",
amount: 15000,
}),
).rejects.toThrow("SQS connection failed");
});
});
// 테스트 실행 결과
PASS src/order/order-event.publisher.spec.ts
OrderEventPublisher
✓ 주문 생성 이벤트를 SQS에 발행해야 한다 (23ms)
✓ SQS 발행 실패 시 에러를 throw해야 한다 (5ms)
Test Suites: 1 passed, 1 total
Tests: 2 passed, 2 total
Time: 1.234s

방법 2: LocalStack (통합 테스트 권장)

LocalStack은 AWS 서비스(SQS, SNS, S3 등)를 로컬 Docker 컨테이너에서 에뮬레이션한다. Testcontainers와 연계하면 CI 환경에서 실제 AWS 없이 통합 테스트를 실행할 수 있다.

Terminal window
npm install --save-dev @testcontainers/localstack
order-processing.integration.spec.ts
import {
LocalstackContainer,
StartedLocalStackContainer,
} from "@testcontainers/localstack";
import {
SQSClient,
CreateQueueCommand,
ReceiveMessageCommand,
} from "@aws-sdk/client-sqs";
describe("OrderProcessing Integration (LocalStack)", () => {
let container: StartedLocalStackContainer;
let sqsClient: SQSClient;
let queueUrl: string;
beforeAll(async () => {
// LocalStack 컨테이너 시작 (SQS 서비스만 활성화)
container = await new LocalstackContainer("localstack/localstack:3.0")
.withEnvironment({ SERVICES: "sqs" })
.start();
sqsClient = new SQSClient({
endpoint: container.getConnectionUri(),
region: "ap-northeast-2",
credentials: { accessKeyId: "test", secretAccessKey: "test" },
});
// 테스트용 큐 생성
const createResult = await sqsClient.send(
new CreateQueueCommand({ QueueName: "order-events-test" }),
);
queueUrl = createResult.QueueUrl!;
}, 60000); // LocalStack 시작 시간 고려 60초 타임아웃
afterAll(async () => {
await container.stop();
});
it("주문 이벤트가 SQS에 발행되고 소비자가 수신해야 한다", async () => {
// 실제 SQS 큐에 메시지 전송
await sqsClient.send(
new SendMessageCommand({
QueueUrl: queueUrl,
MessageBody: JSON.stringify({
type: "OrderCreated",
orderId: "order-001",
}),
}),
);
// 메시지 수신 확인
const response = await sqsClient.send(
new ReceiveMessageCommand({ QueueUrl: queueUrl, MaxNumberOfMessages: 1 }),
);
expect(response.Messages).toHaveLength(1);
const body = JSON.parse(response.Messages![0].Body!);
expect(body.orderId).toBe("order-001");
});
}, 120000);
// LocalStack 통합 테스트 실행 결과
PASS src/order/order-processing.integration.spec.ts
OrderProcessing Integration (LocalStack)
✓ 주문 이벤트가 SQS에 발행되고 소비자가 수신해야 한다 (1243ms)
Test Suites: 1 passed, 1 total
Tests: 1 passed, 1 total
Time: 18.456s ← LocalStack 시작 시간 포함

단위 테스트 vs 통합 테스트 선택 기준

섹션 제목: “단위 테스트 vs 통합 테스트 선택 기준”
상황권장 방법
SQS 호출 파라미터 검증aws-sdk-client-mock (빠름, 단위 테스트)
메시지 발행 → 소비 흐름 검증LocalStack + Testcontainers (통합 테스트)
CI/CD 파이프라인단위 테스트만 기본, 통합 테스트는 별도 스테이지
로컬 개발 빠른 피드백aws-sdk-client-mock 우선

결정은 “AWS를 Mock할 것인가, 로컬 에뮬레이터를 띄울 것인가”가 아니라 검증하려는 실패가 어느 경계에서 발생하는가로 한다. SendMessageCommandMessageBody, DelaySeconds, MessageGroupId 같은 파라미터가 맞는지 보려면 단위 테스트가 충분하다. 하지만 SNS → SQS 구독, DLQ redrive policy, FIFO deduplication처럼 AWS 서비스 간 계약이 맞아야 드러나는 문제는 Mock이 초록색이어도 실제 메시지가 소비되지 않는 silent failure로 남는다.

예를 들어 주문 이벤트 발행 테스트가 통과했는데 소비자가 아무 메시지도 받지 못하면 LocalStack 통합 테스트에서 다음 명령으로 큐 속성을 확인한다.

Terminal window
aws --endpoint-url=http://localhost:4566 sqs get-queue-attributes \
--queue-url http://localhost:4566/000000000000/order-events-test \
--attribute-names RedrivePolicy ApproximateNumberOfMessages

예상 출력은 RedrivePolicyApproximateNumberOfMessages가 함께 보이는 JSON이다. ApproximateNumberOfMessages가 계속 0인데 producer 단위 테스트만 통과한다면, 다음 단계는 SNS subscription 또는 queue URL을 통합 테스트 fixture에서 다시 생성하는 것이다. 반대로 메시지가 쌓이는데 consumer가 처리하지 못한다면 consumer visibility timeout, JSON schema, idempotency 키를 확인한다. 이처럼 테스트 레벨 선택은 “빠른 피드백”과 “실제 경계 검증” 사이의 비용을 명시적으로 고르는 일이다.

📖 더 보기: aws-sdk-client-mock 공식 문서 — AWS SDK v3 Mock 라이브러리 사용법과 NestJS 적용 예제 (입문~중급)


어디부터 테스트를 작성할 것인가

섹션 제목: “어디부터 테스트를 작성할 것인가”

모든 코드에 테스트를 처음부터 100% 작성하는 것은 비현실적이다. 우선순위를 정한다:

1순위: 비즈니스 로직이 담긴 Service 클래스
→ 돈, 포인트, 주문 상태 변경 등 핵심 로직
→ 버그 발생 시 비즈니스 손실이 큰 부분
2순위: 복잡한 유틸리티 함수
→ 날짜 계산, 데이터 변환, 유효성 검사 로직
3순위: Controller (E2E 테스트로 커버)
→ DTO 유효성 검사, HTTP 상태 코드 확인
4순위 (낮음): 단순 CRUD Repository
→ TypeORM이 이미 검증된 라이브러리이므로
→ Integration Test에서 함께 검증 가능

CI에서 테스트 자동화 (GitHub Actions)

섹션 제목: “CI에서 테스트 자동화 (GitHub Actions)”
.github/workflows/test.yml
name: Test
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
services:
# Integration Test용 PostgreSQL (Testcontainers 대신 서비스로 사용 가능)
postgres:
image: postgres:15
env:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: test_db
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Run unit tests
run: npm test -- --coverage --coverageReporters=lcov
- name: Run E2E tests
run: npm run test:e2e
env:
DATABASE_URL: postgresql://test:test@localhost:5432/test_db
- name: Upload coverage report
uses: codecov/codecov-action@v3

흔히 “80% 이상 커버리지”를 목표로 하지만, 숫자 자체보다 중요한 것은 어디를 커버하느냐다.

✅ 올바른 접근:
- 비즈니스 로직(Service): 100% 커버
- 유틸리티 함수: 100% 커버
- 전체 코드베이스: 60~70%도 충분히 의미 있다
❌ 잘못된 접근:
- 숫자 채우기 위한 의미 없는 테스트 작성
- main.ts, app.module.ts 같은 설정 파일까지 테스트
- 단순 getter/setter 테스트로 커버리지 올리기
Terminal window
# 커버리지 임계값 설정 (jest.config.js)
coverageThreshold: {
global: {
branches: 60,
functions: 70,
lines: 70,
statements: 70,
},
// 특정 파일은 엄격하게
'./src/user/user.service.ts': {
branches: 100,
functions: 100,
lines: 100,
},
},

BackOps 엔지니어로서 실제 적용 포인트:

일반적인 BackOps 서비스:
- 주문 상태 변경 로직 (OrderService.updateStatus)
→ 상태 전환 규칙이 복잡 → Unit Test 필수
- 정산 계산 로직 (SettlementService.calculate)
→ 금액 계산 오류는 치명적 → TDD로 작성
- 알림 발송 로직 (NotificationService.send)
→ 외부 API Mock 처리 → Unit Test 적합
// SQS 연동 서비스 Mock 테스트 예시
describe("QueueService", () => {
it("메시지 발송 성공 시 true를 반환해야 한다", async () => {
// AWS SDK Mock
const mockSqsClient = {
send: jest.fn().mockResolvedValue({ MessageId: "mock-id" }),
};
const service = new QueueService(mockSqsClient as any);
const result = await service.sendMessage("test-queue", { orderId: 1 });
expect(result).toBe(true);
expect(mockSqsClient.send).toHaveBeenCalledTimes(1);
});
});

구분TDDBDD
초점구현 테스트행동/시나리오 테스트
언어기술적 표현 (getUserById)비즈니스 언어 (주문을 생성하면 재고가 감소한다)
도구JestJest + jest-cucumber, Jasmine
적합개발자 내부 품질기획자-개발자 협업

테스트에서 실제 의존성을 대체하는 객체들:

종류설명예시
Mock호출 여부/인수를 검증할 수 있는 가짜 객체jest.fn()
Stub미리 정해진 값을 반환하는 가짜 객체mockResolvedValue(fixedData)
Spy실제 객체를 감싸서 호출을 감시jest.spyOn(service, 'method')
Fake단순화된 실제 구현 (인메모리 DB 등)SQLite를 PostgreSQL 대신 사용
특징JestMochaVitest
설정제로 설정설정 필요제로 설정
Mock내장별도 라이브러리내장
속도빠름보통매우 빠름
NestJS 기본
추천NestJS 프로젝트Express/전통 NodeVite 기반 프로젝트

“테스트에서 의존성을 어떻게 처리하는가”에 대한 근본적 견해 차이:

구분Classicist (Chicago School)Mockist (London School)
주도자Kent Beck (TDD 창시자)Steve Freeman, Nat Pryce
의존성 처리실제 객체 사용 (진짜 DB, 실제 서비스)모든 의존성을 Mock으로 대체
테스트 속도느림 (실제 IO)빠름 (메모리)
약점외부 시스템 설정 복잡구현 세부사항 변경 시 Mock 수정 비용
NestJS 적용SQLite 인메모리 + TypeORMjest.fn(), mockResolvedValue()

실무 판단 기준:

비즈니스 로직 (계산, 상태 전환) → Classicist (실제 결과 검증)
외부 API / SQS / 이메일 발송 → Mockist (IO 없이 빠른 피드백)
DB 레이어 → 팀 선택 (테스트 DB vs Repository Mock)

NestJS 공식 문서는 두 방식 모두 소개한다. 중요한 것은 팀 내 일관성.


문제 1: 테스트 간 DB 상태 오염 (격리 실패)

섹션 제목: “문제 1: 테스트 간 DB 상태 오염 (격리 실패)”

증상:

● UserService (Integration) › 유저 조회 테스트
Expected: { id: 1, name: '홍길동' }
Received: { id: 3, name: '홍길동' } ← ID가 다르게 나온다
# 또는: 이전 테스트에서 생성한 데이터가 다음 테스트에 영향을 준다

원인: 테스트 A에서 INSERT한 데이터가 테스트 B에서도 남아있다. beforeEach에서 DB를 초기화하지 않았거나, Auto Increment ID가 예상과 다르다.

해결 방법:

// 각 테스트 후 데이터 정리
afterEach(async () => {
await userRepository.query("DELETE FROM users");
// PostgreSQL의 경우 시퀀스도 초기화
await userRepository.query("ALTER SEQUENCE users_id_seq RESTART WITH 1");
});
// 또는 트랜잭션 롤백 방식
let queryRunner: QueryRunner;
beforeEach(async () => {
queryRunner = dataSource.createQueryRunner();
await queryRunner.startTransaction();
});
afterEach(async () => {
await queryRunner.rollbackTransaction(); // 테스트 후 롤백
await queryRunner.release();
});

문제 2: Jest 메모리 누수 (Worker 메모리 초과)

섹션 제목: “문제 2: Jest 메모리 누수 (Worker 메모리 초과)”

증상:

FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory
# 또는 테스트가 점점 느려지다가 중단됨
<--- Last few GCs --->
[12345:0x...] Mark-Compact (reduce) ...

원인: NestJS 테스트 모듈이 afterAll에서 제대로 닫히지 않아 메모리가 누수된다. 대용량 Mock 데이터, 미처리된 Promise, 닫히지 않은 DB 커넥션이 원인이다.

해결 방법:

// 1. 반드시 모듈 종료
afterAll(async () => {
await app.close(); // NestJS 앱 종료
await module.close(); // 테스트 모듈 종료
});
// 2. Jest 설정으로 메모리 제한 및 워커 격리
// jest.config.js
module.exports = {
// 각 테스트 파일을 별도 워커에서 실행
runInBand: false,
maxWorkers: '50%',
// 워커당 최대 메모리 (MB)
workerIdleMemoryLimit: '512MB',
};
// 3. CLI에서 메모리 제한 늘리기
// package.json
{
"scripts": {
"test": "node --max-old-space-size=4096 node_modules/.bin/jest"
}
}

문제 3: 비동기 테스트 타임아웃

섹션 제목: “문제 3: 비동기 테스트 타임아웃”

증상:

Timeout - Async callback was not invoked within the 5000 ms timeout
specified by jest.setTimeout.
● UserService › 유저 생성 테스트
Timeout - Async callback was not invoked within timeout.

원인:

  • await를 빠뜨려서 Promise가 완료되기 전에 테스트가 종료된다
  • Testcontainers가 시작되는 데 시간이 오래 걸린다
  • 실제 외부 API를 호출해서 느리다

해결 방법:

// 원인 1: await 누락
// ❌ 잘못된 코드
it("유저를 생성해야 한다", () => {
service.createUser({ name: "홍길동" }); // await 빠짐!
expect(something).toBe(true);
});
// ✅ 올바른 코드
it("유저를 생성해야 한다", async () => {
await service.createUser({ name: "홍길동" }); // await 필수
expect(something).toBe(true);
});
// 원인 2: Testcontainers 타임아웃
// jest.config.js
module.exports = {
testTimeout: 30000, // 기본 5초 → 30초로 증가
};
// 또는 개별 테스트에 적용
describe("Integration Test", () => {
jest.setTimeout(60000); // 이 describe 블록만 60초
beforeAll(async () => {
container = await new PostgreSqlContainer().start(); // 최대 60초
});
});
// 원인 3: 외부 API 호출 → Mock으로 대체
jest.mock("@aws-sdk/client-sqs"); // AWS SDK 전체 Mock

문제 4: Flaky 테스트 — 같은 코드인데 때때로 실패

섹션 제목: “문제 4: Flaky 테스트 — 같은 코드인데 때때로 실패”

증상:

CI에서 동일 커밋인데 실행할 때마다 결과가 다르다.
로컬에서는 항상 통과하는데 CI에서만 가끔 실패한다.
"Re-run jobs" 버튼을 누르면 통과한다.
팀에서 "그냥 다시 돌려봐"가 일상이 된다.

원인: Flaky 테스트의 주요 원인은 세 가지다.

  1. 시간 의존성: Date.now(), setTimeout 등 실행 환경에 따라 결과가 달라지는 코드
  2. 실행 순서 의존성: 테스트 A가 먼저 실행되어야 테스트 B가 통과하는 숨겨진 의존
  3. 외부 리소스 의존성: 테스트 DB, 외부 API의 네트워크 상태에 따라 불안정

해결 방법:

// 원인 1 해결: 시간을 Mock으로 고정
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date("2025-01-01T00:00:00Z"));
});
afterEach(() => {
jest.useRealTimers();
});
// 원인 2 해결: 테스트 간 독립성 보장
// jest.config.js
module.exports = {
randomize: true, // 테스트 실행 순서를 랜덤화 → 숨겨진 의존 발견
};
// 원인 3 해결: 외부 의존은 반드시 Mock
// ❌ 실제 외부 API 호출 (네트워크 상태에 따라 실패)
const result = await axios.get("https://external-api.com/data");
// ✅ Mock으로 대체
jest.spyOn(httpService, "get").mockResolvedValue({ data: mockData });

📖 더 보기: Flaky Tests at Google and How We Mitigate Them — Google Testing Blog — 대규모 CI에서 flaky 테스트가 의사결정 신호를 어떻게 오염시키는지 설명한 사례 (중급)

Google의 대규모 테스트 분석을 기준으로 보면 flaky 테스트는 단순한 불편함이 아니라 의사결정 신호를 오염시킨다. 같은 코드에서 pass와 fail이 모두 나오는 테스트가 많아지면 “다시 돌리면 되겠지”가 습관이 되고, 실제 회귀도 false positive처럼 취급된다. Google은 1000개 테스트가 있는 프로젝트에서 flaky 실행률 1.5%면 약 15개 테스트가 실패해 조사 비용이 생길 수 있다고 설명한다 (출처: https://testing.googleblog.com/2016/05/flaky-tests-at-google-and-how-we.html). 따라서 flaky 테스트를 발견했을 때의 결정 기준은 “재실행해서 통과했는가”가 아니라 “이 테스트를 critical path에 둘 만큼 신뢰할 수 있는가”다. 동일 커밋에서 3회 중 1회라도 실패하면 quarantine 후보로 분리하고, production race condition을 가리는지 확인한 뒤 원인을 고정해야 한다.


문제 5: TypeScript 타입 오류로 Mock 생성 실패

섹션 제목: “문제 5: TypeScript 타입 오류로 Mock 생성 실패”

증상:

Type '{ findOne: jest.Mock<any, any>; }' is missing the following
properties from type 'Repository<User>': manager, metadata, ...

원인: TypeORM Repository는 수십 개의 메서드가 있는데, 일부만 Mock으로 정의하면 TypeScript 타입이 맞지 않는다.

해결 방법:

// 방법 1: Partial<Repository<User>>로 타입 완화
const mockRepository = {
findOne: jest.fn(),
save: jest.fn(),
} as unknown as Repository<User>; // as unknown as 사용
// 방법 2: jest-mock-extended 라이브러리 사용 (권장)
import { mock } from "jest-mock-extended";
import { Repository } from "typeorm";
const mockRepository = mock<Repository<User>>();
// 모든 메서드가 자동으로 Mock 처리됨
mockRepository.findOne.mockResolvedValue(null);

문제 6: E2E 테스트에서 JWT 인증 토큰 처리

섹션 제목: “문제 6: E2E 테스트에서 JWT 인증 토큰 처리”

증상:

● OrderController (e2e) › POST /orders › 주문을 생성해야 한다
Expected: 201
Received: 401
# 또는
Cannot read properties of undefined (reading 'userId')
at JwtAuthGuard.canActivate

원인: E2E 테스트에서 실제 JWT Guard가 동작하여 인증 없는 요청을 거부한다. 테스트마다 실제 JWT를 발급하거나 Guard를 교체하지 않으면 인증이 필요한 모든 엔드포인트 테스트가 실패한다.

해결 방법:

test/order.e2e-spec.ts
import { Test, TestingModule } from "@nestjs/testing";
import { INestApplication } from "@nestjs/common";
import * as request from "supertest";
import { JwtAuthGuard } from "../src/auth/jwt-auth.guard";
describe("OrderController (e2e)", () => {
let app: INestApplication;
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
})
// 방법 1: Guard를 항상 통과하는 Mock으로 교체
.overrideGuard(JwtAuthGuard)
.useValue({
canActivate: (context) => {
// request 객체에 테스트용 사용자 정보 주입
const req = context.switchToHttp().getRequest();
req.user = { userId: "test-user-123", email: "[email protected]" };
return true; // 항상 인증 통과
},
})
.compile();
app = moduleFixture.createNestApplication();
await app.init();
});
afterAll(async () => {
await app.close();
});
it("POST /orders - 주문을 생성해야 한다", async () => {
const response = await request(app.getHttpServer())
.post("/orders")
.send({ items: [{ productId: "prod-1", quantity: 2 }] })
.expect(201); // Guard Mock이 있으므로 401 아닌 201
expect(response.body).toMatchObject({
orderId: expect.any(String),
status: "pending",
});
});
});
// 방법 2: 실제 JWT를 발급해서 테스트 (더 현실적인 E2E)
describe("OrderController (e2e) - Real JWT", () => {
let app: INestApplication;
let jwtToken: string;
beforeAll(async () => {
// ...앱 초기화...
// 로그인 API로 실제 토큰 발급
const loginResponse = await request(app.getHttpServer())
.post("/auth/login")
.send({ email: "[email protected]", password: "test-password" });
jwtToken = loginResponse.body.accessToken;
});
it("인증된 사용자는 주문을 생성할 수 있다", () => {
return request(app.getHttpServer())
.post("/orders")
.set("Authorization", `Bearer ${jwtToken}`) // 실제 토큰 사용
.send({ items: [{ productId: "prod-1", quantity: 1 }] })
.expect(201);
});
});
// Guard Mock 적용 시 테스트 결과
PASS test/order.e2e-spec.ts
OrderController (e2e)
✓ POST /orders - 주문을 생성해야 한다 (87ms)
Test Suites: 1 passed, 1 total
Tests: 1 passed, 1 total

선택 기준: Guard Mock 방식은 빠르고 단순하지만 인증 로직 자체는 검증하지 않는다. 인증 플로우까지 검증해야 한다면 실제 JWT 발급 방식을 사용한다. 일반적으로 인증 로직은 별도 Unit Test로, 비즈니스 로직 E2E는 Guard Mock으로 분리한다.


  • TDD Red-Green-Refactor 사이클을 설명할 수 있다
  • jest.fn(), mockResolvedValue(), toHaveBeenCalledWith() 차이를 안다
  • Unit Test에서 TypeORM Repository를 Mock으로 대체할 수 있다
  • 테스트 피라미드의 각 레벨 비율과 이유를 설명할 수 있다
  • Supertest로 E2E 테스트를 작성할 수 있다
  • Testcontainers로 실제 DB를 사용하는 Integration Test를 설정할 수 있다
  • GitHub Actions에서 자동 테스트 파이프라인을 구성할 수 있다
  • 현재 프로젝트의 핵심 비즈니스 로직 Service에 Unit Test 추가
  • npm test -- --coverage 실행 후 커버리지 확인
  • CI 파이프라인에 테스트 단계 추가
  • 테스트 실패 시 PR 머지 차단 설정

키워드한 줄 설명
TDD테스트를 먼저 작성하고 코드를 나중에 작성하는 개발 방법론
Red-Green-RefactorTDD의 3단계 사이클: 실패 → 통과 → 개선
테스트 피라미드Unit(70%) > Integration(20%) > E2E(10%) 비율 권고
Unit Test함수/클래스 단위 테스트, 의존성은 Mock으로 대체
Integration Test실제 DB/인프라와 연동한 모듈 간 동작 검증
E2E TestHTTP 요청부터 응답까지 전체 흐름 검증
Mock실제 의존성을 대체하는 가짜 객체, 호출 검증 가능
Stub미리 정해진 값을 반환하는 가짜 객체
jest.fn()Jest의 Mock 함수 생성자
mockResolvedValueasync 함수의 반환값을 설정하는 Mock 메서드
SupertestHTTP 서버 없이 HTTP 요청을 테스트하는 라이브러리
TestcontainersDocker로 테스트용 DB/Redis를 실행하는 라이브러리
Coverage테스트가 실행한 코드의 비율
Test Isolation테스트 간 상태 공유를 막아 독립성을 보장하는 원칙


Terminal window
# NestJS 프로젝트에서 테스트 의존성 확인
cat package.json | grep -E '"jest|@nestjs/testing|supertest|testcontainers'

예상 출력:

"@nestjs/testing": "^10.0.0",
"jest": "^29.0.0",
"supertest": "^6.0.0",
"@testcontainers/postgresql": "^10.0.0",
"ts-jest": "^29.0.0"
Terminal window
# 단일 파일 테스트
npx jest src/user/user.service.spec.ts --verbose
# 전체 Unit Test
npm test
# Watch 모드 (파일 변경 감지)
npm run test:watch

예상 출력:

PASS src/user/user.service.spec.ts
UserService (Unit)
getUserById
✓ DB에서 유저를 찾으면 반환해야 한다 (5ms)
✓ DB에 유저가 없으면 null을 반환해야 한다 (1ms)
Test Suites: 1 passed, 1 total
Tests: 2 passed, 2 total
Snapshots: 0 total
Time: 1.234s
Ran all test suites matching /user.service.spec.ts/
Terminal window
npm test -- --coverage

예상 출력:

PASS src/user/user.service.spec.ts
PASS src/order/order.service.spec.ts
----------|---------|----------|---------|---------|
File | % Stmts | % Branch | % Funcs | % Lines |
----------|---------|----------|---------|---------|
All files | 72.45 | 65.32 | 80.00 | 73.12 |
user/ | | | | |
service | 100.00 | 100.00 | 100.00 | 100.00 | ← 핵심 로직 100%
entity | 50.00 | 100 | 50.00 | 50.00 |
order/ | | | | |
service | 85.71 | 83.33 | 88.89 | 85.71 |
----------|---------|----------|---------|---------|
Terminal window
npm run test:e2e

예상 출력:

PASS test/user.e2e-spec.ts (4.521s)
UserController (E2E)
GET /users/:id
✓ 200: 유저 조회 성공 (156ms)
✓ 404: 존재하지 않는 유저 조회 (43ms)
POST /users
✓ 201: 유저 생성 성공 (89ms)
✓ 400: 이메일 누락 시 유효성 검사 실패 (38ms)
Test Suites: 1 passed, 1 total
Tests: 4 passed, 4 total
Time: 4.521s
Terminal window
# Docker가 실행 중인지 확인
docker ps
# Integration Test 실행 (시간이 더 걸림)
npx jest --testPathPattern="integration" --testTimeout=60000 --runInBand

예상 출력:

[+] Starting PostgreSQL container...
[Testcontainers] Starting container postgresql:15...
[Testcontainers] Container started in 8.432s
PASS src/user/user.integration.spec.ts (12.341s)
UserService (Integration)
✓ 유저를 저장하고 조회할 수 있어야 한다 (421ms)
✓ 중복 이메일로 가입 시 에러가 발생해야 한다(87ms)
[Testcontainers] Stopping container...
Test Suites: 1 passed, 1 total
Tests: 2 passed, 2 total
Time: 12.341s

TDD로 요구사항을 테스트로 정의하고, 테스트 피라미드 구조로 Unit(70%) → Integration(20%) → E2E(10%) 계층별 테스트를 구성하면, 빠른 피드백과 안정적인 배포가 가능하다.