Learning path · Level 12 · Tutorial 4

Run Local Browser Journeys with Playwright

Run the same critical Playwright journey in locally installed Chromium and Firefox, control API responses, and investigate cross-browser differences without treating engine checks as complete target-browser coverage.

  • Level: Advanced
  • Duration: 25 min
  • 10 steps
Start tutorial Back to the path
Run Local Browser Journeys with Playwright

What you will go through

  1. Define the Critical Local Journey Define a focused Trail Shop browser journey and create the local application it will exercise. 2 min
  2. Install Playwright and Separate Test Discovery Install local Playwright browser-testing tools and configure separate discovery boundaries for Playwright journeys and Vitest source tests. 3 min
  3. Connect the Local Server and Browser Projects Configure Playwright to own one local Vite server and run the same suite in isolated Chromium and Firefox projects. 3 min
  4. Control the Browser's API Response Make the Trail Shop journey deterministic by intercepting its product request before navigation. 3 min
  5. Interact Through Accessible Locators Navigate the controlled Trail Shop catalog with accessible Playwright locators and synchronize the journey through observable interface state. 3 min
  6. Verify URLs, Detail Navigation, and Back Complete the Trail Shop browser journey by verifying filtered state, detail navigation, and browser-history restoration through both URLs and visible results. 3 min
  7. Run and Compare Both Browser Engines Run the controlled Trail Shop journey across both configured browser projects, distinguish headless and headed execution, and record each project's real outcome. 2 min
  8. Investigate a Project-Specific Failure Preserve, inspect, and document local evidence for a selected Playwright browser-project discrepancy without mistaking an injected test failure for an engine defect. 4 min
  9. State What the Engine Checks Do Not Prove Define exactly what the Chromium and Firefox project results establish, then identify the actual-browser or device evidence that remains necessary. 2 min
  10. Complete the Local Verification Record Restore the complete local configuration and journey, run it in both browser projects, and document results and remaining coverage honestly. 3 min

What you will learn

  • Configure separate Playwright discovery, a local Vite server, and Chromium and Firefox projects.
  • Write one critical journey using accessible locators, controlled responses, and retrying assertions.
  • Run the same navigation and interaction checks in both browser engines and compare their outcomes.
  • Record and investigate an engine-specific failure using a headed reproduction, failure report, or local trace.
  • Identify additional actual-browser and device checks that the Playwright projects do not establish.

Before you start

  • Test Asynchronous Interfaces with Controlled Responses
  • Run a Local Development Server with Vite
  • Build Accessible Client-Side Routes with React Router
  • Represent Detail Views and Filters in the URL

Step 1 of 10

Define the Critical Local Journey

Define a focused Trail Shop browser journey and create the local application it will exercise.

Bound the User Outcome

Choose One Valuable Journey

A browser journey protects one valuable user outcome across boundaries that isolated unit or component tests do not cross. Keep it bounded: do not duplicate every component case.

For Trail Shop, follow this sequence:

  1. Open the catalog.
  2. Select Bags.
  3. Open Day Pack.
  4. Use the browser’s Back control and recover the filtered catalog.

The journey checks observable outcomes: URL, selected control, visible links, and headings. It does not inspect React state variables, component names, or internal function calls.

The Trail Shop Journey

A four-stage flow from catalog to a filtered Bags catalog, then Day Pack detail, then Back to the filtered catalog.

The route and rendered state change together. Back must restore the filtered catalog, not merely any catalog.

Prepare the Workspace

Create the Local Workspace

Create a self-contained React, TypeScript, and Vitest-ready workspace on your computer. No repository, hosted app, downloaded asset, or API is required. The first command creates the Vite project; the remaining commands install the application and source-test dependencies.

Do not start a development server yet. You will add the application files first.

Create and install the workspace

Run these commands in a terminal. The final command adds a local Vitest script for the source-test setup that already exists in this workspace; the browser-test discovery boundary is configured in the next step.

bash
npm create vite@latest journey-lab -- --template react-ts
cd journey-lab
npm install react-router-dom
npm install -D vitest jsdom
npm pkg set scripts.test='vitest run'
Expected output
> npx
> create-vite journey-lab --template react-ts

Scaffolding project in ./journey-lab...
Done. Now run:
  cd journey-lab
  npm install

added packages, and audited packages
found 0 vulnerabilities

This is representative npm output. Package counts, audit messages, and Vite versions can vary by date and operating system.

vite.config.ts

Replace vite.config.ts with this complete file. It supplies the existing Vitest environment that the next step will keep while narrowing test discovery.

typescript
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',
  },
});

src/main.tsx

Replace src/main.tsx with this complete file.

tsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App';

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <BrowserRouter>
      <App />
    </BrowserRouter>
  </StrictMode>,
);

Build the Application Under Test

Add the Trail Shop Interface

Replace src/App.tsx by joining the next two sections, in order, into one file. The catalog filters its loaded records locally. The detail route derives its content from that same list.

src/App.tsx — Part 1

src/App.tsx — Part 1 of 2. Start the file with this section.

tsx
import { useEffect, useState } from 'react';
import { Link, Route, Routes, useParams, useSearchParams } from 'react-router-dom';

type Product = {
  slug: string;
  name: string;
  category: string;
};

function Catalog({ products }: { products: Product[] }) {
  const [params, setParams] = useSearchParams();
  const category = params.get('category') ?? '';
  const visible = category
    ? products.filter((product) => product.category === category)
    : products;

  return (
    <main>
      <h1>Products</h1>
      <label>
        Category
        <select
          value={category}
          onChange={({ target }) =>
            setParams(target.value ? { category: target.value } : {})
          }
        >
          <option value=''>All</option>
          <option value='bags'>Bags</option>
          <option value='lighting'>Lighting</option>
        </select>
      </label>
      <ul>
        {visible.map((product) => (
          <li key={product.slug}>
            <Link to={`/products/${product.slug}`}>{product.name}</Link>
          </li>
        ))}
      </ul>
    </main>
  );
}

