"테스트 코드를 짜야 한다"는 것은 알겠는데, NestJS에서 DI 컨테이너를 쓰면서 어떻게 의존성을 모킹하나요? Spring의 @MockBean처럼 NestJS도 overrideProvider라는 깔끔한 방법을 제공합니다.


NestJS 테스트의 핵심 — TestingModule

NestJS의 테스트는 실제 DI 컨테이너를 부트스트랩 하되, 원하는 의존성을 모킹으로 교체하는 방식입니다.

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

describe('UserService', () => {
  let service: UserService;
  let repository: Repository<User>;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [
        UserService,
        {
          provide: getRepositoryToken(User),
          useValue: {                        // Repository 모킹
            find: jest.fn(),
            findOneBy: jest.fn(),
            save: jest.fn(),
            create: jest.fn(),
          },
        },
      ],
    }).compile();

    service = module.get<UserService>(UserService);
    repository = module.get<Repository<User>>(getRepositoryToken(User));
  });
});

Spring의 @ExtendWith(MockitoExtension.class) + @InjectMocks와 비슷하지만, NestJS는 실제 모듈 구조를 그대로 사용 하면서 특정 프로바이더만 교체합니다.


유닛 테스트 — 서비스 레이어

서비스의 비즈니스 로직만 검증합니다. DB, 외부 API 같은 의존성은 모두 모킹합니다.

TYPESCRIPT
describe('UserService', () => {
  // ... beforeEach 생략 (위와 동일)

  describe('findOne', () => {
    it('존재하는 유저를 반환한다', async () => {
      const user = { id: 1, name: '홍길동', email: 'hong@test.com' };
      jest.spyOn(repository, 'findOneBy').mockResolvedValue(user as User);

      const result = await service.findOne(1);

      expect(result).toEqual(user);
      expect(repository.findOneBy).toHaveBeenCalledWith({ id: 1 });
    });

    it('존재하지 않으면 NotFoundException을 던진다', async () => {
      jest.spyOn(repository, 'findOneBy').mockResolvedValue(null);

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

  describe('create', () => {
    it('유저를 생성하고 저장한다', async () => {
      const dto = { name: '김개발', email: 'kim@test.com' };
      const savedUser = { id: 1, ...dto };

      jest.spyOn(repository, 'create').mockReturnValue(savedUser as User);
      jest.spyOn(repository, 'save').mockResolvedValue(savedUser as User);

      const result = await service.create(dto);

      expect(result.id).toBe(1);
      expect(repository.create).toHaveBeenCalledWith(dto);
      expect(repository.save).toHaveBeenCalled();
    });
  });
});

컨트롤러 테스트

컨트롤러는 라우팅이 올바른 서비스 메서드를 호출하는지 검증합니다.

TYPESCRIPT
describe('UserController', () => {
  let controller: UserController;
  let service: UserService;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      controllers: [UserController],
      providers: [
        {
          provide: UserService,
          useValue: {
            findAll: jest.fn().mockResolvedValue([]),
            findOne: jest.fn().mockResolvedValue({ id: 1, name: '테스트' }),
            create: jest.fn().mockResolvedValue({ id: 1 }),
          },
        },
      ],
    }).compile();

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

  it('findAll은 서비스의 findAll을 호출한다', async () => {
    await controller.findAll();
    expect(service.findAll).toHaveBeenCalled();
  });
});

overrideProvider — 특정 의존성만 교체

실제 모듈 구조를 유지하면서 하나의 프로바이더만 교체하고 싶을 때 사용합니다.

TYPESCRIPT
const module = await Test.createTestingModule({
  imports: [UserModule],        // 실제 모듈 가져옴
})
  .overrideProvider(getRepositoryToken(User))
  .useValue(mockRepository)     // Repository만 모킹으로 교체
  .overrideProvider(AuthService)
  .useValue({ validate: jest.fn() })  // AuthService도 모킹
  .compile();

Spring Boot의 @MockBean과 같은 역할입니다. 모듈 전체를 다시 구성하지 않아도 되어 테스트 코드가 간결해집니다.


E2E 테스트 — HTTP 요청 전체 흐름

E2E 테스트는 실제 HTTP 요청을 보내서 Middleware → Guard → Pipe → Controller → Service 전체 흐름을 검증합니다.

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

describe('UserController (e2e)', () => {
  let app: INestApplication;

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

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

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

  it('GET /users — 200과 유저 목록 반환', () => {
    return request(app.getHttpServer())
      .get('/users')
      .expect(200)
      .expect(res => {
        expect(Array.isArray(res.body)).toBe(true);
      });
  });

  it('POST /users — 잘못된 이메일로 400 반환', () => {
    return request(app.getHttpServer())
      .post('/users')
      .send({ name: '홍길동', email: 'not-an-email' })
      .expect(400);
  });

  it('POST /users — 유효한 데이터로 201 반환', () => {
    return request(app.getHttpServer())
      .post('/users')
      .send({ name: '홍길동', email: 'hong@test.com' })
      .expect(201);
  });
});

E2E 테스트에서 useGlobalPipes를 다시 설정해야 합니다. main.ts에서 설정한 전역 파이프는 E2E 테스트의 앱 인스턴스에는 적용되지 않습니다. 이걸 빠뜨리면 ValidationPipe가 동작하지 않아서 검증 테스트가 통과해 버리는 문제가 생깁니다.


주의할 점

E2E 테스트에서 전역 설정 누락

가장 흔한 실수입니다. main.ts의 전역 Pipe, Guard, Interceptor를 E2E 테스트 설정에서 ** 동일하게 적용 **해야 합니다. 그렇지 않으면 실제 운영과 다른 환경에서 테스트하는 것이 됩니다.

TYPESCRIPT
// main.ts와 동일하게 설정
app.useGlobalPipes(new ValidationPipe({ whitelist: true }));
app.useGlobalFilters(new HttpExceptionFilter());
app.useGlobalInterceptors(new TransformInterceptor());

모킹이 너무 많으면 의미 없는 테스트가 된다

모든 의존성을 모킹하면 "모킹한 대로 동작하는지" 테스트하는 것이 되어 실제 버그를 잡지 못합니다. 유닛 테스트에서는 ** 직접 의존성만** 모킹하고, 통합 테스트에서는 실제 DB를 사용하는 것이 균형 잡힌 전략입니다.

PLAINTEXT
유닛 테스트:  Service만 테스트, Repository는 모킹
통합 테스트:  Service + Repository + 실제 DB (테스트용 DB)
E2E 테스트:  HTTP 요청 → 응답 전체 흐름

정리

포인트내용
TestingModule실제 DI 컨테이너 부트스트랩 + 선택적 모킹
overrideProvider특정 프로바이더만 교체 (Spring의 @MockBean 대응)
유닛 테스트서비스 로직 검증, 의존성 모킹
E2E 테스트supertest로 HTTP 전체 흐름 검증
핵심 함정E2E에서 전역 Pipe/Guard 설정 누락, 과도한 모킹
댓글 로딩 중...