DEV Community

Cover image for 10 Playwright Tips That Will Change How You Write Tests
Faizal
Faizal

Posted on

10 Playwright Tips That Will Change How You Write Tests

I remember the first week I seriously used Playwright.

I was migrating a 200-test WDIO suite. I thought it would take two weeks. It took four days. Not because Playwright is magic — but because once you understand how it actually thinks, everything clicks into place faster than any other tool I have used.

But I also made a lot of mistakes early on. I wrote tests the old way — the Selenium way — inside a tool that was designed for something better.

These are the 10 tips that changed how I write Playwright tests. Not the basics you find in the docs. The real stuff.


Tip 1 🎯 — Stop Using waitForTimeout. Seriously.

This is the first habit to break. If you are coming from Selenium or WDIO, you are used to sprinkling sleeps everywhere to handle timing issues.

Playwright has auto-waiting built into every action. When you click an element, Playwright automatically waits for it to be visible, stable, and enabled before acting. You do not need to help it.

// ❌ Old habit — never do this
await page.waitForTimeout(3000);
await page.click('#submit-btn');

// ✅ Playwright way — just click, it handles the wait
await page.click('#submit-btn');

// ✅ When you genuinely need to wait for a condition
await page.waitForSelector('#success-message', { state: 'visible' });
Enter fullscreen mode Exit fullscreen mode

Every waitForTimeout in your suite is a lie you are telling yourself. Replace them all.


Tip 2 🔍 — Use getByRole Over CSS Selectors

Most engineers default to CSS selectors because that is what they know. But Playwright's locator methods like getByRole, getByText, and getByLabel are more resilient and closer to how a real user interacts with your app.

// ❌ Fragile — breaks when a developer changes the class name
await page.click('.btn-primary-submit-checkout');

// ✅ Resilient — targets what the user actually sees
await page.getByRole('button', { name: 'Place Order' }).click();

// ✅ Even better for forms
await page.getByLabel('Email Address').fill('test@example.com');
await page.getByRole('button', { name: 'Sign In' }).click();
Enter fullscreen mode Exit fullscreen mode

These locators survive UI refactors. A developer can change every class name in the codebase and your tests will still pass because you are targeting meaning, not implementation.


Tip 3 🏗️ — Use Page Object Model But Keep It Lean

Page Object Model is still the right pattern in Playwright. But I see engineers over-engineer it — abstracting every single element into a getter, creating three layers of inheritance, building a framework that takes longer to navigate than the app itself.

Keep it lean.

// pages/LoginPage.js
export class LoginPage {
  constructor(page) {
    this.page = page;
    this.emailInput = page.getByLabel('Email');
    this.passwordInput = page.getByLabel('Password');
    this.submitButton = page.getByRole('button', { name: 'Sign In' });
    this.errorMessage = page.getByTestId('error-message');
  }

  async login(email, password) {
    await this.emailInput.fill(email);
    await this.passwordInput.fill(password);
    await this.submitButton.click();
  }
}

// In your test
const loginPage = new LoginPage(page);
await loginPage.login('user@test.com', 'password123');
Enter fullscreen mode Exit fullscreen mode

One class per page. Actions as methods. Locators as properties. That is all you need.


Tip 4 🌐 — Mock Your APIs for Faster, Stable Tests

This is the most underused Playwright feature I see in real projects. Network interception is built-in, clean, and incredibly powerful. Use it to remove external dependencies from your UI tests.

// Mock an API response directly in your test
await page.route('**/api/products', async route => {
  await route.fulfill({
    status: 200,
    contentType: 'application/json',
    body: JSON.stringify([
      { id: 1, name: 'Test Product', price: 99.99 }
    ])
  });
});

await page.goto('/products');
await expect(page.getByText('Test Product')).toBeVisible();
Enter fullscreen mode Exit fullscreen mode

Your UI tests should test UI behaviour. They should not fail because a third-party API was slow or returned unexpected data. Mock aggressively.


Tip 5 📸 — Use Trace Viewer When Tests Fail in CI

When a test fails in CI, most engineers look at the error message and guess. Playwright gives you something far better — a full visual trace of everything that happened.

// playwright.config.js
export default {
  use: {
    trace: 'on-first-retry',  // captures trace only when test fails
    screenshot: 'only-on-failure',
    video: 'on-first-retry'
  }
}
Enter fullscreen mode Exit fullscreen mode

Then open it locally:

npx playwright show-trace trace.zip
Enter fullscreen mode Exit fullscreen mode

You get a full timeline — every action, every network request, every DOM snapshot, every screenshot — in a beautiful visual interface. Debugging a CI failure goes from 2 hours to 10 minutes.


