티스토리 뷰

728x90
SMALL

배경

jest는 우아한테크캠프에서 조금 공부하고 사용해보긴 했다. 그런데 사가 테스트에만 사용을 했었고 tdd방식으로 만들어본적도 없다. 또한 백엔드가 진짜 테스트가 중요한데 백엔드에서 적용해본적이 없다. 그래서 이번에 만든 프로젝트에 테스트를 적용해보려고 한다. 이글은 www.daleseo.com) 글들을 바탕으로 적었다.

 

사용한 라이브러리 버전

package.json

    "jest": "^27.3.1",
    "supertest": "^6.1.6",
    "ts-jest": "^27.0.7",
    "@types/jest": "^27.0.2",

 

jest 설정하기

jest는 jest.config.js, jest.config.ts, package.json에서 설정할 수 있다. 나는 jest.config.ts에 작성했다.
세부적인 설정은 공식 홈페이지를 살펴보자
https://jestjs.io/docs/configuration

내가 사용한 설정만 적어두겠다.

  • moduleFileExtensions : 테스트할 파일의 형식을 적어주는 옵션이다. default는 ["js", "jsx", "ts", "tsx", "json", "node"]이다. 나는 ts만 테스트할 생각이라 ts만 적어줬다.
  • testEnvironment : 말그대로 테스트 환경이다. default는 node이며 지금은 nodejs를 사용하니 건드리지 않아도 좋다.
  • testMatch : 테스트할 파일들을 정규식으로 찾는 옵션이다. default는 [ "**/**tests**/**/_.\[jt\]s?(x)", "\*\*/?(_.)+(spec|test).\[jt\]s?(x)" ] 이다. 사용하는 경로를 지정해주면 좋다.
  • transform : 소스파일을 제공하기 위한 동기 함수를 제공하는 모듈이고. default는 {"\\.[jt]sx?$": "babel-jest"}이다. '\.ts?$': 'ts-jest'로 바꿔주자. 나는 지금 서버에 바벨을 사용하지 않는다.
  • transformIgnorePatterns : 변환 전에 모든 소스 파일 경로와 일치하는 정규 표현식 패턴 문자열의 배열이다. 파일 경로 가 패턴 과 일치 하면 변환되지 않는다. default는 ["/node_modules/", "\\.pnp\\.[^\\\/]+$"]이다. ['<rootDir>/node_modules/'] 다음과 같이 바꿔줬다.
  • moduleNameMapper : 단일 모듈로 이미지나 스타일과 같은 리소스를 제거할 수 있도록 해주는 옵션이다. alias에 매핑된 모듈은 목이 해제된다. default는 null이다. 프론트에서는 해줬는데 서버에서는 따로 지정하지 않았다. 프론트 테스트할 때 얘기하겠다.
  • modulePathIgnorePatterns : 해당 경로가 모듈 로더에 '보이는' 것으로 간주되기 전에 모든 모듈 경로에 대해 일치하는 정규 표현식 패턴 문자열의 배열이다. dist 폴더를 제외해주자. ['<rootDir>/dist/']
  • globalSetup : 테스트전에 파일을 실행할 수 있다. 나는 env파일을 경우에 따라 나누기 때문에 필요하다.

대충 이정도만 찾아봤다. 웹팩이든 jest든 공식문서에 있는 모든 설정을 찾아보는 것은 너무 힘들고 귀찮은 일이다. 나는 필요한 것만 조금씩 보고 필요성을 느낄 때 조금 더 깊게 찾아보는 편이다.

 

jest.config.ts

export default {
  moduleFileExtensions: ['js', 'ts'],
  testEnvironment: 'node',
  transform: {
    '\\.ts?$': 'ts-jest',
  },
  transformIgnorePatterns: ['<rootDir>/node_modules/'],
  modulePathIgnorePatterns: ['<rootDir>/dist/'],
  globalSetup: '<rootDir>/src/config/dot-env-config.ts',
};

