Guider/Backend/BackendDevGuide0006
Backend#06

BackendDevGuide0006

테스트 심화와 TDD 완전 정복

🧪

BackendDevGuide0006

테스트 심화와 TDD 완전 정복 A-Z

비전공자도 OK! 버그 없는 코드를 만드는 테스트의 모든 것

⏱ 예상 학습: 2~3주 🎯 버그 80% 감소 💼 현업 필수 역량

💡 왜 테스트를 배워야 하나요?

80%
테스트 작성 시 버그 감소율
6x
버그 수정 비용 (출시 후 vs 개발 중)
40%
TDD 적용 시 개발 속도 향상
필수
대기업/스타트업 코드 리뷰 기준

📚 학습 목차 — 12가지 핵심 주제

01 테스트란? 왜 필요한가 — 핵심 개념
02 Jest 기초 완전 정복 — 설치와 기본 문법
03 Matcher 완전 정복 — 모든 검증 방법
04 Mock / Spy / Stub — 가짜 객체 완전 정복
05 NestJS Unit Test — 서비스/컨트롤러 테스트
06 E2E Test — 실제 HTTP 요청 통합 테스트
07 TDD 란? — Red-Green-Refactor 사이클
08 TDD 실전 — API 기능을 테스트 먼저 작성
09 테스트 커버리지 — 측정과 목표 설정
10 DB 테스트 — Prisma Mock + 인메모리 DB
11 CI/CD 파이프라인에 테스트 통합
12 면접 Q&A + 실무 체크리스트 + 로드맵

🔬 01. 테스트란? 왜 필요한가

테스트 코드는 "내가 작성한 코드가 예상대로 동작하는지 자동으로 검증하는 또 다른 코드"입니다. 음식점 비유: 음식을 손님에게 내기 전에 주방에서 맛을 보는 것!

🍽️ 테스트가 없는 세상 vs 있는 세상

😱 테스트 없는 개발

  • 새 기능 추가 → 기존 기능 망가짐
  • 배포 후 버그 발견 → 늦은 밤 긴급 수정
  • 리팩토링이 두려워서 묵은 코드 방치
  • 팀원 코드 수정 시 불안함
  • "이게 왜 되지?" 매직 코드 출현

😎 테스트 있는 개발

  • 새 기능 추가 → 자동으로 회귀 테스트
  • 배포 전 자동으로 모든 케이스 검증
  • 자신감 있게 리팩토링 가능
  • 팀원 코드도 믿고 수정 가능
  • 코드 의도가 테스트로 문서화됨
테스트 종류 범위 속도 목적 비율(권장)
Unit Test 함수/클래스 단독 ⚡ 매우 빠름 (ms) 로직 검증 70%
Integration Test 여러 모듈 연결 🔶 보통 (초) 모듈 간 협력 검증 20%
E2E Test 전체 앱 흐름 🐢 느림 (분) 사용자 시나리오 검증 10%

* 테스트 피라미드: Unit 많이, E2E 적게

⚙️ 02. Jest 기초 완전 정복

Jest는 Facebook이 만든 JavaScript/TypeScript 테스트 프레임워크입니다. NestJS 기본 내장!

💻 Jest 설치 및 기본 구조

# NestJS는 Jest 기본 내장 (별도 설치 불필요)
# 순수 Node.js/Express 프로젝트라면:
npm install --save-dev jest @types/jest ts-jest

# jest.config.js
module.exports = {
  moduleFileExtensions: ['js', 'json', 'ts'],
  rootDir: 'src',
  testRegex: '.*\.spec\.ts$',     // .spec.ts 파일을 테스트 파일로 인식
  transform: { '^.+\.(t|j)s$': 'ts-jest' },
  collectCoverageFrom: ['**/*.(t|j)s'],
  coverageDirectory: '../coverage',
  testEnvironment: 'node',
  coverageThreshold: {
    global: { branches: 80, functions: 80, lines: 80 }
  }
};

# 테스트 실행 명령어
npm test                     # 모든 테스트 실행
npm test -- --watch          # 변경 감지 모드
npm test -- --coverage       # 커버리지 포함
npm test -- users.spec       # 특정 파일만
npm run test:e2e             # E2E 테스트

💻 Jest 기본 문법 완전 가이드

