6 분 소요

Jest란?

Jest는 React를 만든 Facebook에서 선보인 테스팅 도구이다. Jest의 철학은 zero-configuration으로, 쉽게 말해 설치 후 따로 설정을 잡아주지 않아도 바로 사용가능하다는 것이다.

JavaScript로 작성된 코드를 테스트 도구는 Jest이외에도 Jasmine, Mocha 등 다수가 있지만 현재까지는 Jest가 가장 강력하고 편리하다고 알려져있다. 그리하여 얼마나 편리하고 강력한지 한번 경험해 보고자한다. 또한, 아직 소프트웨어 테스트에 익숙하지 않지만 TDD를 지향하고자하는 나로써는 Jest를 활용한 테스트코드를 짜보려 한다. 그렇다면 과연 테스트코드를 작성하면 실제 결과물과 개발 과정이 어떻게 달라질지 몸소 느껴보고자 한다.



설치

필자는 Vue3와 그에 맞는 Framework인 Quasar2 환경에서 Jest를 설치하여 실행하고자 한다.

$ quasar ext add @quasar/testing-unit-jest@alpha

↑ 보다시피 alpha 버전이다,,, 😭 이것때문에 며칠간 고민했다ㅜㅜ beta버전만 되었어도 고민은 별로 안했을 텐데 말이지.. 어찌되었든 하고자하는 프로젝트 파일의 루트 경로로 들어가 위 명령어를 입력했다.

설치하면 _ *tests_ *디렉토리가 생성되며 그 안에 “(테스트하고자하는 파일명).test.js”의 파일을 하나 만들어준다.

cmd창에서 jest --watch 를 입력하면 _ _tests_ _ 디렉토리 내에 있는 test.js확장자로 끝나는 파일들을 모두 테스트 파일로 자동적으로 인식한다.


시작하기 앞서 간단 한 규칙은 다음과 같다.

  • expect(검증할 대상)
  • Matchers(예측 값)
  • Matchers 앞에 not을 붙이면 같지 않다. ex) not.toBe처럼 사용 가능

사용 빈도가 높은 Mathers

  • toBe(기대되는 값)
  • toEqaul(toBe와 비슷하지만, 개체나 배열 테스트를 위해 사용)
  • toStrictEqaul(toEqaul보다 엄격함)
  • toBeNull, toBeUndefined, toBeDefined(각각 Null, Underined, Define일 경우 테스트 통과)
  • toBeTruthy, toBeFalsy(각각 Truth, Falsy면 통과)
  • toBeGreaterThan(크다)
  • toBeGreaterThanOrEqual(크거나 같다)
  • toBeLessThan(작다)
  • toBeLessThanOrEqaul(작거나 같다)
  • toBeCloseTo(근사치 판별)
  • toMatch(문자열 판단 - 정규표현식: 대소문자도 구분함)
  • toContain(리스트 내 기대되는 값이 존재하는가)
  • toThrow(특정 작업에대해 에러가 발생하는가)


비동기 코드 테스트

비동기(Asynchronous) 함수란?

호출부에서 실행 결과를 가다리지 않아도 되는 함수로 Single Thread 환경에서 실행되는 언어에서 광범위하게 많이 사용됨.

callback패턴을 활용한 비동기 처리

➀ 3초 후, Mike를 내뱉는 비동기 함수 구현
const fn = {
  add: (num1, num2) => num1 + num2,
  getName: (callback) => {
    const name = "Mike";
    setTimeout(() => {
      callback(name);
    }, 3000); //3000 = 3초
  },
};

module.exports = fn;


➁ ① 의 테스트 구현
test("3초 후 받아온 이름은 Mike", done => {
    function callback(name) {
        expect(name).toBe('Mike');
        done(); //done함수를 넣어 done이 호출되기 전까지 테스트 안 끝낸다.
    }
    fn.getName(callback);
});



Promise패턴을 활용한 비동기 처리

위 callback을 사용했을 때와 비슷하게 30이라는 나이를 알려주는 함수를 Promise패턴을 활용하여 비동기로 처리해보자.

const fn = {
    add: (num1, num2) => num1 + num2,
    getName: callback => {
        const name = "Mike";
        setTimeout(() => {
            callback(name);
        }, 3000);
    },
    getAge : () => {
        const age = 30;
        return new Promise((res, rej)=>{
            setTimeout(()=>{
                res(age);
            }, 3000));
        });
    },
};

module.exports = fn;


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

위 Mike를 불러오는 코드와의 차이가 눈에 들어오는가? 바로 promise를 활용할 경우 return을 넣어줘야 한다는 것이다.


또한, 더욱 간단하게 resolves, rejects등의 Macher들을 활용 할 수도 있습니다.

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


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

module.exports = fn;

test("3초 후 에러!?", () => {
    return expect(fn.getAge()).resolves.toMatch('error');
});



async/await + Matchers 사용
const fn = {
    add: (num1, num2) => num1 + num2,
    getName: callback => {
        const name = "Mike";
        setTimeout(() => {
            callback(name);
        }, 3000);
    },
    getAge : () => {
        const age = 30;
        return new Promise((res, rej)=>{
            setTimeout(()=>{
                res(age);
            }, 3000));
        });
    },
};

