본문 바로가기

단위 테스트 작성과 모킹

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

8장에서는 테스트 전략을 다룹니다. 첫 글인 이 글에서는 단위 테스트(unit test)를 작성하는 방법과 의존성을 가짜로 바꾸는 모킹(mocking) 기법을 설명합니다.

테스트는 코드의 품질을 지키고, 예상하지 못한 버그를 찾고, 나중에 코드를 바꿀 때 이미 되던 기능이 깨지는 회귀(regression)를 막는 데 꼭 필요합니다. NestJS의 의존성 주입 구조는 실제로 실행할 대상과 가짜로 바꿀 협력자를 분명하게 나누기 쉽게 해 줍니다. 다만 테스트하기 쉬운 코드는 프레임워크만으로 만들어지지 않으며, 클래스가 맡는 일과 I/O를 작게 나누는 설계가 함께 필요합니다.


테스트의 종류

다음 세 가지는 자주 쓰는 구분입니다. 이름보다는 실제로 무엇을 부팅하는지, 어떤 의존성을 쓰는지, 어디서 호출을 시작하는지를 기준으로 구분하는 편이 정확합니다.

단위 테스트는 함수, 메서드, 클래스처럼 가장 작은 코드 조각이 예상대로 동작하는지 확인합니다. 격리된 환경에서 실행하며 데이터베이스나 네트워크 요청 같은 외부 의존성은 모킹하거나 스텁(stub)으로 바꿉니다. 실행이 빠르고, 문제가 생겼을 때 위치를 쉽게 찾을 수 있으며, 개발 중에 빠르게 피드백을 받을 수 있습니다.

통합 테스트(integration test)는 여러 모듈이나 서비스가 함께 동작하며 예상대로 통신하는지 확인합니다. 데이터베이스나 다른 서비스 같은 실제 의존성의 일부나 전부를 포함하므로, 단위 테스트로는 찾기 어려운 문제를 실제 환경에 가깝게 찾아낼 수 있습니다.

E2E 테스트(end-to-end test)는 애플리케이션의 실제 진입점부터 응답까지가 정상적으로 동작하는지 확인합니다. Nest 애플리케이션을 초기화하고 Supertest 같은 도구로 HTTP API를 호출하며, 라우팅, 프레임워크 처리 과정, provider 조립, 응답 형식을 함께 검증합니다. 대신 실행이 느리고 문제가 생겼을 때 원인을 찾기 어렵습니다. 브라우저 UI 조작은 프런트엔드나 더 넓은 시스템 테스트의 범위입니다.

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

Nest · Test scope

단위 테스트의 실행 경계

같은 메서드 호출도 실제로 통과한 경계에 따라 의미가 다릅니다. 서비스와 컨트롤러의 의존성을 대체하면 로직과 호출 계약을 빠르게 검증할 수 있습니다.

실제 대상과 double을 두 경계에서 구분한다 두 단위 테스트는 독립된 경계이다. 서비스 사례는 테스트가 실제 UsersService를 호출하고 DatabaseService double의 결과 또는 오류를 돌려받아 결과와 DB 호출 인자를 단언한다. 컨트롤러 사례는 테스트가 실제 AppController를 직접 호출하고 AppService double에 위임한 값과 반환 결과를 단언한다. 점선은 DI 토큰에 double을 주입하는 설정이며 HTTP 요청이나 실제 DB 연결이 아니다. 두 영역 사이에는 실행 화살표가 없다. 서비스 단위 컨트롤러 단위 호출 협력 DI 관찰 호출 협력 DI 관찰 서비스 테스트 결과와 인자 단언 실제 UsersService 직접 메서드 호출 DB double 성공 · 없음 · 오류 컨트롤러 테스트 위임과 반환 단언 실제 AppController 직접 메서드 호출 Service double 협력 결과 고정
단위 테스트 두 경계와 double 주입 두 단위 테스트는 독립된 경계이다. 서비스 사례는 테스트가 실제 UsersService를 호출하고 DatabaseService double의 결과 또는 오류를 돌려받아 결과와 DB 호출 인자를 단언한다. 컨트롤러 사례는 테스트가 실제 AppController를 직접 호출하고 AppService double에 위임한 값과 반환 결과를 단언한다. 점선은 DI 토큰에 double을 주입하는 설정이며 HTTP 요청이나 실제 DB 연결이 아니다. 두 영역 사이에는 실행 화살표가 없다. 서비스 단위 컨트롤러 단위 서비스 테스트 결과와 인자 단언 실제 UsersService 메서드 직접 호출 DB double 성공 · 없음 · 오류 컨트롤러 테스트 위임과 반환 단언 실제 AppController 메서드 직접 호출 Service double 계산을 복제하지 않음
단위

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

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

