NESTJS를 배워보자(20) - Testing

yoon·2023년 11월 5일
0

NESTJS를 배워보자

목록 보기
20/21

Testing

nest의 공식문서를 토대로 작성합니다.

자동화된 테스트는 모든 소프트웨어 개발 노력의 필수적인 부분으로 간주됩니다. 자동화를 사용하면 개발 중에 개별 테스트 또는 여러 테스트를 쉽고 빠르게 반복할 수 있습니다. 이를 통해 배포가 품질 및 성능 목표를 충족하도록 보장할 수 있습니다. 자동화는 커버리지를 늘리고 개발자에게 더 빠른 피드백 루프를 제공하는 데 도움이 됩니다. 자동화는 개별 개발자의 생산성을 높이고 소스 코드 제어 체크인, 기능 통합 및 버전 릴리스와 같은 중요한 개발 수명 주기 시점에 테스트를 실행할 수 있도록 합니다.

이러한 테스트는 단위 테스트, e2e 테스트, 통합 테스트 등 다양한 유형에 걸쳐 있는 경우가 많습니다. 이러한 테스트의 이점은 의심할 여지가 없지만 설정하는 것은 지루할 수 있습니다. Nest는 효과적인 테스트를 포함한 개발 모범 사례를 장려하기 위해 노력하고 있으므로 개발자와 팀이 테스트를 빌드하고 자동화하는 데 도움이 되는 다음과 같은 기능이 포함되어 있습니다.

  • 컴포넌트에 대한 기본 단위 테스트와 애플리케이션에 대한 E2E 테스트를 자동으로 생성.
  • 기본 도구를 제공.(예: 고립된 모듈/애플리케이션 로더를 구축하는 테스트 실행기와 같은 도구)
  • 테스트 도구와 관계없이 Jest와 Supertest와 통합 가능.
  • 테스트 환경에서 Nest 종속성 주입 시스템을 이용하여 컴포넌트를 쉽게 모의 테스트 가능.

앞서 언급했듯이 Nest는 특정 툴을 강요하지 않으므로 원하는 테스트 프레임워크를 사용할 수 있습니다. 테스트 러너 등 필요한 요소만 교체하면 Nest의 기본 테스트 도구의 이점을 그대로 누릴 수 있습니다.

Installation

먼저 필요한 패키지를 설치합니다:

$ npm i --save-dev @nestjs/testing

Unit testing

다음 예제에서는 CatsControllerCatsService를 테스트합니다. 앞서 언급했듯이 Jest는 기본 테스트 프레임워크로 제공됩니다. 다음 기본 테스트에서는 테스트에 필요한 클래스를 수동으로 인스턴스화하고 컨트롤러와 서비스가 API 계약을 만족하는지 확인합니다.

# cats.controller.spec.ts

import { CatsController } from './cats.controller';
import { CatsService } from './cats.service';

describe('CatsController', () => {
  let catsController: CatsController;
  let catsService: CatsService;

  beforeEach(() => {
    catsService = new CatsService();
    catsController = new CatsController(catsService);
  });

  describe('findAll', () => {
    it('should return an array of cats', async () => {
      const result = ['test'];
      jest.spyOn(catsService, 'findAll').mockImplementation(() => result);

      expect(await catsController.findAll()).toBe(result);
    });
  });
});

위의 예는 간단한 것이기 때문에 Nest와 관련된 어떤 것도 테스트하고 있지 않습니다. 실제로 종속성 주입도 사용하지 않습니다. 테스트 대상 클래스를 수동으로 인스턴스화하는 이러한 형태의 테스트는 프레임워크와 독립적이기 때문에 종종 격리 테스트라고 합니다. Nest의 기능을 보다 광범위하게 사용하는 애플리케이션을 테스트하는 데 도움이 되는 몇 가지 고급 기능을 소개하겠습니다.

Testing utilities

nestjs/testing 패키지는 보다 강력한 테스트 프로세스를 가능하게 하는 유틸리티를 제공합니다. 내장된 Test 클래스를 사용하여 이전 예제를 다시 작성해봅니다:

# cats.controller.spec.ts

import { Test } from '@nestjs/testing';
import { CatsController } from './cats.controller';
import { CatsService } from './cats.service';

