Jest를 사용한 단위 테스트
Jest 30과 ts-jest로 타입스크립트 테스트 환경을 설정하고, 정상과 오류, 특이한 입력을 검사하는 단위 테스트를 쓴 뒤, 자주 쓰는 matcher와 fetch를 대신하는 모의 함수로 바깥 의존성을 통제하는 방법을 다룹니다.
단위 테스트(unit test)는 함수나 클래스의 메서드처럼 작은 단위를 떼어 내, 정해 둔 입력에 기대한 결과를 내는지 확인하는 테스트입니다. 실행이 빠르고 실패한 곳이 좁게 드러나므로, 코드를 고칠 때마다 돌려 기존 동작이 깨지지 않았는지 바로 확인할 수 있습니다. 잘 쓴 테스트는 그 함수를 어떻게 쓰고 무엇을 기대하는지 보여 주는 실행할 수 있는 문서가 되기도 합니다.
테스팅과 성능 최적화에서는 Vite 프로젝트에 Vitest를 썼습니다. 이 글은 Vite를 쓰지 않는 Node.js 프로젝트에서 많이 쓰는 Jest로 단위 테스트를 작성하고, 바깥 의존성을 모의 함수로 바꾸어 검사하는 방법을 봅니다.
Jest와 ts-jest 설정
Jest는 테스트를 찾아 실행하는 실행기, expect로 결과를 검사하는 단언, 함수를 가짜로 바꾸는 모의(mock) 기능을 한 패키지에 담은 테스트 프레임워크입니다. Jest는 타입스크립트를 직접 실행하지 못하므로, 테스트 파일을 변환해 주는 ts-jest를 함께 씁니다.
mkdir my-jest-ts-app
cd my-jest-ts-app
npm init -y
npm install --save-dev jest ts-jest @types/jest @types/node [email protected]2026년 10월 현재 Jest는 30이고, ts-jest 29.4는 Jest 30과 TypeScript 6.0까지를 지원합니다. ts-jest는 타입스크립트 컴파일러의 API로 파일을 변환하는데 TypeScript 7.0은 그 API를 아직 제공하지 않으므로, 여기서는 이 교재의 기준인 6.0을 함께 설치합니다. @types/jest는 describe, test, expect 같은 Jest의 전역 함수에 타입을 붙여 줍니다.
{
"compilerOptions": {
"target": "es2023",
"module": "nodenext",
"rootDir": "src",
"outDir": "dist",
"types": ["jest", "node"],
"strict": true,
"skipLibCheck": true
},
"include": ["src"]
}module.exports = {
preset: "ts-jest",
testEnvironment: "node",
testMatch: ["<rootDir>/src/**/*.test.ts"],
};npm init -y가 만든 package.json에는 "type": "commonjs"가 들어 있으므로 이 프로젝트의 파일은 CommonJS 모듈이고, module: "nodenext"는 그 규칙대로 검사하고 CommonJS로 출력합니다. TypeScript 6.0부터 types의 기본값이 비어 있으므로, 테스트 파일에서 Jest의 전역 함수를 쓰려면 "types"에 jest를 직접 적어야 합니다. preset: "ts-jest"는 .ts 파일을 ts-jest로 변환하게 하고, testEnvironment: "node"는 브라우저의 DOM 없이 Node.js 환경에서 테스트를 실행합니다. 다만 module이 nodenext이고 isolatedModules를 켜지 않았으므로, ts-jest는 파일을 변환할 때 Using hybrid module kind (Node16/18/Next) is only supported in "isolatedModules: true".로 시작하는 경고를 찍습니다. 이 경고는 테스트 결과를 바꾸지 않으며, 경고가 권하는 대로 isolatedModules를 켜면 ts-jest가 타입 검사 없이 변환만 하게 되므로 여기서는 켜지 않습니다. 경고를 보지 않으려면 jest.config.js에 transform: { "^.+\\.tsx?$": ["ts-jest", { diagnostics: { ignoreCodes: [151002] } }] }를 더해 ts-jest의 diagnostics.ignoreCodes에 151002를 넣습니다.
package.json의 scripts에는 "test": "jest", "test:watch": "jest --watch", "test:cov": "jest --coverage"를 더합니다. npm test는 모든 테스트를 한 번 실행하고, test:watch는 파일이 바뀔 때마다 관련 테스트를 다시 실행하며, test:cov는 테스트가 실행한 코드의 비율을 보여 주는 커버리지 보고서를 만듭니다. --watch는 바뀐 파일을 Git이나 Mercurial로 찾으므로, 이 폴더에서 git init을 하지 않았다면 --watch is not supported without git/hg를 출력하고 멈춥니다. 그때는 git init을 하거나, 바뀔 때마다 모든 테스트를 다시 실행하는 jest --watchAll을 씁니다. Vitest와 달리 ts-jest는 변환하면서 타입도 검사해, 타입 오류가 있는 테스트 파일은 실패로 보고합니다.
더 깊이: Jest 30에서 달라진 것
예전 버전의 Jest로 쓴 글이나 설정을 따라 할 때는 Jest 30 발표에 적힌 바뀐 점을 먼저 확인합니다. Jest 30은 Node.js 14, 16, 19, 21의 지원을 끊었고, 함께 쓸 수 있는 타입스크립트의 최소 버전을 5.4로 올렸습니다. jest-environment-jsdom이 쓰는 jsdom도 21에서 26으로 바뀌었습니다. 또 expect의 별칭 matcher 여러 개가 없어졌는데, 발표는 eslint-plugin-jest의 자동 수정 기능으로 옛 이름을 바꿀 수 있다고 안내합니다. 명령줄 옵션 --testPathPattern의 이름도 --testPathPatterns로 바뀌었습니다.
검사의 결과가 달라지는 변경도 있습니다. toEqual 같은 객체 matcher는 이제 기본으로 열거할 수 없는(non-enumerable) 속성을 비교에서 뺍니다. 처음에 거부되었다가 나중에 catch가 붙는 Promise도 실패로 잘못 보고하지 않도록 고쳐졌습니다. 이 밖에 .mts와 .cts 파일을 따로 설정하지 않아도 다루고, Jest 설정 파일을 타입스크립트로 쓸 수 있게 되었습니다.
첫 단위 테스트
테스트할 함수를 src/math.ts에 만듭니다. add는 두 수를 더하고, divide는 0으로 나누려 하면 결과를 돌려주는 대신 오류를 던집니다. 정상적인 결과와 예외를 모두 테스트해 볼 수 있도록 고른 두 함수입니다.
export function add(a: number, b: number): number {
return a + b;
}
export function divide(a: number, b: number): number {
if (b === 0) {
throw new Error("Cannot divide by zero");
}
return a / b;
}테스트 파일은 테스트할 파일 옆에 .test.ts를 붙인 이름으로 둡니다. 이 프로젝트는 jest.config.js의 testMatch로 src 아래에서 이름이 .test.ts로 끝나는 파일만 테스트로 찾으므로, 이름만 맞추면 따로 등록하지 않아도 됩니다. testMatch를 적지 않으면 Jest는 기본으로 __tests__ 폴더의 파일과 이름이 .test나 .spec으로 끝나는 파일을 찾습니다. 다음 파일의 toBeCloseTo(0.3, 10)은 부동소수점 오차 때문에 0.1 + 0.2가 정확히 0.3이 되지 않아서 toBe 대신 쓴 것입니다.
import { add, divide } from "./math";
describe("add", () => {
test("adds two integers", () => {
expect(add(1, 2)).toBe(3);
expect(add(-1, 1)).toBe(0);
});
test("adds decimals within a tolerance", () => {
expect(add(0.1, 0.2)).toBeCloseTo(0.3, 10);
});
});
describe("divide", () => {
test("divides two numbers", () => {
expect(divide(7, 2)).toBe(3.5);
expect(divide(0, 5)).toBe(0);
});
test("throws when dividing by zero", () => {
expect(() => divide(10, 0)).toThrow("Cannot divide by zero");
});
});describe는 관련된 테스트를 묶고, test는 하나의 테스트를 정의하며, expect(값) 뒤의 matcher가 그 값을 검사합니다. npm test를 실행하면 Jest가 src/math.test.ts를 찾아 네 테스트를 실행하고, 모두 통과했다는 요약을 보여 줍니다. 하나라도 실패하면 어느 expect에서 기대한 값과 실제 값이 무엇이었는지 함께 출력됩니다.
두 번째 테스트에서 toBe 대신 toBeCloseTo를 쓴 데는 이유가 있습니다. 자바스크립트의 숫자는 이진 부동소수점이어서 add(0.1, 0.2)의 결과는 0.30000000000000004이고, toBe(0.3)는 실패합니다. toBeCloseTo(0.3, 10)은 소수점 아래 10자리까지 같으면 통과시키지만, 금액처럼 정확한 소수 계산이 필요한 곳이라면 근삿값으로 비교할 것이 아니라 정수 단위로 계산하는 방식부터 정해야 합니다.
예외를 검사할 때는 expect(divide(10, 0))이 아니라 expect(() => divide(10, 0))처럼 함수로 감쌉니다. 감싸지 않으면 expect가 부르기도 전에 예외가 던져져 테스트 자체가 실패하기 때문입니다. 정상 입력뿐 아니라 0으로 나누기처럼 오류가 나야 하는 입력과, 0을 나누는 것처럼 결과가 특별한 입력도 함께 검사합니다.
자주 쓰는 matcher
matcher는 expect의 결과에 붙여 값을 검사하는 메서드입니다. 자주 쓰는 것은 다음과 같습니다.
| 분류 | matcher | 검사하는 것 |
|---|---|---|
| 같음 | toBe(value) | Object.is로 같은지, 원시 값과 같은 참조에 씁니다. |
| 같음 | toEqual(value) | 객체와 배열의 내용이 재귀적으로 같은지 봅니다. |
| 같음 | toStrictEqual(value) | toEqual보다 엄격하게 undefined 속성과 클래스까지 비교합니다. |
| 참과 거짓 | toBeNull(), toBeUndefined(), toBeDefined() | null인지, undefined인지, undefined가 아닌지 봅니다. |
| 숫자 | toBeGreaterThan(n), toBeLessThanOrEqual(n) | 크기를 비교합니다. |
| 숫자 | toBeCloseTo(n, digits) | 정한 자릿수까지 같은지 봅니다. |
| 문자열 | toMatch(regexp) | 정규 표현식이나 부분 문자열과 맞는지 봅니다. |
| 배열 | toContain(item), toContainEqual(item) | 요소가 있는지, 내용이 같은 요소가 있는지 봅니다. |
| 예외 | toThrow(error) | 함수가 예외를 던지는지, 그 메시지나 종류를 봅니다. |
모든 matcher 앞에는 .not을 붙여 반대를 검사할 수 있습니다. expect({ id: 1 }).toBe({ id: 1 })은 두 객체가 서로 다른 참조여서 실패하고 toEqual은 통과하므로, 객체를 비교할 때는 참조를 볼지 내용을 볼지에 따라 matcher를 고릅니다.
모의 함수로 바깥 의존성 통제하기
테스트할 코드가 네트워크나 데이터베이스, 시계 같은 바깥 시스템에 기대면, 그 시스템의 상태에 따라 테스트의 결과가 달라집니다. 단위 테스트에서는 이런 의존성을 정해 둔 대로만 동작하는 모의 함수로 바꾸어, 테스트할 코드의 동작만 확인합니다.
OBSERVABLE TEST CONTRACT
단위 테스트는 구현이 아니라 관찰 가능한 계약을 고정한다
본문 fetchUser 테스트는 fetch 대역을 사용해 실제 서버를 호출하지 않습니다. 반환값·오류와 요청 URL 계약을 검사하고, clearAllMocks는 호출 기록만 지우며 restoreAllMocks는 spy의 원래 구현을 복원합니다.
- Arrange
정상·경계·오류에 필요한 입력을 준비합니다.
- 경계 통제
외부 fetch를 대역으로 통제합니다. 실제 HTTP 통합은 별도입니다.
- Act
한 동작을 실행하며 비동기 결과는 await합니다.
- Assert
반환값·오류·외부 요청 계약을 검사합니다.
- Cleanup
각 테스트 호출 기록을 정리하고 suite 이후 spy를 복원합니다.
- Regression
구현 세부가 바뀌어도 유지할 공개 계약을 남깁니다.
- 핵심 경계
- 진행·선택 경로
그림처럼 테스트는 필요한 입력을 준비하고, 바깥 의존성을 대역으로 통제한 뒤, 한 동작을 실행해 돌려준 값이나 오류, 바깥으로 보낸 요청을 검사합니다. 끝나면 대역을 정리해 다음 테스트에 영향을 남기지 않습니다. 이렇게 검사하는 것은 함수 안의 구현이 아니라 바깥에서 볼 수 있는 결과이므로, 구현을 바꾸어도 결과가 같으면 테스트는 그대로 통과합니다. 다음 fetchUser는 서버에서 사용자를 받아 모양을 확인합니다.
export interface User {
id: number;
name: string;
}
function isUser(value: unknown): value is User {
if (typeof value !== "object" || value === null) return false;
const row = value as Record<string, unknown>;
return typeof row.id === "number" && typeof row.name === "string";
}
export async function fetchUser(id: number): Promise<User> {
const response = await fetch(`http://localhost:4000/users/${id}`);
if (!response.ok) {
throw new Error("Failed to fetch user");
}
const data: unknown = await response.json();
if (!isUser(data)) {
throw new Error("Invalid user response");
}
return data;
}import { fetchUser } from "./api";
function fakeResponse(ok: boolean, body?: unknown): Response {
return { ok, json: jest.fn(async () => body) } as unknown as Response;
}
beforeAll(() => {
jest.spyOn(global, "fetch").mockResolvedValue(fakeResponse(true, { id: 1, name: "Test User" }));
});
afterEach(() => {
jest.clearAllMocks();
});
afterAll(() => {
jest.restoreAllMocks();
});
describe("fetchUser", () => {
test("returns the user from the server", async () => {
await expect(fetchUser(1)).resolves.toEqual({ id: 1, name: "Test User" });
expect(global.fetch).toHaveBeenCalledTimes(1);
expect(global.fetch).toHaveBeenCalledWith("http://localhost:4000/users/1");
});
test("rejects before reading JSON when the response is not ok", async () => {
const response = fakeResponse(false);
jest.mocked(global.fetch).mockResolvedValueOnce(response);
await expect(fetchUser(99)).rejects.toThrow("Failed to fetch user");
expect(response.json).not.toHaveBeenCalled();
});
test("rejects a response with the wrong shape", async () => {
jest.mocked(global.fetch).mockResolvedValueOnce(fakeResponse(true, { id: "wrong" }));
await expect(fetchUser(1)).rejects.toThrow("Invalid user response");
});
});jest.spyOn(global, "fetch")는 전역 fetch를 감시하는 모의 함수로 바꾸고, mockResolvedValue는 부를 때마다 정해 둔 응답으로 이행하는 Promise를 돌려주게 합니다. 그래서 테스트는 localhost:4000에 실제로 접속하지 않으며, toHaveBeenCalledWith로 fetchUser가 어떤 URL로 요청했는지까지 검사합니다. fakeResponse가 만드는 객체는 fetchUser가 쓰는 ok와 json만 가진 대역이어서, as unknown as Response로 단언해 넘깁니다.
jest.mocked(global.fetch)는 fetch를 모의 함수의 타입으로 다루게 해 주어, mockResolvedValueOnce처럼 한 번만 쓸 응답을 타입 검사를 받으며 정할 수 있습니다. 두 번째 테스트는 실패 응답이면 JSON을 읽기 전에 오류를 던진다는 것을, 세 번째 테스트는 응답의 모양이 틀리면 거절한다는 것을 확인합니다. Promise<User>라는 반환 타입만으로는 잘못된 모양의 응답을 막을 수 없으므로, 세 번째 테스트가 isUser의 검사를 지켜 줍니다.
비동기 결과를 검사하는 resolves와 rejects는 반드시 await하거나 return해야 합니다. 빠뜨리면 Jest가 검사가 끝나기 전에 테스트를 통과로 처리할 수 있습니다. 정리하는 함수도 하는 일이 다릅니다. jest.clearAllMocks()는 호출 기록만 지우고 정해 둔 구현은 남기며, jest.restoreAllMocks()는 spyOn으로 바꾼 함수를 원래의 fetch로 되돌립니다.
정리
- Jest는
ts-jest로 타입스크립트 테스트를 변환하며,ts-jest29.4는 TypeScript 6.0까지를 지원하고 변환하면서 타입도 검사합니다. - TypeScript 6.0부터
types의 기본값이 비어 있으므로, Jest의 전역 함수를 쓰려면"types"에jest를 적습니다. - 테스트는 정상 입력과 오류가 나야 하는 입력, 결과가 특별한 입력을 함께 검사하며, 부동소수점 결과는
toBeCloseTo로 비교합니다. - 예외를 검사할 때는 함수로 감싸
toThrow에 넘기고, 비동기 결과의resolves와rejects는await합니다. - 바깥 의존성은
jest.spyOn과mockResolvedValue같은 모의 함수로 통제하고, 돌려준 값과 오류, 보낸 요청을 검사합니다. clearAllMocks는 호출 기록만 지우고,restoreAllMocks는spyOn으로 바꾼 함수를 원래대로 되돌립니다.
연습 문제
1. 특이한 입력
divide(3, -0)과 add(0.1, 0.2)의 결과는 무엇이고, 각각 어떤 matcher로 검사해야 하나요?
해설 보기
-0 === 0은 참이므로 divide(3, -0)은 Cannot divide by zero 오류를 던지며, expect(() => divide(3, -0)).toThrow(...)로 검사합니다. add(0.1, 0.2)는 0.30000000000000004이므로 toBe(0.3)는 실패하고, 필요한 자릿수를 정한 toBeCloseTo(0.3, 10)으로 비교합니다.
2. await를 빠뜨린 검사
두 번째 fetchUser 테스트에서 await expect(...).rejects.toThrow(...)의 await를 지웠습니다. 무엇이 문제가 되나요?
해설 보기
rejects의 검사는 Promise가 끝난 뒤에야 결과가 나오는데, await가 없으면 테스트 함수가 그 결과를 기다리지 않고 먼저 끝납니다. 그러면 오류 메시지가 달라도 테스트가 통과로 처리될 수 있고, 뒤늦게 실패한 검사가 다른 테스트의 결과에 섞일 수도 있습니다. 비동기 검사는 await하거나 return해서 Jest가 끝나기를 기다리게 합니다.
3. 남아 있는 한 번만 쓸 응답
어떤 테스트가 mockResolvedValueOnce로 실패 응답을 예약해 놓고 fetchUser를 부르지 않은 채 끝났습니다. afterEach의 clearAllMocks()가 실행된 뒤, 다음 테스트에서 fetchUser(1)을 부르면 어떤 응답을 받나요?
해설 보기
clearAllMocks()는 호출 기록만 지우고 예약된 구현은 그대로 두므로, 다음 테스트의 첫 호출이 남아 있던 실패 응답을 받아 Failed to fetch user로 거절됩니다. 테스트끼리 영향을 주지 않으려면 한 번만 쓸 응답은 그 테스트 안에서 반드시 소비하거나, 테스트마다 모의 함수를 새로 설정하고 restoreAllMocks()로 되돌리는 구조로 바꿉니다.
다음 글 E2E 테스트에서는 단위를 떼어 내지 않고, 실제 서버와 브라우저를 띄워 사용자의 흐름 전체를 처음부터 끝까지 검사합니다.