
우아한테크코스 과제를 진행하며 혼자만의 추가 목표를 세워서 진행했다.
“모든 미션을 TDD로 진행해보자!”
테스트 작성 → 개발 → 리팩토링의 사이클을 가져가며 테스트 코드 작성에는 Jest를 사용했다.
이렇게 테스트 코드를 작성하다 보니 한 가지 궁금증이 생겼다.
"테스트 프레임워크는 어떻게 동작할까?"
평소에도 다양한 npm 라이브러리를 사용하며 나도 나만의 라이브러리를 만들고 싶은 마음이 있었고,
이번 기회에 테스트 라이브러리를 제작해보기로 했다.
이 프로젝트는 Jest에서 영감을 받아 제작했기 때문에 많은 부분을 Jest를 참고하여 제작함.
해당 포스트는 전체 코드보다는 핵심 로직만 설명을 하고 있기 때문에 전체 코드가 궁금한 경우 깃허브를 참고
가장 먼저 npm 패키지를 생성한다.
npm init을 실행하면 다양한 설정값을 입력하게 되는데, 프로젝트 상황에 맞게 설정하면 된다.
npm init
package.json의 핵심 설정은 다음과 같다.
{
"name": "@dannysir/js-te",
"version": "0.0.1",
"type": "module",
"main": "index.js",
"bin": {
"js-te": "./bin/cli.js"
}
}
type: "module": ESM(ES6 Modules) 방식 사용binnpm test 또는 js-te 명령어를 실행했을 때 호출되는 진입점이다.bin에 지정한 파일에서 테스트 파일을 찾고 실행하는 로직을 구현해야 한다.@dannysir/js-tejs-te로 등록했지만, npm에 이미 유사한 이름(jste)이 존재하여 배포가 불가능했다.@가 붙은 패키지 이름이 scoped package다)Scoped package는
@사용자명/패키지명형태로 자신만의 네임스페이스를 가질 수 있게 해주는 방식이다.
우리가 bin 으로 진입점을 지정해도 어떻게 실행시킬지 표시하기 위해 Shebang이 필요하다.
#!/usr/bin/env node
import fs from 'fs';
// ... CLI 로직
왜 필요할까?
#!/usr/bin/env node)은 Unix/Linux 시스템에 "이 파일을 node로 실행해라"라고 알려준다.js-te 명령어를 입력하면 bin 파일을 진입점으로 들어올 것이고 이후 Shebang을 통해 파일을 node로 실행할 것이다.그럼 /usr/bin/env 는 뭘까?
#!/usr/local/bin/node // ❌ node 위치가 다르면 실패
#!/usr/bin/env node // ✅ PATH에서 node를 찾음
사용자마다 node 설치 위치가 다를 수 있다.
env를 사용하면 시스템의 PATH에서 node를 찾아 실행한다.
이제 cli.js 파일을 채워보자.
cli.js에서 진행해야할 작업을 정리하면 다음과 같다.
아래는 디렉토리에서 테스트 파일을 찾는 로직이다.
찾는 규칙
.test.js 파일test/ 폴더 안의 모든 .js 파일node_modules는 제외const findTestFiles = (dir) => {
const files = [];
const walk = (directory, inTestDir = false) => {
const items = fs.readdirSync(directory);
const isTestDir = path.basename(directory) === 'test' || inTestDir;
for (const item of items) {
if (item === 'node_modules') continue;
const fullPath = path.join(directory, item);
const stat = fs.statSync(fullPath);
if (stat.isDirectory()) {
walk(fullPath, isTestDir);
} else if (item.endsWith('.test.js') || isTestDir) {
if (item.endsWith('.js')) {
files.push(fullPath);
}
}
}
};
walk(dir);
return files;
};
이제 cli.js에서 테스트 파일은 다 찾았다. 그럼 이제 테스트 구조를 설계해보자.
우선 사용자가 등록한 test 와 description 가 순차적으로 실행되야 하고
추후에 추가될 기능을 위해 스택 구조로 테스트 훅을 저장했다.
describe : 현재 depth를 기록하고 내부 함수를 실행test : 테스트 정보를 배열에 저장export class Tests {
#tests = []; // 테스트 훅 저장 스택
#testDepth = []; // 다중 description 사용시 경로 표시를 위한 스택
test(description, fn) {
const testObj = {
description,
fn,
path: this.#testDepth.join(' > ')
};
this.#tests.push(testObj);
}
describe(suiteName, fn) {
this.#testDepth.push(suiteName);
fn(); // 내부 테스트들이 등록됨
this.#testDepth.pop();
}
}
등록된 테스트들을 순회하며 실행 - 결과 - 출력
테스트 결과 !== 예상 값 => throw new Error
아래 보이는 try/catch 문을 통해 처리
export const run = async () => {
let passed = 0;
let failed = 0;
for (const test of tests.getTests()) {
try {
await test.fn();
console.log(green('✓ ') + test.path + test.description);
passed++;
} catch (error) {
console.log(red('✗ ') + test.path + test.description);
console.log(red(` Error: ${error.message}`));
failed++;
}
}
return { passed, failed };
};
matcher는 테스트의 기댓값을 검증하는 메서드다. 다음과 같은 방식으로 동작한다:
toBe: ===로 원시값 비교toEqual: JSON.stringify로 객체/배열의 내용 비교toThrow: 함수 실행 후 에러 발생 여부 확인toBeTruthy / toBeFalsy: 참/거짓 여부 확인구현 로직
expect()는 객체를 반환하고, 각 matcher 메서드는 검증에 실패하면 에러를 던진다.runArgFnc()는 함수가 전달된 경우 실행하여 결과값을 얻는다.export const expect = (actual) => {
let value = actual;
const runArgFnc = (actual) => {
let value = actual;
if (typeof actual === 'function') {
value = actual();
}
return value;
};
return {
toBe(expected) {
value = runArgFnc(actual);
if (value !== expected) {
throw new Error(getErrorMsg(expected, value));
}
},
toEqual(expected) {
value = runArgFnc(actual);
if (JSON.stringify(value) !== JSON.stringify(expected)) {
throw new Error(getErrorMsg(expected, value));
}
},
toThrow(expected) {
try {
value = runArgFnc(actual);
} catch (e) {
if (!e.message.includes(expected)) {
throw new Error(getErrorMsg(expected, e.message));
} else return;
}
},
toBeTruthy() {
value = runArgFnc(actual);
if (!value) {
throw new Error(getErrorMsg(true, value));
}
},
toBeFalsy() {
value = runArgFnc(actual);
if (value) {
throw new Error(getErrorMsg(true, value));
}
},
};
};
이제 사용자가 test(), describe(), expect()를 사용할 수 있도록 공개 API를 정의한다.
// index.js
import { Tests } from "./src/tests.js";
const tests = new Tests();
export const test = (description, fn) => tests.test(description, fn);
export const describe = (suiteName, fn) => tests.describe(suiteName, fn);
export const expect = (actual) => {
// ... expect 로직
};
그리고 package.json 파일의 exports 옵션을 설정하여 해당 파일을 패키지의 진입점으로 지정한다.
{
"exports": {
".": "./index.js"
}
}
이제 사용자는 다음과 같이 라이브러리를 사용할 수 있다
import { test, describe, expect } from '@dannysir/js-te';
이제 우리는 다음과 같이 테스트 코드를 작성할 수 있다.
import { test, describe, expect } from '@dannysir/js-te';
describe('Math', () => {
test('works', () => {
expect(1).toBe(1);
});
});
하지만 Jest 코드를 보면 jest는 위에서 보이는 import문이 없어도 사용할 수 있는 것을 알 수 있다.
import문 없이 사용하려면 어떻게 해야할까?
이를 해결하기 위해 우리가 진입점으로 설정한 cli.js에서 전역 객체에 미리 함수들을 등록했다.
describe, test, expect 등이 전역에 등록// cli.js
import * as jsTe from '../index.js';
// 모든 공개 API를 전역에 등록
Object.keys(jsTe).forEach(key => {
global[key] = jsTe[key];
});
beforeEach 구현하기이제 간단한 matcher를 통해 테스트를 진행할 수 있다.
이제부터는 추가적으로 필요한 기능들을 하나씩 추가해보자.
테스트 코드를 작성하다 보면 각 테스트 전에 실행해야 하는 전처리 로직이 자주 필요하다.
따라서 다음 단계로 beforeEach를 구현해보자.
구현 로직
test 등록 시 현재의 beforeEach 훅들을 저장describe는 상위의 beforeEach를 모두 실행describe 종료 시 배열 길이를 복원하여 중첩 구조 대응export class Tests {
#beforeEachArr = [];
beforeEach(fn) {
this.#beforeEachArr.push(fn);
}
test(description, fn) {
const beforeEachHooks = [...this.#beforeEachArr];
const testObj = {
description,
fn: async () => {
// beforeEach 훅들을 먼저 실행
for (const hook of beforeEachHooks) {
await hook();
}
await fn();
},
path: this.#testDepth.join(' > ')
};
this.#tests.push(testObj);
}
describe(suiteName, fn) {
this.#testDepth.push(suiteName);
const prevLength = this.#beforeEachArr.length;
fn();
this.#beforeEachArr.length = prevLength; // describe 종료 시 복원
this.#testDepth.pop();
}
}
test.each 구현하기다음으로 자주 사용하는 기능을 생각해보니 each()가 떠올랐다.
그런데 구현을 위해 설계하며 고민이 되는 부분이 두 가지 있었다.
each()에 따른 다양한 플레이스홀더가 있다. 이 부분을 어떻게 구현할까?test는 테스트 코드를 등록하는 함수인데, 여기에 test.each를 어떻게 추가할 수 있을까?each 와 플레이스홀더Jest에는 %i(정수), %d(숫자), %f(부동소수점) 등 다양한 플레이스홀더가 있다.
하지만 이들은 모두 테스트 설명 문자열에 값을 삽입하는 용도이고,
JavaScript에서는 결국 문자열로 변환되기 때문에 %s로 대부분의 케이스를 커버할 수 있다.
따라서 실질적으로 구분이 필요한 %s(문자열/숫자)와 %o(객체)만 우선 구현했다.
%s: 문자열/숫자%o: 객체 (JSON.stringify)export class Tests {
/* 생략 */
testEach(cases) {
return (description, fn) => {
cases.forEach(testCase => {
const args = Array.isArray(testCase) ? testCase : [testCase];
this.test(this.#formatDescription(args, description), () => fn(...args));
});
};
}
#formatDescription(args, description) {
let argIndex = 0;
return description.replace(/%([so])/g, (match, type) => {
if (argIndex >= args.length) return match;
const arg = args[argIndex++];
switch (type) {
case 's':
return arg;
case 'o':
return JSON.stringify(arg);
default:
return match;
}
});
}
}
그렇다면 test에서 each 메서드를 어떻게 호출하게 만들 수 있을까?
코드를 보면 생각보다 간단하다.
// index.js
import { Tests } from "./src/tests.js";
const tests = new Tests();
export const test = (description, fn) => tests.test(description, fn);
test.each = (cases) => tests.testEach(cases);
이게 어떻게 가능할까?
그 이유는 JavaScript에서 함수도 객체이기 때문이다.
JavaScript의 함수는 Function 생성자의 인스턴스이며, 내부적으로 Call 메서드를 가진 호출 가능한 객체다.
test.each()의 사용 예시를 살펴보자.
test.each([
[1, 2, 3, 6],
[3, 4, 5, 12],
[10, 20, 13, 43],
[10, 12, 13, 35],
])('[each test] - input : %s, %s, %s, %s', (a, b, c, result) => {
expect(a + b + c).toBe(result);
});
/*
✓ [each test] - input : 1, 2, 3, 6
✓ [each test] - input : 3, 4, 5, 12
✓ [each test] - input : 10, 20, 13, 43
✓ [each test] - input : 10, 12, 13, 35
*/
test.each([
[{ name: 'dannysir', age: null }],
])('[each test placeholder] - input : %o', (arg) => {
expect(arg.name).toBe('dannysir');
});
/*
✓ [each test placeholder] - input : {"name":"dannysir","age":null}
*/
여기까지 테스트 라이브러리의 기본 구조와 핵심 기능들을 구현했다.
이후에는 모킹(mocking) 기능을 구현하는 과정을 담을 계획인데
생각보다 분량이 길어져서 나눠서 가려고 한다.