Playwright is a test automation framework made by Microsoft. It controls a real browser, so your tests can click buttons, fill forms and check results. The same way a user would. Many teams now choose Playwright because it is fast, stable and easy to read.
In this tutorial you will learn the basic building blocks of every Playwright test. You do not need any setup. You can run every example in the TestAcademy editor.
What a Playwright test looks like
Here is a complete test. Read it first, then we will explain each part.
import { test, expect } from '@playwright/test';
test('user can sign in', async ({ page }) => {
await page.goto('/');
await page.getByLabel('Email').fill('demo@nimbus.app');
await page.getByLabel('Password').fill('secure123');
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByTestId('welcome-message')).toContainText('Welcome back');
});
Every test has the same three steps:
- Navigate, open a page with
page.goto(). - Act, find elements and interact with them.
- Assert, check that the page shows the right result with
expect().
The test function
test('user can sign in', async ({ page }) => { ... });
- The first argument is the test name. Use clear names that describe the behavior, like
'shows an error for a wrong password'. - The function receives a
pageobject. This is your remote control for the browser tab. - The function is
asyncbecause browser actions take time. You mustawaitevery action.
Locators: how to find elements
A locator describes how to find an element on the page. This is the most important skill in test automation. Playwright gives you several ways:
page.getByRole('button', { name: 'Sign in' }) // by role and visible name
page.getByLabel('Email') // by form label
page.getByText('Welcome back') // by text content
page.getByPlaceholder('Search products') // by placeholder
page.getByTestId('cart-count') // by data-testid attribute
page.locator('.price') // by CSS selector
Which one should you use? Prefer getByRole and getByLabel. They match what a user sees, so they do not break when a developer changes a CSS class. Use getByTestId when the element has no clear role or label. Use raw CSS selectors as a last option.
Locators can be chained and filtered:
// The "Add to cart" button inside one specific product card
page.getByTestId('product-card')
.filter({ hasText: 'Alpine Tent' })
.getByRole('button', { name: 'Add to cart' });
Actions
Once you have a locator, you can interact with it:
await locator.click(); // click
await locator.fill('some text'); // set an input value
await locator.press('Enter'); // press a key
await locator.check(); // tick a checkbox
await locator.selectOption('DE'); // choose a select option
await locator.hover(); // move the mouse over it
Assertions
Assertions check the result. In Playwright they start with expect:
await expect(locator).toBeVisible();
await expect(locator).toHaveText('Order confirmed');
await expect(locator).toContainText('confirmed');
await expect(locator).toHaveCount(8);
await expect(locator).toBeChecked();
await expect(page).toHaveURL('/dashboard');
Add .not to check the opposite:
await expect(page.getByTestId('error')).not.toBeVisible();
Auto-waiting: the Playwright superpower
Web apps are slow sometimes. A button may appear after a network request. Old test tools failed here, and people added sleep(3000) everywhere. That makes tests slow and unstable.
Playwright waits automatically:
- An action waits until the element exists, is visible and is enabled.
- An assertion retries until it passes or a timeout is reached.
So this code works even when login takes two seconds:
await page.getByRole('button', { name: 'Sign in' }).click();
// The dashboard loads slowly — expect() just retries until it appears.
await expect(page.getByTestId('welcome-message')).toBeVisible();
You almost never need manual waits. If you write waitForTimeout, stop and ask why.
Grouping tests
Use test.describe to group tests and test.beforeEach for shared setup:
test.describe('Login page', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('shows validation errors for an empty form', async ({ page }) => {
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByTestId('email-error')).toBeVisible();
});
});
Common mistakes
- Forgetting
await. Every action and assertion returns a promise. Withoutawait, the test continues before the action finishes and fails in strange ways. - Using fragile selectors.
page.locator('div > div:nth-child(3) > button')breaks with any layout change. Use roles, labels or test IDs. - Adding manual sleeps.
waitForTimeout(3000)hides real problems and slows the suite. Trust auto-waiting. - Testing too much in one test. One test should check one behavior. Small tests are easier to debug when they fail.
- Depending on other tests. Each test should start from a clean state. Never rely on data created by a previous test.
Best practices
- Name tests after the behavior:
'shows an error for a wrong password', not'test 2'. - Prefer user-facing locators (
getByRole,getByLabel) over CSS. - Keep one assertion topic per test. A few related
expectcalls are fine. - Use
test.beforeEachfor navigation and setup you repeat. - Let assertions do the waiting. Do not check conditions with
if.
Practice now
The fastest way to learn is to write tests yourself. Open the the Playwright editor, it has a login app, starter tests and challenges. Try the first challenge: write a test that submits a wrong password and checks the error message.