본문으로 바로가기
검색 (Ctrl+K)

Docker로 Playwright와 GitHub Actions CI 환경을 로컬에 재현해보기

·8분 읽기·
---
·
개발

Docker로 CI 환경을 재현하고 E2E 테스트 검증 루프를 구축해보자

들어가며

Playwright로 작성한 E2E 테스트가 로컬에서는 잘 통과했는데 GitHub Actions에서는 반복해서 실패했다. 테스트를 다시 실행하면 성공하기도 했고, 실패 지점도 조금씩 달라졌다. 처음에는 selector나 timeout 문제라고 생각했다. 실패할 때마다 대기 시간을 늘리거나 조건을 더 붙이면 해결될 것처럼 보였다.

하지만 CI를 여러 번 재실행하는 방식으로는 원인을 좁히기 어려웠다. 코드를 수정해 push하고, runner가 준비되기를 기다리고, 긴 테스트 시나리오가 실패한 뒤 artifact를 내려받는 과정이 매번 반복됐다.

그러다 GitHub Actions도 결국 정해진 운영체제와 Node, 브라우저 위에서 테스트를 실행한다는 생각이 들었다. 그 조건을 Docker image로 만들면 CI에서만 보이던 실패를 내 Mac에서도 재현할 수 있지 않을까?

이 글은 그 아이디어에서 시작해 GitHub Actions의 실행 조건을 Docker로 옮기고, 실제 CI 실패를 로컬에서 재현한 과정을 정리한 글이다.

문제 정의

문제는 테스트 실패 자체보다 로컬에서 재현할 수 없다는 데 있었다. 작은 수정 하나도 아래 과정을 거쳐야 했다.

text
테스트 코드 수정
→ branch push
→ PR 반영과 승인
→ GitHub Actions 실행 대기
→ E2E 실패
→ artifact와 로그 확인
→ 다시 코드 수정

긴 E2E 시나리오가 끝날 때까지 기다린 뒤 로그를 확인하고 다시 push하는 방식은 피드백이 너무 느렸다. 로컬 macOS와 CI의 Linux·UTC·브라우저 설치 조건이 달라 실패 지점을 추측하기도 어려웠다.

macOS 설정을 하나씩 바꾸거나 별도 VM을 관리하는 대신 Docker를 써보기로 했다. CI와 같은 Node·pnpm·Chromium을 Linux image에 설치하고 CI=true로 실행하면 GitHub Actions 전체는 아니더라도 테스트 실행 조건은 상당 부분 맞출 수 있었다.

Docker로 GitHub Actions 실행 환경 구축

바로 Dockerfile부터 작성하지는 않았다. 먼저 실제 E2E workflow를 열어 GitHub Actions가 테스트를 실행하기 전까지 무엇을 준비하는지 확인했다.

GitHub Actions 실행 조건

workflow에서 가져올 실행 조건은 여섯 가지였다.

text
Ubuntu runner
→ Node 18
→ pnpm 설치
→ frozen lockfile로 dependency 설치
→ Chromium과 Linux dependency 설치
→ CI 환경변수 설정
→ Playwright 실행

Playwright 공식 CI 문서도 같은 순서를 안내한다. CI runner에서 브라우저를 실행할 수 있어야 하고, package 설치 후 playwright install --with-deps로 브라우저와 시스템 라이브러리를 준비한 다음 test runner를 실행한다. CI 안정성을 우선할 때 worker를 1개로 두는 것도 공식 문서의 권장안 중 하나다. (Playwright CI 문서 (새 탭에서 열림))

이 조건을 Dockerfile, Compose와 실행 script 세 부분으로 나눴다.

text
Dockerfile.e2e
  Linux·Node·pnpm·dependency·Chromium

compose.e2e.yml
  실행 시점의 계정·URL·CI 환경변수

run-e2e-ci-like.sh
  Playwright 실행 범위와 종료 코드

Linux·Node 버전을 고정한 Base Image

Dockerfile은 Node 18이 포함된 Debian Bookworm image에서 시작한다.

docker
FROM node:18-bookworm

ENV DEBIAN_FRONTEND=noninteractive

WORKDIR /workspace

GitHub Actions workflow도 Node 18을 사용하고 있었다. Docker의 Debian Bookworm과 GitHub-hosted runner의 Ubuntu가 완전히 같은 운영체제는 아니다. 그래도 macOS에서 직접 실행할 때보다 Linux 사용자 공간과 library 조건을 훨씬 가깝게 맞춘다.

