Jest + ts-jest
TypeScript로 백엔드를 작성하면 테스트도 TypeScript로 작성하고 싶어진다. 그런데 Jest는 기본적으로 JavaScript만 실행할 수 있다. .ts 파일을 넣으면 구문 오류가 난다. Jest가 파일을 읽을 때 import 구문이나 타입 어노테이션을 이해하지 못하기 때문이다.
이 문제를 해결하는 방법은 두 가지다. 테스트를 실행하기 전에 TypeScript를 JavaScript로 컴파일해두거나, Jest가 파일을 읽는 시점에 실시간으로 변환하거나. 전자는 빌드 단계가 필요하고 소스맵 매핑이 번거롭다. 후자가 ts-jest가 하는 일이다.
ts-jest가 해결하는 문제
Jest에는 transform이라는 설정이 있다. 테스트 파일을 실행하기 전에 특정 변환기(transformer)를 거치게 하는 기능이다. Babel을 사용하는 babel-jest가 기본 내장되어 있는데, ts-jest는 이 transform 파이프라인에 TypeScript 컴파일러(tsc)를 끼워넣는다.
.ts 파일 → ts-jest (tsc로 변환) → JavaScript → Jest 실행
핵심은 타입 체크를 포함한다는 점이다. @swc/jest나 babel-jest + @babel/preset-typescript 같은 대안들은 타입 어노테이션을 그냥 벗겨내기만 하고 타입 검사를 하지 않는다. 코드에 타입 오류가 있어도 테스트는 통과해버린다. ts-jest는 실제 tsc를 사용하기 때문에 타입 오류가 있으면 테스트가 실패한다.
| 변환기 | 타입 체크 | 속도 | 특징 |
|---|---|---|---|
| ts-jest | ✅ | 느림 | tsc 기반, 완전한 타입 검사 |
| @swc/jest | ❌ | 빠름 | Rust 기반 트랜스파일러, 타입 스트립만 |
| babel-jest + preset-typescript | ❌ | 보통 | Babel 기반, 타입 스트립만 |
속도와 안정성의 트레이드오프다. CI에서 타입 체크를 tsc --noEmit으로 별도로 돌리는 프로젝트라면 @swc/jest가 나을 수 있다. 하지만 테스트 단계에서 타입 오류까지 한 번에 잡고 싶다면 ts-jest가 맞다.
설치와 기본 설정
pnpm add -D jest ts-jest @types/jest
@types/jest는 describe, it, expect 같은 Jest 전역 함수의 타입 정의를 제공한다. 없으면 TypeScript가 이 함수들을 인식하지 못한다.
초기 설정 파일을 생성하는 CLI가 있다:
npx ts-jest config:init
이 명령은 jest.config.js를 생성한다. 내용은 간단하다:
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
};
preset: 'ts-jest'가 하는 일을 풀어쓰면 이렇다:
module.exports = {
transform: {
'^.+\\.tsx?$': 'ts-jest',
},
testEnvironment: 'node',
};
.ts와 .tsx 파일을 만나면 ts-jest 변환기를 사용하라는 뜻이다. preset은 이 설정을 한 줄로 축약한 것뿐이다.
testEnvironment
Jest는 테스트를 실행할 환경을 선택할 수 있다. 기본값은 'node'이지만, 명시적으로 적는 편이 좋다.
node: Node.js 환경.window,document같은 브라우저 API가 없다. 백엔드 테스트에 적합하다.jsdom: 가상 DOM 환경.window,document를 에뮬레이션한다. React 컴포넌트 테스트에 사용한다.
백엔드 코드를 테스트하는데 jsdom을 사용하면 불필요한 오버헤드가 생긴다. 반대로 프론트엔드 코드를 node 환경에서 테스트하면 document is not defined 에러가 난다.
tsconfig 설정과의 관계
ts-jest는 기본적으로 프로젝트 루트의 tsconfig.json을 읽는다. 그런데 테스트 코드와 프로덕션 코드의 TypeScript 설정이 다를 수 있다. 테스트에서는 strict를 좀 느슨하게 하고 싶거나, paths 매핑이 다를 수 있다.
이런 경우 테스트 전용 tsconfig를 만들어서 지정한다:
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
transform: {
'^.+\\.tsx?$': ['ts-jest', {
tsconfig: 'tsconfig.test.json',
}],
},
};
tsconfig.test.json은 보통 메인 tsconfig를 확장한다:
{
"extends": "./tsconfig.json",
"compilerOptions": {
"types": ["jest"]
},
"include": ["src/**/*", "test/**/*"]
}
"types": ["jest"]를 명시하면 테스트 파일에서 describe, it 등을 import 없이 사용할 수 있다. include에 test/ 디렉토리를 포함시켜야 테스트 파일의 타입 검사가 동작한다.
moduleNameMapper — 경로 별칭 매핑
TypeScript에서 paths를 설정해서 @/utils/helper 같은 경로 별칭을 사용하는 경우가 흔하다:
// tsconfig.json
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
}
}
문제는 Jest가 이 별칭을 모른다는 것이다. TypeScript 컴파일러는 paths를 타입 체크에만 사용하고, 실제 모듈 해석(resolution)은 런타임 환경의 몫이다. ts-jest가 코드를 변환할 때 import { helper } from '@/utils/helper'의 경로를 바꿔주지 않는다.
Jest의 moduleNameMapper로 동일한 매핑을 수동으로 잡아줘야 한다:
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
},
};
<rootDir>은 Jest가 제공하는 토큰으로, jest.config가 위치한 디렉토리를 가리킨다. 정규식의 캡처 그룹 (.*)이 $1에 매핑된다. 이 설정이 없으면 Cannot find module '@/utils/helper' 에러가 발생한다.
매번 tsconfig의 paths와 moduleNameMapper를 동기화하는 게 번거롭다면 ts-jest/utils의 pathsToModuleNameMapper 유틸리티를 사용할 수 있다:
const { pathsToModuleNameMapper } = require('ts-jest');
const { compilerOptions } = require('./tsconfig.json');
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
moduleNameMapper: pathsToModuleNameMapper(
compilerOptions.paths,
{ prefix: '<rootDir>/' }
),
};
tsconfig.json의 paths를 읽어서 자동으로 Jest용 매핑을 생성한다. paths가 변경되면 자동으로 반영되니 동기화 문제가 사라진다.
transform 설정 상세
기본 preset은 .ts, .tsx 파일만 처리한다. 프로젝트에 .js 파일과 .ts 파일이 섞여 있으면 transform을 직접 설정해야 한다:
module.exports = {
transform: {
'^.+\\.tsx?$': 'ts-jest', // .ts, .tsx
'^.+\\.jsx?$': 'babel-jest', // .js, .jsx
},
};
transform의 키는 정규식이고, 값은 사용할 변환기다. Jest는 파일 경로가 정규식에 매칭되는 변환기를 사용한다. 매칭되는 변환기가 없으면 파일을 그대로 실행하는데, ESM 문법이나 TypeScript 문법이 있으면 실패한다.
ts-jest에 옵션을 전달할 때는 배열 문법을 사용한다:
transform: {
'^.+\\.tsx?$': ['ts-jest', {
tsconfig: 'tsconfig.test.json',
diagnostics: true, // 타입 체크 활성화 (기본값)
isolatedModules: false, // 전체 프로그램 타입 체크
}],
},
diagnostics 옵션
diagnostics는 ts-jest의 타입 체크 동작을 제어한다:
true(기본값): 타입 오류를 Jest 에러로 보고한다.false: 타입 체크를 비활성화한다. 트랜스파일만 한다. 속도가 빨라지지만 타입 안전성을 잃는다.{ warnOnly: true }: 타입 오류를 경고로만 표시하고 테스트는 계속 실행한다.{ ignoreDiagnostics: [2345, 7006] }: 특정 TypeScript 에러 코드를 무시한다.
처음에는 true로 시작하고, 레거시 코드 때문에 타입 오류가 너무 많으면 warnOnly로 완화하는 전략이 현실적이다.
isolatedModules 옵션
isolatedModules: true로 설정하면 ts-jest가 파일 단위로만 변환한다. 프로젝트 전체의 타입 정보를 로드하지 않기 때문에 속도가 크게 향상된다. 단, const enum이나 namespace 같은 전체 프로그램 정보가 필요한 TypeScript 기능은 사용할 수 없게 된다.
['ts-jest', {
isolatedModules: true, // 빠르지만 일부 TS 기능 제한
}]
SWC 수준은 아니지만 체감할 만큼 빨라진다. const enum을 사용하지 않는 프로젝트라면 켜는 편이 좋다.
테스트 파일 구조와 위치
Jest는 기본적으로 다음 패턴의 파일을 테스트로 인식한다:
**/__tests__/**/*.[jt]s?(x)—__tests__폴더 내부의 모든 파일**/?(*.)+(spec|test).[jt]s?(x)—.test.ts또는.spec.ts로 끝나는 파일
두 가지 컨벤션이 흔하다:
1. 소스 옆에 배치 (co-location)
src/
user/
user.service.ts
user.service.spec.ts
user.controller.ts
user.controller.spec.ts
소스와 테스트가 같은 디렉토리에 있어서 관련 파일을 찾기 쉽다. NestJS에서 권장하는 구조다.
2. 별도 디렉토리
src/
user/
user.service.ts
user.controller.ts
test/
user/
user.service.spec.ts
user.controller.spec.ts
소스 디렉토리가 깔끔해지지만 파일 이동 시 테스트 경로도 같이 바꿔야 한다.
testMatch 또는 testRegex로 커스터마이징할 수 있다:
module.exports = {
testMatch: ['<rootDir>/test/**/*.spec.ts'],
};
모킹(Mocking)
Jest의 모킹 시스템은 TypeScript와 함께 쓸 때 약간의 보일러플레이트가 필요하다. jest.fn()의 반환값이 기본적으로 any 타입이기 때문이다.
함수 모킹
const mockFn = jest.fn<number, [string, number]>();
// 시그니처: (arg1: string, arg2: number) => number
mockFn.mockReturnValue(42);
mockFn('hello', 1); // 42
제네릭의 첫 번째 인자가 반환 타입, 두 번째가 매개변수 튜플이다. 이렇게 타입을 지정하면 mockFn.mockReturnValue('wrong')에서 타입 오류가 발생한다.
모듈 모킹
jest.mock('./database');
import { getConnection } from './database';
const mockedGetConnection = jest.mocked(getConnection);
// getConnection의 원래 타입 시그니처가 유지된다
mockedGetConnection.mockReturnValue({
query: jest.fn(),
close: jest.fn(),
});
jest.mocked()는 Jest 27.4+에서 추가된 유틸리티로, 모킹된 함수에 원래 타입 정보를 부여한다. 이전에는 getConnection as jest.MockedFunction<typeof getConnection> 같은 캐스팅을 해야 했다.
클래스 모킹
NestJS에서 서비스의 의존성을 모킹할 때 자주 사용하는 패턴이다:
const mockUserRepository = {
find: jest.fn(),
findOne: jest.fn(),
save: jest.fn(),
delete: jest.fn(),
};
const module = await Test.createTestingModule({
providers: [
UserService,
{
provide: getRepositoryToken(User),
useValue: mockUserRepository,
},
],
}).compile();
실제 데이터베이스 연결 없이 Repository의 메서드를 모킹해서 서비스 로직만 테스트한다.
자주 만나는 문제와 해결
"Cannot use import statement outside a module"
node_modules의 패키지가 ESM으로 배포된 경우 발생한다. Jest는 기본적으로 CommonJS로 동작하기 때문이다.
module.exports = {
preset: 'ts-jest',
transformIgnorePatterns: [
'node_modules/(?!(problematic-package)/)',
],
};
transformIgnorePatterns는 기본적으로 node_modules 전체를 변환에서 제외한다. 위 설정은 "problematic-package를 제외한 나머지 node_modules는 변환하지 마라"는 뜻이다. 이중 부정이라 헷갈리지만, 결과적으로 해당 패키지만 ts-jest 변환을 거치게 된다.
"SyntaxError: Unexpected token"
CSS, 이미지 등 JavaScript가 아닌 파일을 import할 때 발생한다. 프론트엔드 프로젝트에서 흔하다.
module.exports = {
moduleNameMapper: {
'\\.(css|less|scss)$': 'identity-obj-proxy',
'\\.(jpg|jpeg|png|svg)$': '<rootDir>/test/__mocks__/fileMock.js',
},
};
identity-obj-proxy는 CSS Modules의 클래스명을 키 이름 그대로 반환하는 프록시다. styles.button이 'button'을 반환한다. 이미지 파일은 빈 문자열이나 파일 경로를 반환하는 간단한 모킹 모듈로 대체한다.
타입 체크가 너무 느릴 때
대규모 프로젝트에서 ts-jest의 타입 체크가 테스트 실행 시간을 크게 늘릴 수 있다.
// 방법 1: isolatedModules로 파일 단위 변환
['ts-jest', { isolatedModules: true }]
// 방법 2: diagnostics 비활성화 (타입 체크는 CI의 tsc --noEmit에 위임)
['ts-jest', { diagnostics: false }]
// 방법 3: ts-jest 대신 @swc/jest 사용
transform: {
'^.+\\.tsx?$': '@swc/jest',
}
방법 1이 속도와 안전성의 균형이 가장 좋다. 방법 3이 가장 빠르지만 타입 체크를 완전히 포기한다.
Jest 설정 파일 형식
Jest 설정은 여러 형식으로 작성할 수 있다:
jest.config.js: CommonJS 형식. 가장 보편적이다.jest.config.ts: TypeScript 형식. ts-jest가 설치되어 있으면 사용 가능하다. 설정 파일 자체에서 자동완성을 받을 수 있다.package.json의jest필드: 별도 파일 없이 package.json에 인라인으로 작성한다.
// jest.config.ts
import type { JestConfigWithTsJest } from 'ts-jest';
const config: JestConfigWithTsJest = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/src', '<rootDir>/test'],
collectCoverageFrom: [
'src/**/*.ts',
'!src/**/*.d.ts',
'!src/**/index.ts',
],
};
export default config;
TypeScript로 설정 파일을 작성하면 JestConfigWithTsJest 타입 덕분에 잘못된 옵션명을 쓰면 컴파일 에러가 나서 오타를 방지할 수 있다.
커버리지 설정
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
collectCoverage: true,
coverageDirectory: 'coverage',
coverageProvider: 'v8',
collectCoverageFrom: [
'src/**/*.ts',
'!src/**/*.d.ts',
'!src/**/*.spec.ts',
'!src/main.ts',
],
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
};
coverageProvider는 두 가지 옵션이 있다:
'babel'(기본값): Istanbul 기반. 코드를 계측(instrument)해서 커버리지를 수집한다.'v8': V8 엔진의 내장 커버리지 기능을 사용한다. 더 빠르고 정확하지만 Node.js 환경에서만 동작한다.
collectCoverageFrom의 ! 패턴은 커버리지 수집에서 제외할 파일이다. 타입 정의 파일(.d.ts), 테스트 파일, 엔트리 포인트(main.ts) 등은 커버리지를 측정할 필요가 없다.
coverageThreshold를 설정하면 커버리지가 기준 이하일 때 Jest가 실패한다. CI에서 커버리지 하락을 방지하는 게이트로 사용할 수 있다.
정리
- ts-jest는 tsc 기반이라 변환과 타입 체크를 동시에 수행하고, isolatedModules로 속도와 안전성의 균형을 잡을 수 있다
- moduleNameMapper와 pathsToModuleNameMapper로 tsconfig paths를 Jest에 동기화하고, transformIgnorePatterns로 ESM 패키지 문제를 해결한다
- diagnostics 옵션으로 타입 체크 수준을 조절하되, CI에서 tsc --noEmit을 별도로 돌리면 @swc/jest로 전환해도 안전하다
관련 문서
- supertest - HTTP 통합 테스트
- vitest - Vite 기반 테스트 러너
- ioredis-mock - Redis 모킹 라이브러리