<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:media="http://search.yahoo.com/mrss/">
  <channel>
    <title>Playwright · Nhân Tài Đức</title>
    <link>https://jared.lynskey.co.nz/vi/tags/playwright/</link>
    <description>Những câu chuyện ngắn về kỹ thuật phần mềm và lãnh đạo đội ngũ kỹ sư — performance review, tuyển dụng và mở rộng team, CI/CD, giám sát DevOps và AI agent. Viết từ Seoul.</description>
    <generator>Hugo</generator>
    <language>vi</language>
    <copyright>© 2026 Nhân Tài Đức</copyright>
    <lastBuildDate>Thu, 22 Oct 2026 00:00:00 +1300</lastBuildDate>
    <atom:link href="https://jared.lynskey.co.nz/vi/tags/playwright/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>Lỗi &#34;Test timeout of 30000ms exceeded.&#34; trong Playwright: tìm bước chờ không bao giờ kết thúc</title>
      <link>https://jared.lynskey.co.nz/vi/posts/2026/2026-10-22-playwright-timeout-30000ms/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/vi/posts/2026/2026-10-22-playwright-timeout-30000ms/</guid>
      <pubDate>Thu, 22 Oct 2026 00:00:00 +1300</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>Playwright</category>
      <category>UI Testing</category>
      <category>Testing Best Practices</category>
      <category>Debugging</category>
      <description>Thông báo &#34;Test timeout of 30000ms exceeded.&#34; 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.</description>
      <content:encoded><![CDATA[<p><code>Test timeout of 30000ms exceeded.</code> 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 <code>beforeEach</code> 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.</p>
<p>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:</p>
<ol>
<li>Locator (bộ chọn phần tử) không khớp với phần tử nào, nên lệnh <code>click()</code> hay <code>fill()</code> 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.</li>
<li>Một lần chuyển trang hay lệnh <code>waitForURL()</code> đ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ờ.</li>
<li>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 <code>beforeEach</code>. Cách sửa: sửa môi trường, hoặc chỉ chuẩn bị một lần.</li>
<li>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 <code>test.slow()</code> hoặc <code>test.setTimeout()</code>.</li>
</ol>
<p>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 <code>actionTimeout</code>, để ở lần thất bại sau, chính thông báo lỗi sẽ chỉ ra bước đó cho bạn.</p>

<h2 class="relative group">30 giây ấy bao gồm những gì
    <div id="30-giây-ấy-bao-gồm-những-gì" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#30-gi%c3%a2y-%e1%ba%a5y-bao-g%e1%bb%93m-nh%e1%bb%afng-g%c3%ac" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>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 <code>beforeEach</code> và hàm kiểm thử. Khi bài kiểm thử kết thúc, các hook <code>afterEach</code> 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. <code>beforeAll</code> và <code>afterAll</code> 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.</p>
<p>Trong quỹ thời gian đó, chỉ có các câu lệnh kiểm tra (assertion) là có đồng hồ riêng. <code>await expect(locator).toBeVisible()</code> 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: <code>actionTimeout</code> và <code>navigationTimeout</code> 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ử.</p>
<p>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.</p>
<table>
	<thead>
			<tr>
					<th>Loại timeout</th>
					<th>Mặc định</th>
					<th>Đặt ở đâu</th>
					<th>Thông báo in ra</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Bài kiểm thử (thân bài, <code>beforeEach</code>, chuẩn bị fixture)</td>
					<td>30,000 ms</td>
					<td><code>timeout</code> trong tệp cấu hình, <code>--timeout</code>, <code>test.setTimeout()</code>, <code>test.slow()</code></td>
					<td><code>Test timeout of 30000ms exceeded.</code></td>
			</tr>
			<tr>
					<td><code>beforeAll</code> / <code>afterAll</code></td>
					<td>bằng timeout của bài kiểm thử</td>
					<td><code>test.setTimeout()</code> bên trong hook</td>
					<td><code>&quot;beforeAll&quot; hook timeout of 30000ms exceeded.</code></td>
			</tr>
			<tr>
					<td>Assertion (<code>expect</code>)</td>
					<td>5,000 ms</td>
					<td><code>expect: { timeout }</code> trong tệp cấu hình, <code>{ timeout }</code> trên từng assertion</td>
					<td><code>expect(locator).toBeVisible() failed</code> … <code>Timeout: 5000ms</code></td>
			</tr>
			<tr>
					<td>Thao tác (<code>click</code>, <code>fill</code>)</td>
					<td>không có</td>
					<td><code>use: { actionTimeout }</code>, <code>{ timeout }</code> trên từng thao tác</td>
					<td><code>TimeoutError: locator.click: Timeout 10000ms exceeded.</code></td>
			</tr>
			<tr>
					<td>Chuyển trang (<code>goto</code>, <code>waitForURL</code>)</td>
					<td>không có</td>
					<td><code>use: { navigationTimeout }</code>, <code>{ timeout }</code> trên từng lệnh</td>
					<td><code>TimeoutError: page.waitForURL: Timeout 15000ms exceeded.</code></td>
			</tr>
			<tr>
					<td>Toàn bộ lần chạy</td>
					<td>không có</td>
					<td><code>globalTimeout</code> trong tệp cấu hình</td>
					<td><code>Timed out waiting 3600s for the test suite to run</code></td>
			</tr>
			<tr>
					<td>Khởi động <code>webServer</code></td>
					<td>60,000 ms</td>
					<td><code>webServer: { timeout }</code></td>
					<td><code>Timed out waiting 60000ms from config.webServer.</code></td>
			</tr>
	</tbody>
</table>
<p>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.</p>

<h2 class="relative group">Năm bài kiểm thử và một test ID bị đổi tên
    <div id="năm-bài-kiểm-thử-và-một-test-id-bị-đổi-tên" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#n%c4%83m-b%c3%a0i-ki%e1%bb%83m-th%e1%bb%ad-v%c3%a0-m%e1%bb%99t-test-id-b%e1%bb%8b-%c4%91%e1%bb%95i-t%c3%aan" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>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:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">  1) [default] › tests/templates.spec.ts:50:7 › Templates › should edit a template ───
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    Test timeout of 30000ms exceeded.
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    Error: locator.click: Target page, context or browser has been closed
</span></span><span class="line"><span class="cl">    Call log:
</span></span><span class="line"><span class="cl">      - waiting for locator(&#39;[data-testid=&#34;template-item&#34;]&#39;).filter({ hasText: &#39;E2E Edit Template&#39; }).locator(&#39;[data-testid=&#34;edit-template&#34;]&#39;)
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">      57 |       .locator(&#39;[data-testid=&#34;template-item&#34;]&#39;)
</span></span><span class="line"><span class="cl">      58 |       .filter({ hasText: &#34;E2E Edit Template&#34; });
</span></span><span class="line"><span class="cl">    &gt; 59 |     await item.locator(&#39;[data-testid=&#34;edit-template&#34;]&#39;).click();
</span></span><span class="line"><span class="cl">         |                                                         ^
</span></span><span class="line"><span class="cl">      60 |     await page.waitForTimeout(500);</span></span></code></pre></div></div>
<p>Chỉ nhìn dòng <code>Target page, context or browser has been closed</code> 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 <code>page</code> có sẵn, dòng đó sẽ nhắc lại chuyện hết giờ, thành <code>Error: locator.click: Test timeout of 30000ms exceeded.</code> Các bài kiểm thử này tự mở ngữ cảnh trình duyệt (browser context) trong <code>beforeAll</code>, nên lệnh bấm chỉ nhận được thông báo chung chung.</p>
<p>Dòng có ích là nhật ký lệnh gọi (call log). Lệnh bấm đang chờ <code>[data-testid=&quot;template-item&quot;]</code>. Mười tiếng trước đó, tôi đã đổi test ID của component dòng danh sách từ <code>template-item</code> thành <code>template-item-${name}</code>, để 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 <code>=</code> đò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.</p>
<p>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ó <code>retries: process.env.CI ? 2 : 1</code>, kèm một dòng chú thích mà tới giờ vẫn ghi &ldquo;for flaky browser crashes&rdquo;, 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.</p>
<p>Cách sửa là khớp theo tiền tố:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ts" data-lang="ts"><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">item</span> <span class="o">=</span> <span class="nx">page</span>
</span></span><span class="line"><span class="cl">  <span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="s1">&#39;[data-testid^=&#34;template-item-&#34;]&#39;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="p">.</span><span class="nx">filter</span><span class="p">({</span> <span class="nx">hasText</span><span class="o">:</span> <span class="s2">&#34;E2E Edit Template&#34;</span> <span class="p">});</span>
</span></span><span class="line"><span class="cl"><span class="k">await</span> <span class="nx">item</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="s1">&#39;[data-testid=&#34;edit-template&#34;]&#39;</span><span class="p">).</span><span class="nx">click</span><span class="p">();</span></span></span></code></pre></div></div>
<p>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ư <code>getByRole()</code>, 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.</p>

<h2 class="relative group">Tìm bước bị treo
    <div id="tìm-bước-bị-treo" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#t%c3%acm-b%c6%b0%e1%bb%9bc-b%e1%bb%8b-treo" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>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 <code>waiting for locator(...)</code> không có gì nữa, nghĩa là locator chưa bao giờ khớp được với phần tử nào.</p>
<p>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 <code>trace: &quot;on-first-retry&quot;</code>. Tùy chọn này chỉ ghi lại ở lần chạy lại đầu tiên, nên cần đặt <code>retries</code> ít nhất là 1. Nếu không dùng chạy lại thì dùng <code>&quot;retain-on-failure&quot;</code>; còn muốn chạy thử một lần thì thêm <code>--trace on</code>. Sau đó mở trace trực tiếp, hoặc mở từ báo cáo HTML:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">npx playwright show-trace test-results/&lt;test-folder&gt;/trace.zip
</span></span><span class="line"><span class="cl">npx playwright show-report</span></span></code></pre></div></div>
<p>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.</p>
<p>Các phiên bản gần đây còn ghi thêm một tệp <code>error-context.md</code> 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à đủ.</p>
<p>Có một cái bẫy: <code>--debug</code> vừa mở Inspector vừa đặt <code>--timeout=0</code>, cùng với <code>--max-failures=1</code>, <code>--headed</code> và <code>--workers=1</code>. Một bài kiểm thử chạy qua được dưới <code>--debug</code> chẳng nói lên điều gì về 30 giây kia.</p>

<h2 class="relative group">Đặt trần thời gian cho mọi thao tác
    <div id="đặt-trần-thời-gian-cho-mọi-thao-tác" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%c4%91%e1%ba%b7t-tr%e1%ba%a7n-th%e1%bb%9di-gian-cho-m%e1%bb%8di-thao-t%c3%a1c" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>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 <code>use</code> là thay đổi được:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ts" data-lang="ts"><span class="line"><span class="cl"><span class="kr">import</span> <span class="p">{</span> <span class="nx">defineConfig</span> <span class="p">}</span> <span class="kr">from</span> <span class="s2">&#34;@playwright/test&#34;</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kr">export</span> <span class="k">default</span> <span class="nx">defineConfig</span><span class="p">({</span>
</span></span><span class="line"><span class="cl">  <span class="nx">timeout</span>: <span class="kt">30_000</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nx">expect</span><span class="o">:</span> <span class="p">{</span> <span class="nx">timeout</span>: <span class="kt">5_000</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">  <span class="nx">use</span><span class="o">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nx">actionTimeout</span>: <span class="kt">10_000</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nx">navigationTimeout</span>: <span class="kt">15_000</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nx">trace</span><span class="o">:</span> <span class="s2">&#34;retain-on-failure&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="p">},</span>
</span></span><span class="line"><span class="cl"><span class="p">});</span></span></span></code></pre></div></div>
<p>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:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">    TimeoutError: locator.click: Timeout 10000ms exceeded.
</span></span><span class="line"><span class="cl">    Call log:
</span></span><span class="line"><span class="cl">      - waiting for locator(&#39;[data-testid=&#34;template-item&#34;]&#39;).filter({ hasText: &#39;E2E Edit Template&#39; }).locator(&#39;[data-testid=&#34;edit-template&#34;]&#39;)</span></span></code></pre></div></div>
<p>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.</p>

<h2 class="relative group">Chỉ tăng timeout cho bài kiểm thử dài, đừng tăng cho bài bị kẹt
    <div id="chỉ-tăng-timeout-cho-bài-kiểm-thử-dài-đừng-tăng-cho-bài-bị-kẹt" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#ch%e1%bb%89-t%c4%83ng-timeout-cho-b%c3%a0i-ki%e1%bb%83m-th%e1%bb%ad-d%c3%a0i-%c4%91%e1%bb%abng-t%c4%83ng-cho-b%c3%a0i-b%e1%bb%8b-k%e1%ba%b9t" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>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 <code>waiting for</code> cho tới tận cuối thì đó là bài bị kẹt.</p>
<p>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:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ts" data-lang="ts"><span class="line"><span class="cl"><span class="nx">test</span><span class="p">(</span><span class="s2">&#34;chat: open, send a message, go back&#34;</span><span class="p">,</span> <span class="kr">async</span> <span class="p">({</span> <span class="nx">page</span> <span class="p">})</span> <span class="o">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nx">test</span><span class="p">.</span><span class="nx">setTimeout</span><span class="p">(</span><span class="mi">180</span><span class="nx">_000</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">  <span class="c1">// ...
</span></span></span><span class="line"><span class="cl"><span class="p">});</span></span></span></code></pre></div></div>
<p>Gọi <code>test.setTimeout()</code> bên trong <code>describe</code> 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 đó. <code>test.slow()</code> 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 <code>beforeEach</code>, <code>testInfo.setTimeout()</code> kéo dài thời gian cho mọi bài kiểm thử đi qua hook đó:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ts" data-lang="ts"><span class="line"><span class="cl"><span class="nx">test</span><span class="p">(</span><span class="s2">&#34;slow in WebKit&#34;</span><span class="p">,</span> <span class="kr">async</span> <span class="p">({</span> <span class="nx">page</span><span class="p">,</span> <span class="nx">browserName</span> <span class="p">})</span> <span class="o">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nx">test</span><span class="p">.</span><span class="nx">slow</span><span class="p">(</span><span class="nx">browserName</span> <span class="o">===</span> <span class="s2">&#34;webkit&#34;</span><span class="p">,</span> <span class="s2">&#34;This page is slow in WebKit&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">  <span class="c1">// ...
</span></span></span><span class="line"><span class="cl"><span class="p">});</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nx">test</span><span class="p">.</span><span class="nx">beforeEach</span><span class="p">(</span><span class="kr">async</span> <span class="p">({</span> <span class="nx">page</span> <span class="p">},</span> <span class="nx">testInfo</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nx">testInfo</span><span class="p">.</span><span class="nx">setTimeout</span><span class="p">(</span><span class="nx">testInfo</span><span class="p">.</span><span class="nx">timeout</span> <span class="o">+</span> <span class="mi">30</span><span class="nx">_000</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="p">});</span></span></span></code></pre></div></div>
<p>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 <code>page.waitForTimeout()</code>, 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: &ldquo;Never wait for timeout in production. Tests that wait for time are inherently flaky.&rdquo; 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:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ts" data-lang="ts"><span class="line"><span class="cl"><span class="c1">// before
</span></span></span><span class="line"><span class="cl"><span class="k">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="s1">&#39;[data-testid=&#34;save-template&#34;]&#39;</span><span class="p">).</span><span class="nx">click</span><span class="p">();</span>
</span></span><span class="line"><span class="cl"><span class="k">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">waitForTimeout</span><span class="p">(</span><span class="mi">1500</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="k">await</span> <span class="nx">expect</span><span class="p">(</span><span class="nx">page</span><span class="p">.</span><span class="nx">getByText</span><span class="p">(</span><span class="s2">&#34;E2E Edited Template&#34;</span><span class="p">)).</span><span class="nx">toBeVisible</span><span class="p">();</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1">// after
</span></span></span><span class="line"><span class="cl"><span class="k">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="s1">&#39;[data-testid=&#34;save-template&#34;]&#39;</span><span class="p">).</span><span class="nx">click</span><span class="p">();</span>
</span></span><span class="line"><span class="cl"><span class="k">await</span> <span class="nx">expect</span><span class="p">(</span><span class="nx">page</span><span class="p">.</span><span class="nx">getByText</span><span class="p">(</span><span class="s2">&#34;E2E Edited Template&#34;</span><span class="p">)).</span><span class="nx">toBeVisible</span><span class="p">();</span></span></span></code></pre></div></div>
<p>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 <code>expect.timeout</code> 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ồ.</p>

<h2 class="relative group">Khi đồng hồ không phải của bài kiểm thử
    <div id="khi-đồng-hồ-không-phải-của-bài-kiểm-thử" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#khi-%c4%91%e1%bb%93ng-h%e1%bb%93-kh%c3%b4ng-ph%e1%ba%a3i-c%e1%bb%a7a-b%c3%a0i-ki%e1%bb%83m-th%e1%bb%ad" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Nếu thông báo có nhắc tới <code>config.webServer</code>, nghĩa là chưa bài kiểm thử nào kịp chạy. Playwright khởi động lệnh <code>webServer</code> của bạn và chờ cho tới khi <code>url</code> 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:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ts" data-lang="ts"><span class="line"><span class="cl"><span class="nx">webServer</span><span class="o">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nx">command</span><span class="o">:</span> <span class="s2">&#34;npm run web -- --port 8081&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nx">url</span><span class="o">:</span> <span class="s2">&#34;http://localhost:8081&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nx">reuseExistingServer</span>: <span class="kt">false</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nx">timeout</span>: <span class="kt">120_000</span><span class="p">,</span>
</span></span><span class="line"><span class="cl"><span class="p">},</span></span></span></code></pre></div></div>
<p>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 <a href="https://jared.lynskey.co.nz/vi/posts/2026/2026-09-09-scale-to-zero-504s/" >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</a>.</p>

<h2 class="relative group">Nếu thông báo là &ldquo;No tests found&rdquo;
    <div id="nếu-thông-báo-là-no-tests-found" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#n%e1%ba%bfu-th%c3%b4ng-b%c3%a1o-l%c3%a0-no-tests-found" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p><code>No tests found</code> 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 <code>**/*.@(spec|test).?(c|m)[jt]s?(x)</code> bên dưới <code>testDir</code>, và <code>testDir</code> 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 <code>testDir: &quot;./tests&quot;</code> 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 &ldquo;Make sure that arguments are regular expressions matching test files.&rdquo; Để xem Playwright đã gom được những gì mà không chạy gì cả:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">npx playwright <span class="nb">test</span> --list</span></span></code></pre></div></div>
<p>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 <a href="https://jared.lynskey.co.nz/vi/posts/2024/2024-06-09-playwright-tests/" >Kiểm thử trực quan (visual testing) cho các đội nhỏ</a>.</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-10-22-playwright-timeout-30000ms/featured.jpg" medium="image" type="image/jpeg" />
    </item>
    <item>
      <title>Kiểm thử trực quan (visual testing) cho các đội nhỏ</title>
      <link>https://jared.lynskey.co.nz/vi/posts/2024/2024-06-09-playwright-tests/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/vi/posts/2024/2024-06-09-playwright-tests/</guid>
      <pubDate>Sun, 09 Jun 2024 00:30:03 +0000</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>Visual Testing</category>
      <category>Small Teams</category>
      <category>UI Testing</category>
      <category>Playwright</category>
      <description>Vì sao so sánh ảnh chụp màn hình bằng Playwright lại là loại kiểm thử mang lại nhiều giá trị nhất cho đội nhỏ của tôi.</description>
      <content:encoded><![CDATA[<p>Bạn đẩy lên một thay đổi CSS chỉ có đúng một dòng, thế rồi một cái nút ở tận trang khác lặng lẽ vỡ giao diện. Không bài kiểm thử nào báo lỗi, khâu rà soát mã (code review) cũng không phát hiện ra, và một tuần sau bạn mới biết chuyện qua phản hồi của người dùng. Đội nhỏ nào cũng từng dính phải lỗi kiểu này.</p>
<p>Theo sách vở thì câu trả lời là viết thêm kiểm thử đơn vị (unit test). Tôi đang quản lý một đội lập trình nhỏ, và nói thật là viết kiểm thử đơn vị cho từng thành phần (component) và từng trạng thái bố cục là thứ xa xỉ mà chúng tôi không kham nổi. Thay vào đó, chúng tôi dựa vào kiểm thử trực quan (visual testing): chụp màn hình các trang, so với những ảnh chuẩn (baseline) đã biết chắc là đúng, rồi đánh dấu bản dựng nếu có pixel nào xê dịch ở chỗ lẽ ra không được xê dịch. Xét trên công sức bỏ ra, tôi chưa thử cách nào khác mà hiệu quả được như vậy.</p>

<h2 class="relative group">Vì sao ảnh chụp màn hình đáng đồng tiền bát gạo
    <div id="vì-sao-ảnh-chụp-màn-hình-đáng-đồng-tiền-bát-gạo" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#v%c3%ac-sao-%e1%ba%a3nh-ch%e1%bb%a5p-m%c3%a0n-h%c3%acnh-%c4%91%c3%a1ng-%c4%91%e1%bb%93ng-ti%e1%bb%81n-b%c3%a1t-g%e1%ba%a1o" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Cái hay nằm ở chỗ có rất nhiều thứ bạn không cần phải viết. Bài kiểm thử trực quan không kiểm tra từng phần tử riêng lẻ mà đánh giá cả trang như một tổng thể. Bố cục bị xô lệch, màu sắc bị đổi, giao diện bị vỡ trên màn hình có kích thước khác, các phần tử đè lên nhau: đó chính là những lỗi hay lọt qua khâu rà soát mã, mà viết kiểm thử đơn vị để bao phủ thì cực kỳ khổ sở. Với kiểm thử trực quan, tất cả đều hiện ra thành một bản so sánh khác biệt (diff) ngay trong pull request (PR).</p>
<p>Chi phí bảo trì cũng gần như bằng không. Bạn chỉ cần đặt ảnh chuẩn một lần, sau đó bài kiểm thử chạy trên mọi PR và lần nào cũng cho ra cùng một kết quả. Giao diện thay đổi có chủ đích? Cập nhật ảnh chuẩn rồi làm tiếp. Thay đổi ngoài ý muốn? Bản so sánh đã nằm sẵn trong PR, không ai phải bấm lòng vòng qua nửa trang web mới tìm ra.</p>

<h2 class="relative group">Kiểm thử trực quan không thay thế kiểm thử đơn vị
    <div id="kiểm-thử-trực-quan-không-thay-thế-kiểm-thử-đơn-vị" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#ki%e1%bb%83m-th%e1%bb%ad-tr%e1%bb%b1c-quan-kh%c3%b4ng-thay-th%e1%ba%bf-ki%e1%bb%83m-th%e1%bb%ad-%c4%91%c6%a1n-v%e1%bb%8b" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Kiểm thử đơn vị kiểm tra logic, còn ảnh chụp màn hình kiểm tra giao diện. Về lâu dài thì bạn sẽ cần cả hai. Nhưng nếu đội bạn nhỏ và phải chọn nơi để dồn những giờ làm việc đầu tiên, thì tính trên mỗi giờ thiết lập, kiểm thử trực quan cho độ bao phủ nhiều hơn bất cứ thứ gì khác. Nguyên tắc của tôi là: kiểm thử đơn vị dành cho phần logic nghiệp vụ (business logic) mà nếu hỏng thì hậu quả thật sự nghiêm trọng, còn lại cứ để kiểm thử trực quan lo.</p>
<p>Toàn bộ hệ thống chỉ có vậy: ảnh chuẩn, bản so sánh trên mỗi PR, và thỉnh thoảng bấm xác nhận &ldquo;ừ, chỗ đó là cố ý&rdquo;. Vậy mà số lỗi nó bắt được nhiều hơn hẳn những gì tôi dám kỳ vọng.</p>

<h2 class="relative group">Thông tin thêm và các liên kết hữu ích
    <div id="thông-tin-thêm-và-các-liên-kết-hữu-ích" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#th%c3%b4ng-tin-th%c3%aam-v%c3%a0-c%c3%a1c-li%c3%aan-k%e1%ba%bft-h%e1%bb%afu-%c3%adch" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>

<h3 class="relative group">Kiểm thử trực quan với Playwright
    <div id="kiểm-thử-trực-quan-với-playwright" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#ki%e1%bb%83m-th%e1%bb%ad-tr%e1%bb%b1c-quan-v%e1%bb%9bi-playwright" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Tài liệu về kiểm thử bằng ảnh chụp (snapshot testing) của Playwright: cách chụp cả trang hoặc từng phần tử, so sánh với ảnh chuẩn, và cập nhật ảnh chuẩn khi giao diện được thay đổi có chủ đích.
<a href="https://playwright.dev/docs/test-snapshots"  target="_blank" rel="noreferrer">Kiểm thử trực quan với Playwright</a></p>

<h3 class="relative group">Các thực hành tốt khi kiểm thử với Playwright
    <div id="các-thực-hành-tốt-khi-kiểm-thử-với-playwright" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#c%c3%a1c-th%e1%bb%b1c-h%c3%a0nh-t%e1%bb%91t-khi-ki%e1%bb%83m-th%e1%bb%ad-v%e1%bb%9bi-playwright" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Lời khuyên của Playwright cho các bài kiểm thử có đụng tới cơ sở dữ liệu: tách riêng dữ liệu kiểm thử, dọn dẹp sau khi chạy xong, và giữ các bài kiểm thử độc lập với nhau để cả bộ kiểm thử luôn đáng tin cậy.
<a href="https://playwright.dev/docs/best-practices#testing-with-a-database"  target="_blank" rel="noreferrer">Các thực hành tốt khi kiểm thử với Playwright</a></p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2024/2024-06-09-playwright-tests/featured.jpg" medium="image" type="image/jpeg" />
    </item>
  </channel>
</rss>
