↓ Chuyển đến nội dung chính

Playwright UI Testing Testing Best Practices

Lỗi "Test timeout of 30000ms exceeded." trong Playwright: tìm bước chờ không bao giờ kết thúc

Thông báo "Test timeout of 30000ms exceeded." của Playwright thực ra đang đo cái gì, các nguyên nhân thường gặp theo thứ tự tôi hay kiểm tra, cách tìm ra bước bị treo, và khi nào tăng giới hạn thời gian lại là cách sửa sai. Kèm theo một lỗi có thật trong bộ kiểm thử của chính tôi: năm bài kiểm thử và một test ID bị đổi tên.

Lỗi "Test timeout of 30000ms exceeded." trong Playwright: tìm bước chờ không bao giờ kết thúc

Test timeout of 30000ms exceeded. nghĩa là một bài kiểm thử Playwright đã chạy quá giới hạn 30 giây dành cho nó. Ba mươi giây là giá trị mặc định của Playwright Test, và khoảng thời gian này tính gộp cả phần thân bài kiểm thử, các hook beforeEach lẫn bước chuẩn bị fixture. Thông báo chỉ nêu tên bài kiểm thử chứ không nêu bước nào bị kẹt, vì theo mặc định, các thao tác như bấm, nhập liệu hay chuyển trang không có giới hạn thời gian (timeout) riêng. Chúng cứ chờ cho tới khi xong thì thôi, và timeout của bài kiểm thử đơn giản là chiếc đồng hồ hết giờ đầu tiên.

Vì vậy, lỗi này hiếm khi thật sự là chuyện chậm. Thường thì có một bước đang chờ một điều sẽ không bao giờ xảy ra. Dưới đây là các nguyên nhân theo thứ tự tôi hay kiểm tra, kèm cách sửa cho từng cái:

  1. Locator (bộ chọn phần tử) không khớp với phần tử nào, nên lệnh click() hay fill() cứ chờ cho tới khi bài kiểm thử hết giờ. Lý do quen thuộc là test ID bị đổi tên, chữ hiển thị bị đổi, hoặc có hộp thoại che phía trước. Cách sửa: sửa lại locator.
  2. Một lần chuyển trang hay lệnh waitForURL() đang chờ một trang không bao giờ tới, vì một lần chuyển hướng (redirect), màn hình đăng nhập hoặc màn hình chạy lần đầu đã đưa ứng dụng sang chỗ khác. Cách sửa: sửa lại điều kiện bạn đang chờ.
  3. Bước chuẩn bị chậm thật: máy chủ phát triển (dev server) chỉ bắt đầu biên dịch khi có yêu cầu đầu tiên, backend phải khởi động nguội (cold start) sau khi bị thu về 0 (scale to zero), hoặc đăng nhập qua giao diện trong mọi beforeEach. Cách sửa: sửa môi trường, hoặc chỉ chuẩn bị một lần.
  4. Bài kiểm thử vốn dĩ dài. Khi đó, và chỉ khi đó, mới cho riêng bài ấy thêm thời gian bằng test.slow() hoặc test.setTimeout().

Dù là trường hợp nào, việc đầu tiên vẫn là tìm ra bước còn đang chạy vào lúc hết giờ. Việc thứ hai là đặt actionTimeout, để ở lần thất bại sau, chính thông báo lỗi sẽ chỉ ra bước đó cho bạn.

30 giây ấy bao gồm những gì
#

Timeout của bài kiểm thử là một quỹ thời gian duy nhất, dùng chung cho bước chuẩn bị fixture, các hook beforeEach và hàm kiểm thử. Khi bài kiểm thử kết thúc, các hook afterEach và bước dọn dẹp fixture (teardown) được cấp thêm một quỹ thời gian riêng có cùng độ dài. beforeAll và afterAll cũng nhận cùng giá trị đó, nhưng không dùng chung với bài kiểm thử nào, và khi hết giờ thì báo lỗi bằng thông điệp riêng.

Trong quỹ thời gian đó, chỉ có các câu lệnh kiểm tra (assertion) là có đồng hồ riêng. await expect(locator).toBeVisible() mặc định bỏ cuộc sau 5 giây và cho bạn biết chính xác nó đang tìm gì. Các thao tác và lệnh chuyển trang thì không: actionTimeout và navigationTimeout mặc định là không giới hạn. Đó là toàn bộ lý do khiến lỗi này mơ hồ đến vậy. Một lệnh bấm vào phần tử sẽ không bao giờ xuất hiện thì không hề thất bại. Nó chỉ ngoan ngoãn chờ, cho tới khi có thứ khác kết thúc bài kiểm thử.