Tip 6 ⚙️ — Use test.describe and test.use for Context Isolation

One of Playwright's most powerful features is the ability to configure context at the describe block level. Use it to run the same tests across different states without duplicating code.

// Run the same checkout tests as guest and logged-in user
test.describe('Checkout — Guest User', () => {
  test.use({ storageState: undefined });

  test('should show login prompt at checkout', async ({ page }) => {
    // test logic
  });
});

test.describe('Checkout — Logged In User', () => {
  test.use({ storageState: 'auth/user.json' });

  test('should proceed to payment directly', async ({ page }) => {
    // test logic
  });
});
Enter fullscreen mode Exit fullscreen mode

Clean, isolated, and no code duplication. This pattern alone will clean up suites that have grown messy over time.


Tip 7 🔐 — Save Authentication State and Reuse It

Logging in before every test is one of the biggest performance killers in UI automation. Playwright makes it trivially easy to authenticate once and reuse that session across your entire suite.

// auth.setup.js — runs once before all tests
import { test as setup } from '@playwright/test';

setup('authenticate', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill(process.env.TEST_EMAIL);
  await page.getByLabel('Password').fill(process.env.TEST_PASSWORD);
  await page.getByRole('button', { name: 'Sign In' }).click();
  await page.waitForURL('/dashboard');
  await page.context().storageState({ path: 'auth/user.json' });
});
Enter fullscreen mode Exit fullscreen mode
// playwright.config.js
export default {
  projects: [
    { name: 'setup', testMatch: /auth.setup.js/ },
    {
      name: 'tests',
      use: { storageState: 'auth/user.json' },
      dependencies: ['setup']
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Log in once. Run everything. Your suite just got significantly faster.


Tip 8 🧪 — Use expect Soft Assertions for Multi-Step Validations

Standard assertions stop the test the moment they fail. Sometimes you want to check multiple things on a page and see all failures at once rather than fixing them one by one. That is what soft assertions are for.

test('product page should display all details', async ({ page }) => {
  await page.goto('/products/1');

  // Soft assertions — test continues even if one fails
  await expect.soft(page.getByTestId('product-title')).toBeVisible();
  await expect.soft(page.getByTestId('product-price')).toContainText('$');
  await expect.soft(page.getByTestId('product-image')).toBeVisible();
  await expect.soft(page.getByTestId('add-to-cart')).toBeEnabled();

  // Hard assertion at the end — fails if any soft assertion failed
  expect(test.info().errors).toHaveLength(0);
});
Enter fullscreen mode Exit fullscreen mode

One test run, all failures surfaced. Much faster feedback loop.


Tip 9 📊 — Tag Your Tests and Run Subsets Smartly

As your suite grows, running everything on every commit becomes too slow. Playwright's tagging system lets you run exactly the tests you need, when you need them.

test('critical checkout flow @smoke @critical', async ({ page }) => {
  // test logic
});

test('promo code validation @regression', async ({ page }) => {
  // test logic
});
Enter fullscreen mode Exit fullscreen mode
# Run only smoke tests on every commit
npx playwright test --grep @smoke

# Run full regression suite nightly
npx playwright test --grep @regression
Enter fullscreen mode Exit fullscreen mode

Combine this with your CI/CD pipeline and you have a smart test execution strategy — fast feedback on commits, full coverage overnight.


Tip 10 🛠️ — Use codegen to Bootstrap Tests, Not to Write Them

Playwright's code generator is a fantastic tool that most engineers either ignore completely or over-rely on.

npx playwright codegen https://yourapp.com
Enter fullscreen mode Exit fullscreen mode

It records your browser interactions and generates test code in real time. The mistake is treating that output as finished tests. It is not. It is a starting point.

Use it to:

  • Quickly capture the right locators for a new page
  • Understand how Playwright sees your UI
  • Bootstrap repetitive test scaffolding

Then go back and refactor — replace fragile selectors with getByRole, extract repeated logic into page objects, add proper assertions. The codegen is your first draft, not your final test.


Wrapping Up

Playwright is genuinely one of the best automation tools available right now. But like any tool, the difference between a messy suite and a clean one is not the tool itself — it is how deeply you understand it.

These 10 tips took me months of real project work to internalize. Start applying even two or three of them today and you will notice the difference in how your tests feel to write, maintain, and debug.

The best Playwright suite is one your whole team trusts. Build towards that.


Written by **Abdulfaizal Shaikh* — Senior Automation Engineer with 7+ years of experience building production automation frameworks across UI, API, and mobile platforms.*

Top comments (0)