// ===== 테스트 구조 =====
describe('UsersService', () => {         // 테스트 그룹
  
  describe('create()', () => {           // 중첩 그룹
    
    it('사용자를 성공적으로 생성한다', () => {  // 개별 테스트
      // Arrange (준비)
      const input = { name: '홍길동', email: 'hong@e.com' };
      
      // Act (실행)
      const result = createUser(input);
      
      // Assert (검증)
      expect(result.name).toBe('홍길동');
    });
    
    test('이메일 중복 시 에러를 던진다', () => { // it과 동일
      expect(() => createUser({ email: 'dup@e.com' }))
        .toThrow('이미 사용 중인 이메일');
    });
  });
});

// ===== 생명주기 훅 =====
beforeAll(async () => {     // 모든 테스트 전에 한 번
  await connectDB();
});

afterAll(async () => {      // 모든 테스트 후에 한 번
  await disconnectDB();
});

beforeEach(() => {            // 각 테스트 전
  jest.clearAllMocks();       // Mock 초기화 (매우 중요!)
});

afterEach(() => {             // 각 테스트 후
  // 상태 정리
});

// ===== 테스트 건너뛰기/집중 =====
it.skip('나중에 작성할 테스트', () => {...}); // 건너뜀
it.only('이것만 실행', () => {...});        // 이것만 실행
xit('비활성화', () => {...});              // 건너뜀

🎯 03. Matcher 완전 정복

Matcher는 expect()와 함께 쓰는 검증 함수입니다. 다양한 상황을 정확하게 검증할 수 있습니다.

💻 모든 Matcher 완전 가이드

// ===== 1. 동등 비교 =====
expect(1 + 1).toBe(2);              // 완전 동일 (===, 원시타입)
expect({ a: 1 }).toEqual({ a: 1 }); // 깊은 비교 (객체/배열에 사용)
expect(user).not.toBe(null);        // not으로 부정
expect(value).toStrictEqual(obj);    // 타입까지 엄격 비교

// ===== 2. Truthiness (참/거짓) =====
expect(true).toBeTruthy();            // truthy 값 (true, 1, "abc", {})
expect(false).toBeFalsy();           // falsy 값 (false, 0, "", null)
expect(null).toBeNull();             // null 인지
expect(undefined).toBeUndefined();   // undefined 인지
expect(1).toBeDefined();             // undefined가 아닌지

// ===== 3. 숫자 비교 =====
expect(5).toBeGreaterThan(3);        // 3 초과
expect(5).toBeGreaterThanOrEqual(5); // 5 이상
expect(3).toBeLessThan(5);           // 5 미만
expect(0.1 + 0.2).toBeCloseTo(0.3); // 부동소수점 비교 (toEqual 대신)

// ===== 4. 문자열 =====
expect('Hello World').toContain('World');  // 포함 여부
expect('hello').toMatch(/^hel/);           // 정규식 매칭
expect('abc').toHaveLength(3);             // 길이

// ===== 5. 배열/객체 =====
expect([1, 2, 3]).toContain(2);       // 배열에 포함
expect([1, 2, 3]).toHaveLength(3);    // 배열 길이
expect(user).toHaveProperty('email');   // 속성 존재
expect(user).toHaveProperty('role', 'ADMIN'); // 속성 값까지 확인
expect(users).toEqual(
  expect.arrayContaining([               // 일부 요소 포함
    expect.objectContaining({ id: 1 })   // 일부 속성 포함
  ])
);

// ===== 6. 비동기 테스트 =====
// Promise 방식
it('비동기 테스트', async () => {
  const user = await usersService.findOne(1);
  expect(user.id).toBe(1);
});

// Promise reject (에러) 테스트
it('없는 사용자 조회 시 에러', async () => {
  await expect(usersService.findOne(999))
    .rejects.toThrow('찾을 수 없습니다');
  
  // 또는
  await expect(usersService.findOne(999))
    .rejects.toBeInstanceOf(NotFoundException);
});

// ===== 7. 스냅샷 테스트 =====
it('응답 구조가 변경되지 않음', async () => {
  const user = await usersService.findOne(1);
  expect(user).toMatchSnapshot();  // 첫 실행에 스냅샷 저장, 이후 비교
});

🎭 04. Mock / Spy / Stub — 가짜 객체 완전 정복

테스트에서 외부 의존성(DB, API, 이메일 서비스)을 가짜로 대체하는 기법입니다. 빠르고 독립적인 테스트의 핵심!

🔍 Mock vs Stub vs Spy 차이

