로컬 테스트는 성공하는데 CI에서만 실패하는 미스터리

로컬 테스트는 성공하는데 CI에서만 실패하는 미스터리

로컬 테스트는 성공하는데 CI에서만 실패하는 미스터리

오전 10시. 출근해서 Jenkins 확인했다. 빨간 불 15개. 어젯밤 배포 파이프라인 전부 터졌다.

슬랙에 개발자들 멘션 폭격. “J님, 테스트 왜 깨진 거예요?”

난 아무것도 안 건드렸다. 어제 퇴근 전에 PR 머지만 했다. 로컬에선 다 초록불이었는데.

로컬은 천국, CI는 지옥

# 로컬에서 100번 돌려도 성공
pytest tests/test_checkout.py
# ✓ 20 passed in 45.23s

근데 CI에서 돌리면.

FAILED tests/test_checkout.py::test_payment_processing
ElementNotInteractableException: element not interactable

다시 로컬에서 돌린다. 성공한다. 10번 연속 성공.

CI 다시 돌린다. 실패한다. 5번 중 3번 실패.

이게 뭐냐고.

개발자들은 이런 거 처음 겪나 보다. “제 로컬에선 되는데요?” 알아. 나도 그래.

근데 CI에서 실패하면 배포를 못 한다. 그게 문제다.

첫 번째 용의자: 네트워크 타이밍

로컬에선 API 응답이 빠르다. 개발 서버가 내 컴퓨터 바로 옆이니까.

CI는 다르다. 컨테이너끼리 네트워크 통신한다. 지연이 생긴다.

# 내가 짠 코드
def test_user_login():
    driver.get("https://dev.example.com/login")
    email_input = driver.find_element(By.ID, "email")
    email_input.send_keys("test@test.com")
    # 바로 submit
    submit_btn = driver.find_element(By.ID, "submit")
    submit_btn.click()

로컬에선 찰나다. 페이지 로드 0.5초.

CI에선 2초 걸린다. 그 사이 내 스크립트는 이미 element 찾으러 갔다. 당연히 못 찾는다.

해결법.

from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC

def test_user_login():
    driver.get("https://dev.example.com/login")
    
    # 명시적 대기 추가
    email_input = WebDriverWait(driver, 10).until(
        EC.presence_of_element_located((By.ID, "email"))
    )
    email_input.send_keys("test@test.com")
    
    submit_btn = WebDriverWait(driver, 10).until(
        EC.element_to_be_clickable((By.ID, "submit"))
    )
    submit_btn.click()

이거 하나 바꾸는 데 오전 다 갔다. 테스트 케이스가 300개인데 하나하나 고쳐야 한다.

두 번째 용의자: 병렬 실행 순서

로컬에선 테스트를 순차로 돌린다. 한 번에 하나씩.

CI는 시간이 금이다. 병렬로 돌린다. 6개 워커.

# .gitlab-ci.yml
test:
  script:
    - pytest -n 6  # 6개 병렬

문제는 테스트끼리 간섭한다는 거다.

# test_cart.py
def test_add_to_cart():
    login("test@test.com")
    add_item("MacBook Pro")
    assert cart_count() == 1
    
def test_remove_from_cart():
    login("test@test.com")
    remove_item("MacBook Pro")
    assert cart_count() == 0

순차 실행하면 문제없다. 1번 테스트가 끝나고 2번 시작.

병렬 실행하면 꼬인다.

  • 워커 1: add_to_cart 실행 중
  • 워커 2: remove_from_cart 동시 실행
  • 둘 다 같은 계정 사용
  • 장바구니 카운트가 왔다갔다

결과: 랜덤 실패.

해결법은 격리다.

import pytest
import uuid

@pytest.fixture
def unique_user():
    email = f"test_{uuid.uuid4()}@test.com"
    create_test_user(email)
    return email

def test_add_to_cart(unique_user):
    login(unique_user)
    add_item("MacBook Pro")
    assert cart_count() == 1

각 테스트가 독립적인 데이터를 쓴다. 이제 병렬로 돌려도 안 꼬인다.

근데 이것도 문제다. 테스트 계정 300개가 생성된다. DB cleanup이 필요하다.

@pytest.fixture
def unique_user():
    email = f"test_{uuid.uuid4()}@test.com"
    user_id = create_test_user(email)
    yield email
    delete_test_user(user_id)  # teardown

이제 CI 실행 시간이 늘었다. cleanup 때문에 1분 추가.

트레이드오프다.

세 번째 용의자: 환경 변수

제일 짜증 나는 케이스.

로컬 .env 파일.

API_BASE_URL=http://localhost:3000
DB_HOST=localhost
REDIS_URL=redis://localhost:6379

