Playwright Tutorial for Beginners: Write Your First Test

9 min readupdated July 11, 2026

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:

  1. Navigate, open a page with page.goto().
  2. Act, find elements and interact with them.
  3. 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 page object. This is your remote control for the browser tab.
  • The function is async because browser actions take time. You must await every 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

  1. Forgetting await. Every action and assertion returns a promise. Without await, the test continues before the action finishes and fails in strange ways.
  2. Using fragile selectors. page.locator('div > div:nth-child(3) > button') breaks with any layout change. Use roles, labels or test IDs.
  3. Adding manual sleeps. waitForTimeout(3000) hides real problems and slows the suite. Trust auto-waiting.
  4. Testing too much in one test. One test should check one behavior. Small tests are easier to debug when they fail.
  5. 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 expect calls are fine.
  • Use test.beforeEach for 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.

now prove it

Prove the login really checks the password

Reading is the easy part. Write the test yourself, then we break the app on purpose to check it would really catch the bug.

Solve this problem →

Keep reading