📖 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:

typescript · smoke.spec.ts
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:

  1. "Go to the toy store" — That's page.goto(). You're telling the robot to walk to a website.
  2. "Check if the sign says 'Toy Store'" — That's expect(page).toHaveTitle(). You're asking the robot to verify what it sees.
  3. "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:

1
Discovery
The test runner scans your project for all files matching *.spec.ts or *.test.ts and parses every test() call.
2
Worker Allocation
Playwright spawns parallel worker processes (default: number of CPU cores). Each worker gets its own browser instance.
3
Fixture Setup
For each test, the page fixture launches a new browser context and a new page tab. Clean slate. Zero shared state.
4
Test Execution
Your test function runs step-by-step. Each await pauses until the browser action completes or the assertion resolves.
5
Assertion & Reporting
Auto-retrying assertions check conditions up to 5 seconds. Pass → ✅. Fail → ❌ with screenshot, trace, and error details.
6
Teardown
The page is closed, the browser context is destroyed, and the worker moves on to the next test. No residue.

🔬 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.

first-test.spec.ts Hover each line →
import { test, expect } from '@playwright/test';
 
test('homepage loads successfully', async ({ page }) => {
await page.goto('https://playwright.dev');
await expect(page).toHaveTitle(/Playwright/);
await expect(page.getByRole('link', { name: 'Get started' })).toBeVisible();
});
Import
Test Declaration
Navigation
Assertion

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 async keyword is required because every browser interaction takes time—you must await it.
  • 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.

💡 Key Insight: Auto-Retrying Assertions

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:

bash
# 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:

typescript · first-test.spec.ts
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

bash
# 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:

Terminal — npx playwright test
Running 2 tests using 2 workers

first-test.spec.ts:3:1Playwright homepage has correct title (2.1s)
first-test.spec.ts:8:1Get 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()
💡 Pro Tip: toHaveText vs toContainText

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

typescript · ❌ Wrong
// ❌ 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

typescript · ❌ Unnecessary
// ❌ 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

typescript · ❌ Avoid
// ❌ 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

  1. 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.
  2. Name tests by behavior, not by action. 'login form displays error for invalid credentials' is better than 'clicks login button'.
  3. Always use await with browser actions and assertions. No exceptions.
  4. Use locators, not CSS selectors. getByRole('button', { name: 'Submit' }) is better than page.locator('.btn-submit') because it tests what users see, not implementation details.
  5. Keep tests independent. Never rely on Test A setting up state for Test B. Each test should work in isolation.
  6. Run --headed while 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.beforeEach to navigate once per test instead of repeating page.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 --workers wisely: On CI, set --workers=2 or --workers=4 to 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.ts to limit what's captured.

🐛 Debugging Tips

  • Use UI Mode: npx playwright test --ui opens a full interactive debugger with time-travel inspection.
  • Use --debug: npx playwright test --debug opens 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=500 adds 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():

  1. The engine registers an async matcher that queries the DOM.
  2. It polls the DOM at increasing intervals (starting ~0ms, then ~100ms, ~200ms, etc.).
  3. If the condition becomes true at any point before the timeout (default: 5000ms), it immediately resolves as passed.
  4. 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

