단위테스트와 TDD (with. TypeScript)

윤성현·2025년 12월 15일
post-thumbnail

1. 단위테스트란?

1) 정의

  • 단위테스트(Unit Test)는 소프트웨어의 가장 작은 단위(함수, 메서드, 클래스 등)를 독립적으로 테스트하는 것입니다.

2) 특징

  • 독립성: 다른 코드에 의존하지 않고 독립적으로 실행
  • 빠른 실행: 몇 초 안에 수백 개의 테스트 실행 가능
  • 자동화: 수동 테스트 없이 자동으로 검증
  • 명확한 검증: 예상 결과와 실제 결과 비교를 통해 검증

2. 왜 단위테스트가 필요한가?

1) 코드 품질 향상

  • 테스트가 없는 경우
    • 버그 발견 어려움 → 배포 후 장애
  • 테스트가 있는 경우
    • 개발 과정에서 버그를 발견 → 안정적인 배포 가능

2) 주요 이점

1. 버그 조기 발견

  • 개발 단계에서 문제를 발견하여 수정 비용 최소화

2. 리팩토링 안정성

  • 코드 변경 시 기존 기능이 정상 작동하는지 즉시 확인 가능

3. 문서화 효과

  • 테스트 코드 자체가 함수의 사용법과 예상 동작을 설명함

4. 개발 속도 향상

  • 장기적으로 디버깅 시간 감소로 개발 속도 증가

5. 자신감 있는 배포

  • 모든 테스트 통과 시 배포에 대한 확신

3. 왜 Jest인가?

1) 테스트 프레임워크 선택지

TypeScript 프로젝트에서 사용할 수 있는 테스트 프레임워크는 여러 가지가 있습니다.

  • Jest: Facebook에서 개발한 올인원 테스트 프레임워크
  • Mocha + Chai: 유연하지만 설정이 복잡
  • Vitest: 최신 프레임워크, Vite 기반
  • Jasmine: 오래된 프레임워크

2) Jest를 선택하는 이유

(1) 올인원 솔루션

Jest = Test Runner + Assertion + Mocking + Coverage

다른 프레임워크는 여러 라이브러리를 조합해야 하지만, Jest는 테스트에 필요한 모든 도구를 하나의 패키지로 제공합니다. 테스트 실행기(Test Runner), 검증 라이브러리(Assertion Library), 모킹 도구(Mocking), 코드 커버리지 분석(Coverage)까지 별도 설치 없이 모두 사용할 수 있습니다.

(2) 제로 설정 (Zero Config)

  • 최소한의 설정으로 바로 시작 가능
  • ts-jest 추가만으로 TypeScript 지원

(3) 강력한 기능들

  • 스냅샷 테스트: UI 컴포넌트 변경 감지
  • 병렬 실행: 빠른 테스트 실행 속도
  • 커버리지 리포트: 내장된 코드 커버리지 분석
  • Watch 모드: 변경된 파일만 자동 재테스트

(4) 풍부한 생태계

  • React, Vue, Angular 등 모든 프레임워크 지원
  • 방대한 커뮤니티와 문서
  • 대부분의 CI/CD 도구와 쉬운 통합

(5) 직관적인 API

// Jest - 읽기 쉬운 문법
expect(result).toBe(expected);
expect(array).toContain(item);
expect(fn).toThrow(error);

// 다른 프레임워크 - 체이닝이 복잡할 수 있음
assert.equal(result, expected);

3) Jest의 실용적 장점

기능JestMocha + ChaiVitest
설정 난이도⭐ 쉬움⭐⭐⭐ 어려움⭐⭐ 보통
TypeScript 지원ts-jest 추가여러 패키지 필요네이티브 지원
실행 속도빠름보통매우 빠름
커뮤니티매우 큼성장 중
안정성매우 높음높음보통

4) Jest의 유용한 기능들

(1) Watch 모드 - 개발 중 자동 테스트

npm run test:watch

파일 저장할 때마다 관련 테스트만 자동 실행

(2) 커버리지 리포트 - 테스트 범위 확인