src/App.tsx — Part 2

src/App.tsx — Part 2 of 2. Append this directly after Part 1, then save.

tsx
function Detail({ products }: { products: Product[] }) {
  const { slug } = useParams();
  const product = products.find((item) => item.slug === slug);

  if (!product) {
    return <main><h1>Product not found</h1></main>;
  }

  return (
    <main>
      <h1>{product.name}</h1>
      <p>Category: {product.category}</p>
      <Link to='/products'>Catalog</Link>
    </main>
  );
}

export default function App() {
  const [products, setProducts] = useState<Product[] | null>(null);
  const [error, setError] = useState(false);

  useEffect(() => {
    fetch('/api/products')
      .then((response) => {
        if (!response.ok) throw new Error('Request failed');
        return response.json() as Promise<Product[]>;
      })
      .then(setProducts)
      .catch(() => setError(true));
  }, []);

  if (error) return <main><p role='alert'>Unable to load products.</p></main>;
  if (!products) return <main><p>Loading products…</p></main>;

  return (
    <Routes>
      <Route path='/products' element={<Catalog products={products} />} />
      <Route path='/products/:slug' element={<Detail products={products} />} />
    </Routes>
  );
}

Expected Filtered Screen

A Products page with the Category control set to Bags and only the Day Pack link visible.

This is the user-facing filtered outcome: Products, a Category select set to Bags, and a Day Pack link.

Plan the Evidence

Start Vite for a brief manual check

After saving the files, you may inspect the application manually. Vite stays running until you stop it, so use this only for a brief check.

bash
npm run dev -- --host 127.0.0.1 --port 5173 --strictPort
Expected output
> journey-lab@0.0.0 dev
> vite --host 127.0.0.1 --port 5173 --strictPort

  VITE v6.x.x  ready in 180 ms

  ➜  Local:   http://127.0.0.1:5173/
  ➜  press h + enter to show help

The Vite version and timing may vary. The required readiness address is `http://127.0.0.1:5173/`.

Tip

Stop the Manual Server Before Browser Tests

To inspect the route, visit http://127.0.0.1:5173/products. Then stop this manual Vite process with Ctrl+C before moving to the Playwright-managed runs in later steps.

Later, Playwright starts Vite itself with the same strict port and reuseExistingServer: false. Leaving this manual process running would occupy port 5173 and make that later run fail instead of testing the application.

Warning

Why a Manual Visit Can Show an Error

This Vite application deliberately provides no API. A direct visit to /products can show Unable to load products. because /api/products has no server response.

That is expected now. In a later step, the browser journey will supply a successful response for that one request. The application’s fetch, local filtering, rendering, routes, and browser history will remain real application behavior. This is not a live-backend check.

Define the Observable Checkpoints

Write the journey’s observable checkpoints in order, from the initial catalog through the Back action. Then name one React-internal check that would not be an appropriate replacement for an observable checkpoint.

Write at least 180 characters (0/180).

Step 2 of 10

Install Playwright and Separate Test Discovery

Install local Playwright browser-testing tools and configure separate discovery boundaries for Playwright journeys and Vitest source tests.

Install the two local layers

Install the runner and browser builds

From journey-lab, install the test packages as project-local development dependencies, then install the Playwright-managed browser builds. The package provides the Playwright test runner and APIs. The install command downloads the Chromium and Firefox builds that later browser projects use; it does not select an arbitrary browser already installed on your computer.

Local installation commands

Run these commands from journey-lab. Re-running the npm command is safe if Step 1 already installed Vitest and jsdom; npm will confirm that the existing dependencies are current.

bash
npm install -D @playwright/test vitest jsdom
npx playwright install chromium firefox
Expected output
added <package count> packages, and audited <package count> packages

found 0 vulnerabilities

Downloading Chromium <build> from <Playwright download source>
Chromium <build> downloaded to <local Playwright browser cache>
Downloading Firefox <build> from <Playwright download source>
Firefox <build> downloaded to <local Playwright browser cache>

Representative success output. Package counts, audit messages, browser build numbers, download URLs, and whether a cached browser is reused vary by machine and Playwright version. Confirm that npm completes successfully and that both Chromium and Firefox are downloaded or already installed.

Warning

Installation boundary

A Playwright browser build can also require supported operating-system dependencies. If installation or launch reports a missing dependency, follow Playwright's installation guidance for your operating system. Do not assume an installed Chrome, Firefox, or another branded browser is the build used by this local setup.

See the discovery boundary

Illustrative workspace layout

This is an annotated layout, not a request to create src/App.test.tsx. The workspace already has src/main.tsx and src/App.tsx; a source test appears only when you add one later. The important boundary is where each runner searches.

text
journey-lab/
├── src/
│   ├── main.tsx
│   ├── App.tsx
│   └── App.test.tsx          # illustrative future Vitest test
├── e2e/
│   └── catalog.spec.ts       # Playwright browser journey
├── playwright.config.ts
└── vite.config.ts

Two test suites, two discovery boundaries

A project directory splits source-level tests and browser journey tests into separate folders, each connected to its own test runner configuration.

Playwright searches the e2e/ journey area; Vitest searches source-level test files under src/.

Tip

Why the boundary matters

Put the Trail Shop browser journey in e2e/catalog.spec.ts. A future source test such as src/App.test.tsx belongs to Vitest. Explicit discovery patterns prevent either runner from attempting to collect the other runner's files.

Make collection explicit

Playwright discovery

Create playwright.config.ts with explicit browser-test discovery. Later steps extend this same file with the local server and browser-project settings; do not add those settings yet.

Collect only browser journey specs

playwright.config.ts

typescript
import { defineConfig } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',
  testMatch: '**/*.spec.ts',
});

Vitest discovery

Replace vite.config.ts with this complete compatible configuration. It preserves the React plugin and jsdom environment from the initial workspace while adding a narrow Vitest pattern. An empty matching set is valid now: you do not need to invent a source test for this browser-journey tutorial.