종류 역할 호출 기록 반환값 설정 사용 예
Mock 전체 객체/함수 대체 ✅ 기록 ✅ 가능 DB 서비스 전체 대체
Stub 고정된 값만 반환 ❌ 미기록 ✅ 가능 특정 API 응답 고정
Spy 실제 함수를 감시 ✅ 기록 ⚠️ 선택적 함수 호출 여부 확인

💻 Jest Mock 완전 가이드

// ===== 1. jest.fn() — 함수 Mock =====
const mockFn = jest.fn();

// 반환값 설정
mockFn.mockReturnValue('hello');             // 항상 'hello' 반환
mockFn.mockReturnValueOnce('first');         // 첫 번째 호출만
mockFn.mockResolvedValue({ id: 1 });         // Promise.resolve()
mockFn.mockResolvedValueOnce({ id: 1 });    // 첫 번째 호출만 resolve
mockFn.mockRejectedValue(new Error('에러')); // Promise.reject()
mockFn.mockImplementation((x) => x * 2);   // 구현 자체를 대체

// 호출 검증
expect(mockFn).toHaveBeenCalled();            // 호출됐는지
expect(mockFn).toHaveBeenCalledTimes(2);     // 2번 호출됐는지
expect(mockFn).toHaveBeenCalledWith('arg1'); // 특정 인수로 호출됐는지
expect(mockFn).toHaveBeenLastCalledWith(1); // 마지막 호출 인수

// ===== 2. jest.spyOn() — 실제 메서드 감시 =====
const emailService = new EmailService();
const sendSpy = jest.spyOn(emailService, 'sendMail');
sendSpy.mockResolvedValue(true); // 실제 이메일 발송 방지

// 테스트 실행 후
expect(sendSpy).toHaveBeenCalledWith(
  expect.objectContaining({ to: 'hong@e.com' })
);
sendSpy.mockRestore(); // 원래 함수로 복구

// ===== 3. 모듈 전체 Mock =====
jest.mock('../utils/email'); // 모듈 전체를 자동 Mock

// 특정 모듈의 일부만 Mock
jest.mock('bcryptjs', () => ({
  hash: jest.fn().mockResolvedValue('hashedPassword'),
  compare: jest.fn().mockResolvedValue(true),
}));

// ===== 4. 타이머 Mock (시간 제어) =====
jest.useFakeTimers();
jest.advanceTimersByTime(1000); // 1초 경과시키기
jest.runAllTimers();             // 모든 타이머 즉시 실행
jest.useRealTimers();            // 실제 타이머로 복구

🏗️ 05. NestJS Unit Test — 서비스/컨트롤러 테스트

NestJS의 의존성 주입 시스템을 활용해 서비스와 컨트롤러를 격리하여 테스트하는 방법입니다.

💻 UsersService Unit Test 완전 구현

// users.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { UsersService } from './users.service';
import { PrismaService } from '../prisma/prisma.service';
import { NotFoundException, ConflictException } from '@nestjs/common';