npm run test:coverage

어떤 코드가 테스트되지 않았는지 한눈에 확인

(3) only/skip - 특정 테스트만 실행

describe.only('이 그룹만 실행', () => {
  it('테스트 1', () => { /* ... */ });
});

it.skip('이 테스트는 건너뛰기', () => { /* ... */ });

(4) 강력한 Matcher - 직관적인 검증

expect(value).toBe(expected);           // 정확히 같음
expect(value).toEqual(expected);        // 깊은 비교 (객체/배열)
expect(array).toContain(item);          // 배열에 포함
expect(fn).toThrow(error);              // 에러 발생
expect(obj).toHaveProperty('key');      // 속성 존재
expect(string).toMatch(/pattern/);      // 정규식 매칭
expect(num).toBeGreaterThan(10);        // 숫자 비교
expect(value).toBeTruthy();             // truthy 값

4. TypeScript + Jest 환경 설정

1) 패키지 설치

# 프로젝트 초기화
npm init -y

# TypeScript 설치
npm install -D typescript @types/node

# Jest 및 TypeScript 지원 패키지 설치
npm install -D jest @types/jest ts-jest

# Jest 설정 파일 생성
npx ts-jest config:init

2) jest.config.js 설정

module.exports = {
  preset: 'ts-jest',
  testEnvironment: 'node',
  roots: ['<rootDir>/src'],
  testMatch: ['**/__tests__/**/*.ts', '**/?(*.)+(spec|test).ts'],
  collectCoverageFrom: [
    'src/**/*.ts',
    '!src/**/*.d.ts',
    '!src/**/*.test.ts'
  ]
};

3) package.json 스크립트 추가

{
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch",
    "test:coverage": "jest --coverage",
    "test:verbose": "jest --verbose"
  }
}

4) tsconfig.json 설정 (선택사항)

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "commonjs",
    "lib": ["ES2022"],
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "outDir": "./dist",
    "rootDir": "./src"
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "**/*.test.ts"]
}

주요 포인트:

  • target: "ES2022": # 문법(Private Fields) 지원
  • strict: true: 엄격한 타입 체크로 버그 사전 방지

5. 테스트 코드 작성하기

1) 기본 구조: AAA 패턴

test('테스트 설명', () => {
  // Arrange (준비): 테스트에 필요한 데이터 준비
  const input = 5;

  // Act (실행): 테스트할 함수 실행
  const result = add(input, 3);

  // Assert (검증): 결과 확인
  expect(result).toBe(8);
});

2) 실전 예제 1: 계산기 함수

calculator.ts

export class Calculator {
  add(a: number, b: number): number {
    return a + b;
  }

  subtract(a: number, b: number): number {
    return a - b;
  }

  multiply(a: number, b: number): number {
    return a * b;
  }

  divide(a: number, b: number): number {
    if (b === 0) {
      throw new Error('0으로 나눌 수 없습니다');
    }
    return a / b;
  }
}

calculator.test.ts

import { Calculator } from './calculator';

describe('Calculator', () => {
  let calculator: Calculator;

  beforeEach(() => {
    calculator = new Calculator();
  });

  describe('add', () => {
    it('두 숫자를 더한다', () => {
      expect(calculator.add(2, 3)).toBe(5);
    });

    it('음수도 정확히 더한다', () => {
      expect(calculator.add(-1, -2)).toBe(-3);
    });
  });

  describe('divide', () => {
    it('두 숫자를 나눈다', () => {
      expect(calculator.divide(10, 2)).toBe(5);
    });

    it('0으로 나누면 에러를 던진다', () => {
      expect(() => calculator.divide(10, 0))
        .toThrow('0으로 나눌 수 없습니다');
    });
  });
});

3) 실전 예제 2: 사용자 검증 함수

userValidator.ts

export interface User {
  email: string;
  password: string;
  age: number;
}

export class UserValidator {
  validateEmail(email: string): boolean {
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    return emailRegex.test(email);
  }

  validatePassword(password: string): boolean {
    return password.length >= 8;
  }

