본문으로 건너뛰기

안동민 개발노트

본문 시작

단위 테스트 작성과 모킹

테스트 모듈로 서비스와 컨트롤러를 격리하고 외부 의존성을 mock과 stub으로 대체해 단위 동작을 검증합니다.

8장에서는 테스팅 전략을 다루며, 먼저 단위 테스트(Unit Test) 작성과 모킹(Mocking) 기법을 설명합니다.

소프트웨어 개발에서 테스트는 코드의 품질을 보장하고, 예상치 못한 버그를 발견하며, 향후 코드 변경 시 회귀(regression)를 방지하는 데 필수적입니다.

NestJS의 의존성 주입 구조는 실제로 실행할 대상과 대체할 협력자를 명시하기 쉽게 해 줍니다. 다만 테스트 가능성은 프레임워크만으로 보장되지 않으며, 코드의 책임과 I/O 경계를 작게 나누는 설계가 함께 필요합니다.


테스팅의 중요성 및 종류

다음 세 범위는 자주 쓰는 구분입니다. 이름만으로 테스트를 고정하기보다 실제로 부팅하는 범위, 사용하는 의존성, 진입점을 기준으로 구분해야 합니다.

단위 테스트(Unit Test)
  • 목표: 애플리케이션의 가장 작은 독립적인 코드 조각(함수, 메서드, 클래스)이 예상대로 작동하는지 검증합니다.
  • 특징: 격리된 환경에서 수행되며, 외부 의존성(데이터베이스, 네트워크 요청 등)은 모킹하거나 스텁(Stubbing) 처리합니다.
  • 장점: 실행 속도가 빠르고, 문제 발생 시 정확한 위치를 파악하기 용이하며, 개발 단계에서 피드백을 빠르게 얻을 수 있습니다.
통합 테스트(Integration Test)
  • 목표: 여러 단위(모듈, 서비스)들이 함께 작동하여 예상대로 통신하고 동작하는지 검증합니다.
  • 특징: 실제 의존성(데이터베이스, 다른 서비스)의 일부 또는 전체를 포함하여 테스트합니다.
  • 장점: 실제 환경에 가까운 테스트를 통해 단위 테스트에서 발견하기 어려운 문제를 찾아낼 수 있습니다.
E2E 테스트(End-to-End Test)
  • 목표: 실제 애플리케이션 진입점부터 응답까지의 계약이 정상적으로 동작하는지 검증합니다.
  • 특징: Nest 애플리케이션을 초기화하고 Supertest 등으로 HTTP API를 호출합니다. 브라우저 UI 조작은 프런트엔드나 더 넓은 시스템 테스트의 범위입니다.
  • 장점: 라우팅, 프레임워크 파이프라인, provider 조립과 응답 계약을 함께 검증합니다.
  • 단점: 실행 속도가 느리고, 문제 발생 시 원인을 파악하기 어렵습니다.

아래 다이어그램은 세 범위와 서비스·컨트롤러 단위 테스트에서 실제 대상과 test double이 갈리는 지점을 함께 보여 줍니다.

NestJS의 단위·통합·E2E 테스트 범위와 서비스·컨트롤러 단위 테스트에서 실제 대상, test double, 결과와 호출 단언을 나누는 기준

Test scope · observable boundary

단위 테스트는 대상 하나를 실제로 실행하고 경계 밖 협력자는 test double로 바꿉니다. 결과를 통제하는 일과 호출을 관찰하는 일을 구분하면 무엇을 검증했는지가 선명해집니다.

단위

클래스 하나와 직접 맞닿은 계약

대상 메서드를 직접 호출하고 DB·네트워크 같은 경계는 double로 대체합니다.

통합

여러 provider의 조립과 협력

모듈 wiring, 실제 구현 사이의 계약, 선택한 어댑터와의 상호작용을 함께 확인합니다.

E2E

부팅한 Nest 앱의 HTTP 계약

실제 앱을 초기화하고 HTTP 요청부터 라우팅, 프레임워크 처리와 응답까지 검증합니다.