ubuntu-latest는 고정된 Docker image 이름이 아니라 GitHub가 제공하는 최신 안정 runner image를 가리킨다. GitHub 문서에 따르면 hosted runner는 작업마다 새 VM으로 제공되며, -latest가 가리키는 OS image는 장기적으로 바뀔 수 있다. 정확한 OS까지 고정해야 한다면 workflow도 ubuntu-24.04처럼 명시해야 한다. (GitHub-hosted runners 문서 (새 탭에서 열림))

이번 Docker 환경의 목표는 GitHub VM을 바이트 단위로 복제하는 것이 아니었다. Node와 browser를 포함한 Linux 실행 조건을 로컬에서 빠르게 반복하는 데 있었다.

Corepack을 이용한 pnpm 버전 고정

패키지 매니저 버전도 CI와 맞췄다.

docker
RUN corepack enable \
  && corepack prepare pnpm@10.31.0 --activate

Node major version이 같더라도 pnpm 버전이 다르면 lockfile 해석이나 dependency 설치 결과가 달라질 수 있다. 프로젝트의 packageManager에 선언된 버전과 Docker 안의 pnpm 버전을 같게 유지했다.

그다음 package manifest와 lockfile만 먼저 복사했다.

docker
COPY package.json pnpm-lock.yaml ./

RUN pnpm install --frozen-lockfile

소스 코드 전체를 먼저 복사하면 파일 하나만 바뀌어도 dependency 설치 layer의 cache가 깨진다. Docker는 instruction과 그 instruction이 의존하는 파일이 그대로일 때 이전 layer를 재사용한다. package.jsonpnpm-lock.yaml을 먼저 복사하면 소스 코드가 바뀌어도 dependency를 다시 설치하지 않는다. Docker 공식 문서도 package 관련 파일을 먼저 복사하고 dependency 설치 후 source를 복사하는 순서를 권장한다. (Docker build cache 최적화 (새 탭에서 열림))

docker
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile

COPY . .
  • frozen-lockfile은 Docker에서 임의로 lockfile을 고치지 못하게 한다. CI와 로컬 container 모두 repository에 commit된 dependency graph를 그대로 설치한다.

Browser Package와 Linux Dependency

@playwright/test를 설치했다고 바로 Chromium을 실행할 수 있는 것은 아니다. Playwright test runner와 실제 browser binary는 별도로 설치되며, Linux에서는 browser가 요구하는 system library도 필요하다.

docker
RUN pnpm install --frozen-lockfile \
  && pnpm exec playwright install --with-deps chromium
  • with-deps는 browser와 운영체제 dependency를 함께 설치한다. Playwright CLI 문서에서도 Linux system dependency가 필요한 경우 이 옵션을 사용할 수 있다고 설명한다. (Playwright CLI 문서 (새 탭에서 열림))

Build Context 제외 목록

의존성 설치가 끝난 뒤 소스 코드를 image에 복사했다.

docker
COPY . .

이때 현재 directory의 모든 파일이 무조건 들어가지는 않는다. .dockerignore에서 build context에 포함하지 않을 파일을 정의했다.

text
.git
.idea/
.next/
node_modules/
.env
.env.e2e

node_modules.next를 제외하면 host에서 만들어진 macOS binary나 오래된 build artifact가 Linux image에 섞이지 않는다. .env.env.e2e를 제외한 이유는 더 중요하다. 테스트 계정과 비밀번호는 image layer에 들어가면 안 된다.

Docker 공식 문서에 따르면 .dockerignore는 불필요한 파일을 build context에서 제거해 전송량과 cache invalidation을 줄인다. 이 프로젝트에서는 .env가 image layer에 남는 것도 함께 막았다. (Docker build context와 (새 탭에서 열림).dockerignore (새 탭에서 열림))

Runtime 환경변수와 Compose

Docker image에는 실행 코드와 도구만 넣고, 테스트 대상 URL과 자격증명은 container 실행 시 전달했다.

yaml
services:
  e2e:
    build:
      context: .
      dockerfile: Dockerfile.e2e
    env_file:
      - path: .env.e2e
        required: false
    environment:
      CI: "true"
      TEST_TARGET: ${TEST_TARGET:-}
      GREP: ${GREP:-}

