이전에 쓰던 To Do List를 폐기하고, NestJS MVC 환경에서 TDD를 수행하는 법을 작성하려 한다.

크게 Unit Test와 Integration Test로 나누어서 연재할 예정이다.


간략한 MVC

흔히 서비스의 프론트엔드에서 발생하는 요청을 처리하기 위해 우리는 백엔드의 시스템을 MVC 디자인 패턴을 이용해 설계하곤 한다. MVC 패턴을 이용해 디자인 하면 객체 지향 설계 및 개발이 쉬워지기 때문인데, 간략히 설명하면 Model, View, Controller 각각의 객체(시스템 컴포넌트)들은 각자의 책임과 의무에만 집중하면 되는, 쉽게 말해 시스템을 ‘분리’하여 설계하고 개발할 수 있기 때문이다.

NestJS에서는 Module, Controller, Service를 통해 쉽게 MVC Design을 이용하여 애플리케이션을 구현할 수 있다.

일반적인 MVC 모델에서의 각 역할은 다음과 같다.

Controller
Frontend(View)에서 발생하는 요청에 따라 서버의 Model을 활용하여 알맞게 반환하는 로직을 수행한다.
Model
시스템이 가지고 있는 데이터로, Database, File System, Analysis 등 서비스와 관련된 모든 데이터를 가리킨다.
Service
MVC 모델에는 없는 요소지만, 서비스 패턴으로 거의 함께 설계된다. 컨트롤러가 라우팅 기능(요청과 응답)에 집중할 수 있도록 실제 Model을 관리하는 비즈니스 로직을 수행한다.

이것을 NestJS에서는 Module을 통해 쉽게 설계할 수 있는데, 그 생김새는 다음과 같다.

데이터 관리에 더 집중하기 위해 Repository를 추가하여 사용하기도 한다.

이렇게 각각 설계된 모듈은 NestJS의 imports를 통한 Dependency Injection를 통해 사용 가능하다.

위 예시와 같이 UserModule이 캐싱 기능 사용을 위해 RedisModule이 필요하다면, 의존성 주입을 통해 사용할 수 있다.

@Module({
  imports: [
    ConfigModule.forRoot(),
        //* Redis 기능 사용을 위한 Dependency Injection
    **RedisManagerModule**,
        //* Mongoose 기능 사용을 위한 D.I.
    **MongooseModule.forFeature([{ name: User.name, schema: _UserSchema }]),**
  ],
  controllers: [UserController],
  providers: [UserService, UserRepository],
  exports: [UserService],
})
export class UserModule {}

여기까지 NestJS가 어떻게 동작하는지 간략하게 알아봤다면, 쉬운 예시를 통해 NestJS에서 TDD를 어떻게 수행하는지 다음 장 부터 본격적으로 설명하기 전에 간략히 해 보겠다.

전체적으로 우리가 구현할 것은 User Module로, 사용자 정보를 관리하는 웹서버를 만들것이다. 최종적으로는 User Module에 Mongoose와 Redis Module을 연결할 것인데, 이에 대해 Unit TestIntegration Test를 수행할 예정이다.

웹 서버는 RESTful API 서버를 만들 것인데, 관련된 내용은 아래 글을 확인해주면 감사하겠다.

 

[RESTful API 설계하기] 1. RESTful과 API / RESTful API란

[RESTful API 설계하기] 2. REST 특징 [RESTful API] 1. RESTful과 API 어떤 서비스를 개발할 때 본래 필수적인 기능은 아니었지만, 이제는 필수적인 기능이 되어버린 API와 관련하여 글을 작성하려 한다. 이 중

dev-whoan.xyz


먼저 Unit Test는 말 그대로 단위 테스트다. 우리가 어떤 기능을 설계할 때, 해당 기능이 동작해야 다른 기능이 완전하게 동작하는 것을 알 수 있다. 우리의 UserModule로 설명하면, Service가 죽을 경우 Controller는 어떤 요청을 받더라도 정상적인 응답을 할 수 없게된다.

즉 이러한 상황을 사전에 방지하기 위해, Controller, Service, Model이 모두 각각 잘 동작하는지 확인하는 것이 그 목적이다. 다시 말하면, Controller에 각 라우팅이 잘 되는지 확인하고, Service의 Model 접근 등 기능이 잘 동작하는지 확인하고, 최종적으로 Model 또한 잘 동작하는지 확인하는 것이다.

