유동

[Jest/NestJS] 단위 / 통합 테스트 분리 및 실행하기 [1] 본문

node.js/TestCode

[Jest/NestJS] 단위 / 통합 테스트 분리 및 실행하기 [1]

동 선 2025. 4. 4. 05:25

NestJS 테스트 구조 설계 - 단위 테스트와 통합 테스트 분리하기

NestJS를 기반으로 프로젝트를 진행하고 있는데, 핵심기능 테스트를 하기 위해 테스트에 잘못 발을 딛었다가 호되게 혼나고 거의 1달 동안 밤낮을 새가며 어느 정도 테스트 구조에 대해 갈피를 잡았다.

테스트를 효율적으로 운영하기 위해서는 단위(Unit) 테스트와 통합(Integration) 테스트를 명확하게 분리하는 것은 당연히 중요하고, Jest를 이용해서 얼마나 똑똑하게 실행할 것인지가 사실 관건이다. 공식문서에 가보면 Jest는 다양한 CLI 기능을 제공한다. Jest 공식문서

이 글에서는 실제 프로젝트에서 어떻게 테스트 환경을 분리하고, 실행 구조를 구성했는지에 대해 정리해 볼 것이다.
(문제 해결과 관련된 이야기는 다음 글에서 시리즈로 다룰 예정)


테스트 전략

서버 전략은 다음과 같이 구성하고 들어가 봅시다. 단위, 통합, E2E 테스트가 존재하는데 각 테스트마다 목적이 다르다. 하지만 본질은 테스트라는 것에 명심하자. 결국은 원하는 동작을 하는지 검증하는행위이기때문에 너무 겁먹지 말자. 처음에는 테스트에 대해 거부감이 많이 들었었다... 일단 뭐든 해봐야지...

단위 테스트

  • 단위 테스트는 mocking을 활용하여, 실제 서비스의 핵심 비즈니스 로직이 돌아가는 부분을 테스트하기 위해 mocking을 활용해 테스트를 위한 의존성들을 mock으로 대체하여, 순수하게 로직 자체만을 검증한다.
  • (mocking에 대해서는 강경파와 온건파가 나뉘는 것 같던데... 개인적으로는 서비스는 모킹하는 게 좋은 것 같다. 탈모 방지를 위해)

통합 테스트

  • Presentation 레이어 (Controller)는 실제 네트워크 요청을 날려, 해당 레이어에 맡은 책임들을 검사하고, 의도한 대로 작동하는지 (Validation, Parsing, Authorization 등)
  • Persistence (Repository) 레이어는 실제 DB(PostgreSQL, Redis)를 연결해서, 원하는 동작을 실제 쿼리문을 날리고, 원하는 결과값이 잘 저장되는지 검증한다. 이 부분에서는 Product Database와 동일한 환경을 Docker를 이용해서 구축함. (아래에서 docker-compose파일 있을거임 잠시대기)

여기서 중요한 점은 두 테스트는 완전히 별도의 실행 환경으로 분리해서 관리하는 것

  • 이를 위해 Jest에서는 jest.config.js라는 별도의 config 파일을 제공하는데, 이걸 잘 사용하는 게 반은 먹고 들어간다.
  • 시간 나면 Jest config 공식문서에서 정독은 아니더라도, "이 프로퍼티는 뭐지?" 싶은 건 봐두는 게 좋다.

현재 테스트 구조

실제 테스트 파일들은 다음과 같은 규칙으로 정의해놨다.
도메인 디렉토리의 __test__ 하위에 unit, integration 안에 **/**.spec.ts
필요하면 fixture도 따로 추가하면 좋다

여기는 모든 테스트(통테, 단위)가 실행될때 필요한 config나 util등을 모아놓았다


Jest 설정 - jest.config.js

require('dotenv').config({ path: '.env.test' });

module.exports = {
  roots: ['<rootDir>'],
  projects: [
    {
      displayName: 'unit',
      testMatch: ['**/__test__/**/*.spec.ts'],
      setupFilesAfterEnv: ['<rootDir>/test/unit/setup.ts'],
      testEnvironment: 'node',
      transform: { '^.+\\.(t|j)s$': 'ts-jest' },
      moduleFileExtensions: ['js', 'json', 'ts'],
      moduleNameMapper: { '^src/(.*)$': '<rootDir>/src/$1' },
    },
    {
      displayName: 'integration',
      testMatch: ['**/__test__/integration/**/*.spec.ts'],
      testEnvironment: 'node',
      transform: { '^.+\\.(t|j)s$': 'ts-jest' },
      moduleFileExtensions: ['js', 'json', 'ts'],
      moduleNameMapper: { '^src/(.*)$': '<rootDir>/src/$1' },
    },
  ],
};
  • testMatch 정규식에 걸리면 unitintegration을 구분하고, 각각의 setup.ts 파일을 실행하게 된다.

test-configuration 변수 설정

import { ConfigType } from '@nestjs/config';

