Testing Strategy
분류: Layer 9 - 아키텍처 & 설계 패턴
Testing Strategy (테스팅 전략)
섹션 제목: “Testing Strategy (테스팅 전략)”1. 한 줄 정의
섹션 제목: “1. 한 줄 정의”소프트웨어가 의도한 대로 동작하는지 자동으로 검증하는 체계적 방법론으로, TDD와 테스트 피라미드를 기반으로 Unit → Integration → E2E 계층별로 테스트를 구성한다.
2. 왜 중요한가
섹션 제목: “2. 왜 중요한가”테스트 없는 코드의 현실
섹션 제목: “테스트 없는 코드의 현실”코드는 처음 작성할 때보다 읽고 수정하는 시간이 훨씬 더 많다. 테스트가 없으면 다음 문제가 발생한다:
- 회귀 버그(Regression Bug): 새 기능을 추가했더니 기존 기능이 깨진다. 언제 깨졌는지 모른다.
- 리팩터링 공포: “이 코드를 바꾸면 어디가 터질지 모르니 그냥 두자” 심리가 생긴다.
- 배포 두려움: 수동으로 확인해야 하는 항목이 많아져서 배포 속도가 느려진다.
- 온보딩 어려움: 새로운 팀원이 코드의 의도를 파악하기 어렵다.
테스트가 주는 실질적 가치
섹션 제목: “테스트가 주는 실질적 가치”| 가치 | 설명 |
|---|---|
| 빠른 피드백 | 코드 변경 즉시 수백 개의 테스트가 자동 실행된다 |
| 살아있는 문서 | 테스트 코드는 “이 함수가 어떻게 동작해야 하는지” 설명하는 예시다 |
| 설계 개선 | 테스트하기 어려운 코드 = 결합도가 높은 코드. 테스트가 설계를 개선한다 |
| 배포 자신감 | CI에서 모든 테스트 통과 확인 후 배포하면 두려움이 줄어든다 |
3. 핵심 개념
섹션 제목: “3. 핵심 개념”프론트엔드 브릿지: 프론트에서 Jest + RTL(React Testing Library)로 컴포넌트를 테스트했다면, 백엔드 테스트는 그것과 동일한 Jest 위에서 동작한다.
render(<Button />)대신request(app).post('/users')로 API를 호출하고,screen.getByText()대신expect(response.body).toEqual()로 결과를 검증한다. 도구 체계는 같고 테스트 대상이 컴포넌트에서 HTTP 엔드포인트로 바뀐 것이다.
3.1 TDD (Test Driven Development)
섹션 제목: “3.1 TDD (Test Driven Development)”비유: 건축 설계도 먼저 그리기
섹션 제목: “비유: 건축 설계도 먼저 그리기”일반적인 개발은 집을 짓고 나서 “이게 맞나?” 확인한다. 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 서비스에 적용하는 과정 (중급)
Nest.js에서 TDD 실습
섹션 제목: “Nest.js에서 TDD 실습”Step 1: 서비스 생성 (spec 파일이 자동 생성됨)
nest g service user# 생성되는 파일:# src/user/user.service.spec.ts ← 이것부터 먼저 작성!Step 2: Red - 실패하는 테스트 작성
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(); }); });});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 totalTests: 2 failed, 0 totalStep 3: Green - 테스트를 통과시키는 최소한의 구현
import { Injectable } from "@nestjs/common";
@Injectable()export class UserService { // 일단 하드코딩으로 통과시킨다 (DB 연결은 나중에) private readonly users = [ { id: 1, name: "홍길동", email: "hong@example.com" }, ];
async getUserById(id: number) { return this.users.find((u) => u.id === id) ?? null; }}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 totalTests: 2 passed, 2 totalTime: 1.234sStep 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 } }); }}3.2 테스트 피라미드
섹션 제목: “3.2 테스트 피라미드”비유: 피라미드 건물
섹션 제목: “비유: 피라미드 건물”피라미드는 바닥이 넓고 꼭대기가 좁다. 테스트도 마찬가지다. 바닥의 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%): 실행 수 분, 핵심 시나리오만 커버| 테스트 유형 | 실행 시간 | 실패 원인 특정 | 유지보수 비용 | 안정성(Flaky 위험) |
|---|---|---|---|---|
| Unit | ~1ms/건 | 매우 쉬움 | 낮음 | 높음(안정적) |
| Integration | ~1s/건 | 보통 | 중간 | 중간 |
| E2E | ~5s/건 | 어려움 | 높음 | 낮음(불안정) |
Unit Test (단위 테스트) — 70%
섹션 제목: “Unit Test (단위 테스트) — 70%”무엇을 테스트하는가: 하나의 함수, 하나의 클래스 메서드. 외부 의존성(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: "홍길동", email: "hong@example.com", } 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(); }); });});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 totalTests: 2 passed, 2 totalTime: 0.892sIntegration 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: "홍길동", email: "hong@example.com", }); const found = await service.getUserById(saved.id);
expect(found).toBeDefined(); expect(found?.name).toBe("홍길동"); });});npx jest user.integration.spec.ts --testTimeout=60000예상 출력:
PASS src/user/user.integration.spec.ts UserService (Integration) ✓ 유저를 저장하고 조회할 수 있어야 한다 (3421ms)
Test Suites: 1 passed, 1 totalTests: 1 passed, 1 totalTime: 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: "test@example.com" }) .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); }); });});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 totalTests: 4 passed, 4 totalTime: 4.521s3.3 테스트 도구
섹션 제목: “3.3 테스트 도구”Jest (기본 테스트 프레임워크)
섹션 제목: “Jest (기본 테스트 프레임워크)”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",};Supertest (HTTP E2E 테스트)
섹션 제목: “Supertest (HTTP E2E 테스트)”실제 HTTP 서버 없이도 HTTP 요청을 시뮬레이션할 수 있는 라이브러리. NestJS E2E 테스트에서 표준으로 사용한다.
npm install --save-dev supertest @types/supertestTestcontainers (실제 DB/Redis Integration 테스트)
섹션 제목: “Testcontainers (실제 DB/Redis Integration 테스트)”Docker 컨테이너를 코드로 제어해서 테스트용 실제 DB를 실행한다. 테스트가 끝나면 자동으로 컨테이너를 종료한다.
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:327683.6 AWS SQS/SNS 연동 코드의 테스트 전략
섹션 제목: “3.6 AWS SQS/SNS 연동 코드의 테스트 전략”왜 AWS SDK 테스트가 까다로운가
섹션 제목: “왜 AWS SDK 테스트가 까다로운가”NestJS 서비스가 AWS SQS 메시지를 수신·발행하는 코드를 테스트할 때 실제 AWS 계정에 의존하면 세 가지 문제가 생긴다: 테스트 비용 발생, 네트워크 의존으로 인한 Flaky 테스트, CI 환경에서 IAM 권한 관리 복잡도. 이를 해결하는 방법은 두 가지다.
방법 1: aws-sdk-client-mock (단위 테스트 권장)
npm install --save-dev @aws-sdk/client-sqs aws-sdk-client-mockimport { 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 totalTests: 2 passed, 2 totalTime: 1.234s방법 2: LocalStack (통합 테스트 권장)
LocalStack은 AWS 서비스(SQS, SNS, S3 등)를 로컬 Docker 컨테이너에서 에뮬레이션한다. Testcontainers와 연계하면 CI 환경에서 실제 AWS 없이 통합 테스트를 실행할 수 있다.
npm install --save-dev @testcontainers/localstackimport { 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 totalTests: 1 passed, 1 totalTime: 18.456s ← LocalStack 시작 시간 포함단위 테스트 vs 통합 테스트 선택 기준
섹션 제목: “단위 테스트 vs 통합 테스트 선택 기준”| 상황 | 권장 방법 |
|---|---|
| SQS 호출 파라미터 검증 | aws-sdk-client-mock (빠름, 단위 테스트) |
| 메시지 발행 → 소비 흐름 검증 | LocalStack + Testcontainers (통합 테스트) |
| CI/CD 파이프라인 | 단위 테스트만 기본, 통합 테스트는 별도 스테이지 |
| 로컬 개발 빠른 피드백 | aws-sdk-client-mock 우선 |
📖 더 보기: aws-sdk-client-mock 공식 문서 — AWS SDK v3 Mock 라이브러리 사용법과 NestJS 적용 예제 (입문~중급)
4. 실무에서 어떻게 쓰이나
섹션 제목: “4. 실무에서 어떻게 쓰이나”어디부터 테스트를 작성할 것인가
섹션 제목: “어디부터 테스트를 작성할 것인가”모든 코드에 테스트를 처음부터 100% 작성하는 것은 비현실적이다. 우선순위를 정한다:
1순위: 비즈니스 로직이 담긴 Service 클래스 → 돈, 포인트, 주문 상태 변경 등 핵심 로직 → 버그 발생 시 비즈니스 손실이 큰 부분
2순위: 복잡한 유틸리티 함수 → 날짜 계산, 데이터 변환, 유효성 검사 로직
3순위: Controller (E2E 테스트로 커버) → DTO 유효성 검사, HTTP 상태 코드 확인
4순위 (낮음): 단순 CRUD Repository → TypeORM이 이미 검증된 라이브러리이므로 → Integration Test에서 함께 검증 가능CI에서 테스트 자동화 (GitHub Actions)
섹션 제목: “CI에서 테스트 자동화 (GitHub Actions)”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 테스트로 커버리지 올리기# 커버리지 임계값 설정 (jest.config.js)coverageThreshold: { global: { branches: 60, functions: 70, lines: 70, statements: 70, }, // 특정 파일은 더 엄격하게 './src/user/user.service.ts': { branches: 100, functions: 100, lines: 100, },},5. 내 업무와의 연결고리
섹션 제목: “5. 내 업무와의 연결고리”BackOps 엔지니어로서 실제 적용 포인트:
Nest.js 서비스 레이어
섹션 제목: “Nest.js 서비스 레이어”일반적인 BackOps 서비스:- 주문 상태 변경 로직 (OrderService.updateStatus) → 상태 전환 규칙이 복잡 → Unit Test 필수- 정산 계산 로직 (SettlementService.calculate) → 금액 계산 오류는 치명적 → TDD로 작성- 알림 발송 로직 (NotificationService.send) → 외부 API Mock 처리 → Unit Test 적합AWS 연동 서비스 테스트
섹션 제목: “AWS 연동 서비스 테스트”// 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); });});6. 비슷한 개념과 비교
섹션 제목: “6. 비슷한 개념과 비교”BDD vs TDD
섹션 제목: “BDD vs TDD”| 구분 | TDD | BDD |
|---|---|---|
| 초점 | 구현 테스트 | 행동/시나리오 테스트 |
| 언어 | 기술적 표현 (getUserById) | 비즈니스 언어 (주문을 생성하면 재고가 감소한다) |
| 도구 | Jest | Jest + jest-cucumber, Jasmine |
| 적합 | 개발자 내부 품질 | 기획자-개발자 협업 |
테스트 더블 종류
섹션 제목: “테스트 더블 종류”테스트에서 실제 의존성을 대체하는 객체들:
| 종류 | 설명 | 예시 |
|---|---|---|
| Mock | 호출 여부/인수를 검증할 수 있는 가짜 객체 | jest.fn() |
| Stub | 미리 정해진 값을 반환하는 가짜 객체 | mockResolvedValue(fixedData) |
| Spy | 실제 객체를 감싸서 호출을 감시 | jest.spyOn(service, 'method') |
| Fake | 단순화된 실제 구현 (인메모리 DB 등) | SQLite를 PostgreSQL 대신 사용 |
Jest vs Mocha vs Vitest
섹션 제목: “Jest vs Mocha vs Vitest”| 특징 | Jest | Mocha | Vitest |
|---|---|---|---|
| 설정 | 제로 설정 | 설정 필요 | 제로 설정 |
| Mock | 내장 | 별도 라이브러리 | 내장 |
| 속도 | 빠름 | 보통 | 매우 빠름 |
| NestJS 기본 | ✅ | ❌ | ❌ |
| 추천 | NestJS 프로젝트 | Express/전통 Node | Vite 기반 프로젝트 |
6.5. 트러블슈팅
섹션 제목: “6.5. 트러블슈팅”문제 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.jsmodule.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 timeoutspecified 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.jsmodule.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 테스트의 주요 원인은 세 가지다.
- 시간 의존성:
Date.now(),setTimeout등 실행 환경에 따라 결과가 달라지는 코드 - 실행 순서 의존성: 테스트 A가 먼저 실행되어야 테스트 B가 통과하는 숨겨진 의존
- 외부 리소스 의존성: 테스트 DB, 외부 API의 네트워크 상태에 따라 불안정
해결 방법:
// 원인 1 해결: 시간을 Mock으로 고정beforeEach(() => { jest.useFakeTimers(); jest.setSystemTime(new Date("2025-01-01T00:00:00Z"));});afterEach(() => { jest.useRealTimers();});
// 원인 2 해결: 테스트 간 독립성 보장// jest.config.jsmodule.exports = { randomize: true, // 테스트 실행 순서를 랜덤화 → 숨겨진 의존 발견};
// 원인 3 해결: 외부 의존은 반드시 Mock// ❌ 실제 외부 API 호출 (네트워크 상태에 따라 실패)const result = await axios.get("https://external-api.com/data");
// ✅ Mock으로 대체jest.spyOn(httpService, "get").mockResolvedValue({ data: mockData });📖 더 보기: Best Practices for Testing NestJS Applications (Toxigon) — Flaky 테스트 방지와 테스트 격리 전략 (입문~중급)
문제 5: TypeScript 타입 오류로 Mock 생성 실패
섹션 제목: “문제 5: TypeScript 타입 오류로 Mock 생성 실패”증상:
Type '{ findOne: jest.Mock<any, any>; }' is missing the followingproperties 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를 교체하지 않으면 인증이 필요한 모든 엔드포인트 테스트가 실패한다.
해결 방법:
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: "test@example.com" }; 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: "test@example.com", 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 totalTests: 1 passed, 1 total선택 기준: Guard Mock 방식은 빠르고 단순하지만 인증 로직 자체는 검증하지 않는다. 인증 플로우까지 검증해야 한다면 실제 JWT 발급 방식을 사용한다. 일반적으로 인증 로직은 별도 Unit Test로, 비즈니스 로직 E2E는 Guard Mock으로 분리한다.
7. 체크리스트
섹션 제목: “7. 체크리스트”학습 체크리스트
섹션 제목: “학습 체크리스트”- 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 머지 차단 설정
8. 핵심 키워드
섹션 제목: “8. 핵심 키워드”| 키워드 | 한 줄 설명 |
|---|---|
| TDD | 테스트를 먼저 작성하고 코드를 나중에 작성하는 개발 방법론 |
| Red-Green-Refactor | TDD의 3단계 사이클: 실패 → 통과 → 개선 |
| 테스트 피라미드 | Unit(70%) > Integration(20%) > E2E(10%) 비율 권고 |
| Unit Test | 함수/클래스 단위 테스트, 의존성은 Mock으로 대체 |
| Integration Test | 실제 DB/인프라와 연동한 모듈 간 동작 검증 |
| E2E Test | HTTP 요청부터 응답까지 전체 흐름 검증 |
| Mock | 실제 의존성을 대체하는 가짜 객체, 호출 검증 가능 |
| Stub | 미리 정해진 값을 반환하는 가짜 객체 |
| jest.fn() | Jest의 Mock 함수 생성자 |
| mockResolvedValue | async 함수의 반환값을 설정하는 Mock 메서드 |
| Supertest | HTTP 서버 없이 HTTP 요청을 테스트하는 라이브러리 |
| Testcontainers | Docker로 테스트용 DB/Redis를 실행하는 라이브러리 |
| Coverage | 테스트가 실행한 코드의 비율 |
| Test Isolation | 테스트 간 상태 공유를 막아 독립성을 보장하는 원칙 |
8.5. 추천 리소스
섹션 제목: “8.5. 추천 리소스”📚 추천 리소스
섹션 제목: “📚 추천 리소스”- 📖 NestJS 공식 Testing 문서 — NestJS 테스팅 모듈 설정, Mock 주입 방법 공식 가이드. 가장 먼저 봐야 할 문서 (입문)
- 📖 Jest 공식 문서 —
expect,mock,spyAPI 레퍼런스. NestJS와 독립적으로 Jest 자체를 이해하는 데 필수 (입문) - 📖 Applying TDD with NestJS — Trilon Consulting — NestJS 창시자 팀(Trilon)이 작성한 TDD 실전 가이드. Red-Green-Refactor를 NestJS 서비스에 적용하는 과정 (중급)
- 📖 Testcontainers Node.js 공식 문서 — PostgreSQL, Redis 컨테이너별 설정 방법과 NestJS Integration Test 예제 포함 (중급)
- 📖 Best Practices for Testing NestJS Applications in 2025 — Toxigon — 2025년 기준 NestJS 테스트 모범 사례 종합 정리. Flaky 테스트 방지, CI 최적화 전략 포함 (입문~중급)
9. 직접 확인해보기
섹션 제목: “9. 직접 확인해보기”환경 설정 확인
섹션 제목: “환경 설정 확인”# 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"Unit Test 실행
섹션 제목: “Unit Test 실행”# 단일 파일 테스트npx jest src/user/user.service.spec.ts --verbose
# 전체 Unit Testnpm 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 totalTests: 2 passed, 2 totalSnapshots: 0 totalTime: 1.234sRan all test suites matching /user.service.spec.ts/커버리지 측정
섹션 제목: “커버리지 측정”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 |----------|---------|----------|---------|---------|E2E 테스트 실행
섹션 제목: “E2E 테스트 실행”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 totalTests: 4 passed, 4 totalTime: 4.521sTestcontainers Integration Test 실행
섹션 제목: “Testcontainers Integration Test 실행”# 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 totalTests: 2 passed, 2 totalTime: 12.341s10. 한 줄 요약
섹션 제목: “10. 한 줄 요약”TDD로 요구사항을 테스트로 정의하고, 테스트 피라미드 구조로 Unit(70%) → Integration(20%) → E2E(10%) 계층별 테스트를 구성하면, 빠른 피드백과 안정적인 배포가 가능하다.