describe('UsersService', () => {
  let service: UsersService;

  // Prisma Mock 객체 — 실제 DB 연결 없이 테스트
  const mockPrisma = {
    user: {
      findMany: jest.fn(),
      findUnique: jest.fn(),
      findFirst: jest.fn(),
      create: jest.fn(),
      update: jest.fn(),
      delete: jest.fn(),
      count: jest.fn(),
    }
  };

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [
        UsersService,
        { provide: PrismaService, useValue: mockPrisma }
      ],
    }).compile();
    
    service = module.get<UsersService>(UsersService);
    jest.clearAllMocks(); // 각 테스트마다 Mock 초기화
  });

  // ===== findAll 테스트 =====
  describe('findAll()', () => {
    it('사용자 목록과 총 개수를 반환한다', async () => {
      // Arrange
      const mockUsers = [
        { id: 1, name: '홍길동', email: 'hong@e.com', createdAt: new Date() },
        { id: 2, name: '김영희', email: 'kim@e.com', createdAt: new Date() },
      ];
      mockPrisma.user.findMany.mockResolvedValue(mockUsers);
      mockPrisma.user.count.mockResolvedValue(2);
      
      // Act
      const result = await service.findAll(1, 10);
      
      // Assert
      expect(result.data).toHaveLength(2);
      expect(result.total).toBe(2);
      expect(result.page).toBe(1);
      expect(mockPrisma.user.findMany).toHaveBeenCalledWith(
        expect.objectContaining({ skip: 0, take: 10 })
      );
    });
  });

  // ===== findOne 테스트 =====
  describe('findOne()', () => {
    it('ID로 사용자를 반환한다', async () => {
      const mockUser = { id: 1, name: '홍길동', email: 'hong@e.com' };
      mockPrisma.user.findUnique.mockResolvedValue(mockUser);
      
      const result = await service.findOne(1);
      expect(result).toEqual(mockUser);
      expect(mockPrisma.user.findUnique).toHaveBeenCalledWith({ 
        where: { id: 1 }, select: expect.any(Object) 
      });
    });

    it('존재하지 않는 ID 조회 시 NotFoundException을 던진다', async () => {
      mockPrisma.user.findUnique.mockResolvedValue(null);
      
      await expect(service.findOne(999))
        .rejects.toThrow(NotFoundException);
    });
  });

  // ===== create 테스트 =====
  describe('create()', () => {
    it('새 사용자를 생성하고 반환한다', async () => {
      const dto = { name: '홍길동', email: 'hong@e.com', password: 'Test123!' };
      mockPrisma.user.findUnique.mockResolvedValue(null); // 중복 없음
      mockPrisma.user.create.mockResolvedValue({ id: 1, name: dto.name, email: dto.email });
      
      const result = await service.create(dto);
      expect(result.email).toBe(dto.email);
      expect(result).not.toHaveProperty('password'); // 보안: password 응답에서 제외
    });

    it('이메일 중복 시 ConflictException을 던진다', async () => {
      mockPrisma.user.findUnique.mockResolvedValue({ id: 1, email: 'dup@e.com' });
      
      await expect(service.create({ name: '홍', email: 'dup@e.com', password: 'pw' }))
        .rejects.toThrow(ConflictException);
    });
  });
});

🌐 06. E2E Test — 실제 HTTP 요청 통합 테스트

E2E(End-to-End) 테스트는 실제 HTTP 요청을 보내서 전체 API 흐름을 검증합니다. 사용자가 실제로 API를 사용하는 것처럼 테스트!

💻 NestJS E2E 테스트 완전 구현

// test/users.e2e-spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from '../src/app.module';
import { PrismaService } from '../src/prisma/prisma.service';

describe('Users API (e2e)', () => {
  let app: INestApplication;
  let prisma: PrismaService;
  let accessToken: string;

  beforeAll(async () => {
    const moduleRef: TestingModule = await Test.createTestingModule({
      imports: [AppModule],     // 실제 앱 모듈 사용
    }).compile();

    app = moduleRef.createNestApplication();
    app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
    app.setGlobalPrefix('api/v1');
    await app.init();

    prisma = moduleRef.get<PrismaService>(PrismaService);
    // 테스트 DB 초기화
    await prisma.user.deleteMany();
  });

  afterAll(async () => {
    await prisma.user.deleteMany(); // 테스트 후 정리
    await app.close();
  });

  // ===== 회원가입 테스트 =====
  describe('POST /api/v1/auth/register', () => {
    it('201 — 회원가입 성공', () => {
      return request(app.getHttpServer())
        .post('/api/v1/auth/register')
        .send({ name: '테스터', email: 'test@e2e.com', password: 'Test123!' })
        .expect(201)
        .expect((res) => {
          expect(res.body.data.email).toBe('test@e2e.com');
          expect(res.body.data).not.toHaveProperty('password');
        });
    });

    it('409 — 이메일 중복', () => {
      return request(app.getHttpServer())
        .post('/api/v1/auth/register')
        .send({ name: '중복', email: 'test@e2e.com', password: 'Test123!' })
        .expect(409);
    });

    it('400 — 입력값 검증 실패', () => {
      return request(app.getHttpServer())
        .post('/api/v1/auth/register')
        .send({ name: 'a', email: 'invalid', password: '1234' })
        .expect(400)
        .expect((res) => {
          expect(res.body.message).toBeInstanceOf(Array);
          expect(res.body.message.length).toBeGreaterThan(0);
        });
    });
  });

  // ===== 로그인 + 인증 테스트 =====
  describe('POST /api/v1/auth/login', () => {
    it('200 — 로그인 성공, 토큰 발급', async () => {
      const res = await request(app.getHttpServer())
        .post('/api/v1/auth/login')
        .send({ email: 'test@e2e.com', password: 'Test123!' })
        .expect(200);

      expect(res.body.data.accessToken).toBeDefined();
      accessToken = res.body.data.accessToken; // 이후 테스트에서 사용
    });

    it('401 — 잘못된 비밀번호', () => {
      return request(app.getHttpServer())
        .post('/api/v1/auth/login')
        .send({ email: 'test@e2e.com', password: 'wrongpassword' })
        .expect(401);
    });
  });

  // ===== 인증이 필요한 라우트 테스트 =====
  describe('GET /api/v1/auth/profile', () => {
    it('200 — 토큰으로 프로필 조회', () => {
      return request(app.getHttpServer())
        .get('/api/v1/auth/profile')
        .set('Authorization', `Bearer ${accessToken}`)
        .expect(200);
    });

    it('401 — 토큰 없이 접근', () => {
      return request(app.getHttpServer())
        .get('/api/v1/auth/profile')
        .expect(401);
    });
  });
});