실제 대상 · Service

UsersService는 규칙을 실행

  • 경계 double: DatabaseService의 성공값, 빈 결과, reject를 테스트가 지정
  • 결과 관찰: 반환값과 도메인 오류 분기를 단언
  • 상호작용 관찰: 규칙상 필요한 DB 호출과 인자만 단언

실제 DB 구현과 네트워크는 이 단위 테스트의 실행 경계 밖입니다.

실제 대상 · Controller

AppController는 위임 계약을 실행

  • 경계 double: AppService가 정해진 결과나 오류를 반환
  • 위임 관찰: 요청 값에서 만든 서비스 호출 인자를 단언
  • 반환 관찰: 서비스 결과를 약속한 형태로 돌려주는지 단언

메서드 직접 호출만으로 라우팅 데코레이터, Guard, Pipe, Interceptor, Exception Filter, HTTP 직렬화가 실행되지는 않습니다.

결과 제어

Stub 역할

mockResolvedValueOncemockRejectedValueOnce로 협력자의 이번 응답을 고정해 대상의 분기를 재현합니다.

호출 관찰

Mock 역할

toHaveBeenCalledWith처럼 계약상 중요한 호출 횟수·인자를 관찰합니다. 내부 구현 순서를 그대로 복제하지 않습니다.

Nest API의 E2E는 보통 초기화한 애플리케이션에 HTTP 요청을 보내는 테스트입니다. 브라우저 UI 조작은 별도의 프런트엔드·시스템 테스트 범위입니다.

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


NestJS에서 단위 테스트 설정

Nest CLI 기본 스타터는 Jest 의존성과 테스트 스크립트, 단위 테스트 예시와 E2E 설정을 함께 제공합니다. 파일 위치와 설정 형식은 CLI 버전과 프로젝트 옵션에 따라 달라질 수 있습니다.

기본 파일 구조
app.controller.ts
app.controller.spec.ts
app.module.ts
app.service.ts
app.e2e-spec.ts # E2E 테스트 예시
package.json # test 스크립트와 Jest 설정

기본 스타터에서는 npm test로 시작할 수 있습니다. 기존 저장소나 사용자 정의 빌드에서는 TypeScript 변환, 테스트 환경, 경로 별칭 설정을 프로젝트 구성에 맞게 확인해야 합니다.

이제 특정 서비스에 대한 단위 테스트를 작성해 보겠습니다.


단위 테스트 작성: 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 패키지를 통해 테스트 유틸리티를 제공합니다. 다음 예시는 소스 옆에 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

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


모킹과 스텁

단위 테스트 작성과 모킹에서는 실제 DB나 외부 API를 실행하지 않고, 테스트 대상이 의존성에 어떤 값을 요청하고 어떤 결과를 돌려받는지만 고정합니다.

실제 애플리케이션에서는 서비스가 다른 서비스나 데이터베이스, 외부 API 등 다양한 의존성을 가집니다.

단위 테스트는 이런 의존성을 배제하고 테스트 대상 코드만 격리하여 테스트해야 합니다.

이때 모킹(Mocking)스텁(Stubbing)이 사용됩니다.

  • 스텁 역할(Stub role): test double의 반환값이나 오류를 미리 정해 대상이 실행할 분기를 통제합니다.
  • 목 역할(Mock role): test double이 호출됐는지, 몇 번·어떤 인자로 호출됐는지처럼 의미 있는 상호작용을 관찰합니다. 하나의 jest.fn()이 한 사례에서 두 역할을 함께 맡을 수도 있습니다.

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';

const createDatabaseServiceMock = () => ({
  findOneUser: jest.fn(),
  createUser: jest.fn(),
});

