- Published on
Bun 1.1에서 Jest 테스트가 깨지는 이유와 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서론
Bun을 런타임(또는 패키지 매니저)로 쓰다가 bun upgrade 혹은 CI 이미지 업데이트 이후, Jest 테스트가 갑자기 깨지는 경험을 하는 팀이 많습니다. 특히 Bun 1.1 전후로는 Node.js 생태계와의 경계(모듈 해석, ESM/CJS, 트랜스파일, 워커/타이머, 네이티브 모듈, 테스트 러너 실행 방식)가 더 뚜렷해지면서, 기존에 “우연히 통과하던” Jest 설정이 실패로 드러나는 경우가 잦습니다.
이 글은 “Bun 1.1이 나빠서”가 아니라, Bun에서 Jest를 돌릴 때 생기는 구조적 차이를 기준으로 원인을 빠르게 분류하고, 프로젝트에 맞게 해결하는 방법을 제공합니다.
> 결론을 먼저 말하면: Jest는 기본적으로 Node 환경을 전제로 설계되어 있고, Bun은 Node 호환을 넓혀가고 있지만 100% 동일하지 않습니다. 따라서 “Bun으로 Jest를 실행”하는 전략은 리스크가 있고, 많은 경우 Node로 Jest를 돌리고 Bun은 install/build에만 사용하는 구성이 가장 안정적입니다.
증상 패턴별로 원인 좁히기
먼저 실패 로그를 아래 패턴 중 어디에 가까운지 분류하면 진단 속도가 크게 올라갑니다.
1) ReferenceError: require is not defined / Cannot use import statement outside a module
- ESM/CJS 경계 문제
- Jest 설정(
transform,extensionsToTreatAsEsm,moduleNameMapper)과 TS/바벨 트랜스파일 설정 불일치
2) SyntaxError: Unexpected token 'export' (특히 node_modules)
- Jest가
node_modules를 변환하지 않는데, 의존성이 ESM만 제공하거나 최신 문법을 사용 - Bun에서는 실행되던 코드가 Jest(Node) 환경에서는 변환 없이 파싱되어 실패
3) TextEncoder is not defined, fetch is not defined, crypto is not defined
- 테스트 환경이
jsdom/node인지, polyfill이 필요한지 문제 - Bun과 Node의 글로벌 객체 제공 차이
4) 타이머/워커 관련 플래키
setTimeout/fake timers/worker_threads/MessageChannel등에서 런타임 차이- 병렬성/스케줄링 차이로 “가끔” 깨짐
5) CI에서만 깨짐
- 캐시가 꼬여서 예전 lockfile/예전 node_modules가 섞임
- Bun/Node 버전이 로컬과 다름
CI 캐시 이슈는 런타임 문제처럼 보여도 원인이 전혀 다른 경우가 많습니다. 필요하면 아래 글의 체크리스트가 그대로 도움이 됩니다.
Bun 1.1에서 Jest가 깨지는 대표 원인 6가지
1. Jest를 Bun으로 실행하고 있는 경우(가장 흔함)
가장 흔한 구성은 아래처럼 “Jest 바이너리를 Bun으로 실행”하는 것입니다.
{
"scripts": {
"test": "bun jest"
}
}
이 방식은 jest 자체가 Node 런타임 가정(내부적으로 process, vm, worker_threads, 모듈 로더 동작 등)을 강하게 갖고 있어, Bun 버전/프로젝트 구성에 따라 깨지기 쉽습니다.
해결(권장): Jest는 Node로 실행하고 Bun은 install만
가장 안정적인 조합은:
- 의존성 설치: Bun
- 테스트 실행: Node
{
"scripts": {
"test": "node ./node_modules/.bin/jest"
}
}
또는 npx jest도 가능하지만, CI 재현성을 위해 로컬 바이너리를 직접 호출하는 편이 안전합니다.
핵심: “Bun 1.1 업그레이드 후 Jest가 깨졌다”면, 먼저 bun jest를 쓰고 있는지 확인하고, 가능하면 Node로 돌리세요.
2) ESM/CJS 설정 충돌: type: module + Jest 설정 미흡
Bun은 ESM 경험이 상대적으로 자연스럽지만, Jest는 여전히 프로젝트의 모듈 타입/변환 설정에 민감합니다.
예를 들어 package.json에 아래가 있으면:
{
"type": "module"
}
테스트 파일/소스/설정 파일이 ESM인지 CJS인지가 혼재될 때 오류가 폭발합니다.
해결 A: Jest 설정 파일을 CJS로 고정
jest.config.cjs로 바꾸면, 최소한 설정 로딩에서의 ESM 이슈를 줄일 수 있습니다.
// jest.config.cjs
module.exports = {
testEnvironment: "node",
transform: {
"^.+\\.(t|j)sx?$": ["@swc/jest"],
},
};
해결 B: TS를 쓰면 ts-jest보다 SWC가 단순한 경우가 많음
Bun과 TS 조합에서 Jest까지 얹으면 변환 레이어가 늘어납니다. ts-jest는 강력하지만 설정 복잡도가 커서, “업그레이드 후 깨짐” 상황에서 원인 분리가 어려워집니다.
@swc/jest + .swcrc로 단순화하는 전략이 자주 통합니다.
// .swcrc
{
"jsc": {
"parser": {
"syntax": "typescript",
"tsx": false
},
"target": "es2022"
},
"module": {
"type": "commonjs"
}
}
> 포인트: 테스트 러너(Jest)가 원하는 모듈 형태(CJS)를 명확히 만들어 주면, 런타임 차이(Bun vs Node)보다 설정 충돌을 먼저 제거할 수 있습니다.
3) node_modules의 ESM 의존성 파싱 실패
Bun은 ESM 패키지를 자연스럽게 실행하지만, Jest는 기본적으로 node_modules를 transform 하지 않습니다.
그래서 아래 같은 로그가 뜹니다.
SyntaxError: Unexpected token 'export'Cannot use import statement outside a module
해결: transformIgnorePatterns를 “필요한 것만” 예외 처리
예를 들어 nanoid, ky, lodash-es 같은 ESM 의존성이 테스트에서 로드된다면:
// jest.config.cjs
module.exports = {
transform: {
"^.+\\.(t|j)sx?$": ["@swc/jest"],
},
transformIgnorePatterns: [
"/node_modules/(?!(nanoid|ky|lodash-es)/)"
],
};
여기서 실수는 node_modules 전체를 변환하게 만드는 것입니다. CI에서 테스트 시간이 폭증하고, 변환 캐시 충돌까지 겹쳐 더 큰 장애로 이어질 수 있습니다.
4) 글로벌 객체 차이: fetch/TextEncoder/crypto
Bun은 fetch 등을 기본 제공하지만, Jest의 testEnvironment가 node일 때 Node 버전에 따라 글로벌이 다릅니다.
해결 A: Node 버전 고정(특히 CI)
Node 18+에서는 fetch가 기본 제공됩니다. CI에서 Node 16을 쓰면 로컬(Bun)에서는 되던 코드가 Jest(Node)에서 깨집니다.
GitHub Actions 예:
- uses: actions/setup-node@v4
with:
node-version: 20
해결 B: 테스트 셋업에서 polyfill
Node 버전을 올리기 어렵다면, Jest setup 파일에서 보완합니다.
// jest.setup.js
const { TextEncoder, TextDecoder } = require("node:util");
global.TextEncoder = TextEncoder;
global.TextDecoder = TextDecoder;
// fetch가 없다면 undici를 사용
if (!global.fetch) {
const { fetch, Headers, Request, Response } = require("undici");
global.fetch = fetch;
global.Headers = Headers;
global.Request = Request;
global.Response = Response;
}
// jest.config.cjs
module.exports = {
setupFilesAfterEnv: ["<rootDir>/jest.setup.js"],
};
5) 타이머/비동기 플래키: fake timers, microtask queue
Bun과 Node는 이벤트 루프/타이머/마이크로태스크 처리에서 미세한 차이가 있고, Jest의 fake timers는 그 위에 또 한 겹을 씌웁니다. 업그레이드 후 갑자기 “가끔” 깨지는 테스트는 아래를 의심하세요.
jest.useFakeTimers()사용 후await/Promise 체인과 섞임setImmediate,process.nextTick에 의존- 테스트가 종료되기 전에 비동기 작업이 남아 있음
해결: fake timers 사용 범위를 최소화 + 명시적 flush
// example.test.ts
it("debounce works", async () => {
jest.useFakeTimers();
const fn = jest.fn();
const debounced = debounce(fn, 200);
debounced();
debounced();
jest.advanceTimersByTime(200);
// microtask flush
await Promise.resolve();
expect(fn).toHaveBeenCalledTimes(1);
jest.useRealTimers();
});
그리고 가능하면 타이머 기반 로직은 단위 테스트에서 시간을 제어하고, 통합 테스트에서는 real timers로 두는 편이 안정적입니다.
6) CI 캐시/락파일 혼선: “Bun 1.1 문제처럼 보이는” 가짜 장애
Bun을 쓰면 bun.lockb가 생기고, Node 기반 워크플로우는 package-lock.json/pnpm-lock.yaml 등을 기대합니다. 여기서 캐시 키가 잘못되면:
- 예전 의존성 트리가 남아 Jest transform 결과가 달라짐
- 로컬에서는 통과, CI에서는 실패
해결: 캐시 키에 lockfile을 정확히 포함
Bun을 쓴다면 bun.lockb를 캐시 키에 포함하고, Node로 테스트를 돌린다면 Node 버전도 키에 포함하세요.
또한 “이상하다” 싶으면 캐시를 과감히 무효화하고 재현성을 먼저 확보하는 게 빠릅니다. 자세한 진단 흐름은 아래 글을 참고하세요.
실전 권장 구성: Bun + Node(Jest) 하이브리드
현실적인 베스트 프랙티스는 아래 조합입니다.
- 로컬 개발/설치 속도: Bun
- 테스트 러너: Node에서 Jest
- CI: Node 버전 고정 + 캐시 키 정교화
package.json 예시
{
"scripts": {
"install:ci": "bun install --frozen-lockfile",
"test": "node ./node_modules/.bin/jest --runInBand",
"test:watch": "node ./node_modules/.bin/jest --watch"
}
}
--runInBand는 CI에서 워커/병렬성 이슈를 줄이는 데 도움이 됩니다(특히 플래키가 있을 때 임시로).
GitHub Actions 예시
name: test
on: [push, pull_request]
jobs:
unit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: "1.1.0"
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Install deps
run: bun install --frozen-lockfile
- name: Test (Jest on Node)
run: node ./node_modules/.bin/jest
이 구성은 “Bun 업그레이드로 Jest가 깨지는” 문제를 구조적으로 차단합니다. Bun은 설치/번들/스크립트 실행에 강점을 살리고, Jest는 본래 의도대로 Node에서 돌리는 방식입니다.
체크리스트: 10분 안에 원인 확정하기
아래 순서대로 확인하면 대부분의 케이스가 빠르게 정리됩니다.
bun jest로 실행 중인가? → Node로 전환해 재시도package.json의type확인(module여부)- Jest 설정 파일을
jest.config.cjs로 바꿔서 로딩 이슈 제거 - TS/JS 변환은
@swc/jest로 단순화 가능한지 검토 Unexpected token export면transformIgnorePatterns예외 처리fetch/TextEncoder/crypto면 Node 버전 고정 또는 setup polyfill- 플래키면
--runInBand로 재현성 확보 후 타이머/비동기 정리 - CI에서만 실패하면 캐시 무효화 후 재실행
마무리
Bun 1.1 업그레이드 이후 Jest가 깨지는 문제는 대체로 “Bun의 버그”라기보다, Jest가 기대하는 Node 런타임/모듈 로딩/트랜스파일 전제와 프로젝트 설정이 충돌하면서 표면화되는 경우가 많습니다.
가장 비용 대비 효과가 큰 해결책은 Jest는 Node로 실행하고, Bun은 설치/빌드에 집중시키는 것입니다. 그 다음으로는 ESM/CJS 경계 정리, node_modules ESM 변환 예외 처리, 그리고 CI 캐시/버전 고정이 안정화를 좌우합니다.
CI 캐시가 얽혀 있거나, 특정 커밋부터만 깨지는 등 재현성이 낮다면 캐시 진단 글도 함께 참고해 보세요.