통합

여러 provider의 조립과 협력

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

E2E

부팅한 Nest 앱의 HTTP 계약

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

실제 대상 · Service

UsersService는 DB 호출을 위임

  • 경계 double: DatabaseService의 성공값과 빈 결과를 테스트가 지정
  • 결과 관찰: 예제는 반환값과 빈 결과를 단언. 오류는 별도 사례로 추가
  • 상호작용 관찰: 규칙상 필요한 DB 호출과 인자만 단언

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

실제 대상 · Controller

AppController는 위임 계약을 실행

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

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

결과 제어

Stub 역할

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

호출 관찰

Mock 역할

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

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

위 그림의 위쪽은 이 글에서 다룰 두 가지 단위 테스트를 나란히 보여 줍니다. 서비스 테스트에서는 실제 UsersService를 직접 호출하고 DatabaseService 자리에 가짜 객체라고도 부르는 테스트 대역(test double)을 넣어 결과와 호출 인자를 확인하며, controller 테스트에서는 실제 AppController를 호출하고 AppService 자리에 테스트 대역을 넣어 위임과 반환값을 확인합니다. 점선은 DI token에 테스트 대역을 주입하는 설정일 뿐 HTTP 요청이나 실제 DB 연결이 아닙니다. 아래쪽은 결과를 정해 주는 stub 역할과 호출을 지켜보는 mock 역할을 구분해 정리합니다.

이 글에서는 가장 기본이 되는 단위 테스트에 집중합니다.


NestJS의 단위 테스트 설정

Nest 공식 테스트 문서는 새로 만든 프로젝트가 기본으로 Vitest를 쓴다고 안내합니다. 이는 NestJS 12의 기본 모듈 형식인 ESM 프로젝트의 이야기이며, 1장에서 CommonJS를 고른 이 교재의 프로젝트에는 Jest 30이 설정되어 있습니다. Nest가 제공하는 테스트 모듈과 의존성 주입 기능은 특정 러너에 묶여 있지 않으므로, 아래 예제의 jest.fn() 자리에 Vitest의 vi.fn()을 쓰면 같은 방식으로 동작합니다.

CommonJS로 만든 프로젝트에는 다음과 같은 테스트 관련 파일이 있습니다.

app.controller.ts
app.controller.spec.ts
app.module.ts
app.service.ts
app.e2e-spec.ts # E2E 테스트 예시
jest-e2e.json # E2E용 Jest 설정
jest.config.ts # 단위 테스트용 Jest 설정
package.json # test 스크립트

NestJS 12의 CommonJS 템플릿은 Jest 설정을 package.json 안이 아니라 프로젝트 루트의 jest.config.ts에 둡니다. 이 파일은 ts-jest로 TypeScript를 변환하고, 이름이 .spec.ts로 끝나는 파일을 테스트로 찾으며, tsconfig.json의 경로 별칭도 읽어 옵니다. package.json의 test 스크립트는 node --experimental-vm-modules ./node_modules/jest/bin/jest.js처럼 Node.js 옵션을 붙여 Jest를 실행하는데, ESM 전용인 NestJS 12 패키지를 Jest가 불러오려면 1장에서 설명한 대로 Node.js 24.9 이상이 필요합니다. 그래서 테스트는 npx jest가 아니라 npm test로 실행합니다.

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


단위 테스트 작성: 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;
  }
}

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);
  });
});

beforeEach가 테스트마다 테스트 모듈을 새로 컴파일하므로, 세 it 블록은 각자 새로 만든 AppService 인스턴스를 받습니다. module.get()은 실제 애플리케이션과 같은 방식의 DI 컨테이너에서 인스턴스를 꺼내므로, 서비스에 생성자 의존성이 있다면 그것도 providers에 등록해야 컴파일이 성공합니다. 이제 테스트를 실행합니다. CLI가 만든 test 스크립트는 Jest를 실행하고, Jest는 설정에 맞는 .spec.ts 파일을 찾아 실행합니다.