🔴 07. TDD란? — Red-Green-Refactor 사이클

TDD(Test-Driven Development) = 테스트를 먼저 작성하고 코드를 나중에 작성하는 개발 방법론! 처음엔 어색하지만 익히면 강력합니다.

🔄 TDD 3단계 사이클

🔴

RED

실패하는 테스트 먼저 작성
아직 구현이 없으므로 당연히 실패!

🟢

GREEN

테스트를 통과하는 최소한의 코드 작성
완벽하지 않아도 OK!

🔵

REFACTOR

테스트를 통과하면서 코드 개선
중복 제거, 가독성 향상

🔴 RED 🟢 GREEN 🔵 REFACTOR 🔴 RED (반복)

🍽️ 음식점 비유로 이해하는 TDD

❌ 일반 개발 방식

  1. 요리(코드) 만들기
  2. 손님에게 서빙
  3. 맛이 없다고 불평 (버그 발견)
  4. 긴급 수정

✅ TDD 방식

  1. 레시피(테스트) 작성
  2. 레시피대로 요리
  3. 자동으로 맛 검증
  4. 안심하고 서빙!
항목 일반 개발 TDD 개발
버그 발견 시점 출시 후 (비용 ↑↑) 개발 중 (비용 ↓↓)
리팩토링 자신감 ❌ 두려움 ✅ 자신감
초기 개발 속도 ✅ 빠름 ⚠️ 약간 느림
장기 유지보수 ❌ 어려움 ✅ 매우 쉬움
코드 설계 ⚠️ 설계 후 고치기 어려움 ✅ 자연스럽게 좋은 설계

🛠️ 08. TDD 실전 — 장바구니 기능을 TDD로 구현

실제로 TDD 사이클을 돌며 기능을 구현해봅니다. 처음엔 테스트가 실패하고, 코드를 작성하면 통과됩니다!

💻 1단계: 🔴 RED — 실패하는 테스트 먼저 작성

// cart.service.spec.ts — 구현 전에 테스트 먼저 작성!
describe('CartService', () => {
  let service: CartService;

  beforeEach(() => {
    service = new CartService();
  });

  describe('addItem()', () => {
    it('빈 장바구니에 상품을 추가한다', () => {
      service.addItem({ id: 1, name: '사과', price: 1000, quantity: 2 });
      expect(service.getItems()).toHaveLength(1);
    });

    it('같은 상품 추가 시 수량이 증가한다', () => {
      service.addItem({ id: 1, name: '사과', price: 1000, quantity: 2 });
      service.addItem({ id: 1, name: '사과', price: 1000, quantity: 3 });
      expect(service.getItems()[0].quantity).toBe(5);
      expect(service.getItems()).toHaveLength(1); // 중복 안 생김
    });
  });

  describe('getTotalPrice()', () => {
    it('총 금액을 올바르게 계산한다', () => {
      service.addItem({ id: 1, name: '사과', price: 1000, quantity: 2 });
      service.addItem({ id: 2, name: '바나나', price: 500, quantity: 3 });
      expect(service.getTotalPrice()).toBe(3500); // 2000 + 1500
    });

    it('빈 장바구니의 총 금액은 0이다', () => {
      expect(service.getTotalPrice()).toBe(0);
    });
  });

  describe('removeItem()', () => {
    it('상품을 장바구니에서 제거한다', () => {
      service.addItem({ id: 1, name: '사과', price: 1000, quantity: 2 });
      service.removeItem(1);
      expect(service.getItems()).toHaveLength(0);
    });
  });
});
// ↑ 아직 CartService가 없으므로 실행하면 모두 실패 (🔴 RED)