그런데, 우리 서버는 웹 서버이므로, Controller가 잘 동작한다는 것은 실제 외부 요청에 대해 응답이 잘 반환되는지 확인하는 것이다. 즉 Controller의 실질적인 동작 테스트는 e2e (End to End) 테스트 다시 말해 Integration Test를 통해 확인할 것이다.

Service가 잘 동작한다는 것은, Model로 부터 우리가 기대한 모델을 잘 가져와서 Controller로 잘 반환했다는 것이기 때문에, Controller에서의 Service 호출이 잘 동작하는 것을 확인할 것이다.

마지막으로, Model의 경우 Redis의 경우는 실제 테스트 코드를 작성할 것이지만, Mongoose는 생략하도록 하겠다. (결국 똑같기 때문에)

정리하자면 다음 테스트들을 수행할 것이다.

  • Controller와 Service 간의 Unit Test
  • Service와 Model 간의 Redis를 통한 Unit Test
  • e2e Test

정리가 길었다. 이제 진짜 간단히 어떻게 Nest에서 TDD를 수행하는지 확인해 보자.

우선, NestJS 프로젝트를 생성하면 기본적으로 jest가 함께 설치된다. jest와 관련하여 자세한 내용은 https://jestjs.io 여기를 확인해 주길 바란다.

다음 명령어를 통해 NestJS 아래에서 Module, Controller, Service를 생성하자.

nest g module user --no-spec
nest g controller user --no-spec
nest g service user --no-spec

초기 user 모듈만 설정한 상태

그러고 나면 위와 같은 구조를 갖게 된다.

src는 NestJS를 개발할 source directory고, 그 아래의 user directory가 우리가 실제 개발할 User 관련 코드가 작성될 경로이다.

NestJS에서 test를 수행할 때, 나는 다음과 같은 구조로 수행한다.

user 디렉토리(이하 도메인 디렉토리) 아래에 test폴더와 mocks폴더를 생성하는데, 그 역할은 다음과 같다.

test: 실질적인 테스트 코드가 작성될 디렉토리
test/stubs: 테스트 코드에 필요한 Mocking Data(Dummy Data)가 작성될 디렉토리
mocks: 테스트에 필요한 Mocking Providers가 작성될 디렉토리


본격적으로 간단한 테스트를 작성해보자. 우리는 다음과 같은 요청을 처리하는 것을 만들려고 한다.

[REQUEST]
GET: /user
[RESPONSE]
OK

이를 위해 먼저 라우터의 처리가 필요한데 이는 Controller가 담당하므로, user.controller.ts에 다음의 코드를 작성하자. 다음부터는 해당하는 함수 등 필요한 부분만 잘라서 작성하겠다.

import { Controller, Get } from '@nestjs/common';

@Controller('user')
export class UserController {
  @Get()
  getUser(): string {
    return 'OK';
  }
}

이 후 Postman 등 HTTP Request를 보낼 수 있는 툴(Get method기 때문에 Web Browser를 이용해도 괜찮다.)로 http://localhost:3000/user로 요청을 보내보자.

지금은 TDD의 처음이기 때문에, 곧바로 controller에 실제 요청을 보내보았지만, 앞으로는 테스트 코드를 먼저 작성하고, 그것이 통과되면 실제 요청(e2e test 등)을 할 것이다.

처음으로 돌아가서, 테스트 코드를 작성해야 하는데 어떤것이 필요할까?

현재 우리는 User Controller를 테스트하고싶다. 즉 외부의 요청 없이 User Controller의 어떤 함수가 호출되었을 때, 기대하는 값이 잘 반환되는지 확인하고싶다. 그렇다면 User Controller의 구성은 어떻게 될까?

