icon
8장 : 테스팅 전략

단위 테스트 작성과 모킹


7장에서는 NestJS를 활용한 마이크로서비스 아키텍처 구축에 대해 알아보며, 확장 가능하고 유연한 시스템을 설계하는 방법을 익혔습니다. 이제 8장에서는 개발 프로세스에서 매우 중요한 부분인 테스팅 전략에 대해 다루고, 특히 단위 테스트(Unit Test) 를 작성하고 모킹(Mocking) 기법을 활용하는 방법에 대해 자세히 살펴보겠습니다.

소프트웨어 개발에서 테스트는 코드의 품질을 보장하고, 예상치 못한 버그를 발견하며, 향후 코드 변경 시 회귀(regression)를 방지하는 데 필수적입니다. NestJS는 견고한 아키텍처와 테스트 친화적인 설계를 통해 테스트 작성을 매우 용이하게 합니다.


테스팅의 중요성 및 종류

소프트웨어 테스트는 크게 세 가지 수준으로 나눌 수 있습니다.

단위 테스트(Unit Test)

  • 목표: 애플리케이션의 가장 작은 독립적인 코드 조각(함수, 메서드, 클래스)이 예상대로 작동하는지 검증합니다.
  • 특징: 격리된 환경에서 수행되며, 외부 의존성(데이터베이스, 네트워크 요청 등)은 모킹하거나 스텁(Stubbing) 처리합니다.
  • 장점: 실행 속도가 빠르고, 문제 발생 시 정확한 위치를 파악하기 용이하며, 개발 단계에서 피드백을 빠르게 얻을 수 있습니다.

통합 테스트(Integration Test)

  • 목표: 여러 단위(모듈, 서비스)들이 함께 작동하여 예상대로 통신하고 동작하는지 검증합니다.
  • 특징: 실제 의존성(데이터베이스, 다른 서비스)의 일부 또는 전체를 포함하여 테스트합니다.
  • 장점: 실제 환경에 가까운 테스트를 통해 단위 테스트에서 발견하기 어려운 문제를 찾아낼 수 있습니다.

E2E 테스트(End-to-End Test)

  • 목표: 사용자 관점에서 전체 애플리케이션 흐름이 정상적으로 동작하는지 검증합니다.
  • 특징: 실제 사용자처럼 UI를 조작하거나 API를 호출하여 전체 시스템의 기능을 확인합니다.
  • 장점: 실제 비즈니스 시나리오를 검증하여 최종 사용자 경험을 보장합니다.
  • 단점: 실행 속도가 느리고, 문제 발생 시 원인을 파악하기 어렵습니다.

이 절에서는 가장 기본이 되는 단위 테스트에 집중하여 NestJS에서 단위 테스트를 효과적으로 작성하는 방법을 알아봅니다.


NestJS에서 단위 테스트 설정

NestJS는 기본적으로 Jest를 사용하여 테스트 환경을 구성합니다. 새 NestJS 프로젝트를 생성하면 test 폴더와 jest-e2e.json, jest.config.js 등의 설정 파일이 자동으로 생성됩니다.

기본 파일 구조

src/
  app.controller.ts
  app.module.ts
  app.service.ts
test/
  app.e2e-spec.ts  # E2E 테스트 예시
jest.config.js     # Jest 설정 파일

단위 테스트를 위한 추가적인 설정은 필요하지 않습니다. 이제 특정 서비스에 대한 단위 테스트를 작성해 보겠습니다.


단위 테스트 작성: AppService 예시

가장 간단한 AppService의 단위 테스트를 예로 들어보겠습니다.

기존 AppService 코드

// src/app.service.ts
import { Injectable } from '@nestjs/common';

@Injectable()
export class AppService {
  getHello(): string {
    return 'Hello World!';
  }

  sum(a: number, b: number): number {
    return a + b;
  }
}

AppService에 대한 단위 테스트 코드

NestJS는 @nestjs/testing 패키지를 통해 테스트 유틸리티를 제공합니다.

