NestJS Testing 실전 가이드

NestJS 테스트 전략: 단위·통합·E2E

NestJS는 테스트를 1급 시민으로 취급한다. @nestjs/testing 패키지로 모듈 시스템을 그대로 활용하면서, 의존성을 자유롭게 모킹하고, 실제 HTTP 요청을 시뮬레이션할 수 있다. 테스트 피라미드에 따라 단위 테스트(Unit)로 비즈니스 로직을, 통합 테스트(Integration)로 모듈 간 상호작용을, E2E 테스트로 API 전체 흐름을 검증한다.

이 글에서는 Testing Module 구성, 서비스 단위 테스트, 컨트롤러 통합 테스트, Supertest E2E 테스트, 데이터베이스 격리, 그리고 CI 최적화까지 실무 패턴을 다룬다.

Testing Module: 테스트 전용 DI 컨테이너

Test.createTestingModule()은 NestJS의 DI 컨테이너를 테스트용으로 생성한다. 실제 모듈과 동일한 구조이되, 원하는 의존성만 모킹으로 교체할 수 있다.

import { Test, TestingModule } from '@nestjs/testing';

describe('UserService', () => {
  let service: UserService;
  let userRepository: jest.Mocked<EntityRepository<User>>;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [
        UserService,
        {
          provide: getRepositoryToken(User),
          useValue: {
            findOne: jest.fn(),
            findAll: jest.fn(),
            persistAndFlush: jest.fn(),
            removeAndFlush: jest.fn(),
          },
        },
        {
          provide: EventEmitter2,
          useValue: { emit: jest.fn() },
        },
      ],
    }).compile();

    service = module.get(UserService);
    userRepository = module.get(getRepositoryToken(User));
  });

  afterEach(() => jest.clearAllMocks());
});

useValue로 모킹 객체를 직접 넣는 것이 가장 명시적이다. 어떤 메서드가 모킹되었는지 한눈에 보인다.

서비스 단위 테스트: 비즈니스 로직 검증

describe('UserService.create', () => {
  it('should create a user and emit event', async () => {
    // Arrange
    const dto: CreateUserDto = { name: 'Alice', email: 'alice@test.com' };
    const savedUser = { id: 1, ...dto, createdAt: new Date() } as User;
    userRepository.persistAndFlush.mockResolvedValue(undefined);
    // persistAndFlush 호출 시 엔티티에 id가 할당되는 것을 시뮬레이션
    userRepository.persistAndFlush.mockImplementation(async (entity: User) => {
      Object.assign(entity, { id: 1, createdAt: new Date() });
    });

    const eventEmitter = module.get(EventEmitter2);

    // Act
    const result = await service.create(dto);

    // Assert
    expect(userRepository.persistAndFlush).toHaveBeenCalledTimes(1);
    expect(eventEmitter.emit).toHaveBeenCalledWith(
      'user.created',
      expect.objectContaining({ userId: 1 }),
    );
    expect(result.name).toBe('Alice');
  });

  it('should throw on duplicate email', async () => {
    const dto: CreateUserDto = { name: 'Bob', email: 'exists@test.com' };
    userRepository.findOne.mockResolvedValue({ id: 2 } as User);

    await expect(service.create(dto))
      .rejects
      .toThrow(ConflictException);

    expect(userRepository.persistAndFlush).not.toHaveBeenCalled();
  });

  it('should calculate premium tier for high-value users', async () => {
    const user = { id: 1, totalSpent: 150000, orderCount: 25 } as User;
    userRepository.findOne.mockResolvedValue(user);

    const tier = await service.calculateTier(1);

    expect(tier).toBe(UserTier.PREMIUM);
  });
});

컨트롤러 통합 테스트: Pipe·Guard·Interceptor 포함

컨트롤러 테스트는 서비스를 모킹하되, Pipe(Validation), Guard, Interceptor는 실제로 동작시켜야 한다. 요청/응답 변환이 올바른지 검증하는 것이 목적이다.

