
프론트엔드든 백엔드든, 테스트를 어디까지 작성해야 하는지는 늘 논쟁거리다. 모든 계층을 촘촘하게 테스트하는 방식도 있고, 핵심 로직에만 집중하는 방식도 있다. 나는 후자에 가깝다. 컨트롤러나 레졸버, 레포지토리 계층까지 전부 테스트하기보다는, 비즈니스 로직이 모여 있는 서비스 계층을 중심으로 테스트를 작성하는 편이다.
이런 선택에는 이유가 있다. 컨트롤러나 레포지토리는 대부분 프레임워크나 ORM의 규칙을 따르는 얇은 계층이고, 실제로 버그가 발생하는 지점은 거의 항상 “의사결정 로직”에 있다. 그래서 테스트 역시 프레임워크 사용법을 검증하기보다는, 내가 작성한 코드의 분기와 규칙을 검증하는 데 집중하는 것이 효율적이라고 생각한다. 이 글에서는 그런 관점에서 Jest를 어떻게 사용하고 있는지를 정리해보려 한다.
Jest를 선택한 이유
Jest는 “편하다”는 말로 설명이 끝나는 테스트 러너다. 설정이 적고, 기본 제공 기능이 많으며, Nest.js와의 궁합도 매우 좋다. 특히 별도의 설정 없이도 mock, spy, matcher가 한 번에 제공된다는 점은 테스트 작성의 진입 장벽을 크게 낮춰준다.
무엇보다 Jest는 **“테스트 프레임워크”이면서 동시에 “테스트 도구 상자”**다. assertion, mocking, fake timer, coverage 측정까지 테스트에 필요한 거의 모든 것을 하나의 문법 체계로 제공한다. 덕분에 테스트 코드 역시 애플리케이션 코드처럼 일관된 스타일을 유지할 수 있다.
테스트의 기본 구조
Jest 테스트는 기본적으로 describe와 it(test) 블록으로 구성된다. 구조 자체는 단순하지만, 나는 이 구조를 의미 단위로 분리하는 데 신경을 쓰는 편이다.
describe('AuthService', () => {
describe('signIn', () => {
it('유효한 이메일과 비밀번호가 주어지면 토큰을 반환한다', () => {
// ...
});
});
describe('signUp', () => {
it('이미 존재하는 이메일이면 예외를 던진다', () => {
// ...
});
});
});바깥
describe는 테스트 대상안쪽
describe는 메서드 혹은 기능 단위it은 하나의 기대 결과
이렇게 나누어두면, 테스트 실패 로그만 봐도 어디서 어떤 규칙이 깨졌는지 바로 파악할 수 있다.
beforeEach와 테스트 격리
나는 거의 모든 테스트에서 beforeEach를 사용해 테스트 환경을 초기화한다. 테스트 간 상태 공유는 생각보다 많은 문제를 만든다. 특히 mock 함수의 호출 횟수나 반환값이 이전 테스트의 영향을 받는 경우는 디버깅 난이도를 크게 높인다.
beforeEach(() => {
jest.clearAllMocks();
});Nest.js 테스트에서는 Test.createTestingModule을 beforeEach에서 호출해 매 테스트마다 새로운 DI 컨테이너를 생성한다. 느릴 수는 있지만, side effect를 제거하는 비용이라고 생각하면 충분히 감수할 만하다.
expect와 matcher
Jest의 expect는 테스트의 핵심이다. 나는 한 테스트에서 너무 많은 expect를 쓰는 것을 선호하지 않는다. 하나의 테스트는 하나의 규칙을 검증하는 데 집중하는 편이 좋다.
expect(result).toBeDefined();
expect(result.email).toBe(email);자주 사용하는 matcher는 다음 정도로 고정되어 있다.
toBe,toEqualtoHaveBeenCalled,toHaveBeenCalledWithtoThrow,rejects.toThrowtoMatchObject
특히 객체 전체를 비교할 때는 toEqual보다는 toMatchObject를 더 자주 쓴다. 테스트는 “정확한 구현”보다 의미 있는 결과를 검증하는 게 목적이기 때문이다.
Mocking
Jest에서 가장 강력한 기능은 단연 mocking이다. 나는 실제 구현을 호출하지 않아도 되는 모든 의존성은 mock으로 대체한다.
const repository = {
findById: jest.fn(),
create: jest.fn(),
};mock 함수는 단순히 값을 반환하는 용도뿐 아니라, 호출 여부와 호출 인자를 검증하는 데도 사용한다.
expect(repository.create).toHaveBeenCalledWith({
email,
password,
});이 방식은 “이 메서드가 호출되었는가?”보다 “어떤 의도를 가지고 호출되었는가?”를 검증하는 데 적합하다.
spyOn
이미 존재하는 객체의 메서드를 감시하고 싶을 때는 jest.spyOn을 사용한다. 특히 Date, Math, console 같은 전역 객체를 다룰 때 유용하다.
const spy = jest.spyOn(console, 'error').mockImplementation(() => {});테스트가 끝난 뒤에는 반드시 원래 구현으로 복구한다.
spy.mockRestore();spy는 mock보다 “침습적”이기 때문에, 필요한 경우에만 사용하는 게 좋다.
비동기 테스트
Jest에서 비동기 테스트는 크게 두 가지 방식으로 작성할 수 있다.
await expect(service.run()).resolves.toBe(true);
await expect(service.run()).rejects.toThrow();나는 try/catch로 감싸는 방식보다 이 문법을 더 선호한다. 실패했을 때의 로그가 훨씬 명확하기 때문이다.
Fake Timer
시간에 의존하는 로직을 테스트할 때는 fake timer가 유용하다.
jest.useFakeTimers();
setTimeout(fn, 1000);
jest.advanceTimersByTime(1000);
expect(fn).toHaveBeenCalled();다만 fake timer는 전역 상태를 바꾸기 때문에, 테스트가 끝난 뒤에는 반드시 원래 상태로 되돌린다.
jest.useRealTimers();마무리
내가 Jest를 쓰면서 가장 중요하게 생각하는 점은 테스트가 코드의 사용 설명서가 되어야 한다는 것이다. 테스트를 읽으면 이 메서드가 어떤 입력을 받는지, 어떤 상황에서 실패하는지, 그리고 무엇을 보장하는지가 드러나야 한다. 그런 이유로 테스트 코드에는 종종 구현 코드보다 더 많은 설명이 들어간다. 테스트는 단순한 검증 수단이 아니라, 실행 가능한 문서다.
Jest는 이런 테스트를 작성하는 과정을 어렵게 만들지 않는다. 설정에 시간을 쓰지 않아도 되고, 필요한 기능들이 기본적으로 잘 갖춰져 있어 테스트 자체에 집중할 수 있다. 나는 Jest를 통해 완벽한 커버리지를 달성하는 것을 목표로 삼지 않는다. 대신 중요한 규칙이 깨지지 않도록 최소한의 보호막을 두는 데 의미를 둔다.
테스트는 품질을 보장하는 마지막 단계라기보다는, 설계를 점검하는 첫 번째 단계에 가깝다고 생각한다. 테스트를 작성하다 보면 책임이 모호한 코드가 보이고, 의도가 분명하지 않은 로직이 드러난다. Jest는 그 과정을 가장 자연스럽게 만들어주는 도구 중 하나다.
더 읽어보기
2026.05.09
TanStack Router 파일 기반 라우팅과 타입 안전하게 React 구조 잡기
React 앱의 라우팅은 처음에는 별일 아닌 것처럼 보인다. /about으로 가면 About 화면을 보여주고, /posts/1로 가면 1번 글을 보여주면 된다. 문제는 앱이 조금만 커져도 URL이 더 이상 주소표시줄의 문자열로만 남아 있지 않다는 데 있다. URL은 권한의 경계가 되고,…
2026.05.05
Changesets 실무 운영 가이드: 내부 의존성, prerelease, 운영 Q&A
이 글은 Changesets로 모노레포 릴리즈를 관리하는 시리즈의 3편이다. 1편에서는 changeset 파일을 작성하는 방법을, 2편에서는 GitHub Actions로 Version Packages PR과 publish를 자동화하는 흐름을 다뤘다. 이번 글에서는 실제 운영 중 자주 헷…
2026.05.05
Changesets와 GitHub Actions로 릴리즈 자동화하기
이 글은 Changesets로 모노레포 릴리즈를 관리하는 시리즈의 2편이다. 1편에서 changeset 파일로 변경 의도를 기록했다면, 이번 글에서는 그 파일을 기준으로 GitHub Actions가 Version Packages PR을 만들고 publish까지 이어지는 흐름을 다룬다.…
2026.05.05
Changesets로 모노레포 버전 관리 시작하기
이 글은 Changesets로 모노레포 릴리즈를 관리하는 시리즈의 1편이다. 여기서는 Changesets를 왜 도입하는지, 어떤 파일을 남기는지, PR 단계에서 semver 판단을 어떻게 기록하는지에 집중한다. 1편: Changesets로 모노레포 버전 관리 시작하기 2편: Change…
2026.06.07
AI 에이전트의 비밀값을 macOS Keychain에 맡기기
AI 에이전트나 스킬을 만들다보면 비밀값을 어떻게 관리하면 좋을지 하는 생각을 자주 하게 된다. API를 호출하려면 API Key가 필요하고, 특정 기능을 자동화하는 과정에서 아이디와 비밀번호가 필요할 수도 있다. 그런데 그 값을 프롬프트에 박아버리면 대화 기록에 남고, 명령어 인자로…
2026.06.01
React Server Components를 위한 컴포넌트 아키텍처
이 포스트는 Vercel의 Next.js 팀 소속 개발자 Aurora Scharff가 자신의 블로그에 올린 Component Architecture for React Server Components 게시글을 번역한 것이다. 번역하는 과정에서 다소 의역이 있을 수 있으며, 일부 번역에는…
2026.05.26
차트는 멈췄는데 윈도우가 움직인다
상황 어느날 서비스를 살펴보시던 팀장님께서 이런 말씀을 slack에 남기셨다. 진호님, 예측 차트에서 zoom을 계속하면 어느 순간 라인 차트가 아니라 단일 스캐터 차트처럼 보이는 데, 이거 수정하면 좋을 거 같아요. 어느정도 zoom을 하면 그 이후로는 zoom이 안 되도록 할 수 없…
2026.05.21
피자가게로 이해하는 디자인 패턴
에이든 피자는 처음부터 복잡한 시스템을 만들 생각이 없었다. 처음에는 메뉴 몇 개만 만들면 됐다. 그런데 손님은 커스텀 주문을 넣기 시작했고, 주방은 상태를 나눠야 했고, 결제와 배달앱과 알림이 하나씩 붙었다. 코드도 가게를 닮는다. 장사가 잘될수록 이상하게 더 쉽게 망가진다. 디자인…
댓글
댓글을 불러오는 중...