Writing Your First Playwright Test
The tools are installed, the terminal is open, and your heart is racing. It's time to write the test that proves you are officially an automation engineer. No more watching. No more reading. This is where the code meets the browser.
📖 The Story: The Green Checkmark Moment
Imagine it's your first day as a junior QA automation engineer. Your team lead, Priya, walks over and says, "We need a smoke test for the login page by end of day." You nod confidently, but inside, your stomach flips. You've installed Playwright (thanks to Lesson 1.2), but you've never actually written a test that talks to a real browser.
You open an empty file. The cursor blinks. You type import, then delete it. You type test, then delete it. The blank page mocks you.
Then you remember: every expert was once a beginner staring at a blank file. So you take a breath, write five lines of code, hit run, and—a browser pops open, navigates to the page, and the terminal prints a green checkmark. That green checkmark is the most beautiful thing you've ever seen. It says: "Your code works. You control the browser. You are an automation engineer."
By the end of this lesson, you will feel that moment. Let's build it together.
🎯 Why This Lesson Is Critical
Before diving into advanced selectors, parallel execution, or CI pipelines, you need to understand the fundamental unit of Playwright: the test. Every complex test suite in the world—from startups to Google—is built from the same basic structure you'll learn here.
The Building Block
Every test suite is composed of individual test() blocks. Master this one pattern and you can build anything.
Assertions Are Logic
Tests without assertions are just scripts. Learning toHaveTitle and toContainText teaches you how tests actually verify behavior.
The Runner Is Your Engine
Understanding how Playwright discovers, executes, and reports tests is the foundation for debugging, filtering, and parallelization.
🌍 Real-World Example: The Smoke Test
In production QA teams, a "smoke test" is the first suite that runs after every deployment. It doesn't test every feature—it verifies that the most critical paths work. If the smoke test fails, the deployment is rolled back immediately. Here's what a real smoke test looks like:
import { test, expect } from '@playwright/test';
test('homepage loads successfully', async ({ page }) => {
// Navigate to the application
await page.goto('https://playwright.dev');
// Verify the page title is correct
await expect(page).toHaveTitle(/Playwright/);
// Verify a key element is visible
await expect(page.getByRole('link', { name: 'Get started' })).toBeVisible();
});
What this test does: It opens the Playwright website, confirms the page title contains "Playwright", and checks that the "Get started" link is visible. Three lines of test logic. Infinite confidence.
🧒 Explain Like I'm 10
Think of a Playwright test like giving instructions to a robot friend:
- "Go to the toy store" — That's
page.goto(). You're telling the robot to walk to a website. - "Check if the sign says 'Toy Store'" — That's
expect(page).toHaveTitle(). You're asking the robot to verify what it sees. - "Make sure the 'Open' sign is lit up" — That's
expect(element).toBeVisible(). You're checking that something important is actually showing.
If all three checks pass, the robot gives you a thumbs up ✅. If any check fails, the robot shakes its head ❌ and tells you exactly what went wrong. That's a test!
🎓 Professional Explanation
A Playwright test is an asynchronous function registered with the test() API from the @playwright/test module. The test runner discovers all files matching the configured test pattern (default: **/*.spec.ts or **/*.test.ts), extracts every test() call, and executes them in isolated worker processes.
Each test receives a fixture object (destructured as { page }) that provides a fresh browser page instance. This fixture-based dependency injection ensures complete test isolation—no shared state between tests by default.
The expect() function is Playwright's assertion library, purpose-built for async web assertions. Unlike Jest's expect, Playwright's version uses auto-retrying assertions—it keeps checking the condition until it passes or a timeout is reached. This is what makes Playwright tests resilient to slow-loading pages, animations, and network delays.
⚙️ Behind the Scenes: What Happens When You Run a Test
When you type npx playwright test and press Enter, an entire orchestration engine springs to life. Here is the exact sequence of events:
*.spec.ts or *.test.ts and parses every test() call.page fixture launches a new browser context and a new page tab. Clean slate. Zero shared state.await pauses until the browser action completes or the assertion resolves.🔬 Anatomy of Your First Test
Let's break down every single line of the test you just saw. Understanding each piece now will save you hours of confusion later.
Line-by-Line Breakdown
Line 1: The Import
import { test, expect } from '@playwright/test';
This pulls two critical functions from the Playwright test module:
test— The function you use to define a new test case. It's the container for your test logic.expect— The assertion function you use to verify expected outcomes. It's how your test decides pass or fail.
Line 3: The Test Declaration
test('homepage loads successfully', async ({ page }) => {
This single line does three things:
- Names the test: The string
'homepage loads successfully'appears in your test reports. Good names describe what is being verified, not how. - Marks it async: The
asynckeyword is required because every browser interaction takes time—you mustawaitit. - Receives the page fixture: The
{ page }destructured parameter is Playwright injecting a fresh browser page into your test. You didn't create it. You didn't configure it. It just works.
Line 4: Navigation
await page.goto('https://playwright.dev');
This tells the browser to navigate to the specified URL. The await is mandatory—without it, the test would move to the next line before the page finishes loading, causing flaky failures.
Lines 5-6: Assertions
await expect(page).toHaveTitle(/Playwright/);
This checks that the page's <title> element contains the word "Playwright". Note the regex /Playwright/—this matches any title that includes the word, not an exact string.
await expect(page.getByRole('link', { name: 'Get started' })).toBeVisible();
This finds a link with the accessible name "Get started" and verifies it's visible to users. This is a user-facing assertion—it checks what a real person would see, not what the DOM looks like internally.
Playwright's expect() is different from Jest's. When you write await expect(page).toHaveTitle(/Playwright/), Playwright does NOT check the title once and fail. Instead, it keeps checking repeatedly for up to 5 seconds (configurable). If the title appears at any point during that window, the assertion passes. This single feature eliminates the majority of flaky test failures caused by slow-loading content.
🚀 Running Your First Test
Step 1: Create the Test File
In your project (set up in Lesson 1.2), navigate to the tests folder and create a new file:
# Navigate to your project's tests directory
cd playwright-cseian/tests
# Create your first test file
touch first-test.spec.ts
Step 2: Write the Test Code
Open first-test.spec.ts in your editor and paste this complete test:
import { test, expect } from '@playwright/test';
test('Playwright homepage has correct title', async ({ page }) => {
await page.goto('https://playwright.dev');
await expect(page).toHaveTitle(/Playwright/);
});
test('Get started link is visible', async ({ page }) => {
await page.goto('https://playwright.dev');
await expect(page.getByRole('link', { name: 'Get started' })).toBeVisible();
});
Two tests, one file: Each test() call is independent. They each get their own fresh page instance. If the first test fails, the second still runs. This isolation is built into Playwright's design.
Step 3: Run the Test
# Run all tests in headless mode (no visible browser)
npx playwright test
# Run with a visible browser window (great for learning)
npx playwright test --headed
# Run only a specific test file
npx playwright test first-test.spec.ts
# Run with Playwright's interactive UI mode
npx playwright test --ui
--headed vs headless: In --headed mode, you see the browser open and perform actions. In headless mode (the default), the browser runs invisibly in the background. Always use --headed while learning—it helps you see exactly what your test is doing.
Step 4: Understand the Output
When your tests pass, the terminal displays this:
✓ first-test.spec.ts:3:1 › Playwright homepage has correct title (2.1s)
✓ first-test.spec.ts:8:1 › Get started link is visible (1.8s)
2 passed (3.9s)
Let's decode what this output tells you:
- 2 tests using 2 workers: Playwright ran both tests in parallel using 2 browser instances.
- ✓ (green checkmark): The test passed.
- File:line:column: The exact location in your source file where the test was defined.
- (2.1s): The total time from test start to completion, including navigation and assertion retries.
- 2 passed (3.9s): The overall summary—2 tests passed in 3.9 seconds total.
🔍 Assertions Deep Dive
Assertions are the heart of every test. Without them, you're just running a script—you have no idea if the application actually works. Playwright provides dozens of assertion methods, but here are the most essential ones you'll use daily:
| Assertion | What It Checks | Example |
|---|---|---|
toHaveTitle() |
Page title matches string/regex | expect(page).toHaveTitle(/Login/) |
toHaveURL() |
Current URL matches string/regex | expect(page).toHaveURL(/dashboard/) |
toBeVisible() |
Element is visible on the page | expect(locator).toBeVisible() |
toHaveText() |
Element's text content matches exactly | expect(locator).toHaveText('Welcome') |
toContainText() |
Element's text contains the substring | expect(locator).toContainText('Welcome') |
toBeEnabled() |
Element is not disabled | expect(button).toBeEnabled() |
toBeChecked() |
Checkbox/radio is checked | expect(checkbox).toBeChecked() |
Use toHaveText() when you want an exact match of the entire text content. Use toContainText() when you only care about a substring. For example, if a heading says "Welcome back, Alex!", toHaveText('Welcome') will FAIL (because the full text is longer), but toContainText('Welcome') will PASS. In practice, toContainText() is more resilient and is preferred for most checks.
⚠️ Common Mistakes
Mistake 1: Forgetting await
// ❌ Missing await — test moves on before navigation completes
page.goto('https://playwright.dev');
await expect(page).toHaveTitle(/Playwright/);
What goes wrong: Without await, the test starts navigating but immediately moves to the assertion. The page hasn't loaded yet, so the title check fails. Always await browser actions and assertions.
Mistake 2: Using Manual Browser Management
// ❌ Don't manually create browser, context, and page
const browser = await chromium.launch();
const context = await browser.newContext();
const page = await context.newPage();
// ... and then manually closing everything at the end
Why it's a problem: The { page } fixture already handles all of this automatically—launching, context creation, cleanup. Manual management adds boilerplate and risks resource leaks if the test fails midway. Use the fixture unless you need custom browser configuration.
Mistake 3: Using page.$() Instead of Locators
// ❌ Old-school selector — no auto-wait, no retry
const button = await page.$('text=Get started');
await button.click();
// ✅ Locator — auto-waits, auto-retries, resilient
await page.getByRole('link', { name: 'Get started' }).click();
The difference: page.$() immediately queries the DOM and returns null if nothing matches. Locators (getByRole, getByText, etc.) are lazy—they wait and retry until the element appears. Always prefer locators over page.$(). We'll cover this deeply in Chapter 2.
✅ Best Practices
- One assertion per test for unit-level tests. For UI tests, 2-3 related assertions are acceptable. If you have 10 assertions in one test, split it.
- Name tests by behavior, not by action.
'login form displays error for invalid credentials'is better than'clicks login button'. - Always use
awaitwith browser actions and assertions. No exceptions. - Use locators, not CSS selectors.
getByRole('button', { name: 'Submit' })is better thanpage.locator('.btn-submit')because it tests what users see, not implementation details. - Keep tests independent. Never rely on Test A setting up state for Test B. Each test should work in isolation.
- Run
--headedwhile developing to see what the browser does. Switch to headless for CI.
⚡ Performance Tips
- Reuse navigation: If multiple tests visit the same page, use
test.beforeEachto navigate once per test instead of repeatingpage.goto()in every test body. - Run in parallel: Playwright runs tests in parallel by default. Don't add dependencies between tests—this breaks parallelism.
- Use
--workerswisely: On CI, set--workers=2or--workers=4to avoid overloading the machine. Locally, the default (number of CPU cores) works well. - Trim browser binaries: If you only test Chromium, install only Chromium:
npx playwright install chromium. This saves ~300MB and reduces CI setup time.
🔒 Security Notes
- Never hardcode credentials in test files. Use environment variables:
process.env.LOGIN_PASSWORD. - Don't test against production databases. Use staging or test environments with disposable data.
- Be careful with
page.evaluate(). It runs arbitrary JavaScript in the browser context. Never pass untrusted input into it. - Store test results securely. Screenshots and traces may contain sensitive data (tokens, personal info). Configure
playwright.config.tsto limit what's captured.
🐛 Debugging Tips
- Use UI Mode:
npx playwright test --uiopens a full interactive debugger with time-travel inspection. - Use
--debug:npx playwright test --debugopens the Playwright Inspector, letting you step through each action line-by-line. - Add
page.pause()inside your test to freeze execution and open the inspector at a specific point. - Check traces: If a test fails in CI, configure
trace: 'on-first-retry'in your config. Playwright will record a full trace you can view later at trace.playwright.dev. - Slow down execution:
npx playwright test --headed --slow-mo=500adds a 500ms pause between each action, making it easy to follow along visually.
🏗️ Senior Engineer Deep Dive
For engineers with 5+ years of experience, here's what's happening under the hood that matters for architecture decisions:
Fixture System Architecture
Playwright's fixture system is inspired by dependency injection patterns found in frameworks like Angular and NestJS. When you destructure { page }, the test runner doesn't just pass a variable—it executes a setup-teardown lifecycle:
- Setup phase: Launch browser → create context → create page → inject into test.
- Teardown phase: Close page → close context → (if no other tests need it) close browser.
- Error handling: If the test throws, teardown still runs. This prevents resource leaks (orphan browser processes).
This is fundamentally different from Selenium's approach, where you manually manage WebDriver lifecycle and must implement your own teardown in afterEach hooks.
Worker Process Isolation
Each worker is a separate Node.js process (not a thread). This means:
- Complete memory isolation—no shared mutable state between workers.
- If one worker crashes (e.g., out of memory), other workers are unaffected.
- Communication between the test runner orchestrator and workers uses IPC (Inter-Process Communication).
- Browser instances are pinned to workers, not shared. Worker 1's Chromium is completely separate from Worker 2's Chromium.
Auto-Retry Assertion Engine
Playwright's assertion engine uses a polling loop with exponential backoff. When you call expect(locator).toBeVisible():
- The engine registers an async matcher that queries the DOM.
- It polls the DOM at increasing intervals (starting ~0ms, then ~100ms, ~200ms, etc.).
- If the condition becomes true at any point before the timeout (default: 5000ms), it immediately resolves as passed.
- If the timeout expires, it captures a screenshot and throws a detailed error with the actual vs expected state.
This design eliminates the need for explicit waitFor calls in 95% of cases. It's the single biggest reason Playwright tests are less flaky than Selenium tests.
Enterprise Trade-offs
- Trade-off: Fixture isolation vs. test speed. Creating a new browser context for every test ensures isolation but adds ~50-100ms of overhead per test. For large suites (1000+ tests), this adds up. Teams often use
test.describe.configure({ mode: 'serial' })to reuse contexts within a group. - Trade-off: Auto-retry vs. instant failure detection. Auto-retry is great for resilience, but it can mask genuine bugs. If your assertion passes on the 3rd try, it means your application has a timing issue. Monitor retry counts in production.
💼 Interview Questions
test and expect in Playwright?Show answer
test is the function that defines a test case—it gives the test a name and contains the test logic. expect is the assertion function used inside a test to verify that the application behaves as expected. Without expect, a test is just a script that performs actions but never validates anything.async?Show answer
async keyword allows you to use await inside the function, which pauses execution until the browser action finishes. Without await, your test would execute the next line before the previous action completes, causing unpredictable failures.expect differ from Jest's expect?Show answer
expect evaluates the condition immediately and passes or fails on the spot. Playwright's expect works with async web elements and uses auto-retry—it keeps checking the condition for up to 5 seconds (configurable via timeout). This makes Playwright assertions resilient to slow-loading pages and animations, which is the primary cause of flaky tests in web automation.{ page } parameter in a Playwright test, and how does it get created?Show answer
Show answer
Show answer
toBeVisible() checks visual visibility, not DOM presence. An element with display:none or visibility:hidden will fail. (2) Is there a CSS animation or transition that delays visibility? The default 5s timeout might not be enough on slower CI machines. (3) Is the CI environment headless? Some elements render differently in headless mode. (4) Enable trace: 'on-first-retry' in config to capture a full trace and replay the exact failure in the trace viewer.🏋️ Practical Exercises
Exercise: Verify the Playwright Documentation Page
- Create a new test file called
exercise-docs.spec.tsin yourtestsfolder. - Write a test that navigates to
https://playwright.dev/docs/intro. - Assert that the page title contains the word "Playwright".
- Assert that the page URL contains
/docs/intro. - Run the test with
npx playwright test --headedand watch the browser. - Now deliberately break one assertion (change the expected text to something wrong), run it again, and observe the error message. Then fix it.
Challenge: Multiple Assertions on a Real Website
- Navigate to
https://demo.playwright.dev/todomvc(Playwright's official demo app). - Assert that the heading contains "todos".
- Assert that the input placeholder text contains "What needs to be done?".
- Use
--uimode to run the test and explore the time-travel feature. - Bonus: Add a second test that types a todo item and verifies it appears in the list. (Hint: Use
page.getByPlaceholder()and.fill()then.press('Enter').)
Challenge: Build a Reusable Smoke Test Suite
- Create a
smoke/folder insidetests/. - Write 3 separate tests that verify a public website's critical paths: homepage loads, navigation works, and key content is present.
- Use
test.describeto group the tests under a "Smoke Tests" block. - Configure a
test.beforeEachhook to navigate to the site before each test (removing duplicatepage.goto()calls). - Run only the smoke tests using:
npx playwright test smoke/.
🧩 Mini Project: Personal Site Smoke Test
🎯 Project: Automated Smoke Test for Any Website
Apply everything you've learned by building a complete, runnable smoke test suite for a real website. This project gives you something you can immediately add to your portfolio or GitHub.
- Pick a target website. Choose any public site (your company's staging site, a demo app, or even
https://playwright.dev). - Create a project structure:
tests/smoke/homepage.spec.tstests/smoke/navigation.spec.tstests/smoke/content.spec.ts
- Write at least 5 total tests covering:
- Homepage loads and has the correct title
- Primary navigation links are visible
- Clicking a nav link navigates to the correct URL
- Key content/heading is present
- Footer or copyright text is visible
- Run the full suite and verify all tests pass in both headed and headless modes.
- Add a README.md explaining what the suite tests and how to run it. Push to GitHub. You now have a portfolio piece.
🧠 Quiz: Test Your Knowledge
import { browser, page } from 'playwright'import { test, expect } from '@playwright/test'import { describe, it } from '@playwright/test'import Playwright from 'playwright'{ page } parameter in a test function represent?await before page.goto()?npx playwright test --visiblenpx playwright test --headednpx playwright test --browsernpx playwright test --showexpect() different from Jest's?toHaveTitle(/Playwright/) check?page.$('.btn-submit')page.locator('xpath=//button[@class="btn-submit"]')page.getByRole('button', { name: 'Submit' })page.querySelector('.btn-submit')✓ in the test output indicate?❓ Frequently Asked Questions
Show answer
{ page } fixture, Playwright handles the entire lifecycle—launching, context creation, page creation, and teardown. Even if your test crashes, the cleanup still runs. This is one of the biggest advantages over Selenium, where forgetting driver.quit() leaves orphan browser processes.Show answer
npx playwright test path/to/file.spec.ts runs all tests in that file. (2) npx playwright test -g "test name" runs only tests whose names match the pattern. (3) In UI mode (--ui), you can click the play button next to any individual test.Show answer
.spec.ts or .test.ts. You can customize this in playwright.config.ts using the testDir and testMatch options. The convention .spec.ts comes from the Jasmine/Mocha ecosystem and is the most widely used in the Playwright community.--headed but fail in headless mode?Show answer
viewport: { width: 1280, height: 720 } to ensure consistency between modes.Show answer
npm init playwright@latest, choose JavaScript instead of TypeScript. Your test files will use .spec.js extensions. However, the industry standard for Playwright is TypeScript—it provides autocompletion, type checking, and catches errors before runtime. If you're learning, starting with TypeScript from day one is strongly recommended.📌 Summary
🎯 Key Takeaways
test()defines a test case with a name and async callback that receives a{ page }fixture.expect()performs auto-retrying assertions—no more manualwaitForcalls in most cases.awaitis mandatory for all browser actions and assertions. Forgetting it is the #1 beginner mistake.- Locators (
getByRole,getByText) are superior topage.$()because they auto-wait and auto-retry. - Test isolation is built-in—each test gets its own fresh browser page in its own worker process.
- Run modes:
--headedfor development, headless for CI,--uifor debugging,--debugfor step-through. - Default assertion timeout is 5 seconds, configurable globally or per-assertion.
toHaveText()for exact match,toContainText()for substring match—prefertoContainTextfor resilience.
🔗 Related Articles
1.1 Introduction to Playwright
What Playwright is, why it was built, and how it compares to Selenium and Cypress.
1.2 Installing Playwright
Step-by-step Node.js and Playwright installation with troubleshooting tips.
1.4 Anatomy of a Test
Deep dive into every component: imports, fixtures, hooks, and test structure.
2.1 CSS Selectors & Locators
Master Playwright's locator strategies: getByRole, getByText, getByTestId, and more.
