[NestJS] 회원가입 API의 TestCode를 작성하자(with.Jest)

·2023년 1월 30일
1

Jest_TestCode

목록 보기
2/2

🧑 회원가입API 로직


내가 구현한 회원가입 로직은 다음과 같다. Rest API 방식으로 작성했으며 NestJS를 사용했다.

# user.controller.ts

@Controller('/users')
export class UserController {

  constructor(
      private readonly userService: UserService, //

      @InjectRedis('access_token')
      private readonly access_token_pool: Redis,
  ) {}

	/** 회원가입 */
	@ApiOperation({
    	summary: '회원가입하기',
    })
    @ApiBody({
    	type: CreateUserInput, //
    })
    @Post('/createUser')
    async createUser(
    	@Body() input: CreateUserInput, //
        @Res() res: Response,
   ) {
        await this.userService.checkInfo(input.id, input.nickName);
        await this.userService.checkValidatePwd(input.password);

        const result = await this.userService.createUser(input);
        const { deletedAt, createdAt, updatedAt, password, ...output } = result;

        return res.status(HttpStatus.CREATED).json(output);
 	}
}

그러면 이 controller에 대한 TestCode를 Jest로 작성해보자.

🧹 Jest로 작성하는 controller.testcode


우선 NestJS를 사용하게되면 기본적으로 지원이되는 테스팅 라이브러리이다 보니 별도의 설치(NestJS를 만들면서 알아서 설치됨)할 필요가 없다.

우선 Jest 라이브러리로 TestCode를 작성하기 전에 TestCode를 어떻게 인식하는지 보자면 파일이름으로 인식된다.
파일 이름이 *.spec.ts 이런식으로 .spec 이 들어가면 해당 파일을 test파일로 읽어들여 테스트를 진행해준다.

❓ 작성방법


우선 기본적인 구조와 알아둬야할 문법은 다음과 같다.

describe ('부모', () => {
	beforeEach(() => {
    
    })
	it('자식', () => {
    	
    })
})

⬆️ 구조는 보통 위와 같은데 보통은 '부모' 이 자리에 Test할 이름을 쓴다. Class명과 같은 unit을 묶을만한 이름 말이다. 그리고 화살표 함수를 실행하고 그 안에 테스트할 함수 혹은 작은 단위들 쓴다.

그러면 각각의 문장은 뭘 뜻하는가?

  • describe ('', () => {}): 여러개의 테스트 모아놓은 그룹 단위. 최상위 폴더? 다 감싸고 있는 느낌이라고 생각해도 좋다.
  • it ('', () => {}) : 최상위 폴더 안에 있는 작은 파일들. 테스트의 단위를 말한다.
  • beforeEach (() => {}) : Testing 이전에 실행되는 부분. 보통 테스트를 하기전에 초기화 와 같은 작업들이 필요할 경우 실행하게 된다. 이 친구는 ''로 이름 같은것을 쓰지 않는다.

가벼운 예제로는 이렇다.

# 계산.spec.ts

describe('Calculation', () => {
  it('plus 테스트', () => {
    const a = 1;
    const b = 2;

    expect(a + b).toBe(3);
  });

  it('multiply 테스트', () => {
    const a = 1;
    const b = 2;

    expect(a * b).toBe(2);
  });
});

⬆️ Calculation 는 폴더 이름이라고 생각하면 된다. 이 describe안에 Calculation과 관련이 있는 함수를 테스트 할 것이다.

describe('Calculation', () => {
	...
})

이제 이 폴더(그룹)은 Calculation 와 관련있는 함수를 따로따로 테스트 할 것이다.

it('plus 테스트', () => {
    const a = 1;
    const b = 2;
    
    계

    expect(a + b).toBe(3);
  });

⬆️ Calculation 안에는 plus라는 것이 있다고 생각해보자. 이걸 테스트 하기 위해선 다음과 같다.
우선 최상위 폴더 안에 있는 작은 파일들은 어디에 쓴다고 했는가? it이다. 그렇기에 it의 이름으로 plus 함수의 이름을 사용했다.⬆️

const a = 1;
const b = 2;

그리고 그 안에는 위와 같이 해당 함수에서 사용되는 매개변수인 a, b의 값을 각각 할당한다. 그럼 그 다음줄은 뭘까?

expect(a + b).toBe(3);

⬆️ expect의 사전적 의미는 예상하다 다. toBe는 될것이다 라는 의미다.
그러면 생각해보라. 갑분 영어시간
(a+b)는 예상합니다. (3)이 될거라고. 이게 무슨뜻인가!?