Beginner
What is the difference between 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.
Beginner
Why must every Playwright test function be async?
Show answer
Because all browser operations (navigation, clicks, assertions) are asynchronous—they take time to complete. The 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.
Intermediate
How does Playwright's expect differ from Jest's expect?
Show answer
Jest's 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.
Intermediate
What is the { page } parameter in a Playwright test, and how does it get created?
Show answer
It's a fixture—a built-in dependency injected by the Playwright test runner. Before your test runs, the runner automatically launches a browser, creates a new browser context, and opens a new page tab. After the test (regardless of pass/fail), it automatically closes the page and context. This ensures each test starts with a clean, isolated browser state without any manual setup or teardown code.
Advanced
How does Playwright achieve test isolation at the worker level, and what are the implications for shared state?
Show answer
Playwright runs each test in a separate worker process (Node.js child process), each with its own browser instance. This provides OS-level isolation—no shared memory, no shared event loop, no shared browser state. The implication is that you cannot share in-memory state (variables, caches) between tests. If you need to share data, you must use external mechanisms (filesystem, environment variables, or Playwright's project-level fixtures). This design choice sacrifices convenience for reliability—shared state is the #1 cause of flaky tests in Selenium suites.
Scenario-Based
Your test passes locally but fails in CI with "expect(locator).toBeVisible() timed out". The element IS in the HTML. What do you investigate?
Show answer
Check these in order: (1) Is the element visible or just present in DOM? 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

🛠️ Hands-On Exercise Beginner

Exercise: Verify the Playwright Documentation Page

  1. Create a new test file called exercise-docs.spec.ts in your tests folder.
  2. Write a test that navigates to https://playwright.dev/docs/intro.
  3. Assert that the page title contains the word "Playwright".
  4. Assert that the page URL contains /docs/intro.
  5. Run the test with npx playwright test --headed and watch the browser.
  6. Now deliberately break one assertion (change the expected text to something wrong), run it again, and observe the error message. Then fix it.
🔥 Challenge Intermediate

Challenge: Multiple Assertions on a Real Website

  1. Navigate to https://demo.playwright.dev/todomvc (Playwright's official demo app).
  2. Assert that the heading contains "todos".
  3. Assert that the input placeholder text contains "What needs to be done?".
  4. Use --ui mode to run the test and explore the time-travel feature.
  5. 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').)
🏢 Enterprise Challenge Advanced

Challenge: Build a Reusable Smoke Test Suite

  1. Create a smoke/ folder inside tests/.
  2. Write 3 separate tests that verify a public website's critical paths: homepage loads, navigation works, and key content is present.
  3. Use test.describe to group the tests under a "Smoke Tests" block.
  4. Configure a test.beforeEach hook to navigate to the site before each test (removing duplicate page.goto() calls).
  5. 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.

  1. Pick a target website. Choose any public site (your company's staging site, a demo app, or even https://playwright.dev).
  2. Create a project structure:
    • tests/smoke/homepage.spec.ts
    • tests/smoke/navigation.spec.ts
    • tests/smoke/content.spec.ts
  3. 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
  4. Run the full suite and verify all tests pass in both headed and headless modes.
  5. 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

1. Which import statement is required to write Playwright tests?
import { browser, page } from 'playwright'
import { test, expect } from '@playwright/test'
import { describe, it } from '@playwright/test'
import Playwright from 'playwright'
2. What does the { page } parameter in a test function represent?
A global browser page shared by all tests
A fresh browser page fixture automatically managed by Playwright
A configuration object for the test
A reference to the HTML document
3. What happens if you forget await before page.goto()?
The test automatically waits anyway
Playwright throws a syntax error
The test moves to the next line before navigation completes, likely causing failures
The navigation runs synchronously
4. Which command runs tests with a visible browser window?
npx playwright test --visible
npx playwright test --headed
npx playwright test --browser
npx playwright test --show
5. What makes Playwright's expect() different from Jest's?
It can only check strings
It runs synchronously
It auto-retries assertions until they pass or timeout
It doesn't support custom matchers
6. What does toHaveTitle(/Playwright/) check?
The title is exactly the string "Playwright"
The title contains the word "Playwright" (regex match)
The title starts with "Playwright"
The title ends with "Playwright"
7. How does Playwright isolate tests from each other?
By running all tests in the same browser tab sequentially
By clearing cookies between tests
By giving each test its own fresh browser context and page in separate worker processes
By using a shared database that resets between tests
8. Which is the preferred way to locate an element in Playwright?
page.$('.btn-submit')
page.locator('xpath=//button[@class="btn-submit"]')
page.getByRole('button', { name: 'Submit' })
page.querySelector('.btn-submit')
9. What does a green in the test output indicate?
The test ran but assertions were skipped
All assertions in the test passed within their timeout
The test file was discovered
The browser launched successfully
10. What is the default timeout for Playwright's auto-retrying assertions?
1 second
3 seconds
5 seconds
10 seconds

❓ Frequently Asked Questions

Do I need to close the browser manually in my test?
Show answer
No. When you use the { 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.
Can I run a single test instead of the entire suite?
Show answer
Yes. Three ways: (1) 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.
What file naming convention should I use for test files?
Show answer
By default, Playwright looks for files ending in .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.
Why does my test pass with --headed but fail in headless mode?
Show answer
This usually happens because: (1) The element is visually hidden or positioned off-screen in headless mode due to different viewport sizes. (2) A CSS animation hasn't completed in the tighter timing of headless execution. (3) Fonts render differently in headless, affecting element dimensions. Set an explicit viewport in your config: viewport: { width: 1280, height: 720 } to ensure consistency between modes.
Can I write tests in JavaScript instead of TypeScript?
Show answer
Absolutely. When you run 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 manual waitFor calls in most cases.
  • await is mandatory for all browser actions and assertions. Forgetting it is the #1 beginner mistake.
  • Locators (getByRole, getByText) are superior to page.$() 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: --headed for development, headless for CI, --ui for debugging, --debug for step-through.
  • Default assertion timeout is 5 seconds, configurable globally or per-assertion.
  • toHaveText() for exact match, toContainText() for substring match—prefer toContainText for resilience.