How to Fix the Playwright “Test Ended” Error
Fix Playwright’s “Test ended” error by tracing missing awaits, timeouts, route cleanup, and fixture teardown with working examples.

Playwright’s Error: Test ended message usually means your test finished or timed out while asynchronous browser work was still running. The runner then tore down the test-scoped page or browser context, and the late operation tried to use a closing target. Fix the first failure in the report: await every promise, resolve the timeout cause, finish route handlers and event waits, and keep cleanup inside the test lifecycle.
Playwright’s runner uses the close reason Test ended. during normal teardown; a timeout has a timeout-specific reason. That makes this line a lifecycle symptom in many failures, not the original error. See the runner’s test information and teardown source.
1. Find the first failure
Open the HTML report and trace before changing timeouts. The final “Test ended” exception is often secondary.

- Run the failing test with a report:
npx playwright test --reporter=html. - Open the report with
npx playwright show-report. - Read the earliest error, including the first stack frame in your test or helper.
- Open the trace and inspect the action, request, event, or fixture that failed first.
Playwright’s guidance recommends the HTML report and trace viewer for identifying which part of a test failed. A late page or route error can hide the original assertion, navigation, or timeout problem.
2. Fix missing await
Every Playwright action, assertion, event wait, and asynchronous helper must be part of the promise returned by the test. A test function that returns early allows teardown to close its fixtures.
import { test, expect } from '@playwright/test';
test('saves a contact', async ({ page }) => {
await page.goto('/contacts');
await page.getByLabel('Name').fill('Ada Lovelace');
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByText('Saved')).toBeVisible();
});
Common missing awaits include:
page.goto(),page.reload(), andpage.waitForLoadState()click(),fill(),press(),selectOption(), and uploadsexpect(locator).toBeVisible()and other web-first assertionswaitForEvent(),waitForResponse(), downloads, popups, and dialogs- custom helpers that return a promise
Event waits must start before the action
test('downloads a report', async ({ page }) => {
await page.goto('/reports');
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Download file' }).click();
const download = await downloadPromise;
await download.saveAs('artifacts/report.csv');
});
Page and context event waits reject if the page or context closes first. Starting the wait before the triggering action prevents a race and awaiting both promises keeps the operation inside the test lifecycle. See Playwright’s event handling documentation.
Await helper functions too
async function signIn(page) {
await page.goto('/login');
await page.getByLabel('Email').fill('user@example.com');
await page.getByLabel('Password').fill('correct-horse-battery-staple');
await page.getByRole('button', { name: 'Sign in' }).click();
}
test('opens the dashboard', async ({ page }) => {
await signIn(page);
await expect(page).toHaveURL(/dashboard/);
});
A call such as signIn(page); without await starts work and immediately lets the test return. The same rule applies to helpers in fixtures and hooks.
3. Separate a timeout from teardown
The default Playwright Test timeout is 30 seconds. It includes the test body, fixtures, and beforeEach hooks. If the report begins with Test timeout of 30000ms exceeded, the test ended because its budget was exhausted; the later “Test ended” line describes the resulting teardown.
Increase a timeout only when the operation is legitimately slow. A larger timeout cannot make an un-awaited promise part of the test.
import { test } from '@playwright/test';
test('exports a large report', async ({ page }) => {
test.setTimeout(90_000);
await page.goto('/reports');
await page.getByRole('button', { name: 'Export' }).click();
await page.getByText('Export complete').waitFor();
});
Prefer a scoped timeout over a globally inflated value:
import { defineConfig } from '@playwright/test';
export default defineConfig({
timeout: 30_000,
expect: { timeout: 5_000 },
projects: [
{
name: 'slow-export',
testMatch: /export\.spec\.ts/,
timeout: 90_000,
},
],
});
Keep assertion timeout and test timeout distinct. An assertion can fail quickly while the overall test still has time to collect diagnostics.
4. Finish route handlers and route.fetch
If the stack mentions route.fetch, route.fulfill, or a route callback, make the callback asynchronous and await all work before it returns.
test('modifies an API response', async ({ page }) => {
await page.route('**/api/profile', async route => {
const response = await route.fetch();
const body = await response.json();
await route.fulfill({
response,
json: { ...body, featureEnabled: true },
});
});
await page.goto('/profile');
await expect(page.getByText('Feature enabled')).toBeVisible();
});
For the specific failure mode where route work is still active during teardown, Playwright maintainers recommend removing routes with ignored cleanup errors:
test.afterEach(async ({ page }) => {
await page.unrouteAll({ behavior: 'ignoreErrors' });
});
Use the context equivalent when routes were installed on the browser context:
test.afterEach(async ({ context }) => {
await context.unrouteAll({ behavior: 'ignoreErrors' });
});
ignoreErrors is useful for teardown because the target may already be closing. During normal test execution, still fix the underlying handler so it completes its fetch and fulfillment.
5. Replace fixed sleeps with readiness signals
waitForTimeout() waits for elapsed time, not for the condition your test needs. It can pass locally and fail under CI load, or waste time when the page is already ready. Playwright’s auto-waiting and assertion APIs perform actionability and state checks and report a useful timeout when the condition is not met.