테스트 코드는 동작을 확인하기 위해서 해당 함수를 실행해본다. 즉 우리가 인자값도 정의를 했으니 a+b가 3일 경우 정상적으로 테스트가 통과되고 3이 되지 않을 경우 테스트가 실패로 끝난다.



그럼 이제 실제 코드를 살펴보자!

💻 TestCode 예제1


import { getRedisToken, RedisModule } from '@liaoliaots/nestjs-redis';
import { createRequest, createResponse } from 'node-mocks-http';
import { Test, TestingModule } from '@nestjs/testing';
import { ConflictException } from '@nestjs/common';

import { UserService } from '../user.service';
import { UserController } from '../user.controller';

/** UserService Mocking */
const mockUserService = {
  checkInfo: jest.fn(),
  checkValidatePwd: jest.fn(),
  createUser: jest.fn(),
};

/** Redis Mocking*/
const mockAccessTokenPool = {
  get: jest.fn(),
};

/** DTO - createUserInput */
interface IUser {
  id: string;
  password: string;
  nickName?: string;
}

const createUserDto: IUser = {
  id: 'user123',
  password: '12345!@fd',
  nickName: '홍길동',
};

describe('UserController', () => {
  let userController;
  let userService;

  beforeEach(async () => {
    jest.clearAllMocks();
    jest.resetAllMocks();
    jest.restoreAllMocks();

    const module: TestingModule = await Test.createTestingModule({
      controllers: [UserController],
      providers: [
        {
          provide: UserService,
          useValue: mockUserService,
        },
        {
          provide: getRedisToken('access_token'),
          useValue: mockAccessTokenPool,
        },
      ],
    }).compile();

    userController = module.get<UserController>(UserController);
    userService = module.get<UserService>(UserService);
  });

  afterAll(async () => {
    jest.clearAllMocks();
    jest.resetAllMocks();
    jest.restoreAllMocks();
  });

  it('to be defined', async () => {
    expect(userController).toBeDefined();
    expect(userService).toBeDefined();
  });

  describe('createUser', () => {
    it('If user does not exist', async () => {
      let res = createResponse();

      mockUserService.checkInfo.mockResolvedValue(false);
      mockUserService.checkValidatePwd.mockResolvedValue(true);
      mockUserService.createUser.mockResolvedValue(false);

      try {
        await userController.createUser(createUserDto, res);
      } catch (e) {
        expect(mockUserService.checkInfo).toBeCalledTimes(1);
        expect(mockUserService.checkInfo).toBeCalledWith(
          createUserDto.id,
          createUserDto?.nickName,
        );

        expect(mockUserService.checkValidatePwd).toBeCalledTimes(1);
        expect(mockUserService.checkValidatePwd).toBeCalledWith(
          createUserDto.password,
        );

        expect(mockUserService.createUser).toBeCalledTimes(1);
        expect(mockUserService.createUser).toBeCalledWith(false);
      }
    });
  });
});

⬆️ 잊었을 수 있지만 userController 를 테스트 하는 테스트 코드이며 userController는 최상단에 코드가 있다. 하나씩 파헤쳐보자.

1. Service 목킹

/** UserService Mocking */
const mockUserService = {
  checkInfo: jest.fn(),
  checkValidatePwd: jest.fn(),
  createUser: jest.fn(),
};

⬆️ 제일 먼저 한 일은 Service Mocking이다. userService라는 클래스에 checkInfo(), checkValidatePwd(), createUser() 라는 함수가 있다. 이때 각각의 함수는 인자를 받는것부터 내부 로직이 복잡하게 돌아가는데, 이걸 그냥 퉁치는 것이 jest.fn() 이다. Jest에서 지원하는 Mocking 함수로,
간단하게 생각하자면 ~이런저런 함수로직을 실행한다~ 라는 역할이다.

2. Redis 목킹

/** Redis Mocking*/
const mockAccessTokenPool = {
  get: jest.fn(),
};

⬆️ 나 같은 경우에 Redis에 유저 token값을 저장하고 확인하기 위해 쓰는데,
이때 get, set 등 사용한 메소드를 1번과 같이 Mocking 해주면 된다.

🤔 그럼 여기서 질문!

어떤걸 Mocking 해야하는 것인가? 인데, 이는 의외로 간단하게 해답을 찾을 수 있다.
바로 userController에서 constructor(){}에 주입한 부분을 전부 Mocking해야 하는 것이다!