Docker Compose의 env_file은 파일의 값을 container environment로 전달한다. 같은 key가 environment에도 선언되어 있다면 environment 값이 우선한다. (Compose service의 (새 탭에서 열림)env_file (새 탭에서 열림))

실제 자격증명은 commit하지 않고 .env.e2e에 둔다. repository에는 어떤 key가 필요한지만 보여주는 .env.e2e.example을 남겼다.

text
BASE_URL=https://example.test
PDA_BASE_URL=https://worker.example.test
TEST_LOGIN_ID=e2e-user
TEST_PASSWORD=...
PDA_LOGIN_ID=e2e-worker
PDA_PASSWORD=...

required: false이므로 .env.e2e가 없어도 Compose 자체는 실행된다. 대신 테스트는 필수 환경변수를 읽는 시점에 실패한다. 로컬 재현 도구로 사용할 때는 container 실행 전에 example을 복사하고 실제 값을 채워야 한다.

bash
cp .env.e2e.example .env.e2e

Playwright를 CI Mode로 실행하기

CI=true 한 줄로 Playwright의 CI 분기를 키면 된다.

yaml
CI: "true"

Playwright config는 process.env.CI (새 탭에서 열림)에 따라 실행 조건을 다르게 둔다.

javascript
expect: {
  timeout: process.env.CI ? 15_000 : 5_000,
},
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: 1,

로컬 shell에서 단순히 playwright test를 실행하면 CI에서만 적용되는 timeout, retry와 reporter 조건이 빠질 수 있다. Docker container에 CI=true를 전달해 운영체제뿐 아니라 Playwright의 CI 분기까지 재현했다.

이 설정만으로 테스트가 안정되지는 않는다. retry가 있는 상태에서 두 번째 실행이 성공하면 첫 번째 실패가 가려질 수도 있다. Docker는 CI와 같은 조건에서 같은 실패를 반복 관찰하려고 사용했다.

Shell Script 기반 실행 범위

container의 기본 command는 별도 shell script로 작성했다.

docker
CMD ["bash", "scripts/run-e2e-ci-like.sh"]
bash
#!/usr/bin/env bash
set -euo pipefail

args=(--project=chromium)

if [[ -n "${TEST_TARGET:-}" ]]; then
  args+=("$TEST_TARGET")
fi

if [[ -n "${GREP:-}" ]]; then
  args+=(--grep "$GREP")
fi

pnpm exec playwright test "${args[@]}"

set -euo pipefail은 실패한 command, 선언되지 않은 변수와 pipeline 내부 오류를 놓치지 않게 한다. 인자는 문자열을 조합하지 않고 Bash array에 추가했다. 파일 경로나 grep 표현식에 공백이 포함돼도 하나의 argument로 전달하기 위해서다.

전체 테스트가 아니라 CI에서 실패한 파일부터 확인할 수 있다.

bash
TEST_TARGET=e2e/tests/auth/login.spec.ts \
docker compose -f compose.e2e.yml run --rm e2e

특정 테스트 이름도 필터링할 수 있다.

bash
GREP="로그인" \
docker compose -f compose.e2e.yml run --rm e2e

Playwright가 실패하면 process가 non-zero exit code로 끝나고 container 실행도 실패로 종료된다. 로컬 terminal에서도 GitHub Actions와 비슷한 성공·실패 신호를 받는다.

Docker에서 재현된 CI 실패 사례 정리

Docker image를 빌드하고 CI에서 실패하던 테스트를 실행했다. GitHub Actions에서 보던 실패가 container에서도 같은 지점에서 나타났다. 로컬 macOS에서는 통과하던 테스트였기 때문에, 적어도 Linux 실행 조건에서 재현되는 문제라는 사실을 확인할 수 있었다.

이제 테스트를 고칠 때마다 CI 결과를 기다릴 필요가 없었다. Docker에서 실패를 반복해 확인하고, 수정한 코드를 같은 container에서 다시 실행했다. 이 과정에서 테스트가 CI 환경에 기대고 있던 조건도 하나씩 드러났다.

브라우저 시간대

브라우저 날짜가 업무 날짜와 달라 DatePicker 기본값과 조회 조건이 어긋났다. 새 browser context에 시간대를 명시했다.

