단위·통합·E2E 테스트 전략, 모킹, 비동기 테스트, 커버리지, 정적 분석으로 신뢰할 수 있는 코드 기반을 만든다.
테스트와 코드 품질 자동화
입문편에서 try/catch와 DevTools를 이용한 디버깅을 익혔다면, 이번 레슨은 그 한 단계 위입니다. 버그가 발생한 뒤 잡는 것이 아니라, 자동화된 테스트와 정적 분석으로 결함 자체가 프로덕션에 도달하지 못하도록 막는 것이 목표입니다. 단순히 "테스트를 작성한다"는 행위를 넘어, 어떤 종류의 테스트를 언제 쓰고, 어떻게 신뢰도와 비용의 균형을 맞출지, 그리고 CI 파이프라인에서 품질 게이트를 어떻게 구성할지까지 다룹니다.
학습 목표
- 테스트 피라미드의 계층별 역할과 비용/신뢰도 트레이드오프를 설명할 수 있다.
- Vitest/Jest로 의미 있는 단언을 설계하고, 비동기·타이머 코드를 올바르게 테스트할 수 있다.
- 모킹·스파이·MSW를 활용해 외부 의존성을 격리하고 결정론적 테스트를 작성할 수 있다.
- 커버리지 지표의 함정을 인식하고, 변이 테스트 개념을 통해 테스트 품질 자체를 검증할 수 있다.
- ESLint·타입 검사·CI 게이트를 연결해 결함을 코드 작성 시점에 차단하는 파이프라인을 구성할 수 있다.
1. 테스트 피라미드: 계층별 역할과 비용/신뢰도 균형
테스트 피라미드는 Mike Cohn이 제안한 개념으로, 아래에서 위로 갈수록 실행 비용이 높아지고 실제 환경에 가까워집니다.
| 계층 | 속도 | 비용 | 신뢰도 | 비중 권장 |
|---|---|---|---|---|
| 단위(Unit) | 매우 빠름 (ms) | 낮음 | 로직 정확성 | ~70% |
| 통합(Integration) | 보통 (초) | 중간 | 모듈 간 협력 | ~20% |
| E2E(End-to-End) | 느림 (수십 초) | 높음 | 사용자 시나리오 | ~10% |
⚠️ 주의 피라미드를 뒤집어 E2E 비중을 높이면 CI가 느려지고 불안정(flaky)해집니다. 반대로 단위 테스트만 있으면 모듈 통합 오류를 놓칩니다.
실무에서는 피라미드보다 "트로피(Trophy)" 형태—단위보다 통합에 더 집중—가 프런트엔드에서 더 실용적이라는 시각도 있습니다(Kent C. Dodds). 어느 쪽이든 핵심은 레이어별 역할을 명확히 구분하는 것입니다.
// 단위 테스트: 순수 함수 하나만 검증
// ✅ 외부 의존성 없음, 입력→출력만 검증
function calculateDiscount(price, rate) {
if (rate < 0 || rate > 1) throw new RangeError('rate must be 0–1');
return Math.round(price * (1 - rate) * 100) / 100;
}
// 통합 테스트: DB·파일시스템·다른 모듈과의 협력 검증
// E2E 테스트: 실제 브라우저에서 사용자 플로우 전체 검증 (Playwright, Cypress)
2. Vitest/Jest로 작성하는 단위 테스트와 의미 있는 단언 설계
Vitest는 Vite 생태계와 통합된 빠른 테스트 러너로, Jest와 API가 거의 동일합니다. 이 섹션에서는 두 도구 모두에 적용 가능한 패턴을 다룹니다.
설치 및 기본 구조
# Vitest (Vite 프로젝트)
npm install -D vitest
# Jest (일반 Node 프로젝트)
npm install -D jest @jest/globals
// discount.js
export function calculateDiscount(price, rate) {
if (rate < 0 || rate > 1) throw new RangeError('rate must be 0–1');
return Math.round(price * (1 - rate) * 100) / 100;
}
// discount.test.js (Vitest / Jest 공통)
import { describe, it, expect } from 'vitest';
import { calculateDiscount } from './discount.js';
describe('calculateDiscount', () => {
it('정상적인 할인율로 가격을 계산한다', () => {
expect(calculateDiscount(10000, 0.1)).toBe(9000);
});
it('소수점을 반올림한다', () => {
// 19.99 * 0.9 === 17.991 (부동소수 오차 포함) → 소수점 둘째 자리로 반올림
expect(calculateDiscount(19.99, 0.1)).toBe(17.99);
});
it('rate가 범위를 벗어나면 RangeError를 던진다', () => {
// ✅ 예외 메시지까지 검증하는 것이 "의미 있는 단언"
expect(() => calculateDiscount(1000, 1.5)).toThrow(RangeError);
expect(() => calculateDiscount(1000, -0.1)).toThrow('rate must be 0–1');
});
});
의미 있는 단언 vs. 의미 없는 단언
// ❌ 단언이 너무 약함 — 어떤 값이 나와도 truthy면 통과
expect(result).toBeTruthy();
// ❌ 구현 세부 사항을 검증 (내부 변수명이 바뀌면 깨짐)
expect(component.state.isLoading).toBe(false);
// ✅ 동작(behavior)을 검증
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
// ✅ 구체적인 값을 검증
expect(result).toStrictEqual({ id: 1, name: '테스트', active: true });
💡 TIP
toEqual은 참조를 무시하고 깊은 비교를 하지만,undefined프로퍼티를 무시합니다. 정확한 구조 검증에는toStrictEqual을 사용하세요.
경계값과 엣지 케이스를 테스트 케이스로 문서화
테스트는 "명세"이기도 합니다. it 설명문을 자연어로 풍부하게 작성하면 코드 없이도 동작을 이해할 수 있습니다.
describe('사용자 나이 검증', () => {
it.each([
[0, true, '0세는 유효하다'],
[17, false, '미성년자는 거부된다'],
[18, true, '성인 최소 나이는 허용된다'],
[150, false, '비현실적인 나이는 거부된다'],
])('나이 %i → 유효: %s (%s)', (age, expected) => {
expect(isAdult(age)).toBe(expected);
});
});
3. 비동기·타이머·이벤트 루프 코드 테스트
Promise와 async/await 테스트
// api.js
export async function fetchUser(id) {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
// api.test.js
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { fetchUser } from './api.js';
// 전역 fetch를 모킹 (MSW 없이 빠르게 처리할 때)
beforeEach(() => {
vi.resetAllMocks();
});
it('성공 응답을 파싱해 반환한다', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ id: 1, name: 'Alice' }),
}));
const user = await fetchUser(1);
expect(user).toStrictEqual({ id: 1, name: 'Alice' });
});
it('HTTP 오류 시 Error를 throw한다', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 404 }));
await expect(fetchUser(99)).rejects.toThrow('HTTP 404');
});
⚠️ 주의
async테스트 함수에서await를 빠뜨리면 Promise가 pending 상태로 테스트가 통과돼 버립니다.rejects체인도 반드시await해야 합니다.
가짜 타이머(Fake Timers)
setTimeout, setInterval, Date가 포함된 코드는 실제 시간을 기다리지 않고 가짜 타이머로 즉시 제어할 수 있습니다.
// debounce.js
export function debounce(fn, delay) {
let timer;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
}
// debounce.test.js
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { debounce } from './debounce.js';
describe('debounce', () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it('마지막 호출만 실행된다', () => {
const fn = vi.fn();
const debounced = debounce(fn, 300);
debounced();
debounced();
debounced(); // 세 번 호출
expect(fn).not.toHaveBeenCalled(); // 아직 300ms 안 지남
vi.advanceTimersByTime(300);
expect(fn).toHaveBeenCalledOnce(); // ✅ 딱 한 번만 실행
});
});
마이크로태스크 플러시(flush microtasks)
Promise 체인 내부 상태를 검증하려면 마이크로태스크 큐를 비워야 합니다.
import { flushPromises } from '@vue/test-utils'; // Vue
// 또는 직접 구현 — 진짜 마이크로태스크만 비울 때:
const flushMicrotasks = () => Promise.resolve();
// queueMicrotask로도 동일하게 구현할 수 있습니다:
// const flushMicrotasks = () => new Promise(queueMicrotask);
it('상태가 비동기로 업데이트된다', async () => {
const store = createStore();
store.fetchData(); // await 없이 호출 (실제 컴포넌트처럼)
await flushMicrotasks(); // 마이크로태스크 큐 비우기
expect(store.data).not.toBeNull();
});
⚠️ 주의
new Promise(resolve => setTimeout(resolve, 0))은setTimeout콜백을 매크로태스크(태스크) 큐에 등록하므로 마이크로태스크가 아닌 한 틱(태스크) 뒤에 resolve됩니다. setTimeout 콜백 실행 시점에는 마이크로태스크가 이미 모두 처리된 뒤라 결과적으로 큐가 비워진 상태가 되지만, 이를 '마이크로태스크 플러시'라 부르는 것은 이벤트 루프 개념상 부정확합니다. 진짜 마이크로태스크만 비우려면await Promise.resolve()나queueMicrotask를, 타이머까지 진행해야 한다면 변수명을flushMacrotask/flushPromisesAndTimers로 두고 '태스크 큐까지 한 틱 진행'으로 설명하세요.
4. 모킹·스파이·의존성 주입과 네트워크 모킹(MSW)
모킹과 스파이의 차이
| 개념 | 설명 | 주요 용도 |
|---|---|---|
| Mock | 실제 구현을 가짜로 완전 대체 | 느리거나 부작용이 있는 외부 의존성 |
| Stub | 특정 반환값을 고정 | 결정론적 입력 보장 |
| Spy | 실제 구현은 유지하면서 호출 기록 | 함수가 올바르게 호출됐는지 검증 |
import { vi } from 'vitest';
// ✅ 스파이: 원본 함수 실행 + 호출 추적
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
// 테스트 후
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Error'));
consoleSpy.mockRestore();
의존성 주입으로 테스트 가능한 설계
// ❌ 외부 의존성이 하드코딩 — 테스트 어려움
class OrderService {
async createOrder(data) {
const db = new Database(); // 직접 생성
return db.insert('orders', data);
}
}
// ✅ 의존성 주입 — 테스트 시 가짜 DB를 주입 가능
class OrderService {
constructor(db) {
this.db = db;
}
async createOrder(data) {
return this.db.insert('orders', data);
}
}
// 테스트
it('주문을 DB에 저장한다', async () => {
const mockDb = { insert: vi.fn().mockResolvedValue({ id: 42 }) };
const service = new OrderService(mockDb);
const result = await service.createOrder({ item: 'book', qty: 1 });
expect(mockDb.insert).toHaveBeenCalledWith('orders', { item: 'book', qty: 1 });
expect(result.id).toBe(42);
});
MSW(Mock Service Worker)로 네트워크 모킹
vi.stubGlobal('fetch', ...) 방식은 간단하지만, 실제 HTTP 요청 레이어를 우회합니다. MSW는 서비스 워커 또는 Node 인터셉터 레벨에서 요청을 가로채므로 Axios, fetch, ky 등 클라이언트에 무관하게 동작합니다.
npm install -D msw
// mocks/handlers.js
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('/api/users/:id', ({ params }) => {
return HttpResponse.json({ id: Number(params.id), name: 'Alice' });
}),
http.post('/api/orders', async ({ request }) => {
const body = await request.json();
return HttpResponse.json({ id: 100, ...body }, { status: 201 });
}),
];
// mocks/server.js (Node/Vitest 환경)
import { setupServer } from 'msw/node';
import { handlers } from './handlers.js';
export const server = setupServer(...handlers);
// vitest.setup.js
import { beforeAll, afterAll, afterEach } from 'vitest';
import { server } from './mocks/server.js';
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
// 테스트 내에서 특정 케이스만 핸들러 오버라이드
import { http, HttpResponse } from 'msw';
import { server } from './mocks/server.js';
it('서버 오류 시 에러 메시지를 표시한다', async () => {
server.use(
http.get('/api/users/:id', () => HttpResponse.json({ error: 'not found' }, { status: 404 }))
);
await expect(fetchUser(999)).rejects.toThrow('HTTP 404');
});
💡 TIP
onUnhandledRequest: 'error'옵션을 설정하면 핸들러에 등록되지 않은 요청이 실행될 때 테스트가 실패합니다. 의도치 않은 실제 네트워크 요청을 방지하는 좋은 습관입니다.
5. 커버리지 해석의 함정과 변이 테스트(Mutation Testing)
커버리지 지표의 의미와 한계
# Vitest 커버리지 (v8 또는 istanbul)
npx vitest run --coverage
커버리지 지표에는 네 가지 주요 항목이 있습니다.
| 지표 | 의미 |
|---|---|
| Statement | 실행된 구문의 비율 |
| Branch | if/else, 삼항 연산자 분기 실행 비율 |
| Function | 호출된 함수 비율 |
| Line | 실행된 줄 비율 |
⚠️ 주의 커버리지 100%는 버그가 없음을 보장하지 않습니다. 아래 예시를 보세요.
function add(a, b) { return a + b; }
it('add 함수가 실행된다', () => {
expect(add(1, 2)).toBeDefined(); // ✅ 커버리지 100% 달성
// ❌ 하지만 결과가 3인지 검증하지 않음!
});
커버리지는 "테스트되지 않은 코드"를 찾는 도구이지, "테스트가 올바른지"를 보장하지 않습니다.
변이 테스트(Mutation Testing): 테스트 품질 검증
변이 테스트는 소스 코드에 작은 결함(변이, Mutant)을 주입한 뒤, 기존 테스트가 그것을 감지해 실패하는지(Kill) 확인합니다.
원본: if (rate < 0 || rate > 1)
변이1: if (rate <= 0 || rate > 1) ← 테스트가 잡아야 함
변이2: if (rate < 0 && rate > 1) ← 테스트가 잡아야 함
변이3: if (rate < 0 || rate >= 1) ← 테스트가 잡아야 함
# Stryker Mutator (JavaScript/TypeScript)
npm install -D @stryker-mutator/core @stryker-mutator/vitest-runner
npx stryker run
변이 점수(Mutation Score) = 죽인 변이 수 / 전체 변이 수 × 100
변이 점수가 낮다면 테스트가 형식만 갖추고 실제로는 결함을 감지하지 못한다는 신호입니다. 커버리지와 변이 점수를 함께 모니터링하면 테스트 스위트의 실질적인 품질을 파악할 수 있습니다.
6. ESLint·타입 검사·정적 분석과 CI 게이트
ESLint로 런타임 전에 결함 차단
npm install -D eslint @eslint/js
// eslint.config.js (Flat Config, ESLint v9+)
import js from '@eslint/js';
export default [
js.configs.recommended,
{
rules: {
'no-unused-vars': 'error',
'no-undef': 'error',
'eqeqeq': ['error', 'always'], // == 사용 금지
'no-implicit-coercion': 'error', // +str 형변환 금지
// no-floating-promises는 코어 규칙이 아니라 @typescript-eslint 플러그인 규칙입니다.
// 플러그인을 등록한 뒤 '@typescript-eslint/no-floating-promises'로 사용하세요.
},
},
];
// ❌ ESLint가 잡는 결함 예시
const user = getUser();
if (user.role == 'admin') { /* ... */ } // eqeqeq: == 대신 ===
const result = processData(); // no-unused-vars: result 미사용
TypeScript 타입 검사: 가장 강력한 정적 분석
// tsconfig.json (strict 모드)
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
}
}
// ✅ 타입 검사로 런타임 오류 사전 차단
function getFirstItem<T>(arr: T[]): T | undefined {
return arr[0]; // noUncheckedIndexedAccess: 반환 타입이 T | undefined
}
const items: string[] = [];
const first = getFirstItem(items);
// first.toUpperCase(); // ❌ 컴파일 오류: 'first'는 undefined일 수 있음
if (first !== undefined) {
console.log(first.toUpperCase()); // ✅
}
CI 파이프라인에서 품질 게이트 구성
# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
# 1단계: 정적 분석 (가장 빠름)
- name: Type check
run: npx tsc --noEmit
- name: Lint
run: npx eslint . --max-warnings 0
# 2단계: 단위·통합 테스트
- name: Test with coverage
run: npx vitest run --coverage
# 3단계: 커버리지 임계값 강제
- name: Check coverage threshold
run: |
npx vitest run --coverage \
--coverage.thresholds.lines=80 \
--coverage.thresholds.branches=75
💡 TIP
--max-warnings 0옵션은 경고도 CI 실패로 처리합니다. 경고를 허용하면 점차 쌓여 무감각해지므로, 새 프로젝트에서는 처음부터 이 옵션을 적용하는 것이 좋습니다.
pre-commit 훅으로 로컬 게이트
npm install -D husky lint-staged
npx husky init
// package.json
{
"lint-staged": {
"*.{js,ts,jsx,tsx}": [
"eslint --fix",
"vitest related --run"
]
}
}
# .husky/pre-commit
npx lint-staged
이렇게 하면 커밋 전에 변경된 파일에 대해서만 린트와 관련 테스트가 실행되므로, 전체 테스트 스위트 실행 시간 없이도 기본 품질 게이트를 유지할 수 있습니다.
요약
- 테스트 피라미드는 단위 > 통합 > E2E 순으로 비중을 권장하며, 각 계층은 역할이 다르다. 피라미드를 뒤집으면 CI가 느리고 불안정해진다.
- 의미 있는 단언은 구체적인 값과 동작을 검증한다.
toBeTruthy()같은 약한 단언은 버그를 놓친다. - 가짜 타이머(
vi.useFakeTimers)와 마이크로태스크 플러시로 비동기 코드를 결정론적으로 테스트할 수 있다. - MSW는 네트워크 계층을 인터셉트해 HTTP 클라이언트에 무관한 현실적인 API 모킹을 제공한다.
- 커버리지 100%는 충분하지 않다. 변이 테스트(Stryker)로 테스트가 실제로 결함을 감지하는지 검증하라.
- ESLint + TypeScript strict + CI 게이트를 연결하면 결함이 코드 작성 시점 → 커밋 시점 → PR 시점에 순차적으로 차단된다.
연습문제
-
다음
clamp함수에 대해 경계값을 포함한 단위 테스트를 작성하세요.it.each를 사용하여 최소 5개의 케이스를 커버하고, 경계를 넘는 값도 포함하세요.function clamp(value, min, max) { return Math.min(Math.max(value, min), max); }힌트 min === max인 경우, value가 min보다 작은 경우/큰 경우/범위 안인 경우를 모두 생각해 보세요.
-
아래
retryFetch함수는 실패 시 최대 3번 재시도합니다.vi.useFakeTimers()와 MSW(또는vi.fn())를 사용해 "처음 2번 실패 → 3번째 성공"을 테스트하는 코드를 작성하세요.async function retryFetch(url, retries = 3, delay = 1000) { for (let i = 0; i < retries; i++) { try { const res = await fetch(url); if (!res.ok) throw new Error(`HTTP ${res.status}`); return await res.json(); } catch (err) { if (i === retries - 1) throw err; await new Promise(r => setTimeout(r, delay)); } } }힌트
vi.fn().mockRejectedValueOnce(...).mockRejectedValueOnce(...).mockResolvedValue(...)체인을 활용하고,vi.advanceTimersByTimeAsync로 타이머를 진행하세요. -
다음 ESLint 설정을 보고 잘못 적용된 규칙 또는 빠진 규칙을 찾아 수정하세요. 규칙들이 TypeScript 프로젝트에서 어떤 문제를 잡는지 설명하세요.
export default [ { rules: { 'eqeqeq': 'warn', // (A) warn 레벨 'no-unused-vars': 'off', // (B) 비활성화 'no-undef': 'error', // (C) TypeScript 프로젝트에서의 문제 }, }, ];힌트 TypeScript를 사용할 때
no-undef는 tsc가 이미 커버하므로 중복이 되거나 충돌이 날 수 있습니다. -
다음 코드의 커버리지는 90%이지만 변이 점수는 40%입니다. 어떤 테스트를 추가해야 변이 점수를 높일 수 있을지 분석하고 보완 테스트를 작성하세요.
function grade(score) { if (score >= 90) return 'A'; if (score >= 80) return 'B'; if (score >= 70) return 'C'; return 'F'; } // 현재 테스트 it('95점은 A이다', () => expect(grade(95)).toBe('A')); it('75점은 C이다', () => expect(grade(75)).toBe('C')); it('50점은 F이다', () => expect(grade(50)).toBe('F'));힌트 변이 테스트는
>=를>나<=로 바꿉니다. 경계값인 90, 80, 70을 테스트하면 이 변이들을 잡을 수 있습니다.
💡 연습문제 풀이
불러오는 중…
댓글 0
“JavaScript 심화” 강좌에 대한 댓글입니다.