Keep Vitest inside src

vite.config.ts

typescript
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',
    include: ['src/**/*.test.{ts,tsx}'],
  },
});

Check your discovery boundaries

Complete the discovery map

Match each example test path to the complete discovery rule that should collect it.

Tap an item and then its matching pair.

Step 3 of 10

Connect the Local Server and Browser Projects

Configure Playwright to own one local Vite server and run the same suite in isolated Chromium and Firefox projects.

Map the local execution flow

One controlled local route to the app

A browser-project run is a small local system, not just a test file. Playwright starts the Vite command, waits for the configured readiness URL, launches the selected browser engine, and gives the test a page in that browser.

baseURL is the shared origin used by relative navigation later in the journey. Therefore, the Vite host, port, readiness URL, and baseURL must describe the same local application: 127.0.0.1:5173.

Local execution flow

A diagram shows a test runner starting a local server, then running the same Trail Shop page in two separate browser windows.

The test runner owns the Vite process, then runs the same suite once per browser project.

Create a trustworthy server boundary

Configure the runner, server, and projects

Put this complete configuration in playwright.config.ts. The global use settings apply to both projects. Each project supplies only its browser engine and uses the same 1280 × 720 viewport.

The webServer block launches Vite with the exact host and strict port. Its url is the address Playwright waits for before tests begin. With reuseExistingServer: false, this practice workspace does not attach to a server that happened to be running already.

playwright.config.ts

playwright.config.ts

typescript
import { defineConfig } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',
  testMatch: '**/*.spec.ts',
  use: {
    baseURL: 'http://127.0.0.1:5173',
    viewport: { width: 1280, height: 720 },
  },
  webServer: {
    command: 'npm run dev -- --host 127.0.0.1 --port 5173 --strictPort',
    url: 'http://127.0.0.1:5173',
    reuseExistingServer: false,
  },
  projects: [
    {
      name: 'chromium',
      use: { browserName: 'chromium' },
    },
    {
      name: 'firefox',
      use: { browserName: 'firefox' },
    },
  ],
});

Warning

Strict port means fail clearly

Do not replace --strictPort with a fallback port for this workspace. If port 5173 is occupied, Vite stops instead of silently selecting another port. Resolve the conflicting local process, then run the test again. This makes a connection mistake visible rather than letting a test target an unexpected application.

Understand the page fixture

Start every test with an isolated session

For each test and each browser project, Playwright provides a fresh browser context and a fresh page. The context is the isolated browser session that contains browser-managed state such as cookies and local storage; page is the tab the test controls.

A Chromium test does not pass its session to Firefox, and one test does not inherit another test's state. That isolation makes outcomes easier to compare. This tutorial uses Playwright's built-in fixtures; it does not reuse authentication state or define custom fixtures.

Tip

Same suite, separate engine sessions

Keep a browser-project configuration symmetric. The same spec and viewport should run under both named projects; only the selected browser engine differs. A project is a Playwright configuration entry, not a claim about every installed target browser or device.

Practice the configuration

Complete the shared configuration

Complete the values that make this server and two-project configuration use one controlled local origin.

typescript
export default defineConfig({
  use: {
    baseURL: '',
    viewport: { width: 1280, height: 720 },
  },
  webServer: {
    command: 'npm run dev -- --host  --port  --strictPort',
    url: '',
    reuseExistingServer: ,
  },
  projects: [
    { name: 'chromium', use: { browserName: 'chromium' } },
    { name: 'firefox', use: { browserName: 'firefox' } },
  ],
});

Apply the complete setup diagnosis

Diagnose an untrustworthy local run

A teammate launches Vite with --host 127.0.0.1 --port 5173 --strictPort, but sets baseURL to http://127.0.0.2:5173. Also, another local application already occupies port 5173.

In 3–5 sentences, describe the corrected server and project configuration. Explain why the host mismatch prevents a trustworthy run and why the occupied strict port must be resolved before running the suite.

Write at least 180 characters (0/180).

Step 4 of 10

Control the Browser's API Response

Make the Trail Shop journey deterministic by intercepting its product request before navigation.

Intercept the request at the browser boundary

Where the response is substituted

Substitute one response, not the application

The Trail Shop page still performs its real fetch('/api/products'). Playwright intercepts that one browser request and supplies a controlled response before it reaches a server.

Register the route before page.goto('/products'): navigation loads the app, and the app can request products immediately. A broad pattern such as **/* could replace documents, scripts, and other resources; target only the products endpoint instead.

Each test receives a fresh page fixture, so each test must register its own handler. The controlled data is not shared through cookies, local storage, or another test.

Request flow

Diagram showing the browser loading the local app from Vite, then its product request being intercepted by Playwright and fulfilled with controlled data before the app renders product cards.

Vite still serves the application. Only the /api/products response is substituted; the browser fetch, React rendering, and routing behavior remain real.

Fulfill the exact API path

A deterministic catalog response

Install a narrow handler before navigation

page.route() observes requests from this page. The glob **/api/products limits the handler to the intended endpoint. Inside the handler, route.fulfill() creates a successful JSON response with the exact records used by the journey.

This is a controlled browser journey, not a live-backend test. It does not establish that a production API has the same contract, is available, or correctly authorizes requests.

e2e/catalog.spec.ts — controlled API response

Add this near the beginning of the journey test, before its catalog navigation.

typescript
import { test, expect } from '@playwright/test';

test('shows products from the controlled catalog response', async ({ page }) => {
  await page.route('**/api/products', async route => {
    await route.fulfill({
      contentType: 'application/json',
      body: JSON.stringify([
        { slug: 'day-pack', name: 'Day Pack', category: 'bags' },
        { slug: 'trail-light', name: 'Trail Light', category: 'lighting' },
      ]),
    });
  });

  await page.goto('/products');

  await expect(page.getByRole('heading', { name: 'Products' })).toBeVisible();
});

Tip