nest g class app/app.service.spec.ts --no-spec # app/app.service.spec.ts 파일을 수동으로 만들지 않았다면 생성

아니면 src/app.service.spec.ts 파일을 직접 생성합니다.

// src/app.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { AppService } from './app.service';

describe('AppService', () => { // 'describe' 블록은 테스트 그룹을 정의합니다.
  let service: AppService; // 테스트할 서비스 인스턴스

  // 각 테스트 실행 전에 이 블록이 실행됩니다.
  // 여기서는 NestJS 테스트 모듈을 설정하고 서비스 인스턴스를 가져옵니다.
  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [AppService], // 테스트할 서비스(AppService)를 프로바이더로 등록
    }).compile(); // 모듈 컴파일

    service = module.get<AppService>(AppService); // 컴파일된 모듈에서 AppService 인스턴스 가져오기
  });

  // 'it' 또는 'test' 블록은 개별 테스트 케이스를 정의합니다.
  it('should be defined', () => {
    // AppService 인스턴스가 성공적으로 생성되었는지 확인
    expect(service).toBeDefined();
  });

  it('should return "Hello World!"', () => {
    // getHello() 메서드가 예상된 문자열을 반환하는지 확인
    expect(service.getHello()).toBe('Hello World!');
  });

  it('should return the sum of two numbers', () => {
    // sum() 메서드가 올바른 합계를 반환하는지 확인
    expect(service.sum(1, 2)).toBe(3);
    expect(service.sum(-1, 1)).toBe(0);
    expect(service.sum(0, 0)).toBe(0);
  });
});

테스트 실행

npm run test # 또는 npm test

콘솔에 테스트 통과 결과가 나타날 것입니다.


모킹과 스텁

실제 애플리케이션에서는 서비스가 다른 서비스나 데이터베이스, 외부 API 등 다양한 의존성을 가집니다. 단위 테스트는 이런 의존성을 배제하고 테스트 대상 코드만 격리하여 테스트해야 합니다. 이때 모킹(Mocking)스텁(Stubbing) 이 사용됩니다.

  • 스텁(Stub): 테스트 대상 메서드가 외부 의존성을 호출할 때, 미리 정의된 가짜 데이터를 반환하도록 만듭니다. 실제 구현을 대체하여 예상 가능한 결과를 제공합니다.
  • 목(Mock): 스텁의 기능 외에도, 특정 메서드가 호출되었는지, 몇 번 호출되었는지, 어떤 인자로 호출되었는지 등 호출 여부와 방식까지 검증할 수 있는 가짜 객체입니다. 목 객체는 행위 검증(Behavior Verification)에 주로 사용됩니다.

NestJS에서는 Jest의 모킹 기능을 활용하여 의존성을 쉽게 모킹할 수 있습니다.

시나리오: UsersServiceDatabaseService에 의존한다고 가정하고, UsersService의 단위 테스트를 작성할 때 DatabaseService를 모킹해 봅시다.

UsersService 코드

// src/users/users.service.ts
import { Injectable } from '@nestjs/common';

interface User {
  id: number;
  name: string;
  email: string;
}

// 가상의 DatabaseService (실제로는 ORM이나 DB 클라이언트가 될 수 있음)
@Injectable()
export class DatabaseService {
  private users: User[] = [
    { id: 1, name: 'Alice', email: 'alice@example.com' },
    { id: 2, name: 'Bob', email: 'bob@example.com' },
  ];
  private nextId = 3;

  async findOneUser(id: number): Promise<User | undefined> {
    // 실제 DB 호출 로직 (비동기)
    console.log(`[DB] Finding user with ID: ${id}`);
    return Promise.resolve(this.users.find(user => user.id === id));
  }

  async createUser(user: { name: string; email: string }): Promise<User> {
    // 실제 DB 삽입 로직 (비동기)
    const newUser = { id: this.nextId++, ...user };
    this.users.push(newUser);
    console.log(`[DB] Creating user: ${newUser.name}`);
    return Promise.resolve(newUser);
  }
}