이 포인트가 정말 중요한데, NestJS에서는 Module을 만들고, 해당 모듈로부터 컨트롤러, 서비스 등 제공되는(Providing) 기능을 사용하고, 따라서 Module을 먼저 구성해야한다. 이것이 이해 안간다면, NestJS의 Module을 확인해보길 바란다. (👉 https://docs.nestjs.com/modules)

그래서 우리는 test code에서도 먼저 mock user module을 구현해야 한다.

//* user.controller.spec.ts
import { Test } from '@nestjs/testing';
import { UserController } from '../user.controller';
import { UserService } from '../user.service';

describe('UserController', () => {
  //* 사용할 Controller를 정의한다.
  let controller: UserController;
  //* 사용할 Service를 정의한다.
  let service: UserService;
  //* 이렇듯 우리가 테스트하는 대상이 사용하는 모든 것을 선언한다.

    //* 매 테스트를 수행하기 전에, testing을 위한 module을 정의한다.
  beforeEach(async () => {
    const moduleRef = await Test.createTestingModule({
      imports: [],
      controllers: [],
      providers: [],
        //* Compile을 붙이는 이유는, 해당 모듈이 생성되어야 하기 때문이다.
    }).compile();
  });
});

여기서 중요한 것은, imports, controllers, providers를 무엇으로 채우냐가 중요한데, 그것은 현재 테스트하는 대상에 따라 바뀐다.

즉, providers 제공자는 우리의 대상인 UserController가 어떤것을 제공받아야 하는지, 다시 말해 어떤 의존성을 갖는지를 기준으로 작성하면되고, Controller는 Controller가 필요하다면 적으면 된다.

마지막으로 imports의 경우 해당 모듈의 환경을 적어주면 되는데, 예를 들어 Mongoose 혹은 Redis 등 필요한 외부 모듈 (Mocking하지 않은)이 가지는 환경을 적어주면 된다.

우리는 UserController를 테스트하기 위해, 반드시 UserController가 필요하고 아직까지 의존성은 존재하지 않으니, 다음과 같이 갱신해주자.

//* 매 테스트를 수행하기 전에, testing을 위한 module을 정의한다.
beforeEach(async () => {
    const moduleRef = await Test.createTestingModule({
    controllers: [UserController],
  }).compile();

    //* 이후 컴파일 된 module에서 controller 등 필요한 것을 구한다.
    controller = moduleRef.get<UserController>(UserController);
  // service = moduleRef.get<UserService>(UserService);
});

우리가 Unit Test할 때 수행하는 내용은 크게 다음과 같다.

  • 해당 함수가 정상적으로 호출 되었는가?
  • 해당 함수에서 필요로 하는 의존성 대상들이 정상적으로 호출 되었는가?
    • 이 때 의존성 대상에 따라 해당 단계가 많아질수도, 줄어들 수도 있다.
  • 해당 함수가 종료되면서 기대한 값을 반환했는가?
  • 예외 처리는 잘 되는가?

우리는 아직 getUser() 함수를 통해 OK를 반환하는 것 밖에 없지만, 위 단계에 따르면 (1) 정상적으로 호출 되었는가? (2) 정상적으로 ‘OK’를 반환했는가? 를 확인할 수 있겠고, 해당 코드는 다음과 같이 작성할 수 있다.

//* getUser() 함수가 호출되었다면,
describe('when getUser is called', () => {
  let response: string;

  //* 응답 비교를 위해 일단 직접 호출하여 응답을 저장하자.
  beforeEach(() => {
    //* controller의 getUser 함수를 관찰하자.
    jest.spyOn(controller, 'getUser');
    response = controller.getUser();
  });

  //* controller.getUser 함수가 한 번 반드시 호출될 것이다.
  test('it should call controller.getUser once', () => {
    expect(controller.getUser).toBeCalledTimes(1);
  });

  //* controller.getUser 함수가 파라미터 없이 호출될 것이다.
  test('it should call controller.getUser without parameter', () => {
    expect(controller.getUser).toBeCalledWith();
  });

  //* controller.getUser 함수의 반환 값은 'OK'일 것이다.
  test('it should return a value "OK"', () => {
    expect(response).toEqual('OK');
  });
});

Jest는 사람이 읽을 수 있도록 테스트 코드가 작성되고, 또한 나도 위에 주석을 통해 설명을 적었기 때문에, 더 자세히 설명하지 않고 오늘의 글을 마무리짓도록 하겠다.

' > NestJS' 카테고리의 다른 글

NestJS — Test Driven Development (2)  (0) 2023.03.20

+ Recent posts