export const testConfiguration = () => ({
  database: {
    url: process.env.TEST_DATABASE_URL,
  },
  redis: {
    host: process.env.REDIS_HOST,
    port: parseInt(process.env.TEST_REDIS_PORT!, 10),
    password: process.env.REDIS_PASSWORD,
  },
  token: {
    JWT_ACCESS_SECRET: process.env.JWT_ACCESS_SECRET,
    ACCESS_TOKEN_EXPIRES_IN: process.env.ACCESS_TOKEN_EXPIRES_IN,

    JWT_REFRESH_SECRET: process.env.JWT_REFRESH_SECRET,
    REFRESH_TOKEN_EXPIRES_IN: process.env.REFRESH_TOKEN_EXPIRES_IN,
    REFRESH_TOKEN_TTL: process.env.REFRESH_TOKEN_TTL,
  },
  // ....
});

export type TestConfig = ConfigType<typeof testConfiguration>;

VSCode Jest Runner 익스텐션

(Jest 실행 버튼 이미지)

  • 해당 버튼을 누르면 해당 테스트 파일에 대해 jest 커맨드를 실행해준다.
  • testMatch 설정이 가장 중요하고, 디렉토리 구조를 명확히 구분해서 정규식으로 매칭시키는 것이 핵심이다.



아주굳


통합 테스트를 위한 docker-compose 환경 구성

테스트 전용 PostgreSQL, Redis를 docker-compose로 관리하고 있음


통합 테스트 실행 전/후 작업 자동화

테스트는 구조만 잘 짜는 게 중요한 게 아니라. 실제로 실행될 때 어떤 준비를 하고 어떤 마무리를 하는지가 키포인트다

테스트 실행 전

  1. Database 초기화 및 ORM 연결
  2. 시드 데이터 생성
  3. 테스트 간 전처리 작업 (데이터 초기화)
  • 3번 부분은 통테 전체를 트랜잭션으로 묶냐 아니면 매번 db를 드랍하냐 뭐 이런 의견들이 많은데..

개인적으로는 일관성 있는 방식을 더 선호해서 테스트 전체를 트랜잭션으로 묶기보단, Jest의 Hook을 활용해 각 테스트 실행 전, 후로 해당작업을 수행하는 방식이 더 맘에들어서 채택했다.

테스트 실행 후

  1. Redis flush
  2. Prisma disconnect
    이거 커넥션 잡는게 비동기작업이라, 테스트가 다 끝나도 비동기가 걸려있으면 Jest가 경고계속내서 해주는게 좋슴니다

Jest Hook 예시

// setup.ts
beforeAll(async () => {
  const testFixture = TestFixtureUtil.getInstance();
  await testFixture.setUp();
  await testFixture.createTestUser();
});

beforeEach(async () => {
  const fixture = TestFixtureUtil.getInstance();
  await fixture.resetAll();
});

afterAll(async () => {
  const fixture = TestFixtureUtil.getInstance();
  await fixture.tearDown();
});

TestFixture 작성 (통합테스트를 위한)

export class TestFixtureUtil {
  private static instance: TestFixtureUtil;
  public prisma: PrismaClient;
  public redis: Redis;
  private initialized = false;
  private testUser1: UserEntity;
  private testUser2: UserEntity;

  private constructor() {
    this.prisma = new PrismaClient({
      datasources: {
        db: {
          url: testConfiguration().database.url,
        },
      },
    });
    this.redis = new Redis({
      host: testConfiguration().redis.host,
      port: testConfiguration().redis.port,
    });
  }

  static getInstance(): TestFixtureUtil {
    if (!TestFixtureUtil.instance) {
      TestFixtureUtil.instance = new TestFixtureUtil();
    }
    return TestFixtureUtil.instance;
  }

  async setUp() {
    if (this.initialized) return;
    await this.prisma.$connect();
    this.initialized = true;
  }

  async createTestUser() {
    // 시드 데이터 삽입
  }

  async resetAll() {
    // 테스트 간 데이터 초기화
  }

  async tearDown() {
    await this.prisma.$disconnect();
    await this.redis.quit();
  }
}

여기서 싱글톤으로 만든 이유는 테스트 실행 중 중복 인스턴스 생성을 방지하고, Prisma/Redis 커넥션을 테스트 전체에서 공유하도록 하기 위함


정리하며

지금까지 테스트 구조를 어떻게 구성했는지, 단위 테스트와 통합 테스트를 분리하고 독립적으로 실행되게 만드는 구조를 어떻게 잡았는지 정리해봤다. 테스트는 정말 연약한 존재이기때문에 (구조잘못짜면개고생) 초기작업을 잘 마쳐줘야 한다. 이후에는 픽스쳐를 얼마나 잘 활용하느냐에 따라서 테스트작성 속도와, 어플리케이션 무결성이 보장된다 리얼

하지만, 위에서 언급한 구조에서도 실제로 문제가 생긴다. 예를 들어:

  • setup.ts가 테스트마다 여러 번 실행되며 Redis 커넥션이 계속 열리고
  • 테스트가 끝났는데도 Jest가 종료되지 않는 등

이 문제들을 가지고 삽질해서 또 풀어냈는데, 다음 글에서 설명해보도록 하겠습니다.
다음 글에서는 "setup.ts 반복 실행 이슈와 리소스 누수 문제를 어떻게 해결했는가" 에 대해 다룰 예정입니다

이 글은 기본설계잡는거에 초점을 뒀다면, 다음 글에서는 트러블슈팅에 대해서 작성할 예정 끗

'node.js > TestCode' 카테고리의 다른 글

[Jest] Typescript환경에서 Jest사용하기  (1) 2023.12.05