TDD (feat.nestjs + jest)

DaeChan Jo·2024년 1월 6일
0

외해야되?

사실 지금도 테스트 주도 개발이란 말이 뼈에 와닿지는 않는다.
그동안 했던 프로젝트들은 엉망진창이었던 기획으로 시도 때도 없이 날아오는 스펙들과 촉박한 마감에서 사실상 TDD를 적용한다는 것 자체가 굉장히 비효율적일뿐더러 산출 기한 내에 프로젝트를 완성하지도 못했을 것 같다.

사실 TDD를 제외하고도 간단한 기능 하나 구현하는데도 계층을 쪼개고 DTO를 적용하고 수많은 유효성 검사를 거치는 등 가끔 너무 과하지 않나 생각이 들 때도 있다. 매우 간단한 기능을 만들었는데 100줄의 코드가 넘어가는 걸 보면 이게 맞나 싶을 때도 있다
그런데도 추상화와 규칙이 있어야 하는 이유는 결국 유지보수를 위한 게 아닐까 생각한다.
간혹 내가 작성했던 코드도 기능이 고도화될수록 추후 수정사항이 생길 때 어떻게 손을 대야 할지 엄두도 안 날 때가 많았다.




단점은생략한다

만들고 싶은 기능에 대한 테스트 케이스를 작성하고 이후 단위 테스트를 통과할 수 있을 정도 최소한의 코드를 작성한 뒤 최적의 성능을 위해 리팩토링을 진행한 후 프로덕션 레벨에 적용하는 순서를 밟기 때문에 강제적으로 구현해야 할 기능에 대해 더 깊이 이해하게 되고 결과적으로 더 높은 품질의 코드를 작성하는 데 도움이 된다.

또 기능 변경이나 추가가 필요할 때, 또는 구조를 변경하거나 개선하는 리팩토링을 할 때 프로덕션 코드가 아닌 미리 작성되어있는 테스트 케이스로 테스트하면 안전하게 수정할 수 있다.

사용하는 데 주의하면 좋을 점으론, TDD도 결국 객체지향을 위한 방법이므로 각각의 테스트는 독립적이면서 재사용이 보장되어야 하고 의존해서는 안 되며 어느 환경에서도 반복할 수 있어야 한다.




적용해보기

├─ src
│  ├─ app.module.ts
│  ├─ entities
│  ├─ main.ts
│  ├─ modules
│  │  ├─ auth
│  │  │  ├─ auth.controller.spec.ts
│  │  │  ├─ auth.controller.ts
│  │  │  ├─ auth.module.ts
│  │  │  ├─ auth.service.spec.ts
│  │  │  ├─ auth.service.ts
│  │  │  └─ dto

다음과 같이 회원가입, 로그인 인증 컨트롤러 코드가 있다고 가정한다

  @Post()
  @ApiOperation({
    summary: '회원가입',
  })
  @ApiBody({ type: JoinDataDto })
  @ApiResponse({ status: 201, type: CreatedUserDto })
  @UsePipes(new ValidationPipe())
  async createUser(@Body() joinData: JoinDataDto): Promise<CreatedUserDto> {
    if (!this.authService.isValidPassword(joinData)) {
      throw new HttpException('Passwords do not match', 400);
    }
    const { passwordConfirm: passwordConfirm, ...createUserDto } = joinData;
    return await this.authService.createUser(createUserDto);
  }

  @Post('login')
  @ApiOperation({
    summary: '로그인',
    description: '성공시 JWT 발급',
  })
  @ApiBody({ type: LoginDataDto })
  @ApiResponse({ status: 201, type: LoginUserDto })
  @UsePipes(new ValidationPipe())
  @UseGuards(AuthGuard('local'))
  async login(@Request() req: RequestWithUser): Promise<LoginUserDto> {
    return await this.authService.login(req.user);
  }

