↓ 본문으로 건너뛰기

Playwright UI Testing Testing Best Practices

Playwright "Test timeout of 30000ms exceeded.": 끝나지 않는 대기부터 찾기

Playwright의 "Test timeout of 30000ms exceeded."가 실제로 무엇을 재는지, 제가 확인하는 순서대로 본 흔한 원인, 멈춘 단계를 찾는 방법, 그리고 타임아웃을 늘리는 게 오답인 경우를 정리했습니다. 제 테스트 스위트에서 실제로 난 실패, 테스트 다섯 개와 이름이 바뀐 테스트 ID 하나의 이야기도 담았습니다.

Playwright "Test timeout of 30000ms exceeded.": 끝나지 않는 대기부터 찾기

Test timeout of 30000ms exceeded.는 Playwright 테스트 하나가 30초 제한 시간을 넘겼다는 뜻입니다. 30초는 Playwright Test의 기본값이고, 이 제한 시간에는 테스트 본문과 beforeEach 훅, 픽스처(fixture) 준비가 모두 들어갑니다. 메시지가 특정 단계가 아니라 테스트를 지목하는 건, 클릭이나 입력, 페이지 이동에 기본적으로 자체 타임아웃이 없기 때문입니다. 이것들은 얼마가 걸리든 기다리고, 테스트 타임아웃은 그저 가장 먼저 울리는 타이머일 뿐이죠.

그래서 이 에러가 정말 속도 문제인 경우는 드뭅니다. 대개는 어떤 단계가 절대 일어나지 않을 일을 기다리고 있었던 겁니다. 제가 확인하는 순서대로 원인과 해결책을 정리하면 이렇습니다.

  1. 로케이터가 아무것도 찾지 못해서, click()이나 fill()이 테스트 시간이 다 될 때까지 기다립니다. 테스트 ID나 텍스트가 바뀌었거나, 대화상자가 앞을 가리고 있는 게 흔한 이유입니다. 로케이터를 고치세요.
  2. 페이지 이동이나 waitForURL()이 끝내 오지 않는 페이지를 기다립니다. 리다이렉트, 로그인 화면, 첫 실행 화면 때문에 앱이 다른 곳으로 가버린 경우죠. 기다리는 대상을 고치세요.
  3. 준비 과정이 정말로 느립니다. 첫 요청에서야 컴파일을 시작하는 개발 서버, 0으로 스케일 다운됐다가 깨어나는 백엔드, beforeEach마다 UI로 하는 로그인 같은 것들입니다. 환경을 고치거나, 준비를 한 번만 하세요.
  4. 테스트가 그냥 깁니다. 이때, 그리고 이때만 test.slow()나 test.setTimeout()으로 그 테스트에 시간을 더 주세요.

어느 쪽이든 첫 번째 할 일은 시간이 다 됐을 때 아직 실행 중이던 단계를 찾는 것입니다. 두 번째는 actionTimeout을 설정해서, 다음에 실패할 때는 에러가 그 단계를 직접 알려주게 하는 것이고요.

30초에는 무엇이 들어가나
#

테스트 타임아웃은 픽스처 준비, beforeEach 훅, 테스트 함수가 함께 쓰는 하나의 제한 시간입니다. 테스트가 끝나면 afterEach 훅과 픽스처 정리(teardown)는 같은 길이의 제한 시간을 따로 받습니다. beforeAll과 afterAll도 같은 값을 받지만 어떤 테스트와도 시간을 나눠 쓰지 않고, 실패할 때도 자기 메시지를 따로 냅니다.

이 제한 시간 안에서 자기 타이머를 가진 건 어서션(assertion)뿐입니다. await expect(locator).toBeVisible()은 기본 5초가 지나면 포기하고, 무엇을 찾고 있었는지 정확히 알려줍니다. 액션과 페이지 이동은 그렇지 않습니다. actionTimeout과 navigationTimeout의 기본값은 무제한입니다. 이 에러가 이렇게 모호한 이유가 전부 여기 있습니다. 절대 나타나지 않을 요소를 클릭하는 코드는 실패하지 않습니다. 다른 무언가가 테스트를 끝낼 때까지 얌전히 기다릴 뿐이죠.

아래는 실행을 끝낼 수 있는 타임아웃 전부와, 각각이 출력하는 메시지입니다. 메시지는 제 프로젝트에 깔린 버전 중 가장 오래된 1.58과 가장 최신인 1.63에서 그대로 옮겼습니다.