package.json

  "scripts": {
    "test": "jest --config ./jest.config.ts"

 

dot-env-config.ts (참고)

import dotenv from 'dotenv';
import path from 'path';

const matchEnv = (NODE_ENV: string) => {
  switch (NODE_ENV) {
    case 'production':
      return '.env';
    case 'test':
      return '.test.env';
    default:
      return '.dev.env';
  }
};

const dotEnvConfig = () =>
  dotenv.config({
    path: path.resolve(process.cwd(), matchEnv(process.env.NODE_ENV || 'development')),
  });

export default dotEnvConfig;

 

린트 적용

테스트를 시작하기 전에 린트를 설치해주자. mac에서는 린트 오류가 안났던것같은데... 린트 설정이 달라서 그런지는 몰라도... eslint-plugin-jest를 설치해주자. 그리고 아래 설정을 추가해주자. 더 알아보고 싶다면 아래 글을 참조
https://www.npmjs.com/package/eslint-plugin-jest)[https://www.npmjs.com/package/eslint-plugin-jest

 

.eslintric.json

{
  "env": {
    "jest/globals": true
  },
  "extends": ["airbnb-base", "plugin:prettier/recommended", "plugin:jest/recommended"],
  "plugins": ["@typescript-eslint", "prettier", "jest"],
  "ignorePatterns": ["jest.config.ts"]
}

 

기본 테스트 적용해보기

일단 기본적으로 jest가 동작하는 방법을 알아야 한다. 공식문서와 사이트를 보는것을 추천한다. 간단하게만 설명하고 넘어갈 것이다.
https://jestjs.io/docs/getting-started
https://www.daleseo.com/jest-basic/

위에서 참고한 간단한 예제를 몇가지 적어보겠다. test.ts 파일을 만들고 yarn test로 직접 테스트해보자

test('1 is 1', () => {
  expect(1).toBe(1);
});

실행해보면 성공하는 것을 볼 수 있다.

 

function errorFunction() {
  throw new Error('에러 발생');
}

test('compiling android goes as expected', () => {
  expect(() => errorFunction()).toThrow();
  expect(() => errorFunction()).toThrow(Error);

  expect(() => errorFunction()).toThrow('에러 발생');
  expect(() => errorFunction()).toThrow(/발생/);
  expect(() => errorFunction()).toThrow('error');
});

실행해보고 어디서 틀리고 왜 틀렸는지 생각해보자. 위와같이 정규식도 가능하다. 그리고 주의해야 할 점은 expect함수를 끝내고 체이닝으로 toThrow함수를 호출하는 것이다.

 

test("테스트 설명", () => {
  expect("검증 대상").toXxx("기대 결과");
});

toxxx를 test matcher라고 한다. toBe는 원시값, toEqual은 객체, toThrow는 에러를 매치시킨다. 이외에도 여러가지 matcher들이 있다.
https://jestjs.io/docs/using-matchers

 

비동기 테스트

콜백함수

가장 기본인 콜백함수부터 적용해보자

function fetchUser(id: number, cb: Function) {
  setTimeout(() => {
    console.log('wait 0.1 sec.');
    const user = {
      id,
      name: `User${id}`,
      email: `${id}@test.com`,
    };
    cb(user);
  }, 100);
}

위의 콜백함수에서 유저값을 검증할 것이다.

test('fetch a user', done => {
  fetchUser(1, (user: Object) => {
    expect(user).toEqual({
      id: 1,
      name: 'User1',
      email: '1@test.com',
    });
    done();
  });
});

여기에 done을 사용하지 않으면 문제가 발생한다. jest는 빨리 실행되어야 한다. 그래서 콜백함수를 인지하지 못하고 toEqual메서드도 실행되지 않는다. 하지만 jest버전때문인지 lint 때문인지 오류를 잡아주기도 한다. 어쨋든 이럴대는 done을 콜백함수 마지막에 호출해서 jest에게 콜백함수라고 알려준다.

promise

사실 콜백함수보다 promise가 직관적이고 알아보기도 쉽다. lint에서는 done을 promise로 바꾸라고 독촉한다.

function fetchUser2(id: number) {
  return new Promise(resolve => {
    setTimeout(() => {
      console.log('wait 0.1 sec.');
      const user = {
        id,
        name: `User${id}`,
        email: `${id}@test.com`,
      };
      resolve(user);
    }, 100);
  });
}

위와 같이 아까와 유사한 함수를 만들자

test('fetch a user2', () => {
  return fetchUser2(1).then(user => {
    expect(user).toEqual({
      id: 1,
      name: 'User1',
      email: '1@test.com',
    });
  });
});

이렇게 테스트 하면 끝이다. 주의할점은 return을 해야한다. 근데 lint쓰면 그전에 리턴하라고 알려준다.

async await

promise보다 가독성을 높인 async await를 살펴보자

test('fetch a user3', async () => {
  const user = await fetchUser2(1);
  expect(user).toEqual({
    id: 1,
    name: 'User1',
    email: '1@test.com',
  });
});

이렇게 하면 끝!!

jest 전후처리

  • beforeEach : 각각의 테스트를 시작하기 전에 실행
    테스트를 하다보면 상태가 변경되어서 초기화 시켜야될 필요가 있다. 그럴때 유용하다.
  • afterEach : 각각의 테스트가 끝나고 실행
    데이터를 정리할 때 유용하다.
  • beforeAll : 맨 처음에 실행
    ex) db에 처음에 connection
  • afterAll : 맨 끝에 실행
    ex) db connection 종료
  • only : only를 붙이면 그 테스트만 실행된다.
  • test.only("run only", () => { // 이 테스트 함수만 실행됨 });
test("not run", () => {  
// 실행 안됨  
});
  • skip : skip을 붙이면 그 테스트는 스킵한다.
test.skip("skip", () => {  
// 이 테스트 함수는 제외됨  
});

test("run", () => {  
// 실행됨  
});
  • describe : 연관된 test 함수들을 묶을 때 유용하다.
describe("group 1", () => {  
test("test 1-1", () => {  
// ...  
});

test("test 1-2", () => {  
// ...  
});  
});

describe("group 2", () => {  
it("test 2-1", () => {  
// ...  
});

it("test 2-2", () => {  
// ...  
});  
});
  • it : test와 완전 똑같음

 

모킹(mocking)

mocking은 가짜로 만드는 것을 의미한다. 이게 무슨말이나면 프론트라면 실제 서버와 연결하지 않고 모킹한 값을 얻어오고 서버라면 db에 접근하지 않고 가짜 값을 가져오는 것이다. 일단 단위 테스트라는 개념을 알아야 한다. 단위 테스트는 각각의 나눠서 하나하나 테스트 하는 것을 말한다. 전체 코드를 돌아가게 만드는 것이 아니다. 물론 경우에 따라서 단위 테스트만 하기도 하지만 연동을 하기도 한다. 어쨋든.. 일단 모킹은 가짜 값을 연결해준다. 라고 알고 있으면 된다.

jest.fn 함수

jest에서는 jest.fn()으로 가짜 함수를 생성한다. 인자를 넘겨줄 수도 있고 일단 default값은 undefined이다. 메서드 체이닝을 이용해 원하는 값을 return해줄수있다. 결국 인자와 return 값 모두를 우리가 만드는 것이다. 그 중간과정은 생략하고 말이다.

test('fn test', () => {  
const mockFn = jest.fn();

mockFn.mockReturnValue('I am a mock!');  
console.log(mockFn()); // I am a mock!

mockFn.mockResolvedValue('I will be a mock!');  
mockFn().then((result: string) => {  
console.log(result); // I will be a mock!  
});

mockFn.mockImplementation((name) => `I am ${name}!`);  
console.log(mockFn('Dale')); // I am Dale!

mockFn('a');  
mockFn(\['b', 'c'\]);

expect(mockFn).toBeCalledTimes(5);  
expect(mockFn).toBeCalledWith('a');  
expect(mockFn).toBeCalledWith(\['b', 'c'\]);  
});

위의 3가지 메서드를 기억하자

  • mockReturnValue : 기본 return값 지정
  • mockResolvedValue : promise가 resolve하는 값을 인자로 넣어서 비동기 함수 모킹
  • mockImplementation : 함수를 재구현
mockFn("a");  
mockFn(\["b", "c"\]);

expect(mockFn).toBeCalledTimes(2);  
expect(mockFn).toBeCalledWith("a");  
expect(mockFn).toBeCalledWith(\["b", "c"\]);

위의 코드를 봐보자. 뭔가 신기하다. 아니 당연하다고 생각할지도 모르겠다. 모킹함수는 자신이 어떻게 호출되었는지 전부 기억한다.

jest.spyOn 스파이

mocking에는 스파이(spy)라는 개념이 있다. 테스트를 작성할 때도 이처럼, 어떤 객체에 속한 함수의 구현을 가짜로 대체하지 않고, 해당 함수의 호출 여부와 어떻게 호출되었는지만을 알아내야 할 때가 있다. 이럴 때, Jest에서 제공하는 jest.spyOn(object, methodName) 함수를 이용하면 된다.

test('fn test', () => {
  const calculator = {
    add: (a: number, b: number) => a + b,
  };

  const spyFn = jest.spyOn(calculator, 'add');

  const result = calculator.add(2, 3);

  expect(spyFn).toBeCalledTimes(1);
  expect(spyFn).toBeCalledWith(2, 3);
  expect(result).toBe(5);
});

axios 통신 테스트

import axios from 'axios';

const API_ENDPOINT = 'https://jsonplaceholder.typicode.com';

const findOne = (id: number) => {
  return axios
    .get(`${API_ENDPOINT}/users/${id}`)
    .then((response) => response.data);
};

export { findOne };

먼저 위와같이 간단한 axios 함수를 만든다.

 

import axios from 'axios';  
import { findOne } from './userService';

test('findOne fetches data from the API endpoint', async () => {  
const spyGet = jest.spyOn(axios, 'get');  
await findOne(1);  
expect(spyGet).toBeCalledTimes(1);  
expect(spyGet).toBeCalledWith(`https://jsonplaceholder.typicode.com/users/1`);  
});

이렇게 spyOn 함수를 붙이면 실제 테스트가 가능하다. 하지만 이는 단위 테스트가 아니다. 서버가 다운되어 있으면 테스트는 동작하지 않는다. 일단은 이렇게 하는 방법도 있구나를 알면된다.

 

import axios from 'axios';  
import { findOne } from './userService';

test('findOne returns what axios get returns', async () => {  
axios.get = jest.fn().mockResolvedValue({  
data: {  
id: 1,  
name: 'Dale Seo',  
},  
});

const user = await findOne(1);  
expect(user).toHaveProperty('id', 1);  
expect(user).toHaveProperty('name', 'Dale Seo');  
});

위와같이 return값을 지정해주면 서버와 관계없이 테스트가 가능하다. 이게 모킹이다.

 

jest.mock 모듈

이 함수는 사실 필요없다. 정확히 말하면 위에 있는 코드들로 작성할 수 있다. 하지만 훨씬 간단하게 구현을 할 수 있다는 장점이 있다. 마치 redux-toolkit같은 존재? 현대 자바스크립트는 모듈화해서 이용한다. 위의 함수를 이용하면 내부를 모킹해야 하지만 모듈 전체를 모킹하는 것이다. 일단 jest.mock을 사용하지 않은 코드를 보고 mock을 사용해 더 간편하게 바뀌는 것을 보이겠다.

이메일과 문자를 보낼 때 사용하는 messageService라는 자바스크립트 모듈이 있다고 가정하자. 이렇게 외부 매체를 통해 메세지를 보내는 작업은 어플리케이션에서 수시로 일어날 수 있지만, 단위 테스트 측면에서는 모킹 기법 없이는 처리가 매우 끼다로운 대표적인 케이스 중 하나다. 왜냐하면, 일반적으로 이메일과 문자는 외부 서비스를 이용하는 경우가 많아서 테스트 실행 시 마다 불필요한 과금 발생할 수 있고, 해당 외부 서비스에 장애가 발생하면 관련 테스트가 모두 깨지는 불상사가 발생할 수 있기 때문이다.

 

messageService.ts

export function sendEmail(email: string, message: string) {
  /* 이메일 보내는 코드 */
}

export function sendSMS(phone: string, message: string) {
  /* 문자를 보내는 코드 */
}

 

userService.ts

import { mocked } from 'ts-jest/utils';
import { register, deregister } from './userService';
import { sendEmail, sendSMS } from './messageService';

jest.mock('./messageService');

beforeEach(() => {
  // sendEmail.mockClear();
  // sendSMS.mockClear();
  mocked(sendEmail).mockClear();
  mocked(sendSMS).mockClear();
});

const user = {
  email: 'test@email.com',
  phone: '012-345-6789',
};

test('register sends messeges', () => {
  register(user);

  expect(sendEmail).toBeCalledTimes(1);
  expect(sendEmail).toBeCalledWith(user.email, '회원 가입을 환영합니다!');

  expect(sendSMS).toBeCalledTimes(1);
  expect(sendSMS).toBeCalledWith(user.phone, '회원 가입을 환영합니다!');
});

test('deregister sends messaes', () => {
  deregister(user);

  expect(sendEmail).toBeCalledTimes(1);
  expect(sendEmail).toBeCalledWith(user.email, '탈퇴 처리 되었습니다.');

  expect(sendSMS).toBeCalledTimes(1);
  expect(sendSMS).toBeCalledWith(user.phone, '탈퇴 처리 되었습니다.');
});

이 상황에서 테스트를 작성할 때 많은 나오는 실수가 임포트한 함수를 저장하고 있는 변수에 목(mock) 함수를 바로 할당하려고 하는 것이다. 아래와 같이 말이다. 자바스크립트 기본기가 있다면 재할당할 수 없다는 것을 알것이다. 몰라도 이제부터 알면된다.

import { sendEmail, sendSMS } from "./messageService";

sendEmail = jest.fn(); // "sendEmail" is read-only.  
sendSMS = jest.fn(); // "sendSMS" is read-only.

그렇다면 *으로 불러와서 하나씩 할당해주는 방법밖에 없다.

userService.test.js

import { register, deregister } from "./userService";  
import \* as messageService from "./messageService";

messageService.sendEmail = jest.fn();  
messageService.sendSMS = jest.fn();

const sendEmail = messageService.sendEmail;  
const sendSMS = messageService.sendSMS;

beforeEach(() => {  
sendEmail.mockClear();  
sendSMS.mockClear();  
});

const user = {  
email: "[test@email.com](mailto:test@email.com)",  
phone: "012-345-6789",  
};

test("register sends messeges", () => {  
register(user);

expect(sendEmail).toBeCalledTimes(1);  
expect(sendEmail).toBeCalledWith(user.email, "회원 가입을 환영합니다!");

expect(sendSMS).toBeCalledTimes(1);  
expect(sendSMS).toBeCalledWith(user.phone, "회원 가입을 환영합니다!");  
});

test("deregister sends messaes", () => {  
deregister(user);

expect(sendEmail).toBeCalledTimes(1);  
expect(sendEmail).toBeCalledWith(user.email, "탈퇴 처리 되었습니다.");

expect(sendSMS).toBeCalledTimes(1);  
expect(sendSMS).toBeCalledWith(user.phone, "탈퇴 처리 되었습니다.");  
});

사실 나는 lint와 ts를 사용해서 컴파일이전부터 할당할 수 없다고 오류를 내뱉는다. js와 lint가 없다면 오류가 나지 않을 것이다.
그다지 복잡하지 않아보일지도 모른다. 하지만 여러 모듈을 불러오고 모듈의 크기도 크다면 굉장히 번거로운 작업이 아닐 수 없다. 이제 jest.mock을 이용해보자

import { mocked } from 'ts-jest/utils';
import { register, deregister } from './userService';
import { sendEmail, sendSMS } from './messageService';

jest.mock('./messageService');

beforeEach(() => {
  // sendEmail.mockClear();
  // sendSMS.mockClear();
  mocked(sendEmail).mockClear();
  mocked(sendSMS).mockClear();
});

const user = {
  email: 'test@email.com',
  phone: '012-345-6789',
};

test('register sends messeges', () => {
  register(user);

  expect(sendEmail).toBeCalledTimes(1);
  expect(sendEmail).toBeCalledWith(user.email, '회원 가입을 환영합니다!');

  expect(sendSMS).toBeCalledTimes(1);
  expect(sendSMS).toBeCalledWith(user.phone, '회원 가입을 환영합니다!');
});

test('deregister sends messaes', () => {
  deregister(user);

  expect(sendEmail).toBeCalledTimes(1);
  expect(sendEmail).toBeCalledWith(user.email, '탈퇴 처리 되었습니다.');

  expect(sendSMS).toBeCalledTimes(1);
  expect(sendSMS).toBeCalledWith(user.phone, '탈퇴 처리 되었습니다.');
});

그냥 함수 하나만 호출하면 끝이다. 엄청 간단하다. 여기서 js와 ts의 사용법이 조금 다르다. js는 그냥 mockClear가 가능하지만 ts에서는 mocked()로 한번 감싸줘야 한다.

이제 위에서 만들어본 axios 테스트 2개를 하나로 합쳐보자

import axios, { AxiosResponse } from 'axios';
import { findOne } from './userService';
import { mocked } from 'ts-jest/utils';

jest.mock('axios');

test('findOne fetches data from the API endpoint and returns what axios get returns', async () => {
  // const mockedResponse: AxiosResponse = {
  //   data: {
  //     id: 1,
  //     name: 'Dale Seo',
  //   },
  //   status: 200,
  //   statusText: 'OK',
  //   headers: {},
  //   config: {},
  // };
  // mocked(axios.get).mockResolvedValue(mockedResponse);
  mocked(axios.get).mockResolvedValue({
    data: {
      id: 1,
      name: 'Dale Seo',
    },
  });

  // axios.get.mockResolvedValue({
  //   data: {
  //     id: 1,
  //     name: 'Dale Seo',
  //   },
  // });

  const user = await findOne(1);

  expect(user).toHaveProperty('id', 1);
  expect(user).toHaveProperty('name', 'Dale Seo');
  expect(axios.get).toBeCalledTimes(1);
  expect(axios.get).toBeCalledWith(`https://jsonplaceholder.typicode.com/users/1`);
});

일단 여기서도 ts라면 mocked를 감싸야 한다. 또한 mockResolveValue에 그냥 data만 넣어줄 수도 있지만 타입스크립트의 axiosResponse 인터페이스를 이용해 전부 하나하나 할당해줄수도 있다.

후기

그냥 대충 찾아서 할까 하다가 테스트는 앞으로 계속 사용할 중요한 기술이기 때문에 확실히 해야겠다고 생각했다. 저번에 너무 급하게 공부했더니 머리에 남는게 거의 없었다. 아직 express, db연동은 들어가지도 않았지만 기본기부터 차근차근 조급해하지말고 공부하자
다음 글에는 아마 express 연동, 클래스 테스트, db 테스트, 연동테스트를 쓸 것같다. 글이 몇 개 분량이 나올지는 잘 모르겠다.

 

전체 코드는 아래에 올려두었다.

https://github.com/yoonminsang/jest-test-ex

 

GitHub - yoonminsang/jest-test-ex: 테스트코드 예제

테스트코드 예제. Contribute to yoonminsang/jest-test-ex development by creating an account on GitHub.

github.com

 

728x90
LIST
댓글
공지사항