Bảng dưới đây liệt kê mọi loại timeout có thể kết thúc một lần chạy, cùng thông báo mà mỗi loại in ra. Các thông báo được chép nguyên văn từ Playwright 1.58 và 1.63, phiên bản cũ nhất và mới nhất trong các dự án của tôi.

Loại timeoutMặc địnhĐặt ở đâuThông báo in ra
Bài kiểm thử (thân bài, beforeEach, chuẩn bị fixture)30,000 mstimeout trong tệp cấu hình, --timeout, test.setTimeout(), test.slow()Test timeout of 30000ms exceeded.
beforeAll / afterAllbằng timeout của bài kiểm thửtest.setTimeout() bên trong hook"beforeAll" hook timeout of 30000ms exceeded.
Assertion (expect)5,000 msexpect: { timeout } trong tệp cấu hình, { timeout } trên từng assertionexpect(locator).toBeVisible() failed … Timeout: 5000ms
Thao tác (click, fill)không cóuse: { actionTimeout }, { timeout } trên từng thao tácTimeoutError: locator.click: Timeout 10000ms exceeded.
Chuyển trang (goto, waitForURL)không cóuse: { navigationTimeout }, { timeout } trên từng lệnhTimeoutError: page.waitForURL: Timeout 15000ms exceeded.
Toàn bộ lần chạykhông cóglobalTimeout trong tệp cấu hìnhTimed out waiting 3600s for the test suite to run
Khởi động webServer60,000 mswebServer: { timeout }Timed out waiting 60000ms from config.webServer.

Ba hàng thao tác, chuyển trang và toàn bộ lần chạy không có giá trị mặc định để in ra, nên con số ở đó là giá trị do bạn tự đặt.

Năm bài kiểm thử và một test ID bị đổi tên
#

Hồi tháng 2, trong một lần chạy, năm bài kiểm thử trong bộ kiểm thử của ứng dụng báo giá mà tôi đang làm đã thất bại theo cùng một kiểu. Bộ kiểm thử này dùng Playwright để điều khiển bản web của ứng dụng (do máy chủ phát triển của ứng dụng phục vụ), chạy với một worker, gọi tới một backend cục bộ vừa được nạp lại dữ liệu mẫu (seed). Đây là lỗi đầu tiên trong năm lỗi, đã lược bớt:

  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);

Chỉ nhìn dòng Target page, context or browser has been closed thì tưởng trình duyệt bị sập. Không phải vậy. Đó là thông báo mà một thao tác đưa ra khi trang bị đóng ngay dưới chân nó: 30 giây đã hết, Playwright dọn dẹp bài kiểm thử, và lệnh bấm vẫn đang chờ kia báo lại rằng trang của nó đã mất. Nếu dùng fixture page có sẵn, dòng đó sẽ nhắc lại chuyện hết giờ, thành Error: locator.click: Test timeout of 30000ms exceeded. Các bài kiểm thử này tự mở ngữ cảnh trình duyệt (browser context) trong beforeAll, nên lệnh bấm chỉ nhận được thông báo chung chung.

Dòng có ích là nhật ký lệnh gọi (call log). Lệnh bấm đang chờ [data-testid="template-item"]. Mười tiếng trước đó, tôi đã đổi test ID của component dòng danh sách từ template-item thành template-item-${name}, để các bài kiểm thử đầu cuối (end-to-end) trên bản native của ứng dụng có thể chọn đúng một dòng theo tên. Bộ chọn (selector) thuộc tính dùng = đòi hỏi khớp toàn bộ giá trị, nên lúc này các bài kiểm thử web đang tìm một phần tử không hề tồn tại ở đâu trên trang. Lại không có timeout cho thao tác, nên lệnh bấm đó chắc chắn sẽ chờ cho tới khi bài kiểm thử hết giờ. Năm bài kiểm thử, mỗi bài 30 giây, là hai phút rưỡi, trong một lần chạy tổng cộng mất 3.8 phút.

Thứ tôi thêm vào đầu tiên là chạy lại (retry). Chưa tới nửa tiếng sau, tệp cấu hình đã có retries: process.env.CI ? 2 : 1, kèm một dòng chú thích mà tới giờ vẫn ghi “for flaky browser crashes”, tức là để phòng những lần trình duyệt thỉnh thoảng bị sập. Ở lần chạy kế tiếp, bốn trong năm bài kiểm thử cũ lại thất bại, kể cả ở lần chạy lại, và lần chạy kéo dài 6.4 phút thay vì 3.8. Các khối describe này chạy ở chế độ tuần tự (serial): một bài thất bại thì các bài còn lại trong nhóm bị bỏ qua, còn khi chạy lại thì cả nhóm chạy lại từ đầu. Vì thế mỗi lệnh bấm hỏng đã chờ trọn 30 giây tới hai lần. Một bài kiểm thử lần nào cũng thất bại theo cùng một cách thì chạy lại không cứu được. Nó chỉ bắt bạn trả giá hai lần.