타임아웃기본값설정하는 곳출력되는 메시지
테스트 (본문, beforeEach, 픽스처 준비)30,000 ms설정 파일의 timeout, --timeout, test.setTimeout(), test.slow()Test timeout of 30000ms exceeded.
beforeAll / afterAll테스트 타임아웃과 같은 값훅 안에서 test.setTimeout()"beforeAll" hook timeout of 30000ms exceeded.
어서션 (expect)5,000 ms설정 파일의 expect: { timeout }, 어서션별 { timeout }expect(locator).toBeVisible() failed … Timeout: 5000ms
액션 (click, fill)없음use: { actionTimeout }, 액션별 { timeout }TimeoutError: locator.click: Timeout 10000ms exceeded.
페이지 이동 (goto, waitForURL)없음use: { navigationTimeout }, 호출별 { timeout }TimeoutError: page.waitForURL: Timeout 15000ms exceeded.
전체 실행없음설정 파일의 globalTimeoutTimed out waiting 3600s for the test suite to run
webServer 기동60,000 mswebServer: { timeout }Timed out waiting 60000ms from config.webServer.

액션, 페이지 이동, 전체 실행에는 기본값이 없으니, 해당 행의 숫자는 직접 설정한 값이 들어가는 자리입니다.

테스트 다섯 개와 이름이 바뀐 테스트 ID
#

2월, 제가 만드는 견적 앱의 테스트 스위트에서 테스트 다섯 개가 한 번의 실행 중에 모두 같은 식으로 실패했습니다. 이 스위트는 개발 서버가 띄운 앱의 웹 버전을 워커 하나로 돌리고, 시드 데이터를 새로 넣은 로컬 백엔드를 상대로 테스트합니다. 다섯 개 중 첫 번째를 조금 줄여서 옮기면 이렇습니다.

  1) [default] › tests/templates.spec.ts:50:7 › Templates › should edit a template ───

    Test timeout of 30000ms exceeded.

    Error: locator.click: Target page, context or browser has been closed
    Call log:
      - waiting for locator('[data-testid="template-item"]').filter({ hasText: 'E2E Edit Template' }).locator('[data-testid="edit-template"]')


      57 |       .locator('[data-testid="template-item"]')
      58 |       .filter({ hasText: "E2E Edit Template" });
    > 59 |     await item.locator('[data-testid="edit-template"]').click();
         |                                                         ^
      60 |     await page.waitForTimeout(500);

Target page, context or browser has been closed라는 줄만 보면 브라우저가 죽은 것 같습니다. 아닙니다. 발밑에서 페이지가 닫혔을 때 액션이 내는 메시지입니다. 30초가 다 되자 Playwright가 테스트를 정리했고, 아직 기다리던 클릭이 사라진 페이지를 보고한 것이죠. 기본 제공 page 픽스처를 썼다면 이 줄은 Error: locator.click: Test timeout of 30000ms exceeded.처럼 타임아웃을 한 번 더 말해줬을 겁니다. 이 테스트들은 beforeAll에서 브라우저 컨텍스트를 직접 열기 때문에, 클릭 쪽에는 일반적인 메시지가 간 겁니다.

쓸모 있는 건 호출 로그(call log)입니다. 클릭은 [data-testid="template-item"]을 기다리고 있었습니다. 그보다 열 시간 전에 저는 목록 행 컴포넌트의 테스트 ID를 template-item에서 template-item-${name}으로 바꿨습니다. 네이티브 앱의 E2E 테스트가 이름으로 특정 행을 집어낼 수 있게 하려고요. =를 쓴 속성 선택자는 값 전체가 같아야 일치하니, 웹 테스트는 이제 페이지 어디에도 없는 요소를 찾고 있었습니다. 액션 타임아웃도 없었으니 그 클릭은 테스트 시간이 다 될 때까지 기다릴 수밖에 없었고요. 30초짜리 실패 다섯 개면 2분 30초인데, 전체 실행 시간이 3.8분이었습니다.

제가 먼저 넣은 건 재시도였습니다. 30분도 안 돼서 설정에 retries: process.env.CI ? 2 : 1이 들어갔고, 거기 달린 주석에는 지금도 “for flaky browser crashes”, 즉 가끔 나는 브라우저 크래시 대비용이라고 적혀 있습니다. 다음 실행에서는 같은 다섯 개 중 네 개가 재시도까지 포함해 또 실패했고, 실행 시간은 3.8분에서 6.4분으로 늘었습니다. 이 describe 블록들은 직렬(serial) 모드로 돌아갑니다. 하나가 실패하면 그룹의 나머지는 건너뛰고, 재시도는 그룹을 처음부터 다시 돌리죠. 그래서 잘못된 클릭마다 30초를 두 번씩 기다렸습니다. 매번 같은 이유로 실패하는 테스트는 재시도로 살릴 수 없습니다. 대가만 두 번 치를 뿐입니다.

해결책은 접두사 매칭이었습니다.

const item = page
  .locator('[data-testid^="template-item-"]')
  .filter({ hasText: "E2E Edit Template" });
await item.locator('[data-testid="edit-template"]').click();

