[Clone Coding] What I Learn 6 : Unit Test With Jest

먹보·2023년 2월 9일
1

MUK_BO's Clone Coding

목록 보기
7/7

이전 게시물을 마지막으로 USER CRUD의 제작이 완료되었고 이제 제대로 작동이 되는지 자동 테스트를 돌려볼 시간이 되었다.

테스트에 대한 배경 지식은 여기에서 확인 해 볼 수 있습니다

우선 Unit Test를 시작으로 하나 하나 씩 해볼 예정이고, 오늘 이 게시글에서는 Unit Test 모듈을 만들면서 새롭게 알게된 지식을 공유해보려고 한다.

✍ <기술 스택>

  • Programming Language
    TypeScript
  • Framework
    NestJS
  • API 설계
    GraphQL and Apollo
  • Database & Relevant
    PostgresQL & TypeORM

✍ Unit Test Tool for JavaScript

사실 Unit Test에 대한 진입 장벽이 높아 제대로 진행해 본 적이 없다.

계산기의 덧셈 뺄셈 곱셈 그리고 나눗셈 정도의 함수들만 만들어서 Jest를 설치해 해본 적은 있어도 내가 만든 API에 대한 Unit Test에 대한 경험은 1~2번이 전부이다.

그만큼 부담이 되었고 난이도가 높아 쉽지 않았다.

우선 Unit Test Tool이다.

JavaScript 하면 가장 먼저 떠올려지고 대표적인 Unit Test Framework는 바로 Jest이다

Jest는 자바스크립트 환경에서 작동하는 오픈소스 테스트 프레임워크이며 현재 Facebook(meta), Airbnb, Twitter와 같은 대기업에서 가장 많이 쓰는 테스트 프레임워크이며 프론트와 백엔드에서 쓰일 수 있다.

📝 특징

  1. 설정과 사용이 쉽다 : 이따가 코드를 통해서 몇 가지 문법들을 접해 볼 예정이지만, Jest는 마치 우리가 영어 문장을 만들 듯 테스트 코드를 작성 할 수 있다.

  2. 다양하고 직관적인 빌트 인 메서드

  3. 자동 Mocking : Mocking이 쉬워 테스트 하려는 코드의 독립성을 강화한다.

  4. 병렬적인 테스트 실행 : 테스트를 병렬적으로 실행하여 속도가 빠르다.

  5. 커버리지 리포트 제공

  6. Watcher Mode 제공

  7. 리액트, 타입스크립트, 그리고 바벨과 같이 다양한 자바스크립트 개발 도구와 호환이 가능하다.

사실 써보지 않고는 모르는 법!!

Jest에서 제공하는 다양한 문법을 코드와 함께 알아보면서 간단하게 설명을 해보려고 한다.

✍ Jest 사용법

셋업부터 이번 User CRUD에서 사용된 문법까지 전부 하나 하나 알아볼 예정이다.

USER CRUD 이외에도 다른 주제에 대한 CRUD를 만들 예정이고 Test도 진행 예정이지만 유닛 테스트에 대한 포스팅은 이게 마지막 일 것 이다.

📝 Jest Set Up

셋업에는 2~3가지 정도는 기본적으로 해 줄 수 있다.

  1. 파일 만들기
  2. 실행 스크립트 설정
  3. 커버리지 설정

✏️ 파일 만들기

Jest는 따로 설정해 주지 않는 이상 파일 명에 .spec.ts/js가 들어가 있는 파일 내에 쓰여져 있는 코드들만 인식해서 테스트를 실행하기 때문에 테스트 코드 파일 만들 시 유의해서 만들어야 한다.

한 가지 알아둬야 할 것이 있다!

NestJSTypeScript를 사용하는 프로젝트에서는 유심히 보다보면 파일들 간의 경로 설정에서 ../ 또는 ./으로 시작되지 않고 바로 폴더 디렉토리로 연결 되는 것을 볼 수 있다.

JEST는 일반 자바스크립트 환경에서 굴러가는 프레임워크다 보니 다음과 같이 스크립트를 변경 해 줄 필요가 있다.

  "moduleNameMapper": {
  "^src/(.*)":"<rootDir>/$1"
  },

위 스크립트는 .. 쉽게 말해, 점을 빼는거다. 정규표현식을 다뤄본 사람이라면 알 것이다.

✏️ Script 설정

Jest를 설치 후 파일까지 만들어 주었다면, 실행 스크립트를 package.json에 짜주는 것만으로 테스트를 간단한 명령 하나로 실행해 줄 수 있다.

실질적으로 NestJS에서는 처음 프로젝트 폴더를 생성할 시, 스크립트가 짜여져 있지만 아닐 수도 있으니 기억해 두자.