Why the order matters

The route callback runs only when the matching request occurs. Calling page.route() does not fetch data itself; it prepares the response handler that the application's later request will use.

Practice: control the request

Prepare the response before navigation

Complete the marked expressions so the products response is fulfilled inline and the interception is ready before navigation triggers the request.

typescript
import { test } from '@playwright/test';

test('controls the catalog data before navigation', async ({ page }) => {
  
    await ({
      contentType: 'application/json',
      body: JSON.stringify([
        { slug: 'day-pack', name: 'Day Pack', category: 'bags' },
        { slug: 'trail-light', name: 'Trail Light', category: 'lighting' },
      ]),
    });
  });

  await page.goto('/products');
});

Step 5 of 10

Interact Through Accessible Locators

Navigate the controlled Trail Shop catalog with accessible Playwright locators and synchronize the journey through observable interface state.

Identify stable user-facing targets

Locate the catalog as a user does

The configured baseURL lets the journey navigate by application path, so begin with await page.goto('/products'); rather than repeating the host and port.

Target what a person can identify in Trail Shop:

  • the Products heading by heading role and accessible name;
  • the category control through its associated Category label;
  • the Day Pack product link by link role and accessible name.

These locators describe the interface contract. They do not depend on CSS classes, DOM nesting, or React component names.

Accessible targets on the catalog

Trail Shop catalog diagram showing Products as a heading target, Category as the label for a select control, and Day Pack as a link target.

Use a role and accessible name for semantic content; use the associated label for the select control.

Navigate and filter in the single spec

Replace the exploratory spec with one evolving journey

Replace, do not append to, the exploratory e2e/catalog.spec.ts from Step 4. Delete its earlier import and its shows products from the controlled catalog response test, then paste the complete file below.

The result must contain exactly one Playwright import and one evolving journey test. It retains the controlled API handler, navigates to the catalog, and confirms the first filtered state. In Step 6, extend this same test callback before its final });; do not create a second test or import.

Establish the initial accessible journey

e2e/catalog.spec.ts — replace the entire file

typescript
import { test, expect } from '@playwright/test';

test('filters, opens detail, and restores the filter with Back', async ({ page }) => {
  await page.route('**/api/products', async route => {
    await route.fulfill({
      contentType: 'application/json',
      body: JSON.stringify([
        { slug: 'day-pack', name: 'Day Pack', category: 'bags' },
        { slug: 'trail-light', name: 'Trail Light', category: 'lighting' },
      ]),
    });
  });

  await page.goto('/products');

  const catalogHeading = page.getByRole('heading', { name: 'Products' });
  const category = page.getByLabel('Category');

  await expect(catalogHeading).toBeVisible();
  await category.selectOption('bags');
  await expect(category).toHaveValue('bags');
});

Act through the interface, then assert its state

A locator is a reusable query, not a one-time DOM snapshot. The category locator can first perform an action and then support a later assertion. Playwright waits for an awaited action such as selectOption() only when the target is ready to receive it.

Web-first assertions such as toBeVisible() and toHaveValue() retry while observable interface state settles. By contrast, await category.inputValue() reads one immediate value, and a fixed delay merely waits for elapsed time. Neither is a substitute for asserting the user-visible outcome.

The next step uses getByRole('link', { name: 'Day Pack' }) to confirm and activate the now-visible product link, then verifies detail navigation and browser Back inside this same test.

Tip

Keep the controlled boundary in place

Keep the API handler before page.goto(). The application still performs its real fetch, local filtering, rendering, and routing; only the /api/products response is controlled for this journey.

Prefer interface state over elapsed time

Choose state-based synchronization

After selecting a category, which approach synchronizes the journey with the control's observable state?

Apply accessible locators and retrying assertions

Complete the initial accessible journey

Complete the accessible locators and retrying assertions for the initial catalog journey.

typescript
const catalogHeading = page.getByRole(, { name: 'Products' });
const category = page.getByLabel();

await page.goto('/products');
await expect(catalogHeading).();
await category.selectOption('bags');
await expect(category).('bags');

const dayPackLink = page.getByRole(, { name: 'Day Pack' });
await expect(dayPackLink).toBeVisible();

Step 6 of 10

Verify URLs, Detail Navigation, and Back

Complete the Trail Shop browser journey by verifying filtered state, detail navigation, and browser-history restoration through both URLs and visible results.

Connect actions to observable outcomes

Pair route state with rendered state

A route is only one part of the user-visible result. After filtering, verify the address, the selected Category control, the included Day Pack link, and the excluded Trail Light link. After detail navigation, verify both the product route and the rendered product heading.

Browser Back is also a contract: it should return to the filtered catalog state, not merely to a URL that looks correct. A URL-only check can miss a stale select value or stale product results. Keep these checks in the same independent journey, with identical expectations for every browser project.

Route and history outcome

Each action has an address expectation and a rendered-state expectation.
Journey pointExpected URLVisible state
Catalog/productsProducts heading
Filter Bags/products?category=bagsBags selected; Day Pack only
Open product/products/day-packDay Pack heading
Browser Back/products?category=bagsFiltered catalog restored

Trace the route and history states

A four-stage Trail Shop browser journey: catalog, filtered catalog, product detail, then the filtered catalog restored by browser Back.

The journey moves from /products to the Bags-filtered catalog, then to Day Pack detail; browser Back restores the earlier filtered state.

Create one engine-neutral journey

Replace the evolving test with one complete journey

Replace the entire existing e2e/catalog.spec.ts file from Step 5; do not append this code after its earlier closing });. Paste Part 1 and then Part 2 consecutively into that one file.

This replacement uses one locator named category and contains one sequence only: filter, verify the filtered catalog, click Day Pack once, verify detail, use browser history, and verify restoration. The controlled response remains before navigation.

Set up filtering and open the detail page

e2e/catalog.spec.ts — Part 1 of 2. Start the replacement file with this section.

typescript
import { test, expect } from '@playwright/test';