describe('UsersService', () => {
  let service: UsersService;
  let databaseServiceMock: ReturnType<typeof createDatabaseServiceMock>;

  beforeEach(async () => {
    // 테스트마다 새 double을 만들어 호출 기록과 구현을 공유하지 않습니다.
    databaseServiceMock = createDatabaseServiceMock();

    const module: TestingModule = await Test.createTestingModule({
      providers: [
        UsersService,
        {
          provide: DatabaseService,
          useValue: databaseServiceMock,
        },
      ],
    }).compile();

    service = module.get<UsersService>(UsersService);
  });

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

  describe('getUserById', () => {
    it('should return a user if found', async () => {
      databaseServiceMock.findOneUser.mockResolvedValueOnce({
        id: 1,
        name: 'MockUser1',
        email: 'mock1@example.com',
      });

      const user = await service.getUserById(1);

      expect(user).toEqual({ id: 1, name: 'MockUser1', email: 'mock1@example.com' });
      expect(databaseServiceMock.findOneUser).toHaveBeenCalledTimes(1);
      expect(databaseServiceMock.findOneUser).toHaveBeenCalledWith(1);
    });

    it('should return undefined if user not found', async () => {
      databaseServiceMock.findOneUser.mockResolvedValueOnce(undefined);

      const user = await service.getUserById(2);

      expect(user).toBeUndefined();
      expect(databaseServiceMock.findOneUser).toHaveBeenCalledWith(2);
    });
  });

  describe('createUser', () => {
    it('should create and return a new user', async () => {
      databaseServiceMock.createUser.mockResolvedValueOnce({
        id: 99,
        name: 'New User',
        email: 'new@example.com',
      });

      const newUser = await service.createUser('New User', 'new@example.com');

      expect(newUser).toEqual({ id: 99, name: 'New User', email: 'new@example.com' });
      expect(databaseServiceMock.createUser).toHaveBeenCalledTimes(1);
      expect(databaseServiceMock.createUser).toHaveBeenCalledWith({ name: 'New User', email: 'new@example.com' });
    });
  });
});
주요 모킹 기법 설명
  • jest.fn(): Jest의 mock 함수를 생성합니다. 각 사례에서 반환값이나 reject를 정하고 호출 횟수와 인자를 관찰할 수 있습니다.
  • provide: DatabaseService, useValue: databaseServiceMock: Test.createTestingModuleproviders 배열에서 DatabaseService 토큰에 실제 클래스 대신 double을 주입하도록 설정합니다. 이를 통해 UsersService는 실제 데이터베이스 대신 모킹된 객체와 상호작용합니다.
  • expect(databaseServiceMock.findOneUser).toHaveBeenCalledWith(1);: findOneUser가 계약상 필요한 인자로 호출되었는지 검증합니다.
  • 예시처럼 beforeEach에서 새 double을 만들면 테스트 사이에 호출 기록과 임시 구현이 공유되지 않습니다. 공유 mock을 선택했다면 clear, reset, restore의 차이를 알고 필요한 범위에서만 정리해야 합니다.

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';

const createAppServiceMock = () => ({
  getHello: jest.fn(),
  sum: jest.fn(),
});

describe('AppController', () => {
  let appController: AppController;
  let appServiceMock: ReturnType<typeof createAppServiceMock>;

  beforeEach(async () => {
    appServiceMock = createAppServiceMock();

    const app: TestingModule = await Test.createTestingModule({
      controllers: [AppController],
      providers: [
        {
          provide: AppService,
          useValue: appServiceMock,
        },
      ],
    }).compile();

    appController = app.get<AppController>(AppController);
  });

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

  describe('getHello', () => {
    it('should return "Hello from Mock!"', () => {
      appServiceMock.getHello.mockReturnValueOnce('Hello from Mock!');

      const result = appController.getHello();

      expect(result).toBe('Hello from Mock!');
      expect(appServiceMock.getHello).toHaveBeenCalledTimes(1);
    });
  });

  describe('sumNumbers', () => {
    it('should return the sum from the service', () => {
      // 계산을 mock에 복제하지 않고 이번 협력 결과만 정합니다.
      appServiceMock.sum.mockReturnValueOnce(8);

      const result = appController.sumNumbers({ a: 5, b: 3 });

      expect(result).toBe(8);
      expect(appServiceMock.sum).toHaveBeenCalledTimes(1);
      expect(appServiceMock.sum).toHaveBeenCalledWith(5, 3);
    });
  });
});

