
CI에서만 테스트가 깨질 때는 거의 항상 환경 변수, OS·런타임·타임존, 병렬 실행과 격리, 네트워크 접근성 이 네 가지 중 하나에서 원인을 찾을 수 있습니다. 로컬에서는 멀쩡히 통과하던 테스트가 GitHub Actions나 GitLab CI 같은 파이프라인에서만 빨갛게 실패한다면, 코드 로직을 의심하기 전에 이 네 가지를 순서대로 좁혀 나가는 쪽이 디버깅 시간을 훨씬 줄여줍니다. 이 글에서는 각 원인을 어떤 명령과 설정으로 확인하는지, 실제로 동작하는 예시와 함께 정리합니다.
CI에서만 테스트가 깨지는 원인, 네 가지로 좁혀집니다
“로컬에서는 되는데 CI에서만 안 된다”는 문제가 반복되는 이유는 단순합니다. CI 러너는 로컬 개발 환경과 OS, 파일시스템, 네트워크 정책, 하드웨어 자원이 전부 다르기 때문입니다. 예를 들어 GitHub Actions의 ubuntu-latest는 컨테이너 기반 Linux 환경이고, 로컬은 macOS나 Windows일 가능성이 높습니다.
이 차이를 코드 하나하나 들여다보며 찾으면 시간이 끝없이 들어갑니다. 아래 네 가지 범주로 먼저 좁히고, 그중 어디에 해당하는지 확인한 다음 세부 원인을 파는 쪽이 효율적입니다.
| 순서 | 범주 | 대표 증상 | 1차 확인 방법 |
|---|---|---|---|
| 1 | 환경 변수·시크릿 | undefined, API 키 누락, NODE_ENV 다름 | env \| sort 비교 |
| 2 | OS·런타임·타임존 | 날짜/시간 관련 테스트만 실패, import 경로 오류 | node -v, echo $TZ |
| 3 | 병렬 실행·격리 | 돌릴 때마다 결과가 달라지는 flaky 테스트 | --runInBand로 재현 |
| 4 | 네트워크 접근성 | 타임아웃, 429, DNS 실패 | curl로 직접 요청 |
가장 먼저 확인할 것: 환경 변수와 시크릿 차이
CI는 .env 파일을 커밋하지 않는 경우가 많아, 로컬에서 쓰던 환경 변수가 CI에는 아예 주입되지 않은 채 실행됩니다. POSIX 표준이 정의하는 환경 변수는 프로세스가 상속받는 이름-값 쌍인데, 이 값이 로컬과 CI에서 다르면 같은 코드가 다른 분기를 타게 됩니다.
확인은 단순한 diff로 시작하는 게 빠릅니다.
# 로컬에서
env | sort > local_env.txt
# CI 워크플로에 디버그 스텝 추가
- name: Dump env
run: env | sort > ci_env.txt
# 두 결과를 나란히 비교
diff local_env.txt ci_env.txt

놓치기 쉬운 포인트 하나는, GitHub Actions·CircleCI·Travis 같은 대부분의 CI가 기본으로 CI=true라는 환경 변수를 자동으로 심어 둔다는 점입니다. Jest나 chalk 같은 라이브러리는 이 값을 보고 색상 출력이나 인터랙티브 모드를 자동으로 끄기 때문에, 코드에서 process.env.CI를 분기 조건으로 쓰고 있다면 바로 이 부분이 원인일 수 있습니다.
두 번째 의심 대상: OS·런타임·타임존이 다른가요?
환경 변수가 동일한데도 깨진다면 OS와 타임존을 봐야 합니다. GitHub Actions의 ubuntu-latest 러너는 기본 시스템 시간대가 UTC인 경우가 많은 반면, 로컬 개발 환경은 보통 Asia/Seoul로 설정돼 있습니다. 날짜 계산이 들어간 테스트는 이 한 줄 차이로 바로 깨집니다.
test('시간 변환 테스트', () => {
const d = new Date('2024-01-15T00:00:00Z');
expect(d.getHours()).toBe(9); // 로컬 TZ=Asia/Seoul 기준
});
// 로컬 실행: Pass (9)
// CI 실행: Fail, Expected 9, Received 0 (TZ=UTC)
파일 경로 문제도 같은 범주입니다. macOS의 APFS와 Windows의 NTFS는 기본적으로 대소문자를 구분하지 않지만, Linux CI 컨테이너의 ext4는 대소문자 구분이 엄격해서 import './Utils'처럼 실제 파일명과 대소문자가 다른 import 문이 로컬에서는 통과하고 CI에서만 “모듈을 찾을 수 없음” 에러로 터집니다.
해결은 워크플로의 env 블록에 시간대를 직접 못 박아 두는 방식이 가장 간단합니다. GitHub Actions 공식 문서가 안내하는 워크플로 환경 변수 설정 방식으로 TZ: Asia/Seoul을 지정하면 재현 환경을 로컬과 맞출 수 있습니다. Node 버전도 node -v로 로컬과 CI를 비교해, actions/setup-node에 지정한 버전과 로컬 버전이 실제로 같은지 확인해 보시는 게 좋습니다.
세 번째로 살펴볼 것: 병렬 실행과 테스트 격리가 깨졌을 때
환경 변수와 OS/타임존이 모두 같은데도 가끔씩만 실패한다면 병렬 실행 차례입니다. Jest는 기본적으로 maxWorkers를 로컬 CPU 코어 수를 기준으로 자동 계산합니다. CI 컨테이너에 할당된 CPU 코어 수가 로컬과 다르면 워커 개수가 달라지고, 테스트 파일 사이에 전역 상태(싱글톤 인스턴스, 모듈 캐시, 임시 파일)를 공유하고 있던 버그가 특정 워커 조합에서만 드러납니다.
재현은 CI 로그에 찍힌 워커 수를 그대로 로컬에서 지정해 보는 것부터 시작합니다.