더 넓게 보면, 테스트 ID도 일종의 계약입니다. 이 ID 하나에 기대는 테스트 스위트가 두 개였는데, 둘 다 서로의 존재를 몰랐던 거죠. Playwright 모범 사례 가이드가 제가 쓴 것 같은 CSS 선택자보다 getByRole()처럼 사용자에게 보이는 속성에 기반한 로케이터를 권하는 것도, 사용자는 눈치채지도 못할 DOM 변경에 테스트가 깨지지 않게 하려는 것입니다.

멈춘 단계 찾기
#

먼저 호출 로그를 읽으세요. 이 로그는 시간이 다 됐을 때 아직 실행 중이던 단계의 것이고, waiting for locator(...) 뒤에 아무것도 이어지지 않는다면 로케이터가 끝내 아무것도 찾지 못했다는 뜻입니다.

그걸로 부족하면 트레이스를 엽니다. 제 앱 스위트 두 개, 견적 앱 스위트와 제가 만드는 마켓플레이스·커뮤니티 앱 스위트는 둘 다 trace: "on-first-retry"를 씁니다. 첫 재시도 때만 기록하는 옵션이라 retries가 1 이상이어야 합니다. 재시도를 안 쓴다면 "retain-on-failure"를, 한 번만 돌려볼 때는 --trace on을 쓰면 됩니다. 그다음 트레이스를 직접 열거나, HTML 리포트에서 엽니다.

npx playwright show-trace test-results/<test-folder>/trace.zip
npx playwright show-report

끝나지 않은 액션은 목록 끝 근처, 에러가 표시된 항목입니다. 그걸 선택하고 스냅샷을 보세요. Playwright가 기다리던 그 순간의 페이지라서, 기대했던 요소가 있는지 직접 뒤져볼 수 있습니다. 제 경우라면 테스트가 찾지 않는 ID를 단 행이 멀쩡히 거기 있었을 겁니다.

최근 버전은 실패 스크린샷 옆에 error-context.md도 남깁니다. 실패한 순간의 페이지를 텍스트 스냅샷으로 담은 파일인데, 요소가 없는 경우라면 이것만으로 충분할 때가 많습니다.

함정이 하나 있습니다. --debug는 Inspector를 열면서 --max-failures=1, --headed, --workers=1과 함께 --timeout=0까지 설정합니다. --debug에서 통과한 테스트는 30초에 대해서는 아무것도 말해주지 않습니다.

모든 액션에 상한선을
#

제 설정 파일 중 액션 타임아웃을 정해둔 곳은 하나도 없습니다. 그래서 요소 하나가 없다는 이유로 30초를 다섯 번이나 쓴 거죠. use에 두 줄만 넣으면 달라집니다.

import { defineConfig } from "@playwright/test";

export default defineConfig({
  timeout: 30_000,
  expect: { timeout: 5_000 },
  use: {
    actionTimeout: 10_000,
    navigationTimeout: 15_000,
    trace: "retain-on-failure",
  },
});

그러면 똑같이 잘못된 로케이터가, 테스트에 아직 시간이 남아 있을 때 이렇게 실패합니다.

    TimeoutError: locator.click: Timeout 10000ms exceeded.
    Call log:
      - waiting for locator('[data-testid="template-item"]').filter({ hasText: 'E2E Edit Template' }).locator('[data-testid="edit-template"]')

어느 단계에서, 어떤 액션이, 얼마나 기다렸는지가 다 나옵니다. 두 값은 테스트 타임아웃보다 넉넉히 작게 두는 게 좋다고 봅니다. 그래야 멈춘 단계 하나가 제한 시간을 다 써버리고 모호한 메시지 뒤에 숨는 일이 없습니다. 2월의 문제는 30초가 아니었습니다. 그 클릭에 걸린 타이머가 그것 하나뿐이었으니, 그게 울렸을 뿐이죠.

타임아웃은 멈춘 테스트가 아니라 긴 테스트에만
#

타임아웃을 늘리는 건 테스트가 실제로 시간이 걸리는 일을 할 때 맞는 해결책입니다. 어떤 단계가 일어나지 않을 일을 기다리고 있을 때는 틀린 해결책이고요. 숫자를 키워봐야 실패가 더 늦게 올 뿐이니까요. 어느 쪽인지는 대개 트레이스를 보면 정리됩니다. 액션이 하나하나 끝나는데 그냥 수가 많다면 긴 테스트이고, 액션 하나가 끝까지 waiting for에 머물러 있다면 멈춘 테스트입니다.

긴 테스트라면 스위트 전체가 아니라 실제로 긴 곳에 시간을 더하세요. 마켓플레이스 앱 스위트에는 채팅 화면을 돌면서 스크린샷을 찍는 스모크 테스트가 하나 있는데, 이 테스트에는 3분을 줍니다.

test("chat: open, send a message, go back", async ({ page }) => {
  test.setTimeout(180_000);
  // ...
});