npm run test # 또는 npm test

콘솔에 세 테스트가 모두 통과했다는 결과가 나타납니다.


모킹과 스텁

실제 애플리케이션의 서비스는 다른 서비스, 데이터베이스, 외부 API처럼 여러 의존성을 가집니다. 단위 테스트는 이런 의존성을 실제로 실행하지 않고, 테스트 대상이 의존성에 어떤 값을 요청하고 어떤 결과를 돌려받는지만 정해 둔 채 대상 코드만 격리해서 확인합니다. 이때 모킹과 스텁을 씁니다.

  • 스텁 역할: 테스트 대역의 반환값이나 오류를 미리 정해, 대상이 어떤 분기를 실행할지 통제합니다.
  • 목(mock) 역할: 테스트 대역이 호출되었는지, 몇 번 어떤 인자로 호출되었는지처럼 의미 있는 상호작용을 지켜봅니다. 하나의 jest.fn()이 한 사례 안에서 두 역할을 함께 맡을 수도 있습니다.

예를 들어 UsersService가 DatabaseService에 의존한다고 하고, UsersService의 단위 테스트에서 DatabaseService를 가짜로 바꿔 보겠습니다.

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: '[email protected]' },
    { id: 2, name: 'Bob', email: '[email protected]' },
  ];
  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;
  }
}

위 DatabaseService는 비동기 메서드를 가진 메모리 저장소 예제입니다. 주석에 DB 호출이라고 적혀 있어도 실제 DB 연결은 없습니다. 이어지는 테스트는 이 클래스의 저장 동작을 실행하지 않고, 같은 DI token에 주입한 테스트 대역과 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: '[email protected]',
      });

      const user = await service.getUserById(1);

      expect(user).toEqual({ id: 1, name: 'MockUser1', email: '[email protected]' });
      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: '[email protected]',
      });

      const newUser = await service.createUser('New User', '[email protected]');

      expect(newUser).toEqual({ id: 99, name: 'New User', email: '[email protected]' });
      expect(databaseServiceMock.createUser).toHaveBeenCalledTimes(1);
      expect(databaseServiceMock.createUser).toHaveBeenCalledWith({ name: 'New User', email: '[email protected]' });
    });
  });
});

이 테스트에서 쓴 모킹 기법은 다음과 같습니다.

  • jest.fn(): Jest의 mock 함수를 만듭니다. 사례마다 반환값이나 reject를 정하고, 호출 횟수와 인자를 확인할 수 있습니다.
  • provide: DatabaseService, useValue: databaseServiceMock: Test.createTestingModule()의 providers에서 DatabaseService token에 실제 클래스 대신 테스트 대역을 주입하도록 설정합니다. 그래서 UsersService는 실제 저장소 대신 테스트 대역과 상호작용합니다.
  • expect(databaseServiceMock.findOneUser).toHaveBeenCalledWith(1): findOneUser가 필요한 인자로 호출되었는지 확인합니다.
  • 예시처럼 beforeEach에서 매번 새 테스트 대역을 만들면, 테스트 사이에 호출 기록이나 임시로 정한 반환값이 공유되지 않습니다. 테스트 대역을 여러 테스트가 함께 쓰기로 했다면 clear, reset, restore의 차이를 알고 필요한 범위에서만 정리해야 합니다.

의존성이 실패할 때도 확인하기

사용자를 찾지 못해 undefined를 돌려받는 경우와 조회 자체가 실패한 경우는 다릅니다. 앞의 테스트 파일에서 service와 databaseServiceMock을 준비하는 바깥 describe 안에 다음 사례를 추가합니다. 서비스는 오류를 잡거나 HTTP 응답으로 바꾸지 않으므로, 테스트 대역이 거절한 Promise의 오류가 호출한 쪽에 그대로 전달되어야 합니다.