describe('CatsController', () => {
  let catsController: CatsController;
  let catsService: CatsService;

  beforeEach(async () => {
    const moduleRef = await Test.createTestingModule({
        controllers: [CatsController],
        providers: [CatsService],
      }).compile();

    catsService = moduleRef.get<CatsService>(CatsService);
    catsController = moduleRef.get<CatsController>(CatsController);
  });

  describe('findAll', () => {
    it('should return an array of cats', async () => {
      const result = ['test'];
      jest.spyOn(catsService, 'findAll').mockImplementation(() => result);

      expect(await catsController.findAll()).toBe(result);
    });
  });
});

Test 클래스는 기본적으로 전체 Nest 런타임을 모킹하는 애플리케이션 실행 컨텍스트를 제공하는 데 유용하지만 모킹 및 재정의 등 클래스 인스턴스를 쉽게 관리할 수 있는 훅을 제공합니다. Test 클래스에는 모듈 메타데이터 객체를 인자로 받는 createTestingModule() 메소드가 있습니다. 이 메소드는 몇 가지 메소드를 제공하는 TestingModule 인스턴스를 반환합니다. 단위 테스트에서 중요한 것은 compile() 메소드입니다. 이 메소드는 종속성이 있는 모듈을 부트스트랩하고 테스트할 준비가 된 모듈을 반환합니다.

HINT
compile() 메소드는 비동기적임. 모듈이 컴파일되면 get() 메소드를 사용하여 모듈이 선언한 정적 인스턴스를 검색 가능.

TestingModule은 모듈 참조 클래스를 상속하므로 범위가 지정된 provider를 동적으로 확인할 수 있는 기능이 있습니다. 이 작업은 resolve() 메소드를 사용하여 수행합니다(get() 메소드는 정적 인스턴스만 검색 가능).

const moduleRef = await Test.createTestingModule({
  controllers: [CatsController],
  providers: [CatsService],
}).compile();

catsService = await moduleRef.resolve(CatsService);

WARNING
resolve() 메소드는 자체 DI 컨테이너 하위 트리에서 provider의 고유한 인스턴스를 반환. 각 하위 트리에는 고유한 컨텍스트 식별자가 있음. 따라서 이 메소드를 두 번 이상 호출하고 인스턴스 참조를 비교하면 인스턴스 참조가 동일하지 않음.

프로덕션 버전의 provider를 사용하는 대신 테스트 목적으로 사용자 지정 provider로 재정의할 수 있습니다. 예를 들어 라이브 데이터베이스에 연결하는 대신 데이터베이스 서비스를 모의 테스트할 수 있습니다. 이후에 오버라이드를 다루지만 단위 테스트에도 사용할 수 있습니다.

Auto mocking

Nest를 사용하면 누락된 모든 종속성에 적용할 모의 팩토리를 정의할 수 있습니다. 이 기능은 클래스에 많은 종속성이 있고 모든 종속성을 모킹하는 데 오랜 시간과 많은 설정이 필요한 경우에 유용합니다. 이 기능을 사용하려면 createTestingModule()useMocker() 메소드와 연결하여 의존성 목업에 대한 팩토리를 전달해야 합니다. 이 팩토리는 인스턴스 토큰인 선택적 토큰, Nest provider에 유효한 모든 토큰을 받을 수 있으며 목업 구현을 반환합니다. 아래는 jest-mock을 사용하여 일반 목업 객체를 생성하는 예시와 jest.fn()을 사용하여 CatsService에 대한 특정 목업 객체를 생성하는 예시입니다.

// ...
import { ModuleMocker, MockFunctionMetadata } from 'jest-mock';

const moduleMocker = new ModuleMocker(global);

describe('CatsController', () => {
  let controller: CatsController;

  beforeEach(async () => {
    const moduleRef = await Test.createTestingModule({
      controllers: [CatsController],
    })
      .useMocker((token) => {
        const results = ['test1', 'test2'];
        if (token === CatsService) {
          return { findAll: jest.fn().mockResolvedValue(results) };
        }
        if (typeof token === 'function') {
          const mockMetadata = moduleMocker.getMetadata(token) as MockFunctionMetadata<any, any>;
          const Mock = moduleMocker.generateFromMetadata(mockMetadata);
          return new Mock();
        }
      })
      .compile();

    controller = moduleRef.get(CatsController);
  });
});