@Injectable()
export class UsersService {
  constructor(private readonly databaseService: DatabaseService) {} // DatabaseService 의존성 주입

  async getUserById(id: number): Promise<User | undefined> {
    return this.databaseService.findOneUser(id);
  }

  async createUser(name: string, email: string): Promise<User> {
    // 사용자 생성 전 추가 로직이 있을 수 있음
    const newUser = await this.databaseService.createUser({ name, email });
    // 예를 들어, 환영 이메일 발송 등
    return newUser;
  }
}

UsersService에 대한 단위 테스트 (모킹 활용)

// src/users/users.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { UsersService } from './users.service';
import { DatabaseService } from './users.service'; // DatabaseService 임포트

// Mock DatabaseService를 정의합니다.
// Jest의 mock functions를 사용하여 메서드 호출을 가로챕니다.
const mockDatabaseService = {
  // findOneUser 메서드는 스텁 역할을 합니다.
  findOneUser: jest.fn(id => {
    if (id === 1) return Promise.resolve({ id: 1, name: 'MockUser1', email: 'mock1@example.com' });
    return Promise.resolve(undefined);
  }),
  // createUser 메서드는 목 역할을 합니다. (호출 여부 및 인자 검증 가능)
  createUser: jest.fn(user => {
    const newMockUser = { id: 99, ...user }; // 가짜 ID 부여
    return Promise.resolve(newMockUser);
  }),
};

describe('UsersService', () => {
  let service: UsersService; // 테스트할 UsersService 인스턴스

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [
        UsersService,
        {
          provide: DatabaseService, // DatabaseService를 주입받을 때
          useValue: mockDatabaseService, // 실제 DatabaseService 대신 모킹된 객체를 사용
        },
      ],
    }).compile();

    service = module.get<UsersService>(UsersService); // UsersService 인스턴스 가져오기
  });

  // 각 테스트 후 모킹된 함수의 호출 기록을 초기화합니다.
  afterEach(() => {
    jest.clearAllMocks();
  });

  it('should be defined', () => {
    expect(service).toBeDefined();
  });

  describe('getUserById', () => {
    it('should return a user if found', async () => {
      const user = await service.getUserById(1);
      expect(user).toEqual({ id: 1, name: 'MockUser1', email: 'mock1@example.com' });
      // DatabaseService의 findOneUser 메서드가 한 번 호출되었는지 검증 (목 기능)
      expect(mockDatabaseService.findOneUser).toHaveBeenCalledTimes(1);
      expect(mockDatabaseService.findOneUser).toHaveBeenCalledWith(1); // 1이라는 인자로 호출되었는지 검증
    });

    it('should return undefined if user not found', async () => {
      const user = await service.getUserById(2); // mockDatabaseService는 2번 ID에 대해 undefined 반환하도록 설정
      expect(user).toBeUndefined();
      expect(mockDatabaseService.findOneUser).toHaveBeenCalledTimes(1);
      expect(mockDatabaseService.findOneUser).toHaveBeenCalledWith(2);
    });
  });

  describe('createUser', () => {
    it('should create and return a new user', async () => {
      const newUser = await service.createUser('New User', 'new@example.com');
      expect(newUser).toEqual({ id: 99, name: 'New User', email: 'new@example.com' });
      // DatabaseService의 createUser 메서드가 올바른 인자로 호출되었는지 검증
      expect(mockDatabaseService.createUser).toHaveBeenCalledTimes(1);
      expect(mockDatabaseService.createUser).toHaveBeenCalledWith({ name: 'New User', email: 'new@example.com' });
    });
  });
});

