Wrapping a Playwright assertion in try/catch silences the failure: the test passes when it shouldn't, and the bug ships. This guide covers when error handling belongs in test code (setup failures, retry logic, cleanup) and when it doesn't, plus finally for guaranteed teardown, custom throw messages that replace generic assertion errors, and the async patterns that prevent unhandled rejections from hiding test failures.
The Basics: try/catch
A try/catch block lets you run code and handle errors without crashing the program:
try {
// Code that might throw
const result = JSON.parse('not valid json');
} catch (error) {
// This runs if the try block throws
console.log('Parsing failed:', error.message);
}Without try/catch, JSON.parse('not valid json') would crash your script with an unhandled error. With try/catch, you handle it gracefully.
The error variable in the catch block is the Error object, which has:
error.message— human-readable descriptionerror.name— error type ('SyntaxError', 'TypeError', etc.)error.stack— full stack trace
The finally Block
finally runs whether or not an error occurred — useful for cleanup:
test('file upload with cleanup', async ({ page }) => {
const tempFile = await createTempFile();
try {
await page.setInputFiles('[data-testid="upload"]', tempFile);
await expect(page.locator('[data-testid="upload-success"]')).toBeVisible();
} catch (error) {
console.error('Upload test failed:', error.message);
throw error; // Re-throw so the test still fails
} finally {
await deleteTempFile(tempFile); // Always cleans up, even on failure
}
});finally always runs. This is valuable for test cleanup code.
async/await and Errors
In Playwright, almost everything is async. Errors in async code work the same way — you just need await in the right place:
// Without try/catch: unhandled rejection = test fails with ugly error
async function getUserFromApi() {
const response = await fetch('/api/user/999');
const data = await response.json();
return data;
}
// With try/catch: handled rejection = you control the error message
async function getUserFromApi() {
try {
const response = await fetch('/api/user/999');
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Failed to fetch user:', error.message);
throw error; // Re-throw if you want the caller to handle it too
}
}Throwing Your Own Errors
You can throw errors intentionally to signal failure:
function validateEmail(email: string): void {
if (!email.includes('@')) {
throw new Error(`Invalid email format: "${email}"`);
}
if (email.length > 254) {
throw new Error(`Email too long: ${email.length} characters (max 254)`);
}
}
try {
validateEmail('not-an-email');
} catch (error) {
console.log(error.message); // 'Invalid email format: "not-an-email"'
}In tests, throwing is how you fail with a specific message rather than a generic assertion failure:
async function getAuthToken(request: APIRequestContext): Promise<string> {
const response = await request.post('/api/auth/login', {
data: { email: 'test@test.com', password: 'TestPass1' },
});
if (response.status() !== 200) {
throw new Error(`Login failed: ${response.status()} — cannot proceed with tests`);
}
const { token } = await response.json();
if (!token) {
throw new Error('Login response missing token field');
}
return token;
}Error Types
JavaScript has several built-in error types:
| Error type | When it appears |
|-----------|----------------|
| Error | Generic error (base class) |
| TypeError | Wrong type — null.something, calling non-function |
| SyntaxError | Invalid JavaScript or JSON parsing |
| RangeError | Value out of range — new Array(-1) |
| ReferenceError | Variable doesn't exist |
You can check the type to handle different errors differently:
try {
await doSomething();
} catch (error) {
if (error instanceof TypeError) {
console.log('Type error — check your data types');
} else if (error instanceof SyntaxError) {
console.log('Syntax error — invalid JSON or similar');
} else {
throw error; // Unknown error — re-throw it
}
}Common Patterns in Playwright Tests
1. Retrying an operation on failure
async function waitForApiReady(request: APIRequestContext, maxAttempts = 5): Promise<void> {
for (let i = 0; i < maxAttempts; i++) {
try {
const response = await request.get('/api/health');
if (response.status() === 200) return;
} catch (error) {
// API not ready yet, try again
}
await new Promise(resolve => setTimeout(resolve, 1000));
}
throw new Error(`API not ready after ${maxAttempts} attempts`);
}2. Collecting errors without stopping
const errors: string[] = [];
for (const email of testEmails) {
try {
await validateEmailField(page, email);
} catch (error) {
errors.push(`${email}: ${error.message}`);
}
}
if (errors.length > 0) {
throw new Error(`Validation failures:\n${errors.join('\n')}`);
}3. Custom error class for cleaner code
class TestDataError extends Error {
constructor(message: string) {
super(message);
this.name = 'TestDataError';
}
}
// Now you can distinguish test data setup errors from test assertion errors
try {
const user = await createTestUser(request);
} catch (error) {
if (error instanceof TestDataError) {
console.error('Test setup failed — skipping test');
test.skip();
}
throw error;
}Errors in Playwright: What You'll See
TimeoutError
The most common Playwright error. A locator didn't become visible in time:
TimeoutError: locator.click: Timeout 30000ms exceeded.
Locator: [data-testid="submit-button"]This is not a JavaScript error you need to catch in most cases — Playwright handles it and fails the test with a clear message. But if you want to handle a timeout gracefully (try something else if an element doesn't appear), you can:
try {
await page.locator('[data-testid="popup"]').click({ timeout: 2000 });
} catch (error) {
// Popup didn't appear within 2 seconds — that's fine
// Continue test without interacting with popup
}Unhandled Promise Rejection
If you call an async function without await and it throws, the error may be an "unhandled rejection" — it doesn't fail the test immediately and the error message can be confusing.
// Bad: no await, error might be swallowed
page.goto('/admin'); // If this fails, the test might pass with wrong state
// Good: error surfaces immediately
await page.goto('/admin');Always await async operations in tests.
When NOT to Use try/catch in Tests
Don't use try/catch to hide assertion failures. If an assertion fails, the test should fail:
// Wrong — swallows assertion failure, test "passes" when it shouldn't
try {
await expect(page.locator('[data-testid="success"]')).toBeVisible();
} catch (error) {
console.log('Element not visible but continuing...');
}
// Right — let assertion failures fail the test
await expect(page.locator('[data-testid="success"]')).toBeVisible();try/catch in tests is for:
- Setup and teardown code where failure shouldn't mask the actual test
- Intentional resilience (retry logic, optional interactions)
- Test data creation where you want a clear, custom error message
It's NOT for silencing assertions.
Quick Summary
try/catchhandles errors — code intryruns,catchhandles any thrown errorfinallyalways runs — use it for cleanup- Always
awaitasync operations — unawaited async failures are hard to debug throwintentionally when you want a specific error message- Don't catch assertion failures — they should fail the test
instanceoflets you check the error type and handle different errors differently
Error handling is a tool for making your code more resilient and your error messages more informative — not for hiding failures.
→ See also: JavaScript for QA Engineers: The Minimum You Need to Start Automating | Async/Await in Plain English (for Testers Who Get Tripped Up by Promises) | How to Read Playwright Error Messages