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
실습 파일
파일 경로를 확인하고 같은 프로젝트 안에 저장하세요. 이미지 등 소스 목록에 없는 파일과 실행 안내는 실습 ZIP에 포함되어 있습니다.
전체 코드
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();
});
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 체크리스트에서 구분합니다.
이 글이 도움이 되었나요?
테스트 학습 순서
필수 7개 · 전체 9개
읽음 기록 관리
전체 과정 목차 (9개)
- 필수 학습 · Vite 프로젝트에서 Vitest로 테스트 환경 시작하기
- 필수 학습 · React Testing Library로 클릭·입력 테스트 작성하기
- 선택 참고 · Vitest document is not defined 오류 해결: jsdom 설정 기준
- 필수 선수 · Jest console.error 테스트: try catch 에러 검증 현재 글
- 필수 학습 · React 검색 필터 테스트: 교집합·빈 결과·입력 초기화 검증
- 필수 학습 · TanStack Query 컴포넌트 테스트: QueryClient 격리와 실패·재시도·목록 갱신 검증
- 필수 학습 · 비동기 테스트 고급: 실패·재시도·경쟁 상태를 결정적으로 검증하기
- 필수 학습 · Playwright 첫 E2E 테스트: 로컬 앱 실행부터 결과 확인까지
- 선택 참고 · Playwright 테스트 실패 해결: locator가 요소를 찾지 못할 때 체크리스트
새 글 받아보기
RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.