[Jest] 테스트 코드로 JS 의 기능 및 로직 점검하기

Quartz 쿼츠·2022년 11월 6일
15
post-thumbnail

0. Introduction

Testing이란 코드의 에러를 확인하고 추후 나타날 수 있는 이슈와 버그를 예측하는 과정이다. 지금까지의 나는 console.log나 터미널에 뜬 에러를 확인하는 방법으로 이를 대응해왔다. 더 개발자다운 방법으로 테스팅은 여러 툴을 사용하여 자동화할 수 있다. 이 글에서는 우테코 프리코스 2 주차를 수강하며 공부한 Jest에 대해 간결하게 정리해보려 한다.

1. Testing

1.1 Testing 단위에 따라 분류하기

테스트는 단위에 따라 아래와 같이 3 가지로 나눌 수 있다. 아무런 의존성 없이 하나의 로직을 테스트하는 경우를 unit test, 다른 함수에 의존성을 가진 로직을 테스트하는 경우를 integration test, 그리고 하나의 큰 기능을 테스트하는 경우를 E2E test라고 부른다.

1.2 Testing 환경

Test Runner는 일반적으로 테스트를 실행하고 결과를 보여주는 환경이다. Assertion Library를 여기에 추가하면 mock, spy, stub 등 테스트에 필요한 유틸리티를 활용할 수 있다. 더불어 Headless Browser를 사용하게 되면 실제 브라우저 환경에서 발생하는 interaction도 가상으로 테스트해볼 수 있다.

이 글에서는 Jest를 사용한 Unit + Integration test를 다룰 예정이다.

2. Jest란?

Jest는 페이스북에서 만든 JavaScript testing framework이다. config를 따로 설정하지 않아도 빠르게 테스팅 환경을 만들 수 있다는 것이 큰 장점이다. 2 장에서의 기본 개념과 코드는 코딩앙마님의 Jest 강의유데미의 JS 강의중 테스팅 모듈 파트를 참고하여 작성하였다.

2.1 설치 방법 및 사용 방법

2.1.1 Jest 설치하기

  1. $ npm i --save-dev jest

    개발자 도구이므로 -save-dev를 붙여 설치한다.

  2. $ npm test 로 테스트 실행

    package.json
      "scripts": {
        "test": "jest"
      },

2.1.2 Jest 사용하기

Jest는 .test .spec가 포함되거나 __test__ 폴더 내에 있는 파일을 감지하여 테스트를 실행한다.

예를 들어 아래와 add 함수를 테스팅한다면,

fn.js // 테스트할 함수

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

module.exports = fn; // 모듈화하여 내보내기

아래와 같이 테스트 코드를 작성할 수 있다.

  • expect(테스트할 함수).toBe(기대하는 결과값)
fn.test.js // 테스트 코드

const { default: test } = require("node:test");
const fn = require("./fn");

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

2.1 유용한 matchers

Matcher는 테스트 대상 함수에서 반환된 값을 예측하기 위해 사용한다.

2.1.1 Primitive value

예측하는 값이 원시값(number, string, boolean 등)일 때는 다음의 macher를 자주 사용한다.

  • toBe() : 해당 값과 일치하면 통과
  • toBeNull() toBeUndefined() toBeDefined() : 인 경우 통과
  • toBeTruthy() toBeFasly() : boolean 값 판별
  • toBeGreaterThan 등… : 이상, 이하, 초과, 미만
  • toMatch(/H/) : 정규 표현식으로 문자열 판단

2.1.2 Reference value

  • toEqual() : 참조값(객체, 배열)을 비교할 때. 해당 값 포함하면 true
  • toStrictEqual() : 더 엄격한 비교. 완전히 똑같아야 true
  • toContain() : 배열에서 아이템 포함되어 있는지
    • ex) expect(["a", "b", "c"]).toContain("a")

2.1.3 Error 발생 여부

테스트에서 throw new Error("Error msg") 등의 에러 발생 여부를 체크하기 위해서는 아래와 같은 코드를 작성한다. 에러 메시지를 특정하여 잡아내는 것도 가능하다.

fn.test.js // 테스트 코드
const fn = require("./fn");

test("에러가 발생하나요?", () => {
  expect(()=> fn.throwErr()).toThrow("Error number 1") // 같은 에러메시지어야 true
});

2.2 비동기 코드 테스트하기

Jest에서는 테스트 코드가 끝까지 실행되면 비동기 로직을 기다리지 않고 테스트가 종료된다. 따라서 비동기 코드는 별도로 작업을 해주어야 테스트가 가능하다.

2.2.1 Callback 패턴

  • test 함수에 done callback을 추가하여 비동기 로직이 끝나는 부분 명시해주기
  • error 감지하고 싶으면 try-catch 로 감싸야 한다.
fn.js // 테스트 대상
const fn = {
  getName: (callback) => {
    const name = "Mike";
    setTimeout(() => {
      callback(name);
    }, 3000); // 3 초 후에 이름 넘겨주기
  },
};
fn.test.js // 테스트 코드
const fn = require("./fn");

test("3 초 후에 받아온 이름은 Mike", (done) => {
  function callback(name) {
    try {
      expect(name).toBe("Mike");
      done();
    } catch (error) {
      done();
    }
  }
  fn.getName(callback); // 여기서 콜백 실행함
});

2.2.2 Promise 패턴

  • done을 넘겨주지 않아도 되어 더 간결하나, 프로미스를 return 해야함
fn.js // 테스트 대상
const fn = {
  getName: () => {
    const name = "Mike";
    return new Promise((res, rej) => {
      setTimeout(() => {
        res(name);
				// error 보내려면 rej("error");
      }, 3000); // 3 초 후에 이름 넘겨주기
    });
  },
};
fn.test.js // 테스트 코드
// resolves, rejects
test("3 초 후에 받아온 이름은 Mike", () => {
	// 방법 1
  return fn.getName().then((name) => {
   expect(name).toBe("Mike");
  });

	// 방법 2
  return expect(fn.getName()).resolves.toBe("Mike");

	// Error 잡으려면
	return expect(fn.getName()).rejects.toMatch("error"); // 3초 후에 에러가 나면 성공
});

2.3.3 Async - await 패턴

#2.3.2의 promise 패턴과 동일한 경우를 async - await 패턴으로도 작성할 수 있으며, 이 방법도 resolves matcher를 사용할 수 있다.

fn.test.js // 테스트 코드
test("3 초 후에 받아온 이름은 Mike", async () => {
  const name = await fn.getName();
  expect(name).toBe("Mike");
});

2.3 Testing 그룹화 및 전후 작업 추가하기

2.3.1 Describe 사용하기

여러 테스트 함수를 작성할 때 관련된 테스트끼리 묶기 위해 describe 함수를 사용할 수 있다.

describe("입력 A: 예외 처리 테스트", () => {
  test("공백만 입력되면 오류가 발생합니다", () => {});
  test("문자를 입력하면 오류가 발생합니다", () => {});
  test("3자리가 아닌 숫자를 입력하면 오류가 발생합니다", () => {});

});
describe("입력 B: 예외 처리 테스트", () => {
  test("1이나 2 이외의 값을 입력하면 오류가 발생합니다", () => {});
});
  • test.only() : 해당 테스트만 실행하고 싶은 경우
  • test.skip(): 해당 테스트를 건너뛰려는 경우

2.3.2 테스트 전후 작업 추가하기

관련된 테스트끼리는 동일한 사전 혹은 사후 작업이 필요한 경우가 있다. 이 경우에는 beforeEach() afterEach()를 사용한다. 만약 모든 테스트를 통틀어 한 번만 필요한 작업(ex. DB 연결)이라면 beforeAll() afterAll() 을 사용할 수 있다.

2.3.3 반복적인 테스트는 each 사용하기

validation 함수와 같이 각각 다른 문자열을 반복적으로 확인하는 테스트는 빈번히 발생한다. 이 경우 each에 배열을 인자로 하여 테스트 콜백함수에서 value를 받아 테스트를 진행할 수 있다.

test.each(["R",1,"UD"])("Moving validation 테스트", (value) => {
  expect(() => Validation.moving(value)).toThrow(INPUT_VAL.MOVING_ERROR);
});

2.4 Mock function 만들기

테스트를 위해 제작하는 모형의 함수를 mock function이라고 부른다. 모형의 함수를 사용하는 이유는 실제 함수를 구현하려면 시간이 오래 소요되고 third party package의 함수에 의한 오류로 테스트가 실패할 수도 있기 때문이다. 모형 함수는 우리가 JS 코드로 생성하거나 Jest에서 제공하는 mock 기능을 활용할 수도 있다.

2.4.1 JS로 mock funtion 만들기

API에서 데이터를 fetching해오는 함수를 사용하여 테스트를 작성한다고 가정해보자. fetching 과정에서 오류가 발생할 수도 있으므로 데이터를 받아오는 기능을 모형으로 아래와 같이 구현할 수 있다.

__mocks__/http.js //가짜함수 생성
const fetchData = () => {
  return Promise.resolve({ title: 'delectus aut autem' });
};

exports.fetchData = fetchData;

2.4.1 Jest로 mock funtion 만들기

Jest의 mock funtion을 만들기 위해서는 jest.fn()으로 함수를 할당한다. 이 함수의 mock object는 다음 정보들을 가지고 있다.

  • mock.results : 각 호출 시 리턴된 값을 배열로 저장
  • mock.calls : mock Fn의 호출 횟수, 호출 전달인수
const mockFn = jest.fn((num) => num + 1); // mock function으로 만들기

mockFn(1);
mockFn(10);
test("두번째로 호출된 함수의 첫번째 인수는 1입니다.", () => {
	console.log(mockFn.mock.calls); // [ [1], [10]]
  	console.log(mockFn.mock.calls[1][0]); // 10 
 	console.log(mockFn.mock.results); // [ { type: 'return', value: 2 }, { type: 'return', value: 11 } ]	
});

모형 함수의 로직을 아예 만들지 않고 아래와 같이 리턴값만 지정할 수도 있다. 만약 mock function을 비동기 함수로 반환하고 싶다면 mockResolvedValue를 사용하면 된다.

const mockFn = jest.fn();

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

const result = [1, 2, 3].filter((num) => mockFn(num));

test("홀수는 1,3", () => {
  expect(result).toStrictEqual([1, 3]);
});

만약 외부 함수를 mock function으로 지정한다면, 해당 함수의 실제 로직이 동작하지 않고 테스트 코드에서 지정한대로 함수가 모형으로 동작하게 된다.

2.4.3 Mock function 테스트하기

Mock function은 아래와 같은 matcher를 사용하여 호출 횟수, 호출한 인수 등을 테스트할 수 있다.

  • expect(mockFn).toBeCalled() : 1 번 이상 호출되면 참
    • toBeCalledTimes(3) : 정확히 3번 호출된 경우 참
    • toBeCalledWith(10,20) : 인수로 10 ,20을 받은 함수가 있는가?
    • lastCalledWith(30,40) : 마지막으로 실행된 함수의 인수가 30, 40 인가?

2.5 Spy function 만들기

Mock function은 함수의 로직 구현을 가짜로 하여 효율적인 테스트를 돕는다. 만약 외부에서 작성한 로직으로 테스트를 하는 경우, 해당 함수(method of object)의 호출 여부 & 호출 인자를 알아야 하는 경우가 있다. 이 경우 method에 spy를 붙인다는 의미로 spyOn을 사용하여 해당 함수를 spy function으로 만든다.

jest.spyOn(object, methodName)

test('계산기 객체의 더하기 함수 테스트', () => {
   // object
   const calculator = {
      add: (a, b) => a + b, // method
   };
 
   // calculator.add() method에 spy를 붙이기
   const spyFn = jest.spyOn(calculator, 'add');
 
   // Spy를 붙인 함수를 실행하면
   const result = calculator.add(1, 2);
 
   expect(spyFn).toBeCalledTimes(1); // 호출 횟수 테스트하기
   expect(spyFn).toBeCalledWith(1, 2); // 호출된 인자 테스트하기
});

3. 실제 사용 예시

우테코 프리코스 2 주차에서 Jest를 사용하여 docs/README.md에 정리한 기능 목록의 동작을 테스트 코드로 확인하는 작업을 하였다. 그 중 두 개의 테스트 코드를 소개해보려 한다.

3.1 Regex를 사용하여 세 자리 숫자 검증

  • 검증 대상: App object의 pickComputerNum method
  • 검증 기능
      1. 1-9 사이의 3 자리 자연수인가?
      1. 서로 다른 수로 이루어져 있는가?


검증 기능 1은 regextoMatch matcher를 사용하여 간단하게 테스트하였다. 다만 이 수가 서로 다른 수로 이루어졌는지 판별하는 정규 표현식은 찾지 못하여, 검증 기능 2는 중복 수를 판별하고 해당 배열이 비었는지 확인하는 테스트를 추가하였다.

const App = require("../src/App");

  test("중복 없는 세자리 수 고르기", () => {
    const app = new App();
    
    const num = app.pickComputerNum();
    // 검증 2. 중복 숫자 확인
    let answerArr = num.split("");
    let duplicates = answerArr.filter((value, index) => {
      return index !== answerArr.indexOf(value);
    });
    
    expect(num).toMatch(/^[1-9]{3}$/); // 검증 1. 세자리 숫자 정규표현식
    expect(duplicates.length).toBe(0); // 검증 2. 중복 array가 비어있어야 한다
  });

3.2 예외 처리의 에러 메시지 검증

  • 검증 대상: App object의 validateUserNum validateRestartNum
  • 검증 기능
    • 각 입력에 대한 예외 처리 로직이 잘 구현되었는가? > 에러 메시지 일치 여부 확인

describe("입력 A: 예외 처리 테스트", () => {
  const app = new App();
  
  test("공백만 입력되면 오류가 발생합니다", () => {
    expect(() => app.validateUserNum("  ")).toThrow("숫자를 입력해주세요");
  });
  
  test("문자를 입력하면 오류가 발생합니다", () => {
    expect(() => app.validateUserNum("가나다")).toThrow(
      "문자를 제외한 숫자만을 입력해주세요"
    );
  });
  
  test("3자리가 아닌 숫자를 입력하면 오류가 발생합니다", () => {
    expect(() => app.validateUserNum("1 3")).toThrow(
      "입력한 숫자가 3 자리가 아닙니다"
    );
    expect(() => app.validateUserNum("5984")).toThrow(
      "입력한 숫자가 3 자리가 아닙니다"
    );
  });
});

describe("입력 B: 예외 처리 테스트", () => {
  const app = new App();
  
  test("1이나 2 이외의 값을 입력하면 오류가 발생합니다", () => {
    expect(() => app.validateRestartNum("8")).toThrow(
      "1 과 2 중 하나를 입력해주세요"
    );
    expect(app.validateRestartNum(" 1 ")).toBe("1");
  });
});


마치며

이전에도 Jest를 사용해보고 싶었으나 테스트를 어디에 적용해야 하는지 감이 오지않고 테스트 코드 작성에 시간 소모가 많다고 생각하여 도입하지 못하고 있었다. 이번 테스팅 경험으로 실제로 버그가 발생하는 함수를 발견할 수 있었으며, 기능을 구현한 후에도 로직을 더 면밀히 살펴볼 수 있었다. React 프로젝트에서 Jest 도입이 가능하므로 이후 다른 웹 앱 프로젝트에서도 적용한다면 버그를 줄이는 역할을 톡톡히 해낼 것으로 예상한다.

참고 자료

Jest 공식 문서
코딩앙마 Jest 강의
Udemy JS 강의 중 Testing 모듈
regex 연습

profile
Code what we love. 좋아하는 것들을 구현하고 있는 프론트엔드 개발자입니다. 사용자도 함께 만족하는 서비스를 만들고 싶습니다.

0개의 댓글