src/users/users.service.spec.ts (추가할 실패 사례)
it('should propagate a database failure', async () => {
  const failure = new Error('DB offline');
  databaseServiceMock.findOneUser.mockRejectedValueOnce(failure);

  await expect(service.getUserById(7)).rejects.toBe(failure);
  expect(databaseServiceMock.findOneUser).toHaveBeenCalledTimes(1);
  expect(databaseServiceMock.findOneUser).toHaveBeenCalledWith(7);
});

실행 순서는 beforeEach에서 새 테스트 대역 생성, 이번 사례에서 한 번 거절할 결과 설정, 실제 서비스 호출, 오류와 호출 인자 확인입니다. await를 빼면 비동기 단언이 끝나기 전에 테스트가 끝나 버릴 수 있습니다. 이 테스트는 DB 장애 때 HTTP 상태가 500인지 확인하는 것이 아니며, HTTP 상태는 controller 뒤의 예외 처리 단계에서 따로 확인합니다.

더 깊이: Jest 29의 테스트를 Jest 30으로 옮길 때 깨지는 곳

NestJS 12의 nest upgrade는 Jest를 30으로 올리므로, 예전 프로젝트의 테스트 코드나 오래된 예제를 Jest 30에서 실행하면 몇 군데가 깨질 수 있습니다. Jest 30 이전 안내에 따르면 Jest 26부터 폐기 예정이던 matcher의 별칭이 모두 제거되었습니다. 그래서 toBeCalledWith()는 toHaveBeenCalledWith()로, toBeCalledTimes()는 toHaveBeenCalledTimes()로, toThrowError()는 toThrow()로 바꿔야 하며, 동작은 같고 이름만 다릅니다. 예전 jest.genMockFromModule()도 사라져 jest.createMockFromModule()을 써야 합니다.

타입과 경로도 더 엄격해졌습니다. 다만 타입 변화는 @jest/globals에서 expect와 jest를 직접 가져와 쓰는 경우에 해당합니다. 그때는 jest.spyOn()의 반환 타입에 붙이던 jest.SpyInstance가 공개 API에서 빠졌으므로 jest.Spied로 바꾸고, toHaveBeenCalledWith() 같은 matcher가 mock 함수의 매개변수 타입을 추론하므로 예전에는 지나치던 타입 오류가 새로 드러날 수 있습니다. 이 교재의 템플릿처럼 @types/jest의 전역 타입을 쓰면 jest.SpyInstance가 그대로 남아 있고 matcher도 인자 타입을 추론하지 않습니다. 또 jest.mock()은 이제 모듈 경로의 대소문자를 구분하므로, 경로의 대소문자가 실제 파일 이름과 다른 호출은 Jest 30에서 동작하지 않습니다.


NestJS controller 단위 테스트

controller는 주로 서비스에 의존하므로, controller를 단위 테스트할 때는 보통 서비스도 가짜로 바꿉니다. 먼저 테스트할 controller입니다.

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);
    });
  });
});

이 테스트는 controller 메서드를 직접 호출하므로 서비스에 일을 맡기는지와 반환값은 확인하지만, 라우팅 데코레이터, guard, pipe, interceptor, exception filter, HTTP 직렬화 같은 프레임워크의 요청 처리 과정은 실행하지 않습니다. 그 과정을 확인하려면 초기화한 애플리케이션에 HTTP 요청을 보내는 통합 테스트나 E2E 테스트를 따로 작성합니다.

단위 테스트가 오래 안정적으로 유지되려면 테스트 대역의 모양과 수명, 결과·오류·상호작용을 확인하는 지점을 함께 관리해야 합니다.

NestJS 모킹 테스트의 계약 설계와 격리 정책

새 double·DI 조립·성공/거절 지정·대상 실행·단언·다음 새 double로 테스트 기록의 수명이 바뀐다.

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

Nest · Mock lifecycle

Mock 구성과 정리의 순서

테스트할 계약을 정한 뒤 의존성을 대체하고 결과와 호출을 단언합니다. 호출 이력, 구현, 원래 속성의 복원은 서로 다른 정리 작업입니다.

