본문으로 건너뛰기
김신건의 로그

[NestJS] Testing (Jest, Supertest, e2e)

· 수정 · 📖 약 1분 · 355자/단어 #nestjs #testing #jest #e2e
NestJS Testing, NestJS Jest, NestJS Supertest, Test.createTestingModule, NestJS e2e test, NestJS 테스트

정의

NestJS Testing 은 Jest (기본) + Supertest 조합으로 unit test 와 e2e test 를 지원합니다. @nestjs/testingTest.createTestingModule 로 module 을 격리된 형태로 만들고, provider 를 mock 으로 교체합니다.

Unit Test 기본

// users.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { UsersService } from './users.service';
import { UsersRepository } from './users.repository';

describe('UsersService', () => {
  let service: UsersService;
  let repo: jest.Mocked<UsersRepository>;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [
        UsersService,
        {
          provide: UsersRepository,
          useValue: {
            findAll: jest.fn(),
            findOne: jest.fn(),
            create: jest.fn(),
          },
        },
      ],
    }).compile();

    service = module.get<UsersService>(UsersService);
    repo = module.get(UsersRepository);
  });

  it('should be defined', () => {
    expect(service).toBeDefined();
  });

  it('findAll returns users', async () => {
    repo.findAll.mockResolvedValue([{id: 1, name: 'kim'}]);
    const result = await service.findAll();
    expect(result).toEqual([{id: 1, name: 'kim'}]);
    expect(repo.findAll).toHaveBeenCalledTimes(1);
  });

  it('create throws on duplicate email', async () => {
    repo.create.mockRejectedValue(new UserAlreadyExistsError('a@b.com'));
    await expect(
      service.create({email: 'a@b.com', name: 'kim'})
    ).rejects.toThrow(UserAlreadyExistsError);
  });
});

Controller Test

// users.controller.spec.ts
describe('UsersController', () => {
  let controller: UsersController;
  let service: jest.Mocked<UsersService>;

  beforeEach(async () => {
    const module = await Test.createTestingModule({
      controllers: [UsersController],
      providers: [
        {
          provide: UsersService,
          useValue: {
            findAll: jest.fn(),
            findOne: jest.fn(),
          },
        },
      ],
    }).compile();

    controller = module.get(UsersController);
    service = module.get(UsersService);
  });

  it('GET /users returns all users', async () => {
    service.findAll.mockResolvedValue([{id: 1, name: 'kim'}]);
    const result = await controller.findAll();
    expect(result).toHaveLength(1);
  });
});

Controller test 는 unit test 지만 실제 HTTP 는 안 거침 (그건 e2e).

E2E Test

실제 HTTP 요청을 보내 응답 검증. Nest 앱 실체 사용.

// test/app.e2e-spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from '../src/app.module';

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

  beforeAll(async () => {
    const moduleFixture: TestingModule = await Test.createTestingModule({
      imports: [AppModule],
    }).compile();

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

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

  it('/ (GET)', () => {
    return request(app.getHttpServer())
      .get('/')
      .expect(200)
      .expect('Hello World!');
  });

  it('POST /users creates user', () => {
    return request(app.getHttpServer())
      .post('/users')
      .send({email: 'a@b.com', password: 'secret123', name: 'kim'})
      .expect(201)
      .expect(res => {
        expect(res.body.id).toBeDefined();
        expect(res.body.email).toBe('a@b.com');
      });
  });

  it('POST /users returns 400 on invalid input', () => {
    return request(app.getHttpServer())
      .post('/users')
      .send({email: 'not-an-email'})
      .expect(400);
  });
});

Provider override

E2E 에서 특정 provider 만 mock:

const moduleFixture = await Test.createTestingModule({
  imports: [AppModule],
})
  .overrideProvider(EmailService)
  .useValue({send: jest.fn().mockResolvedValue(true)})
  .overrideProvider(getRepositoryToken(User))
  .useValue({find: jest.fn(), save: jest.fn()})
  .overrideGuard(JwtAuthGuard)
  .useValue({canActivate: jest.fn().mockReturnValue(true)})
  .compile();
  • overrideProvider
  • overrideGuard
  • overrideInterceptor
  • overridePipe
  • overrideFilter

DB 격리

패턴 1: SQLite in-memory

// test.module.ts
TypeOrmModule.forRoot({
  type: 'sqlite',
  database: ':memory:',
  entities: [User, Order],
  synchronize: true,
})

빠름, 격리 완벽. PostgreSQL-specific 기능은 불가.

패턴 2: PostgreSQL + transaction rollback

let queryRunner: QueryRunner;

beforeEach(async () => {
  queryRunner = dataSource.createQueryRunner();
  await queryRunner.connect();
  await queryRunner.startTransaction();
});

afterEach(async () => {
  await queryRunner.rollbackTransaction();
  await queryRunner.release();
});

패턴 3: Testcontainers

import { PostgreSqlContainer } from '@testcontainers/postgresql';

let container: StartedPostgreSqlContainer;

beforeAll(async () => {
  container = await new PostgreSqlContainer('postgres:16').start();
  process.env.DATABASE_URL = container.getConnectionUri();
}, 60000);

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

프로덕션 DB 와 동일 환경. Docker 필요.

Fixture / Seed

async function seedUser(prisma: PrismaService, override: Partial<User> = {}) {
  return prisma.user.create({
    data: {
      email: `test-${Date.now()}@example.com`,
      name: 'Test User',
      password: 'hashed',
      ...override,
    },
  });
}

it('finds seeded user', async () => {
  const user = await seedUser(prisma);
  const result = await service.findOne(user.id);
  expect(result.email).toBe(user.email);
});