describe 안에서 test.setTimeout()을 부르면 그룹 안의 모든 테스트에 적용됩니다. 견적 앱 스위트의 작업 비용(job-cost) 테스트가 1분을 받는 방식이 바로 이겁니다. test.slow()는 현재 타임아웃이 얼마든 세 배로 늘리고, 조건을 받을 수도 있습니다. beforeEach 안에서 testInfo.setTimeout()을 쓰면 그 훅을 거치는 모든 테스트의 시간이 늘어납니다.

test("slow in WebKit", async ({ page, browserName }) => {
  test.slow(browserName === "webkit", "This page is slow in WebKit");
  // ...
});

test.beforeEach(async ({ page }, testInfo) => {
  testInfo.setTimeout(testInfo.timeout + 30_000);
});

제한 시간을 매번 조용히 갉아먹는 게 하나 더 있습니다. 고정 대기입니다. 견적 앱 스위트에는 거의 모든 스펙 파일에 걸쳐 page.waitForTimeout() 호출이 백 개 넘게 있습니다. 위의 편집 테스트는 클릭에 닿기도 전에 3초를 자고 시작하는데, 얼마를 더 잤어도 살아나지는 못했을 겁니다. Playwright API 문서도 이 부분은 단호합니다. “Never wait for timeout in production. Tests that wait for time are inherently flaky.” 프로덕션에서는 절대 시간으로 기다리지 말라는 것, 시간을 기다리는 테스트는 본질적으로 불안정하다는 것입니다. 웹 우선 어서션(web-first assertion)은 원래 필요한 만큼만, 딱 그만큼 기다립니다.

// before
await page.locator('[data-testid="save-template"]').click();
await page.waitForTimeout(1500);
await expect(page.getByText("E2E Edited Template")).toBeVisible();

// after
await page.locator('[data-testid="save-template"]').click();
await expect(page.getByText("E2E Edited Template")).toBeVisible();

어서션 타임아웃도 똑같이 조심해야 합니다. 두 앱 스위트 모두 expect.timeout을 15초로 올려두었고, 둘 다 프로덕션 빌드가 아니라 앱의 개발 서버를 상대로 돌아갑니다. 여유는 생기지만, 느린 어서션 두 개가 연달아 오면 둘이서 30초를 다 써버릴 수도 있습니다. 그러면 테스트 타임아웃이 먼저 터지고, 다시 모호한 메시지로 돌아가게 되죠.

테스트의 타이머가 아닐 때
#

메시지에 config.webServer가 보인다면 아직 테스트는 하나도 실행되지 않은 겁니다. Playwright는 webServer 명령을 실행하고, 그 url이 응답할 때까지 기다린 뒤에야 테스트를 시작합니다. 이 대기에는 별도의 타임아웃이 있고, 기본값은 60초입니다. 두 앱 스위트 모두 개발 서버에 2분을 줍니다.

webServer: {
  command: "npm run web -- --port 8081",
  url: "http://localhost:8081",
  reuseExistingServer: false,
  timeout: 120_000,
},

테스트가 로컬 서버가 아니라 배포된 환경을 상대로 돈다면, 첫 요청이 얼마나 걸리는지부터 확인해보세요. 제가 쓰는 호스팅 환경을 마침내 측정해봤더니, 0으로 스케일 다운된 상태에서 시작하는 머신이 무엇이든 응답하기까지 25~33초가 걸렸습니다. 첫 클릭도 하기 전에 테스트 제한 시간의 대부분, 혹은 전부를 쓰는 셈입니다. 그 이야기는 0으로 스케일 다운, 그리고 재시작마다 30초짜리 장애에 정리해두었습니다.

“No tests found"가 뜬다면
#

No tests found는 비슷해 보이지만 타임아웃이 아닙니다. Playwright가 찾아봤는데 실행할 게 없었다는 뜻입니다. 기본적으로 testDir 아래에서 **/*.@(spec|test).?(c|m)[jt]s?(x)에 맞는 파일을 모으고, testDir의 기본값은 설정 파일이 있는 폴더입니다. 제 설정들은 설정 파일 옆의 testDir: "./tests"를 가리키니, 다른 곳에 저장한 스펙은 보이지 않습니다. 명령줄 인자는 파일 경로와 매칭되는 정규식이라서, 에러에 “Make sure that arguments are regular expressions matching test files.“라는 문장이 붙습니다. 아무것도 실행하지 않고 Playwright가 무엇을 모았는지만 보려면 이렇게 합니다.

npx playwright test --list

그 목록에 스펙이 없다면, 애초에 타임아웃이 날 일도 없었던 겁니다. 그리고 Playwright를 처음 세팅하는 중이라면, 스크린샷 비교부터 시작하자는 제 주장은 소규모 팀일수록 비주얼 테스팅에 있습니다.