module.exports = fn;
test("3초 후 나이가 30!", async () => {
  await expect(fn.getAge()).resolves.toBe(30);
});



테스트 전/후 작업

helper를 사용하여 각각의 테스트 단계에서 도움을 받을 수 있는 helper들이 존재한다. helper에는 beforeEach, beforeAll, afterEach, afterAll과 only, skip이 있는데 각각 어떤 것인지 확인해 본다.


  • beforeEach : 각각의 테스트케이스가 실행되기 직전에 동작하는 함수
  • afterEach : 각각의 테스트케이스가 실행되고 난 후 동작하는 함수
  • beforeAll : 실행하고자 하는 테스트를 모두 실행하기 전 맨 처음 동작하는 함수
  • afterAll : 실행하고자 하는 테스트를 모두 실행한 후 맨 마지막으로 동작하는 함수
  • decribe : 여러개의 테스트케이스를 묶어서 실행 가능
  • only : 여러 테스트케이스 중 test.only인 테스트 케이스만 실행. 나머지는 모두 스킵.
  • skip : test.skip을 활용하면 only와는 반대로 skip 부분만 제외하고 실행.



Mock 함수

테스트 하기 위해 흉내만 내는 함수
가끔 테스트 코드를 짜려다보면 본래 실제 코드보다 테스트코드의 양이 방대해 질 가능성이 다분한 경우가 많다. 또한, 네트워크 환경, DB 상태 등 외부 요인의 영향을 받을 받을 수도 있다.
어찌되었든, 결과적으로는 이러한 상황을 피하기 위해 테스트에서 같은 코드는 동일 한 결과를 내는 것이 매우 중요한데 이때, 사용하는 함수가 바로 Mock함수이다.

mock();이라는 함수를 만들면 mock이라는 property가 있는데 이 안에는 calls라는 배열이 존재하게 된다.

const mockFn = jest.fn();

mockFn();
mockFn(1);

test("dd", () => {
  console.log(mockFn.mock.calls); //출력값 : [[].[1]]
  expect("dd").toBe("dd");
});

위 예제를 보면 mock 함수의 강점이 드러난다. mock 함수가 호출되면 어떤 값들이 고스란히 저장되어 있는지 알 수 있다. 몇 번 호출되었는지, 어떤 인수가 전달되었는지 알 수 있으므로 이로부터 가짜(?) data를 만드는 등의 동작으로 활용가능하다.
calls로 알 수 있는 내용은 두 가지가 있다. 첫째, 함수가 몇 번 호출되었는가. 둘째, 호출될 때 전달된 인수는 무엇인가이다. 아래 예제는 calls를 활용하여 이를 증명해보고자 한다.

//mock function

const mockFn = jest.fn();

mockFn();
mockFn(1);

//의미없는 테스트이다.
test("함수는 2번 호출됩니다", () => {
  expect(mockFn.mock.calls.length).toBe(2); //pass
});
test("2번째로 호출된 함수에 전달된 첫번째 인수는 1입니다.", () => {
  expect(mockFn.mock.calls[1][0].toBe(1)); //pass
});


다음은 숫자가 들어있는 배열을 반복하며 1 증가 시켜 준 값을 callback() 함수가 전달해 주는 forEachAdd1이라는 함수를 만들어보자.

const mockFn = jest.fn();
function forEachAdd1(arr){
    arr.forEach(num => {
        mockFn(num+1)
    })
}

forEachAdd1([10, 20, 30])

test("함수 호출은 3번 됩니다.", () => {
expect(mockFn.mock.calls.length.toBe(3));
});
test("전달된 값은 1씩 증가하여 각각 11, 21, 31입니다.", () => {
expect(mockFn.mock.calls[0][0].length.toBe(11));
expect(mockFn.mock.calls[1][0].length.toBe(21));
expect(mockFn.mock.calls[2][0].length.toBe(31));
});


어떤 값을 리턴하는 함수가 필요하다고 가정해보자. 숫자를 받아서 +1을 실시한다.

const mockFn = jest.fn(num => num+1);

mockFn(10);
mockFn(20);
mockFn(30);

test("함수 호출은 3번 됩니다.", () => {
   console.log(mockFn.mock.results);
   expect(mockFn.mock.falls.length).toBe(3);
});


순서대로 console.log가 나타내는 결과값은 [{type: 'return', value: 11}, {type: 'return', value:21}, {type: 'return', value: 31}] 이와같이 배열로 나타내진다.

따라서, test 코드는 아래와 같이 작성 가능하다.

const mockFn = jest.fn((num) => num + 1);

mockFn(10);
mockFn(20);
mockFn(30);

test("10에서 1증가한 값이 반환된다.", () => {
  expect(mockFn.mock.result[0].value).toBe(11);
});
test("20에서 1증가한 값이 반환된다.", () => {
  expect(mockFn.mock.result[1].value).toBe(21);
});
test("30에서 1증가한 값이 반환된다.", () => {
  expect(mockFn.mock.result[2].value).toBe(31);
});