# CI 로그의 워커 수와 동일하게 재현
jest --maxWorkers=2
# 완전 순차 실행으로 격리 문제인지 판별
jest --runInBand
--runInBand로 돌렸을 때 실패가 사라진다면, 테스트가 실행 순서나 다른 테스트의 부작용에 의존하고 있다는 신호입니다. Python 쪽에서 pytest-xdist를 쓴다면 -n auto와 -n 0을 비교하는 방식이 동일하게 적용됩니다.
다만 이 방법은 Jest, Vitest, pytest-xdist처럼 워커 기반으로 테스트를 분산 실행하는 러너에만 유효합니다. 기본이 순차 실행인 단순한 unittest 구조라면 병렬성 문제가 아니라 다른 세 가지 범주를 먼저 봐야 합니다.
네 번째: CI 러너가 외부 네트워크를 막아두거나 제한하는 경우
앞의 세 가지가 모두 동일한데 외부 API를 호출하는 테스트만 실패한다면 네트워크 접근성을 봐야 합니다. GitHub가 제공하는 호스티드 러너는 수많은 다른 작업과 아웃바운드 IP 대역을 공유하기 때문에, 호출하는 외부 API가 그 IP 대역을 레이트 리밋이나 차단 목록에 올려두고 있으면 로컬에서는 통과하던 요청이 CI에서만 429나 타임아웃으로 떨어집니다. 사내망에만 열려 있는 내부 API라면 애초에 CI 러너에서 도달 자체가 불가능합니다.
원인을 좁히려면 테스트 로직이 아니라 네트워크 요청 자체를 CI 스텝에서 직접 찍어보는 게 빠릅니다.
- name: 외부 API 접근성 확인
run: curl -sSf -o /dev/null -w "%{http_code}\n" https://api.example.com/health
이 스텝이 타임아웃이나 비정상 코드를 돌려주면 테스트 코드가 아니라 네트워크 정책이 원인이라는 게 명확해집니다. 대안으로는 self-hosted 러너로 바꾸거나, nock·msw 같은 도구로 외부 호출을 모킹해 테스트를 네트워크 환경과 분리하는 방법이 있습니다. 다만 모킹으로 전환하면 실제 API 스펙이 바뀌었을 때 테스트가 이를 못 잡아낸다는 트레이드오프가 남습니다.
이 순서로 좁히면 재현과 수정이 한 번에 끝납니다
다음에 CI에서만 실패하는 테스트를 만나면 코드를 고치기 전에 env diff 비교 → OS/타임존 확인 → --runInBand 재현 → curl 네트워크 체크, 이 순서로 2~3분만 투자해 보시는 걸 추천합니다. 네 가지를 모두 확인했는데도 원인이 안 나온다면, 마지막으로 CI 컨테이너의 CPU·메모리 제한(cgroup) 때문에 타이밍에 민감한 테스트가 타임아웃되는 경우를 의심해 볼 필요가 있습니다. 이 경우는 네 가지 범주 바깥의 리소스 문제라, 테스트의 타임아웃 값을 늘리거나 러너 사양을 올리는 쪽으로 접근해야 합니다.