💻 2단계: 🟢 GREEN — 테스트를 통과하는 최소 구현

// cart.service.ts — 테스트를 통과하도록 구현
interface CartItem {
  id: number;
  name: string;
  price: number;
  quantity: number;
}

export class CartService {
  private items: CartItem[] = [];

  addItem(item: CartItem): void {
    const existing = this.items.find(i => i.id === item.id);
    if (existing) {
      existing.quantity += item.quantity;
    } else {
      this.items.push({ ...item });
    }
  }

  getItems(): CartItem[] {
    return [...this.items];
  }

  getTotalPrice(): number {
    return this.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
  }

  removeItem(id: number): void {
    this.items = this.items.filter(i => i.id !== id);
  }
}
// npm test 실행 → 모든 테스트 통과! (🟢 GREEN)

💻 3단계: 🔵 REFACTOR — 테스트 유지하며 코드 개선

// 리팩토링 후 (기능은 동일, 코드만 개선)
export class CartService {
  private items = new Map<number, CartItem>(); // 배열 → Map으로 성능 개선

  addItem(item: CartItem): void {
    const existing = this.items.get(item.id);
    if (existing) {
      this.items.set(item.id, { ...existing, quantity: existing.quantity + item.quantity });
    } else {
      this.items.set(item.id, { ...item });
    }
  }

  getItems(): CartItem[] {
    return Array.from(this.items.values());
  }

  getTotalPrice(): number {
    return this.getItems().reduce((sum, { price, quantity }) => sum + price * quantity, 0);
  }

  removeItem(id: number): void {
    this.items.delete(id); // filter 대신 delete로 성능 개선
  }
}
// npm test 다시 실행 → 여전히 모두 통과! 리팩토링 성공 🔵

📊 09. 테스트 커버리지 — 측정과 목표 설정

커버리지는 "내 테스트가 코드의 몇 %를 검증하는가"입니다. 단, 100%가 목표가 아닙니다 — 의미 있는 테스트가 중요!

💻 커버리지 측정 및 설정

# 커버리지 리포트 생성
npm test -- --coverage

# 결과 예시:
--------------------|---------|----------|---------|---------|
File                | % Stmts | % Branch | % Funcs | % Lines |
--------------------|---------|----------|---------|---------|
All files           |   87.5  |   80.0   |   90.0  |   87.5  |
 users.service.ts   |   95.0  |   88.0   |   100.0 |   95.0  |
 auth.service.ts    |   80.0  |   72.0   |   80.0  |   80.0  |
--------------------|---------|----------|---------|---------|

# 4가지 커버리지 지표
# - Statements: 실행된 코드 라인 비율
# - Branches: if/switch 분기 처리 비율
# - Functions: 호출된 함수 비율
# - Lines: 실행된 줄 비율

// jest.config.js — 커버리지 최소 기준 설정
module.exports = {
  coverageThreshold: {
    global: {
      branches: 80,    // 전체 80% 이하면 테스트 실패
      functions: 80,
      lines: 80,
      statements: 80
    },
    './src/services/': {  // 서비스 폴더는 더 엄격하게
      lines: 90
    }
  },
  collectCoverageFrom: [
    'src/**/*.ts',
    '!src/**/*.spec.ts',    // 테스트 파일 제외
    '!src/main.ts',         // 진입점 제외
    '!src/**/*.module.ts',  // 모듈 파일 제외
    '!src/**/*.dto.ts',     // DTO 파일 제외
  ],
  coverageReporters: ['text', 'lcov', 'html'], // HTML 리포트도 생성
};

📏 커버리지 목표 가이드

커버리지 평가 상황
0~50% ❌ 위험 테스트가 거의 없는 상태. 즉시 개선 필요
50~70% ⚠️ 부족 핵심 기능은 테스트되지만 엣지 케이스 부족
70~85% ✅ 양호 대부분 스타트업/중소기업 목표
85~95% 🚀 우수 대기업/핀테크/의료 서비스 목표
95~100% 💎 최고 항공/금융 등 고신뢰성 시스템. 비용↑

🗄️ 10. DB 테스트 — Prisma Mock + 인메모리 DB

실제 DB 없이 빠르게 테스트하는 두 가지 방법을 배웁니다.

💻 Prisma Mock 전략 완전 가이드

// npm install --save-dev jest-mock-extended