  validateAge(age: number): boolean {
    return age >= 18 && age <= 120;
  }

  validate(user: User): { valid: boolean; errors: string[] } {
    const errors: string[] = [];

    if (!this.validateEmail(user.email)) {
      errors.push('유효하지 않은 이메일입니다');
    }

    if (!this.validatePassword(user.password)) {
      errors.push('비밀번호는 최소 8자 이상이어야 합니다');
    }

    if (!this.validateAge(user.age)) {
      errors.push('나이는 18세 이상 120세 이하여야 합니다');
    }

    return {
      valid: errors.length === 0,
      errors
    };
  }
}

userValidator.test.ts

import { UserValidator, User } from './userValidator';

describe('UserValidator', () => {
  let validator: UserValidator;

  beforeEach(() => {
    validator = new UserValidator();
  });

  describe('validateEmail', () => {
    it('유효한 이메일을 통과시킨다', () => {
      expect(validator.validateEmail('test@example.com')).toBe(true);
    });

    it('유효하지 않은 이메일을 거부한다', () => {
      expect(validator.validateEmail('invalid-email')).toBe(false);
      expect(validator.validateEmail('test@')).toBe(false);
      expect(validator.validateEmail('@example.com')).toBe(false);
    });
  });

  describe('validate', () => {
    it('모든 조건을 만족하면 통과한다', () => {
      const user: User = {
        email: 'test@example.com',
        password: 'password123',
        age: 25
      };

      const result = validator.validate(user);

      expect(result.valid).toBe(true);
      expect(result.errors).toHaveLength(0);
    });

    it('여러 조건이 실패하면 모든 에러를 반환한다', () => {
      const user: User = {
        email: 'invalid',
        password: '123',
        age: 15
      };

      const result = validator.validate(user);

      expect(result.valid).toBe(false);
      expect(result.errors).toHaveLength(3);
      expect(result.errors).toContain('유효하지 않은 이메일입니다');
    });
  });
});

4) 실전 예제 3: Mock 사용하기 (외부 의존성 테스트)

외부 API나 데이터베이스 같은 의존성을 테스트할 때는 Mock을 사용합니다.

userService.ts

export interface EmailService {
  sendWelcomeEmail(email: string): Promise<boolean>;
}

export class UserService {
  #emailService: EmailService;

  constructor(emailService: EmailService) {
    this.#emailService = emailService;
  }

  async registerUser(email: string, password: string): Promise<string> {
    // 실제로는 DB 저장 등의 로직이 있겠지만 단순화
    const userId = `user-${Date.now()}`;

    // 외부 이메일 서비스 호출
    await this.#emailService.sendWelcomeEmail(email);

    return userId;
  }
}

src/userService.test.ts

import { UserService, EmailService } from './userService';

describe('UserService', () => {
  let userService: UserService;
  let mockEmailService: jest.Mocked<EmailService>;

  beforeEach(() => {
    // Mock 생성
    mockEmailService = {
      sendWelcomeEmail: jest.fn()
    };

    userService = new UserService(mockEmailService);
  });

  it('사용자 등록 시 환영 이메일을 보낸다', async () => {
    mockEmailService.sendWelcomeEmail.mockResolvedValue(true);

    await userService.registerUser('test@example.com', 'password123');

    // Mock이 올바른 인자로 호출되었는지 검증
    expect(mockEmailService.sendWelcomeEmail).toHaveBeenCalledWith('test@example.com');
    expect(mockEmailService.sendWelcomeEmail).toHaveBeenCalledTimes(1);
  });

  it('이메일 전송 실패 시 에러를 처리한다', async () => {
    mockEmailService.sendWelcomeEmail.mockRejectedValue(
      new Error('Email service unavailable')
    );

    await expect(
      userService.registerUser('test@example.com', 'password123')
    ).rejects.toThrow('Email service unavailable');
  });
});

Mock의 장점:

  • 외부 서비스 없이 테스트 가능
  • 빠른 테스트 실행
  • 다양한 시나리오 (성공, 실패) 쉽게 테스트