실행·단언을 마친 다음 사례는 새 상태로 돌아간다 한 사례에서 경계를 정하고 새 double을 만든 뒤 TestingModule에서 DI 토큰으로 조립한다. 이번 성공값 또는 거절을 지정하고 실제 대상을 실행해 결과와 의미 있는 호출만 단언한다. 다음 beforeEach는 다시 새 double로 돌아가므로 이전 기록과 구현을 공유하지 않는다. 마지막 점선은 다음 사례 준비이고 clear/reset/restore를 모두 연속 수행하라는 뜻이 아니다. 공유 mock이나 spy를 선택한 경우의 정리 차이는 아래 비교를 따른다. 다음 사례 새 객체 경계 선택 대상 · 협력자 새 double 생성 beforeEach DI 토큰 조립 useValue 이번 응답 지정 성공 · 없음 · reject 실제 대상 호출 동기 / Promise 계약 결과와 호출 단언 await · 값 · 인자 다음 beforeEach 이전 상태 공유 없음
사례마다 새 double로 시작하는 수명 한 사례에서 경계를 정하고 새 double을 만든 뒤 TestingModule에서 DI 토큰으로 조립한다. 이번 성공값 또는 거절을 지정하고 실제 대상을 실행해 결과와 의미 있는 호출만 단언한다. 다음 beforeEach는 다시 새 double로 돌아가므로 이전 기록과 구현을 공유하지 않는다. 마지막 점선은 다음 사례 준비이고 clear/reset/restore를 모두 연속 수행하라는 뜻이 아니다. 공유 mock이나 spy를 선택한 경우의 정리 차이는 아래 비교를 따른다. 경계 선택 실제 대상 · 협력자 새 double 생성 beforeEach TestingModule 조립 토큰 → useValue 이번 응답 지정 성공 · 없음 · reject 실제 대상 호출 동기 / Promise 계약 결과와 호출 단언 await · 값 · 인자 다음 beforeEach 이전 상태 공유 없음
  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.spyOn과 jest.replaceProperty로 바꾼 대상을 원래 구현·값으로 되돌립니다. 일반 jest.fn은 자동 복구되지 않습니다.

fresh first

새 double이 가장 단순한 기본값

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

hook by owner

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

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

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

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • 한 사례에서 경계를 정하고 새 double을 만든 뒤 TestingModule에서 DI 토큰으로 조립한다. 이번 성공값 또는 거절을 지정하고 실제 대상을 실행해 결과와 의미 있는 호출만 단언한다. 다음 beforeEach는 다시 새 double로 돌아가므로 이전 기록과 구현을 공유하지 않는다. 마지막 점선은 다음 사례 준비이고 clear/reset/restore를 모두 연속 수행하라는 뜻이 아니다. 공유 mock이나 spy를 선택한 경우의 정리 차이는 아래 비교를 따른다.
관계도의 보충 설명
원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
1NestJS 모킹 테스트의 계약 설계와 격리 정책
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
새 double·DI

사례마다 새 double을 만들어 TestingModule에 조립합니다.

double: 새 객체 · 연결: DI token이전 기록이 없는 시험 입력이 생깁니다.
행동과 대상 실행

이번 성공·거절을 지정하고 실제 대상을 호출합니다.

입력: 이번 행동 · 실행: 실제 대상대역 계약으로 실제 결과를 관찰합니다.
단언·다음 새 사례

결과·의미 있는 호출을 단언하고 다음 beforeEach에서는 새 double을 만듭니다.

단언: 결과·의미 있는 호출 · 다음: 새 double사례 사이 기록·구현을 공유하지 않습니다.

NestJS 모킹 테스트의 계약 설계와 격리 정책

새 double·DI 조립·성공/거절 지정·대상 실행·단언·다음 새 double로 테스트 기록의 수명이 바뀐다.

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

Nest · Mock lifecycle

Mock 구성과 정리의 순서

테스트할 계약을 정한 뒤 의존성을 대체하고 결과와 호출을 단언합니다. 호출 이력, 구현, 원래 속성의 복원은 서로 다른 정리 작업입니다.