// test/prisma-mock.ts — 재사용 가능한 Prisma Mock
import { PrismaClient } from '@prisma/client';
import { mockDeep, mockReset, DeepMockProxy } from 'jest-mock-extended';
import { PrismaService } from '../src/prisma/prisma.service';

export const prismaMock = mockDeep<PrismaService>();
export type MockPrisma = DeepMockProxy<PrismaService>;

// jest.setup.ts
beforeEach(() => {
  mockReset(prismaMock);
});

// 사용 예시
describe('PostsService', () => {
  beforeEach(async () => {
    const module = await Test.createTestingModule({
      providers: [
        PostsService,
        { provide: PrismaService, useValue: prismaMock }
      ],
    }).compile();
    service = module.get(PostsService);
  });

  it('게시글 목록을 조회한다', async () => {
    // 타입 안전한 Mock 설정 (TypeScript 자동완성 지원!)
    prismaMock.post.findMany.mockResolvedValue([
      { id: 1, title: '테스트 글', content: '내용', published: true, 
        authorId: 1, createdAt: new Date() }
    ]);
    prismaMock.post.count.mockResolvedValue(1);

    const result = await service.findAll(1, 10);
    expect(result.data).toHaveLength(1);
    expect(prismaMock.post.findMany).toHaveBeenCalledTimes(1);
  });
});

🚀 11. CI/CD 파이프라인에 테스트 통합

테스트를 자동화 파이프라인에 포함시켜 코드 병합 전에 자동으로 실행되도록 합니다. GitHub Actions로 구현합니다!

🔄 CI/CD + 테스트 자동화 흐름

1 개발자가 git push 또는 PR(Pull Request) 생성
2 GitHub Actions 자동 실행 → 의존성 설치
3 Unit Test + Integration Test + E2E Test 자동 실행
4 커버리지 기준 미달 시 → PR 병합 차단!
5 모든 테스트 통과 → 자동 배포!

💻 GitHub Actions 워크플로우

# .github/workflows/ci.yml
name: CI Pipeline

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest

    services:
      # E2E 테스트용 MySQL 컨테이너
      mysql:
        image: mysql:8.0
        env:
          MYSQL_ROOT_PASSWORD: testpassword
          MYSQL_DATABASE: testdb
        ports:
          - 3306:3306
        options: --health-cmd="mysqladmin ping" --health-interval=10s

    steps:
      - uses: actions/checkout@v4

      - name: Node.js 설정
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: 의존성 설치
        run: npm ci

      - name: TypeScript 빌드 확인
        run: npm run build

      - name: Unit Test 실행
        run: npm test -- --coverage --forceExit

      - name: 커버리지 80% 미달 시 실패 처리
        run: |
          COVERAGE=$(cat coverage/coverage-summary.json | jq '.total.lines.pct')
          echo "커버리지: $COVERAGE%"
          if (( $(echo "$COVERAGE < 80" | bc -l) )); then
            echo "❌ 커버리지 80% 미달!"
            exit 1
          fi

      - name: Prisma 마이그레이션 실행
        run: npx prisma migrate deploy
        env:
          DATABASE_URL: mysql://root:testpassword@localhost:3306/testdb

      - name: E2E Test 실행
        run: npm run test:e2e -- --forceExit
        env:
          DATABASE_URL: mysql://root:testpassword@localhost:3306/testdb
          JWT_SECRET: test-secret-key

      - name: 커버리지 리포트 업로드 (Codecov)
        uses: codecov/codecov-action@v4
        with:
          file: ./coverage/lcov.info

  deploy:
    needs: test      # test 성공 후에만 실행
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - name: 프로덕션 배포
        run: echo "🚀 테스트 통과! 자동 배포 시작..."

🎤 12. 면접 Q&A + 실무 체크리스트 + 로드맵

테스트 관련 면접 단골 질문과 현업에서 바로 쓸 수 있는 체크리스트입니다.

Q1. TDD란 무엇인가요? 장단점을 설명해주세요.

TDD(Test-Driven Development)는 테스트 코드를 먼저 작성하고 그 테스트를 통과하는 코드를 나중에 작성하는 개발 방법론입니다. Red(실패 테스트) → Green(통과 코드) → Refactor(개선) 사이클을 반복합니다. 장점: 버그 조기 발견, 자신감 있는 리팩토링, 자연스러운 좋은 설계. 단점: 초기 개발 속도 다소 저하, 학습 곡선 존재.

Q2. Unit Test와 E2E Test의 차이는?

