원티드 백엔드 프리코스는 TDD(테스트 주도 개발)이 개발 필수사항입니다.
1주차 두번째 프레시코드 과제를 진행하면서 유닛 테스트 부분을 맡게 되었습니다.
TDD란 무엇인지 원리보다는 실질적으로 어떻게 접근해서 사용 할 수 있는지를 중점적으로 다룹니다.

시나리오

  • 유저를 저장하기 위해 정보를 받는다.
  • 새로운 API를 만들어야 한다.
  • 개발자 본인외에 외부에서 API를 사용 한다.

Controller Unit Test

테스트 의도

  • 올바른 입력

API SPEC

{
   "id": 1
   "email": "wanted",
   "password": "wecode"
   "roles": "admin"
}
nest g module users
nest g controller users
npm test

위에서 추가 한 controller를 포함하여 두 파일에 관한 테스트가 성공적으로 통과 하는 것을 볼 수 있다.

테스트 진행방식

컨트롤러 -> 서비스 -> 레포지토리 순으로 하향식으로 테스트를 진행 할 예정입니다.
왜냐하면 API SPEC이 명확하기 때문에 하향식으로 진행하는게 좀 더 이해하기 쉬울 수 있습니다.
또한 항상 TDD하면 나오는 그림을 따라 실패 -> 통과 -> 리팩토링 순으로 진행하도록 하겠습니다.

@Controller('users')
export class UsersController {}


//test
it('should call the service', () => {
    controller.create();
    
});

fail

컨트롤러의 create기능이 없을 뿐만 아니라 서비스가 계층이 존재하지 않음을 알 수 있습니다.
테스트를 작성하는 과정에서 IDE가 경고를 통해 무엇이 부족한지 알려주지만 그래도 테스트를 진행합니다.
이 과정은 무엇이 부족한지 직관적으로 알 수 있을 정도로 단순하지만 이 과정을 통해 무엇을 만들어야 하는지 인식 할 수 있는 과정입니다.

이제 테스트를 통과 하도록 컨트롤러의 기능과 서비스 계층을 추가합니다.

nest g s users

pass?

// controller
@Controller('users')
export class UsersController {
  create(createUserDto: any) {}
}
// service
@Injectable()
export class UsersService {
  create(createUserDto: any) {
    throw new Error('호출 될까요?');
  }
}
// test
it('should call the service', () => {
    const createUserDto = {};
    controller.create(createUserDto);
    expect(service.create).toHaveBeenCalled();
});

위의 테스트 코드를 작성하는 과정에서 IDE가 서비스 메서드 호출에 경고를 통해 알려주고 있습니다.
그리고 당연히 테스트는 서비스 계층 호출에서 서비스를 찾지 못해서 실패하게 됩니다.

service 계층을 생성자 주입을 통해 의존성을 해결 해줘야 하는 것을 알았습니다.
이제 아래와 같이 수정 하도록 합니다.

// controller
@Controller('users')
export class UsersController {
  create(createUserDto: any) {
    this.usersService.create(createUserDto);
  }
}
// service
@Injectable()
export class UsersService {
  create(createUserDto: any) {
    return 'pass?';
  }
}
// test
it('should call the service', () => {
    const createUserDto = {};
    controller.create(createUserDto);
    expect(service.create).toHaveBeenCalled();
});

이제 서비스 계층에 관해 의존성도 설정해주었고 함수 호출에도 IDE상에서 빨간줄이 보이지만!!
당연히 이제 테스트는 통과 할거라고 믿어 의심치 않습니다!

하지만?

보기 좋게 테스트는 또 실패하고 말았습니다. 테스트 진행 할때 전달 받은 객체는 mock 또는 spy function 이어야 한다고합니다.

대부분 여기서 현타?가 오기 시작합니다. 간단한 CRUD의 경우 위와 같은 간단한 복잡한 절차를 거치지 않더라도 직관적으로 CRUD를 수행 할 수 있기 때문이죠. 복잡하게 테스트를 만들고 있느니 그냥 만들어서 처리하는게 휠씬 효율적이지 않을까 고민하게 됩니다. 맞습니다. 단순 CRUD의 경우 테스트를 진행하지 않아도 잘 될 수 있지만 우리가 취업하는 환경에서는 복잡한 관계나 많은 예외 사항을 다루게 될 때를 위해 힘을 비축해야 합니다!

우리는 두가지의 방법으로 Mock Service를 만들어서 컨트롤러 -> 서비스의 메소드 호출을 확인 할 수 있습니다.
방법마다 모듈에 providers로 제공하는 방식의 차이가 있는 것을 확인하셔야 합니다.