실행·단언을 마친 다음 사례는 새 상태로 돌아간다 한 사례에서 경계를 정하고 새 double을 만든 뒤 TestingModule에서 DI 토큰으로 조립한다. 이번 성공값 또는 거절을 지정하고 실제 대상을 실행해 결과와 의미 있는 호출만 단언한다. 다음 beforeEach는 다시 새 double로 돌아가므로 이전 기록과 구현을 공유하지 않는다. 마지막 점선은 다음 사례 준비이고 clear/reset/restore를 모두 연속 수행하라는 뜻이 아니다. 공유 mock이나 spy를 선택한 경우의 정리 차이는 아래 비교를 따른다. 다음 사례 새 객체 경계 선택 대상 · 협력자 새 double 생성 beforeEach DI 토큰 조립 useValue 이번 응답 지정 성공 · 없음 · reject 실제 대상 호출 동기 / Promise 계약 결과와 호출 단언 await · 값 · 인자 다음 beforeEach 이전 상태 공유 없음
사례마다 새 double로 시작하는 수명 한 사례에서 경계를 정하고 새 double을 만든 뒤 TestingModule에서 DI 토큰으로 조립한다. 이번 성공값 또는 거절을 지정하고 실제 대상을 실행해 결과와 의미 있는 호출만 단언한다. 다음 beforeEach는 다시 새 double로 돌아가므로 이전 기록과 구현을 공유하지 않는다. 마지막 점선은 다음 사례 준비이고 clear/reset/restore를 모두 연속 수행하라는 뜻이 아니다. 공유 mock이나 spy를 선택한 경우의 정리 차이는 아래 비교를 따른다. 경계 선택 실제 대상 · 협력자 새 double 생성 beforeEach TestingModule 조립 토큰 → useValue 이번 응답 지정 성공 · 없음 · reject 실제 대상 호출 동기 / Promise 계약 결과와 호출 단언 await · 값 · 인자 다음 beforeEach 이전 상태 공유 없음
  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.spyOn과 jest.replaceProperty로 바꾼 대상을 원래 구현·값으로 되돌립니다. 일반 jest.fn은 자동 복구되지 않습니다.

fresh first

새 double이 가장 단순한 기본값

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

hook by owner

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

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

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

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • 한 사례에서 경계를 정하고 새 double을 만든 뒤 TestingModule에서 DI 토큰으로 조립한다. 이번 성공값 또는 거절을 지정하고 실제 대상을 실행해 결과와 의미 있는 호출만 단언한다. 다음 beforeEach는 다시 새 double로 돌아가므로 이전 기록과 구현을 공유하지 않는다. 마지막 점선은 다음 사례 준비이고 clear/reset/restore를 모두 연속 수행하라는 뜻이 아니다. 공유 mock이나 spy를 선택한 경우의 정리 차이는 아래 비교를 따른다.

관계도의 보충 설명

원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
1NestJS 모킹 테스트의 계약 설계와 격리 정책
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
새 double·DI

사례마다 새 double을 만들어 TestingModule에 조립합니다.

double: 새 객체 · 연결: DI token이전 기록이 없는 시험 입력이 생깁니다.
행동과 대상 실행

이번 성공·거절을 지정하고 실제 대상을 호출합니다.

입력: 이번 행동 · 실행: 실제 대상대역 계약으로 실제 결과를 관찰합니다.
단언·다음 새 사례

결과·의미 있는 호출을 단언하고 다음 beforeEach에서는 새 double을 만듭니다.

단언: 결과·의미 있는 호출 · 다음: 새 double사례 사이 기록·구현을 공유하지 않습니다.

위 그림은 모킹 테스트 한 사례의 수명을 순서대로 보여 줍니다. 무엇을 테스트할지 정하고, beforeEach에서 새 테스트 대역을 만들어 DI token에 조립한 뒤, 이번 사례의 성공값이나 거절을 지정하고 실제 대상을 호출해 결과와 의미 있는 호출만 확인합니다. 다음 사례는 다시 새 테스트 대역으로 시작하므로 이전 상태를 공유하지 않습니다. 아래쪽 세 칸은 Jest의 정리 함수 세 가지를 비교합니다. clearAllMocks()는 호출 기록만 비우고 정해 둔 구현은 남기며, resetAllMocks()는 구현까지 지워 undefined를 반환하게 하고, restoreAllMocks()는 jest.spyOn()으로 바꾼 대상을 원래 구현으로 되돌립니다.