test('filters, opens detail, and restores the filter with Back', async ({ page }) => {
  await page.route('**/api/products', async route => {
    await route.fulfill({
      contentType: 'application/json',
      body: JSON.stringify([
        { slug: 'day-pack', name: 'Day Pack', category: 'bags' },
        { slug: 'trail-light', name: 'Trail Light', category: 'lighting' },
      ]),
    });
  });

  await page.goto('/products');

  const category = page.getByLabel('Category');
  await expect(page.getByRole('heading', { name: 'Products' })).toBeVisible();
  await category.selectOption('bags');

  await expect(page).toHaveURL(
    'http://127.0.0.1:5173/products?category=bags',
  );
  await expect(category).toHaveValue('bags');
  await expect(page.getByRole('link', { name: 'Day Pack' })).toBeVisible();
  await expect(page.getByRole('link', { name: 'Trail Light' })).toHaveCount(0);

  await page.getByRole('link', { name: 'Day Pack' }).click();

Verify detail and browser Back restoration

e2e/catalog.spec.ts — Part 2 of 2. Append this directly after Part 1, then save.

typescript
  await expect(page).toHaveURL(
    'http://127.0.0.1:5173/products/day-pack',
  );
  await expect(page.getByRole('heading', { name: 'Day Pack' })).toBeVisible();

  await page.goBack();

  await expect(page).toHaveURL(
    'http://127.0.0.1:5173/products?category=bags',
  );
  await expect(category).toHaveValue('bags');
  await expect(page.getByRole('link', { name: 'Day Pack' })).toBeVisible();
  await expect(page.getByRole('link', { name: 'Trail Light' })).toHaveCount(0);
});

Tip

Exercise actual browser history

page.goBack() activates browser history created by the link navigation. Do not replace it with the application's Catalog link: that would test a different navigation mechanism. The restored checks deliberately repeat the selected filter and matching results, because a restored address alone is not sufficient evidence.

Apply the complete navigation assertions

Complete the detail and Back checks

Complete the detail and browser-Back assertions for the existing Trail Shop journey.

typescript
await page.getByRole('link', { name: 'Day Pack' }).click();

await expect(page).(
  'http://127.0.0.1:5173/products/day-pack',
);
await expect(page.('heading', { name: 'Day Pack' })).toBeVisible();

await page.goBack();

await expect(page).(
  'http://127.0.0.1:5173/products?category=bags',
);
await expect(category).toHaveValue('bags');
await expect(page.getByRole('link', { name: 'Day Pack' })).toBeVisible();
await expect(page.getByRole('link', { name: 'Trail Light' })).(0);

State the regression this journey protects

Explain what URL-only verification misses

In 2–4 sentences, describe one concrete regression that could pass a URL-only assertion after browser Back. Then name the observable restored-state check that would reveal it.

Write at least 100 characters (0/100).

Step 7 of 10

Run and Compare Both Browser Engines

Run the controlled Trail Shop journey across both configured browser projects, distinguish headless and headed execution, and record each project's real outcome.

Execute the full suite

Run both configured projects

From journey-lab, run the complete suite once. With no --project filter, Playwright runs the same journey for every configured browser project: chromium and firefox.

The normal run is headless: browsers run without visible windows. Read the result lines carefully: each should be qualified by its project name. Two project-qualified results mean two executions; one result does not represent the other project.

Run all browser projects

Run this command from the journey-lab directory.

bash
npx playwright test
Expected output
Running 2 tests using 2 workers

  ✓  1 [chromium] › e2e/catalog.spec.ts:3:1 › filters, opens detail, and restores the filter with Back
  ✓  2 [firefox] › e2e/catalog.spec.ts:3:1 › filters, opens detail, and restores the filter with Back

  2 passed (4.2s)

Illustrative successful output only. Worker count, ordering, durations, and your actual pass/fail status can differ. Confirm that both `[chromium]` and `[firefox]` appear in your own result.

One suite, two executions

A local test runner sends the same product journey to two separate generic browser windows, each with its own result.

One test definition is executed separately by the Chromium and Firefox browser projects.

Choose a focused run

Select one project

Complete the command with either configured browser project name to run one project only.

bash
npx playwright test --project=

Observe without changing the test

Headless versus headed

A headed run opens the selected browser window so you can watch the same journey. It changes visibility, not the journey's locators, controlled response, actions, or assertions.

Use it when you need to observe a run locally. Keep the normal headless command for routine execution; do not alter expectations merely because a window is visible.

Run Firefox visibly

This example visibly runs the Firefox project. Replace only the project name when you intentionally need the Chromium project.

bash
npx playwright test --project=firefox --headed
Expected output
Running 1 test using 1 worker

  ✓  1 [firefox] › e2e/catalog.spec.ts:3:1 › filters, opens detail, and restores the filter with Back

  1 passed (3.1s)

The browser window is visible during this run. Worker count, terminal formatting, duration, and actual outcome vary; a successful run ends with one Firefox project result.

Warning

Do not misclassify a launch problem

If a browser project cannot launch, record it as not run or as a launch/environment error, not as a passing application journey. A missing browser binary or an operating-system environment problem prevents the test from reaching the application, so it is not evidence of an application defect either.

Compare actual outcomes

Interpret the recorded status

Use the project-qualified terminal lines to fill a small result matrix. Compare what actually happened, rather than assuming that a result from one project transfers to the other.

Outcome matrix

Classify each configured project independently after the run.

A project is evidence only when it executed.
Recorded statusWhat you can sayWhat you cannot say
PassThat project's journey executed and its assertions passed.The other project passed.
FailThat project's journey executed and reported a failure.The cause is already known.
Not run / launch errorNo completed journey result exists for that project.The project passed or the app failed.

Report both project outcomes

Run npx playwright test in journey-lab. Report the actual outcome for both chromium and firefox as pass, fail, or not run. If either project did not run, include the concise launch or environment reason shown locally. Do not infer an outcome for a project that has no result line.