6. TDD (Test-Driven Development)

1) TDD란?

테스트 주도 개발(TDD)은 테스트를 먼저 작성하고, 그 테스트를 통과하는 코드를 작성하는 개발 방법론입니다.

2) TDD 사이클: Red-Green-Refactor

🔴 Red    → 실패하는 테스트 작성
🟢 Green  → 테스트를 통과하는 최소한의 코드 작성
🔵 Refactor → 코드 개선 (테스트는 계속 통과)

image.png

3) TDD의 장점

  1. 요구사항 명확화: 테스트가 명세서 역할
  2. 과도한 설계 방지: 필요한 만큼만 구현
  3. 높은 테스트 커버리지: 모든 코드가 테스트됨
  4. 리팩토링 용이: 테스트가 안전망 역할

7. TDD 실습: 회의실 예약 시스템 만들기

1) 요구사항 분석

회의실 예약 시스템을 TDD로 개발해봅시다.

핵심 기능

  • 회의실 예약 생성
  • 시간 중복 방지
  • 예약 취소
  • 예약 목록 조회

2) Step 1: 🔴 Red - 첫 번째 테스트 작성

meetingRoom.test.ts

import { MeetingRoomService } from './meetingRoom';

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

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

  describe('예약 생성', () => {
    it('회의실 예약을 생성할 수 있다', () => {
      const reservation = service.createReservation({
        roomId: 'room-101',
        userId: 'user-001',
        startTime: new Date('2025-10-09T10:00:00'),
        endTime: new Date('2025-10-09T11:00:00'),
        title: '팀 회의'
      });

      expect(reservation.id).toBeDefined();
      expect(reservation.roomId).toBe('room-101');
      expect(reservation.title).toBe('팀 회의');
      expect(reservation.status).toBe('confirmed');
    });
  });
});

테스트 실행: npm test → ❌ 실패 (MeetingRoomService 없음)


3) Step 2: 🟢 Green - 최소한의 코드로 테스트 통과

meetingRoom.ts

export interface Reservation {
  id: string;
  roomId: string;
  userId: string;
  startTime: Date;
  endTime: Date;
  title: string;
  status: 'confirmed' | 'cancelled';
}

export interface CreateReservationInput {
  roomId: string;
  userId: string;
  startTime: Date;
  endTime: Date;
  title: string;
}

export class MeetingRoomService {
  #reservations: Map<string, Reservation> = new Map();
  #nextId: number = 1;

  createReservation(input: CreateReservationInput): Reservation {
    const reservation: Reservation = {
      id: `res-${this.#nextId++}`,
      ...input,
      status: 'confirmed'
    };

    this.#reservations.set(reservation.id, reservation);
    return reservation;
  }
}

테스트 실행: npm test → ✅ 통과!


4) Step 3: 🔴 Red - 시간 중복 검증 테스트 추가

describe('MeetingRoomService', () => {
  // ... 이전 테스트

  describe('시간 중복 검증', () => {
    it('같은 회의실에 시간이 겹치는 예약은 생성할 수 없다', () => {
      // 첫 번째 예약
      service.createReservation({
        roomId: 'room-101',
        userId: 'user-001',
        startTime: new Date('2025-10-09T10:00:00'),
        endTime: new Date('2025-10-09T11:00:00'),
        title: '팀 회의'
      });

      // 시간이 겹치는 두 번째 예약 시도
      expect(() => {
        service.createReservation({
          roomId: 'room-101',
          userId: 'user-002',
          startTime: new Date('2025-10-09T10:30:00'),
          endTime: new Date('2025-10-09T11:30:00'),
          title: '기획 회의'
        });
      }).toThrow('해당 시간에 이미 예약이 존재합니다');
    });

    it('다른 회의실이면 같은 시간에 예약할 수 있다', () => {
      service.createReservation({
        roomId: 'room-101',
        userId: 'user-001',
        startTime: new Date('2025-10-09T10:00:00'),
        endTime: new Date('2025-10-09T11:00:00'),
        title: '팀 회의'
      });

      const reservation = service.createReservation({
        roomId: 'room-102',
        userId: 'user-002',
        startTime: new Date('2025-10-09T10:00:00'),
        endTime: new Date('2025-10-09T11:00:00'),
        title: '기획 회의'
      });

      expect(reservation).toBeDefined();
      expect(reservation.status).toBe('confirmed');
    });
  });
});

