새소식

반응형
Back-end/NestJS

[NestJS | Docs] Testing 알아보기

2024.03.06
  • -
반응형

1. Testing

자동화된 테스트는 소프트웨어 개발에 들어가는 모든 공수(effort)의 핵심 파트로 여겨집니다. 테스트를 자동화하면 개발 도중에 개별 테스트 또는 테스트 묶음들을 빠르고 쉽게 반복하여 테스트 해볼 수 있게 됩니다. 또한 제품을 출시할 때 품질과 성능의 만족을 보장하기도 합니다.

 

이렇듯 자동화는 개발자들에게 커버리지(테스트 범위)를 증가하는데 도움을 주며 더 빠른 피드백 순환을 가능하게 합니다. 그리고 각 개발자들의 생산성을 증가시키고 개발 생명주기의 정점(소스 코드 제어 체크인, 기능 통합, 버전 릴리즈 등)에서 테스트가 실행됨을 보장합니다.

 

그러한 테스트는 unit 테스트, end-to-end(e2e) 테스트, integration 테스트 등을 포함하여 다양한 유형에 걸쳐 분포합니다. 테스트를 함으로써 얻을 수 있는 장점은 의심할 여지가 없지만, 이를 설정하는 데 필요한 과정은 매우 지루한 작업입니다. Nest는 효과적인 테스트를 포함한 개발 모범 사례를 제공하기 위해 노력하였고, 그 결과 개발자와 팀이 테스트를 구축하고 자동화하는 데 도움이 되는 다음과 같은 기능을 만들어냈습니다.

  • 컴포넌트에 대한 unit 테스트와 애플리케이션에 대한 e2e 테스트를 자동으로 지원합니다.
  • 기본적인 도구(ex. 고립된 모듈/애플리케이션 로더를 빌드하는 테스트 러너)를 제공합니다.
  • 테스트 도구에 구애받지 않으면서, Jest 및 Supertest와의 통합을 바로 사용가능 합니다.
  • 테스트 환경에서 Nest 의존성 주입을 사용하여 컴포넌트를 쉽게 모킹할 수 있습니다.

 

앞서 언급했듯이, Nest는 특정 테스팅 툴을 사용하도록 강제하지 않기 때문에 각자 선호하는 테스팅 프레임워크를 전부 사용할 수 있습니다. 테스트 러너와 같이 필요한 요소를 잘 교체하기만 하면 Nest의 기성 테스트 기능의 이점을 그대로 누릴 수 있습니다.

 

1-1. Installation

시작하기 전, 필요한 패키지를 설치해줍니다.

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

 

1-2. Unit testing

다음 예제에서는 두 가지 클래스를 테스트해볼 것입니다: CatsControllerCatsService

 

Jest가 기본적인 테스팅 프레임워크로 제공됩니다. Jest는 테스트 러너 역할을 하며 모킹, 스파이 등에 도움이 되는 assert 함수와 test-double 유틸리티도 제공합니다. 다음 기본 테스트에서는 이러한 클래스들을 수동으로 인스턴스화하고 controller와 service가 우리가 만든 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와 관련된 어떤 것도 테스트하고 있지는 않습니다. 코드를 보면 알 수 있듯 실제로 의존성 주입을 사용하고 있지도 않습니다. 테스트 대상 클래스를 수동으로 인스턴스화 하는 이러한 형태의 테스트는 프레임워크와 독립적이기 때문에 종종 isolated testing이라고 부릅니다. 

 

다음은 Nest 기능을 보다 광범위하게 사용하는 애플리케이션을 테스트할 때 도움이 되는 몇 가지 고급 기능을 살펴보도록 하겠습니다.

 

1-3. Testing utilties