// jest 함수를 사용하여 만들 때
jest.mock('./users.service');

const module: TestingModule = await Test.createTestingModule({
      controllers: [UsersController],
      providers: [UsersService],
    }).compile();

// MockService를 arrow func로 만들었을 때
const mockService = () => ({
  create: jest.fn(),
});

const module: TestingModule = await Test.createTestingModule({
      controllers: [UsersController],
      providers: [
        {
          provide: UsersService,
          useValue: mockService(),
        },
      ],
    }).compile();

그렇다면 위의 방법은 대체 무슨 차이가 있는걸까요?

jest.mock()

  • 하나하나 직접 모듈 모킹을 좀 더 편하게 할 수 있도록 자동으로 모듈을 모킹을 해주기 위해 jest.mock()이라는 강력한 함수를 제공합니다.
  • 예를 들어 두번째 방법에 직접 create를 jest.fn()으로 모킹했지만, 함수의 개수가 늘어난다면 실수가 발생 할 수 있습니다.
  • jest.mock() 함수는 첫 번째 인자로 넘어온 모듈 내의 모든 함수를 자동으로 목(mock) 함수로 변경합니다.

jest.fn()

  • Jset는 가짜 함수(mock functiton)를 생성 하는 함수입니다.
  • jest.fn()으로 모킹을 진행 할 수 있지만 모듈의 메서드 수정 및 사람의 실수가 발생 하기 쉬워요.

pass!

이제 jest.mock를 사용해서 서비스 모듈에 모킹하도록 합니다. service계층에 함수가 추가되더라도 테스트를 유연하게 대처할 수 있게 되었습니다.

우리는 이제 컨트롤러 -> 서비스 계층 간의 호출이 제대로 작동하는것을 확인했습니다.
우리는 이제 본격적으로 서비스계층 즉 비지니스 로직을 개발 할 준비가 되었습니다. 그렇다면 이제 본격적으로 개발 하면 될까요?
하지만 방금전에 언급한 것처럼 비지니스로직은 서비스 계층의 책임입니다. 그렇기 무엇을 테스트할까 여기서 끝?

Refactor!

컨트롤러의 또 다른 역할 중 하나인 데이터 검증을 넣어 리팩토링 하도록 하겠습니다.
NestJS에서는 벨리데이션 파이프라인을 지원하기 때문에 라우터 핸들러가 실행 되기 이전에 요청 값을 검증 할 수 있습니다.
우리는 어떤 값이 들어올지 알고 있고 이것에 맞춰 벨리데이션을 진행하면 서비스의 오류를 줄일 수 있습니다.

// dto
export class CreateUserDto {
  email: string;
  password: string;
}
// controller
@Controller('users')
export class UsersController {
  constructor(private readonly usersService: UsersService) {}
  create(createUserDto: CreateUserDto) {
    this.usersService.create(createUserDto);
  }
}
// service
@Injectable()
export class UsersService {
  create(createUserDto: CreateUserDto) {
    return 'pass?';
  }
}
// test
it('should call the service', () => {
    const createUserDto = {
      email: 'wanted',
      password: 'wecode',
    } as CreateUserDto;
    controller.create(createUserDto);
    expect(service.create).toHaveBeenCalled();
  });

completed refactor?

위와 같이 DTO를 추가하고 테스트를 실행하니 문제 없이 테스트를 통과합니다. 올바른 데이터 타입을 설정했고 통과 했으니 끝일까요?
아닙니다. 우리는 DTO를 설정해서 데이터 타입을 정의 했을 뿐 데이터의 유효성 검사를 하지 않았습니다.
그럼 이제 데이터 유효성을 검사하는 테스트 코드를 추가하도록 합시다.

데이터 유효 검사를 위한 리팩토링 시작

테스트 환경에서는 TypeORM, MySQL를 사용하고 있지만 자유롭게 사용하셔두 됩니다.
테스트 환경과 같은 환경을 사용하고자 한다면 NestJS 공식가이드 문서를 따라 진행해주시면 됩니다.

이제 DB가 정상 작동하시면 다시 테스트를 고고싱하면됩니다.

이메일의 글자수가 6글자 이하라면 유효한 값이라고 가정하는 validate 함수를 만들었습니다.

// userEntity
public static validate(email: string): boolean {
    const isValid = email && email.length <= 6;
    if (!isValid) {
      throw new Error('Invalid email');
    }
    return true;
  }