TDD순서가 뒤바꼈지만 지금은 Jest를 어떻게 사용하는가에 초점을 둔다
회원가입은 특별한 인증 없이 이메일 중복여부와 사용자가 입력한 비밀번호와 컴펌 비밀번호가 일치한지만 검사하고 로그인은 passport의 local 전략을 이용한 기본적인 인증방식의 기능들의 컨트롤러 코드다.


차근차근 테스트 케이스를 작성해보자
describe('AuthController', () => {

describe는 Jest에서 테스트를 그룹화하는데 사용하는 함수다. 첫 번째 인자는 테스트 그룹의 이름이고 두 번째 인자는 그룹에 속한 테스트들을 정의하는 함수다.


  let controller: AuthController;
  let authService: AuthService;

AuthController와 AuthService 인스턴스를 저장할 변수를 선언해준다.
후에 테스트 모듈 내부에서 재할당을 해야하니 상수가아닌 let으로 해준다


beforeEach(async () => {

beforeEach 는 각 테스트 케이스가 실행되기 전에 매번 호출되는 함수다. 이 함수 내에서 테스트 환경을 초기화하는 작업을 수행한다.


    const module: TestingModule = await Test.createTestingModule({

Test.createTestingModule는 NestJS의 테스트 모듈을 생성하는 메서드다. 해당 메서드를 호출하면 테스트에 필요한 의존성 주입 환경을 아래와 같이 설정할 수 있다. (NestJS의 일반적인 모듈 설정과 동일하다.)

      imports: [
        JwtModule.register({
          secret: process.env.JWT_SECRET,
          signOptions: { expiresIn: '24h' },
        }),
        TypeOrmModule.forRoot(typeOrmConfig),
        TypeOrmModule.forFeature([User, Post]),
      ],
      controllers: [AuthController],
      providers: [AuthService, LocalStrategy],
     }).compile();

테스트에 필요한 컨트롤러와 서비스를 등록해주고 compile 메서드를 호출해 테스트 모듈을 컴파일한다.


    controller = module.get<AuthController>(AuthController);
    authService = module.get<AuthService>(AuthService);
  });

module.get 메서드를 이용해 AuthController와 AuthService 인스턴스를 재할당한다. 해당 인스턴스들은 각 테스트 케이스에서 사용된다.


  describe('createUser', () => {
    it('should create a user and return the created user', async () => {

createUser 메서드에 대한 새로운 테스트 그룹을 추가해준다. it 함수의 첫 번째 인자는 테스트 케이스의 설명이고, 두 번째 인자는 테스트를 수행하는 함수다.

      const joinData: JoinDataDto = {
        username: 'test',
        password: '1234',
        passwordConfirm: '1234',
        email: 'test@test.com',
      };
      const createdUser: CreatedUserDto = {
        username: 'test',
        password: '1234',
        email: 'test@test.com',
      };

테스트에 필요한 mock 데이터를 생성해준다


      jest.spyOn(authService, 'isValidPassword').mockReturnValue(true);
      jest.spyOn(authService, 'createUser').mockResolvedValue(createdUser);

jest.spyOn 함수를 이용해 authService 메서드를 mock 한다. mockReturnValue와 mockResolvedValue 메서드를 이용해 해당 메서드가 호출될 대 반환할 값을 지정해준다.


      const result: CreatedUserDto = await controller.createUser(joinData);

createUser 메서드를 호출하고 그 결과를 저장해준다.


      expect(result).toEqual(createdUser);
      expect(authService.isValidPassword).toHaveBeenCalledWith(joinData);
      const { passwordConfirm, ...expectedCreateUserDto } = joinData;
      expect(authService.createUser).toHaveBeenCalledWith(expectedCreateUserDto);
    });

expect 함수의 toEqual, toHaveBeenCalledWith 을 이용해 기대한 인자로 호출되었는지 확인한다.
이런식으로 유닛들의 테스트 케이스를 작성해 나가면 된다.


테스트 케이스 전체 코드

import { Test, TestingModule } from '@nestjs/testing';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JoinDataDto } from './dto/joinData.dto';
import { CreatedUserDto } from './dto/createdUser.dto';
import { HttpException } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from '../../entities/User';
import { LocalStrategy } from '../../passport/local.strategy';
import { Post } from '../../entities/Post';
import { typeOrmConfig } from '../../../typeorm.config';

describe('AuthController', () => {
  let controller: AuthController;
  let authService: AuthService;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      imports: [
        JwtModule.register({
          secret: process.env.JWT_SECRET,
          signOptions: { expiresIn: '24h' },
        }),
        TypeOrmModule.forRoot(typeOrmConfig),
        TypeOrmModule.forFeature([User, Post]),
      ],
      controllers: [AuthController],
      providers: [AuthService, LocalStrategy],
    }).compile();

    controller = module.get<AuthController>(AuthController);
    authService = module.get<AuthService>(AuthService);
  });

  describe('createUser', () => {
    // 회원가입
    it('should create a user and return the created user', async () => {
      const joinData: JoinDataDto = {
        username: 'test',
        password: '1234',
        passwordConfirm: '1234',
        email: 'test@test.com',
      };
      const createdUser: CreatedUserDto = {
        username: 'test',
        password: '1234',
        email: 'test@test.com',
      };

      jest.spyOn(authService, 'isValidPassword').mockReturnValue(true);
      jest.spyOn(authService, 'createUser').mockResolvedValue(createdUser);

      const result: CreatedUserDto = await controller.createUser(joinData);

      expect(result).toEqual(createdUser);
      expect(authService.isValidPassword).toHaveBeenCalledWith(joinData);
      const { passwordConfirm, ...expectedCreateUserDto } = joinData;
      expect(authService.createUser).toHaveBeenCalledWith(expectedCreateUserDto);
    });

    // 로그인
    it('should throw HttpException when passwords do not match', async () => {
      const joinData: JoinDataDto = {
        username: 'test',
        password: '1234',
        passwordConfirm: '1234',
        email: 'test@gmail.com',
      };

      jest.spyOn(authService, 'isValidPassword').mockReturnValue(false);

      await expect(controller.createUser(joinData)).rejects.toThrow(
        new HttpException('Passwords do not match', 400),
      );
      expect(authService.isValidPassword).toHaveBeenCalledWith(joinData);
    });
  });
});

