자바스크립트 테스트와 디버깅 - 개발자 도구 활용과 단위 테스트 작성
이 단원에서 배우는 것
고급 과정의 마지막 단원이다. 17단원부터 21단원까지 "왜 이렇게 동작하는가"를 파고들었고, 22·23단원에서 도구로 미리 막는 법을 봤다. 그래도 버그는 남는다. 이번에는 남은 버그를 빠르게 좁히고, 같은 버그가 다시 나오지 않게 고정하는 방법을 다룬다. 스터디 관리 대시보드에 지금까지 만든 debounce, loadMembers, 출석률 계산이 그대로 테스트 대상이 된다.
- 조건부 브레이크포인트와 로그포인트로
console.log를 심고 다시 빌드하는 순환을 없앤다. node:test와 Vitest로 순수 함수, 비동기 함수, 타이머가 있는 코드를 각각 테스트한다.- 깨지기 쉬운 테스트와 값어치 있는 테스트를 구분하는 기준을 세운다.
기준은 Node.js 20 이상에 내장된 node:test와 Vitest 2다. 개발자 도구는 Chrome 기준으로 설명하지만 Firefox와 Safari에도 같은 기능이 대부분 있다.
왜 필요한가
"3만 명 중 특정 참가자만 출석률이 이상하다"는 제보를 받았다고 하자. 첫 반응은 대개 이렇다.
function attendanceRate(member, total) {
console.log("member", member, "total", total); // 심고
return Math.round((member.sessions / total) * 100);
}
실행하면 로그가 3만 줄 쏟아진다. 조건을 붙이면 로그가 줄지만, 조건을 바꿀 때마다 코드를 고치고 저장하고 화면을 새로 고쳐야 한다. 한 번 왕복에 30초씩 걸리고 열 번 반복하면 5분이다. 게다가 이 console.log는 커밋에 섞여 들어가기 십상이다.
브레이크포인트를 쓰면 코드를 한 줄도 고치지 않고 조건을 바꿔 가며 멈출 수 있고, 멈춘 순간의 모든 지역 변수와 호출 스택을 볼 수 있다. 그리고 원인을 찾은 뒤에는 테스트로 고정한다. 고정하지 않으면 몇 달 뒤 누군가 같은 자리를 다시 깨뜨린다.
문법과 예제
조건부 브레이크포인트와 로그포인트
개발자 도구 Sources 패널에서 줄 번호를 오른쪽 클릭하면 세 가지가 나온다.
| 종류 | 동작 | 쓰는 상황 |
|---|---|---|
| Conditional breakpoint | 조건식이 참일 때만 멈춘다 | 3만 건 중 한 건만 보고 싶을 때 |
| Logpoint | 멈추지 않고 값만 출력한다 | 흐름은 유지하며 값 추적 |
| Never pause here | 이 줄에서 절대 멈추지 않는다 | 다른 사람이 남긴 debugger 무시 |
조건식에는 아무 표현식이나 쓸 수 있다. 처음 다섯 번만 보고 싶으면 이렇게 쓴다.
member.id === 4271 // 특정 참가자만
member.sessions > total // 이상한 데이터일 때만
(window.__n = (window.__n ?? 0) + 1) <= 5 // 처음 5회만
로그포인트도 같은 자리에 표현식을 쓴다. console.log를 코드에 심는 것과 결과는 같지만 소스가 바뀌지 않고, 새로고침해도 유지되며, 커밋에 섞이지 않는다. 22단원에서 소스맵을 제대로 설정해 뒀다면 번들된 코드가 아니라 원본 파일에 그대로 걸 수 있다.
어디서 멈춰야 할지조차 모를 때 쓰는 브레이크포인트도 있다. Sources 패널 오른쪽에서 켠다.
- Event Listener Breakpoints: 특정 이벤트가 발생하는 순간 멈춘다. "이 버튼을 누르면 어느 코드가 도는지" 모를 때
Mouse → click을 켠다. - DOM Breakpoints: 요소를 오른쪽 클릭해
Break on → attribute modifications를 켜면 그 요소를 누가 바꾸는지 잡는다. 정체불명의 클래스 변경 범인을 찾을 때 유용하다. - XHR/fetch Breakpoints: URL 조각을 넣어 두면 그 요청이 나가는 순간 멈춘다. 어느 코드가 이 API를 부르는지 찾을 때 쓴다.
콘솔을 제대로 쓰기
console.table(members.slice(0, 10), ["id", "name", "sessions", "paid"]);
console.group("출석 계산");
console.log("대상", members.length);
console.groupEnd();
console.trace("여기까지 어떻게 왔나");
console.assert(total > 0, "total 이 0 이하다", { total });
console.table은 객체 배열을 표로 보여 준다. 두 번째 인자로 열을 고르면 필요한 필드만 본다. console.trace는 호출 스택을 함께 찍어 "이 함수를 누가 불렀나"를 즉시 알려 준다. 콘솔 패널에서는 $0이 지금 선택한 DOM 요소, $_가 직전 실행 결과, $$(".row")가 querySelectorAll의 축약이다.
다만 18단원에서 본 것처럼 콘솔에 출력한 객체는 개발자 도구가 참조를 붙잡는다. 메모리를 측정할 때는 콘솔을 비운다.
단위 테스트 — 도구 없이 시작하기
Node.js 20 이상은 테스트 러너를 내장한다. 설치할 것이 없다.
// src/attendance.js
export function attendanceRate(member, totalSessions) {
if (totalSessions <= 0) throw new RangeError("totalSessions 는 1 이상이어야 한다");
return Math.round((member.sessions / totalSessions) * 100);
}
// src/attendance.test.js
import { test, describe } from "node:test";
import assert from "node:assert/strict";
import { attendanceRate } from "./attendance.js";
describe("attendanceRate", () => {
test("3회 참석에 총 4회면 75를 반환한다", () => {
assert.equal(attendanceRate({ sessions: 3 }, 4), 75);
});
test("반올림한다", () => {
assert.equal(attendanceRate({ sessions: 1 }, 3), 33);
});
test("totalSessions 가 0이면 RangeError 를 던진다", () => {
assert.throws(() => attendanceRate({ sessions: 3 }, 0), RangeError);
});
});
node --test
인자 없이 부르면 현재 디렉터리를 재귀 탐색해 *.test.js 같은 이름 규칙에 맞는 파일을 찾는다. 대상을 좁히고 싶으면 node --test "src/**/*.test.js"처럼 글로브 패턴을 준다. Node.js 22 부터는 위치 인자를 글로브로 해석하므로 node --test src/처럼 디렉터리만 적으면 실행되지 않는다.
테스트 하나의 구조는 준비, 실행, 검증 세 단계다. 위에서는 한 줄에 다 들어갔지만 복잡해지면 세 덩어리로 나눠 쓴다. 테스트 이름은 "무엇을 하면 어떻게 된다" 형태로 쓴다. test("attendanceRate 테스트") 같은 이름은 실패했을 때 아무것도 알려 주지 못한다.
여기서 중요한 사실 하나. 위 함수가 테스트하기 쉬운 이유는 입력만으로 출력이 정해지기 때문이다. DOM도 fetch도 Date.now()도 쓰지 않는다. 계산 로직을 이런 순수 함수로 분리해 두면 테스트는 저절로 쉬워진다. 반대로 렌더링과 계산과 통신이 한 함수에 섞여 있으면 무엇을 하든 테스트가 어렵다. 테스트가 어렵다는 것은 대개 설계 문제의 신호다.
비동기 함수 테스트
// src/api.js
export async function loadMembers(studyId, fetchImpl = fetch) {
const res = await fetchImpl(`/api/studies/${studyId}/members`);
if (!res.ok) throw new Error(`참가자 조회 실패: ${res.status}`);
return res.json();
}
// src/api.test.js
import { test, describe, mock } from "node:test";
import assert from "node:assert/strict";
import { loadMembers } from "./api.js";
describe("loadMembers", () => {
test("성공하면 참가자 배열을 반환한다", async () => {
const fakeFetch = mock.fn(async () => ({
ok: true,
status: 200,
json: async () => [{ id: 1, name: "김민수" }],
}));
const result = await loadMembers(42, fakeFetch);
assert.deepEqual(result, [{ id: 1, name: "김민수" }]);
assert.equal(fakeFetch.mock.callCount(), 1);
assert.match(fakeFetch.mock.calls[0].arguments[0], /\/api\/studies\/42\/members/);
});
test("500 이면 상태 코드가 담긴 오류를 던진다", async () => {
const fakeFetch = async () => ({ ok: false, status: 500 });
await assert.rejects(() => loadMembers(42, fakeFetch), /참가자 조회 실패: 500/);
});
});
fetch를 매개변수의 기본값으로 받은 것이 핵심이다. 전역을 통째로 바꿔치기하지 않아도 테스트에서 가짜를 넘길 수 있다. 이렇게 외부 의존을 인자로 받는 구조를 의존성 주입이라 하고, 테스트 가능한 코드의 가장 값싼 방법이다.
타이머가 있는 코드 테스트
21단원에서 만든 debounce를 테스트한다고 하자. 실제로 200ms를 기다리면 테스트가 느려지고 불안정해진다. 가짜 타이머로 시간을 직접 감는다.
import { test, describe, mock, beforeEach, afterEach } from "node:test";
import assert from "node:assert/strict";
import { debounce } from "./debounce.js";
describe("debounce", () => {
beforeEach(() => mock.timers.enable({ apis: ["setTimeout"] }));
afterEach(() => mock.timers.reset());
test("연속 호출하면 마지막 한 번만 실행된다", () => {
const spy = mock.fn();
const run = debounce(spy, 200);
run("김");
run("김민");
run("김민수");
assert.equal(spy.mock.callCount(), 0); // 아직 실행 전
mock.timers.tick(200);
assert.equal(spy.mock.callCount(), 1);
assert.deepEqual(spy.mock.calls[0].arguments, ["김민수"]);
});
test("cancel 하면 실행되지 않는다", () => {
const spy = mock.fn();
const run = debounce(spy, 200);
run("김민수");
run.cancel();
mock.timers.tick(1000);
assert.equal(spy.mock.callCount(), 0);
});
});
테스트가 밀리초 단위로 끝나고 결과가 항상 같다. afterEach에서 reset을 빼먹으면 가짜 타이머가 다음 테스트로 새어 나가 엉뚱한 곳에서 멈춘 것처럼 보인다. Vitest에서는 vi.useFakeTimers(), vi.advanceTimersByTime(200), vi.useRealTimers()가 같은 역할을 한다.
브라우저 코드는 Vitest 로
DOM을 쓰는 코드는 node:test만으로는 부족하다. Vitest는 22단원의 Vite 설정을 그대로 재사용하므로 별도 빌드 설정이 필요 없다.
// vitest.config.js
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "jsdom", // 브라우저 API 를 흉내 낸다
globals: false, // describe/test 를 명시적으로 import 한다
},
});
import { describe, test, expect, beforeEach } from "vitest";
import { renderRoster } from "./roster.js";
describe("renderRoster", () => {
beforeEach(() => {
document.body.innerHTML = `<ul id="member-list"></ul>`;
});
test("참가자 수만큼 항목을 그린다", () => {
renderRoster(document.querySelector("#member-list"), [
{ id: 1, name: "김민수", sessions: 3, paid: true },
{ id: 2, name: "이서연", sessions: 0, paid: false },
]);
const items = document.querySelectorAll("#member-list li");
expect(items).toHaveLength(2);
expect(items[0].textContent).toContain("김민수");
});
});
jsdom은 실제 브라우저가 아니라 흉내다. 레이아웃 계산이 없어서 offsetHeight는 항상 0이고, CSS는 적용되지 않는다. 21단원의 가상 스크롤처럼 실제 크기에 의존하는 코드는 jsdom에서 검증할 수 없다. 그런 코드는 Playwright 같은 실제 브라우저 도구로 테스트한다.
실무에서 자주 틀리는 것
1. 비동기 단언에서 await 를 빠뜨린다
test("500 이면 오류를 던진다", () => { // async 도 아니고 await 도 없다
assert.rejects(() => loadMembers(42, failing));
});
이 테스트는 함수가 오류를 던지지 않아도 통과한다. assert.rejects가 반환한 프로미스를 아무도 기다리지 않아서, 검증이 끝나기 전에 테스트가 종료되기 때문이다. node:test는 그나마 "테스트가 끝난 뒤 비동기 활동이 발생했다"는 경고를 남기지만, 다른 러너에서는 조용히 초록불이 켜진다. 검증하지 않는 테스트는 없느니만 못하다. 거짓 안전감을 주기 때문이다. 비동기 단언 앞의 await는 린트 규칙으로 강제하는 편이 안전하다.
2. 테스트끼리 상태를 공유한다
// 나쁜 예 — 첫 테스트가 배열을 바꾸면 둘째가 깨진다
const members = [{ id: 1, name: "김민수", paid: false }];
test("납부 처리", () => { members[0].paid = true; /* ... */ });
test("미납자 조회", () => { assert.equal(findUnpaid(members).length, 1); });
두 번째 테스트는 단독으로 돌리면 통과하고 전체를 돌리면 실패한다. 원인을 찾는 데 오래 걸리는 대표적 유형이다. 게다가 러너가 파일을 병렬로 실행하거나 순서를 섞으면 결과가 매번 달라진다. 테스트 데이터는 beforeEach에서 매번 새로 만든다. 모듈 최상위에 두더라도 함수로 감싸 새 객체를 반환하게 한다. 순서에 의존하는 테스트는 그 자체가 결함이다.
3. 구현 세부를 테스트한다
// 나쁜 예 — 내부 함수 호출 횟수를 검증한다
assert.equal(sortSpy.mock.callCount(), 1);
assert.equal(el.className, "row row--paid is-active");
정렬을 한 번 하든 두 번 하든 결과가 맞으면 사용자에게는 같다. 이런 테스트는 리팩터링할 때마다 깨지고, 깨질 때마다 "코드는 멀쩡한데 테스트만 고치는" 작업이 붙는다. 그 경험이 쌓이면 팀이 테스트를 짐으로 여기게 된다. 검증할 것은 입력에 대한 출력과 겉으로 드러나는 동작이다. 클래스 문자열 전체 대신 el.classList.contains("row--paid")를 보는 식으로 범위를 좁힌다.
4. 커버리지 숫자를 목표로 삼는다
node --test --experimental-test-coverage
커버리지는 "실행된 줄"을 셀 뿐 "검증된 동작"을 세지 않는다. 단언이 하나도 없는 테스트로도 100%를 만들 수 있다. 80% 같은 목표를 강제하면 사람들은 검증이 쉬운 곳(게터, 상수, 설정 파일)에 테스트를 붙여 숫자를 채운다. 정작 조건 분기가 복잡한 계산 로직은 비어 있다. 커버리지는 목표가 아니라 빈 곳을 찾는 지도로 쓴다. 커버리지 리포트에서 빨간 줄을 열어 보고 "이 분기가 안 도는 게 맞나"를 판단하는 용도다.
5. 대기 시간을 늘려 불안정한 테스트를 덮는다
// 나쁜 예 — 느린 CI 에서는 여전히 깨진다
await new Promise((r) => setTimeout(r, 500));
assert.equal(listBox.children.length, 2);
가끔 실패하는 테스트를 만나면 대기 시간을 500ms에서 1000ms로 늘리게 된다. 근본 원인은 그대로이고 테스트만 느려진다. 100개가 쌓이면 CI가 몇 분씩 길어진다. 올바른 방법은 시간이 아니라 조건을 기다리는 것이다. 상태 변화를 알리는 프로미스를 코드가 직접 반환하게 하거나, 폴링으로 조건을 확인하는 헬퍼를 쓴다. 타이머가 원인이라면 위에서 본 가짜 타이머로 시간을 통제한다. 그래도 재현이 안 되는 불안정한 테스트는 비활성화하지 말고 원인을 찾는다. 대개 실제 경쟁 조건이 코드에 있다는 신호다.
내장 러너의 전체 API는 Node.js 테스트 러너 문서에 정리돼 있다.
스스로 확인하기
- 3만 명을 순회하는 반복문에서
member.id가 4271인 경우에만 멈추고 싶다.console.log를 쓰지 않고 어떻게 하는가? 그 방법이console.log보다 나은 이유를 두 가지 쓰라. - 다음 테스트는
loadMembers가 오류를 던지지 않아도 통과한다. 이유와 고친 코드를 쓰라.test("500 이면 오류", () => { assert.rejects(() => loadMembers(42, failing)); }); - 200ms 디바운스가 걸린 검색 함수를 테스트한다. 실제로 250ms를 기다리는 방식의 문제 두 가지와 대안을 쓰라.
정답
- 개발자 도구 Sources 패널에서 해당 줄 번호를 오른쪽 클릭해 조건부 브레이크포인트를 걸고 조건에
member.id === 4271을 쓴다. 값만 보면 되는 경우에는 로그포인트를 쓴다. 나은 이유는 첫째, 소스 코드를 고치지 않으므로 빌드와 새로고침 왕복이 없고 조건을 즉시 바꿔 가며 시험할 수 있다. 둘째, 멈춘 시점의 모든 지역 변수와 호출 스택을 볼 수 있어 미리 정한 값만 찍는console.log보다 정보가 많다. 덧붙여 커밋에 디버깅 코드가 섞일 위험도 없다. assert.rejects는 프로미스를 반환하는데 테스트 함수가 동기 함수라 그 프로미스를 기다리지 않고 즉시 끝난다. 검증이 끝나기 전에 테스트가 통과 처리되므로 실제 결과와 무관하게 초록불이 켜진다. 테스트 함수를async로 바꾸고await를 붙인다.test("500 이면 오류", async () => { await assert.rejects(() => loadMembers(42, failing), /500/); });처럼 기대하는 메시지까지 지정하면 다른 이유로 실패한 경우를 통과시키지 않는다.- 첫째, 테스트 하나당 250ms가 그대로 실행 시간이 되어 테스트가 수백 개면 CI가 몇 분씩 길어진다. 둘째, 느린 CI 환경에서는 250ms 안에 콜백이 실행되지 않아 간헐적으로 실패하는 불안정한 테스트가 된다. 대안은 가짜 타이머다.
node:test에서는mock.timers.enable({ apis: ["setTimeout"] })후mock.timers.tick(200)으로, Vitest에서는vi.useFakeTimers()후vi.advanceTimersByTime(200)으로 시간을 직접 진행시킨다. 실행이 즉시 끝나고 결과가 환경과 무관하게 항상 같다. 테스트가 끝나면 반드시 타이머를 원래대로 되돌린다.