CI 환경 변수.

variables:
  API_BASE_URL: http://api-service:3000
  DB_HOST: postgres-service
  REDIS_URL: redis://redis-service:6379

URL이 다르다. 당연히 테스트가 깨진다.

더 미묘한 건 이런 거다.

import os

# 내 코드
def get_api_url():
    return os.getenv("API_BASE_URL", "http://localhost:3000")

# CI에서는 환경변수가 안 들어간다
# 왜? Docker 컨테이너 안에서 pytest 실행
# 환경변수가 컨테이너 안으로 안 들어감

해결법.

# .gitlab-ci.yml
test:
  script:
    - export API_BASE_URL=http://api-service:3000
    - pytest

또는 docker-compose에서.

services:
  test:
    environment:
      - API_BASE_URL=http://api-service:3000

이것도 찾는 데 2시간 걸렸다. print 디버깅으로 겨우 찾았다.

def test_api_connection():
    print(f"API_BASE_URL: {os.getenv('API_BASE_URL')}")
    # CI 로그 보니까 None이 찍혔다

Docker로 환경 동일하게 만들기

결론은 하나다. 로컬 환경을 CI 환경과 똑같이 만든다.

Docker Compose 파일 작성.

# docker-compose.test.yml
version: '3.8'

services:
  app:
    build: .
    environment:
      - API_BASE_URL=http://api:3000
      - DB_HOST=postgres
    depends_on:
      - postgres
      - redis
      - api

  postgres:
    image: postgres:14
    environment:
      - POSTGRES_PASSWORD=testpass

  redis:
    image: redis:7

  api:
    image: our-api:latest
    environment:
      - DB_HOST=postgres

이제 로컬에서도 똑같이 돌린다.

docker-compose -f docker-compose.test.yml up --build
docker-compose -f docker-compose.test.yml run app pytest

CI에서도 똑같이.

# .gitlab-ci.yml
test:
  script:
    - docker-compose -f docker-compose.test.yml up -d
    - docker-compose -f docker-compose.test.yml run app pytest

이제 로컬이랑 CI랑 환경이 동일하다. 같은 네트워크 설정. 같은 서비스 이름. 같은 환경 변수.

차이가 없으니 결과도 같다.

그래도 가끔 깨진다

환경을 맞춰도 100% 해결은 아니다.

Flaky 테스트는 남는다.

  • CI 서버 CPU가 바쁠 때 타임아웃
  • 외부 API 호출하는 테스트 (네이버 로그인 등)
  • 랜덤 데이터 쓰는 테스트

이런 건 다른 방법이 필요하다.

외부 API는 mocking.

@patch('requests.get')
def test_naver_login(mock_get):
    mock_get.return_value.json.return_value = {
        "email": "test@naver.com"
    }
    # 실제 네이버 API 안 부름

타임아웃은 여유 있게.

# Before
WebDriverWait(driver, 5)  # 5초는 짧다

# After
WebDriverWait(driver, 15)  # 15초로 늘림

랜덤은 시드 고정.

import random
random.seed(42)  # 매번 같은 랜덤 값

완벽한 해결책은 없다. 그냥 확률을 낮추는 거다.

현재 상태

오후 6시. Jenkins 다시 돌렸다. 초록불 20개.

개발자들 슬랙 반응. “오 고쳤네요!” “ㄳㄳ”

나는 PR에 코멘트 남겼다.

변경사항:
- 명시적 대기 추가 (WebDriverWait)
- 테스트별 unique user fixture 적용
- docker-compose로 로컬/CI 환경 통일
- 환경변수 주입 확인

테스트 시간: 45초 → 1분 20초 (cleanup 때문)
성공률: 60% → 95%

완벽하진 않다. 95%. 가끔 1-2개는 여전히 깬다.

근데 이 정도면 된다. Flaky 테스트는 retry로 처리.

# .gitlab-ci.yml
test:
  retry: 2  # 2번까지 재시도

배운 것

로컬과 CI가 다르다는 걸 인정해야 한다. “내 컴퓨터에선 되는데”는 핑계다.

CI 환경을 로컬에서 재현할 수 있어야 한다. Docker가 답이다.

네트워크 타이밍은 항상 고려. 명시적 대기를 쓴다.

테스트는 독립적이어야 한다. 순서에 의존하면 병렬 실행에서 터진다.

환경 변수는 명확하게. print 디버깅으로 값 확인.

100% 안정적인 자동화는 없다. 95%면 성공이다. 나머지 5%는 retry와 monitoring으로.


퇴근 7시. 오늘은 정시다. CI 초록불 보니까 기분이 좋다.