Introduction

If you’ve spent any time in the world of QA, you know the pain of ‘flaky’ tests—those elusive bugs that fail in CI but pass on your local machine. After spending years with legacy tools, I’ve found that the most reliable way to handle modern web apps is through a playwright vs cypress vs selenium comparison, where Playwright consistently wins on speed and reliability. In this playwright automation with typescript tutorial, I’m going to show you exactly how to set up a professional-grade testing framework that scales.

Why TypeScript? Because when your test suite grows to hundreds of files, you need the type safety and autocompletion that only TypeScript provides. It transforms your tests from fragile scripts into maintainable code.

Prerequisites

Before we dive into the code, make sure you have the following installed on your machine:

Step 1: Initializing Your Playwright Project

The beauty of Playwright is that it handles the boilerplate for you. Open your terminal and run:

npm init playwright@latest

During the setup, the CLI will ask you a few questions. I recommend the following choices for a professional setup:

Once the installation is complete, you’ll see a playwright.config.ts file. This is the brain of your operation. I usually configure my timeout to 30 seconds and enable trace: 'on-first-retry' to help debug failures without slowing down every single run.

Terminal output showing successful Playwright initialization and folder structure creation
Terminal output showing successful Playwright initialization and folder structure creation

Step 2: Writing Your First Test

Let’s create a simple test to verify a login flow. Create a file named tests/auth.spec.ts. I’ve written this example using a real-world scenario where we navigate to a site and check for a specific heading.

import { test, expect } from '@playwright/test';

test('user can successfully login', async ({ page }) => {
  // Navigate to the application
  await page.goto('https://example.com/login');

  // Interact with the UI
  await page.fill('#username', 'test_user');
  await page.fill('#password', 'secure_password123');
  await page.click('button[type="submit"]');

  // Assert the result
  await expect(page).toHaveURL(/dashboard/);
  await expect(page.locator('h1')).toContainText('Welcome Back');
});

To run this test, use the command: npx playwright test. If you want to see the browser in action, add the --headed flag.

Step 3: Implementing the Page Object Model (POM)

As I’ve scaled automation for various clients, I’ve realized that putting all your selectors in the test file is a recipe for disaster. The Page Object Model (POM) abstracts the UI, making your tests cleaner and easier to update when the UI changes.

First, create a directory pages/ and a file pages/LoginPage.ts:

import { Page, Locator } from '@playwright/test';

export class LoginPage {
  readonly page: Page;
  readonly usernameInput: Locator;
  readonly passwordInput: Locator;
  readonly loginButton: Locator;

  constructor(page: Page) {
    this.page = page;
    this.usernameInput = page.locator('#username');
    this.passwordInput = page.locator('#password');
    this.loginButton = page.locator('button[type="submit"]');
  }

  async login(user: string, pass: string) {
    await this.usernameInput.fill(user);
    await this.passwordInput.fill(pass);
    await this.loginButton.click();
  }
}

Now, update your test to use the page object. As shown in the images of a structured project, this separates what the test does from how it interacts with the page.

import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';

test('POM Login Test', async ({ page }) => {
  const loginPage = new LoginPage(page);
  await page.goto('/login');
  await loginPage.login('admin', 'password');
  await expect(page).toHaveURL(/dashboard/);
});
Visual comparison of a flat test structure vs a Page Object Model structure
Visual comparison of a flat test structure vs a Page Object Model structure

Step 4: Handling Dynamic Content and Waiting

One of the main reasons I prefer Playwright over older tools is Auto-waiting. You don’t need sleep(5000) anymore. Playwright waits for elements to be actionable before performing an action.

However, for complex async loads, I recommend using waitForSelector or expect().toBeVisible(). In my experience, the locator.waitFor() method is the most reliable way to ensure a spinner has disappeared before proceeding.

Step 5: Integrating with CI/CD

A test that only runs on your machine isn’t a test; it’s a demo. To get real value, you need these running on every pull request. Since we opted into the GitHub Actions setup during initialization, you already have a .github/workflows/playwright.yml file.

I recommend diving deeper into integrating playwright with github actions tutorial to learn how to upload HTML reports as artifacts so you can see exactly why a test failed in the cloud.

Pro Tips for Robust Automation

Troubleshooting Common Issues

Issue: Element is not interceptable

This usually happens when an element is covered by a loading overlay. Instead of adding a hard sleep, use await page.waitForSelector('.loading-spinner', { state: 'hidden' }).

Issue: TypeScript cannot find the ‘tests’ module

Check your tsconfig.json. Ensure your baseUrl and paths are correctly mapped to your source and test directories.

What’s Next?

Now that you have a working framework, I suggest exploring API Testing with Playwright. You can use the same tool to validate your backend endpoints before running the UI tests, which drastically speeds up your test suite.

Ready to take your productivity to the next level? Check out my other guides on automation and development tools to build a seamless workflow.