정리

  • 단위 테스트는 작은 코드 조각을 격리해 확인하고, 통합 테스트는 여러 provider의 협력을, E2E 테스트는 부팅한 앱의 HTTP 동작을 확인합니다.
  • 이 교재의 CommonJS 프로젝트는 Jest 30을 쓰며, 설정은 jest.config.ts에 있고 테스트는 Node.js 옵션이 붙은 npm test 스크립트로 실행합니다.
  • Test.createTestingModule()의 providers에 { provide: 실제토큰, useValue: 가짜객체 }를 넣으면 대상 클래스가 실제 의존성 대신 테스트 대역을 주입받습니다.
  • mockResolvedValueOnce()와 mockRejectedValueOnce()로 이번 사례의 결과를 정하고, toHaveBeenCalledWith()로 의미 있는 호출만 확인합니다.
  • controller 메서드를 직접 호출하는 테스트는 guard, pipe, filter 같은 요청 처리 과정을 실행하지 않습니다.

연습 문제

1. 새로 만든 가짜 객체의 기본값

앞의 실패 사례 다음에 실행되는 테스트에서 beforeEach가 막 끝났습니다. 이전 사례의 DB offline 설정이나 호출 기록이 남아 있을까요? 이 새 테스트 대역에 아무 반환값도 설정하지 않고 getUserById(7)을 호출하면 결과는 무엇일까요?

해설 보기

매번 createDatabaseServiceMock()을 호출해 새 객체를 만들므로, 이전 테스트 대역의 설정과 기록은 공유되지 않습니다.

새 jest.fn()은 구현을 정하지 않으면 undefined를 반환하고, async getUserById()는 그 값을 이행한 Promise로 감쌉니다. 따라서 이 경우에는 undefined를 받으며, DB 장애는 재현되지 않습니다. 실패를 확인하려면 이번 사례에서도 reject를 직접 설정해야 합니다.

2. controller 테스트에서 문자열 숫자를 보내면

AppController 단위 테스트에 appController.sumNumbers({ a: '5' as unknown as number, b: 3 })을 호출하는 사례를 추가했습니다. 실제 앱에는 전역 ValidationPipe가 있고, 본문 DTO는 a와 b에 @IsInt()를 붙였다고 가정합니다. 이 단위 테스트에서 appServiceMock.sum은 어떤 인자로 호출될까요? 이 테스트로 무엇을 증명할 수 있을까요?

해설 보기

controller 메서드를 직접 호출하므로 ValidationPipe가 실행되지 않습니다. appServiceMock.sum은 '5'와 3으로 그대로 호출되며, 테스트는 controller가 받은 값을 서비스에 그대로 넘긴다는 사실만 보여 줍니다.

실제 HTTP 요청에서 문자열 "5"가 400으로 거부되는지는 이 테스트로 알 수 없습니다. 그 동작은 pipe가 실행되도록 애플리케이션을 초기화하고 HTTP 요청을 보내는 E2E 테스트에서 확인해야 합니다.

3. clear와 reset의 차이

여러 테스트가 함께 쓰는 테스트 대역에 beforeAll에서 getHello.mockReturnValue('shared')를 설정했습니다. afterEach에 jest.clearAllMocks()를 두었을 때와 jest.resetAllMocks()를 두었을 때, 두 번째 테스트에서 getHello()는 각각 무엇을 반환할까요?

해설 보기

clearAllMocks()는 호출 기록만 비우고 정해 둔 구현은 남깁니다. 그래서 두 번째 테스트에서도 getHello()는 'shared'를 반환하고, 호출 횟수는 0부터 다시 셉니다.

resetAllMocks()는 기록과 함께 구현도 지우므로, 두 번째 테스트의 getHello()는 undefined를 반환합니다. beforeAll에서 한 번만 설정한 값은 다시 생기지 않으므로, reset을 쓴다면 반환값 설정을 beforeEach로 옮겨야 합니다. 이런 헷갈림을 피하려면 이 글처럼 beforeEach에서 새 테스트 대역을 만드는 방식이 가장 단순합니다.

다음 글 E2E 테스트 구현에서는 Nest 애플리케이션을 실제로 초기화하고 Supertest로 HTTP 요청을 보내, 라우팅과 pipe, guard까지 포함한 동작을 확인합니다.