describe('UserController', () => {
  let app: INestApplication;
  let userService: jest.Mocked<UserService>;

  beforeAll(async () => {
    const module = await Test.createTestingModule({
      controllers: [UserController],
      providers: [
        {
          provide: UserService,
          useValue: {
            create: jest.fn(),
            findById: jest.fn(),
            findAll: jest.fn(),
            update: jest.fn(),
            delete: jest.fn(),
          },
        },
      ],
    })
      .overrideGuard(JwtAuthGuard)
      .useValue({ canActivate: () => true })  // 인증 우회
      .compile();

    app = module.createNestApplication();
    app.useGlobalPipes(new ValidationPipe({
      whitelist: true,
      forbidNonWhitelisted: true,
      transform: true,
    }));
    await app.init();

    userService = module.get(UserService);
  });

  afterAll(() => app.close());

  describe('POST /users', () => {
    it('201 - should create user', () => {
      userService.create.mockResolvedValue({
        id: 1, name: 'Alice', email: 'alice@test.com',
      } as User);

      return request(app.getHttpServer())
        .post('/users')
        .send({ name: 'Alice', email: 'alice@test.com' })
        .expect(201)
        .expect((res) => {
          expect(res.body.id).toBe(1);
          expect(res.body.name).toBe('Alice');
        });
    });

    it('400 - should reject invalid email', () => {
      return request(app.getHttpServer())
        .post('/users')
        .send({ name: 'Alice', email: 'not-an-email' })
        .expect(400)
        .expect((res) => {
          expect(res.body.message).toContain('email');
        });
    });

    it('400 - should reject extra fields', () => {
      return request(app.getHttpServer())
        .post('/users')
        .send({ name: 'Alice', email: 'a@b.com', isAdmin: true })
        .expect(400);  // forbidNonWhitelisted
    });
  });
});

E2E 테스트: 전체 스택 검증

E2E 테스트는 실제 DB, 실제 모듈을 사용하여 API의 전체 흐름을 검증한다. 테스트 전용 DB(SQLite in-memory 또는 테스트 컨테이너)를 사용한다.

// test/app.e2e-spec.ts
describe('User API (e2e)', () => {
  let app: INestApplication;
  let orm: MikroORM;

  beforeAll(async () => {
    const module = await Test.createTestingModule({
      imports: [AppModule],
    })
      .overrideProvider(MikroOrmModuleOptions)
      .useValue({
        dbName: ':memory:',        // SQLite in-memory
        driver: SqliteDriver,
        allowGlobalContext: true,
      })
      .compile();

    app = module.createNestApplication();
    app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
    await app.init();

    orm = module.get(MikroORM);
    await orm.getSchemaGenerator().createSchema();
  });

  afterAll(async () => {
    await orm.close();
    await app.close();
  });

  // 각 테스트마다 DB 초기화
  beforeEach(async () => {
    await orm.getSchemaGenerator().clearDatabase();
  });

  it('should create and retrieve a user', async () => {
    // Create
    const createRes = await request(app.getHttpServer())
      .post('/users')
      .send({ name: 'Alice', email: 'alice@test.com' })
      .expect(201);

    const userId = createRes.body.id;

    // Retrieve
    const getRes = await request(app.getHttpServer())
      .get(`/users/${userId}`)
      .expect(200);

    expect(getRes.body.name).toBe('Alice');
    expect(getRes.body.email).toBe('alice@test.com');
  });

  it('should return 404 for non-existent user', async () => {
    await request(app.getHttpServer())
      .get('/users/99999')
      .expect(404);
  });

  it('should handle concurrent creation with same email', async () => {
    const dto = { name: 'Alice', email: 'same@test.com' };

    const [res1, res2] = await Promise.all([
      request(app.getHttpServer()).post('/users').send(dto),
      request(app.getHttpServer()).post('/users').send(dto),
    ]);

    const statuses = [res1.status, res2.status].sort();
    expect(statuses).toEqual([201, 409]);  // 하나 성공, 하나 충돌
  });
});

모킹 패턴: useValue vs useClass vs useFactory

방식 용도 예시
useValue jest.fn() 객체 직접 주입 단위 테스트에서 가장 흔함
useClass 테스트 전용 구현체 클래스 InMemoryUserRepository
useFactory 동적 모킹 (다른 의존성 참조) ConfigService 기반 분기
overrideProvider 기존 모듈의 provider 교체 E2E에서 외부 서비스 모킹
overrideGuard Guard 교체 인증 우회
// useClass: 테스트 전용 구현체
class InMemoryUserRepository {
  private users: User[] = [];
  private nextId = 1;

  async findOne(filter: any): Promise<User | null> {
    return this.users.find(u => u.id === filter.id) ?? null;
  }

