Writing Playwright tests can be incredibly rewarding, but like any tool, improper use can lead to frustrating results. Many developers, especially those transitioning from Selenium, inadvertently carry over practices that don’t align with Playwright’s capabilities. In this blog, we’ll explore the most common mistakes made when writing Playwright tests and provide actionable tips to help you avoid them.
The importance of web-first assertions
A common culprit behind test flakiness is the improper use of assertions and interactions. Playwright’s web-first assertions are designed to address the flakiness issue by seamlessly waiting for elements to meet the desired condition before proceeding. This approach reduces the need for manual wait logic, leading to more reliable and maintainable tests.
Assertions are crucial in validating that actual outcomes match expected ones. With Playwright’s web-first assertions (e.g., toBeVisible()), the framework automatically retries checks until the condition is met or the timeout expires. For example, imagine testing a dynamic search suggestion dropdown. A test may input text into a search bar and expect a list of suggestions to appear. Instead of adding manual delays, you can assert that the dropdown is visible with expect + toBeVisible(). This ensures the test dynamically waits for the dropdown to load, avoiding flakiness caused by variable response times.
More details about web-first assertions can be found in the official documentation page.
To fully understand the common mistakes I’ll cover below, it’s essential to first understand how wait intervals for interactions are defined. In Playwright, these intervals are specified in the configuration file. It’s good approach to use a TimeoutValues enum, which establishes strict, standardized timeout durations, ensuring consistency across all tests. By centralizing these timeout values, we gain better control and simplify updates. Always stick to the default values to maintain uniformity and prevent the introduction of hard-coded waits in individual tests.
// Simplified version, usually we should keep enum with the values.
export default defineConfig({
timeout: 240000,
expect: {
timeout: 5000
},
use: {
actionTimeout: 30000,
navigationTimeout: 15000
}
})
In our Playwright configuration, we define several timeouts to control the maximum wait durations for various actions. These timeouts ensure that tests are consistent and reliable, even in different environments:
- timeout: This is the base timeout for all tests. It is set to 240 seconds to ensure sufficient time for the tests to complete.
- expectTimeout: This is the default timeout for asynchronous expect matchers, which checks whether the expected condition is met. By default, Playwright sets this to 5000 milliseconds (5 seconds), but in our configuration, it is extended to 30 seconds to accommodate slower operations.
- actionTimeout: This defines the timeout for each Playwright action, such as clicks, typing, and other interactions. By default, Playwright has no timeout (0), but in our configuration, it is set to 30 seconds to allow sufficient time for actions to complete.
- navigationTimeout: This sets the timeout for navigation actions, such as page.goto() or clicking links that lead to new pages. The default timeout in Playwright is 0 (no timeout), but it is configured to 60 seconds to handle slower page loads.
Mistake No.1 - Using static waits
await page.waitForTimeout(5000)
What is wrong in this code?
It creates static wait that pauses test execution for a fixed amount of time, regardless of the condition being met.
❌ Masks issues: Static waits hide problems like slow-loading elements.
❌ Hard to maintain: Fixed wait times may need frequent updates as conditions change.
❌ Reduces test quality: It slows down tests and introduces unnecessary delays.
✅ Instead of static waits, use Playwright’s web-first assertions to wait for elements or conditions dynamically, ensuring faster and more reliable tests.
Mistake No.2 - Using isVisible() to validate for visibility
// This won't do anything
await page.getByRole('button', { name: 'Continue' }).isVisible()
What is wrong in this code?
It is ineffective for visibility validation because:
❌ Does nothing: The isVisible() method only returns a Promise<boolean>
, which is not validated or used in this case.
❌ Use web-first assertions: Visibility checks should be done with expect to ensure dynamic and reliable waiting.
✅ Always use await expect(locator).toBeVisible()
for element visibility validation! This method waits dynamically up to 30 seconds (configurable in the playwright config) for the element to be visible, allowing for flexibility.
await expect(page.getByRole('button', { name: 'Continue' })).toBeVisible()
Mistake No.3 - Using isVisible() or isEnabled() before click to validate for visibility
// click() will do the visibility check for you
await page.getByRole('button', { name: 'Continue' }).isVisible()
await page.getByRole('button', { name: 'Continue' }).click()
What is wrong in this code?
❌ Introduces extra step that is not needed.
❌ Can potentially introduce flakiness.
Playwright click() method automatically checks for visibility, and using the action timeout, will wait for the button to become visible, stable, enabled and ready to receive event.
✅ No need for visibility check before making click actions, .click() method does that automatically. See the complete list of actionability checks performed for each action here.
await page.getByRole('button', { name: 'Continue' }).click()
Mistake No.4 - Using textContent() to validate text visibility
const orderText = await page.locator('#orderText').textContent()
expect(orderText).toBe('Some text')
What is wrong in this code?
❌ It introduces flakiness, as it doesn’t use web-first assertion for text validation.
✅ Use await expect(locator).toHaveText(expected[, options])
if you want to validate text for locator. Or, use await expect(locator).toBeVisible(expected[, options])
to validate if locator with text (assuming locator points to it) is visible.
await expect(page.locator('#orderText')).toHaveText('Some text')
Mistake No.5 - Using window.scroll methods
// Using static window.scrollBy()
await page.evaluate(() => {
window.scrollBy(0, -1000)
})
await page.getByRole('button', { name: 'Continue' }).click()
What is wrong in this code?
❌ Unnecessary Scrolling: Manually scrolling adds redundant code and complexity.
✅ Playwright Handles Scrolling Automatically: The framework ensures that any element interacted with is brought into view, so manual scrolling is not required.
If the element is not yet loaded into the DOM and requires scrolling to bring it into view, consider using scrollIntoViewIfNeeded()
instead of window.scrollBy()
. This ensures that scrolling happens intelligently without relying on hardcoded values.
// Use native scrollIntoViewIfNeeded
await page.getByRole('button', { name: 'Continue' }).scrollIntoViewIfNeeded()
await page.getByRole('button', { name: 'Continue' }).click()
Mistake No.6 - Using old-school Selenium patterns for validation
// textContent() to validate text visibility
export const validateSignIn = async () => {
await page
.locator('#signin-label')
.waitFor({ state: 'visible', timeout: 5000 })
await expect(page.locator('#signin-label')).toContainText('Sign In')
}
What is wrong in this code?
❌ Redundant explicit wait: The waitFor method is unnecessary because Playwright’s auto-wait mechanism ensures elements are actionable.
❌ Fragile locator use: Depending on a locator to validate text can lead to brittle tests since locators might change, even if the actual text remains consistent.
❌ Over-complicated design: Wrapping this validation in a dedicated method adds unnecessary abstraction without providing significant value.
✅ Playwright’s toHaveText() assertion automatically waits for the page to contain the expected text, removing the need for additional waits or locators.
✅ Reduces Complexity: Placing the validation directly in the test eliminates redundant wrapper methods, that usually are written by developers that worked with Selenium in the past.
// expect + toHaveText to validate text visibility
await expect(page).toHaveText('Sign In')
Mistake No.7 - Using CSS or XPath selectors instead of Playwright’s accssible locators
// Using CSS selectors
await page.locator('.submit-button').click()
// Using XPath selectors
await page.locator('//button[contains(text(), "Submit")]').click()
What is wrong in this code?
❌ CSS and XPath selectors are more susceptible to breaking when the UI changes.
❌ Poor maintainability: These selectors often rely on implementation details rather than user-facing attributes.
❌ Reduced accessibility: These approaches bypass accessibility concerns that role-based selectors naturally enforce.
✅ Use Playwright’s accessible locators that prioritize user-facing attributes like role, text, and testid:
// Using role-based locators
await page.getByRole('button', { name: 'Submit' }).click()
// Using text-based locators
await page.getByText('Submit').click()
Mistake No.8 - Forgetting to await assertions
// Missing await in a form submission flow
await page.getByLabel('Email').fill('[email protected]')
await page.getByLabel('Password').fill('securePassword123')
await page.getByRole('button', { name: 'Login' }).click()
// Missing await before checking for success message
expect(page.getByText('Welcome back!')).toBeVisible() // ❌ Missing 'await'
// Continues with next action too soon
await page.getByRole('link', { name: 'Dashboard' }).click() // May fail if previous assertion hasn't completed
What is wrong in this code?
❌ Introduces race conditions: Without await, the test continues execution before the assertion completes.
❌ Creates flaky tests: Tests may pass locally but fail in CI, or pass/fail inconsistently.
❌ Undermines Playwright’s auto-waiting: Playwright’s waiting mechanisms can’t work properly without proper await usage.
✅ Always await all Playwright assertions and actions to ensure your test steps execute in the correct sequence:
// Properly awaited login flow
await page.getByLabel('Email').fill('[email protected]')
await page.getByLabel('Password').fill('securePassword123')
await page.getByRole('button', { name: 'Login' }).click()
// Properly awaited assertion
await expect(page.getByText('Welcome back!')).toBeVisible()
// Now safe to continue with next action
await page.getByRole('link', { name: 'Dashboard' }).click()
Remember that every Playwright action and assertion returns a Promise that must be awaited. Consider using TypeScript ESLint with the @typescript-eslint/no-floating-promises
rule to catch these issues automatically before they cause problems in your test suite.
Mistake No.9 - Reimplementing retry logic instead of using toPass()
// Manually implementing retry logic with role-based locators
let attempts = 0
const maxAttempts = 5
let success = false
while (attempts < maxAttempts && !success) {
try {
// Using role-based locator to find the cart count
const cartBadge = page.getByRole('status', { name: 'Cart items' })
const count = await cartBadge.textContent()
if (count === '2') {
success = true
} else {
await page.waitForTimeout(1000)
attempts++
}
} catch (error) {
await page.waitForTimeout(1000)
attempts++
}
}
expect(success).toBe(true)
What is wrong in this code?
❌ Reinventing the wheel: Manually implementing retry logic that Playwright already provides.
❌ Introducing static waits: The 1000ms timeout between retries is arbitrary and could lead to flakiness.
❌ Error-prone: Custom retry logic is likely to have edge cases that Playwright’s built-in functionality already handles.
✅ Use Playwright’s toPass() to handle assertions that may take time to become true:
// Using toPass() with role-based locators
await expect(async () => {
const cartBadge = page.getByRole('status', { name: 'Cart items' })
const count = await cartBadge.textContent()
expect(count).toBe('2')
}).toPass()
// With additional options for timeout
await expect(async () => {
const cartBadge = page.getByRole('status', { name: 'Cart items' })
const count = await cartBadge.textContent()
expect(count).toBe('2')
}).toPass({ timeout: 10000 })
Conclusion
After reviewing a lot of pull requests, these mistakes stand out as the most frequent roadblocks to reliable Playwright tests. While static waits and redundant visibility checks might seem harmless, they fundamentally undermine Playwright’s powerful auto-waiting capabilities. Additionally, proper use of web-first assertions and role-based locators will improve your test stability.
This isn’t an exhaustive list of everything that can go wrong with Playwright tests. Each application and team will face unique challenges. However, knowing about these mistakes will eliminate a issues during code reviews. The pattern is clear: trust Playwright’s built-in mechanisms rather than implementing workarounds from older testing paradigms. Your tests will be cleaner, more reliable, and better equipped to handle the complexities of modern web applications.