이런식으로 직접 함수를 만들어 확인하지 않고 mock함수를 활용해 가짜 데이터를 만들고 만들어진 데이터를 test코드에 다시 넣어 테스트에서 빨간불이 뜨는지 초록불이 뜨는지 확인이 가능하다.



mockReturnValue

const mockFn = jest.fn();

mockFn
    .mockReturnValueOnce(10);
    .mockReturnValueOnce(20);
    .mockReturnValueOnce(30);
    .mockReturnValue(40);

mockFn()
mockFn()
mockFn()

test("dd", () => {
    console.log(mockFn.mock.results);
    expect("dd").toBe("dd");
});

결과는 예상했겠지만 [{type:'return', value:10}, {type:'return', value:20}, {type:'return', value:30}, {type:'return', value:40}]이렇게 가라(?)의 데이터가 만들어졌다.

이를 활용하여 1부터 5까지 받아서 홀수만 리턴하는 함수를 한번 만들어보겠다.

const mockFn = jest.fn();

mockFn
  .mockReturnValueOnce(true)
  .mockReturnValueOnce(false)
  .mockReturnValueOnce(true)
  .mockReturnValueOnce(false)
  .mockReturnValue(true);

const result = [1, 2, 3, 4, 5].filter((num) => mockFn(num));
test("홀수는 1, 3, 5", () => {
  expect(result).toStrictEqual([1, 3, 5]); //앞서 언급했다싶이, 배열을 확인할 때는 toEqual이 아니라 toStrictEqaul을 사용해야 한다.
});

원래는 mockFn(num)이부분에 Mock 함수가 아니라 홀수인지 짝수인지 판별해주는 함수를 넣어야 한다. 하지만, 우리의 시간과 자원은 언제나 한정되어 있으므로 일단은 mock함수를 사용하고 어떤 숫자가 넘어오든간에 순서대로 true, false를 return하도록 만들었다. 결국, mock 함수는 내가 생각하는 대로 가정하여값을 입력한다고 생각하면 된다. 그 값의 data type과 상관없이 말이다. 이러한 생각을 갖고 홀수를 true라고 했을 때, truemockFn 내부가 true, false, true, false, true 순으로 떨어지므로 1(true), 2(false), 3(true), 4(false), 5(true)로 예상했을 때 모두 초록불인지 확인하면 되는 것이다.

mockResolvedValue

const mockFn = jest.fn();

mockFn.mockResolvedValue({ name: "Mike" }); //비동기함수 흉내내기

test("받아온 이름은 Mike", () => {
  mockFn().then((res) => {
    expect(res.name).toStrictEqual("Mike");
  });
});

결과는 당연히 pass이다.

mockingModule

User를 생성하는 코드를 테스트해보고 싶다. 그런데, test 동작시마다 user가 생겨버리면 곤란..😅 그렇다고 테스트가 끝날 때마다 rollback을 하는 것도 상당히 번거로울 것이다. 이럴 때 사용 하는 것이 mockingModule이다.

const fn = {
    add: (num1, num2) => num1 + num2,
    createUser : (name) => {
        console.log('실제로 사용자가 생성되었습니다.')
        return{
            name,
        };
    };
};
const fn = require("./fn");

test("유저를 만든다.", () => {
  const user = fn.createUser("Mike");
  expect(user.name).toBe("Mike");
});

결과는 console.log 실제로 사용자가 생성되었습니다.이다. 이렇게 되면 DB내에 실제로 user가 생성된 것이다. 그렇다면 어떻게 해야 할까? 간단하다. 다시 DB에 접속하여 방금 만든 test를 삭제하면 되는 것이다.
이럴 때, mock을 활용한다.

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

jest.mock("./fn"); //jest.mock을 활용하여 ./fn을 mocking 모듈로 만들어준다.
fn.createUser.mockReturnValue({ name: "Mike" });

test("유저를 만든다.", () => {
  const user = fn.createUser("Mike");
  expect(user.name).toBe("Mike");
});


fn.createUser.mockReturnValue({name: "Mike"});이렇게 작성하면 실제 fn.createUser는 호출되지 않는다. 다만, {name: "Mike"}을 반환하는 mock함수가 동작할 뿐이다.
실행해보면 테스트는 통과했고 아까 보였던 log는 찍히지 않음을 알 수 있을 것이다. 만약 의심이 든다면 3,4라인을 주석처리 하고 실행해보면 다시 log가 찍히는 것을 확인 할 수 있을 것이다.

유용한 Method 소개

  • toBeCalled() : 한 번이라도 호출되었으면 통과됨
  • toBeCalledTimes() : 정확한 호출 횟수를 의미
  • toBeCalledWith(a, b) : 인수로 어떤 값이든 체크해줌
  • lastCalledWith(a, b) : 마지막으로 실행된 함수의 인수만 체크한다.



정리

Mock 함수는 특정 기능에 집중하는 가짜함수이다!!!!라고 한마디 정리가 가능 할 것 같다.

댓글남기기