주요 모킹 기법 설명

  • jest.fn(): Jest의 목 함수를 생성합니다. 이 함수는 호출된 횟수, 인자 등을 추적할 수 있습니다.
  • provide: DatabaseService, useValue: mockDatabaseService: Test.createTestingModule에서 providers 배열 내에서 DatabaseService 토큰에 실제 DatabaseService 클래스 대신 mockDatabaseService 객체를 주입하도록 설정합니다. 이를 통해 UsersService는 실제 데이터베이스 대신 모킹된 객체와 상호작용합니다.
  • expect(mockDatabaseService.findOneUser).toHaveBeenCalledTimes(1);: findOneUser 메서드가 정확히 한 번 호출되었는지 검증합니다.
  • expect(mockDatabaseService.findOneUser).toHaveBeenCalledWith(1);: findOneUser 메서드가 특정 인자(여기서는 1)로 호출되었는지 검증합니다.
  • jest.clearAllMocks(): afterEach 훅에서 각 테스트 실행 후 모든 목 함수의 호출 기록을 초기화하여, 독립적인 테스트 환경을 유지합니다.

NestJS 컨트롤러 단위 테스트 (간단한 예시)

컨트롤러는 주로 서비스에 의존하므로, 컨트롤러를 단위 테스트할 때는 서비스도 모킹하는 것이 일반적입니다.

// src/app.controller.ts (추가 예시)
import { Controller, Get, Post, Body } from '@nestjs/common';
import { AppService } from './app.service';

@Controller('app')
export class AppController {
  constructor(private readonly appService: AppService) {}

  @Get('hello')
  getHello(): string {
    return this.appService.getHello();
  }

  @Post('sum')
  sumNumbers(@Body() data: { a: number; b: number }): number {
    return this.appService.sum(data.a, data.b);
  }
}
// src/app.controller.spec.ts (새로 생성)
import { Test, TestingModule } from '@nestjs/testing';
import { AppController } from './app.controller';
import { AppService } from './app.service';

// Mock AppService 정의
const mockAppService = {
  getHello: jest.fn(() => 'Hello from Mock!'),
  sum: jest.fn((a, b) => a + b), // 실제 로직과 동일하게 스텁
};

describe('AppController', () => {
  let appController: AppController; // 테스트할 AppController 인스턴스
  let appService: AppService; // 모킹된 AppService 인스턴스 (메서드 호출 추적용)

  beforeEach(async () => {
    const app: TestingModule = await Test.createTestingModule({
      controllers: [AppController],
      providers: [
        {
          provide: AppService, // AppService를 주입받을 때
          useValue: mockAppService, // 모킹된 객체 사용
        },
      ],
    }).compile();

    appController = app.get<AppController>(AppController);
    appService = app.get<AppService>(AppService); // 모킹된 AppService 인스턴스도 가져옵니다.
  });

  afterEach(() => {
    jest.clearAllMocks(); // 각 테스트 후 목 함수 초기화
  });

  it('should be defined', () => {
    expect(appController).toBeDefined();
  });

  describe('getHello', () => {
    it('should return "Hello from Mock!"', () => {
      // 컨트롤러의 메서드를 호출
      const result = appController.getHello();
      // 예상 결과와 일치하는지 검증
      expect(result).toBe('Hello from Mock!');
      // AppService의 getHello 메서드가 호출되었는지 검증
      expect(appService.getHello).toHaveBeenCalledTimes(1);
    });
  });

  describe('sumNumbers', () => {
    it('should return the sum from the service', () => {
      const result = appController.sumNumbers({ a: 5, b: 3 });
      expect(result).toBe(8);
      // AppService의 sum 메서드가 올바른 인자로 호출되었는지 검증
      expect(appService.sum).toHaveBeenCalledTimes(1);
      expect(appService.sum).toHaveBeenCalledWith(5, 3);
    });
  });
});

단위 테스트는 개발 초기 단계에서부터 꾸준히 작성하는 것이 중요합니다. 이는 코드의 안정성을 높이고, 리팩토링 시 자신감을 부여하며, 장기적으로 개발 비용을 절감하는 데 큰 기여를 합니다. NestJS의 테스트 모듈과 Jest의 강력한 모킹 기능을 활용하면 효과적인 단위 테스트를 쉽게 작성할 수 있습니다.