export class UserController {

  constructor(
      private readonly userService: UserService, //

      @InjectRedis('access_token')
      private readonly access_token_pool: Redis,
  ) {}
  
}

⬆️ 위에 처럼 constructor에 기재한 userServiceredis를 우리가 방금 Mocking 한 것이다. 이러면 일단 기본적인 Mocking은 끝났다고 봐도 무방하다.

번외로 constructor에 주입하고 실질적으로 사용되지 않는 값이 있는 경우에는 과감히 해당 값을 지우던가, Mocking을 해주던가 해야한다.

constructor에 있는데 Mocking이 없으면 안된다.

3. 인자로 쓸 가짜값 정의

/** DTO - createUserInput */
interface IUser {
  id: string;
  password: string;
  nickName?: string;
}

const createUserDto: IUser = {
  id: 'user123',
  password: '12345!@fd',
  nickName: '홍길동',
};

⬆️ 회원가입 로직에 필요한 인자값을 미리 만들어둔다. 사실 이 부분은 언제 만들어도 상관없지만 나의 경우엔 상단에 배치했다. 전역으로 쓸 생각으로.
위의 값이 테스트할 함수에 들어왔을때 어떤 것이 예상된다~ 를 위해서 사용한다.

4. userController, userService 정의

describe('UserController', () => {
  let userController;
  let userService;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      controllers: [UserController],
      providers: [
        {
          provide: UserService,
          useValue: mockUserService,
        },    
        {
          provide: getRedisToken('access_token'),
          useValue: mockAccessTokenPool,
        },
      ],
    }).compile();

    userController = module.get<UserController>(UserController);
    userService = module.get<UserService>(UserService);
  });
}

⬆️ 본격적으로 testCode를 작성하기에 앞서서 controllerservice를 module에 주입했던 것 처럼 똑같이 testingModule 이라는 곳에 주입해준다.
여기서 재밌는 점은 controller는 평범하게 주입해주면 되지만, service의 경우는 provider에 주입과 동시에 useValue 라는 것을 사용해 1번에서 만들어둔 mockingUserService 데이터를 사용하게 된다. 이는 해당 service의 구현을 모의 객체로 대체하기 위해서 사용하는 부분이다.
redis 역시 똑같은 방식으로 주입해준 후에 module을 컴파일 하면 된다.

userController = module.get<UserController>(UserController);
userService = module.get<UserService>(UserService);

컴파일 된 후 beforeEach 이전에 선언된 변수에 module의 controller와 service를 가져오면 이제 테스트 준비가 끝난다!

5. 테스트 코드 실행 전에 이뤄져야 할 것들

beforeEach(async () => {
	jest.clearAllMocks();
    jest.resetAllMocks();
    jest.restoreAllMocks();
})

⬆️ beforeEach 부분에는 module 주입뿐만 아니라 위와 같은 부분도 있는데 이는 무엇을 뜻할까?
이는 각각의 test가 진행되기 전에 무조건 꼭 거쳐가야 하는 부분으로, 테스트를 진행하기 전에 필수로 해야할 것들을 정의해 놓는다.
주로 테스트가 이루어지기 전에 한번 초기화가 필요하다던가, 임시로 저장되어있는 데이터들을 초기화 시킨다 던가 등등 이런식으로 다음 테스트가 이뤄지기 전에 미리 비워두거나 정리해야할 경우에 사용하여 청소를 시켜준다고 생각하면 좋다.

6. controller와 service가 제대로 정의되었는지 확인해보자.

it('to be defined', async () => {
    expect(userController).toBeDefined();
    expect(userService).toBeDefined();
});

⬆️ 간단한 테스트로 우리가 테스트 전에 설정한 service와 controller가 제대로 정의되었는지 확인해 볼 수 있다.
it이라는 단위로 toBeDefined Mather를 사용하여 userController가 제대로 정의 되었는가를 테스트 하는 코드이다.
이때 테스트가 통과되지 않으면 controller와 service에 정의가 제대로 되어있지 않은 것이고, 여기서 삐걱되면 뒤에 테스트를 진행할 수 없으니 내가 생각하기에는 필수 관문이라고 생각된다.

7. createUser의 테스트 코드를 작성해보자.