Write at least 80 characters (0/80).

Step 8 of 10

Investigate a Project-Specific Failure

Preserve, inspect, and document local evidence for a selected Playwright browser-project discrepancy without mistaking an injected test failure for an engine defect.

Retain evidence on the first failure

Keep the first failure as evidence

Preserve the first failure

Do not add delays or retry the suite while investigating. A first-run failure is evidence; a later retry can pass and conceal the condition that needs inspection.

Replace playwright.config.ts with the complete configuration below. It retains the existing discovery, controlled local Vite server, shared viewport, and symmetric browser projects while adding diagnostic settings:

  • retries: 0 preserves the initial outcome;
  • the list reporter gives project-qualified terminal results;
  • the HTML reporter writes a local report to playwright-report/ without opening it automatically;
  • trace: 'retain-on-failure' keeps failure artifacts under test-results/.

A retained trace supports investigation of a failed run; it does not itself prove an engine defect.

playwright.config.ts

Replace the configuration so diagnostics are enabled without changing the local server or browser-project conditions.

typescript
import { defineConfig } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',
  testMatch: '**/*.spec.ts',
  retries: 0,
  reporter: [
    ['list'],
    ['html', { open: 'never' }],
  ],
  use: {
    baseURL: 'http://127.0.0.1:5173',
    viewport: { width: 1280, height: 720 },
    trace: 'retain-on-failure',
  },
  webServer: {
    command: 'npm run dev -- --host 127.0.0.1 --port 5173 --strictPort',
    url: 'http://127.0.0.1:5173',
    reuseExistingServer: false,
  },
  projects: [
    { name: 'chromium', use: { browserName: 'chromium' } },
    { name: 'firefox', use: { browserName: 'firefox' } },
  ],
});

Reproduce and inspect locally

Inspect the earliest divergence

Reproduce one selected project

Start with the browser project that actually failed. The commands below use Firefox as the selected example; use --project=chromium only when Chromium is the project with the recorded failure.

First reproduce visibly. Then inspect the report and trace as separate local viewer sessions. In the evidence, work backward from the failed assertion:

  1. Identify the last navigation, selection, click, or Back action in the action log.
  2. Compare the DOM snapshot with the expected rendered state.
  3. Confirm that /api/products received the controlled fulfilled response.
  4. Find the earliest divergence, rather than explaining only a later timeout.

Close each viewer tab or window when finished, then stop its terminal server with Ctrl+C before moving to the next command.

1. Reproduce Firefox visibly

Run this from journey-lab to reproduce the selected Firefox failure with a visible browser window. Close the browser window after observing the run.

bash
npx playwright test --project=firefox --headed
Expected output
Running 1 test using 1 worker

  ✘  1 [firefox] › e2e/catalog.spec.ts:3:1 › filters, opens detail, and restores the filter with Back (2.1s)

  1 failed
    [firefox] › e2e/catalog.spec.ts:3:1 › filters, opens detail, and restores the filter with Back

This is representative failed output. Timings and source locations vary. If the project passes, do not invent a defect; use the explicitly simulated workflow on the next screen instead.

2. Open the local HTML report

After a failure generates playwright-report/, run this command. Inspect the failed assertion and its attachments in the browser report. Close the report tab or window, then press Ctrl+C in this terminal to stop the local report server.

bash
npx playwright show-report
Expected output
Serving HTML report at http://localhost:9323. Press Ctrl+C to quit.

The local address and wording can vary. The command serves the existing report; it does not rerun the test.

3. Open the generated retained trace

Copy the trace path printed by your failed run, surround that real path with single quotes, and replace the example path below. For example, if the terminal printed a trace under test-results/, paste that complete generated path between the quotes. Inspect the action log, DOM snapshot, request evidence, and failed assertion. Then close the trace-viewer tab or window and press Ctrl+C in this terminal.

bash
npx playwright show-trace 'test-results/catalog-firefox-failure/trace.zip'
Expected output
Serving trace viewer at http://localhost:9323. Press Ctrl+C to quit.

The shown path is an example format only, not a path you should assume exists. The viewer address, port, and wording vary by machine.

4. Record the Playwright version

Run this separately and record the installed Playwright version in the discrepancy record.

bash
npx playwright --version
Expected output
Version 1.52.0

Your installed version will differ. Record the exact line from your computer.

5. Record the browser build from the fixture

Temporarily add browser to the existing journey test fixture and add this line near the start of its callback. Keep the existing route handler and journey assertions below it. Run the selected project once, record the printed build, then remove the diagnostic line.

typescript
test('filters, opens detail, and restores the filter with Back', async ({ page, browser }) => {
  console.log(`Browser build: ${browser.version()}`);

  // Keep the existing route handler and journey assertions here.
});
Expected output
Browser build: 136.0

This is representative terminal output; the actual browser build depends on the locally installed Playwright browser binary.

Connect report and trace evidence

Use the report and trace as connected evidence: an assertion says where the journey was observed to fail; the action log, snapshot, and controlled request help locate why it first diverged.

A four-panel local test investigation view showing a failed assertion, action timeline, DOM snapshot, and intercepted API response, with the earliest mismatch highlighted.

Inspect the failed assertion together with the action log, DOM snapshot, and /api/products request evidence.

Create a safe diagnostic fallback

Practice without claiming an engine bug

If both projects pass

A passing two-project run is not evidence of an engine-specific defect. You can still practice retaining and reading local artifacts with a deliberately injected test error.

Keep the successful all-project run as the baseline. Temporarily change only the URL expectation immediately after selecting the category, run only Firefox, and label the resulting failure as an injected test error. It is not an application or Firefox defect.

A responsible comparison keeps source conditions visible:

  • Correct baseline source: Chromium passed; Firefox passed.
  • Temporarily mutated source: Firefox failed as intended.

Those outcomes came from different test source, so they are not an engine comparison. After inspecting the new report and trace, restore the original correct expectation before the final two-project run.

