들어가기 전에
테스팅에는 크게 두 가지 개념이 있습니다. 바로 단위 테스팅(Unit Testing)과 통합 테스팅((Integration Testing)가 그것입니다. 개념은 간단합니다. 단위 테스팅은 기능들을 최대한 잘게 쪼개어 그 기능이 주어진 파라미터나 상황 등에서 잘 동작하는지, 에러를 일으켜야 할 상황에서 에러를 잘 일으키는지에 대한 테스트입니다. 통합 테스팅은 단위 테스팅으로 테스트가 완료된 것들을 한꺼번에 모아 그 코드가 실제 프로덕션에서 돌아가는 것처럼 하여 테스트해보는 것입니다. 통합 테스팅도 얼마나 통합시킬지, 어디서부터 통합시킬지에 해서 방법론이 갈립니다만, 테스팅에 관한 이론은 여기서 자세히 들어갈 것은 아니기에 단위 테스트 블로그 글, 통합 테스트 블로그 글에서 이론을 공부해보아요.
필자는 처음에 테스팅을 크게 고려하지 않았습니다. 왜냐하면 너무 어려울 것 같기 때문이었죠! 근데 어렵기는 커녕 이것이 반드시 필요한 과정이었음을 깨닫게 되었습니다. 직접 해보니 그렇게 어렵지도 않았고 장점이 굉장히 많았습니다. 테스팅을 해 놓으면 자기가 작성한 코드에 신뢰가 가며 앞으로의 코딩이 좀 더 예측 가능해진달까요. Node.js 에서 테스팅 프레임워크는 Jest, Mocha, Jasmine 등이 있는데 저는 Mocha를 선택했습니다. 선택에는 크게 어떤 기준은 없습니다. 하나도 써보지 않은 상태에서 이것 저것 기능을 따지는 건 노력 낭비이지 않을까 싶어요.. 하하. Mocha 를 통한 전반적인 테스팅 글(모던 Javascript 튜토리얼)을 참조하면 더 좋을 것 같습니다. (사실 지금 포스팅을 하는 과정에서 찾아본 건데 꽤 알찬 내용인 것 같아요.) Mocha 는 별도의 Assertion (조건에 따라 올바르다 틀리다 판단해주는) 라이브러리가 필요하여 Chai 를 골랐습니다.
작성된 코드는 효율적이거나 프로덕션에 좋다거나 하는 것과 거리가 있을 수 있음을 미리 말씀드려요. 그리고 단위 테스팅에 대한 부분만 조금 커버했을 뿐, 통합 테스팅과 관련된 내용은 정말 없습니다! 말 그대로 맨 땅에 헤딩이기 때문에 아, 그냥 이렇게도 개발이 가능하구나 하고 가볍게 읽어주시면 감사하겠습니다. BDD와 TDD의 차이도 잘 모르는 얄팍한 지식이라는 점을 미리 알립니다 호호~
테스팅도 생산성에 영향을 크게 미치지만, 개발할 때 jsdoc 로 자바스크립트의 함수나 변수 타입을 명시해주니, vscode 에디터가 멤버 변수 목록을 출력해준다든지 등의 편의 기능을 누릴 수 있어 아주 좋았습니다. 관심이 있으시다면 해당 포스팅도 참조해주세요.
실전 개발
eslint 세팅
어떻게 쓰는 지 감이 안오니, 린팅으로라도 도움을 받자 싶어서 mocha 와 chai 관련 eslint 플러그인(extends
)을 적용하였습니다. (사실 아직 plugin
, extends
, env
의 차이를 잘 모름..)
{
extends: [
'eslint:recommended',
'plugin:mocha/recommended',
'plugin:chai-friendly/recommended',
],
env: {
mocha: true,
},
}
describe
와 it
의 쓰임새
describe 뜻은 묘사하다 이지요? 그러니 검사 대상을 명시하는 겁니다. 여기는 뭐 실제 객체가 들어간다거나 그런 게 아니라 단순히 문자열 텍스트로 무엇을 테스트하는지를 알려줍니다. it
에는 해당 테스트 대상의 행동이나 상태가 이러이러해야 한다 라는 것을 명시해줍니다. ~~해야 한다는 뜻을 가진 영문장 “it should be ~~~ when ~~~” 에서 흔히 it 이 첫 번째 단어로 흔히 들어가기 때문에 it
을 함수명으로 사용하는 듯 합니다. 저는 한국인이기 때문에 it
의 메시지로 한글을 넣었습니다.
describe('order', function () {
describe('db', function () {
describe('getOrder', function () {
// ...
});
describe('getOrders', function () {
beforeEach('Order 미리 만들어놓기', async function () {
// ...
});
it('기본 동작이 제대로 되어야 함', async function () {
// ...
});
it('날짜가 제대로 동작되어야 함', async function () {
// ...
});
it('페이지가 제대로 동작되어야 함', async function () {
// ...
});
it('status 제대로 동작되어야 함', async function () {
// ...
});
});
describe('createOrder', function () {
it('제대로 동작해야 함', async function () {
// ...
});
});
describe('updateOrder', function () {
it('제대로 동작해야 함', async function () {
// ...
});
});
describe('removeOrder', function () {
it('제대로 동작해야 함', async function () {
// ...
});
});
});
});
의존성 주입(Dependency Injection) 패턴을 활용하고 Mock 객체를 활용하자
// payment.js
module.exports = {
make: (db, bootpay) => {
return new PaymentService(db, bootpay);
},
};
유닛 테스팅을 할 때에는 테스트 대상이 의존성이 없으면 없을수록 더 편안합니다. 필자는 특정 자원이나 모듈을 이용하고자 하는 단계를 Manager 로 두고, Manager 를 여러 개 섞어서 실질적인 Business Logic 에 해당하는 Service 를 만들었습니다. 그리고 위 코드는 Payment 라는 Service 를 만들 때 필요한 DB Manager, Bootpay Manager 를 직접 내부에서 import 하여 불러오는 방식이 아니라, Payment Service 를 새롭게 만드는 입장에서 make
함수를 통해 Manager를 전달받는 형식입니다. 이렇게 하면 두 개의 Manager가 제대로 동작해야 한다는 책임으로부터 Service 가 해방된다는 것입니다. 이는 가짜(Mock) Manager 객체를 적당히 만들어 PaymentService
에 넘겨주고 테스팅하기 용이합니다. 아래 이미지를 참조해주세요.

DI(의존성 주입)과 관련된 내용은 다른 블로그의 글을 참조해주세요. 필자는 깊이 설명할 능력이 없습니다… DI는 특히 자바에서 Spring 앱을 만들 때 흔히 사용하는 개념이라서 예제 코드가 거의 자바일 것입니다. 하하. 하여튼 Mock 객체를 직접 만드는 방법도 있겠지만, 세상은 넓고 편리한 라이브러리는 많습니다. 필자는 sinon 이라는, test spies, stubs and mocks 를 편리하게 제공해주는 프레임워크를 사용했습니다.
const sinon = require('sinon');
const filename = "abc.txt";
const fullpath = "upload/abc.txt";
const dbTest = {
createFile: sinon.fake.returns({ filename, path: fullpath }),
removeFile: sinon.fake(),
};
const fileMgr = {
removeFile: sinon.fake((fn) => {
if (fn !== filename) throw Error('파일이 존재하지 않습니다.');
}),
};
// (중략)
const file = fileServiceFactory.make(dbTest, fileMgr);
file
.removeFile('abcde')
.then((/* result */) => {
expect(dbTest.removeFile.firstCall.args[0]).to.equal('abcde');
expect(fileMgr.removeFile.firstCall.args[0]).to.equal(fullpath);
done();
})
.catch((err) => {
done(err);
});
위 코드는 예제입니다. sinon.fake()
로 가짜 함수를 만들어내면, 어떻게 얼마나 호출되었는지를 기록하며, 어떤 임의의 값을 리턴하기도 할 수 있습니다. 그 외에도 문서를 확인하면 사용할 수 있는 기능이 방대하니까 강력한 기능을 다 활용할 수 있기를 바랍니다.
Mock 객체를 활용할 때에 문제점이 없는 건 아닙니다. 만약 어떤 객체를 정의하는 방법이나 함수를 호출하는 방법이 추후에 달라진다면, 해당 객체/함수를 흉내냈던 가짜 객체들의 인터페이스를 모두 통째로 수정해야 하는데, 프로그램 규모가 크고 복잡해지면 좀 심각한 문제가 될 거 같습니다. Talkdesk 의 한 개발자의 글에서는 mock 을 최대한 쓰지 말라고 하기는 하네요. 관리하기 힘들다고.
Mocha의 HookFunction 을 이용해 테스트 로직을 분리해보자
여전히 이 방법이 좋은 건지 아닌지는 의문이 듭니다만, 적어도 중~소규모에서 효율적으로 테스트 로직을 분리할 수 있는 방법이라고 생각합니다. Mocha 에서 자주 쓰이는 HookFunction 은 Before
, BeforeEach
, After
, AfterEach
등이 있습니다. describe
와 it
로 정의되는 각 테스트의 시작과 끝에서 반복적으로 실행되는 코드를 HookFunction 로 중복 없이 관리할 수 있습니다. 하지만 이러한 HookFunction 을 반복해야 하는 상황이 생겼습니다. 테스트용 MongoMemoryServer
를 구동시키는 코드가 거의 모든 테스트에서 before
, beforeEach
등으로 반복되는 상황이었지요! 이를 해결하기 위해서 다음과 같이 해결했습니다.
// util.js
const testDatabaseServer = (hookFunctions) => {
const mongod = new MongoMemoryServer({ binary: { version: '4.2.9' } });
hookFunctions.before('db 초기화', async function () {
// 중략
});
hookFunctions.beforeEach('유저 세팅', async function () {
// 중략
});
hookFunctions.afterEach('db 내용 초기화', async function () {
// 중략
});
hookFunctions.after('서버 및 db 종료', async function () {
// 중략
});
return mongod;
};
module.exports = {
testDatabaseServer,
}
// order.spec.js
const { testDatabaseServer } = require('./util');
describe('order', function () {
// eslint-disable-next-line mocha/no-setup-in-describe
const mongod = testDatabaseServer({ before, beforeEach, after, afterEach });
describe('기타 등등', etc);
});
Mocha 가 내부적으로 어떻게 HookFunction 호출의 주체를 관리하는지는 잘 모르겠지만, 이런 HookFunction 들을 통째로 납치해서 다른 곳에서 호출하는 코드를 만들어버리고, 이를 재사용하자는 아이디어였습니다. 이 코드는 필자의 큰 번거로움을 덜어주었습니다. order.spec.js
뿐만 아니라 다른 테스트에서도 비슷한 로직을 이용할 수 있게 되었거든요!
supertest agent 를 이용해 쿠키를 가지는 브라우저의 행동을 테스트하자
쿠키-세션 테스트를 할 때 깊은 고민이 들었습니다. 왜냐하면 단순한 HTTP 요청 응답 테스트는 axios 로 진행하면 충분한데, 로그인 로그아웃 테스트를 할 때에는 해당 유저의 신원을 확인하기 위해서 쿠키에 저장된 세션 id 값을 유지시켜줄 장치가 필요했던 것이죠. 요청을 보낼 때는 요청 헤더에 Cookie
를 적절히 보내주어야 하고 받을 때에는 응답 헤더에서 set-cookie
를 적절히 읽어와 처리하는 과정이 필요하겠습니다. 그 과정이 너무 복잡하다 이겁니다. 실제 브라우저에서 일어나는 일을 흉내내기 위해 그렇게까지 Low Level 로다가 구현을 해야 할까, 분명 나랑 같은 고충을 가진 사람이 있을 것이다! 라고 인터넷을 뒤졌지요. 한편으론 가상 브라우저를 띄우는 방식으로 진행하면 어떨까 싶었는데 너무 복잡하여 포기했습니다. 마침 눈에 들어온 것이 supertest 에서 agent 의 기능이었습니다.
/**
* supertest 의 agent 기반으로 graphql 요청을 보냅니다.
* 요청주소는 /graphql 로 고정입니다.
* @param {import("supertest").SuperAgentTest} agent
* @param {string} query
* @param {object} variables
*/
const graphqlSuper = async (agent, query, variables) =>
new Promise((resolve, reject) => {
agent
.post('/graphql')
.set('Content-Type', 'application/json')
.set('Accept', 'application/json')
.withCredentials()
.send(
JSON.stringify({
query,
variables,
}),
)
.expect(200)
.end((err, res) => {
// console.log(`status: ${res.status}`);
const errors = res?.body?.errors;
if (errors === null || errors === undefined) return resolve(res);
return reject(errors);
});
});
withCredentials
함수가 정확히 어떻게 동작하는지 꼼꼼히 체크는 못했지만, 이렇게 하니 쿠키를 유지한 채로 테스트를 진행해볼 수 있었습니다.
결론
툴을 자세히 공부한 다음 실전에 적용하자… 저 코드를 실제로 사용할 때에는 나름 꿀팁이라고 생각했는데 글을 쓰고 나니까 그다지 영양가있지는 않았네요.