Jest 101 -1

dante Yoon·2022년 3월 6일
8

test

목록 보기
3/3
post-thumbnail
post-custom-banner

유닛 테스트를 진행할 때 알고 있으면 좋을 유익한 팁들

Disclaimer

Jest에 익숙하신 분들은 본 포스팅을 패스하셔도 좋습니다.

TLDR

  • Jest, esbuild를 사용해 테스트 (연습) 환경 구축하기
  • toEqual
  • toStrictEqual
  • toMatchObject

들어가며

유닛 테스트를 작성할 때 가장 보편적으로 쓰이는 라이브러리는 Jest 입니다.
Jest의 도움을 받으면 증명하고 싶은 테스트들을 비교적 적은 코드의 양으로 검수할 수 있습니다.
(Jest는 이하 '제스트'로 표현)

하지만

수 많은 유틸 함수들이 있어서 적재적소에 알맞게 유틸 함수를 끼워 맞추는 것은 여간 성가신 일이 아닙니다.

수 많은 유틸 함수들

expect로 참조할 수 있는 수많은 유틸 함수들

오늘은 제가 함수형 프로그래밍 라이브러리인 언더스코어를 만들며 작성한 테스트 함수를 함께 살펴보며 어떤 유틸 함수가 적절한지 같이 논의해보도록 하겠습니다.

유닛 테스트 작성을 위한 개발 환경 구축

본 테스트 환경 구축은 포스팅을 읽어가며 같이 핸즈온을 하지 않으신다면 생략하셔도 무방합니다.

유닛 테스트의 작성은 보통 핵심 로직을 담당하는 함수를 대상으로 작성합니다. 돔 테스팅과는 조금 가벼운 느낌입니다. 유닛 테스트를 작성하는데 있어 리엑트 환경을 구축하는 것은 오버스펙이므로 lodash와 같은 유틸 함수들을 직접 npm에 배포한다고 가정하고 프로젝트 환경을 구성해보겠습니다.

esbuild & esbuild-jest

yarn add -D esbuild esbuild-jest

우리는 번들링/컴파일 툴로 esbuild를 사용합니다.
esbuild-jest는 jest 파일 내부에서 import 구문을 사용하기 위해 사용합니다. 만약 여러분이 webpack을 사용하신다면, babel 설정을 통해 jest 에서 AMD 사용을 할 수 있게 추가 플러그인을 설치해주어야 합니다만, esbuild는 esbuild-jest만 설치하면 됩니다.

프로젝트 폴더 루트에 build.ts 파일을 생성해줍니다. (타입스크립트를 사용하지 않는다면 .js)

// 
const { build } = require("esbuild");
const { dependencies, peerDependencies } = require("./package.json");

const shared = {
  entryPoints: ["src/index.ts"],
  bundle: true,
  external: Object.keys({
    ...dependencies,
    ...peerDependencies,
  }),
};

build({
  ...shared,
  outfile: "dist/index.js",
});

build({
  ...shared,
  outfile: "dist/index.esm.js",
  format: "esm",
});

esbuild의 build 함수를 사용해 번들링 하면 IIFE 형식으로 만들어집니다.
우리는 import {isNil} from lodash 과 같이 모듈 임포트 형식으로 라이브러리를 사용하게 할 것임으로 esm format으로 옵션을 주어 build 함수를 호출해주었습니다.

shared의 external 옵션을 통해 사용자가 사용하지 않는 dependcencies 들이 함께 번들링 되는 것을 방지할 수 있습니다.
outfile 옵션을 통해 dist 이하 폴더에 번들링된 파일이 위치하게 했습니다.

typescript

다음은 타입스크립트 설정을 위한 모듈 설치입니다. 타입스크립트를 사용하고 싶지 않으시면 해당 과정을 건너뛰셔도 됩니다만, 타입스크립트를 사용하지 않을 이유도 없다고 생각합니다.

yarn add -D typescript @types/node @types/jest

프로젝트 폴더 루트에 tsconfig.json 파일을 생성합니다.

// tsconfig.json

{
  "compilerOptions": {
    "target": "es5",
    "module": "commonjs",
    "explainFiles": true
  }
}

jest.config.js

프로젝트 폴더 루트에 위치하는 jest.config.js 입니다.

// jest.config.js
module.exports = {
  transform: {
    "\\.[jt]sx?$":  [ 'esbuild-jest', { 
        loaders: {
          '.spec.js': 'jsx',
          '.spec.ts': 'tsx',
        }
      }
    ]
  },
}

package.json

{
  "name": "라이브러리 이름은 자유",
  "version": "0.0.1", // 첫 배포는 0.0.1 버전으로 배포
  "main": "dist/index.js", 
  "module": "dist/index.esm.js",
  "dependencies": {
    "esbuild": "^0.14.25"
  },
  "scripts": {
    "build": "node build.ts",
    "test": "jest"
  },
  "devDependencies": {
    "@types/jest": "^27.4.1",
    "@types/node": "^17.0.21",
    "esbuild-jest": "^0.5.0",
    "jest": "^27.5.1",
    "typescript": "^4.6.2"
  }
}

프로젝트 기본 세팅을 마쳤습니다.

프로젝트 구조

현재까지 위의 과정을 따라 하셨으면 src 폴더를 제외하고는 아래와 유사한 구조를 가지고 있을 것입니다.

작성할 유틸함수 및 테스트 코드는 src/fp/ 에 위치할 것입니다.

_.each

시그니처

_.each 함수는 {} 와 같은 dictionary 타입 (키-밸류 일반 객체), array와 같은 ArrayLike 타입을 첫 번째 인자로 받고,
두 번째 인자로 iterate 함수를 받습니다.

iterate 함수는 [1,2,3]과 같은 배열의 경우 각 배열 값을, 일반 객체라면 키값을 첫 인자로,
n번째 순회할때의 n을 두번째 인자로,
원본 리스트를 세번째 인자로 받습니다.

리턴 값은 첫번째 인자를 그대로 반환합니다.

구현

//src/fp/each.ts
export const each = (
  arrayLike: any,
  fn: (val, idx: number, list: any) => void
) => {
  if (typeof fn !== "function") {
    throw Error("typeof fn is not a function");
  }

  let index = 0;

  try {
    if (arrayLike.constructor === Object) {
      for (let [key] of Object.entries(arrayLike)) {
        fn(key, index++, arrayLike);
      }
    } else {
      for (let value of arrayLike) {
        fn(value, index++, arrayLike);
      }
    }
    return arrayLike;
  } catch (e) {
    // Uncaught TypeError: f is not iterable
    throw Error(e);
  }
};
// src/fp/index.ts
export * from "./each";
// src/index.ts
import { each } from "./fp";
export * from "./fp";

const underscore = {
  each,
};

export default underscore;

테스팅

어떤 것을 테스팅해야 할까요?
제가 만든 each함수와 Array.prototype.forEach 함수의 가장 눈에 띄는 차이는 반환 값입니다.
후자는 undefined를 리턴함으로, 첫번째 인자가 그대로 반환되는지를 확인해야 할 것 같네요.

// each.spec.ts
import _ from "..";

describe("each", () => {
  it("Should return first argument -> first argument: Array.isArray, second argument: typeof function", () => {
    const list = [1,2,3];
    const fn = (list) => console.log(list);
    const returned = _.each(list, fn);
    expect(returned).toEqual(list);
  })

  it("Should return first argument => first argument: Record<string,any>, second argument: typeof function", () => {
    const records = {
      one: 1,
      two: 2,
      three: 3,
    }; 
    const fn = (list) => console.log(list);
    const returned = _.each(records,fn);
    expect(returned).toStrictEuqal(records);
  });
})

먼저 jest는 두 개의 함수의 그룹핑을 할 수 있습니다.
describe 함수는 해당 테스트가 실행될 때 어떤 테스트인지 제목을 첫인자로, 두번째 인자로는 실제 진행될 테스트 함수를 인자로 받습니다.

우리는 두번째 인자인 함수 내부에 또 작은 테스트들을 it함수를 사용해서 만들어 줄 수 있습니다.

첫번째 it 블록을 보면 each 함수내부에 들어갈 인자를 임의로 만들어 넣어주고 반환 값으로 list가 그대로 나오는지를 확인하고 있습니다.

toEqual

toBe는 deep comparison을 하지 않는 primitive value의 비교를 위한 함수입니다.
array, 일반 객체와 같이 call by reference를 사용하는 타입의 경우, === 비교를 통해 동일한 메모리를 가르키는지를 확인하려면 보다 엄격한 유틸 함수가 필요하네요,
위에 작성한 테스트는 통과는 되지만 엄밀하게 말하면 새로운 인자가 반환되었는지, 말 그대로 인자가 함수에 통과되어 그대로 나왔는지는 확인시켜주지 못합니다.

toStrictEqual

동일한 값은 물론이거니와 동일한 메모리를 참조하고 있는지를 위해서는 toStrictEqual을 사용합니다.

 it("Should return first argument -> first argument: Array.isArray, second argument: typeof function", () => {
    const list = [1,2,3];
    const fn = (list) => console.log(list);
    const returned = _.each(list, fn);
    expect(returned).toStrictEqual(list);
  })

_.reject

시그니처

reject는 Array.prototype.filter를 생각하면 쉽습니다. filter는 로직에 만족하는 값만 가져오지만, reject는 로직에 만족되는 값을 제외시킵니다.
앞서 만들었던 each와 다른 점은, each는 인자를 그대로 반환한다면, reject는 새로운 배열을 만들어 반환해야 한다는 것입니다.

첫 인자로 리스트를, 두번째 인자로 predicate 함수를 받습니다.
preciate함수는 인자로 리스트를 받고 true/false를 반환합니다.

구현

// reject.ts
export const reject = (list: any[], predicate: (item: any) => boolean) => {
  if (!Array.isArray) {
    throw Error("list is not Array");
  }

  const new_list = [];

  try {
    for (let item of list) {
      if (!predicate(item)) {
        new_list.push(item);
      }
    }
    return new_list;
  } catch (e) {
    throw Error(e);
  }
};

테스팅

// reject.spec.ts
import _ from "..";

describe("reject", () => {
  it("should return odd numbers", () => {
    const list = [1, 2, 3, 4, 5, 6];
    const predicate = (number) => number % 2 === 0;
    expect(_.reject(list, predicate)).toMatchObject([1, 3, 5]);
  });

  it("return list shouldn't be same with original list", () => {
    const list = [1, 2, 3, 4, 5, 6];
    const predicate = (number) => number % 2 === 0;
    expect(_.reject(list, predicate)).not.toStrictEqual(list);
  });
});

toMatchObject

우리가 확인해야 할 것은 두가지 입니다. predicate 콜백함수가 반환하는 리스트가 인자로 전달된 list와는 전혀 다른 새로운 리스트인지,
반환된 리스트가 predicate을 만족하는지 입니다.

toMatchObject를 통해 deep comparison을 하는 것이 아닌, object가 동일한 내용물을 가지고 있는지 테스트했습니다.

toStrictEqual && not

유틸함수 앞에 not을 붙임으로써 인자로 넣은 list와 반환된 list 두 객체가 같은 객체를 참조하면 안된다는 것을 명시해주었습니다.

profile
성장을 향한 작은 몸부림의 흔적들
post-custom-banner

1개의 댓글

comment-user-thumbnail
2022년 3월 6일

좋네요,,

답글 달기