<?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 · Jared Lynskey</title>
    <link>https://jared.lynskey.co.nz/en/tags/playwright/</link>
    <description>Short stories of value on software engineering and engineering leadership — performance reviews, hiring and scaling teams, CI/CD, DevOps monitoring and agentic AI. Written from Seoul.</description>
    <generator>Hugo</generator>
    <language>en</language>
    <copyright>© 2026 Jared Lynskey</copyright>
    <lastBuildDate>Thu, 22 Oct 2026 00:00:00 +1300</lastBuildDate>
    <atom:link href="https://jared.lynskey.co.nz/en/tags/playwright/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>Playwright &#34;Test timeout of 30000ms exceeded.&#34;: Find the Wait That Never Finished</title>
      <link>https://jared.lynskey.co.nz/en/posts/2026/2026-10-22-playwright-timeout-30000ms/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/en/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>What Playwright&#39;s &#34;Test timeout of 30000ms exceeded.&#34; actually measures, the usual causes in the order I&#39;d check them, how to find the step that hung, and when raising the timeout is the wrong fix. With a real failure from my own suite: five tests and one renamed test ID.</description>
      <content:encoded><![CDATA[<p><code>Test timeout of 30000ms exceeded.</code> means a Playwright test ran past its 30-second budget. Thirty seconds is Playwright Test&rsquo;s default, and the budget covers the test body, its <code>beforeEach</code> hooks and its fixture setup together. The message names the test rather than the step because clicks, fills and navigations have no timeout of their own by default. They wait as long as it takes, and the test timeout is simply the first clock to run out.</p>
<p>So the error is rarely about speed. Usually one step was waiting for something that was never going to happen. Here are the causes in the order I&rsquo;d check them, with the fix for each:</p>
<ol>
<li>A locator matches nothing, so a <code>click()</code> or <code>fill()</code> waits until the test is out of time. A renamed test ID, changed text or a dialog in the way is the usual reason. Fix the locator.</li>
<li>A navigation or <code>waitForURL()</code> is waiting for a page that never comes, because a redirect, a login screen or a first-run screen took the app somewhere else. Fix what you&rsquo;re waiting for.</li>
<li>Setup really is slow: a dev server compiling on first request, a backend waking from scale-to-zero, a full UI login in every <code>beforeEach</code>. Fix the environment, or do the setup once.</li>
<li>The test is simply long. Then, and only then, give that test more time with <code>test.slow()</code> or <code>test.setTimeout()</code>.</li>
</ol>
<p>Whichever it is, the first job is finding the step that was still running when the time ran out. The second is setting <code>actionTimeout</code>, so the next failure names the step for you.</p>

<h2 class="relative group">What the 30 seconds covers
    <div id="what-the-30-seconds-covers" 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="#what-the-30-seconds-covers" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>The test timeout is one budget, shared by fixture setup, <code>beforeEach</code> hooks and the test function. When the test finishes, <code>afterEach</code> hooks and fixture teardown get a second budget of the same size. <code>beforeAll</code> and <code>afterAll</code> get the same value as well, but they don&rsquo;t share it with any test, and they fail with their own message.</p>
<p>Inside that budget, only assertions have a clock of their own. <code>await expect(locator).toBeVisible()</code> gives up after 5 seconds by default and tells you exactly what it was looking for. Actions and navigations don&rsquo;t: <code>actionTimeout</code> and <code>navigationTimeout</code> default to no limit at all. That is the whole reason this error is so vague. A click on an element that will never exist doesn&rsquo;t fail. It waits, politely, until something else ends the test.</p>
<p>Here is every timeout that can end a run, and what each one prints. The messages are copied from Playwright 1.58 and 1.63, the oldest and newest versions in my projects.</p>
<table>
	<thead>
			<tr>
					<th>Timeout</th>
					<th>Default</th>
					<th>Where to set it</th>
					<th>What it prints</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Test (body, <code>beforeEach</code>, fixture setup)</td>
					<td>30,000 ms</td>
					<td><code>timeout</code> in the config, <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>same as the test timeout</td>
					<td><code>test.setTimeout()</code> inside the 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> in the config, <code>{ timeout }</code> on the assertion</td>
					<td><code>expect(locator).toBeVisible() failed</code> … <code>Timeout: 5000ms</code></td>
			</tr>
			<tr>
					<td>Action (<code>click</code>, <code>fill</code>)</td>
					<td>none</td>
					<td><code>use: { actionTimeout }</code>, <code>{ timeout }</code> on the action</td>
					<td><code>TimeoutError: locator.click: Timeout 10000ms exceeded.</code></td>
			</tr>
			<tr>
					<td>Navigation (<code>goto</code>, <code>waitForURL</code>)</td>
					<td>none</td>
					<td><code>use: { navigationTimeout }</code>, <code>{ timeout }</code> on the call</td>
					<td><code>TimeoutError: page.waitForURL: Timeout 15000ms exceeded.</code></td>
			</tr>
			<tr>
					<td>Whole run</td>
					<td>none</td>
					<td><code>globalTimeout</code> in the config</td>
					<td><code>Timed out waiting 3600s for the test suite to run</code></td>
			</tr>
			<tr>
					<td><code>webServer</code> start-up</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>The action, navigation and whole-run rows have no default to print, so those numbers are whatever you set.</p>

<h2 class="relative group">Five tests and a renamed test ID
    <div id="five-tests-and-a-renamed-test-id" 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="#five-tests-and-a-renamed-test-id" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>In February, five tests in the suite for my estimating app failed in one run, all the same way. The suite drives the web version of the app, served by its dev server, on one worker, against a freshly seeded local backend. Here&rsquo;s the first of the five, trimmed:</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>The <code>Target page, context or browser has been closed</code> line reads like the browser falling over. It isn&rsquo;t. It&rsquo;s what an action says when the page is closed underneath it: the 30 seconds ran out, Playwright tore the test down, and the click that was still waiting reported the page it had lost. With the built-in <code>page</code> fixture, that line would have repeated the timeout instead, as <code>Error: locator.click: Test timeout of 30000ms exceeded.</code> These tests open their own browser context in <code>beforeAll</code>, so the click got the generic version.</p>
<p>The useful line is the call log. The click was waiting for <code>[data-testid=&quot;template-item&quot;]</code>. Ten hours earlier I had changed the row component&rsquo;s test ID from <code>template-item</code> to <code>template-item-${name}</code>, so the end-to-end tests on the native app could pick out one row by name. An attribute selector with <code>=</code> matches the whole value, so the web tests were now asking for an element that existed nowhere on the page, and with no action timeout that click was always going to wait out the test. Five tests at 30 seconds each is two and a half minutes, in a run that took 3.8.</p>
<p>What I added first was a retry. Within half an hour the config had <code>retries: process.env.CI ? 2 : 1</code>, with a comment that still says it&rsquo;s there &ldquo;for flaky browser crashes&rdquo;. On the next run, four of the same five failed again, retry included, and the run took 6.4 minutes instead of 3.8. These describe blocks run in serial mode, where a failure skips the rest of the group and a retry starts the group again from the top, so every bad click now waited out its 30 seconds twice. A retry can&rsquo;t rescue a test that fails the same way every time. It just bills you twice.</p>
<p>The fix was a prefix match:</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>The wider lesson is that a test ID is a contract, and I had two test suites depending on this one without either knowing about the other. Playwright&rsquo;s best-practice guide recommends user-facing locators such as <code>getByRole()</code> over CSS selectors like mine, so that tests survive DOM changes a user would never notice.</p>

<h2 class="relative group">Find the step that hung
    <div id="find-the-step-that-hung" 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="#find-the-step-that-hung" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>Read the call log first. It belongs to the step that was still running when the time ran out, and a <code>waiting for locator(...)</code> line with nothing after it means the locator never matched anything.</p>
<p>If that isn&rsquo;t enough, open the trace. My two app suites, one for the estimating app and one for a marketplace and community app I build, both use <code>trace: &quot;on-first-retry&quot;</code>, which only records on a retry, so it needs <code>retries</code> set to at least 1. Without retries, use <code>&quot;retain-on-failure&quot;</code>; for a one-off run, pass <code>--trace on</code>. Then open the trace directly, or from the HTML report:</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>The action that never finished is the one carrying the error, near the end of the list. Select it and look at the snapshot: that&rsquo;s the page as it was while Playwright waited, and you can inspect it for the element you expected. In a case like mine, the row is sitting right there with an ID the test isn&rsquo;t asking for.</p>
<p>Recent versions also write an <code>error-context.md</code> beside the failure screenshot, with a text snapshot of the page at the moment it failed. For a missing element, that&rsquo;s often enough on its own.</p>
<p>One trap: <code>--debug</code> opens the Inspector and also sets <code>--timeout=0</code>, along with <code>--max-failures=1</code>, <code>--headed</code> and <code>--workers=1</code>. A test that passes under <code>--debug</code> hasn&rsquo;t told you anything about the 30 seconds.</p>

<h2 class="relative group">Give every action a ceiling
    <div id="give-every-action-a-ceiling" 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="#give-every-action-a-ceiling" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>None of my configs set an action timeout, which is how one missing element got to spend 30 seconds, five times over. Two lines in <code>use</code> change that:</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>The same broken locator then fails like this, while the test still has time left:</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>That&rsquo;s the step, the action and how long it waited. I&rsquo;d keep both numbers well under the test timeout, so a single stuck step can&rsquo;t use up the whole budget and hide behind the vaguer message. In February the 30 seconds was never the problem. It was the only clock on that click, so it was the one that went off.</p>

<h2 class="relative group">Raise the timeout for long tests, not stuck ones
    <div id="raise-the-timeout-for-long-tests-not-stuck-ones" 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="#raise-the-timeout-for-long-tests-not-stuck-ones" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>Raising the timeout is the right fix when a test does real work that takes a while. It&rsquo;s the wrong one when a step is waiting for something that won&rsquo;t happen, because a bigger number only makes the failure slower. The trace usually settles which you have. A steady run of actions that each finished, just a lot of them, is a long test. One action sitting on <code>waiting for</code> until the end is a stuck one.</p>
<p>For the long ones, add time where the length is rather than across the whole suite. The marketplace app&rsquo;s suite has one smoke test that walks through the chat screen taking screenshots as it goes, and it gets three minutes:</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>Called inside a <code>describe</code>, <code>test.setTimeout()</code> applies to every test in the group, which is how the job-cost test in the estimating app&rsquo;s suite gets its minute. <code>test.slow()</code> triples whatever the timeout is, and can take a condition. In a <code>beforeEach</code>, <code>testInfo.setTimeout()</code> extends every test that runs the 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>Fixed sleeps are the other thing that eats the budget, quietly, on every run. The estimating app&rsquo;s suite has more than a hundred <code>page.waitForTimeout()</code> calls, in nearly every spec file. The edit test above sleeps for three seconds before it even reaches the click, and no amount of sleeping would have saved it. Playwright&rsquo;s API docs are blunt about this: &ldquo;Never wait for timeout in production. Tests that wait for time are inherently flaky.&rdquo; A web-first assertion already waits as long as it needs to, and no longer:</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>Assertion timeouts need the same care. Both app suites raise <code>expect.timeout</code> to 15 seconds, and both run against a dev server rather than a production build. That buys slack, but two slow assertions in a row can spend the whole 30 seconds between them, and then the test timeout fires first and you&rsquo;re back to the vague message.</p>

<h2 class="relative group">When the clock isn&rsquo;t the test&rsquo;s
    <div id="when-the-clock-isnt-the-tests" 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="#when-the-clock-isnt-the-tests" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>If the message mentions <code>config.webServer</code>, no test has run yet. Playwright starts your <code>webServer</code> command and waits for its <code>url</code> to answer before running anything, on a separate timeout of 60 seconds by default. Both app suites give the dev server two minutes:</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>If your tests run against a deployed environment instead, find out how long its first request takes. When I finally measured mine, a machine starting from scale-to-zero on my host took 25 to 33 seconds to serve anything, which is most or all of a test&rsquo;s budget before the first click. That story is in <a href="https://jared.lynskey.co.nz/en/posts/2026/2026-09-09-scale-to-zero-504s/" >Scale to Zero, and Every Restart Is a 30-Second Outage</a>.</p>

<h2 class="relative group">If it says &ldquo;No tests found&rdquo; instead
    <div id="if-it-says-no-tests-found-instead" 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="#if-it-says-no-tests-found-instead" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p><code>No tests found</code> sounds related, but it isn&rsquo;t a timeout: Playwright looked and found nothing to run. By default it collects files matching <code>**/*.@(spec|test).?(c|m)[jt]s?(x)</code> under <code>testDir</code>, and <code>testDir</code> defaults to the folder the config file lives in. Mine set <code>testDir: &quot;./tests&quot;</code> next to the config, so a spec saved anywhere else is invisible. Arguments on the command line are regular expressions matched against file paths, which is why the error adds &ldquo;Make sure that arguments are regular expressions matching test files.&rdquo; To see what Playwright collected without running anything:</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>If your spec isn&rsquo;t in that list, it was never going to time out. And if you&rsquo;re setting up Playwright for the first time, <a href="https://jared.lynskey.co.nz/en/posts/2024/2024-06-09-playwright-tests/" >Visual Testing for Small Teams</a> is my case for starting with screenshot comparisons.</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>Visual Testing for Small Teams</title>
      <link>https://jared.lynskey.co.nz/en/posts/2024/2024-06-09-playwright-tests/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/en/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>Why screenshot comparisons with Playwright are the highest-value tests my small team runs.</description>
      <content:encoded><![CDATA[<p>You push a one-line CSS change, and a button on a completely different page quietly falls apart. No test fails, review didn&rsquo;t catch it, and you hear about it from a user a week later. Every small team knows this bug.</p>
<p>The textbook answer is more unit tests. I manage a small dev team, and honestly, unit-testing every component and layout state is a luxury we don&rsquo;t have. What we lean on instead is visual testing: screenshot the pages, compare them against known-good baselines, and flag the build when pixels move that shouldn&rsquo;t. For the effort involved, nothing else I&rsquo;ve tried comes close.</p>

<h2 class="relative group">Why screenshots earn their keep
    <div id="why-screenshots-earn-their-keep" 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="#why-screenshots-earn-their-keep" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>The appeal is everything you don&rsquo;t have to write. A visual test doesn&rsquo;t assert on individual elements; it judges the page as a whole. Layout shifts, colour changes, broken responsive behaviour, overlapping elements — exactly the things that slip through code review and are miserable to cover with unit tests — all show up as a diff right in the PR.</p>
<p>Maintenance is close to nil, too. You set a baseline once, then it runs on every PR with the same result every time. Intentional UI change? Update the baseline and move on. Unintentional one? The diff is sitting in the PR, and nobody had to click through half the site to find it.</p>

<h2 class="relative group">It doesn&rsquo;t replace unit tests
    <div id="it-doesnt-replace-unit-tests" 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="#it-doesnt-replace-unit-tests" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>Unit tests verify logic; screenshots verify appearance. You want both eventually. But if you&rsquo;re small and have to choose where the first hours go, visual tests buy more coverage per hour of setup than anything else. My rule of thumb: unit tests for the business logic that would genuinely hurt, visual tests for the rest.</p>
<p>That&rsquo;s the whole system — baselines, diffs on every PR, and the occasional &ldquo;yes, that was intentional&rdquo; click. It catches more than it has any right to.</p>

<h2 class="relative group">More information and useful links
    <div id="more-information-and-useful-links" 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="#more-information-and-useful-links" aria-label="Anchor">#</a>
    </span>
    
</h2>

<h3 class="relative group">Visual Testing with Playwright
    <div id="visual-testing-with-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="#visual-testing-with-playwright" aria-label="Anchor">#</a>
    </span>
    
</h3>
<p>Playwright&rsquo;s snapshot testing docs: capture full pages or individual elements, compare against a baseline, and manage baseline updates when the UI changes on purpose.
<a href="https://playwright.dev/docs/test-snapshots"  target="_blank" rel="noreferrer">Visual Testing with Playwright</a></p>

<h3 class="relative group">Best Practices for Testing with Playwright
    <div id="best-practices-for-testing-with-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="#best-practices-for-testing-with-playwright" aria-label="Anchor">#</a>
    </span>
    
</h3>
<p>Playwright&rsquo;s advice for tests that touch a database: isolate test data, clean up afterwards, and keep tests independent so the suite stays reliable.
<a href="https://playwright.dev/docs/best-practices#testing-with-a-database"  target="_blank" rel="noreferrer">Best Practices for Testing with 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>