// test (-----------> user.entity.spec.ts <----------)
describe('user validate', () => {
  it('should return back a Error', () => {
    const email = 'wantedAndwecode';
    const spaceShipId = () => Users.validate(email);

    expect(spaceShipId).toThrow(Error);
  });

  it('should return back a valid email', () => {
    const email = 'wanted';

    expect(Users.validate(email)).toBeTruthy();
  });
});

우와! 이제 벨리데이션 테스트까지 통과화게 되었습니다. 이제 컨트롤러 테스트를 끝내기까지 얼마 남지 않았습니다.

그럼 이제 벨리데이션을 컨트롤러에 붙여볼까요?

@Controller('users')
export class UsersController {
  constructor(private readonly usersService: UsersService) {}
  create(createUserDto: CreateUserDto) {
    Users.validate(createUserDto.email);
    this.usersService.create(createUserDto);
  }
}

아마도 6글자의 제한이 유효한 검사의 전부라면 위의 벨리데이션 정상적으로 작동하여 유효한 값을 받을 수 있습니다.
하지만 우리는 단일 책임 원칙을 위반했습니다. 라우터 핸들러의 역할은 말그대로 라우터 역할의 책임을 갖고 있기 때문입니다.
다행스럽게도 우리에게는 NestJS가 제공하는 Validation Pipe Line이 있습니다. DTO의 데이터 검증 책임을 파이프에 위임해야합니다.
공식 문서에 나와있는 Schema validation를 진행 할것입니다.
문서에 따라 joi 라이브러리를 설치해주시면 됩니다.

필수

tsconfig.json에 추가해주지 않으면 작동하지 않습니다.

"esModuleInterop": true

그럼 파이프 구현을 시작해봅시다.

// PipeTransform
@Injectable()
export class SpaceShipSaveRequestToSpaceShip implements PipeTransform {
  transform(value: any, metadata: ArgumentMetadata) {
    return value;
  }
}
// test
describe('CreateUserDtoToUserPipe', () => {
  let transformer;
  beforeEach(() => {
    transformer = new CreateUserDtoToUserPipe();
  });
  it('should be defined', () => {
    expect(new CreateUserDtoToUserPipe()).toBeDefined();
  });

  it('should throw error if no body', () => {
    const response = () => transformer.transform({}, {});
    expect(response).toThrow(BadRequestException);
  });

이렇게 베드리퀘스트 ERROR 테스트에서 실패했습니다.

베드리퀘스트 ERROR를 포함한 벨리데이션을 추가하도록 합니다.

@Injectable()
export class CreateUserDtoToUserPipe
  implements PipeTransform<CreateUserDto, Users>
{
  transform(value: CreateUserDto, metadata: ArgumentMetadata): Users {
    const schema = Joi.object({
      email: Joi.string().min(3).max(12).required(),
      password: Joi.string().max(20).required(),
    });

    const { error } = schema.validate(value);

    if (error) {
      throw new BadRequestException('Validation failed');
    }

    Users.validate(value.email);

    const user = {
      email: value.email,
      password: value.password,
    } as Users;
    return user;
  }
}

마지막으로 아래와 같이 Pipe를 사용하여 값을 DTO -> ENTITY 변경 후 값이 같은지 비교합니다.

describe('CreateUserDtoToUserPipe', () => {
  let transformer;
  beforeEach(() => {
    transformer = new CreateUserDtoToUserPipe();
  });
  it('should be defined', () => {
    expect(new CreateUserDtoToUserPipe()).toBeDefined();
  });

  it('should throw error if no body', () => {
    const response = () => transformer.transform({}, {});
    expect(response).toThrow(BadRequestException);
  });

  it('should convert to valid User', () => {
    const createUserDto: CreateUserDto = {
      email: 'wanted',
      password: 'wecode',
    };

    const user = {
      email: 'wanted',
      password: 'wecode',
    } as Users;

    // eslint-disable-next-line @typescript-eslint/ban-ts-comment
    // @ts-ignore
    const parsedUser = transformer.transform(createUserDto, {});
    expect(parsedUser).toEqual(user);
  });
});

completed refactor!!

드디어 컨트롤러에 관한 테스트를 끝냈습니다. 물론 Joi를 사용하기보다 class-Validation를 좀 더 세세하게 유효성을 검증 할 수 없다고 하지만
사용 편의성을 보았을 때 NestJS 벨리데이션 추천순으로 하는게 좋을것 같기도 하다.

다음엔 Service 계층의 테스트로 돌아오겠습니다.

profile
제빵사에서 개발자되기

0개의 댓글

관련 채용 정보

Powered by GraphCDN, the GraphQL CDN