단위 테스트 (Jest 사용)
Jest를 설정하고 matcher·setup·teardown·비동기 검증을 사용해 함수와 모듈의 작은 동작을 자동 확인합니다.
우리는 9장에서 Fetch API, 웹 스토리지, History API, Canvas API,
웹/서비스 워커까지 다루며 동적인 웹 애플리케이션을 만드는 방법을 익혔습니다.
이제 단순 기능 구현을 넘어서 안정성을 확보할 단계입니다.
관건은 여기서부터입니다.
코드 규모가 커질수록 의도대로 동작하는지를 눈으로 확인하기 어려워집니다.
기능 하나를 고치면 다른 부분이 깨지는 사이드 이펙트도 자주 생깁니다.
이 문제를 예방하는 핵심 도구가 테스팅(Testing)입니다.
10장 테스팅과 디버깅에서는 코드 품질을 체계적으로 검증하는 방법을 다룹니다.
첫 주제는 가장 작은 단위를 검증하는 단위 테스트(Unit Testing)와 대표 도구인 Jest입니다.
웹 개발에서 코드가 잘 작동한다는 것은 에러가 없다는 뜻만이 아닙니다.
계산 함수라면 같은 입력에 같은 결과를 반환해야 하고, 상태를 바꾸는 함수라면 약속한 상태 변화만 일으켜야 합니다. 현재 시각이나 서버 응답에 의존하는 코드는 그 조건도 테스트에서 통제해야 합니다.
단위 테스트는 함수, 메서드, 작은 모듈을 독립적으로 검사합니다.
그래서 문제가 생겼을 때 원인을 빠르게 좁힐 수 있고, 수정 이후 회귀 버그를 줄이는 데 큰 도움이 됩니다.
이번 장에서는 자바스크립트 생태계에서 폭넓게 사용되는 Jest 테스트 프레임워크로 단위 테스트를 작성하는 방법을 배웁니다.
함수의 반환값, import/export, 배열·객체 비교, throw, Promise와 async/await를 알고 있으면 예제를 따라갈 수 있습니다. 회귀 버그는 이전에 되던 동작이 수정 후 깨지는 문제이고, 단언(assertion)은 실제 결과가 기대 결과를 만족하는지 판정하는 문장입니다. 기대값은 구현을 그대로 복사해 계산하지 말고 요구사항이나 손으로 계산한 결과에서 정합니다.
Jest 설치 및 기본 설정
Jest를 사용하기 위해서는 먼저 프로젝트에 Jest를 설치해야 합니다.
이 절은 Jest 30을 지원하는 Node.js와 npm이 있는 별도 연습 폴더를 전제로 합니다. 이 교재 저장소에 Jest가 이미 구성되어 있다는 뜻은 아닙니다. 실습용 package.json에는 "type": "module"을 넣지 않고, Babel로 아래 ES 모듈 문법을 CommonJS로 변환합니다. 기존 프로젝트에 적용할 때는 그 프로젝트의 모듈·테스트 설정부터 확인합니다.
npm init -y개발 의존성으로 Jest를 설치합니다.
npm install --save-dev jest@30
# 또는 yarn add --dev jest@30package.json 설정package.json 파일의 scripts 섹션에 테스트 명령어를 추가합니다.
{
"name": "my-js-app",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "jest",
"test:watch": "jest --watchAll"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"jest": "^30.0.0"
}
}devDependencies의 실제 세부 버전은 설치 명령이 기록한 값을 유지합니다. 이후 예제의 import/export를 실행하려면 다음 변환 의존성과 설정도 필요합니다.
npm install --save-dev babel-jest@30 @babel/core @babel/preset-env프로젝트 루트에 babel.config.cjs를 만듭니다. 이 Babel 구성은 Jest 시작 안내에 따른 실습용 선택이며, Jest의 네이티브 ESM 실행 설정과는 다른 경로입니다.
// babel.config.cjs
module.exports = {
presets: [["@babel/preset-env", { targets: { node: "current" } }]],
};설정 후 터미널에서 npm test를 실행하면 Jest가 프로젝트 내의 테스트 파일을 찾아 실행합니다.
npm test -- --watchAll 또는 npm run test:watch를 실행하면 파일 변경을 감지하여 자동으로 테스트를 재실행하는 watch 모드로 실행됩니다.
첫 번째 단위 테스트 작성하기
간단한 함수를 만들고, 이 함수가 올바르게 작동하는지 Jest를 이용해 테스트해 보겠습니다.
여기서는 두 입력을 유한한 number 값으로 제한합니다. 문자열 변환이나 잘못된 타입을 거부하는 기능은 아직 구현하지 않습니다. 예를 들어 add("1", 2)는 이 계약 밖이므로 그 결과만 보고 숫자 덧셈 테스트가 충분하다고 판단하지 않습니다.
src/math.js)// src/math.js
export function add(a, b) {
return a + b;
}
export function subtract(a, b) {
return a - b;
}
export function multiply(a, b) {
return a * b;
}
export function divide(a, b) {
if (b === 0) {
throw new Error("0으로 나눌 수 없습니다.");
}
return a / b;
}src/math.test.js)Jest는 기본적으로 .test.js, .spec.js 또는 __tests__ 디렉터리 내의 파일을 테스트 파일로 인식합니다.
테스트 대상 파일과 동일한 디렉터리에 생성하는 것이 일반적입니다.
// src/math.test.js
import { add, subtract, multiply, divide } from './math'; // 테스트할 함수들을 임포트
// describe 블록: 관련 테스트들을 그룹화 (테스트 스위트)
describe('수학 함수 테스트', () => {
// test 또는 it 블록: 개별 테스트 케이스
test('add 함수는 두 숫자를 더해야 한다', () => {
// expect(): 테스트할 값 (subject)
// toBe(): Jest의 매처(Matcher). 예상 값과 실제 값을 비교
expect(add(1, 2)).toBe(3);
expect(add(0, 0)).toBe(0);
expect(add(-1, 1)).toBe(0);
expect(add(10, -5)).toBe(5);
});
test('subtract 함수는 첫 번째 숫자에서 두 번째 숫자를 빼야 한다', () => {
expect(subtract(5, 2)).toBe(3);
expect(subtract(2, 5)).toBe(-3);
expect(subtract(0, 0)).toBe(0);
});
test('multiply 함수는 두 숫자를 곱해야 한다', () => {
expect(multiply(2, 3)).toBe(6);
expect(multiply(5, 0)).toBe(0);
expect(multiply(-2, 4)).toBe(-8);
});
// 특정 예외(에러)가 발생하는지 테스트
test('divide 함수는 0으로 나눌 때 에러를 발생시켜야 한다', () => {
// toThrow(): 특정 에러가 발생하는지 확인하는 매처
expect(() => divide(10, 0)).toThrow('0으로 나눌 수 없습니다.');
});
test('divide 함수는 두 숫자를 나눠야 한다', () => {
expect(divide(10, 2)).toBe(5);
expect(divide(7, 2)).toBe(3.5);
});
});터미널에서 다음 명령어를 실행합니다.
npm test테스트가 통과하면 Jest가 PASS와 테스트별 결과를 출력합니다. 다음 그림은 실제 출력 화면이 아니라 작성·실행·실패 진단의 흐름입니다.
FLOWCHART · WEB PLATFORM CONTRACT
단위 테스트 작성·실행·진단 루프
작은 입력으로 공개 동작을 실행하고 기대값을 단언한다. 실패하면 입력·기대값·구현을 분리해 원인을 찾고, 같은 초기 조건에서 다시 실행한다.
재실행 때 입력과 공유 상태를 다시 초기화한다. 실패를 숨기기 위해 기대값만 바꾸지 않고, 요구사항과 실제 동작을 비교해 수정 대상을 정한다.
만약 테스트가 실패하면, Jest는 어느 테스트에서 어떤 이유로 실패했는지 상세하게 알려주므로 디버깅에 큰 도움이 됩니다.
expect(add(1, 2)).toBe(3)은 준비(입력 1과 2) → 실행(add) → 판정(결과 3)으로 읽습니다. 준비와 실행을 변수로 나누어도 같은 구조입니다. PASS는 작성한 조건을 만족했다는 뜻이고, 테스트하지 않은 모든 입력까지 검증했다는 뜻은 아닙니다. 예외는 expect(divide(10, 0))처럼 즉시 실행하지 않고 expect(() => divide(10, 0))처럼 함수를 전달해야 Jest가 그 예외를 잡아 판정합니다.
Jest의 주요 매처(Matchers)
expect(value).toBe(expected)와 같이 expect() 뒤에 체이닝되는 메서드를 매처(Matcher)라고 합니다.
Jest는 다양한 매처를 제공하여 여러 가지 방법으로 값을 비교하고 검증할 수 있도록 합니다.
| 매처 메서드 | 설명 | 예시 |
|---|---|---|
toBe(value) | Object.is로 값 비교. 객체는 참조 동일성 확인. NaN과 부호 있는 0에서 ===와 다름. | expect(1).toBe(1); |
toEqual(value) | 객체·배열을 재귀적으로 비교. undefined 속성·희소 배열·객체 타입까지 구별하려면 toStrictEqual 사용. | expect({a: 1}).toEqual({a: 1}); |
toBeTruthy() | 값이 논리적으로 true인지 확인 (truthy). | expect('hello').toBeTruthy(); |
toBeFalsy() | 값이 논리적으로 false인지 확인 (falsy). | expect(0).toBeFalsy(); |
toBeNull() | 값이 null인지 확인. | expect(null).toBeNull(); |
toBeUndefined() | 값이 undefined인지 확인. | expect(undefined).toBeUndefined(); |
toBeDefined() | 값이 undefined가 아닌지 확인. | expect('str').toBeDefined(); |
toContain(item) | 배열에 특정 항목이 포함되어 있는지 또는 문자열에 서브스트링이 포함되어 있는지 확인. | expect([1, 2, 3]).toContain(2); |
toHaveLength(number) | 배열이나 문자열의 길이가 특정 숫자인지 확인. | expect([1, 2]).toHaveLength(2); |
toMatch(regexpOrString) | 문자열이 특정 정규식 패턴과 일치하는지 확인. | expect('hello').toMatch(/ll/); |
toThrow(error) | 함수가 특정 에러를 발생시키는지 확인. 함수를 래핑하는 함수를 전달해야 함. | expect(() => throwError()).toThrow(); |
not | 매처를 부정할 때 사용. (예: not.toBe, not.toContain) | expect(1).not.toBe(2); |
객체 내용이 같은지와 같은 객체인지는 구별합니다. toBe는 참조도 비교하지만 toEqual은 내용을 비교합니다. 소수 계산에는 이진 부동소수점 오차가 있으므로 아래처럼 toBeCloseTo로 허용 정밀도를 명시합니다. 기본 정밀도 2는 보통 차이가 0.005보다 작은지를 판정합니다. 돈을 계산하는 앱의 반올림·허용 오차 규칙은 별도로 정해야 합니다. Jest 매처 안내
test('소수의 합은 허용 정밀도로 비교한다', () => {
expect(add(0.1, 0.2)).toBeCloseTo(0.3, 2);
});// src/data.js
export const users = [
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
{ id: 3, name: 'Charlie' }
];
export function getUserById(id) {
return users.find(user => user.id === id);
}// src/data.test.js
import { users, getUserById } from './data';
describe('사용자 데이터 테스트', () => {
test('users 배열은 3명의 사용자를 포함해야 한다', () => {
expect(users).toHaveLength(3);
});
test('users 배열은 Bob 사용자를 포함해야 한다', () => {
// 객체는 toEqual로 깊은 비교
expect(users).toContainEqual({ id: 2, name: 'Bob' });
});
test('getUserById는 올바른 사용자 객체를 반환해야 한다', () => {
const user = getUserById(1);
expect(user).toEqual({ id: 1, name: 'Alice' });
});
test('getUserById는 존재하지 않는 ID에 대해 undefined를 반환해야 한다', () => {
const user = getUserById(99);
expect(user).toBeUndefined();
});
});테스트 훅 (Hooks)
테스트 스위트(describe 블록) 내의 각 테스트 케이스(test 블록)가 실행되기 전이나 후에 특정 코드를 실행해야 할 때가 있습니다.
예를 들어, 각 테스트마다 동일한 초기 상태를 설정하거나, 테스트 후 정리 작업을 수행할 때 유용합니다.
Jest는 이를 위한 훅(Hook)을 제공합니다.
beforeAll(fn, timeout): 현재describe블록 내의 모든 테스트가 시작되기 전에 한 번 실행됩니다.afterAll(fn, timeout): 현재describe블록 내의 모든 테스트가 끝난 후에 한 번 실행됩니다.beforeEach(fn, timeout): 현재describe블록 내의 각 테스트가 실행되기 전에 매번 실행됩니다.afterEach(fn, timeout): 현재describe블록 내의 각 테스트가 끝난 후에 매번 실행됩니다.
// src/calculator.js
export class Calculator {
constructor() {
this.value = 0;
}
add(num) {
this.value += num;
}
subtract(num) {
this.value -= num;
}
clear() {
this.value = 0;
}
}// src/calculator.test.js
import { Calculator } from './calculator';
describe('Calculator 클래스 테스트', () => {
let calculator; // 각 테스트마다 새로운 계산기 인스턴스를 생성할 변수
// 각 테스트가 시작되기 전에 실행 (초기화)
beforeEach(() => {
calculator = new Calculator();
console.log('Calculator 인스턴스 초기화됨');
});
// 각 테스트가 끝난 후에 실행 (정리)
afterEach(() => {
console.log('테스트 후 정리 (만약 필요하다면)');
});
test('add 메서드는 값을 올바르게 더해야 한다', () => {
calculator.add(5);
expect(calculator.value).toBe(5);
calculator.add(10);
expect(calculator.value).toBe(15);
});
test('subtract 메서드는 값을 올바르게 빼야 한다', () => {
calculator.add(10); // beforeEach에서 초기화된 값에 10을 더함
calculator.subtract(3);
expect(calculator.value).toBe(7);
});
test('clear 메서드는 값을 0으로 초기화해야 한다', () => {
calculator.add(100);
calculator.clear();
expect(calculator.value).toBe(0);
});
});이 예제는 beforeEach에서 새 계산기를 만들어 계산기 상태를 테스트마다 분리합니다. 공유 전역 상태나 외부 자원은 별도로 초기화·정리해야 합니다.
afterEach의 로그 출력 자체가 정리 작업은 아닙니다. 이 계산기는 외부 자원을 만들지 않으므로 별도 해제가 필요 없지만, 실제 테스트에서 연 타이머·파일·연결은 필요한 정리 코드를 작성해야 합니다. 파일 최상위의 훅은 파일 안의 테스트에, describe 안의 훅은 그 그룹의 테스트에 적용됩니다. 비동기 준비·정리는 훅에서도 Promise를 반환하거나 await해야 합니다. Jest setup·teardown 안내
예를 들어, 한 테스트에서 calculator.value를 변경해도 다음 테스트에는 영향을 주지 않습니다.
비동기 코드 테스트
웹 개발에서는 서버와의 통신이나 시간 지연을 포함하는 비동기 코드를 테스트하는 것이 매우 중요합니다.
Jest는 비동기 코드 테스트를 위한 여러 방법을 제공합니다.
아래 fetchData는 이름과 달리 실제 HTTP 요청을 보내지 않는 타이머 기반 연습 함수입니다. 따라서 성공·실패 분기와 Promise의 완료를 검증하며, 서버·네트워크의 동작은 검증하지 않습니다. 100은 타이머에 요청한 지연으로, 정확히 100ms에 끝난다는 보장은 아닙니다.
.resolves, .rejects)// src/api.js
export function fetchData(shouldSucceed) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (shouldSucceed) {
resolve({ data: '데이터 로드 성공!' });
} else {
reject(new Error('데이터 로드 실패!'));
}
}, 100); // 0.1초 지연
});
}// src/api.test.js
import { fetchData } from './api';
describe('비동기 데이터 fetch 테스트', () => {
test('fetchData는 성공적으로 데이터를 가져와야 한다', () => {
// resolves: Promise가 성공적으로 이행되는지 확인
return expect(fetchData(true)).resolves.toEqual({ data: '데이터 로드 성공!' });
});
test('fetchData는 에러를 반환해야 한다', () => {
// rejects: Promise가 에러로 거부되는지 확인
return expect(fetchData(false)).rejects.toThrow('데이터 로드 실패!');
});
});return을 사용하여 Jest에게 Promise가 완료될 때까지 기다리라고 알려줘야 합니다.
return이나 await를 빼면 테스트 콜백이 먼저 끝나 비동기 판정을 기다리지 못할 수 있습니다. 실패를 검사할 때는 단순히 .catch() 안에만 단언을 두지 말고, 거부 자체가 요구사항임을 .rejects로 표현합니다. Jest 비동기 테스트 안내
async/await 문법을 사용하면 비동기 테스트 코드를 동기 코드처럼 작성할 수 있어 가독성이 더욱 좋습니다.
// src/api.test.js (async/await 버전)
import { fetchData } from './api';
describe('비동기 데이터 fetch 테스트 (async/await)', () => {
test('fetchData는 성공적으로 데이터를 가져와야 한다 (async/await)', async () => {
const data = await fetchData(true);
expect(data).toEqual({ data: '데이터 로드 성공!' });
});
test('fetchData는 에러를 반환해야 한다 (async/await)', async () => {
// rejects 매처로 Promise 거부와 오류 메시지를 검증
await expect(fetchData(false)).rejects.toThrow('데이터 로드 실패!');
// 또는
// try {
// await fetchData(false);
// } catch (e) {
// expect(e.message).toBe('데이터 로드 실패!');
// }
});
});test에 전달하는 콜백에 async를 붙이고, await로 Promise의 완료를 기다립니다. 주석의 try/catch 대안을 사용할 때는 expect.assertions(1)을 try 앞에 두어 Promise가 예기치 않게 성공해 단언 없이 통과하는 경우도 잡아야 합니다.
직접 판정해 보기
Calculator를 새로 만든 뒤add(4),subtract(6),clear()를 차례로 실행합니다. 마지막 값만 검사할 때 놓칠 수 있는 버그를 생각하고, 각 단계의 기대값을 먼저 적어 테스트를 작성하세요.getUserById('1')의 기대값을 정하세요. 숫자1과 문자열'1'을 같은 ID로 받을 요구사항이 아직 없다는 조건입니다.- 위 성공 Promise 테스트에서
return을 지우고 기대값을 틀리게 적었다고 가정합니다. PASS가 보이더라도 신뢰할 수 없는 이유와 수정 방법을 설명하세요.
풀이와 확인 기준
1번은 4, -2, 0을 각각 검사해야 합니다. 마지막 0만 검사하면 subtract가 고장 나도 clear가 값을 덮어써 결함이 가려질 수 있습니다.
test('계산과 초기화는 단계별 계약을 만족한다', () => {
const calculator = new Calculator();
calculator.add(4);
expect(calculator.value).toBe(4);
calculator.subtract(6);
expect(calculator.value).toBe(-2);
calculator.clear();
expect(calculator.value).toBe(0);
});2번은 undefined입니다. 구현의 user.id === id는 타입도 비교하므로 숫자 ID만 일치합니다. 문자열 ID도 허용하려면 요구사항과 변환 정책을 정한 뒤 구현·테스트를 함께 바꿉니다.
3번은 Jest가 비동기 단언의 완료를 기다릴 신호가 빠진 상태입니다. return expect(...).resolves...를 복원하거나 async 테스트에서 await expect(...).resolves...를 사용합니다. 틀린 기대값을 넣었을 때 실제로 실패하는지도 확인해야 합니다.
단위 테스트 정리
정리 구간에서는 테스트 케이스 수보다 실패 원인을 빠르게 드러내는 단언 구조를 우선 점검합니다.
이번 장에서는 코드의 안정성과 신뢰성을 높이는 데 필수적인 단위 테스트(Unit Testing)의 개념과, 이를 구현하기 위한 강력한 자바스크립트 테스트 프레임워크인 Jest의 사용법을 심도 있게 학습했습니다.
Jest 설치, package.json 스크립트 설정, describe/test/it 블록 구성, expect().toBe(), toEqual(), toThrow() 같은 매처 사용법을 확인했습니다.
beforeEach, afterEach 같은 테스트 훅과 Promise, async/await 기반 비동기 코드 테스트도 함께 다뤘습니다.
단위 테스트는 회귀 버그를 줄이고 리팩터링의 기준을 제공합니다.
모든 기능을 한 번에 테스트하려 하기보다 계산 로직, 상태 변경, 예외 처리처럼 결과가 명확한 핵심 로직부터 테스트 대상으로 삼는 것이 좋습니다.
단위 테스트를 작성할 때는 구현 세부를 많이 붙잡기보다 작은 입력과 명확한 기대값을 먼저 고정해야 합니다.