시작하며
사진의 스위스 군용 칼처럼 하나의 시스템을 구성하기 위해서 우리는 많은 기능을 구현하게 됩니다. 처음에 만들어진 칼은 칼, 리머, 깡통 따게, 그리고 일자 드라이버가 전부였다고 합니다. 코르크 오프너, 가위 등 추가 도구들을 붙여 나갈 때 전에 있었던 도구들이 잘 기능하는지, 그리고 추가 도구들은 의도에 맞게 기능하는지 검사하는 것이 필요했을 것입니다. 우리의 시스템의 각각의 기능이 잘 작동하는지, 그리고 각각의 기능들의 합이 잘 맞는지 등을 확인하는 것을 통해서 시스템의 안정성을 어느 정도 보장할 수 있습니다. 단위 테스트, 통합 테스트, 시스템 테스트, 인수 테스트로 구성되는 소프트웨어 전체에 대한 테스트에서 먼저 단위 테스트(unit test)를 지원하는 자바스크립트 라이브러리와 프레임워크에 대해서 말씀을 드리고자 합니다.
단위 테스트를 왜 하느냐? 그리고 어떤 것을 지향하며 구성을 해야 하느냐? 에 대해서 docs.microsoft.com/ko-kr/dotnet/core/testing/unit-testing-best-practices 이 문서에 상세히 구성되어 있으니 한번 보시는 것도 좋겠습니다.
Jest
Jest는 Facebook에서 개발한 자바스크립트 테스트 프레임워크입니다. 프레임워크라고 부르는 것은, 과거 테스트를 수행하기 위해서 Test Run(Jasmine, Mocha) / Test Assert(Chai) / Test Mock(Sinon)를 담당하는 여러 자바스크립트 라이브러리를 사용하는 것을 Jest 하나로 대체 가능하기 때문입니다. 각각의 테스트를 위한 라이브러리가 강력한 기능을 제공하는 것도 있기에 특정 상황에 따라 각 라이브러리를 사용할 수도 있습니다. 프로젝트의 특성과 성격에 맞게 테스트를 위한 기술 스택을 잘 선택해서 혼용 없이 조화롭게 사용한다면 코드의 가독성과 스프린트 velocity의 증대에 기여할 수 있습니다.
yarn과 npm 설치 모두 지원합니다. npm의 경우 해당 설치를 통해서 package.json에 추가됩니다.
// for yarn
yarn add --dev jest
// for npm
npm install --save-dev jest
보통은 다음과 같은 스크립트 명령어를 package.json에 작성해 테스트를 수행합니다. 또한 특정 path하위의 파일들만을 수행하도록 분리할 수 있습니다.
"scritps": {
"test": "jest ./*.test.js"
...
}
보통 테스트 파일을 작성할 때 MyService.js에 대응해서 MyService.test.js 혹은 MyService.spec.js로 작성하는 것이 일반적입니다. 관련 부분은 node_modules/jest-config/build/ValidConfig.js에 존재합니다.
testMatch: ['**/__tests__/**/*.[jt]s?(x)', '**/?(*.)+(spec|test).[jt]s?(x)']
React App의 프로그래밍 환경을 구성했다면, 보통 create-react-app(create-react-app.dev)을 통해서 보일러 플레이트 코드들을 자동으로 작성하는 것이 일반적입니다. 이와 더불어서 react-script를 사용해서 Jest test환경을 구성할 수 있습니다.
// quick start
npx create-react-app my-app
cd my-app
npm start
// if already installed by using "npm install -g create-react-app"...
// then delete using below lines, and install with npx
npm uninstall -g create-react-app // - npm
yarn global remove create-react-app // - yarn
create-react-app을 통해서 react / react-dom / react-scripts / react-router / react-router-dom /... 을 설치를 합니다. node_modules/react-scripts/scripts/test.js에는 --coverage, --watchAll, --coveragePathIgnorePatterns 등 옵션 적용이 가능합니다. (create-react-app.dev/docs/running-tests) 그래서 package.json의 스크립트 명령어를 해당 부분 대신 다음과 같이 작성하기도 합니다. Jest는 test coverage-report tool인 Istnabul을 내장하고 있어서, coverageThreshold를 지정할 수 있습니다. (jestjs.io/docs/configuration#coveragethreshold-object)
"scritps": {
"test": "react-scripts test --coverage --watchAll=false --coveragePathIgnorePatterns ./src/*"
...
}
여기까지가 Jest를 설치하고 실행하는 부분이라면 하위 내용은 코드의 구성 요소들에 관한 것입니다.
Jest는 node_modules/jest-environment-node의 @jest/environment에 의해서 npm 설치 시 nodeEnvironment에 global에 함께 등록됩니다. 그래서 React에서는 자바스크립트의 내장 함수인 fetch()를 import하지 않고 사용하는 것처럼 따로 선언을 하지 않아도 사용이 가능하며, 명시적인 import를 하는 것을 필요로 한다면 다음과 같이 작성할 수 있습니다.
import {describe, expect, test} from '@jest/globals';
Runner
Jest는 각 파일에 대해서 테스트를 진행할 수 있도록 지원합니다. describe / test(it) / afterEach /...으로 테스트의 목적에 따른 수행을 진행합니다. TDD(Test-Driven Development)에 기반을 하는 BDD(Business-Driven Development)에 부합하도록 시나리오를 구성하기 위해서 필요한 Given / When / Then을 작성할 수 있습니다.
import MyService from '../MyService.js';
const myService = new MyService();
let inputParameters;
let responseMock = "success";
describe('Given - MyService', () => {
// add more codes for business logic
describe('When - calls functionA', () => {
beforeAll(() => {
inputParameters = { type: "SUCCESS" };
// functionA1 is the function called inside of functionA
myService.functionA1 = jest.fn().mockReturnValue(responseMock);
});
it('Then - returns a value', () => {
const response = await myService.functionA(inputParameters);
expect(response).toEqual(responseMock)
});
});
// it === test
describe('When - calls function B', () => {
beforeAll(() => {
inputParameters = { type: "ERROR" };
// functionB1 is the function called inside of functionB
myService.functionB1 = jest.fn().mockImplementation(() => { throw new Error(); });
});
test('Then - receives an error, and returns null value', () => {
const response = await myService.functionB(inputParameters);
expect(response).toBeNull();
});
}
});
Mocha와 같은 라이브러리에서 it을 Then의 부분으로 사용하고 있기 때문에 저는 test보다는 it의 사용을 선호하는 편입니다. methods는 jestjs.io/docs/api에 있으니 목적에 맞게 사용하시면 되겠습니다.
Assertion(Matcher)
Jest는 테스트의 호출에 대한 결과를 확인하는 작업을 지원합니다. BDD의 주목적 중 하나인 프로그래밍 결과의 검증을 expect() 메서드와 하위의 "matchers"를 사용하는 것으로 비즈니스 로직에 맞게 각각 다른 상황에서의 validation을 수행할 수 있습니다.
expect(myFunctionA).toHaveBeenCalled(); // for checking branch coverage
expect(myFunctionB).toHaveReturned(); // for checking error occurs
호출에 따른 결과 / 호출 여부 / 에러 발생 /... 등 다방면으로 검증 과정을 처리할 수 있습니다. expect() 하위의 methods는 jestjs.io/docs/expect에 있으니 목적에 맞게 사용하시면 되겠습니다.
Mocking
Jest는 unit test를 작성할 때 인프라에 대한 종속성을 도입하지 않도록 할 수 있습니다. 파일 시스템 또는 데이터베이스와 같은 외부 요인에 대해서 독립성을 가지는 것은 반복 가능성에 대한 안정성을 보장합니다. 또한 실제로 외부 서비스를 호출하는 것을 피하기 때문에 전반적인 테스트를 진행하는 시간을 감소시키게 됩니다.
Jest에서 제공하는 Mocking방식은 크게 fn() / spyOn() / mock()이 있습니다. Mocking을 하는 대상이 되는 함수는 arrow function으로 작성을 하게 되면 undefined this로 시작하는 lint error를 해결할 수 있습니다.
fn()
const myString = "RETURN A VALUE";
// returns a value
let fn1 = jest.fn().mockReturnValue(myString);
// same expression
fn1 = jest.fn().mockImplementation(() => { return myString; });
// throws an error
const fn2 = jest.fn().mockImplementation(() => { throw new Error(); });
// resolves a value
let fn3 = jest.fn().mockResolvedValue(true);
// same expression
fn3 = jest.fn().mockImplementation(() => { return Promise.resolve(myString); });
// rejects an error
let fn4 = jest.fn().mockRejectedValue(new Error());
// same expression
fn4 = jest.fn().mockImplementation(() => Promise.reject(new Error()));
fn()은 함수에 대한 모킹을 실행하며 어떤 값을 반환할 것인지를 작성자가 결정하기 때문에 본래 구성된 코드에 대해서 신경 쓰지 않아도 됩니다. 테스트 코드가 server API와 3rd party library에 대해서 독립성을 가질 수 있게 합니다. mockImplementation()을 통해서 비동기 함수의 프로미스 객체 반환에 대한 처리도 수행할 수 있습니다. 'Runner'에서 작성했던 testing code에서는 테스트 코드를 작성하지 않을 곳들에 대해서 fn() 처리를 하고 있습니다. 함수 단위의 import 이후 해당 부분을 모킹을 하게 되면, 차후 다른 방식으로 모킹을 하고자 할 때 const변수로 지정되어 있기 때문에 해당 방식이 불가능합니다. 함수 단위를 가져오는 것 대신에 서비스를 가져온 이후 myService.myFunction = jest.fn()과 같이 사용을 해서 우회하는 방식이 있겠습니다.
spyOn()
import MyService from "./MyService";
const myService = new MyService();
describe('When', () => {
it('Then', () => {
const spyFunction = jest.spyOn(myService, "myFunction");
await myService.myFunction();
expect(spyFunction).toHaveBeenCalled();
});
});
spyOn은 함수를 모킹 하지 않고 실제 함수를 가져오는 것을 의미합니다. 함수의 호출 여부와 결과를 확인하기 위해서 사용합니다. (jestjs.io/docs/jest-object#jestspyonobject-methodname)
해당 부분에 더해서 중요한 기능으로, 함수를 restore 할 수 있습니다. jest.fn()의 경우는 함수 자체를 모킹 하기 때문에, 한번 모킹 된 함수를 다시 실제 작성된 코드로 실행할 수가 없습니다. 그래서 spyOn을 통해서 해당 부분을 우회할 수 있습니다.
import MyService from '../MyService.js';
const myService = new MyService();
let inputParameters;
let responseMock = "success";
describe('Given - MyService', () => {
// add more codes for business logic
describe('When - calls functionA', () => {
beforeAll(() => {
inputParameters = { type: "SUCCESS" };
// functionB is the function called inside of functionA
myService.functionB = jest.fn().mockReturnValue(responseMock);
});
it('Then - returns a value', () => {
const response = await myService.functionA(inputParameters);
expect(response).toEqual(responseMock)
});
});
describe('When - calls functionB', () => {
it('Then - returns a value', () => {
// can't call myService.functionB because we've already mocked functionB
});
});
});
mockFn.mockRestore()를 통해서 original(non-mocked) implementation을 가져올 수 있는데, 해당 속성은 jest.spyOn()으로 만들어진 mock만 가능합니다.
import MyService from '../MyService.js';
const myService = new MyService();
let inputParameters;
let responseMock = "success";
describe('Given - MyService', () => {
// add more codes for business logic
describe('When - calls functionA', () => {
it('Then - returns a value', () => {
inputParameters = { type: "SUCCESS" };
// functionB is the function called inside of functionA
// !Important
const spyFunction = jest.spyOn(myService, "functionB").mockImplementation(() => responseMock);
const response = await myService.functionA(inputParameters);
expect(response).toEqual(responseMock);
spyFunction.mockRestore();
});
});
describe('When - calls functionB', () => {
beforeAll(() => {
inputParameters = { type: "Error" };
// functionC is the function called inside of functionB
myService.functionC = jest.fn().mockImplementation(() => { throws new Error(); });
});
it('Then - returns null', () => {
const response = await myService.functionB(inputParameters);
expect(response).toBe(null);
});
});
});
mock()
import MyService from './MyService';
const myService = new MyService();
jest.mock("myService");
describe('When', () => {
beforeEach(() => {
// functionB : inside of functionA
myService.functionB.mockClear();
});
it('Then', () => {
myService.functionB.mockReturnValue("My Return");
const response = await myService.functionA();
expect(response).toBe("My Return");
});
});
jest.fn(), 그리고 jest.spyOn()을 이용해 각각의 서비스 안의 함수들을 모킹 하는 것에 더 나아가서 jest.mock()을 통해서 하나의 모듈 전체를 모킹 할 수 있습니다. 자동으로 모듈 속의 함수들이 모두 모킹 되기 때문에 그 이후 각각의 함수에 대해서 반환 값을 지정할 수 있습니다. 여러 개의 모듈을 불러와서 모킹을 하는 상황에 효과적입니다.
Jest는 적절하고 올바른 방식을 사용한다면, 다른 라이브러리 없이도 효과적으로 모든 테스트 시나리오를 구성할 수 있습니다. 또한 Enzyme / react-test-renderer / react-testing-library와 함께 사용해서 DOM에 대한 unit test를 진행할 수 있습니다. 해당 테스팅에 대해서 #3에서 소개해보도록 하겠습니다.
※ 잘못된 부분이나 궁금한 점 댓글로 작성해 주시면 최대한 답변하겠습니다. 읽어주셔서 감사합니다.
※ Javascript Testing #2 - Mocha, Chai, Sinon에서 각각을 소개해 볼 예정입니다.
'React > Testing' 카테고리의 다른 글
Javascript Testing #2 - Mocha, Chai, Sinon (0) | 2021.03.17 |
---|