Jest에서 try/catch 반환값과 console.error 호출은 어떻게 검증하나요?
try/catch 안에서 console.error를 출력했다고 Jest 테스트가 자동으로 실패하는 것은 아닙니다. 함수가 약속한 반환값·실패 객체·Promise rejection을 먼저 검증하세요.
적용 조건·범위: 선수 지식은 함수, try/catch, async/await와 expect입니다. 테스트 구성에 익숙하지 않다면 첫 테스트의 준비·실행·검증 구조를 먼저 읽으세요. 이 글의 실행 도구는 Jest이며 Vitest 설정을 함께 복사하지 않습니다. Node v24.19.0과 Jest 30.2.0에서 검증했고 DOM은 필요하지 않습니다.
본문 근거: Jest 비동기 테스트 · spyOn과 복구 API · restoreMocks 설정
Jest에서 실패 계약과 로그 계약 구분하기
try/catch가 오류를 처리하면 호출자는 정상 반환값을 받을 수 있습니다. 테스트는 함수가 약속한 반환값·실패 결과·Promise rejection부터 검증하고, 오류 로그도 제품 계약일 때 console.error spy를 추가합니다. 이 실습은 Jest 고유의 spyOn, rejects, restoreMocks를 실제 테스트로 확인합니다.
선수 지식은 함수, try/catch, async/await와 expect입니다. 테스트 구성에 익숙하지 않다면 첫 테스트의 준비·실행·검증 구조를 먼저 읽으세요. 이 글의 실행 도구는 Jest이며 Vitest 설정을 함께 복사하지 않습니다. Node v24.19.0과 Jest 30.2.0에서 검증했고 DOM은 필요하지 않습니다.
목차
1. try/catch가 있어도 assertion은 필요합니다

함수 안에서 오류를 잡고 로그만 남기면 함수는 정상 종료할 수 있습니다. console.error 출력 자체는 기본 Jest의 실패 조건이 아닙니다. 그렇다고 Jest가 명시적인 throw만 실패로 판단한다는 뜻은 아닙니다. assertion 불일치, 테스트가 반환하거나 await한 Promise의 예상하지 못한 rejection, 제한 시간 초과도 테스트 실패입니다. 콘솔 오류를 실패로 바꾸는 별도 설정이나 플러그인을 사용하는 프로젝트는 동작이 다를 수 있습니다.
test('검증이 없는 호출은 반환 결과를 확인하지 않는다', async () => {
await loadProfileFallback(async () => { throw new Error('offline'); });
});
위 예제는 안 좋은 테스트의 형태를 보여 줍니다. 함수가 null을 반환하든 잘못된 객체를 반환하든 확인하지 않습니다. catch를 없애서 테스트를 빨갛게 만드는 대신, 호출자가 알아야 할 실패 정보를 먼저 정합니다. 오류를 처리한다는 이유만으로 런타임이 안전해지는 것은 아닙니다. 중요한 실패를 성공값으로 숨기면 사용자와 상위 계층이 복구할 기회를 잃습니다.
2. 반환값·rejection·로그를 각각 약속하기
| 함수 계약 | 먼저 검사할 대상 | 로그 spy |
|---|---|---|
| 오류를 상위로 전달 | await expect(…).rejects | 로그 약속이 있을 때만 |
| 실패 결과 객체 반환 | ok와 error 필드 | 기본적으로 필요 없음 |
| 대체값 반환, 실패 로그 1회 | 대체값 null | 횟수와 인수도 검사 |
다음 세 함수는 같은 request를 받지만 실패를 전달하는 방식이 다릅니다. request 주입으로 실제 서버 없이 성공과 실패를 재현합니다. loadProfileFallback에서만 오류 메시지와 원본 오류 객체를 한 번 기록하는 계약을 명시합니다. 운영 코드의 모든 오류에 콘솔 spy가 필요한 것은 아닙니다.
profile.cjs
rejects와 console.error 검증을 나누는 기준
loadProfile은 request의 오류를 전달하므로 rejects를 사용합니다. loadProfileResult와 loadProfileFallback은 catch 이후 정상 완료하므로 resolves나 await한 반환값을 검사합니다. rejects matcher도 Promise이므로 반드시 await하거나 return해야 합니다. 수동 catch 안에만 expect를 넣으면 오류가 발생하지 않았을 때 검증 자체가 생략될 수 있습니다. 이 경우 expect.assertions로 실행 횟수를 보완할 수 있지만, 단순 rejection 검증에는 rejects가 명확합니다.
3. spy 설정과 원복을 실제로 검증하기