테스트 실행: npm test → ❌ 실패 (중복 체크 로직 없음)


5) Step 4: 🟢 Green - 중복 검증 로직 구현

export class MeetingRoomService {
  #reservations: Map<string, Reservation> = new Map();
  #nextId: number = 1;

  createReservation(input: CreateReservationInput): Reservation {
    // 중복 검증
    if (this.#hasConflict(input.roomId, input.startTime, input.endTime)) {
      throw new Error('해당 시간에 이미 예약이 존재합니다');
    }

    const reservation: Reservation = {
      id: `res-${this.#nextId++}`,
      ...input,
      status: 'confirmed'
    };

    this.#reservations.set(reservation.id, reservation);
    return reservation;
  }

  #hasConflict(
    roomId: string,
    startTime: Date,
    endTime: Date
  ): boolean {
    const existingReservations = Array.from(this.#reservations.values())
      .filter(r => r.roomId === roomId && r.status === 'confirmed');

    return existingReservations.some(reservation => {
      // 시간 겹침 확인
      return (
        startTime < reservation.endTime &&
        endTime > reservation.startTime
      );
    });
  }
}

테스트 실행: npm test → ✅ 모두 통과!


6) Step 5: 🔴 Red - 예약 취소 테스트 추가

describe('예약 취소', () => {
  it('예약을 취소할 수 있다', () => {
    const reservation = service.createReservation({
      roomId: 'room-101',
      userId: 'user-001',
      startTime: new Date('2025-10-09T10:00:00'),
      endTime: new Date('2025-10-09T11:00:00'),
      title: '팀 회의'
    });

    service.cancelReservation(reservation.id);

    const cancelled = service.getReservation(reservation.id);
    expect(cancelled?.status).toBe('cancelled');
  });

  it('취소된 예약 시간에는 다시 예약할 수 있다', () => {
    const first = service.createReservation({
      roomId: 'room-101',
      userId: 'user-001',
      startTime: new Date('2025-10-09T10:00:00'),
      endTime: new Date('2025-10-09T11:00:00'),
      title: '팀 회의'
    });

    service.cancelReservation(first.id);

    const second = service.createReservation({
      roomId: 'room-101',
      userId: 'user-002',
      startTime: new Date('2025-10-09T10:00:00'),
      endTime: new Date('2025-10-09T11:00:00'),
      title: '기획 회의'
    });

    expect(second).toBeDefined();
    expect(second.status).toBe('confirmed');
  });
});

테스트 실행: npm test → ❌ 실패


7) Step 6: 🟢 Green - 취소 기능 구현

export class MeetingRoomService {
  // ... 이전 코드

  cancelReservation(id: string): void {
    const reservation = this.#reservations.get(id);
    if (!reservation) {
      throw new Error('예약을 찾을 수 없습니다');
    }

    reservation.status = 'cancelled';
  }

  getReservation(id: string): Reservation | undefined {
    return this.#reservations.get(id);
  }

  // #hasConflict에서 취소된 예약은 제외 (이미 구현됨)
}

테스트 실행: npm test → ✅ 모두 통과!


8) Step 7: 🔵 Refactor - 코드 개선

export class MeetingRoomService {
  #reservations: Map<string, Reservation> = new Map();
  #nextId: number = 1;

  createReservation(input: CreateReservationInput): Reservation {
    this.#validateReservationTime(input.startTime, input.endTime);
    this.#checkTimeConflict(input.roomId, input.startTime, input.endTime);

    const reservation: Reservation = {
      id: `res-${this.#nextId++}`,
      ...input,
      status: 'confirmed'
    };

    this.#reservations.set(reservation.id, reservation);
    return reservation;
  }