Cách sửa là khớp theo tiền tố:

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

Bài học rộng hơn là test ID cũng là một dạng hợp đồng, và tôi có tới hai bộ kiểm thử cùng dựa vào ID này mà không bên nào biết bên kia tồn tại. Hướng dẫn thực hành tốt (best practices) của Playwright khuyên ưu tiên các locator dựa trên thuộc tính người dùng nhìn thấy được, như getByRole(), thay vì bộ chọn CSS như của tôi, để bài kiểm thử không vỡ vì những thay đổi DOM mà người dùng chẳng bao giờ nhận ra.

Tìm bước bị treo
#

Hãy đọc call log trước tiên. Nó thuộc về bước vẫn còn đang chạy lúc hết giờ, và nếu sau dòng waiting for locator(...) không có gì nữa, nghĩa là locator chưa bao giờ khớp được với phần tử nào.

Nếu vẫn chưa đủ, hãy mở bản ghi thực thi (trace). Hai bộ kiểm thử ứng dụng của tôi, một cho ứng dụng báo giá và một cho ứng dụng chợ kiêm cộng đồng mà tôi đang làm, đều dùng trace: "on-first-retry". Tùy chọn này chỉ ghi lại ở lần chạy lại đầu tiên, nên cần đặt retries ít nhất là 1. Nếu không dùng chạy lại thì dùng "retain-on-failure"; còn muốn chạy thử một lần thì thêm --trace on. Sau đó mở trace trực tiếp, hoặc mở từ báo cáo HTML:

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

Thao tác chưa bao giờ hoàn tất là mục mang thông báo lỗi, nằm gần cuối danh sách. Chọn nó rồi xem ảnh chụp nhanh (snapshot): đó là trang đúng như lúc Playwright đang chờ, và bạn có thể soi trong đó để tìm phần tử mình mong đợi. Với trường hợp như của tôi, dòng danh sách vẫn nằm ngay đó, chỉ là mang một ID mà bài kiểm thử không hề tìm.

Các phiên bản gần đây còn ghi thêm một tệp error-context.md bên cạnh ảnh chụp màn hình lúc thất bại, chứa ảnh chụp nhanh dạng văn bản của trang vào đúng lúc lỗi xảy ra. Với trường hợp thiếu phần tử, chỉ riêng tệp này nhiều khi là đủ.

Có một cái bẫy: --debug vừa mở Inspector vừa đặt --timeout=0, cùng với --max-failures=1, --headed và --workers=1. Một bài kiểm thử chạy qua được dưới --debug chẳng nói lên điều gì về 30 giây kia.

Đặt trần thời gian cho mọi thao tác
#

Không tệp cấu hình nào của tôi đặt timeout cho thao tác, và đó là lý do một phần tử không tồn tại có thể tiêu tốn 30 giây, tới năm lần. Thêm hai dòng vào use là thay đổi được:

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",
  },
});

Khi đó, cùng cái locator hỏng ấy sẽ thất bại như thế này, trong lúc bài kiểm thử vẫn còn thời gian:

    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"]')

Thông báo nêu rõ bước nào, thao tác nào và đã chờ bao lâu. Tôi nghĩ nên để cả hai giá trị thấp hơn hẳn timeout của bài kiểm thử, để một bước bị kẹt không thể ngốn hết quỹ thời gian rồi nấp sau thông báo mơ hồ kia. Hồi tháng 2, vấn đề chưa bao giờ nằm ở con số 30 giây. Đó chỉ là chiếc đồng hồ duy nhất gắn với lệnh bấm ấy, nên nó là cái reo lên.

Chỉ tăng timeout cho bài kiểm thử dài, đừng tăng cho bài bị kẹt
#

Tăng timeout là cách sửa đúng khi bài kiểm thử đang làm việc thật và việc đó tốn thời gian. Nó là cách sửa sai khi có một bước đang chờ điều sẽ không xảy ra, vì con số lớn hơn chỉ khiến thất bại đến chậm hơn. Bản ghi trace thường cho biết bạn đang gặp trường hợp nào: một chuỗi thao tác đều đặn, cái nào cũng hoàn tất, chỉ là có nhiều, thì đó là bài kiểm thử dài; còn một thao tác nằm ở trạng thái waiting for cho tới tận cuối thì đó là bài bị kẹt.