End-to-end testing

개별 모듈과 클래스에 초점을 맞추는 단위 테스트와 달리 e2e 테스트는 최종 사용자가 프로덕션 시스템과 상호 작용하는 방식에 더 가까운 수준에서 클래스와 모듈의 상호 작용을 다룹니다. 애플리케이션이 성장함에 따라 각 API 엔드포인트의 e2e 동작을 수동으로 테스트하는 것이 어려워집니다. 자동화된 e2e 테스트는 시스템의 전반적인 동작이 정확하고 프로젝트 요구 사항을 충족하는지 확인하는 데 도움이 됩니다. e2e 테스트를 수행하기 위해 방금 단위 테스트에서 다룬 것과 유사한 구성을 사용합니다. 또한 Nest를 사용하면 Supertest 라이브러리를 사용하여 HTTP 요청을 쉽게 시뮬레이션할 수 있습니다.

# cats.e2e-spec.ts

import * as request from 'supertest';
import { Test } from '@nestjs/testing';
import { CatsModule } from '../../src/cats/cats.module';
import { CatsService } from '../../src/cats/cats.service';
import { INestApplication } from '@nestjs/common';

describe('Cats', () => {
  let app: INestApplication;
  let catsService = { findAll: () => ['test'] };

  beforeAll(async () => {
    const moduleRef = await Test.createTestingModule({
      imports: [CatsModule],
    })
      .overrideProvider(CatsService)
      .useValue(catsService)
      .compile();

    app = moduleRef.createNestApplication();
    await app.init();
  });

  it(`/GET cats`, () => {
    return request(app.getHttpServer())
      .get('/cats')
      .expect(200)
      .expect({
        data: catsService.findAll(),
      });
  });

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

위 예에서는 앞서 설명한 몇 가지 개념을 기반으로 구축합니다. 앞에서 사용한 compile() 메소드에 더해 이제 createNestApplication() 메소드를 사용하여 전체 Nest 런타임 환경을 인스턴스화합니다. 실행 중인 앱에 대한 참조를 앱 변수에 저장하여 HTTP 요청을 시뮬레이션하는 데 사용할 수 있습니다.

Supertest의 request() 함수를 사용하여 HTTP 테스트를 시뮬레이션합니다. 이러한 HTTP 요청이 실행 중인 Nest 앱으로 라우팅되기를 원하므로 request() 함수에 Nest의 기반이 되는 HTTP 리스너에 대한 참조를 전달합니다. 따라서 request(app.getHttpServer()) 구조가 생성됩니다. request()를 호출하면 이제 Nest 앱에 연결된 HTTP 서버가 전달되며 이 서버는 실제 HTTP 요청을 시뮬레이션하는 메소드를 노출합니다. 예를 들어, request(...)get('/cats')를 사용하면 네트워크를 통해 들어오는 get '/cats'와 같은 실제 HTTP 요청과 동일한 요청이 Nest 앱에서 시작됩니다.

Nest는 모듈, 프로바이더, 가드, 인터셉터, 필터 및 파이프를 override하는 메소드를 각각 제공합니다.

각 override 메소드(overrideModule() 제외)는 사용자 정의 provider에 대한 설명과 일치하는 3가지 다른 메소드를 가진 객체를 반환합니다.

  • useClass: 객체를 override하기 위한 인스턴스를 제공하기 위해 인스턴스화될 클래스 제공.
  • useValue: 객체를 override하는 인스턴스 제공.
  • useFactory: 객체를 override할 인스턴스를 반환하는 함수 제공.

반면 overrideModule()은 다음처럼 기존 모듈을 override할 useModule() 메소드와 객체를 반환합니다.

const moduleRef = await Test.createTestingModule({
  imports: [AppModule],
})
  .overrideModule(CatsModule)
  .useModule(AlternateCatsModule)
  .compile();

각 override 메소드 유형은 차례로 TestingModule 인스턴스를 반환하므로 다른 메소드와 연결할 수 있습니다. 이러한 연결 끝에서 compile()을 사용하여 Nest가 모듈을 인스턴스화하고 초기화하도록 해야 합니다.

또한 테스트가 실행될 때 사용자 정의 로거를 제공하고자 하는 경우도 있습니다. setLogger() 메소드를 사용하고 LoggerService 인터페이스를 충족하는 객체를 전달하여 테스트 중에 테스트 모듈 빌더에 로깅하는 방법을 지시하면 됩니다(기본적으로 error 로그만 콘솔에 기록됨).

컴파일된 모듈은 몇 가지 유용한 메소드를 제공합니다:

메소드설명
createNestApplication()주어진 모듈을 기반으로 Nest 애플리케이션을 생성하고 반환.
init() 메소드를 사용하여 애플리케이션을 수동으로 초기화해야 함.
createNestMicroservice()주어진 모듈을 기반으로 Nest 마이크로서비스를 생성하고 반환.
get()애플리케이션 컨텍스트에서 사용할 수 있는 컨트롤러 또는 프로바이더의
정적 인스턴스 검색. 모듈 참조 클래스에 상속.
resolve()애플리케이션 컨텍스트에서 사용할 수 있는 컨트롤러 또는 프로바이더의
동적으로 생성된 범위 인스턴스를 검색.
select()모듈의 종속성 그래프를 탐색. 선택한 모듈에서 특정 인스턴스를
검색하는 데 사용.

Overriding globally registered enhancers

전역으로 등록된 가드(파이프, 인터셉터 또는 필터 포함)가 있는 경우 해당 enhancer를 override 하려면 몇 가지 단계를 더 수행해야 합니다. 원래 등록을 요약하면 다음과 같습니다:

providers: [
  {
    provide: APP_GUARD,
    useClass: JwtAuthGuard,
  },
],

이것은 APP_* 토큰을 통해 가드를 multi-provider로 등록하는 것입니다. 여기서 JwtAuthGaurd를 대체할 수 있으려면 다음과 같이 작성해야 합니다:

providers: [
  {
    provide: APP_GUARD,
    useExisting: JwtAuthGuard,
    // ^^^^^^^^ notice the use of 'useExisting' instead of 'useClass'
  },
  JwtAuthGuard,
],

인스턴스화하는 대신 등록된 프로바이더를 참조하게 됩니다.

이제 테스트 모듈 생성 시 override 가능한 일반 프로바이더로 JwtAuthGuard가 Nest에 표시됩니다.

const moduleRef = await Test.createTestingModule({
  imports: [AppModule],
})
  .overrideProvider(JwtAuthGuard)
  .useClass(MockAuthGuard)
  .compile();

이제 모든 테스트 요청에서 MockAuthGaurd를 사용할 수 있습니다.

Testing request-scoped instances

요청 범위가 지정된 프로바이더는 들어오는 각 요청에 대해 고유하게 생성됩니다. 인스턴스는 요청 처리가 완료된 후 garbage-collected 됩니다. 이는 테스트된 요청을 위해 특별히 생성된 의존성 주입 하위 트리에 접근할 수 없기 때문에 문제가 됩니다.

동적으로 인스턴스화된 클래스를 검색하려면 resolve() 메소드를 사용할 수 있습니다. 고유한 컨텍스트 식별자를 전달하여 DI 컨테이너 하위 트리의 수명주기를 제어할 수 있습니다. 테스트 컨텍스트에서 이를 어떻게 활용할까요?

컨텍스트 식별자를 미리 생성하고 Nest가 이 특정 ID를 사용하여 들어오는 모든 요청에 대한 하위 트리를 생성하도록 합니다. 이렇게 하면 테스트된 요청에 대해 생성된 인스턴스를 검색할 수 있습니다.

이를 위해 ContextIdFactory에서 jset.spyOn()을 사용합니다:

const contextId = ContextIdFactory.create();
jest.spyOn(ContextIdFactory, 'getByRequest').mockImplementation(() => contextId);

이제 contextId를 사용하여 후속 요청에 대해 생성된 단일 DI 컨테이너 하위 트리에 접근 가능합니다.

catsService = await moduleRef.resolve(CatsService, contextId);

고생하셨습니다!
다음 글에서 만나요~~😀


저도 아직 배우는 단계입니다. 지적 감사히 받겠습니다. 함께 열심히 공부해요!!

profile
백엔드 개발자 지망생

0개의 댓글