NestJS 테스트 전략 심화

NestJS 테스트 전략

NestJS는 테스트 유틸리티를 프레임워크 레벨에서 제공하는 몇 안 되는 백엔드 프레임워크입니다. @nestjs/testing 패키지의 Test.createTestingModule()을 활용하면 DI 컨테이너를 그대로 살린 채 의존성을 교체할 수 있어, 단위 테스트와 통합 테스트 모두 깔끔하게 작성할 수 있습니다.

단위 테스트: Service 계층

Service의 비즈니스 로직을 외부 의존성 없이 테스트합니다:

import { Test, TestingModule } from '@nestjs/testing';
import { UserService } from './user.service';
import { getRepositoryToken } from '@nestjs/typeorm';
import { User } from './user.entity';

describe('UserService', () => {
  let service: UserService;
  let mockRepo: Record<string, jest.Mock>;

  beforeEach(async () => {
    mockRepo = {
      findOne: jest.fn(),
      save: jest.fn(),
      create: jest.fn(),
      delete: jest.fn(),
    };

    const module: TestingModule = await Test.createTestingModule({
      providers: [
        UserService,
        {
          provide: getRepositoryToken(User),
          useValue: mockRepo,
        },
      ],
    }).compile();

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

  describe('findById', () => {
    it('유저를 반환한다', async () => {
      const user = { id: 1, name: '테오', email: 'theo@test.com' };
      mockRepo.findOne.mockResolvedValue(user);

      const result = await service.findById(1);

      expect(result).toEqual(user);
      expect(mockRepo.findOne).toHaveBeenCalledWith({
        where: { id: 1 },
      });
    });

    it('유저가 없으면 NotFoundException', async () => {
      mockRepo.findOne.mockResolvedValue(null);

      await expect(service.findById(999))
        .rejects.toThrow(NotFoundException);
    });
  });
});

getRepositoryToken()으로 TypeORM Repository의 DI 토큰을 가져와 mock으로 교체합니다. 이 패턴은 TypeORM QueryBuilder 심화에서 다룬 Repository 패턴과 자연스럽게 연결됩니다.

Mock 팩토리 패턴

테스트마다 mock을 반복 작성하는 것은 비효율적입니다. Mock 팩토리를 만들어 재사용합니다:

// test/mock/repository.mock.ts
export const createMockRepository = <T>() => ({
  find: jest.fn(),
  findOne: jest.fn(),
  save: jest.fn(),
  create: jest.fn(),
  update: jest.fn(),
  delete: jest.fn(),
  createQueryBuilder: jest.fn(() => ({
    where: jest.fn().mockReturnThis(),
    andWhere: jest.fn().mockReturnThis(),
    orderBy: jest.fn().mockReturnThis(),
    skip: jest.fn().mockReturnThis(),
    take: jest.fn().mockReturnThis(),
    getManyAndCount: jest.fn(),
    getOne: jest.fn(),
  })),
});

// test/mock/service.mock.ts
export const createMockUserService = () => ({
  findById: jest.fn(),
  create: jest.fn(),
  update: jest.fn(),
  delete: jest.fn(),
});

// 사용
const module = await Test.createTestingModule({
  providers: [
    OrderService,
    {
      provide: getRepositoryToken(Order),
      useValue: createMockRepository(),
    },
    {
      provide: UserService,
      useValue: createMockUserService(),
    },
  ],
}).compile();

Controller 단위 테스트

Controller는 Service를 mock하고 HTTP 요청/응답 매핑만 검증합니다:

describe('UserController', () => {
  let controller: UserController;
  let mockService: Record<string, jest.Mock>;

  beforeEach(async () => {
    mockService = {
      findById: jest.fn(),
      create: jest.fn(),
      update: jest.fn(),
    };

    const module = await Test.createTestingModule({
      controllers: [UserController],
      providers: [
        { provide: UserService, useValue: mockService },
      ],
    }).compile();

    controller = module.get(UserController);
  });

  it('POST /users → 201 + 생성된 유저', async () => {
    const dto = { name: '테오', email: 'theo@test.com' };
    const created = { id: 1, ...dto };
    mockService.create.mockResolvedValue(created);

    const result = await controller.create(dto);

    expect(result).toEqual(created);
    expect(mockService.create).toHaveBeenCalledWith(dto);
  });
});

E2E 테스트: Supertest 통합

실제 HTTP 요청을 보내 전체 파이프라인(Middleware → Guard → Interceptor → Pipe → Controller)을 검증합니다:

import { INestApplication, ValidationPipe } from '@nestjs/common';
import * as request from 'supertest';

describe('Users E2E', () => {
  let app: INestApplication;

  beforeAll(async () => {
    const module = await Test.createTestingModule({
      imports: [AppModule],
    })
      .overrideProvider(getRepositoryToken(User))
      .useValue(createMockRepository())
      .compile();

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

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

  describe('POST /users', () => {
    it('유효한 요청 → 201', () => {
      return request(app.getHttpServer())
        .post('/users')
        .send({ name: '테오', email: 'theo@test.com' })
        .expect(201)
        .expect((res) => {
          expect(res.body.data).toHaveProperty('id');
          expect(res.body.data.name).toBe('테오');
        });
    });

    it('이메일 누락 → 400', () => {
      return request(app.getHttpServer())
        .post('/users')
        .send({ name: '테오' })
        .expect(400)
        .expect((res) => {
          expect(res.body.message).toContain('email');
        });
    });
  });
});

overrideProvider()로 특정 의존성만 교체할 수 있어, 실제 모듈 구성을 유지하면서 외부 의존성(DB, Redis 등)만 mock 처리합니다.

Guard·Interceptor 테스트

NestJS GuardInterceptor도 독립적으로 테스트할 수 있습니다:

// Guard 테스트
describe('RolesGuard', () => {
  let guard: RolesGuard;
  let reflector: Reflector;

  beforeEach(async () => {
    const module = await Test.createTestingModule({
      providers: [RolesGuard, Reflector],
    }).compile();

    guard = module.get(RolesGuard);
    reflector = module.get(Reflector);
  });

  it('ADMIN 역할이면 접근 허용', () => {
    const context = createMockExecutionContext({
      user: { role: 'ADMIN' },
      handler: () => {},
    });

    jest.spyOn(reflector, 'get').mockReturnValue(['ADMIN']);

    expect(guard.canActivate(context)).toBe(true);
  });
});

// ExecutionContext mock 헬퍼
function createMockExecutionContext(opts: {
  user?: any;
  handler?: Function;
}): ExecutionContext {
  return {
    switchToHttp: () => ({
      getRequest: () => ({ user: opts.user }),
    }),
    getHandler: () => opts.handler,
    getClass: () => ({}),
  } as ExecutionContext;
}

테스트 DB: SQLite In-Memory

E2E 테스트에서 실제 DB를 사용하면 느리고 불안정합니다. SQLite in-memory를 사용하면 테스트마다 깨끗한 DB를 얻습니다:

// test/test-db.module.ts
@Module({
  imports: [
    TypeOrmModule.forRoot({
      type: 'sqlite',
      database: ':memory:',
      entities: [User, Order, Product],
      synchronize: true,     // 테스트에서만!
      dropSchema: true,      // 매번 초기화
    }),
  ],
})
export class TestDbModule {}

// E2E 테스트에서 사용
const module = await Test.createTestingModule({
  imports: [
    TestDbModule,
    TypeOrmModule.forFeature([User]),
    UserModule,
  ],
})
  .overrideModule(TypeOrmModule)  // 실제 DB 모듈 교체 불필요
  .compile();

커버리지와 테스트 구조

권장하는 테스트 디렉토리 구조:

src/
├── user/
│   ├── user.service.ts
│   ├── user.service.spec.ts      # 단위 테스트 (같은 폴더)
│   ├── user.controller.ts
│   └── user.controller.spec.ts
test/
├── mock/
│   ├── repository.mock.ts        # Mock 팩토리
│   └── service.mock.ts
├── fixture/
│   └── user.fixture.ts           # 테스트 데이터
├── user.e2e-spec.ts              # E2E 테스트
└── jest-e2e.json
// test/fixture/user.fixture.ts
export const userFixture = {
  valid: { name: '테오', email: 'theo@test.com' },
  noEmail: { name: '테오' },
  admin: { id: 1, name: 'Admin', role: 'ADMIN' },
};

// jest.config.ts — 커버리지 설정
module.exports = {
  collectCoverageFrom: [
    'src/**/*.ts',
    '!src/**/*.spec.ts',
    '!src/**/*.module.ts',
    '!src/main.ts',
  ],
  coverageThreshold: {
    global: {
      branches: 80,
      functions: 80,
      lines: 80,
      statements: 80,
    },
  },
};

마무리

NestJS의 테스트 유틸리티는 DI 컨테이너와 완벽히 통합되어 있어, mock 주입이 자연스럽고 실제 모듈 구조를 유지하면서 테스트할 수 있습니다. 단위 테스트에서는 Test.createTestingModule()로 의존성을 교체하고, E2E에서는 supertest로 전체 파이프라인을 검증합니다. Mock 팩토리와 Fixture를 체계적으로 관리하면 테스트 코드의 유지보수성이 크게 향상됩니다.

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