describe('createUser', () => {
    it('If user does not exist', async () => {
      let res = createResponse();

      mockUserService.checkInfo.mockResolvedValue(false);
      mockUserService.checkValidatePwd.mockResolvedValue(true);
      mockUserService.createUser.mockResolvedValue(false);

      try {
        await userController.createUser(createUserDto, res);
      } catch (e) {
        expect(mockUserService.checkInfo).toBeCalledTimes(1);
        expect(mockUserService.checkInfo).toBeCalledWith(
          createUserDto.id,
          createUserDto?.nickName,
        );

        expect(mockUserService.checkValidatePwd).toBeCalledTimes(1);
        expect(mockUserService.checkValidatePwd).toBeCalledWith(
          createUserDto.password,
        );

        expect(mockUserService.createUser).toBeCalledTimes(1);
        expect(mockUserService.createUser).toBeCalledWith(false);
      }
    });
  });

⬆️ describe에 어떤걸 테스트 할 지 그룹핑(?) 해준다. createUser를 할 예정이니 해당 부분을 명시해주고, 그 안에 it을 통해 유닛 테스트를 구성해본다.

let res = createResponse();

⬆️ 이 부분은 controller에서 인자로 받는 express의 Response를 Mocking하는 부분이다.

mockUserService.checkInfo.mockResolvedValue(false);

⬆️ 이 부분은 mockUserService의 checkInfo라는 함수가 실행되서 어떤 값을 반환했을때, 그 값은 false이다 라고 명시하는 부분이다.
이 부분이 Mocking을 가정하는 부분이다. jest.fn()은 대충 함수가 실행된다. 라는 느낌이라 실질적으로 로직이 복잡하게 돌아가는 부분을 처리해주지 않는다. 그렇기에 그러한 복잡한 함수가 실행되고 최종적으로 어떤 값을 반환하게 될거다 라는 가정을 명시해줌으로써 테스트를 진행할 수 있다.
그 밑에 줄도 동일한 방식이다.

await userController.createUser(createUserDto, res);

⬆️ 결과값을 가정했으면 실제로 createUser를 실행시켜야 하는데, 이때 실행할때 쓰기 위해 전역으로 createUserDto를 미리 만들어두었고, res는 위에서 express의 Response를 Mocking해 두어서 문제없이 작동이 될 것이다.

expect(mockUserService.checkInfo).toBeCalledTimes(1);
expect(mockUserService.checkInfo).toBeCalledWith(
	createUserDto.id,
    createUserDto?.nickName,
);

⬆️ 이 부분은 다음과 같다.

  • toBeCalledTimes()는 checkInfo가 몇번 호출이 되었는가를 테스트 하는 부분이고,
  • toBeCalledWith()는 어떤 인자를 가지고 호출이 되었는가를 테스트 하는 부분이다.

제일 상단의 controller를 보면 checkInfo는 createUser 내에서 1번만 사용되기에 1번 호출이 될 것이고, 이때 호출될 때 createUserDto와 Express의 Response를 인자로 받아서 사용되기에 그 부분을 같이 명시해 준 것이다.
호출된 인자가 다르거나, 호출 횟수가 다르면 당연하게도 테스트에 통과되지 못한다.

그리고 여기서 보면 try {} catch(e) {}를 사용하였는데 이는 왜 사용했을까?
바로 createUser를 실행시키면 에러가 발생하기 때문에, catch 내부에서 테스트 코드를 작동시켜야 정상적으로 수행이 가능하다. 안 그러면 실행중 에러가 발생하기에 거기서 그냥 에러인 채로 테스트 코드가 끝나게 된다.

그러면 멀쩡한 코드에서 왜 에러가 날까?
바로 아래의 코드 때문에 그렇다.

mockUserService.checkInfo.mockResolvedValue(false);

⬆️ 위에서 말했듯 결과값으로 어떤 값을 받겠다라고 명시하는 부분이다. 이 부분에 결과값을 false로 받겠다고 했다.
checkInfo 내부에서 false일 때 에러를 받게끔 처리를 해두었는데 그렇기 때문에 에러가 발생하는 것이다. 그래서 try {} catch(e) {} 에서 사용하게 되는 것이다!

다른 함수들도 똑같이 작동하고, 이런식으로 원하는 테스트 검증을 마치고 모든 테스트가 통과하면 테스트가 종료되었다고 볼 수 있다.


아직은 미흡한 테스트 코드이지만 조금씩 더 모든 케이스에 통과될 수 있는 능동적인 코드를 짜도록 해야겠다.



참고 자료 및 공식문서
https://docs.nestjs.com/fundamentals/custom-providers
https://docs.nestjs.com/fundamentals/testing#unit-testing

profile
뉴비는 문서화를 습관화 해보자

0개의 댓글