typescript
const workerContext = await browser.newContext({
  viewport: { width: 390, height: 844 },
  timezoneId: "Asia/Seoul",
});

이 설정이 바꾸는 대상은 Playwright가 생성한 browser context의 시간대다. host Linux의 system timezone은 그대로다. Node process에서 new Date()를 사용하는 코드에는 별도로 명시적인 timezone 변환이 필요하다.

로컬 장치의 부재

개발자 Mac에 연결된 프린터나 카메라가 CI에는 존재하지 않는다. 로컬에서는 나타나지 않던 장치 오류 dialog와 권한 warning이 CI에서 다음 버튼을 가렸다.

테스트는 오류 dialog를 닫은 뒤 다음 단계로 넘어가게 했다.

typescript
const nextState = Promise.race([
  expect(nextButton).toBeEnabled().then(() => "ready"),
  printerErrorDialog.waitFor({ state: "visible" }).then(() => "printer-error"),
]);

고정된 몇 초 대신 실제 화면 상태를 기다렸다. Docker에서 로컬에 없던 분기를 확인하자 timeout을 늘리지 않고도 다음 동작을 정할 수 있었다.

Action 이전 응답 대기 등록

CI에서는 로컬과 렌더링·network timing이 달랐다. 요청이 발생한 뒤 waitForResponse를 호출하면 빠르게 끝난 응답을 놓쳤다.

typescript
const responsePromise = page.waitForResponse(
  response =>
    response.url().includes("/work-list") &&
    response.request().method() === "POST",
);

await searchButton.click();
expect((await responsePromise).ok()).toBeTruthy();

응답 대기를 먼저 등록하고 사용자 action을 실행하는 순서로 바꿨다. 문제는 Playwright가 아니라 테스트가 암묵적으로 기대한 timing에 있었다.

Docker가 복제하지 못한 것

Docker 환경을 만들었다고 GitHub Actions 전체가 같아지는 것은 아니다.

재현한 조건남아 있는 차이
Linux 사용자 공간GitHub의 정확한 Ubuntu runner image
Node·pnpm 버전GitHub runner CPU·메모리·disk
lockfile dependencyActions cache 상태
Chromium과 Linux libraryGitHub network와 DNS
CI=true 실행 분기GitHub Secrets·Variables 설정
Playwright 실행 명령실제 배포 완료 시점과 ECS rollout

현재 Docker script는 기본적으로 전체 E2E suite를 실행한다. 반면 배포 후 workflow는 UI regression을 제외한 domain suite만 실행한다. Web과 작업자용 Web이 준비될 때까지 기다리는 health check도 GitHub workflow에만 있다.

이 구성의 재현 범위는 여기까지다.

Node·pnpm·Chromium·Linux dependency와 Playwright CI mode를 재현한 로컬 E2E 실행 환경

Docker에서도 실패하면 운영체제·브라우저·테스트 코드부터 살펴본다. Docker에서는 통과하고 GitHub Actions에서만 실패하면 runner 자원, network, secret, 배포 readiness처럼 container 바깥으로 범위를 옮긴다.

알게 된 점

CI=true는 실행 환경을 구분하는 값

GitHub Actions는 CI=true를 기본 환경변수로 제공한다. Playwright가 이 값을 보고 자동으로 동작하는 것은 아니고, 프로젝트의 playwright.config.jsprocess.env.CI를 읽어 timeout·retry·reporter를 다르게 적용한다. Docker에서는 같은 분기를 실행하려고 이 값을 직접 넣었다.

VM과 Container의 차이

GitHub-hosted runner는 kernel까지 포함한 VM이고, Docker container는 host kernel을 공유하는 process다. Docker image로 Node·pnpm·Chromium과 시스템 라이브러리는 맞출 수 있지만 GitHub runner의 kernel·자원·network까지 복제할 수는 없다. 재현성을 더 높이려면 로컬과 CI가 같은 E2E image를 실행하게 만드는 편이 현실적이다.

E2E Suite는 실행 목적에 따른 테스트 묶음

Suite는 특별한 파일 형식이 아니라 함께 실행할 테스트 집합을 뜻한다. 이 프로젝트에서는 실제 Dev 서버와 통신하는 Domain E2E와 API를 일부 mock하는 UI Regression을 분리했다. 두 묶음은 실행시간과 서버 상태 변경 범위가 다르기 때문에 실행 시점도 달라야 한다.

참고 자료