@nestjs/testing 패키지는 보다 강력한 테스트 프로세스를 가능하게 하는 몇가지 유틸리티를 제공합니다. 기본으로 제공되는 Test 클래스를 사용하여 이전 예제를 다시 작성해 보겠습니다:

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({ // Test 클래스 사용
        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 런타임 자체를 모킹하기 때문에 애플리케이션 실행 컨텍스트를 제공하는 데 유용하고, 모킹 및 오버라이딩 등 클래스 인스턴스를 쉽게 관리할 수 있는 hooks을 제공합니다.

 

Test 클래스에는 모듈 메타데이터 객체(@Module() 데코레이터에 전달한 것과 동일한 객체)를 인수로 받는 createTestingModule() 메서드가 있습니다. 이 메서드는 몇 가지 메서드를 제공하는 TestingModule 인스턴스를 반환합니다.

 

단위(Unit) 테스트의 경우에는 compile()이라는 메서드가 중요합니다. 이 메서드는 의존성을 가진 모듈을 부트스트랩하고(기존의 main.ts 파일에서 NestFactory.create()를 사용하여 애플리케이션을 부트스트랩하는 방식과 유사), 테스트할 준비가 된 모듈을 반환합니다.

 

compile() 메서드는 비동기적이기 때문에 그 이후에 작업들에서 이 인스턴스를 사용하기 위해 await 키워드를 통해 동기 처리를 합니다. 그로 인해 모듈이 컴파일되고 나서야 get() 메서드를 사용하여 모듈이 선언한 모든 정적 인스턴스(컨트롤러 및 프로바이더)를 가져올 수 있습니다.

 

TestingModule은 module reference 클래스를 상속하므로 scoped provider(일시적 또는 요청 범위)를 동적으로 확인하는 기능이 있습니다. 이 경우의 작업은 resolve() 메서드를 사용하여 수행해야 합니다(get() 메서드는 정적 인스턴스만 찾을 수 있음).

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

catsService = await moduleRef.resolve(CatsService);

 

WARNING
resolve() 메서드는 자체 DI 컨테이너 하위 트리에서 유니크한 provider 인스턴스를 반환합니다. 각 하위 트리에는 고유한 context 식별자가 있는데 실제로 이 메서드를 두 번 이상 호출하고 인스턴스 참조를 비교하면 동일하지 않음을 알 수 있습니다.

 

production 버전의 provider를 사용하는 대신 테스트 목적으로 custom provider로 오버라이딩할 수 있습니다. 예를 들어 실제 데이터베이스에 연결하는 대신 데이터베이스 서비스를 mocking 하는 경우가 있을 것입니다. 다음 섹션에서 오버라이드를 다루는데, 이는 단위 테스트에도 사용할 수 있습니다.

 

1-4. Auto mocking

Nest를 사용하면 누락된 모든 의존성에 적용할 모의 factory를 정의할 수도 있습니다. 이 기능은 클래스에 많은 의존성이 있고 모든 의존성을 모킹하는 데 시간이 오래 걸리고 설정이 많은 경우에 유용합니다.

 

이 기능을 사용하려면 createTestingModule()을 useMocker() 메서드와 체인으로 연결하여 의존성 모킹을 위한 팩토리를 전달해야 합니다. 이러한 팩토리는 인스턴스 토큰인 optional token, Nest provider에 유효한 모든 token을 받을 수 있으며 모의 구현(mock implementation)을 반환합니다.

 

아래는 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);
  });
});

 

또한 일반적으로 custom provider를 사용할 때와 마찬가지로 테스트 컨테이너에서 이러한 모형(mock)을 moduleRef.get(CatsService)로 가져올 수도 있습니다.

 

  • @golevelup/ts-jest의 createMock과 같은 일반 모의 팩토리를 직접 전달할 수도 있습니다.
  • REQUESTINQUIRER provider는 컨텍스트에서 이미 사전 정의되어 있으므로 자동 모킹할 수 없습니다. 그러나 custom provider 구문을 사용하거나 .overrideProvider 메서드를 활용하여 덮어쓸 수 있습니다.

 

1-5. End-to-end testing

개별 모듈과 클래스에 초점을 맞추는 단위 테스트와 달리 엔드투엔드(e2e) 테스트최종 사용자가 프로덕션 시스템과 상호 작용하는 방식에 더 가깝기 때문에 보다 전반적인 애플리케이션 수준에서 클래스와 모듈의 상호 작용을 다룹니다.

 

애플리케이션이 커짐에 따라 각 API 엔드포인트의 end-to-end 동작을 수동으로 테스트하기는 점점 어려워집니다. 자동화된 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 런타임 환경을 인스턴스화합니다. 실행 중인 앱에 대한 참조를 app 변수에 저장하여 HTTP 요청을 시뮬레이션하는 데 사용할 수 있습니다.

 

Supertest의 request() 함수를 사용하여 HTTP 테스트를 시뮬레이션합니다. 이러한 HTTP 요청이 실행 중인 Nest 앱으로 라우팅되기를 원하기때문에 request() 함수에 Nest의 기반이 되는 HTTP listener(Express 플랫폼에서 제공될 수 있음)에 대한 참조를 전달합니다.

 

그렇기 때문에 request(app.getHttpServer()) 구조가 생성됩니다. 그리고 request()를 호출하면 이제 Nest 앱에 연결된 래핑되어있는 HTTP 서버가 전달되며, 이 서버는 실제 HTTP 요청을 시뮬레이션하는 메서드를 제공합니다. 예를 들어, request(...).get('/cats')을 사용하면 네트워크를 통해 들어오는 get '/cats'와 같은 실제 HTTP 요청과 동일한 요청이 Nest 앱에서 시작되는 것입니다.

 

이 예에서는 테스트할 수 있는 하드코딩된 값을 간단히 반환하는 CatsService의 대체(test-double) 구현도 제공합니다. 이러한 대체 구현을 제공하려면 overrideProvider()를 사용해야 합니다. 마찬가지로 Nest는 모듈, 가드, 인터셉터, 필터 및 파이프를 재정의하는 메서드를 각각 overrideModule(), overrideGuard(), overrideInterceptor(), overrideFilter() 및 overridePipe() 메서드를 통해 제공합니다.