  cancelReservation(id: string): void {
    const reservation = this.#getReservationOrThrow(id);
    reservation.status = 'cancelled';
  }

  getReservation(id: string): Reservation | undefined {
    return this.#reservations.get(id);
  }

  getActiveReservations(roomId: string): Reservation[] {
    return Array.from(this.#reservations.values())
      .filter(r => r.roomId === roomId && r.status === 'confirmed');
  }

  // Private helper methods
  #validateReservationTime(startTime: Date, endTime: Date): void {
    if (startTime >= endTime) {
      throw new Error('종료 시간은 시작 시간보다 늦어야 합니다');
    }
  }

  #checkTimeConflict(
    roomId: string,
    startTime: Date,
    endTime: Date
  ): void {
    if (this.#hasConflict(roomId, startTime, endTime)) {
      throw new Error('해당 시간에 이미 예약이 존재합니다');
    }
  }

  #hasConflict(
    roomId: string,
    startTime: Date,
    endTime: Date
  ): boolean {
    const activeReservations = this.getActiveReservations(roomId);

    return activeReservations.some(reservation =>
      this.#isTimeOverlapping(
        startTime,
        endTime,
        reservation.startTime,
        reservation.endTime
      )
    );
  }

  #isTimeOverlapping(
    start1: Date,
    end1: Date,
    start2: Date,
    end2: Date
  ): boolean {
    return start1 < end2 && end1 > start2;
  }

  #getReservationOrThrow(id: string): Reservation {
    const reservation = this.#reservations.get(id);
    if (!reservation) {
      throw new Error('예약을 찾을 수 없습니다');
    }
    return reservation;
  }
}

테스트 실행: npm test → ✅ 여전히 모두 통과!

리팩토링 후에도 테스트가 통과하므로 안전하게 코드를 개선할 수 있습니다.


8.정리

핵심 포인트

1. 단위테스트는 필수

  • 버그를 조기에 발견하고 코드 품질을 보장합니다
  • 개발 속도를 장기적으로 향상시킵니다

2. Jest는 시작하기 좋은 선택

  • 올인원 솔루션으로 설정이 간단
  • 강력한 기능과 거대한 커뮤니티 지원

3. TDD는 강력한 설계 도구

  • 테스트 → 구현 → 리팩토링 사이클로 견고한 코드 작성
  • 테스트를 먼저 작성하면 "이 함수가 뭘 해야 하는지"를 명확히 정의 가능
  • 과도한 설계를 방지하고 필요한 만큼만 구현

4. TypeScript + Jest는 완벽한 조합

  • 타입 안정성 + 강력한 테스트 프레임워크
  • ts-jest로 쉽게 연동 가능
  • # 문법(Private Fields)으로 더 강력한 캡슐화

5. Mock으로 외부 의존성 제거

  • 빠르고 독립적인 테스트 가능
  • 다양한 시나리오 쉽게 테스트

9. Q&A

자주 묻는 질문

Q: 모든 코드에 테스트를 작성해야 하나요?

A: 핵심 비즈니스 로직에 집중하세요. 단순한 getter/setter, 타입 정의, 외부 라이브러리 래퍼 등은 생략해도 됩니다.

Q: 테스트 작성 시간이 아깝지 않나요?

A: 초기에는 시간이 더 걸리지만, 디버깅 시간이 크게 줄어듭니다. 특히 코드가 복잡해질수록 테스트의 가치가 높아집니다.

Q: TDD를 항상 해야 하나요?

A: 상황에 따라 다릅니다. 요구사항이 명확한 기능, 복잡한 비즈니스 로직에는 효과적이에요. 탐색적인 프로토타이핑에는 오히려 방해가 될 수 있습니다.

Q: 비동기 코드는 어떻게 테스트하나요?

A: async/await를 사용하면 됩니다.

it('비동기 작업 테스트', async () => {
  const result = await fetchData();
  expect(result).toBe(expected);
});

10. 참고 자료

0개의 댓글