Spring 개발 환경에서는 테스트 프레임워크가 JUnit으로 거의 통일된 반면
JS는 아직 Mocha와 Jest로 테스트 프레임워크가 갈리는 것 같다.
각 테스트 프레임워크의 특징과 방식을 알아보자 🙌
(JS 테스트는 처음이므로 비교적 Spring 테스트 방식이 많이 등장할 수 있다)
포스트 중간에 등장할 수 있는 용어 등의 개념과 특징 설명 등을 포함한다.
TDD, BDD, Mocking 등의 용어와 익숙한 사람들이라면 넘어가자 🙃
그래서 테스트 코드가 왜 필요할까?
실제로 동작을 확인하기 위해서는
그러므로 테스트 코드로 작성하면 위의 단점이 장점이 된다.
그래서 테스트 종류에는 뭐가 있는데?
주로 테스트는 아래와 같이 나뉜다.
이 외에도 필요시 기능 테스트(Functional Test)와 같이 다양한 테스트가 추가될 수 있다.
BDD(Behavior-Driven Development)란?
사실 스프링 기반에서 테스트를 해봤던 사람이라면 BDD는 익숙한 용어일 수 있다.
TDD(Test-Driven Development)에서 파생되었으므로, 근간은 크게 다르지 않다. 행동에 기반한 TDD와 같은 느낌.
(TDD에 설명하자면 너무 내용이 지나치게 늘어나므로 이번 포스팅에서는 다루지 말자)
대표적으로 given-when-then 방식으로 정의되며, 시나리오를 기반으로 테스트한다.
따라서 간편하게 given(주어진 조건, 데이터)-when(실행될 함수, 동작)-then(결과, 검증)로
테스트를 일관된 방식으로 작성할 수 있어 많이 사용된다.
Assertion이란?
테스트에서 Assertion은 특정 요구사항에 대한 적합성을 위한 조건이다.
필요시 한 요구사항에 대해서 여러개 존재할 수 있다.
쉽게 이해하자면 테스트의 성공 / 실패를 판단하기 위한 조건을 표현한다. (Assertion을 검사해 Validation)
Mocking과 Stubbing이란?
분명 단위 테스트는 모듈 별로 독립적으로 실행되야하는데,
테스트하고자 하는 모듈이 특정 모듈을 필요로 하면 어떻게 해야할까?
Mocking과 Stubbing은 주로 단위테스트에서 모듈이 필요로하는 다른 모듈을 처리할 때 사용한다.
길고긴 서론이 끝나고 드디어 본론으로 들어가보자 😅
테스트 러너를 포함한 테스트 프레임워크.
초기에 NodeJs로 실행되는 애플리케이션의 테스트를 위해서 설계되었다.
Assertion, Mocking, Stubbing 등의 라이브러리를 포함하지 않으므로
작동하기 위해서 다른 라이브러리의 설치 및 설정 작업이 필요하다.
단위 테스트, 통합 테스트 E2E 테스트 등 다양한 테스트를 지원한다.
특징으로는
주로 Assertion ⇒ Chai, Mocking ⇒ Sinon 라이브러리를 사용하는 게 대표적이다.
Facebook에 의해 개발된 테스트 프레임워크다.
초기에는 주로 React를 사용한 웹 애플리케이션의 JS 테스트를 위해서 개발되었다.
주로 단순함(Simplicity)에 집중한다. 현재는 Angular, Vue, NodeJs 등도 원활하게 지원한다. (심지어는 TS까지도!)
최근에는 JS 테스트 프레임워크 중 가장 많이 사용되는 추세인듯 하다.
특징으로는
특징도 알았으니 직접 사용해보자 😼
테스트는 /api/info/{id}
경로를 받은 id를 my id is {id}
로 비동기적으로 돌려주는
간단한 API를 기준으로 진행했다.
기본적인 단위 테스트와 간단한 Mocking 처리까지만 해보도록 하자!
(Service Unit Test - Service Mocking 통한 Controller Unit Test)
// node app.js와 같이 직접 실행해줄 경우 설치 필요 X
npm i -D nodemon
package.json
// 필요 라이브러리 모두 설치된 상태
{
"name": "node-test",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "mocha --verbose", // jest의 경우 jest --verbose
"start": "nodemon app.js"
},
"author": "",
"license": "ISC",
"devDependencies": {
"chai": "^4.3.6",
"jest": "^28.1.0",
"mocha": "^10.0.0",
"nodemon": "^2.0.16",
"sinon": "^14.0.0"
},
"dependencies": {
"express": "^4.18.1"
}
}
npm test
를 통해 테스트가 실행될 수 있도록 package.json에 명시해주자.
app.js
const express = require("express");
const testRouter = require("./test.router");
const app = express();
const port = process.env.PORT || 3000;
app.use("/api", testRouter);
app.listen(port, () => {
console.log(`server is listening at localhost:${port}`);
});
test.router.js
const express = require("express");
const TestController = require("./test.controller");
const testRouter = express.Router();
testRouter.get("/info/:id", TestController.getInfo);
module.exports = testRouter;
test.controller.js
const TestService = require("./test.service");
const TestController = {
async getInfo(req, res) {
const { id } = req.params;
const result = await TestService.getOneAsync(id);
res.send(result);
},
};
module.exports = TestController;
test.service.js
const getOne = (id) => {
return `my id is ${id}`;
};
const getOneAsync = (id) => {
return new Promise((res, rej) => {
setTimeout(() => {
res(`my id is ${id}`);
}, 500); // 비동기 로직 처리 위해 500ms 이후 실행
});
};
module.exports = { getOne, getOneAsync };
간단한 API를 작성했으니 먼저 비교적 심플한 Jest로 단위 테스트를 해보자.
개발 의존성으로 설치해준다.
npm i -D jest
Jest는 자동으로 디렉토리 내의 테스트 파일을 테스팅한다.
이때 파일명이 test.js
와 같은 형식도 가능한 것 같지만
이미 TestSerivice
와 같이 작성했으므로 파일명.spec.js
형식을 통해 테스트 파일을 명시해주자.
test.service.spec.js
const TestService = require("./test.service");
describe("Service Test", () => {
it("getOne() Test", () => {
const id = 324;
expect(TestService.getOne(id)).toBe(`my id is ${id}`);
});
it("getOneAsync() Test", async () => {
const id = 524;
const result = await TestService.getOneAsync(id);
expect(result).toBe(`my id is ${id}`);
});
});
describe()
통해 테스트 단위를 그룹화하고 it()
또는 test()
로 각각의 테스트를 작성할 수 있다.
expect()
뒤의 toBe()
와 같은 비교 구문을 통해 값을 검증할 수 있었다. (toBe()
외에도 다양한 함수를 제공한다)
비동기 함수 테스트도 기존에 익숙한 async/await
구문으로 바로 검증할 수 있다.
test.controller.spec.js
const TestController = require("./test.controller");
const TestService = require("./test.service");
jest.mock("./test.service");
describe("Controller Test", () => {
it("getInfo() Test", async () => {
const id = 2349;
const tReq = { params: { id } };
const tRes = { send: jest.fn() };
TestService.getOneAsync.mockResolvedValue(`my id is ${id}`);
await TestController.getInfo(tReq, tRes);
expect(TestService.getOneAsync).toHaveBeenCalledWith(id);
expect(tRes.send).toHaveBeenCalledWith(`my id is ${id}`);
});
});
TestController의 테스트를 위해서 TestService를 Mocking한다.
jest.fn()
은 한 함수를 모킹, jest.mock()
은 명시된 모듈 내 모든 함수를 모킹한다.
mockResolvedValue()
를 통해 서비스의 getOneAsync()
함수의 resolve시의 값을 미리 지정(Stubbing)한다.
JS 테스트에서 특히 신기했던 것은 request의 response를 검증하기 위해
만들어둔 response 객체의 send()
함수를 모킹하는 것이었다.
이를 통해 res.send()
로 전송되는 결과값(send의 파라미터) 검증을 할 수 있었다.
Unit Test까지는 정말 깔끔하게 작성할 수 있어서 너무 좋았다.
하지만 객체 내부의 함수처럼 복잡하게 Mocking 해야하는 경우나
Express와 같이 외부의 라이브러리와 연결될 경우 처리가 복잡해졌다.
특히 Jest는 Mocking을 함수 단위로 하는 느낌이 강했고
(주로 Serivce를 객체 취급하고 내부 함수로 구현하는 방식을 선호하는 내게는 아쉬운 점이었다)
Mocking 대상을 설정하는 부분과 모킹될 함수, 결과값을 설정하는 부분이 나뉘어있어 헷갈리기 쉬웠다.
이제 Mocha로 넘어가보자.
npm i -D mocha chai sinon
Mocha는 test/
디렉토리 내부의 파일을 테스트한다.
test/test.service.js
const chai = require("chai");
const TestService = require("../test.service");
describe("Service Test", () => {
it("getOne() Test", () => {
const id = 324;
chai.expect(TestService.getOne(id)).to.equal(`my id is ${id}`);
});
it("getOneAsync() Test", async () => {
const id = 7824;
const result = await TestService.getOneAsync(id);
chai.expect(result).to.equal(`my id is ${id}`);
});
});
chai를 사용해 assertion한다.
기본적인 테스트 문법은 Jest와 유사하지만 값을 검증하는 부분에서 약간의 문법 차이가 있었다.
(toBe()
와 같은 역할의 to.equal()
)
test/test.controller.js
const TestController = require("../test.controller");
const TestService = require("../test.service");
const sinon = require("sinon");
describe("Controller Test", () => {
it("getInfo() Test", async () => {
const id = 824;
const result = `my id is ${id}`;
const tReq = { params: { id } };
const tRes = { send: sinon.stub() };
sinon.stub(TestService, "getOneAsync").resolves(result);
await TestController.getInfo(tReq, tRes);
sinon.assert.calledWith(TestService.getOneAsync, id);
sinon.assert.calledOnce(TestService.getOneAsync);
sinon.assert.calledWith(tRes.send, result);
});
afterEach(() => {
sinon.restore();
});
});
Jest와 동일하게 비동기 문법의 테스트는 async/await
를 지원한다.
Mock/Stub을 위해서 sinon을 필요로 한다.
sinon.stub(대상, 함수명).resolves(결과값)
과 같이 Mocking 대상의 결과값을 지정할 수 있다.
함수의 호출은 sinon.assert.calledOnce()
와 같이, 파라미터 검증은 sinon.assert.calledWith()
와 같이 검증할 수 있었다.
단 주의할 점은 각 테스트가 완전히 분리되어있는 Jest와 달리
Mocha에서 사용하는 sinon의 stub()
mock()
spy()
는 초기화 해주지 않으면 다른 테스트에도 영향을 줄 수 있다.
따라서 이후에도 필요한 경우가 아니라면 sinon.restore()
와 같은 초기화 함수를 통해 사용 이후 꼭 초기화해주자.
(이것 때문에 컨트롤러 테스트 이후 서비스 테스트를 돌리면 모킹했던 결과값이 나와서 Fail을 겪었다 😵 )
테스트의 순서는 원하는 대로 보장되지 않을 수 있으므로 꼭 초기화하자!
개인적으로는 Mocking이 Mocha가 더 직관적이라는 느낌은 있었다.
하지만 sinon에서 mock()
stub()
의 구분이 뚜렷하지 않은 느낌이었고,
Jest에 비해서 초기화 처리 등 신경 써줘야할 부분이 더 존재한다.
(물론 잘 쓸 수 있다면 공통된 모킹 처리를 편하게 할 수 있을 것 같다. 사람에 따라 장점이자 단점일 수 있을 것 같다.)
그래서 Jest / Mocha 중 무엇을 써야할까?
개인적 생각으로는
Jest에서 속도와 TS 컴파일에 대한 이슈가 있는 것 같기도 한데 확실하지 않아서 포스팅 내용에 넣지 않았다.
사용해봐야 확실히 할 수 있는 부분인 것 같다.
궁금해서 검색해본 npm-trend를 마지막으로 포스팅을 마무리한다! 🖐
테스트 코드를 작성하는 이유
화이트박스 테스트 vs 블랙박스 테스트
Mocks Aren't Stubs
[tdd] 상태검증과 행위검증, stub과 mock 차이
Mocha vs. Jest: comparison of two testing tools for Node.js
Sinon.js의 spy, stub, mock 의 Best Practice
Sinon.JS
[번역] Jest Mocks에 대한 이해
다운로드 수가 연말에 한 번씩 푹 꺼지는건 크리스마스 연휴때문일까요