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:
- Node.js (LTS version recommended)
- Visual Studio Code (The official Playwright extension makes a huge difference)
- Basic knowledge of TypeScript (Interfaces and Async/Await)
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:
- TypeScript: Yes (Essential for this tutorial)
- Name of tests folder: tests
- Add a GitHub Actions workflow? Yes (We will refine this later)
- Install Playwright browsers? Yes
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.
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/);
});
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
- Avoid XPath: Use
getByRoleorgetByText. They are more resilient to HTML structure changes and mimic how users actually find elements. - Use Project Configs: Set up multiple projects in
playwright.config.tsto run tests across Chromium, Firefox, and WebKit simultaneously. - Trace Viewer: Always use the Trace Viewer for debugging. It allows you to step through the test execution and see the DOM state at every single action.
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.