Mocking 패턴

jest.spyOn

const spy = jest.spyOn(service, 'sendEmail').mockResolvedValue(true);
await service.notify(user);
expect(spy).toHaveBeenCalledWith(user.email);

jest.mock (모듈 전체)

jest.mock('nodemailer', () => ({
  createTransport: () => ({
    sendMail: jest.fn().mockResolvedValue(true),
  }),
}));

MSW (외부 HTTP mock)

npm i -D msw
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';

const server = setupServer(
  http.get('https://api.example.com/users/:id', ({params}) => {
    return HttpResponse.json({id: params.id, name: 'kim'});
  }),
);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

Guards 테스트

Guard 자체를 직접 테스트:

describe('JwtAuthGuard', () => {
  let guard: JwtAuthGuard;
  let jwtService: jest.Mocked<JwtService>;

  beforeEach(async () => {
    const module = await Test.createTestingModule({
      providers: [
        JwtAuthGuard,
        {provide: JwtService, useValue: {verifyAsync: jest.fn()}},
      ],
    }).compile();

    guard = module.get(JwtAuthGuard);
    jwtService = module.get(JwtService);
  });

  it('returns true for valid token', async () => {
    jwtService.verifyAsync.mockResolvedValue({sub: 1});
    const context = createMockExecutionContext({
      headers: {authorization: 'Bearer token'},
    });
    await expect(guard.canActivate(context)).resolves.toBe(true);
  });
});

createMockExecutionContext 는 자체 헬퍼:

function createMockExecutionContext(req: Partial<Request>): ExecutionContext {
  return {
    switchToHttp: () => ({
      getRequest: () => req,
    }),
  } as any;
}

Pipe 테스트

const pipe = new ValidationPipe({transform: true, whitelist: true});
const dto = {email: 'not-an-email'};

await expect(
  pipe.transform(dto, {type: 'body', metatype: CreateUserDto})
).rejects.toThrow(BadRequestException);

Coverage

npm run test:cov

jest.config.js:

module.exports = {
  moduleFileExtensions: ['js', 'json', 'ts'],
  rootDir: '.',
  testRegex: '.*\\.spec\\.ts$',
  transform: {'^.+\\.(t|j)s$': 'ts-jest'},
  collectCoverageFrom: ['**/*.(t|j)s'],
  coverageDirectory: '../coverage',
  coverageThreshold: {
    global: {
      statements: 80,
      branches: 75,
      functions: 80,
      lines: 80,
    },
  },
};

CI 통합 예시 (GitHub Actions)

name: test
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_PASSWORD: test
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
        ports:
          - 5432:5432
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
      - run: npm ci
      - run: npm run lint
      - run: npm run test:cov
      - run: npm run test:e2e
        env:
          DATABASE_URL: postgres://postgres:test@localhost:5432/postgres

함정

WARNING

E2E test 에서 useGlobalPipes/Filters/Guards 등록 안 하면 main.ts 와 다른 동작. Test 도 명시적으로 등록.

CAUTION

테스트 격리. DB, cache, external service 를 각 test 별로 reset. 아니면 flaky.

WARNING

process.env 조작. beforeEach 에서 backup, afterEach 에서 복원. 아니면 다른 test 로 leak.

IMPORTANT

Jest module reset. jest.resetModules() 로 module cache clear. Global state 있는 경우 필수.

CAUTION

Async assertion. await expect(...).rejects.toThrow() 형태. expect(...).toThrow() 는 sync.

관련 위키

이 글의 용어 (8개)
[FastAPI] Testingfastapi
정의 FastAPI Testing 은 endpoint 를 실제 서버 없이 직접 호출해 응답을 검증하는 workflow 입니다. Starlette 의 (내부적으로 sync) 또는 …
[Flask] Testingflask
정의 Flask Testing 은 앱 인스턴스를 직접 감싸 HTTP 요청을 시뮬레이션하는 workflow 입니다. (Werkzeug) 로 라우팅/뷰/미들웨어까지 실제 실행하고 응…
[Framework] NestJSframeworks
정의 NestJS 는 Kamil Myśliwiec 이 2017년 발표한 Node.js 서버측 프레임워크 입니다. TypeScript first, Angular 에서 영감받은 모듈…
[NestJS] Controllersframeworks
정의 NestJS Controller 는 데코레이터가 붙은 클래스로, HTTP 요청을 라우팅하고 응답을 반환합니다. Method 별로 , , , , 데코레이터를 붙여 URL + …
[NestJS] Database (TypeORM, Prisma, Mongoose, Drizzle)frameworks
정의 NestJS Database 통합은 여러 ORM/ODM 을 module 로 감싸 DI + 트랜잭션 + 마이그레이션을 관용화합니다. 주요 선택지: TypeORM (성숙), P…
[NestJS] Guardsframeworks
정의 NestJS Guard 는 특정 요청이 controller method 에 도달하기 전에 실행되어 접근 허용 여부 를 결정합니다. Middleware 이후, Intercep…
[NestJS] Modulesframeworks
정의 NestJS Module 은 데코레이터가 붙은 클래스로, 관련된 controller, provider, import/export 를 하나로 묶는 조직 단위입니다. Angul…
[NestJS] Providers & Dependency Injectionframeworks
정의 NestJS Provider 는 데코레이터가 붙은 클래스 (또는 값/factory) 로, DI container 에 등록되어 다른 클래스에 주입됩니다. Service, Re…

💬 댓글

사이트 검색 / 명령어

검색

스크롤 = 확대/축소 · 드래그 = 이동 · 0 = 원래 크기 · ESC = 닫기