Unit Test: 함수/클래스 하나만 격리해서 테스트합니다. 빠르고(ms) 독립적이며 외부 의존성을 Mock으로 대체합니다. 로직 검증에 적합합니다. E2E Test: 실제 HTTP 요청을 보내 전체 앱 흐름을 테스트합니다. 느리지만(초~분) 실제 사용자 경험을 검증합니다. 테스트 피라미드: Unit 70%, Integration 20%, E2E 10%를 권장합니다.

Q3. Mock과 Stub, Spy의 차이는?

Mock: 외부 의존성을 완전히 가짜로 대체하고 호출 여부를 검증합니다 (jest.fn()). Stub: 특정 응답만 반환하는 가짜 구현으로 호출 여부는 검증하지 않습니다. Spy: 실제 함수를 그대로 쓰면서 호출 기록만 감시합니다 (jest.spyOn()). 현업에서는 이 세 가지를 통칭해서 "Mock"이라고 부르는 경우가 많습니다.

Q4. 테스트 커버리지 100%가 목표인가요?

아닙니다. 100% 커버리지보다 의미 있는 테스트가 더 중요합니다. 커버리지가 높아도 엣지 케이스나 비즈니스 로직을 제대로 검증하지 않으면 의미가 없습니다. 일반적인 현업 목표는 80~90%이며, 중요한 비즈니스 로직은 100%를 목표로 합니다. getters/setters, DTO, 단순 매핑 코드는 100% 달성이 어렵고 불필요합니다.

Q5. 좋은 테스트 코드의 조건은?

FIRST 원칙: Fast(빠름), Independent(독립적), Repeatable(반복 가능), Self-validating(자동 검증), Timely(적시에 작성). ② AAA 패턴: Arrange(준비) → Act(실행) → Assert(검증)를 명확히 구분. ③ 하나의 테스트는 하나의 시나리오만 검증. ④ 테스트 이름이 "무엇을 테스트하는지" 명확히 표현. ⑤ 외부 의존성은 Mock으로 격리.

Q6. 레거시 코드에 테스트를 추가하는 전략은?

① 새로운 기능 추가 시 무조건 테스트 작성. ② 버그 수정 시 먼저 버그 재현 테스트 작성 후 수정. ③ 리팩토링 전에 핵심 로직 테스트 작성. ④ 커버리지를 단계적으로 높이기 (40% → 60% → 80%). ⑤ 테스트하기 어려운 코드는 의존성 주입 패턴으로 리팩토링. 한 번에 완벽하게 하려 하지 말고 점진적으로 개선하는 것이 현실적입니다.

📋 테스트 현업 체크리스트

✅ 기초 (반드시)

  • Service 단위 테스트 작성
  • 핵심 비즈니스 로직 Unit Test
  • 에러 케이스 테스트 포함
  • Jest mock으로 외부 의존성 격리
  • beforeEach에서 Mock 초기화
  • AAA 패턴 준수

🚀 심화 (현업 수준)

  • E2E 테스트로 API 전체 검증
  • 커버리지 80% 이상 목표
  • CI 파이프라인에 테스트 통합
  • 커버리지 기준 미달 시 PR 차단
  • 테스트 DB 분리 운영
  • 테스트 이름 명확하게 작성

🏆 고급 (시니어 수준)

  • TDD로 새 기능 개발
  • 계약 테스트 (Pact)
  • 성능 테스트 (k6, Artillery)
  • 돌연변이 테스트 (Stryker)
  • 시각적 회귀 테스트
  • 팀 테스트 문화 구축

🗺️ 테스트 학습 로드맵 (5단계)

단계 학습 내용 기간 결과물
1단계 Jest 기초 + Matcher + 비동기 테스트 3~4일 기본 테스트 코드 작성
2단계 Mock/Spy + NestJS Unit Test 1주 서비스 테스트 완성
3단계 E2E Test + 커버리지 측정 1주 API 전체 테스트
4단계 TDD 실습 + CI/CD 통합 1주 자동화 파이프라인
5단계 기존 프로젝트에 테스트 추가 실전 적용 2주 커버리지 80%+ 달성

🎉 BackendDevGuide0006 완료!

Jest, Mock, E2E 테스트, TDD, 커버리지, CI/CD 통합까지 — 이제 버그 없는 코드를 만드는 완전한 테스트 역량을 갖췄습니다!

다음: BackendDevGuide0007 — Docker + AWS 배포 완전 정복

반응형