jest.spyOn만 호출하면 기본적으로 원본 메서드도 실행합니다. mockImplementation(() => {})을 붙이면 해당 테스트의 콘솔 출력만 막으면서 호출을 기록합니다. 오류 로그를 계약으로 검사하는 테스트에서 spy를 만들고 반환값과 함께 검증합니다.
package.json
{
"name": "jest-error-contract-practice",
"version": "1.0.0",
"private": true,
"scripts": {
"test": "jest --runInBand"
},
"devDependencies": {
"jest": "30.2.0"
},
"jest": {
"testEnvironment": "node",
"restoreMocks": true
}
}
restoreMocks: true는 각 테스트가 시작하기 전에 spy의 원본 구현을 복구합니다. 따라서 spy는 파일 최상단에서 한 번 만들지 말고 해당 test 또는 beforeEach 안에서 만드세요. 아래 다섯 번째 테스트는 앞 테스트가 바꾼 console.error가 원본 함수로 돌아왔는지 실제로 확인합니다. 이 순서 검증이 포함된 실습은 npm test 명령대로 실행합니다.
profile.test.cjs
beforeEach / afterEach를 선택하는 경우
같은 describe 블록의 모든 테스트가 로그 계약을 검사한다면 다음처럼 공통 훅을 사용할 수 있습니다. 이 방식은 위 파일을 대체할 때 선택하는 패턴입니다. 실습 파일은 restoreMocks 설정과 테스트 내부 spy를 사용합니다.
let consoleSpy;
beforeEach(() => {
consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
jest.restoreAllMocks();
});
clearAllMocks는 호출 기록을 지우며 원본 구현 복구를 대신하지 않습니다. restoreAllMocks는 spyOn 등으로 만든 복구 가능한 mock에 적용합니다. console.error = jest.fn()처럼 직접 대입한 함수는 원본을 별도로 보관하고 되돌려야 합니다. 전역 콘솔을 바꾸는 테스트를 test.concurrent로 동시에 실행하지 마세요.
4. 실행 결과와 테스트가 잡아야 할 오류
npm ci
npm test
ZIP에는 정확한 의존성 버전을 기록한 package-lock.json이 들어 있습니다. Node v24.19.0, Jest 30.2.0에서 테스트 6개가 모두 통과했습니다. 실습은 네트워크 대신 주입한 request만 호출합니다.
Test Suites: 1 passed, 1 total
Tests: 6 passed, 6 total
연습할 때 loadProfileFallback의 return null을 return {}로 바꿔 보세요. 반환값 검증이 실패해야 합니다. 로그를 제거하면 호출 횟수 검증이 실패해야 하고, 오류 결과를 { ok: true }로 바꾸면 결과 계약 검증이 실패해야 합니다. 변경을 되돌리면 다시 6개 통과가 완료 기준입니다. 성공 경로의 not.toHaveBeenCalled 검증도 유지해야 성공한 요청에서 잘못된 오류 로그가 나오는 회귀를 막습니다.
FAQ
console.error 대신 throw하면 되나요? 호출자가 오류를 처리해야 하는 계약이라면 전달하는 편이 맞습니다. 테스트를 쉽게 하려고 계약을 바꾸지 않습니다. 대체값이나 결과 객체를 반환하는 구조도 실패를 명확히 관찰할 수 있어야 합니다.
console.warn도 가능한가요? 가능합니다. 해당 로그가 명시된 계약일 때 spyOn(console, 'warn')으로 같은 방식으로 검사합니다.
spy를 모든 테스트에서 설정해도 되나요? 필요한 범위의 describe나 개별 테스트에 두세요. 전체 테스트의 로그를 무조건 가리면 예상하지 못한 오류를 놓칠 수 있습니다. 공통 설정을 쓴다면 복구와 호출 기록 분리를 함께 관리합니다.
면접에서는 어떻게 설명하나요? 함수의 실패 전달 방식, 비동기 matcher를 기다리는 이유, 로그 검증의 적용 조건, spy 원복 순서로 설명하면 됩니다. console.error 호출 검증과 실제 실패 결과 검증은 각각의 계약에 맞게 작성합니다.
실행 가능한 예제 다운로드
본문과 같은 전체 실습 ZIP 다운로드. 압축을 풀고 README.md의 명령을 실행하세요. 의존성 폴더와 빌드 산출물은 포함하지 않습니다.
공식 문서와 다음 단계
Jest 비동기 테스트, spyOn과 복구 API, restoreMocks 설정을 확인했습니다(2026-09-12).
다음은 실패·재시도·경쟁 상태 고급 실습입니다. DOM 환경의 오류는 jsdom 설정, 브라우저 선택자 문제는 locator 체크리스트에서 구분합니다.
선택 실습: 같은 프로젝트로 연습하기
TaskFlow는 작업을 추가·완료·삭제하는 할 일 앱입니다. 이 선택 실습에서는 앱의 동작을 자동 테스트로 확인합니다.
이번 회차 추가·수정 파일: app/lib/tasks-api.ts , app/lib/tasks-api.test.ts
선택: 연재 프로젝트와 전체 자료
연재 프로젝트를 이어 만드는 분을 위한 자료입니다. 이 글의 개념과 예제만 찾으셨다면 필요한 파일과 실행 방법만 펼쳐보세요.
누적 프로젝트 적용
시작 위치: 아래 기존 환경·파일 구성에서 시작합니다. 이 글의 완료 ZIP은 결과 확인용입니다.
시작 체크포인트: Vitest document is not defined 오류 해결: jsdom 설정 기준의 전체 파일·변경 내용에서 이어갑니다.
기존 목록 안의 fetch를 나중에 공용화할 tasks-api.ts와 계약 테스트를 추가합니다. 이 회차에서는 새 모듈을 직접 검사하며 다음 검색 회차에서 실제 UI에 연결합니다. 상단 Jest 독립 예제와 달리 누적 프로젝트는 Vitest를 사용합니다. 성공 배열과 500 rejection 두 개가 통과해야 합니다.
이번 회차 추가·수정 파일: app/lib/tasks-api.ts, app/lib/tasks-api.test.ts
전체 파일·코드 보기
app/lib/tasks-api.ts — 전체 파일
import type { Task } from './types';
export async function getTasks(signal?: AbortSignal): Promise<Task[]> {
const response = await fetch('/api/tasks', { cache: 'no-store', signal });
if (!response.ok) throw new Error('조회 실패');
return response.json();
}
app/lib/tasks-api.test.ts — 전체 파일
import { expect, test, vi } from 'vitest';
import { getTasks } from './tasks-api';
test('성공 응답은 배열을 돌려준다', async () => {
const rows = [{ id: 'a', title: '첫 작업', completed: false, description: '', ownerId: 'demo' }];
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(Response.json(rows)));
await expect(getTasks()).resolves.toEqual(rows);
});
test('HTTP 500은 정상 배열로 위장하지 않는다', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('', { status: 500 })));
await expect(getTasks()).rejects.toThrow('조회 실패');
});
실행 결과 기록: 명령·종료 코드·테스트 개수·실패 이유를 남깁니다. 타입 검사와 jsdom 통과는 실제 브라우저·인증 서버·영속 저장소 검증이 아닙니다.
원래 제안된 확장 기준: 아래 검증 목표 중 현재 회차 파일이 다루는 범위는 위 실행 안내를 따릅니다. 이름만 적힌 testing-step 체크포인트는 다운로드 파일명이 아닙니다.
직접 확인: 4회차 테스트 기준
테스트 대상: 독립 Jest 예제의 rejection·로그와 TaskFlow app/lib/tasks-api.test.ts. 기존 독립 예제는 그대로 연습하고, 아래 기준은 Next.js TaskFlow에 적용할 때 확인합니다. 공개 글의 코드 예시와 실제 프로젝트의 파일 경로·타입이 다른 경우 현재 소스를 먼저 대조하세요.
- 상단 Jest 예제에서는 반환값·rejects·console.error spy를 서로 다른 계약으로 검사합니다. TaskFlow에서는 fetch 성공 응답이 작업을 반환하고 HTTP 500 응답이 rejection이 되는지 Vitest로 검증합니다. 에러 분기를 잠시 제거해 실패를 관찰한 뒤 복구하고 spy 및 전역 fetch mock을 정리합니다.
- 실행 명령:
npm run test:run && npm run build. 실제 명령의 종료 코드와 테스트 파일·개수·실패 메시지를 기록합니다.
통과 예상: 성공 응답은 작업 배열, 500 응답은 예상 Error로 처리되고 다른 테스트가 spy에 오염되지 않아야 합니다.
실패 예상: 에러 분기를 제거하면 rejects.toThrow 단언이 실패해야 합니다.
실패 진단: rejects가 아니라 resolve되면 구현의 response.ok 검사와 mock 응답을 확인합니다. Jest의 jest.spyOn과 Vitest의 vi.spyOn을 같은 파일에 섞지 않습니다.
검증 기록: 이 글의 실습 절차는 작성했지만, 이번 개편에서 TaskFlow 원본 프로젝트의 테스트·빌드를 실행하지 않았습니다. 따라서 testing-step-04-failure-contract 완료와 테스트 통과는 미확인입니다. 실행 로그를 확인한 뒤에만 완료로 바꾸세요.
다음 학습: 5회차 React 검색·상태 필터
전체 코드
package.json
{
"name": "jest-error-contract-practice",
"version": "1.0.0",
"private": true,
"scripts": {
"test": "jest --runInBand"
},
"devDependencies": {
"jest": "30.2.0"
},
"jest": {
"testEnvironment": "node",
"restoreMocks": true
}
}
profile.cjs
async function loadProfile(request) {
return await request();
}
async function loadProfileResult(request) {
try {
return { ok: true, data: await request() };
} catch (error) {
return { ok: false, error };
}
}
async function loadProfileFallback(request) {
try {
return await request();
} catch (error) {
console.error('프로필 조회 실패', error);
return null;
}
}
module.exports = { loadProfile, loadProfileResult, loadProfileFallback };
profile.test.cjs
const {
loadProfile,
loadProfileResult,
loadProfileFallback,
} = require('./profile.cjs');
const originalConsoleError = console.error;
const failure = new Error('unavailable');
const rejectRequest = () => Promise.reject(failure);
test('rejection contract: await the matcher', async () => {
await expect(loadProfile(rejectRequest)).rejects.toBe(failure);
});
test('result contract: failure remains observable without a log', async () => {
await expect(loadProfileResult(rejectRequest)).resolves.toEqual({
ok: false,
error: failure,
});
});
test('result contract: success contains data', async () => {
await expect(loadProfileResult(async () => ({ name: '해비' }))).resolves.toEqual({
ok: true,
data: { name: '해비' },
});
});
test('fallback contract includes one error log and null', async () => {
const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
await expect(loadProfileFallback(rejectRequest)).resolves.toBeNull();
expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenCalledWith('프로필 조회 실패', failure);
});
test('restoreMocks restores the original before the next test', () => {
expect(console.error).toBe(originalConsoleError);
expect(jest.isMockFunction(console.error)).toBe(false);
});
test('successful fallback path does not log an error', async () => {
const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
const profile = { name: '해비' };
await expect(loadProfileFallback(async () => profile)).resolves.toBe(profile);
expect(spy).not.toHaveBeenCalled();
});
실습 파일
파일 경로를 확인하고 같은 프로젝트 안에 저장하세요. 이미지 등 소스 목록에 없는 파일과 실행 안내는 실습 ZIP에 포함되어 있습니다.
글 소개·학습 준비·전체 목차
이 글이 도움이 되었나요?
분류별 과정 이동 (선택)
읽음 기록
읽음 — / 전체 9개 · 필수 7개
새 글 받아보기
RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.