Selenium requires separate browser driver management and explicit waits on almost every interaction; Cypress can't run cross-browser and gates parallel execution behind a paid plan. Playwright handles all of this out of the box: one install command downloads Chromium, Firefox, and WebKit, and auto-waiting means elements don't need manual sleep() calls before interactions. This guide covers installation, your first passing test, what each line actually does, and how to read a trace when something fails.
Why Playwright and not something else
Before installing anything, the short version: Playwright is what most new projects choose in 2026, and for good reason.
Selenium was the standard for years. It works, but it's slow to set up, verbose to write, and requires you to manage browser drivers manually. Cypress is easier but only supports Chromium-based browsers and has a paywall for parallel runs in CI.
Playwright handles modern web apps out of the box. It auto-waits for elements to be ready before interacting with them, so you don't need random sleep() calls in your tests. It supports Chromium, Firefox, and WebKit. It has a built-in test runner, video recording of failures, and a code generator that writes locators for you while you click around a page.
If you're starting from scratch in 2026, Playwright is the right choice.
Installation
You need Node.js installed first. Check if you have it:
node --versionIf that returns a version number (v18 or higher is fine), you're good. If not, download the LTS version from nodejs.org.
Now create a new folder for your project and run the Playwright installer:
mkdir my-first-tests
cd my-first-tests
npm init playwright@latestThe installer asks a few questions. For learning purposes, accept the defaults:
- TypeScript or JavaScript → TypeScript
- Tests folder → tests
- GitHub Actions workflow → no (add later)
- Install Playwright browsers → yes
The browser download takes a minute or two. When it finishes, your project looks like this:
my-first-tests/
tests/
example.spec.ts
playwright.config.ts
package.jsonThat's it. No configuration files to wrestle with, no driver managers, no separate test runner to install.
Run the example test
Before writing anything, run what came with the installation:
npx playwright testPlaywright runs the example tests in headless mode (no visible browser window) and prints results to the terminal. You'll see something like:
Running 2 tests using 2 workers
2 passed (3s)To see what actually happened, open the HTML report:
npx playwright show-reportThis opens a browser with a detailed report of each test: what steps ran, how long each took, and screenshots at the end.
npx playwright test --headed to watch the browser open and execute the test in real time. Useful when you're just starting out and want to see what's happening.Write your first test
Delete the example test file and create a new one. Open tests/login.spec.ts and write this:
import { test, expect } from '@playwright/test';
test('user can log in', async ({ page }) => {
await page.goto('https://lab.becomeqa.com');
await page.getByRole('button', { name: 'Login' }).click();
await page.getByLabel('Username').fill('admin@becomeqa.com');
await page.getByLabel('Password').fill('testpass123');
await page.getByRole('button', { name: 'Submit' }).click();
await expect(page.getByText('My Travel Items')).toBeVisible();
});Run it:
npx playwright test tests/login.spec.tsYou should see 1 passed. Here's what each part does.
Understanding what you just wrote
test('user can log in', async ({ page }) => {
test is the function that defines a test case. The first argument is the name: write it like a sentence describing what the user does, not what the code does. async means the function is asynchronous, which is required because every browser action takes time. { page } is Playwright's page fixture, which gives you a fresh browser tab to work with.
await page.goto('https://lab.becomeqa.com')
Opens the URL. await tells JavaScript to wait for this action to finish before moving to the next line. You'll use await in front of every Playwright action.
page.getByRole('button', { name: 'Login' })
This is a locator. It finds an element on the page. getByRole finds elements by their ARIA role and accessible name. This finds a button with the text "Login". It's the most reliable type of locator because it's tied to how the element actually works, not its CSS class or position.
await page.getByLabel('Username').fill('admin@becomeqa.com')
Finds the input field associated with a "Username" label and types the email address into it.
await expect(page.getByText('My Travel Items')).toBeVisible()
This is the assertion, the part that actually checks something. It verifies that text "My Travel Items" is visible on the page after login. If the login fails and this text doesn't appear, the test fails.
Locators: how to find elements
Locators are how you tell Playwright which element to interact with. There are several types and each has a different use case.
getByRole is the preferred choice. It uses ARIA roles, which describe the semantic meaning of elements:
page.getByRole('button', { name: 'Submit' }) // a button labeled Submit
page.getByRole('link', { name: 'Home' }) // a link labeled Home
page.getByRole('textbox', { name: 'Search' }) // a text input labeled SearchgetByLabel works for form inputs that have a visible label:
page.getByLabel('Email address')
page.getByLabel('Password')getByPlaceholder when there's no label but there's placeholder text:
page.getByPlaceholder('Enter your email')getByText finds elements by their visible text content:
page.getByText('My Travel Items')
page.getByText('Sign out')getByTestId uses a data-testid attribute. Useful when developers add them specifically for testing:
page.getByTestId('submit-button')Avoid locators based on CSS classes or XPath. They break whenever a developer renames a class or restructures the HTML. Role-based locators survive those changes.
Actions: interacting with the page
Once you have a locator, you can perform actions on it:
await locator.click() // click an element
await locator.fill('some text') // clear and type into an input
await locator.type('some text') // type character by character (for inputs that react to keystrokes)
await locator.check() // check a checkbox
await locator.selectOption('value') // select from a dropdown
await locator.hover() // hover over an element
await locator.press('Enter') // press a keyboard keyOne important thing: you almost never need to wait for elements yourself. Playwright automatically waits for an element to be visible and enabled before performing an action. This is called auto-waiting and it's what eliminates most flakiness in Playwright tests.
Assertions: checking the result
An assertion is what makes a test actually test something. Without assertions, you're just clicking around a page.
// Check visibility
await expect(page.getByText('Welcome')).toBeVisible();
await expect(page.getByRole('dialog')).toBeHidden();
// Check text content
await expect(page.getByRole('heading')).toHaveText('My Travel Items');
await expect(page.getByRole('heading')).toContainText('Travel');
// Check input values
await expect(page.getByLabel('Email')).toHaveValue('test@example.com');
// Check URL
await expect(page).toHaveURL('https://lab.becomeqa.com/dashboard');
// Check page title
await expect(page).toHaveTitle('BecomeQA Lab');
// Check element count
await expect(page.getByRole('row')).toHaveCount(5);Playwright's assertions also auto-wait. toBeVisible() doesn't just check right now. It retries for up to 5 seconds by default, waiting for the element to appear. This means you don't need explicit waits before assertions either.
A more complete test
Here's a second test that continues after login:
import { test, expect } from '@playwright/test';
test('user can log in', async ({ page }) => {
await page.goto('https://lab.becomeqa.com');
await page.getByRole('button', { name: 'Login' }).click();
await page.getByLabel('Username').fill('admin@becomeqa.com');
await page.getByLabel('Password').fill('testpass123');
await page.getByRole('button', { name: 'Submit' }).click();
await expect(page.getByText('My Travel Items')).toBeVisible();
});
test('travel items table has data', async ({ page }) => {
// log in first
await page.goto('https://lab.becomeqa.com');
await page.getByRole('button', { name: 'Login' }).click();
await page.getByLabel('Username').fill('admin@becomeqa.com');
await page.getByLabel('Password').fill('testpass123');
await page.getByRole('button', { name: 'Submit' }).click();
// check the table
await expect(page.getByRole('table')).toBeVisible();
const rows = page.getByRole('row');
await expect(rows).toHaveCount(6); // header + 5 data rows
});Run both tests:
npx playwright test tests/login.spec.tsYou'll notice the login steps are duplicated between tests. That's fine for now. Once you have more tests, you'll move repeated setup into a beforeEach block or a fixture, but that's an intermediate topic.
The playwright.config.ts file
Open playwright.config.ts. The defaults work fine, but two settings are worth knowing:
export default defineConfig({
testDir: './tests',
timeout: 30000, // max time per test in ms
retries: 0, // how many times to retry a failed test
use: {
baseURL: 'https://lab.becomeqa.com', // add this
trace: 'on-first-retry',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
],
});If you set baseURL, you can write page.goto('/') instead of the full URL in your tests. Add it to the config and update your tests:
await page.goto('/'); // instead of 'https://lab.becomeqa.com'By default Playwright runs tests across all three browsers (Chromium, Firefox, WebKit). To run only in Chromium while learning:
npx playwright test --project=chromiumWhen a test fails
Let's break our test on purpose. Change the expected text to something that doesn't exist:
await expect(page.getByText('This text does not exist')).toBeVisible();Run it and you'll see:
1 failed
login.spec.ts:10:3 › user can log in ✗
Error: expect(locator).toBeVisible()
Locator: getByText('This text does not exist')
Expected: visible
Received: hidden
Timeout: 5000msThe error tells you exactly what it was waiting for and that it never became visible. Now check the trace:
npx playwright show-reportClick on the failed test. You'll see a step-by-step breakdown with screenshots at each action. This is the trace viewer, one of Playwright's most useful features for debugging failures.
→ See also: Installing Playwright: Step-by-Step Setup Guide (2026) | Playwright Locators: getByRole, getByLabel, getByText, getByTestId Compared | Playwright Assertions: The Complete Guide | Playwright Trace Viewer: Debug Failing Tests Like a ProFix the test back to 'My Travel Items' before moving on.
FAQ
Do I need to know TypeScript before starting?No. The TypeScript you'll write for tests is minimal, mostly async/await and basic types. The Playwright course covers exactly what you need as you go.
TypeScript is the better choice even if you're new to it. It catches mistakes before you run the test. The extra syntax is small and the benefits are immediate.
How do I find the right locator for an element?Use Playwright's codegen tool: npx playwright codegen https://lab.becomeqa.com. It opens a browser, records your clicks, and generates the locator code automatically. Use it to explore, then clean up what it generates.
Usually a timing issue or a missing await. Run with --headed locally to watch what happens, then check if you missed an await somewhere.