Inject a clearly labeled test error

For the temporary diagnostic run only, complete the intentionally incorrect category in the URL expectation.

typescript
await category.selectOption('bags');

await expect(page).toHaveURL(
  'http://127.0.0.1:5173/products?category=',
);

Run the injected Firefox diagnostic

Run this temporary diagnostic only after making the intentional mutation. Inspect the generated local report and actual trace path using the separate viewer operations from the previous screen. Restore the original expectation when finished.

bash
npx playwright test --project=firefox
Expected output
Running 1 test using 1 worker

  ✘  1 [firefox] › e2e/catalog.spec.ts:3:1 › filters, opens detail, and restores the filter with Back

  1 failed
    [firefox] › e2e/catalog.spec.ts:3:1 › filters, opens detail, and restores the filter with Back

This representative failure is expected only while the temporary incorrect expectation remains in the test source. Your trace path will be generated locally and must be copied from your own failure output.

Write an evidence-based failure record

Fields for a concise discrepancy record

Capture only evidence you actually observed. The report directory is playwright-report/; for a trace, record the exact generated path under test-results/ rather than guessing a filename. Record your operating system from your own computer and the fixed viewport as 1280 × 720.

Use concrete local evidence for each field.
FieldWhat to record
ExecutionSelected project and exact reproduction command
VersionsBrowser build from `browser.version()` and Playwright version
EnvironmentOperating system and 1280 × 720 viewport
Evidence`playwright-report/` and actual generated trace path
First divergenceExpected versus observed action, URL, or rendered state
Next checkOne testable application, assumption, or environment investigation

Warning

Investigate causes before changing synchronization

Do not mask a discrepancy with an arbitrary sleep, a forced action, or blanket retries. First test a concrete explanation: inspect whether the application rendered an unexpected state, whether a test assumption conflicts with the snapshot, or whether the local environment changed the run. An injected incorrect expectation must remain labeled as injected evidence, not an engine bug.

Document the discrepancy

Using a real project-specific failure or the explicitly injected diagnostic failure, write a concise local discrepancy record in about 6–10 lines.

Include the selected browser project, browser build, Playwright version, operating system, viewport, and exact reproduction command. State whether the evidence was real application behavior or an injected incorrect expectation. Name playwright-report/ and the actual retained trace path you inspected. Describe the first divergence with expected versus observed behavior, then give one justified next investigation.

Do not claim an engine bug unless equivalent source and controlled conditions support that conclusion.

Write at least 400 characters (0/400).

Step 9 of 10

State What the Engine Checks Do Not Prove

Define exactly what the Chromium and Firefox project results establish, then identify the actual-browser or device evidence that remains necessary.

Bound the two-project evidence

Read the result at the right scope

Your two Playwright projects are useful evidence: the same Trail Shop journey ran in Playwright’s installed Chromium and Firefox builds at the configured viewport. That is an engine check under a controlled local condition.

It is not a blanket compatibility claim. A browser engine is not the same thing as every actual target browser, version, operating system, or physical device used by people. A Chromium pass does not prove all supported Chrome or Edge configurations; a Playwright Firefox pass does not prove every target Firefox installation.

From engine checks to target coverage

A diagram shows two checked local browser-engine windows leading to a larger, partly unchecked set of desktop and mobile target environments.

The checked local engines are valuable evidence, but they are only a subset of the environments that may matter.

Tip

Precision matters

Do not rename an engine result as a branded-browser result. Record the project that ran, then state separately which actual browser and device combinations still need evidence.

Choose evidence based on real targets

Turn gaps into checkable targets

Use support requirements and the application audience to choose the next checks. Each proposed check should identify a browser version, operating system, device when relevant, and a task—not only a browser name.

What each result establishes

A passing journey is one piece of evidence. Keep these boundaries visible in your verification record.

Evidence boundaries for the Trail Shop journey
Observed evidenceIt does not establishNext evidence to seek
Chromium and Firefox projects passEvery shipped Chrome, Edge, or Firefox configuration passesA supported browser/version and OS combination
Controlled /api/products responseLive backend availability, authorization, or API contract behaviorA separate live-service check where appropriate
Accessible locators find controlsAccessibility conformance or usable assistive-technology behaviorLater accessibility scans and manual task audits
Desktop-sized 1280 × 720 viewportPhysical mobile-device behavior or Safari/iOS behaviorA target device and browser task check

Warning

Do not overextend the matrix

Safari and iOS are not covered by the Chromium and Firefox projects. Even an engine project or device emulation would not reproduce every actual-browser version, operating-system integration, network condition, input method, or physical-device characteristic.

Practice making bounded claims

Classify the evidence

Match each statement to the kind of conclusion it supports.

Evidence matching

Match each statement with its appropriate conclusion.

Tap an item and then its matching pair.

State the remaining target check

Write the bounded coverage record

Write a short coverage statement for the completed two-project run. First, state what it establishes. Then name one still-required actual-browser or device check, including its browser and version, operating system, device if relevant, and the Trail Shop task to perform. Also name one claim this check set does not establish.

Write at least 180 characters (0/180).

Step 10 of 10

Complete the Local Verification Record

Restore the complete local configuration and journey, run it in both browser projects, and document results and remaining coverage honestly.

Restore the repeatable baseline

Restore the repeatable baseline

After any investigation, restore the correct assertion if you temporarily changed one. Then keep one stable baseline: Playwright discovers only e2e/*.spec.ts, starts the local Trail Shop server itself, and runs the same suite in two browser projects.

Save this complete reference as playwright.config.ts. It retains failure evidence without opening the HTML report automatically.

A workflow showing one local test suite and server feeding two browser-engine runs, followed by a verification record.

The final evidence comes from one restored journey run in each browser project, not from separate engine-specific test logic.

Complete reference configuration

playwright.config.ts

typescript
import { defineConfig } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',
  testMatch: '**/*.spec.ts',
  retries: 0,
  reporter: [
    ['list'],
    ['html', { open: 'never' }],
  ],
  use: {
    baseURL: 'http://127.0.0.1:5173',
    viewport: { width: 1280, height: 720 },
    trace: 'retain-on-failure',
  },
  webServer: {
    command: 'npm run dev -- --host 127.0.0.1 --port 5173 --strictPort',
    url: 'http://127.0.0.1:5173',
    reuseExistingServer: false,
  },
  projects: [
    { name: 'chromium', use: { browserName: 'chromium' } },
    { name: 'firefox', use: { browserName: 'firefox' } },
  ],
});