각 오버라이드 메서드(overrideModule() 제외)는 custom provider에 기술된 메서드를 미러링하는 3개의 다른 메서드를 갖는 객체를 반환합니다:

  • useClass: 객체를 오버라이딩할 인스턴스를 제공하기 위해 인스턴스화할 클래스(provider, guard 등)를 제공합니다.
  • useValue: 객체를 오버라이딩할 인스턴스를 제공합니다.
  • useFactory: 객체를 오버라이딩할 인스턴스를 반환하는 함수를 제공합니다.

 

반면에 overrideModule()은 아래 코드와 같이 원래 모듈을 오버라이딩할 모듈을 제공할 때 사용할 수 있는 useModule() 메서드를 갖는 객체를 반환합니다:

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

 

각 오버라이딩 메서드 유형은 차례로 TestingModule 인스턴스를 반환하므로 fluent style(.으로 이어지는)로 다른 메서드와 체인으로 연결할 수 있습니다. 이러한 체인의 끝에서는 compile()을 사용하여 Nest가 모듈을 인스턴스화하고 초기화하도록 해야 합니다.

또한 테스트가 실행될 때(예: CI 서버에서) custom logger를 제공하고자 하는 경우도 있습니다. setLogger() 메서드를 사용하고 LoggerService 인터페이스를 충족하는 객체를 전달하여 테스트 중에 TestModuleBuilder에 로깅하는 방법을 사용하도록 합니다.(디폴트로는 "error" 로그만 콘솔에 기록됩니다)

컴파일된 모듈에는 다음 설명과 같은 몇 가지 유용한 메서드들이 있습니다:

  • createNestApplication()
    • 주어진 모듈을 기반으로 Nest 애플리케이션(INestApplication 인스턴스)을 생성하고 반환합니다. init() 메서드를 사용하여 애플리케이션을 수동으로 초기화해야 한다는 점을 유의합니다.
  • createNestMicroservice()
    • 주어진 모듈을 기반으로 Nest 마이크로서비스(INestMicroservice 인스턴스)를 생성하고 반환합니다.
  • get()
    • 애플리케이션 context에서 사용할 수 있는 controller 또는 provider(guard, filter 등)의 정적 인스턴스를 가져옵니다. module reference 클래스에서 상속됩니다.
  • resolve()
    • 애플리케이션 context에서 사용 가능한 controller 또는 provider(guard, filter 등 포함)의 동적으로 생성된 scoped 인스턴스(request 또는 transient)를 가져옵니다. module reference 클래스에서 상속됩니다.
  • select()
    • 모듈의 의존성 그래프를 탐색하고, 선택한 모듈에서 특정 인스턴스를 가져오는 데 사용할 수 있습니다(get() 메서드에서 strict 모드(strict: true)와 함께 사용).

 

1-6. Overriding globally reigstered enhancers

전역으로 등록된 guard(또는 pipe, interceptor, filter)가 있는 경우 해당 enhancer들을 오버라이딩하려면 몇 가지 단계가 더 수행돼야 합니다.

 

이러한 enhander들의 등록은 다음과 같이 이루어졌었습니다.

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

이는 APP_* 토큰을 통해 guard를 "multi" provider로 등록하는 것입니다. 이 슬롯에 기존 provider를 사용해야만 JwtAuthGuard를 대체할 수 있습니다:

 

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

Nest가 토큰 뒤에서 인스턴스화하는 대신 이미 등록된 provider를 참조하도록 useClass를 useExisting으로 변경할 수 있습니다.

 

이제 테스트 모듈을 생성할 때 오버라이딩할 수 있는 일반 provider로 JwtAuthGuard가 Nest에 표시됩니다:

 

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

 

이제 모든 테스트에서 모든 요청에 MockAuthGuard를 사용하도록 되었습니다.

 

1-7. Testing request-scoped instances

requset-scoped provider는 들어오는 각 요청에 대해 고유하게 생성됩니다. 인스턴스는 요청 처리가 완료된 후 garbage-collected 됩니다. 이는 테스트된 요청을 위해 특별히 생성된 의존성 주입 하위 트리에 액세스할 수 없기 때문에 다소 문제가 될 수 있습니다.

 

위의 섹션을 통해 동적으로 인스턴스화된 클래스를 찾을 때는 resolve() 메서드를 사용할 수 있다는 것을 배웠습니다. 또한 여기에 설명된 대로 고유한 context 식별자를 전달하여 DI 컨테이너 하위 트리의 생명주기를 제어할 수 있다는 것도 배웠습니다.

 

그렇다면 테스트 context에서는 이를 어떻게 활용할 수 있을까요?

 

해당 전략은 context 식별자를 미리 생성하고 Nest가 이 특정 ID를 사용하여 들어오는 모든 요청에 대한 하위 트리를 생성하도록 하는 것입니다. 이렇게 하면 테스트된 요청에 대해 생성된 인스턴스를 가져올 수 있습니다.

 

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

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

 

이제 contextId를 사용하여 모든 후속 요청에 대해 생성된 단일 DI 컨테이너 하위 트리에 액세스할 수 있습니다.

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

 

반응형
Contents

포스팅 주소를 복사했습니다

이 글이 도움이 되었다면 공감 부탁드립니다.