Với bài kiểm thử dài, hãy thêm thời gian ở đúng chỗ dài chứ đừng nới cho cả bộ kiểm thử. Bộ kiểm thử của ứng dụng chợ có một bài kiểm thử khói (smoke test) đi qua màn hình chat và chụp ảnh màn hình ở từng bước, và bài này được cho ba phút:

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

Gọi test.setTimeout() bên trong describe thì nó áp dụng cho mọi bài kiểm thử trong nhóm; bài kiểm thử tính chi phí công việc (job-cost) trong bộ kiểm thử của ứng dụng báo giá được một phút theo cách đó. test.slow() nhân ba timeout hiện tại, bất kể nó đang là bao nhiêu, và có thể nhận một điều kiện. Trong beforeEach, testInfo.setTimeout() kéo dài thời gian cho mọi bài kiểm thử đi qua hook đó:

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);
});

Còn một thứ nữa lặng lẽ ăn vào quỹ thời gian ở mọi lần chạy: những lần ngủ cố định. Bộ kiểm thử của ứng dụng báo giá có hơn một trăm lệnh gọi page.waitForTimeout(), rải gần như khắp các tệp spec. Bài kiểm thử chỉnh sửa ở trên ngủ ba giây trước cả khi tới được lệnh bấm, và ngủ bao lâu cũng không cứu được nó. Tài liệu API của Playwright nói rất thẳng về chuyện này: “Never wait for timeout in production. Tests that wait for time are inherently flaky.” Nghĩa là: đừng bao giờ chờ theo thời gian khi chạy thật, vì bài kiểm thử chờ theo thời gian vốn dĩ đã chập chờn. Một assertion kiểu web-first (web-first assertion) vốn đã chờ đúng bao lâu cần thiết, không hơn:

// 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();

Timeout của assertion cũng cần cẩn thận như vậy. Cả hai bộ kiểm thử ứng dụng của tôi đều nâng expect.timeout lên 15 giây, và cả hai đều chạy với máy chủ phát triển của ứng dụng chứ không phải bản build production. Như vậy là có thêm khoảng đệm, nhưng hai assertion chậm liền nhau có thể tiêu hết 30 giây, và khi đó timeout của bài kiểm thử sẽ nổ trước, bạn lại quay về với thông báo mơ hồ.

Khi đồng hồ không phải của bài kiểm thử
#

Nếu thông báo có nhắc tới config.webServer, nghĩa là chưa bài kiểm thử nào kịp chạy. Playwright khởi động lệnh webServer của bạn và chờ cho tới khi url của nó phản hồi rồi mới chạy kiểm thử, với một timeout riêng, mặc định là 60 giây. Cả hai bộ kiểm thử ứng dụng của tôi đều cho máy chủ phát triển hai phút:

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

Nếu bài kiểm thử của bạn chạy với một môi trường đã triển khai thay vì máy chủ cục bộ, hãy tìm hiểu xem yêu cầu đầu tiên tới đó mất bao lâu. Khi cuối cùng cũng đo môi trường của mình trên nền tảng hosting đang dùng, tôi thấy một máy khởi động nguội từ trạng thái thu về 0 mất từ 25 tới 33 giây mới phục vụ được bất cứ thứ gì. Tức là phần lớn, thậm chí toàn bộ quỹ thời gian của một bài kiểm thử, trước cả lần bấm đầu tiên. Câu chuyện đó nằm trong bài Thu về 0 (scale to zero), và mỗi lần khởi động lại là một lần sập 30 giây.

Nếu thông báo là “No tests found”
#

No tests found nghe có vẻ liên quan, nhưng nó không phải timeout: Playwright đã đi tìm và không thấy gì để chạy. Theo mặc định, nó gom các tệp khớp với mẫu **/*.@(spec|test).?(c|m)[jt]s?(x) bên dưới testDir, và testDir mặc định là thư mục chứa tệp cấu hình. Các tệp cấu hình của tôi đặt testDir: "./tests" ngay cạnh tệp cấu hình, nên một tệp spec lưu ở chỗ khác sẽ hoàn toàn vô hình. Các đối số trên dòng lệnh là biểu thức chính quy (regular expression) dùng để so với đường dẫn tệp, nên thông báo lỗi mới kèm thêm câu “Make sure that arguments are regular expressions matching test files.” Để xem Playwright đã gom được những gì mà không chạy gì cả:

npx playwright test --list

Nếu tệp spec của bạn không có trong danh sách đó, thì nó vốn chẳng bao giờ có cơ hội bị timeout. Còn nếu bạn mới bắt đầu thiết lập Playwright, lý do tôi khuyên nên bắt đầu từ so sánh ảnh chụp màn hình nằm trong bài Kiểm thử trực quan (visual testing) cho các đội nhỏ.