"test": "jest", // 테스트 실행
"test:watch": "jest --watch", //노드몬 처럼 코드를 실시간 반영하면서 테스트 지속적으로 실행
"test:cov": "jest --coverage", // 테스트 커버리지를 테스트 실행과 함께 보여준다.
"test:e2e": "jest --config ./test/jest-e2e.json" // E2E 테스트

✏️ 커버리지 설정

사실, 이 부분은 필요하기도 하고 불필요하기도 한데 나는 Service (Business) 로직만 테스트를 진행 할 것이기 때문에 설정해 주었다.

"collectCoverageFrom": [
"**/*.service.(t|j)s"
]

위 명령어는 서비스 로직만 실행한다는 뜻이다.

만약 테스트할 파일들이 많고 그 중 일부만 격리시키고 싶다면 다음의 스크립트를 포함해 주면 된다.

"coveragePathIgnorePatterns": [
      "node_modules",
      ".entity.ts",
      ".constants.ts"
    ]

위 스크립트는 노드 모듈.enttity 그리고 constants가 파일명에 들어간 코드들은 테스트에 포함이 안된다는 것을 뜻한다.

자 이제 정말로 JEST 문법들을 이용해 test 코드를 짜볼 것이기 때문에, 사용되는 문법들을 알아보자

📝 Jest 문법

프로젝트 환경은 NestJS와 TypeScript 이기 때문에 다소 다를 수 있으니 참고 부탁드립니다.

  • Major Jest Method
    • describe
    • it
    • beforeEach & beforeAll
    • afterEach & afterAll
    • jest.fn()
    • mockResolvedValue & mockReturnValue
    • toHaveBeenCalledTimes, toHaveBeenCalledWith

우선 여기서 다뤄볼 문법은 위에 나온 것과 같다.

먼저 모듈 설정, 사실 Nest 환경에서 하나의 모듈을 CLI를 써서 만들게 되면 자동으로 테스트 파일이 만들어지고 모듈 연결 설정도 지원 된다.

다음 코드를 보자

const module = await Test.createTestingModule({
      providers: [UsersService],
    }).compile();
    service = module.get<UsersService>(UsersService);
  });

위 코드는 만들어진 테스트 파일이 어떤 파일에 있는 코드들을 기반으로 테스트 코드가 짜여질 것인지를 설정해주는 코드이다.

=> Test.createTestingModule을 통해 보면 알겠지만 쉽게 말해 테스트 모듈을 설정하는 것으로 우리는 우선 UserService만 가져와서 스타트를 할 것이고 만약 UserService와 다른 서비스 로직이 연결이 되서 사용이 된다면 다음과 같이 계속 추가로 코드를 추가해서 사용해 줄 수 있다.

    const module = await Test.createTestingModule({
      providers: [
        UsersService,
        {
          provide: getRepositoryToken(User),
          useValue: mockRepository(),
        },
        {
          provide: getRepositoryToken(Verification),
          useValue: mockRepository(),
        },
        {
          provide: JwtService,
          useValue: mockJwtService(),
        },
        {
          provide: MailService,
          useValue: mockMailService(),
        },
      ],
    }).compile();
    service = module.get<UsersService>(UsersService);
    mailService = module.get<MailService>(MailService);
    jwtService = module.get<JwtService>(JwtService);
    usersRepository = module.get(getRepositoryToken(User));
    verificationsRepository = module.get(getRepositoryToken(Verification));

참고로, 위에서 사요용된 mockRepository, getRepositoryToken()은 이후에 다룰 예정이니 참고 바랍니다.

이제 모듈 설정이 어느정도 되었으니 지금부터 실전이다.

✏️ Describe() & it() / test()

우선 코드를 통해 보자

  describe('createAccount', () => {
    const createAccountArgs = {
      email: 'bs@email.com',
      password: 'bs.password',
      role: 0,
    };
    it('should fail if user exists', async () => {
      usersRepository.findOne.mockResolvedValue({
        id: 1,
        email: '',
      });
      // return value faking
      const result = await service.createAccount(createAccountArgs);
      expect(result).toMatchObject({
        ok: false,
        error: 'There is a user with that email already',
      });
    });
    
    it.todo('Successful Case Will Be Updated')
})

Describe와 과 it(test)는 Jest에서 테스트를 설계하는 데 있어 가장 필요한 설계 틀이라고 보면 된다.

🚩 Describe()

describe('description', callback_fn)

서로 주제가 연관성이 있는 테스트를 하나의 블록으로 묶는데 쓰인다.