이 테스트는 컨트롤러 메서드를 직접 호출하므로 서비스 위임과 반환값은 검증하지만, 라우팅 데코레이터와 Guard, Pipe, Interceptor, Exception Filter, HTTP 직렬화 같은 프레임워크 요청 파이프라인은 실행하지 않습니다. 그 경로가 목적이라면 초기화한 애플리케이션에 HTTP 요청을 보내는 통합 또는 E2E 테스트를 별도로 작성합니다.

단위 테스트가 안정적으로 유지되려면 double의 계약과 수명, 결과·오류·상호작용의 검증 지점을 함께 관리해야 합니다.

NestJS 모킹 테스트에서 책임 경계를 고르고 실제 계약을 닮은 double을 조립한 뒤 결과·오류·의미 있는 호출을 단언하고 Jest mock 상태를 격리하는 순서

Contract first · isolation last

좋은 mock은 운영 로직을 다시 구현하지 않고 협력자의 계약만 표현합니다. 테스트마다 필요한 응답을 지정하고, 결과와 오류 뒤에 정말 의미 있는 상호작용만 관찰합니다.

  1. 대상 책임과 실행 경계를 고른다

    서비스 규칙을 볼지, 컨트롤러 위임을 볼지 먼저 정합니다. DB·네트워크·다른 provider처럼 경계 밖 협력자만 대체합니다.

  2. 실제 의존성 계약을 닮은 double을 만든다

    주입 토큰, 메서드 이름, 인자, 동기·Promise 반환 형태를 맞춥니다. double 안에 대상과 같은 계산·분기 로직을 복사하지 않습니다.

  3. TestingModule에 실제 대상과 double을 조립한다

    provide는 실제 토큰을 가리키고 useValue는 이번 테스트가 통제하는 객체를 제공합니다.

  4. 이번 사례의 응답을 지정하고 대상만 실행한다

    mockResolvedValueOnce, mockRejectedValueOnce처럼 사례별 성공·빈 결과·실패를 준비한 뒤 서비스나 컨트롤러 메서드를 호출합니다.

  5. 결과·오류와 의미 있는 상호작용을 단언한다

    외부에 보이는 반환값과 예외를 우선 확인하고, 계약상 필요한 호출 횟수·인자만 추가합니다. 우연한 내부 호출 순서는 피합니다.

  6. 다음 사례가 새 상태에서 시작하도록 격리한다

    기본 정책은 beforeEach에서 새 double과 모듈을 만드는 것입니다. 공유 mock이나 spy를 썼을 때만 해당 수명에 맞는 정리 동작을 고릅니다.

clear

jest.clearAllMocks()

모든 mock의 호출·인스턴스·context·result 기록을 비우지만, 지정한 구현과 반환값은 유지합니다.

reset

jest.resetAllMocks()

기록을 비우고 mock 구현을 undefined를 반환하는 빈 함수로 바꿉니다. 원래 함수 구현을 복구하는 동작은 아닙니다.

restore

jest.restoreAllMocks()

jest.spyOnjest.replaceProperty로 바꾼 대상을 원래 구현·값으로 되돌립니다. 일반 jest.fn은 자동 복구되지 않습니다.

fresh first

새 double이 가장 단순한 기본값

가변 반환값이나 호출 기록을 공유하지 않으므로 테스트 순서에 덜 민감합니다. beforeEach에서 생성하면 수명이 코드에 드러납니다.

hook by owner

정리 hook은 만든 범위가 책임진다

모든 테스트에 전역 clear를 관성적으로 두지 않습니다. 공유 spy는 afterEach에서 restore하고, 공유 mock은 의도에 따라 clear 또는 reset합니다.

초기화 API는 서로 바꿔 쓸 수 없습니다. “기록만 비울지, 가짜 구현도 지울지, 실제 구현으로 되돌릴지”를 테스트 격리 요구에 맞춰 선택합니다.