Restore the complete journey

Keep one complete, engine-neutral journey

Create or restore e2e/catalog.spec.ts from the two consecutive sections below. The API handler is registered before navigation. The test has no browser-project condition: identical actions and expectations run in Chromium and Firefox.

This controlled response exercises Trail Shop's real fetch, filtering, rendering, route, and history behavior. It does not verify a live backend.

Journey setup and filtered catalog checks

e2e/catalog.spec.ts — Part 1 of 2

typescript
import { test, expect } from '@playwright/test';

const products = [
  { slug: 'day-pack', name: 'Day Pack', category: 'bags' },
  { slug: 'trail-light', name: 'Trail Light', category: 'lighting' },
];

test('restores the filtered catalog after detail and Back', async ({ page }) => {
  await page.route('**/api/products', (route) =>
    route.fulfill({
      contentType: 'application/json',
      body: JSON.stringify(products),
    }),
  );

  await page.goto('/products');
  await expect(page.getByRole('heading', { name: 'Products' })).toBeVisible();

  const category = page.getByLabel('Category');
  await category.selectOption('bags');

  await expect(page).toHaveURL(
    'http://127.0.0.1:5173/products?category=bags',
  );
  await expect(category).toHaveValue('bags');
  await expect(page.getByRole('link', { name: 'Day Pack' })).toBeVisible();
  await expect(page.getByRole('link', { name: 'Trail Light' })).toHaveCount(0);

Detail navigation and browser Back checks

e2e/catalog.spec.ts — Part 2 of 2; paste directly after Part 1

typescript
  await page.getByRole('link', { name: 'Day Pack' }).click();

  await expect(page).toHaveURL('http://127.0.0.1:5173/products/day-pack');
  await expect(page.getByRole('heading', { name: 'Day Pack' })).toBeVisible();

  await page.goBack();

  await expect(page).toHaveURL(
    'http://127.0.0.1:5173/products?category=bags',
  );
  await expect(category).toHaveValue('bags');
  await expect(page.getByRole('link', { name: 'Day Pack' })).toBeVisible();
  await expect(page.getByRole('link', { name: 'Trail Light' })).toHaveCount(0);
});

Record only what the final run establishes

Rerun and compare final evidence

From journey-lab, run the restored suite once across all configured browser projects. Do not treat a project that was not run as a pass. Compare this final evidence with the earlier diagnostic run: a prior injected incorrect expectation must no longer be present.

If a real discrepancy remains, preserve its project-qualified outcome and reference the relevant local evidence in playwright-report/ or the generated artifact under test-results/. Do not guess a trace filename; use the path produced by the failing run.

Final two-engine command

Run both configured browser projects from journey-lab.

bash
npx playwright test
Expected output
Running 2 tests using 2 workers

  ✓  1 [chromium] › e2e/catalog.spec.ts:8:1 › restores the filtered catalog after detail and Back (1.2s)
  ✓  2 [firefox] › e2e/catalog.spec.ts:8:1 › restores the filtered catalog after detail and Back (1.4s)

  2 passed (3.0s)

This is an illustrative all-pass result. Timing and source locations vary. Record the actual project-qualified outcome shown on your computer; a failure or an unexecuted project is not an all-pass result.

Verification record structure

Use this compact structure for your local record. Replace every bracketed value with evidence from your own final run.

A concise record separates executed evidence from coverage that still remains.
Record fieldWhat to write
Final commandThe exact `npx` command you ran
Project outcomesChromium: [pass/fail/not run]; Firefox: [pass/fail/not run]
ComparisonWhether the final run differs from the earlier diagnostic run
DiscrepancyNone, or project, observed behavior, expected behavior, and local report/trace evidence
Remaining checksSpecific actual browser/version, operating system, device, and user task still to check
Scope statementControlled API response; no claim of live-backend verification or accessibility conformance

Summary

Final review

A finished local verification is evidence, not a universal compatibility claim.

  • The restored API route handler precedes navigation in one independent journey.
  • The filter, detail URL, visible detail heading, and browser Back restoration are asserted in both browser projects.
  • Run npx playwright test to execute Chromium and Firefox together, then record each project-qualified outcome.
  • A retained trace or HTML report is evidence for a failure, not proof that the failure is engine-specific.
  • Installed engine checks do not replace planned checks in actual target browsers, operating systems, or devices.

Submit your evidence-based conclusion

Submit the final local verification record

Run the restored final journey locally, then submit your concise verification record. Include the exact command, the actual Chromium and Firefox outcomes, comparison with any earlier diagnostic run, and either the investigated discrepancy or a clear statement that none occurred. Name remaining actual-browser or device checks with a browser/version, operating system or device, and task. End with a scope statement that does not claim live-backend verification or accessibility conformance.

Write at least 350 characters (0/350).

Tutorial completed

Congratulations! You completed: Run Local Browser Journeys with Playwright

You completed the local two-engine journey workflow and documented its evidence boundary.

50 XP

Download the App now to have access to + 5000 free courses, exercises, certificates and lots of content without paying anything!

  • 100% free online courses from start to finish

    Thousands of online courses in video, ebooks and audiobooks.

  • More than 60 thousand free exercises

    To test your knowledge during online courses

  • Valid free Digital Certificate with QR Code

    Generated directly from your cell phone's photo gallery and sent to your email

Cursa app on the ebook screen, the video course screen and the course exercises screen, plus the course completion certificate