위 코드에서는 createAccount 즉 유저를 생성할 때 쓰이는 함수들의 테스트 들을 모아놓았고 각 테스트는 콜백 함수 내에서 실행 될 예정이다.

🚩 it() & test()

it('description', callback_fn)과 test('description', callback_fn) 두 개의 문법은 서로 동일한 문법이며 개발자의 성향에 따라 골라서 사용하면 되고 콜백 함수에는 실제로 작동하는 테스트 코드가 들어가며 앞에 문자열에는 뒤에 오는 콜백 함수가 어떤 것을 테스트하는 지는 보통 보여준다.

it/test('should fail if user exists', async () => {
      usersRepository.findOne.mockResolvedValue({
        id: 1,
        email: '',
      });
      // return value faking
      const result = await service.createAccount(createAccountArgs);
      expect(result).toMatchObject({
        ok: false,
        error: 'There is a user with that email already',
      });

즉 위 it/test 용법에서 쓰여진 테스트 코드는 유저를 생성할 때 기입되는 아이디가 이미 DB에 존재하기 때문에 유저 생성을 실패하는 테스트 코드이다.

✏️ beforeEach & beforeAll | afterEach & afterAll

 beforeEach(async () => {
    
  });

문법의 이름 그대로 테스트가 실행되기 전 또는 후에 안에 쓰여져 있는 콜백 함수가 실행이 되고 나서 테스트 코드들이 실행 된다.

beforeEach(callback_fn), beforeAll(callback_fn), afterEach(callback_fn), afterAll(callback_fn)

크게 두드러지는 차이점은 없으므로 그냥 넘어가겠다.

✏️ jest.fn() | mockResolvedValue & mockReturnValue & toHaveBeenCalledTimes & toHaveBeenCalledWith

드디어 jest에 있어서 가장 강력한 문법이 나왔다..처음에는 이게 도대체 뭐지 싶지만..쓰다보면..익숙해지겠지 싶다.

🚩 jest.fn()

const mockRepository = () => ({
  findOne: jest.fn(),
  save: jest.fn(),
  create: jest.fn(),
  findOneOrFail: jest.fn(),
  delete: jest.fn(),
});

const mockJwtService = () => ({
  sign: jest.fn(() => 'signed-token-baby'),
  verify: jest.fn(),
});

const mockMailService = () => ({
  sendVerificationEmail: jest.fn(),
});

jest.fn()은 jest에서 제공하는 함수이며, 테스트 동안 실제 함수를 대체하는 일종의 가상의 함수이다.

즉 위의 코드를 예제로 들면, findOne은 TypeORM의 문법 중 하나이고 실제 작동하는 함수이나 우리의 목적은 UserService 내에서 자체적으로 실행되는 로직과 (다른 패키지의 연계 없이) 함수들만 실행시키고 싶기 때문에 임의적으로 저게 함수이다 라고 지정을 해두는 것이다. 즉, 테스트 코드를 돌릴 때 저게 함수이다 라고 속이기만 하는거지 실질적으로 TypeORM findOne 역할을 수행하는 것이 아니다. 그렇기 때문에!!! 사용하려면 그에 상응하는 반환 값을 우리가 직접!! 설정해줘야 한다.

다음가 같이.

🚩 mockResolvedValue & mockReturnValue & toHaveBeenCalledTimes & toHaveBeenCalledWith

usersRepository.create.mockReturnValue(createAccountArgs);
usersRepository.save.mockResolvedValue(createAccountArgs);

expect(verificationsRepository.create).toHaveBeenCalledTimes(1);
expect(verificationsRepository.create).toHaveBeenCalledWith({
        user: createAccountArgs,
      });
expect(verificationsRepository.save).toHaveBeenCalledTimes(1);   expect(verificationsRepository.save).toHaveBeenCalledWith({
        user: createAccountArgs,
      });

//실제 서비스 내부 로직
      await this.verifications.save(
        this.verifications.create({
          user,
        }),
      );

위에서부터 한 줄 한 줄 설명할 테니 잘 따라와야 한다.

먼저 밑에 쓴게 내가 테스트를 하고 싶은 실제 서비스 내부 로직이다. 보면 TypeORM에 save와 create가 사용된 것을 알 수 있다!.

위에서 언급했듯이 테스트를 돌릴 때는 이게 정말로 TypeORM처럼 작동이 되면 안되지만 TypeORM인 것 처럼 보이기 위해 jest.fn()을 사용해서 create와 save가 함수 처럼 작동하도록 정의를 내려줬다!

자 그럼 다시 테스트 로직으로 돌아와보자.

위에 보면 mockReturnValue와 mockResolvedValue를 사용했다.

둘 다 모두, 함수의 반환 값을 우리가 직접!!!! 지정해주는 것이다. 차이점은 Resolved는 Promise 함수에 대한 반환 값을 설정해주는 것이고, Return은 일반 함수에 대한 반환 값을 설정해 주는 것이다.

=> 그래서 위에 실제 서비스 내부 로직을 보면 save는 await쓴걸로 봤을 때 promise 함수여서 resolved로 create는 일반 함수 이므로 return을 썼다.

즉!!! 첫번째 create의 반환 값은 createAccountArgs가 되는 것이고 save의 반환 겂은 createAccountArgs가 되도록 우리 fake를 하는 것이다.

함수가 jest.fn()을 통해서 만들어졌고 그 함수가 실행되었을 때의 반환 값을 저 2개의 문법을 통해 우리가 직접 설정해 줄 수 있다는 얘기이다. jest.fn()는 아무 일도 일어나지 않는 완전 하얀 도화지 같은 함수이기 때문에 우리가 반환 값도 설정할 수 있다.

반환 값을 설정해 주었으니 함수를 실행하고 그 함수가 작동하도록 만들어 줘야 겠지?

그래서 실행되는게 바로 그 밑에 4줄이다..expect().toHave~~ expect 내부에 들어가는 것은 실행되는 함수 및 결과 값 그리고 그 다음

toHaveBeenCalledTimes()는 함수가 몇 번 실행되었는지 그리고 toHaveBeenCalledWith()는 반환 값이 무엇인지 우리가 알려주는 것이다.

우리가 위에서 반환 값을 설정해 주었기 때문에 당연히 반환 값도 우리가 알아야 한다!

이런 식으로 반복적으로 실제 서비스 내부 로직에 맞춰서 함수를 jest.fn()으로 실행해주고 반환 값을 쿵짝 쿵짝 맞춰주면 Unit Test 서비스 로직이 대충 맞아진다.

기억해야 될 것은 테스트 코드에서 실행되는 함수의 순서와 실제 서비스 로직 내부에서 실행되는 함수의 순서가 같아야지! 테스트로써 통과가 되는 것임을 기억해야 한다.

자 이제 내가 쓴 모든 테스트 코드를 한 눈에 복붙을 하면 다음 과 같다.

describe('createAccount', () => {
    const createAccountArgs = {
      email: 'bs@email.com',
      password: 'bs.password',
      role: 0,
    };
    it('should fail if user exists', async () => {
      usersRepository.findOne.mockResolvedValue({
        id: 1,
        email: '',
      });
      // return value faking
      const result = await service.createAccount(createAccountArgs);
      expect(result).toMatchObject({
        ok: false,
        error: 'There is a user with that email already',
      });
    });

    it('should create a new user', async () => {
      //findOne Mocking
      usersRepository.findOne.mockResolvedValue(undefined);
      usersRepository.create.mockReturnValue(createAccountArgs);
      usersRepository.save.mockResolvedValue(createAccountArgs);
      verificationsRepository.create.mockReturnValue({
        user: createAccountArgs,
      });
      verificationsRepository.save.mockResolvedValue({
        code: 'code',
      });

      const result = await service.createAccount(createAccountArgs);
      //expected to be called only one time toHaveBeenCalledTimes(num)
      expect(usersRepository.create).toHaveBeenCalledTimes(1);
      expect(usersRepository.create).toHaveBeenCalledWith(createAccountArgs);

      expect(usersRepository.save).toHaveBeenCalledTimes(1);
      expect(usersRepository.save).toHaveBeenCalledWith(createAccountArgs);

      expect(verificationsRepository.create).toHaveBeenCalledTimes(1);
      expect(verificationsRepository.create).toHaveBeenCalledWith({
        user: createAccountArgs,
      });

      expect(verificationsRepository.save).toHaveBeenCalledTimes(1);
      expect(verificationsRepository.save).toHaveBeenCalledWith({
        user: createAccountArgs,
      });

      expect(mailService.sendVerificationEmail).toHaveBeenCalledTimes(1);
      expect(mailService.sendVerificationEmail).toHaveBeenCalledWith();
      expect(result).toEqual({ ok: true });
    });

    it('should fail on exception', async () => {
      usersRepository.findOne.mockRejectedValue(new Error());
      const result = await service.createAccount(createAccountArgs);
      expect(result).toEqual({ ok: false, error: "Couldn't create account" });
    });
  });

한 번 훑어보시길 바라며, 궁금한 점이 있을 시 문의 부탁드립니다~

profile
🍖먹은 만큼 성장하는 개발자👩‍💻

0개의 댓글