// Fragile
await page.waitForTimeout(2_000);
await page.getByRole('button', { name: 'Continue' }).click();
// Deterministic
await expect(page.getByRole('button', { name: 'Continue' })).toBeEnabled();
await page.getByRole('button', { name: 'Continue' }).click();
Choose the signal that proves readiness:
| What must be ready | Use |
|---|---|
| An element is usable | expect(locator).toBeVisible(), toBeEnabled(), or an action that auto-waits |
| A URL changed | await expect(page).toHaveURL(/dashboard/) |
| An API call completed | Start page.waitForResponse() before the action |
| A popup or download opened | Start waitForEvent() before clicking |
| A specific loading state ended | locator.waitFor() or a web-first assertion |
See Playwright auto-waiting and web-first assertions.
6. Inspect hooks and custom fixtures
Fixture setup, fixture teardown, and beforeEach time count toward the test timeout. Search these areas for promises that are started but not awaited, background tasks that outlive the test, and cleanup that begins after await use() but does not await its own asynchronous work.
import { test as base } from '@playwright/test';
export const test = base.extend({
loggedInPage: async ({ page }, use) => {
await page.goto('/login');
await page.getByLabel('Email').fill('user@example.com');
await page.getByLabel('Password').fill('password');
await page.getByRole('button', { name: 'Sign in' }).click();
await use(page);
// Teardown is awaited before the fixture is released.
await page.context().clearCookies();
},
});
Test-scoped page and context fixtures are isolated and torn down after each test. Do not retain a page in a global variable, worker task, timer, or event emitter and use it after the test returns. Pass the fixture explicitly to helpers and await all work before returning.
7. Common errors and fixes
| Symptom | Likely cause | Fix |
|---|---|---|
Error: Test ended after a click or assertion |
Missing await |
Await the action, assertion, and any helper that wraps it. |
Test timeout of ... exceeded |
Slow navigation, fixture, hook, or assertion | Find the slow step; replace sleeps with signals and use a scoped timeout only when justified. |
route.fetch: Target page, context or browser has been closed |
Route callback still running during teardown | Await route.fetch() and route.fulfill(); call unrouteAll({ behavior: 'ignoreErrors' }) in cleanup. |
page.waitForEvent rejects |
The page closed before the event occurred | Start the wait before the triggering action and await both promises. |
| Passes locally, fails in CI | Timer race, resource contention, or hidden background work | Use locator, response, URL, or event readiness; enable tracing and inspect the first failure. |
Error appears in afterEach |
Cleanup uses a page or context that is already closing | Make cleanup idempotent and use context-level cleanup where possible; ignore expected teardown route errors. |
8. A complete stable example
import { test, expect } from '@playwright/test';
test('submits a contact form', async ({ page }) => {
await page.goto('/contacts');
await page.getByLabel('Name').fill('Ada Lovelace');
await page.getByLabel('Email').fill('ada@example.com');
const responsePromise = page.waitForResponse(
response => response.url().endsWith('/api/contacts') && response.request().method() === 'POST'
);
await page.getByRole('button', { name: 'Save' }).click();
const response = await responsePromise;
expect(response.ok()).toBeTruthy();
await expect(page.getByRole('status')).toHaveText('Saved');
});
This pattern keeps navigation, form actions, network events, and assertions inside the test’s returned promise. It also checks the API response without adding a blind delay.
9. Performance and reliability checklist
- Use the smallest readiness signal that proves the next step is safe.
- Keep expensive setup in fixtures only when it is shared safely and fully awaited.
- Do not increase global timeouts to compensate for missing awaits.
- Use trace recording on retries or CI failures to preserve the first failing step.
- Remove routes, listeners, and timers during teardown, and await cleanup.
- Keep test data isolated so one test does not wait on another test’s state.
- When a failure is intermittent, run the test repeatedly and compare traces rather than adding sleeps.
10. Or skip the browser setup
If your goal is to capture a page image or PDF rather than interact with a live browser, ScreenshotNeo provides a single HTTP request. Its capture pipeline accepts consent banners like a visitor and removes more than 60 known consent platforms, newsletter popups, and chat widgets before the shot. Bot checks, blank pages, failed loads, timeouts, and cache hits are not billed; response headers identify the page verdict and whether it was billed. It also provides an MCP server with take_screenshot, get_page_info, and capture_pdf for Claude, Cursor, and other MCP clients.
See the ScreenshotNeo API documentation for all options.
cURL
curl -G "https://api.screenshotneo.com/v1/shot" \
-d access_key=YOUR_API_KEY \
--data-urlencode url=https://stripe.com \
-o shot.webp
Python
import requests
r = requests.get(
"https://api.screenshotneo.com/v1/shot",
params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"},
timeout=90,
)
r.raise_for_status()
open("shot.webp", "wb").write(r.content)
Node.js
const q = new URLSearchParams({
access_key: 'YOUR_API_KEY',
url: 'https://stripe.com'
});
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot failed: ${res.status}`);
await Bun.write('shot.webp', res);
The free plan includes 1,000 screenshots each month with no card. Paid plans start at $5 for 3,000 shots; every feature is available on every plan. Create a free ScreenshotNeo account.
11. FAQ
Does “Test ended” always mean I forgot an await?
No. Missing awaits are common, but a real test timeout, unfinished route callback, event wait, fixture teardown, or background task can produce the same lifecycle symptom. Find the earliest report error.
Should I set the timeout to 0?
No. Removing the limit can leave a hung test running indefinitely and conceal the cause. Fix synchronization first, then set a scoped timeout for genuinely slow work.
Can I use waitForTimeout as a workaround?
It may hide a race temporarily, but it does not prove readiness. Replace it with a locator assertion, URL check, response wait, or event wait.
Why does the error mention a closed page when my assertion passed?
The assertion may have passed while another promise was still running. Teardown closed the page, and that late promise reported the closed target. Await every concurrent operation and inspect trace timing.
Where should route cleanup go?
Put cleanup in an awaited afterEach or fixture teardown. For routes installed on a page, use page.unrouteAll({ behavior: 'ignoreErrors' }); for context routes, use the context method.