짚고넘어가기

잠깐 헷갈렸던 부분으로 result 변수에 할당된 값이 실제 데이터베이스에 사용자 정보를 저장하고 반환된 값인 줄 알았다.
애초에 테스트 모듈에 연결한 데이터베이스를 별도로 만들지 않고 귀찮아서...어차피 혼자하는데... 기존에 사용하던 DB를 연결해놨었고 jest.spyOn을 이용한 moking은 authService 단에서만 실행된 거로 이해했었다. 그럼, 테스트가 종료되고 나면 DB에 해당 데이터가 남아있어야 하는 게 없어서뭔가 했다.

테스트 코드에서 AuthService의 createUser 함수를 mocking 하게 되면, AuthController의 createUser 함수가 호출될 때 실제 AuthService의 createUser 함수가 호출되는 대신 mock 된 함수가 호출되게 된다.
즉 테스트환경에선 jest.spyOn을 사용해 특정 함수를 mocking 하게 되면 해당 함수를 호출하는 모든 경로를 자동으로 mock 된 함수로 변경하게 된다.

또 한 가지 재밌는 건 spyOn의 메서드인 mockReturnValue 와 mockResolvedValue 다.
예를 들어, authService.isValidPassword() 를 mocking 하여 항상 true를 반환하도록 설정했다. 이는 사용자가 올바른 비밀번호를 입력했을 때의 시나리오를 테스트하고자 하는 의도로 작성했는데, 실제로 이 메서드가 어떻게 구현되어 있고 어떤 값이 입력되었는지에 상관없이 항상 true를 반환하므로 이 함수를 호출하는 코드가 올바르게 동작하는지를 테스트할 수 있게 된다. 즉, AuthController를 테스트하기 위해서 AuthService에 의존할 필요가 사라진다.

profile
BackEnd Developer

0개의 댓글