할인 로직처럼 조건 분기가 있는 코드를 리팩터링하면 경계값에서 버그가 생기기 쉽습니다. 함수를 옮기거나 이름을 바꾸는 과정에서 할인율이 1을 넘는 경우처럼 자주 실행되지 않는 경로가 깨지기도 합니다.
단위 테스트(unit test)를 작성해 두면 이런 문제를 매번 화면에서 확인하지 않고 자동으로 검사할 수 있습니다. 자바스크립트에서 단위 테스트를 시작할 때 가장 무난한 도구가 Jest입니다.
이 글에서는 Jest 설치부터 시작해 ESM 문법을 쓰기 위한 babel 설정, 첫 테스트, 비동기 테스트, mock, 비공개 함수 테스트까지 다룹니다. 글에 나오는 테스트는 모두 실제로 실행해 통과했고, 스크린샷도 그 실행 화면입니다.
글 구성은 다음과 같습니다. 먼저 작은 장바구니 모듈(테스트 대상)을 만들고 환경을 설정한 뒤, 가장 단순한 테스트부터 시작해 비동기·외부 의존성·내부 함수처럼 점점 까다로운 상황으로 넘어갑니다.
테스트 대상 만들기
테스트만 따로 떼서 설명하면 추상적이라 와닿지 않습니다. 그래서 작은 장바구니 모듈을 하나 만들고, 그걸 대상으로 테스트를 붙여보겠습니다.
// src/cart.js
// 장바구니에 담긴 상품 목록의 합계를 계산한다.
// items: [{ name, price, quantity }]
export function calculateTotal(items) {
// 배열이 아닌 값이 들어오면 일찍 막는다.
if (!Array.isArray(items)) {
throw new TypeError('items는 배열이어야 합니다.');
}
// 각 항목의 price * quantity를 더해 총액을 만든다.
return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}
// 총액에 할인율을 적용한 결과를 리턴한다.
export function applyDiscount(total, rate) {
// 비공개 헬퍼로 할인율을 0~1 범위로 보정한다.
const safeRate = clampRate(rate);
// 소수점 오차를 막기 위해 정수 단위로 반올림한다.
return Math.round(total * (1 - safeRate));
}clampRate는 할인율이 음수거나 1을 넘어가면 안전한 범위로 잘라주는 함수입니다.
// 비공개 헬퍼: export 하지 않는다. 모듈 내부에서만 쓴다.
function clampRate(rate) {
if (rate < 0) return 0;
if (rate > 1) return 1;
return rate;
}export가 붙어 있지 않다는 점에 주목해 주세요. 이런 비공개 함수를 어떻게 테스트하는지는 뒤에서 따로 다룹니다.
설치와 기본 설정
빈 폴더에서 시작한다면 먼저 프로젝트를 초기화합니다. 소스는 src 폴더에 두고, 앞서 만든 cart.js도 그 안에(src/cart.js) 둡니다.
mkdir cart-test && cd cart-test && npm init -y그다음 Jest를 개발 의존성으로 설치합니다.
npm install --save-dev jest이대로 테스트 파일에 import를 쓰면 보통 한 번 막힙니다. Jest는 기본적으로 코드를 CommonJS로 변환해 실행하기 때문에, import/export 같은 ESM 문법을 그대로 돌리려면 변환기가 필요합니다. 이 글에서는 소스 코드를 ESM으로 쓰기 위해 변환기로 babel을 함께 설치합니다.
npm install --save-dev babel-jest @babel/core @babel/preset-env@babel/core: babel의 본체입니다.@babel/preset-env: 현재 실행 환경(Node 버전)에 맞춰 최신 문법을 변환해 주는 프리셋입니다.babel-jest: Jest가 테스트를 돌리기 전에 babel로 코드를 변환하도록 이어주는 어댑터입니다.babel.config.js만 있으면 Jest가 자동으로 이 어댑터를 사용합니다.
예전 자료에는 babel 없이 안 되는 것처럼 적혀 있기도 하지만, babel은 ESM 문법을 쓰기 위한 선택지 중 하나입니다. Node의 네이티브 ESM이나 TypeScript의 ts-jest를 쓰는 방법도 있지만, 이 글에서는 설정이 짧은 babel 조합을 씁니다.
아래 두 설정 파일은 ESM(export default)으로 작성합니다. 이게 동작하려면 package.json에 "type": "module"이 필요한데, 잠시 뒤에 함께 추가합니다.
babel.config.js는 이렇게만 적어두면 충분합니다.
// babel.config.js
// preset-env가 현재 노드 버전에 맞게 import/export 등을 변환한다.
export default {
presets: [['@babel/preset-env', { targets: { node: 'current' } }]],
};다음은 Jest 설정입니다. 사실 설정 파일 없이도 돌아가지만, 환경과 커버리지 수집 대상 정도는 명시해 두는 편이 나중을 위해 좋습니다.
// jest.config.js
export default {
// 노드 환경에서 실행한다. 브라우저 DOM이 필요하면 'jsdom'으로 바꾼다.
testEnvironment: 'node',
// 커버리지를 어떤 파일에서 수집할지 지정한다.
collectCoverageFrom: ['src/**/*.js'],
};마지막으로 package.json에 스크립트를 등록합니다. 그리고 소스와 설정 파일에서 import/export default(ESM 문법)를 쓰고 있으니 "type": "module"도 함께 넣어줍니다. 최신 Node는 자동 감지로 이 설정 없이 동작하기도 하지만 매번 성능 경고가 뜨고, 환경에 따라 export default로 작성한 설정 파일을 읽을 때 Unexpected token 'export' 오류가 날 수 있어 명시해 두는 편이 안전합니다.
{
"type": "module",
"scripts": {
"test": "jest",
"coverage": "jest --coverage"
}
}이제 npm test 한 줄이면 프로젝트의 모든 테스트가 돌아갑니다. 준비 끝입니다.
첫 번째 테스트
Jest는 파일 이름이 *.test.js·*.spec.js이거나 __tests__ 폴더 안에 있으면 테스트 파일로 인식합니다(.ts·.jsx 같은 확장자도 포함입니다).
소스 옆에 cart.test.js를 두면 어떤 파일을 테스트하는지 한눈에 보입니다. 이 글에서는 이 방식을 씁니다.
// src/cart.test.js
import { calculateTotal } from './cart.js';
describe('calculateTotal', () => {
it('상품들의 가격과 수량을 곱해 합계를 구한다', () => {
const items = [
{ name: '사과', price: 1000, quantity: 3 },
{ name: '우유', price: 2500, quantity: 2 },
];
// 1000*3 + 2500*2 = 8000
expect(calculateTotal(items)).toBe(8000);
});
});세 가지 함수만 알면 일단 테스트를 쓸 수 있습니다.
describe(설명, 콜백): 관련된 테스트를 묶는 그룹입니다. 같은 함수에 대한 테스트들을 하나로 모아둡니다.it(설명, 콜백): 개별 테스트 케이스입니다.test라는 별칭도 있지만,it('...')을 영어로 읽으면 "그것은 ~한다"가 되어 자연스럽습니다. 이 글에서는it을 씁니다.expect(값).matcher(기대값): 실제 값이 기대대로인지 확인합니다.toBe가 가장 기본적인 matcher입니다.
npm test를 돌려보면 초록색 체크가 뜹니다.
matcher 골라 쓰기
toBe 하나로 모든 걸 검증할 수는 없습니다. 상황마다 어울리는 matcher가 따로 있습니다.
toBe와 toEqual의 차이
숫자나 문자열 같은 원시값은 toBe로 충분합니다. 그런데 객체나 배열은 다릅니다.
toBe는 "같은 값인가"를 Object.is(거의 ===와 같습니다)로 보기 때문에, 객체는 모양이 같아도 같은 참조가 아니면 실패합니다.
객체의 내용이 같은지 보려면 toEqual을 써야 합니다.
먼저 검증할 대상으로, 상품 객체를 만들어 주는 작은 함수를 cart.js에 하나 더 두겠습니다.
// src/cart.js
// 이름, 가격, 수량을 받아 장바구니 항목 객체를 만든다.
export function createItem(name, price, quantity = 1) {
// 수량이 1보다 작으면 잘못된 입력으로 본다.
if (quantity < 1) {
throw new Error('수량은 1 이상이어야 합니다.');
}
return { name, price, quantity };
}이제 이 함수가 늘 같은 모양의 객체를 돌려주는지 toEqual로 확인해 보겠습니다.
// src/cart.test.js
import { createItem } from './cart.js';
describe('createItem', () => {
it('이름, 가격, 수량을 담은 객체를 만든다', () => {
// 객체 비교는 toBe(참조)가 아니라 toEqual(값)을 쓴다.
expect(createItem('빵', 3000, 2)).toEqual({
name: '빵',
price: 3000,
quantity: 2,
});
});
});객체에 toBe를 쓰면 값이 같아도 참조가 달라 테스트가 실패할 수 있습니다. 단위 테스트를 처음 쓸 때 자주 겪는 실수입니다.
toThrow로 에러 확인하기
잘못된 입력을 주면 함수가 에러를 던지는지도 테스트 대상입니다. 이때는 toThrow를 씁니다.
주의할 점은 expect에 함수 자체를 넘겨야 한다는 것입니다. 함수를 호출한 결과값을 넘기면 안 됩니다. Jest가 그 함수를 try-catch로 감싸서 실행하기 때문입니다.
it('배열이 아닌 값을 넣으면 TypeError를 던진다', () => {
// () => ... 로 감싸서 "호출하는 행위"를 넘긴다.
expect(() => calculateTotal(null)).toThrow(TypeError);
// 에러 메시지의 일부 문자열로도 검증할 수 있다.
expect(() => calculateTotal('장바구니')).toThrow('배열이어야');
});toThrow에는 에러 클래스, 메시지 전체, 메시지 일부(부분 문자열), 정규식 등을 넘길 수 있습니다.
도입부에서 말한 할인율이 1을 넘는 경우처럼, 경계값을 검증하는 테스트를 미리 짜두면 리팩터링 중 버그가 생겨도 실패한 테스트로 바로 확인할 수 있습니다.
describe('applyDiscount', () => {
it('할인율을 적용한 금액을 반올림해서 돌려준다', () => {
expect(applyDiscount(10000, 0.1)).toBe(9000); // 10% 할인 -> 9000원
});
it('할인율이 1을 넘으면 1로 보정되어 0원이 된다', () => {
expect(applyDiscount(10000, 1.5)).toBe(0);
});
it('음수 할인율은 0으로 보정되어 원금 그대로다', () => {
expect(applyDiscount(10000, -0.3)).toBe(10000);
});
});테스트마다 깨끗한 상태로 시작하기 (beforeEach)
테스트마다 똑같은 장바구니 데이터를 만들어 쓰는 일이 잦습니다. 매번 복사해서 쓰면 코드가 지저분해집니다.
beforeEach는 각 it이 실행되기 직전마다 호출되어, 깨끗한 초기 상태를 만들어 줍니다.
describe('calculateTotal', () => {
let items;
beforeEach(() => {
// 테스트 사이에 데이터가 오염되지 않도록 매번 새로 만든다.
items = [
{ name: '사과', price: 1000, quantity: 3 },
{ name: '우유', price: 2500, quantity: 2 },
];
});
it('합계를 구한다', () => {
expect(calculateTotal(items)).toBe(8000);
});
it('빈 장바구니의 합계는 0이다', () => {
expect(calculateTotal([])).toBe(0);
});
});매번 새로 만든다는 점이 중요합니다. 한 테스트에서 items를 건드려도 다음 테스트는 영향을 받지 않습니다.
한 테스트가 공유 데이터를 바꾸면, 다른 테스트의 결과가 실행 순서에 따라 달라집니다. beforeEach는 이 문제를 막아줍니다.
비슷하게 afterEach, beforeAll, afterAll도 있습니다. 이름 그대로 각 테스트 뒤, 전체 시작 전, 전체 끝난 뒤에 돌아갑니다.
비동기 테스트
실무 코드 대부분은 비동기입니다. API를 부르고 결과를 기다립니다. 테스트도 그 기다림을 다룰 줄 알아야 합니다.
결제를 진행하는 함수를 예로 들어보겠습니다. 결제 기능을 함수 안에 고정하지 않고 바깥에서 끼워 넣도록(주입) 만들었습니다. 이렇게 해두면 테스트할 때 실제 결제 대신 가짜를 끼우기 쉬운데, 자세한 이유는 바로 다음 절에서 설명합니다.
// src/checkout.js
import { calculateTotal, applyDiscount } from './cart.js';
export async function checkout(items, rate, pay) {
const total = calculateTotal(items);
const finalPrice = applyDiscount(total, rate);
// 주입받은 결제 함수를 호출한다. Promise를 리턴한다고 가정한다.
const receipt = await pay(finalPrice);
return { ...receipt, finalPrice };
}비동기 테스트는 테스트 콜백을 async로 만들고 await로 결과를 기다리면 됩니다.
단언을 await 하지 않은 Promise 안에 두고 콜백이 그걸 반환하지도 기다리지도 않으면, 예전 Jest에서는 비동기 작업이 끝나기도 전에 테스트가 그대로 통과하는 문제가 있었습니다. 최신 Jest는 이런 경우를 대부분 감지해 실패시키지만, 비동기 결과는 반드시 await로 기다리는 습관을 들이는 편이 안전합니다.
아래 발췌에서 items와 pay는 같은 describe 안에 미리 준비해 둔 값이라고 보면 됩니다. items는 합계가 10000원이 되는 장바구니, pay는 다음 절에서 다룰 가짜 결제 함수(jest.fn().mockResolvedValue({ ok: true }))입니다.
it('결제가 끝날 때까지 기다린 뒤 결과를 확인한다', async () => {
const result = await checkout(items, 0.1, pay);
expect(result.finalPrice).toBe(9000);
});거부(reject)되는 Promise를 검증할 때는 rejects 매처를 씁니다. 동기 toThrow와 달리, 비동기 reject 검증은 Promise가 처리될 때까지 기다려야 하므로 expect 앞에 await를 붙입니다.
it('결제 함수가 거부되면 그 에러가 그대로 전파된다', async () => {
const pay = jest.fn().mockRejectedValue(new Error('카드 한도 초과'));
// 비동기 reject는 rejects.toThrow 로 잡는다.
await expect(checkout(items, 0, pay)).rejects.toThrow('카드 한도 초과');
});mock으로 가짜 의존성 끼우기
방금 예제에서 jest.fn()이 나왔습니다. jest.fn()은 mock 함수를 만드는 API입니다.
테스트에서 실제 결제 API를 호출하면 카드 결제가 실제로 발생합니다. 네트워크가 느리거나 서버가 죽어 있으면 테스트도 같이 실패합니다.
우리가 검증하고 싶은 것은 checkout 함수가 결제 API를 올바른 금액으로 한 번 호출하는지입니다. 결제 API 자체가 정상 동작하는지는 이 테스트의 검증 대상이 아닙니다.
그래서 실제 API 대신 가짜 함수를 끼워 넣습니다. 이게 mock입니다.
jest.fn()은 호출 기록만 남기는 빈 함수를 만듭니다. 그냥 부르면 undefined를 돌려주는데, 여기에 .mockResolvedValue(...)를 붙이면 정해진 값으로 이행되는 Promise를 돌려주도록 동작을 지정할 수 있습니다.
// src/checkout.test.js
import { checkout } from './checkout.js';
it('결제 함수에 할인된 최종 금액을 넘겨 호출한다', async () => {
// jest.fn()으로 가짜 결제 함수를 만든다. 호출 기록이 남는다.
const pay = jest.fn().mockResolvedValue({ ok: true, id: 'ORDER-1' });
const items = [{ name: '커피', price: 5000, quantity: 2 }];
// 10000원에서 10% 할인 -> 9000원
const result = await checkout(items, 0.1, pay);
// pay가 9000원으로 정확히 한 번 호출됐는지 검증한다.
expect(pay).toHaveBeenCalledTimes(1);
expect(pay).toHaveBeenCalledWith(9000);
expect(result).toEqual({ ok: true, id: 'ORDER-1', finalPrice: 9000 });
});jest.fn()이 만든 가짜 함수는 자신이 몇 번, 어떤 인자로 불렸는지 전부 기록합니다.
그 기록을 toHaveBeenCalledTimes, toHaveBeenCalledWith 같은 matcher로 들여다봅니다.
mockResolvedValue(값): 호출하면 그 값으로 이행되는 Promise를 돌려준다(성공 흉내).mockRejectedValue(에러): 호출하면 그 에러로 거부되는 Promise를 돌려준다(실패 흉내).toHaveBeenCalledWith(...): 가짜 함수가 어떤 인자로 호출됐는지 확인한다.
앞서 checkout을 만들 때 결제 함수를 인자로 받게 한 이유가 여기 있습니다. 의존성을 밖에서 주입받게 해두면 테스트에서 가짜로 갈아끼우기 쉽습니다.
모듈 전체를 가로채는 jest.mock('모듈경로')도 있지만, 이 글에서는 가능하면 주입으로 푸는 방식을 씁니다.
비공개(미export) 함수 테스트하기
맨 앞에서 clampRate를 일부러 export 없이 남겨뒀습니다. 이런 모듈 내부 함수는 밖에서 불러올 수 없으니 테스트하기 까다롭습니다.
먼저 원칙부터 말하면, 비공개 함수는 대개 직접 테스트하지 않는 게 맞습니다.
clampRate는 applyDiscount를 통해서만 외부에 노출됩니다. applyDiscount(10000, 1.5)가 0을 돌려주는지 확인하면 clampRate도 사실상 함께 검증됩니다.
비공개 함수는 구현 세부사항이고, 테스트가 세부사항에 너무 들러붙으면 리팩터링할 때마다 테스트가 깨집니다. 그래서 공개 API를 통해 테스트하는 게 기본입니다.
그럼에도 내부 로직이 복잡해서 따로 떼어 검증하고 싶을 때가 있습니다. 그럴 때 쓰는 우회법 하나를 소개합니다.
process.env.NODE_ENV는 실행 환경을 구분하는 관례적인 환경 변수인데, Jest는 이 값이 비어 있으면 테스트를 돌릴 때 자동으로 'test'로 채워줍니다(이미 다른 값이 설정돼 있으면 그대로 둡니다). 그래서 평소(운영) 실행에서는 이 조건이 거짓이 됩니다. 이걸 이용해 테스트 환경에서만 열리는 출구를 모듈에 두는 방법입니다.
// src/cart.js 맨 아래
// 테스트 전용 출구: 운영 빌드가 아닐 때만 비공개 함수를 노출한다.
export const __testables__ =
process.env.NODE_ENV === 'test' ? { clampRate } : undefined;// src/cart.test.js
import { __testables__ } from './cart.js';
describe('비공개 헬퍼 clampRate', () => {
it('0~1 범위 밖의 값을 잘라낸다', () => {
// __testables__는 NODE_ENV가 test일 때만 존재한다.
expect(__testables__).toBeDefined();
const { clampRate } = __testables__;
expect(clampRate(-1)).toBe(0);
expect(clampRate(0.4)).toBe(0.4);
expect(clampRate(2)).toBe(1);
});
});운영 환경에서는 __testables__가 undefined가 되어 테스트용 출구가 닫힙니다. clampRate 함수 코드 자체는 applyDiscount가 쓰기 때문에 번들에 그대로 남지만, 외부에서 이름으로 꺼내 쓸 수는 없습니다.
참고로 예전에는
babel-plugin-rewire라는 babel 플러그인을 깔고require('./cart').__get__('clampRate')식으로 비공개 함수를 끄집어내는 방법을 많이 썼습니다. 다만 이 플러그인은 지금은 관리가 거의 멈춘 상태라, 최신 환경에서는 위처럼 환경 변수로 출구를 여는 방식이 훨씬 단순하고 의존성도 안 늘어납니다.
한 가지 주의할 점은, CI처럼 NODE_ENV=production을 고정해 둔 채 테스트를 돌리는 환경에서는 Jest가 그 값을 덮어쓰지 않아 이 출구가 닫혀 버린다는 것입니다. 그런 경우엔 cross-env 등으로 NODE_ENV=test를 명시해 주면 됩니다.
이 방법은 정말 필요할 때만 쓰는 우회법입니다. 평소에는 공개 API로 테스트하는 것이 좋습니다.
실행 결과와 커버리지
여기까지 작성한 테스트를 모두 모아 npm test를 돌리면 다음과 같이 나옵니다. 본문에서 발췌해 설명한 것 외에 createItem·checkout까지 합쳐 12개 케이스가 함께 돌아간 결과입니다. describe로 묶은 그룹과 it으로 정의한 케이스가 트리 형태로 펼쳐지고, 통과한 테스트마다 초록 체크가 붙습니다.
"Jest 테스트 실행 결과. 12개의 테스트가 모두 통과한 화면"
테스트가 코드를 얼마나 실행했는지 보고 싶다면 npm run coverage를 씁니다. 어느 줄이 한 번도 실행되지 않았는지 표로 알려줍니다.
"Jest 커버리지 표. cart.js와 checkout.js의 statements, branch, functions, lines 커버리지 수치"
cart.js의 Branch 커버리지는 90.9%로 100%가 아닙니다. 표 오른쪽의 Uncovered Line #s 칸에는 42가 적혀 있습니다. Lines는 100%이므로 42번 줄은 실행됐지만, 그 줄의 분기 중 하나가 실행되지 않았다는 뜻입니다.
42번 줄은 방금 만든 테스트 전용 출구입니다. process.env.NODE_ENV === 'test' ? { clampRate } : undefined라는 삼항 연산에서, Jest로 돌릴 때는 NODE_ENV가 항상 'test'이므로 : undefined 쪽 분기는 한 번도 실행되지 않습니다. 운영 환경에서만 실행되는 경로라 테스트에서는 실행되지 않는 것이 당연합니다.
이렇게 커버리지 표는 테스트했다고 생각했지만 실제로는 실행되지 않은 경로가 있다는 사실을 알려주는 용도로 유용합니다.
다만 커버리지 숫자에 집착할 필요는 없습니다. 100%라도 잘못된 단언으로 채운 테스트는 의미가 없고, 80%라도 주요 경로를 검증했다면 충분합니다. 숫자는 참고용 지표일 뿐, 그 자체가 목표가 되면 곤란합니다.
정리
describe로 묶고it으로 케이스를 적고expect(...).matcher(...)로 단언하는 것이 Jest 테스트의 뼈대입니다.- 원시값은
toBe, 객체·배열은toEqual, 에러는 함수를 감싸toThrow로 검증합니다. - 비동기 테스트는 콜백을
async로 만들고await로 기다립니다. 거부는rejects매처로 잡습니다. - 외부 의존성은
jest.fn()mock으로 갈아끼우고, 호출 횟수·인자를toHaveBeenCalledWith로 확인합니다. - 비공개 함수는 우선 공개 API로 검증하고, 꼭 필요할 때만 테스트 전용 출구로 우회합니다.
마치며
테스트를 짜두면 리팩터링 중 코드가 깨졌을 때 실패한 테스트로 바로 확인할 수 있습니다.
커버리지를 처음부터 높일 필요는 없습니다. 수정하는 함수부터 테스트를 추가하면 됩니다. 긴 글 읽어주셔서 감사합니다.