  async persistAndFlush(user: User): Promise<void> {
    user.id = this.nextId++;
    this.users.push(user);
  }

  clear(): void { this.users = []; }
}

// useFactory: 동적 생성
{
  provide: CacheService,
  useFactory: (config: ConfigService) => {
    if (config.get('CACHE_DRIVER') === 'redis') {
      return new RedisCacheService(config);
    }
    return new InMemoryCacheService();
  },
  inject: [ConfigService],
}

// overrideProvider: E2E에서 외부 API 차단
const module = await Test.createTestingModule({
  imports: [AppModule],
})
  .overrideProvider(PaymentGateway)
  .useValue({
    charge: jest.fn().mockResolvedValue({ transactionId: 'test-123' }),
    refund: jest.fn().mockResolvedValue({ success: true }),
  })
  .compile();

테스트 데이터베이스 격리 전략

// 전략 1: 각 테스트마다 DB 초기화 (가장 안전)
beforeEach(async () => {
  await orm.getSchemaGenerator().clearDatabase();
});

// 전략 2: 트랜잭션 롤백 (더 빠름)
let em: EntityManager;
beforeEach(async () => {
  em = orm.em.fork();
  await em.begin();
});
afterEach(async () => {
  await em.rollback();
});

// 전략 3: Docker Testcontainers (실제 DB 엔진)
import { PostgreSqlContainer } from '@testcontainers/postgresql';

let container: StartedPostgreSqlContainer;

beforeAll(async () => {
  container = await new PostgreSqlContainer('postgres:16-alpine')
    .withDatabase('test')
    .start();

  const module = await Test.createTestingModule({
    imports: [AppModule],
  })
    .overrideProvider(MikroOrmModuleOptions)
    .useValue({
      clientUrl: container.getConnectionUri(),
      driver: PostgreSqlDriver,
    })
    .compile();
}, 60_000);  // 컨테이너 시작 시간 여유

afterAll(async () => {
  await container.stop();
});

CI 최적화: 속도와 안정성

// jest.config.ts
export default {
  // 프로젝트별 분리 실행
  projects: [
    {
      displayName: 'unit',
      testMatch: ['**/*.spec.ts'],
      testPathIgnorePatterns: ['.e2e-spec.ts'],
    },
    {
      displayName: 'e2e',
      testMatch: ['**/*.e2e-spec.ts'],
      testTimeout: 30_000,
    },
  ],
  // 워커 수 제한 (CI에서 메모리 절약)
  maxWorkers: '50%',
  // 캐시 활용
  cacheDirectory: '/tmp/jest-cache',
};

// package.json scripts
{
  "test": "jest --project unit",
  "test:e2e": "jest --project e2e --runInBand",
  "test:ci": "jest --ci --coverage --project unit && jest --ci --runInBand --project e2e"
}

E2E 테스트는 --runInBand로 직렬 실행한다. 병렬 실행 시 DB 포트 충돌이나 커넥션 풀 고갈이 발생할 수 있다.

운영 체크리스트

  • 테스트 피라미드: Unit 70% / Integration 20% / E2E 10% 비율을 목표로 한다. E2E는 핵심 시나리오만 커버한다
  • app.close() 필수: afterAll에서 반드시 앱과 DB 연결을 정리한다. 미정리 시 Jest가 종료되지 않는다
  • 타임아웃: E2E 테스트는 testTimeout을 넉넉히 설정한다. DB 스키마 생성, 컨테이너 시작 등에 시간이 걸린다
  • 외부 의존성 차단: 결제 API, 이메일 서비스 등 외부 호출은 반드시 overrideProvider로 모킹한다
  • Seed 데이터: 테스트 간 공유 데이터는 beforeAll에서 seed하고, 테스트별 데이터는 beforeEach에서 생성한다
  • 커버리지: Exception Filter와 에러 경로도 반드시 테스트한다. 해피 패스만 테스트하면 장애 시 대응이 어렵다

NestJS의 Testing Module은 DI 컨테이너를 활용한 강력한 테스트 인프라를 제공한다. 핵심은 서비스 로직은 단위 테스트로 빠르게, API 계약은 컨트롤러 통합 테스트로, 핵심 시나리오는 E2E로 검증하는 계층적 전략이다. 모킹 범위를 최소화하고 실제 동작에 가깝게 테스트할수록 프로덕션 버그를 사전에 잡을 수 있다.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux