THE DEVLOG

scribbly.

etc

2024.08.05 23:45:15

코딩앙마님의 유튜브 Jest 강좌를 보고 작성한 글이다.

0. 설치

Jest는 페이스북에서 만든 테스팅 라이브러리이다.

  1. 폴더를 만들고 아래의 명령어를 입력한다.
    npm init
    npm install jest --save-dev
    vs 코드를 사용하는 경우 npm install @types/jest --save-dev@types/jest를 추가로 설치하여 인텔리센스 자동완성 기능을 활용할 수 있다.

  2. 설치가 완료된 후 package.json 폴더를 수정한다.

{
  "name": "jest",
  "version": "1.0.0",
  "main": "index.js",
  "scripts": {
    // test 스크립트를 "jest"로 수정함
    "test": "jest"
  },
  "author": "",
  "license": "ISC",
  "description": "",
  "devDependencies": {
    "jest": "^29.7.0"
  }
}

1. test

먼저 테스트에 활용될 함수들을 저장할 함수 팩토리 fn.js 파일을 만든다.

const fn = {
  add: (num1, num2) => num1 + num2,
};

module.exports = fn;

테스트 파일을 만든다. test.js로 끝나거나, __jest__ 폴더에 있는 파일들은 테스트 파일로 인식한다.

만일 특정 테스트 파일만 실행하고 싶은 경우에는 npm test <파일명 혹은 경로> 를 입력하여 실행한다.

const fn = require("./fn");

test("1은 1이야", () => {
  expect(1).toBe(1);
});
  • test 함수 (혹은 it 함수) : 새로운 테스트 케이스를 만든다. test(설명, fn) 형태로 작성한다. test와 it은 동일하다. test('if it does this thing' ()=>{}), it('should do this thing', () => {})와 같이 '설명'을 작성하는 방식에 차이가 있을 뿐이다.참고 되도록 둘 중 하나로 통일하는 것이 좋다.
  • expect : 테스트할 값을 넘겨 성공 혹은 실패를 판가름하는 함수이다. 성공 혹은 실패를 판가름하기 위해 다양한 matchers에 접근할 수 있다. expect(value).matchers() 형태로 작성한다.
  • toBe : expect의 값이 특정 값과 정확히 일치하는지를 판가름하는 matchers이다. expect(value1).toBe(value2)와 같이 작성하며, value1과 value2가 일치하는지를 확인한다.
const fn = require("./fn");

test("1은 1이야", () => {
  expect(1).toBe(1);
});

test("2 더하기 3은 5야", () => {
  expect(fn.add(2, 3)).toBe(5);
});

test("3 더하기 3은 5가 아니야", () => {
  expect(fn.add(3, 3)).not.toBe(5);
});
  • not : 테스트 결과를 반대로 뒤집는 제한자(Modifiers)이다.

2. 유용한 Matchers

toStrictEqual

이름과 나이를 받아 유저 객체를 반환하는 함수를 테스트해보자.

const fn = {
  makeUser: (name, age) => ({ name, age }),
};

module.exports = fn;

객체를 반환하는 위의 함수를 toBe라는 매처를 사용하면 테스트가 failed된다.

const fn = require("./fn");

test("이름과 나이를 전달받아서 객체를 반환해줘", () => {
  expect(fn.makeUser("Mike", 30)).toBe({
    name: "Mike",
    age: 30,
  });
});

toBe는 makeUser가 반환한 객체와 toBe에 인자로 넘어온 객체가 동일한 인스턴스인지(Object.is)를 판별한다.

깊은 비교를 하기 위해서는 toEqual 이라는 매처를 사용해야 한다.

const fn = require("./fn");

test("이름과 나이를 전달받아서 객체를 반환해줘", () => {
  expect(fn.makeUser("Mike", 30)).toBe({
    name: "Mike",
    age: 30,
  });
});

makeUser 함수에 gender를 인자로 추가해보자.

const fn = {
  makeUser: (name, age, gender) => ({ name, age, gender }),
};

module.exports = fn;

테스트를 실행하면 함수는 expect에 {"age": 30, "name": "Mike", "gender" : undefined }를 인자로 넘겨줄 것이다.

toEqual을 사용한 테스트는 통과된다. toEqual은 매처에 객체나 배열의 모든 필드를 재귀적으로 판별한다. 하지만 비어있는 필드나 숨겨진 필드는 판별하지 않는다.

toStrictEqual은 필드와 비어있는 필드는 물론이고 객체의 타입까지도 판별한다. 아래의 첫번째 테스트는 비어있는 필드가 포함된 예시이고, 두번째 테스트는 서로 다른 타입의 객체를 비교하는 예시이다.

참고 Jest: toEqual or toStrictEqual? The difference.

const fn = require("./fn");

test("이름과 나이를 전달받아서 객체를 반환해줘", () => {
  expect(fn.makeUser("Mike", 30)).toStrictEqual({
    name: "Mike",
    age: 30,
  });
});

/*
failed
expect(received).toStrictEqual(expected) // deep equality

- Expected  - 0
+ Received  + 1

  Object {
    "age": 30,
+   "gender": undefined,
    "name": "Mike",
  }
*/

test("생성자 함수 Pizza로 만든 객체와 객체 리터럴로 만들어진 객체는 타입이 같다.", () => {
  class Pizza {
    constructor(name, price) {
      this.name = name;
      this.price = price;
    }
  }

  expect(new Pizza("Flaming Sizzler", 899)).toStrictEqual({
    name: "Flaming Sizzler",
    price: 899,
  });
});

/*
failed
expect(received).toStrictEqual(expected) // deep equality

- Expected  - 1
+ Received  + 1

- Object {
+ Pizza {
    "name": "Flaming Sizzler",
    "price": 899,
  }
*/

첫번째 테스트는 expect의 {"age": 30, "name": "Mike", "gender" : undefined }와 매처의 {"age": 30, "name": "Mike" }가 서로 필드가 다르기 때문에 failed 되었다.

두번째 테스트는 expect의 객체는 "Pizza" 프로토타입이고, 매처의 객체는 "Object" 프로토타입이기 때문에 테스트가 failed 되었다.

이와 같이 개발자의 의도를 보다 명확하게 하기 위해 toStrictEqual을 기본으로 하는 것이 좋다.

기타 matchers

  • toBeNull(), toBeUndefined() toBeDefined()
    매처를 통해 expect에 전달된 값이 null, undefined인지 아닌지를 판별할 수 있다.

  • toBeTruthy(), toBeFalsy()
    매처를 통해 expect에 전달된 값이 truthy한 값인지 falsy한 값인지 판별할 수 있다.

  • toBeGreaterThan(value), toBeGreaterThanOrEqual(value), toBeLessThan(value), toBeLessThanOrEqual(value)
    expect에 전달된 값이 value보다 큰지, 작은지 등을 비교할 수 있다.

// passed
test("사용자가 입력한 값이 8글자 이상이다.", () => {
  const inputValue = "12345678";
  expect(inputValue.length).toBeGreaterThanOrEqual(8);
});
  • toBeCloseTo(value)
    부동소숫점을 포함한 두 값이 근사값인지를 파악한다. 대략 0.5 * 10^2의 정확도로참고 판별한다.
// failed
test("0.1 더하기 0.2는 0.3 입니다.", () => {
  expect(0.1 + 0.2).toBe(0.3);
});

// passed
test("0.1 더하기 0.2는 0.3 입니다.", () => {
  expect(0.1 + 0.2).toBeCloseTo(0.3);
});
  • toMatch(정규표현식)
    expect의 문자열이 toMatch의 정규표현식을 통과하는지 테스트한다.
// failed
test("Hello World에 대문자 L이 있다.", () => {
  expect("Hello World").toMatch(/L/);
});

// passed
test("Hello World에 대소문자 구분없이 L이 있다.", () => {
  expect("Hello World").toMatch(/L/i);
});
  • toContain(value)
    expect의 배열에 value가 포함되어있는지를 테스트한다.
test("users 배열에 Mike가 있다.", () => {
  const userList = ["Tom", "Jane", "Mike"];
  expect(userList).toContain("Mike");
});
  • toThrow(value?)
    expect에 넘겨진 함수를 실행하면 error가 발생했는지를 판별한다. value를 인자로 넘겨주면 error의 값이 value와 일치하는지 판별한다.
const fn = {
  throwErr: () => {
    throw new Error("xx");
  },
};
// passed
test("throwErr 함수를 실행하면 에러가 발생한다.", () => {
  expect(() => fn.throwErr()).toThrow();
});

//failed
// Expected substring: "oo"
// Received message:   "xx"
test("throwErr 함수에서 발생한 에러값이 oo이다", () => {
  expect(() => fn.throwErr()).toThrow("oo");
});

3. 비동기 코드 테스트

콜백

3초 후에 인자로 "Mike"를 넘겨주는 비동기 함수를 만든다.

const fn = {
  getName: (callback) => {
    const name = "Mike";
    setTimeout(() => {
      callback(name);
    }, 3000);
  },
};

아래와 같이 expect함수를 담은 콜백을 만들고 해당 함수에 전달한다.

// passed
test("3초 후에 받아온 이름이 Tom이다", () => {
  function callback(name) {
    expect(name).toBe("Tome");
  }
  fn.getName(callback);
});

위의 테스트는 잘못된 테스트이지만 0.4초만에 pass로 테스트가 끝난다. 테스트를 중단하지 않고 비동기작업을 처리하면 비동기 작업의 결과와는 상관없이 테스트 종료되는 시점에 테스트가 종료된다. (there are asynchronous operations that weren't stopped in your tests)

비동기 작업이 완료될 때까지 테스트를 중단시키고 싶다면 test 함수에 첫번째 인자로 done을 넘겨주면 된다. done이 인자로 넘어오면 test 함수는 done 함수가 호출될 때까지 테스트를 중단하게 된다.

// passed
test("3초 후에 받아온 이름이 Mike이다", (done) => {
  function callback(name) {
    expect(name).toBe("Mike");
    done(); // 콜백 함수가 종료되면 done을 호출한다.
  }
  fn.getName(callback);
});

만일 callback에 넘어온 이름이 "Mike"가 아니라면 jest는 5초 후 타임아웃 에러를 발생시킨다.
에러가 발생하였을 때에 에러를 보고 싶다면 아래와 같이 Try-Catch문을 사용하면 된다. catch문에서 error를 done(error)와 같이 인자로 넘겨주어야 한다. 이렇게하면 정상적으로 3초 후 fail과 함께 테스트를 종료한다.

// failed
test("3초 후에 받아온 이름이 Mike이다", (done) => {
  function callback(name) {
    try {
      expect(name).toBe("Mike");
      done();
    } catch (error) {
      done(error);
    }
  }

  fn.getName(callback);
});

프라미스

const fn = {
  getAge: () => {
    const age = 30;
    return new Promise((res, rej) => {
      setTimeout(() => {
        res(age);
      });
    }, 3000);
  },
};

위와 같이 Promise.resolve에 나이를 담아서 반환하는 함수를 만들었다.

Promise.then()을 사용하거나, expect의 resolves, rejects 매처를 사용하면 된다. 단 반드시 선언문을 반환(return)해야 한다는 것이다. 반환하지 않으면 Promise가 resolve 혹은 reject되기 전에 test가 passed로 끝나버린다.

const fn = require("./fn");

// passed
test("3초 후에 받아온 나이는 30이다", () => {
  return fn.getAge().then((age) => expect(age).toBe(30));
});

// passed
test("3초 후에 받아온 나이는 30이다", () => {
  return expect(fn.getAge()).resolves.toBe(30);
});

// failed
test("3초 후에 error와 함께 Rejected됩니다.", () => {
  return expect(fn.getAge()).rejects.toMatch("error");
});

async/await

비동기처리를 위해 async/await 구문을 사용하는 대안도 있다.

// passed
test("3초 후에 받아온 나이는 30이다", async () => {
  const age = await fn.getAge();
  expect(age).toBe(30);
});

// passed
test("3초 후에 받아온 나이는 30이다", async () => {
  await fn.getAge().then((age) => expect(age).toBe(30));
});

// passed
test("3초 후에 받아온 나이는 30이다", async () => {
  await expect(fn.getAge()).resolves.toBe(30);
});

4. 테스트 전/후 작업

반복 설정

let num = 0;

// passed
test("0 더하기 1은 1이다", () => {
  num = fn.add(num, 1);
  expect(num).toBe(1);
});

// failed : 3
test("0 더하기 2은 2이다", () => {
  num = fn.add(num, 2);
  expect(num).toBe(2);
});

// failed : 6
test("0 더하기 3은 3이다", () => {
  num = fn.add(num, 3);
  expect(num).toBe(3);
});

위의 코드에서 첫번째 테스트를 제외한 나머지 테스트들은 실패한다.
num이 테스트를 할 때마다 1, 3 등으로 업데이트되고 있기 때문이다.

let num = 0;

// 추가됨
beforeEach(() => {
  num = 0;
});

// passed
test("0 더하기 1은 1이다", () => {
  num = fn.add(num, 1);
  expect(num).toBe(1);
});

// passed
test("0 더하기 2은 2이다", () => {
  num = fn.add(num, 2);
  expect(num).toBe(2);
});

// passed
test("0 더하기 3은 3이다", () => {
  num = fn.add(num, 3);
  expect(num).toBe(3);
});

위와 같이 beforeEach라는 반복 설정 Hook에 콜백함수를 넘겨주면 테스트를 실행하기 전마다 콜백 함수를 실행하며 num을 0으로 초기화한다.

  • beforeEach(콜백함수): 매 테스트를 실행하기 전마다 콜백 함수를 호출한다.
  • afterEach(콜백함수): 매 테스트를 실행한 후마다 콜백 함수를 호출한다.

일회성 설정

아래와 같이 DB에 접속하고, DB와의 접속을 끊는 요청이 있다고 해보자. 각 요청은 500ms가 소요된다.

const fn = {
  connectUserDb: () => {
    return new Promise((res) => {
      setTimeout(() => {
        res({
          name: "Mike",
          age: 30,
          gender: "male",
        });
      }, 500);
    });
  },
  disconnectUserDb: () => {
    return new Promise((res) => {
      setTimeout(() => {
        res();
      }, 500);
    });
  },
};

이를 이용해서 유저 테스트를 만들었다고 가정해보자.

const fn = require("./fn");

describe("유저에 대한 테스트", () => {
  let user;

  beforeEach(async () => {
    user = await fn.connectUserDb();
  });

  afterEach(() => {
    return fn.disconnectUserDb();
  });

  test("유저의 이름은 Mike이다", () => {
    expect(user.name).toBe("Mike");
  });

  test("유저의 나이는 30살 이상이다", () => {
    expect(user.age).toBeGreaterThanOrEqual(30);
  });
});

두 개의 테스트를 모두 통과하지만, 시간이 2초 이상 소요된다.
beforeEach와 afterEach를 통해 DB에 접속하고, 단절하는 작업이 반복되면서 시간이 소요되는 것이다.

테스트 시작 전, 시작 후 한번만 실행하기 위해서는 beforeAll과 afterAll 훅을 사용한다.

describe("유저에 대한 테스트", () => {
  let user;

  beforeAll(async () => {
    user = await fn.connectUserDb();
  });

  afterAll(() => {
    return fn.disconnectUserDb();
  });

  test("유저의 이름은 Mike이다", () => {
    expect(user.name).toBe("Mike");
  });

  test("유저의 나이는 30살 이상이다", () => {
    expect(user.age).toBeGreaterThanOrEqual(30);
  });
});

describe(name, fn)

위의 예제에서 쓰인 describe는 테스트를 그룹화한다. 앞서 설명한 일회성 설정과 반복 설정들은 해당 describe 그룹 안에서만 적용된다.

only, skip

describe나 test 블록에는 only와 skip이라는 메서드가 있다.
이를 이용하여 테스트하고 싶은 블록만 선별하거나,
특정 테스트만 제외할 수 있다.

// only 메서드를 통해 해당 객체만 테스트할 수 있다.
describe.only("유저에 대한 테스트", () => {
  let user;

  beforeAll(async () => {
    user = await fn.connectUserDb();
  });
  afterAll(() => {
    return fn.disconnectUserDb();
  });

  // skip 메서드를 통해 해당 객체만 테스트에서 제외할 수 있다.
  test.skip("유저의 이름은 Mike이다", () => {
    expect(user.name).toBe("Mike");
  });

  test("유저의 나이는 30살 이상이다", () => {
    expect(user.age).toBeGreaterThanOrEqual(30);
  });
});
Test Suites: 1 passed, 1 total
Tests:       1 skipped, 1 passed, 2 total

5. Mock 함수

함수를 실제로 구현하지 않고 모의 함수를 통해 이를 대체할 수 있다. 모의 함수를 통해 코드 간의 연결을 테스트할 수 있고, 함수의 호출을 확인하거나 테스트의 반환값을 임의로 지정하는 등의 편의성을 제공한다.

.mock 속성

아래와 같이 모의 함수의 mock 속성을 통해 함수가 예상대로 호출되는지를 확인할 수 있다.

const mockFn = jest.fn();

mockFn();
mockFn(1);

test("Mock 함수의 mock 속성엔 calls 배열이 있습니다", () => {
  console.log(mockFn.mock.calls); // [ [], [ 1 ] ]
});

아래와 같이 배열을 전달받은 후, 배열의 요소를 순회하며 계산하는 Reduce 함수가 있다고 가정해보자.

function forEachReduce(arr, callback) {
  let value = 0;
  arr.forEach((num) => {
    value = callback(value, num);
  });
}

해당 reduce를 테스트하기 위한 콜백 함수를 아래와 같이 모의 함수로 작성할 수 있다.

const mockCallback = jest.fn((value, num) => {
  return value + num;
});

forEachReduce([1, 2, 3], mockCallback)를 실행하면 callback함수는 1, 3, 6을 반환한다.

이를 테스트 코드로 아래와 같이 작성하여 forEachReduce가 제대로 작동하는지를 확인할 수 있다.

test("forEachReduce 함수가 작동되는지 테스트합니다.", () => {
  forEachReduce([1, 2, 3], mockCallback);

  // 콜백 함수가 3번 호출되었습니다.
  expect(mockCallback.mock.calls).toHaveLength(3);

  // 콜백 함수가 최초로 실행될 때, 첫번째 인자는 0이었습니다.
  expect(mockCallback.mock.calls[0][0]).toBe(0);

  // 콜백 함수가 최초로 실행될 때, 두번째 인자는 1이었습니다.
  expect(mockCallback.mock.calls[0][1]).toBe(1);

  // 콜백 함수가 세번째로 실행될 때, 6을 반환하였습니다.
  expect(mockCallback.mock.results[2].value).toBe(6);
});

모의 반환값

모의 함수를 통해 테스트 값을 주입하는 방식으로도 사용할 수 있다.

const apiMock = jest.fn();

apiMock
  .mockReturnValueOnce({ name: "Mike" })
  .mockReturnValueOnce(30)
  .mockReturnValueOnce(true)
  .mockReturnValueOnce(false);

test("호출된 이름은 Mike이다", () => {
  expect(apiMock().name).toBe("Mike");
});

test("호출된 값은 15보다 크다", () => {
  expect(apiMock()).toBeGreaterThan(15);
});

test("홀수만 출력한다.", () => {
  const result = [1, 2].filter((num) => apiMock(num));
  expect(result).toStrictEqual([1]);
});

test("결과값들을 출력한다.", () => {
  console.log(apiMock.mock.results);
  /*
    [
      { type: 'return', value: { name: 'Mike' } },
      { type: 'return', value: 30 },
      { type: 'return', value: true },
      { type: 'return', value: false }
    ]
  */
});

위와 같이 모의 함수는 연속 전달(continuation-passing)을 통해 편리하게 값을 주입할 수 있다. 단, 실사용시에는 모의 함수를 통해 테스트하려는 로직이 실제 구현가능한 로직인지 유의할 필요가 있다.

비동기 함수

모의 함수를 통해 Promise를 주입해줄 수도 있다.

apiMock.mockResolvedValue({ name: "Mike" });

test("호출된 이름은 Mike이다", async () => {
  const res = await apiMock();
  expect(res.name).toBe("Mike");
});

모듈 Mocking

사용자를 생성하는 api 서비스가 있다고 가정해보자.

const fn = {
  createUser: (name) => {
    return new Promise((res) => {
      console.log(`사용자가 생성되었습니다. ${name}`);
      res({
        name,
      });
    });
  },
};

이를 테스트하는 코드는 아래와 같을 것이다.

test("사용자를 생성한다", async () => {
  const userName = "Mike";
  const user = await fn.createUser(userName);
  expect(user.name).toBe(userName);
});

사용자가 생성되었습니다. Mike
Tests: 1 passed, 1 total

헌데 테스트를 실행할 때마다 사용자가 서버에 생성되게 된다.
jest.mock에 module를 인자로 넘겨주면, 해당 모듈에 모의 함수의 메서드들을 추가시켜준다.

const fn = require("./fn");

jest.mock("./fn");
fn.createUser.mockResolvedValue({ name: "Jane" });

test("사용자를 생성한다", async () => {
  const userName = "Mike";
  const user = await fn.createUser(userName);
  expect(user.name).toBe(userName);
});
    Expected: "Mike"
    Received: "Jane"
    Tests:       1 failed, 1 total

위의 예시에서 fn.createUser.mockResolvedValue({ name: "Jane" });와 같이, 모듈에 추가된 메서드를 통해 모의 반환 값을 주입하도록 하였고, 테스트에서는 실제 서비스를 콜 하는 것이 아닌 모의 반환 값을 통해 테스트된 것을 확인할 수 있다.

매처

모의 함수는 다양한 매처를 제공한다.

const fn = require("./fn");

jest.mock("./fn");
fn.createUser.mockResolvedValue({ name: "Jane" });

test("사용자를 생성한다", async () => {
  const userName = "Mike";
  const user = await fn.createUser(userName);
  expect(user.name).toBe(userName);
});

test("Mock 함수가 Mike와 함께 호출되었다", () => {
  expect(fn.createUser).toHaveBeenCalledWith("Mike");
});

test("Mock 함수가 3번 이하로 호출되었다", () => {
  expect(fn.createUser.mock.calls.length).toBeLessThanOrEqual(3);
});

위의 예시에서 toHaveBeenCalledWith나 .mock와 같은 속성들은 모의 함수를 이용할 때에만 작동한다.