Mert Özen Further With Every Line
Testing & Quality Integration and E2E Testing — #06

Getting Started with Playwright: Playwright Test or Playwright for .NET, Locators, Auto-Waiting and Trace Viewer

Mert Özen Nov 14, 2026 14 min 1 views
Getting Started with Playwright: Playwright Test or Playwright for .NET, Locators, Auto-Waiting and Trace Viewer

Playwright waits for the element before an action but not for the action's result; leave the result to web-first assertions built with expect(locator). For a .NET + Angular team we compare Playwright Test with Microsoft.Playwright and show with real runs how to write locators from the user's point of view with getByRole and getByLabel, strict mode, codegen, and reading a failed test in the trace viewer.

Version note: The code in this post was built and run with .NET 10, Angular 22.2, Playwright 1.65 (@playwright/test), Microsoft.Playwright 1.64.1, xUnit.net v3 4.0.3, Testcontainers for .NET 4.16 and SQL Server 2025.

Guess First

The catalog page of Bookstore's Angular UI lists every book when it opens; when you type something into the search box and press "Search", the list is refreshed with the result from the API. The test database holds four books: Dune, Emma, The Hobbit and Middlemarch. You wrote your first Playwright test like this:

test('count read once', async ({ page }) => {
  await page.goto('/');
  await page.getByLabel('Search books').fill('dune');
  await page.getByRole('button', { name: 'Search' }).click();

  const results = page.getByRole('list', { name: 'Search results' }).getByRole('listitem');
  expect(await results.count()).toBe(1);
});

You have heard that Playwright waits before every action; the click has finished and the result list is read on the next line. Does the test pass? If it fails, what number did count() return?

The Answer: Playwright Waits for the Action, Not for Its Result

The test failed:

  ✘  1 [chromium] › e2e/demo06.spec.ts:3:1 › count read once (474ms)

    Error: expect(received).toBe(expected) // Object.is equality

    Expected: 1
    Received: 4

Four is the length of the list before the search. Playwright waited for the button to be visible, stable and clickable, then clicked it; but waiting for the HTTP request the click triggers and for Angular to re-render the list is not its job. count() is a one-off read that counts whatever is in the DOM at that moment. Angular 22 runs zoneless, so change detection is scheduled for the next turn; when the count was taken, the old four books were still on the screen.

The right move is to leave the waiting to the assertion:

await expect(results).toHaveCount(1);

Playwright calls assertions built with expect(locator) "web-first assertions": they re-evaluate the locator until the condition holds, for 5 seconds by default. With this line the same test passed on every run. The rest of this post builds on that distinction: which language to use Playwright from, how to choose locators and what to look at when a test fails.

Which Playwright: Playwright Test or Microsoft.Playwright?

The first decision for a .NET + Angular team is the language. Playwright offers the same browser automation for TypeScript, .NET, Java and Python, but only the Node.js side has its own test runner. That runner, Playwright Test, includes fixtures, parallel workers, retries, an HTML report, the webServer setting that brings the application up before the tests, and visual comparison. The .NET package Microsoft.Playwright is a library; xUnit, NUnit or MSTest runs the tests. The same search test in C# looks like this:

public sealed class CatalogTests(BrowserFixture fixture) : IAsyncLifetime
{
    private IBrowserContext context = null!;
    private IPage page = null!;

    public async ValueTask InitializeAsync()
    {
        context = await fixture.Browser.NewContextAsync(new BrowserNewContextOptions { BaseURL = fixture.BaseUrl });
        page = await context.NewPageAsync();
    }

    [Fact]
    public async Task Search_finds_a_book_with_a_lowercase_term()
    {
        await page.GotoAsync("/");

        await page.GetByLabel("Search books").FillAsync("dune");
        await page.GetByRole(AriaRole.Button, new() { Name = "Search" }).ClickAsync();

        ILocator results = page.GetByRole(AriaRole.List, new() { Name = "Search results" }).GetByRole(AriaRole.Listitem);
        await Expect(results).ToHaveCountAsync(1);
        await Expect(results.GetByRole(AriaRole.Heading)).ToHaveTextAsync("Dune");
    }

    public async ValueTask DisposeAsync() => await context.DisposeAsync();
}

BrowserFixture is an xUnit v3 assembly fixture like the SQL Server fixture from post 1: it opens Playwright and Chromium once, and each test gets its own BrowserContext. The API and the locators are exactly the same. The difference showed up in the toolchain. The first run happened while the Angular application was not running and ended with this error:

Microsoft.Playwright.PlaywrightException : net::ERR_CONNECTION_REFUSED at http://localhost:4200/
Call log:
  - navigating to "http://localhost:4200/", waiting until "load"

On the xUnit side there is no ready-made mechanism that starts the Angular dev server and the API; you either start them by hand in separate terminals or write process management in a fixture. With the application up, the test passed in 705 milliseconds. The second difference is in the versions: at the time of writing, @playwright/test 1.65.0 is out on npm, while Microsoft.Playwright on NuGet is at 1.64.1. The two packages want different browser builds; when I ran the .NET test against the browser folder of 1.65, this box appeared:

Microsoft.Playwright.PlaywrightException : Executable doesn't exist at .../chromium_headless_shell-1248/...
╔════════════════════════════════════════════════════════════╗
║ Looks like Playwright was just installed or updated.       ║
║ Please run the following command to download new browsers: ║
║                                                            ║
║     pwsh bin/Debug/netX/playwright.ps1 install             ║
║                                                            ║
║ <3 Playwright Team                                         ║
╚════════════════════════════════════════════════════════════╝

Every package version wants its own browser revision (Chromium revision 1248 for 1.64.1, 1254 for 1.65.0). A team using both languages on the same machine has to download and keep two sets of browsers up to date.

TopicPlaywright Test (TypeScript)Microsoft.Playwright + xUnit
Where the code livesnext to the Angular repo, same package.jsonseparate test project in the .NET solution
Starting the applicationwebServer settingby hand or with your own fixture code
Visual comparisontoHaveScreenshotnone
Report, UI mode, shardingbuilt into the runnertrace exists; report and sharding are left to CI
Version at the time of writing1.65.01.64.1
Closer tothe Angular developerthe backend developer

In this series I write the E2E tests in TypeScript with Playwright Test. Three things decide it: webServer, which brings the application up before the tests (the topic of post 7); toHaveScreenshot, which the .NET package does not have (post 8); and keeping the tests in the same repo and the same pull request as the screens they break. If the team is made up only of backend developers and there are few E2E tests, the .NET side is a reasonable choice too; everything you know about locators and assertions is the same in both languages.

The Angular CLI side needs a look as well. When the project has no e2e target, ng e2e says this:

$ npx ng e2e
Cannot find "e2e" target for the specified project.
You can add a package that implements these capabilities.

For example:
  Playwright: ng add playwright-ng-schematics
  Cypress: ng add @cypress/schematic
  Nightwatch: ng add @nightwatch/schematics
  WebdriverIO: ng add @wdio/schematics
  Puppeteer: ng add @puppeteer/ng-schematics

The Angular CLI no longer ships with an E2E framework; the package it suggests for Playwright is the community-maintained playwright-ng-schematics. It adds a builder that wires the ng e2e command to Playwright. I did not need it in Bookstore: npm run e2e runs playwright test directly, and an Angular upgrade does not have to wait for a new version of a community builder.

Setup and the First Tests

The Angular project lives in the web folder from post 5; it is now a full Angular 22.2 application: the catalog page, the cart and the "Cart (n)" link in the header. Playwright is added as a devDependency with a pinned version:

"scripts": {
  "start": "ng serve",
  "e2e": "playwright test"
},
"devDependencies": {
  "@playwright/test": "1.65.0"
}

The browsers are downloaded once with npx playwright install chromium. The config file:

import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  reporter: [['list'], ['html', { open: 'never' }]],
  use: {
    baseURL: 'http://localhost:4200',
    trace: 'retain-on-failure',
    screenshot: 'only-on-failure',
  },
  projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
  webServer: [
    {
      name: 'api',
      command: 'dotnet run --project ../tests/Bookstore.E2E.Host',
      url: 'http://127.0.0.1:5280/api/books',
      reuseExistingServer: !process.env.CI,
      timeout: 180_000,
      gracefulShutdown: { signal: 'SIGTERM', timeout: 10_000 },
    },
    {
      name: 'web',
      command: 'npm start',
      url: 'http://localhost:4200',
      reuseExistingServer: !process.env.CI,
    },
  ],
});

The webServer array starts two processes: a small .NET host that opens the SQL Server container and runs the API on Kestrel at port 5280, and ng serve. The Angular dev server forwards /api requests to that port with a proxy setting. How the host is written, why it uses WebApplicationFactory and the surprises along the way are the topic of post 7; for this post it is enough to know that the application is up. Thanks to reuseExistingServer, if you keep both processes open in separate terminals, Playwright does not restart them; that is how I work locally so I don't wait for a container on every run while writing a test.

The first three tests are three things a user does in the catalog:

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

test('search finds a book with a lowercase term', async ({ page }) => {
  await page.goto('/');

  await page.getByLabel('Search books').fill('dune');
  await page.getByRole('button', { name: 'Search' }).click();

  const results = page.getByRole('list', { name: 'Search results' }).getByRole('listitem');
  await expect(results).toHaveCount(1);
  await expect(results.getByRole('heading')).toHaveText('Dune');
});

test('adding a book updates the cart link', async ({ page }) => {
  await page.goto('/');

  const emma = page.getByRole('listitem').filter({ has: page.getByRole('heading', { name: 'Emma' }) });
  await emma.getByRole('button', { name: 'Add to cart' }).click();

  await expect(page.getByRole('link', { name: 'Cart (1)' })).toBeVisible();
  await expect(page.getByRole('status')).toHaveText('Emma added to cart');
});

test('an out-of-stock book cannot be added', async ({ page }) => {
  await page.goto('/');

  const hobbit = page.getByRole('listitem').filter({ hasText: 'The Hobbit' });
  await expect(hobbit.getByText('Out of stock')).toBeVisible();
  await expect(hobbit.getByRole('button', { name: 'Add to cart' })).toBeDisabled();
});

Each test gets its own page, and Playwright opens a new browser context for every test: cookies, localStorage and the cart signal in Angular do not carry over between tests. That is why the add-to-cart test can expect "Cart (1)". I ran the three tests five times each with five workers:

$ npx playwright test --repeat-each 5 --reporter=line
Running 15 tests using 5 workers
[1/15] [chromium] › e2e/catalog.spec.ts:14:1 › adding a book updates the cart link
...
[15/15] [chromium] › e2e/catalog.spec.ts:24:1 › an out-of-stock book cannot be added
  15 passed (3.8s)

Locators from the User's Point of View

There is no CSS class or XPath in the tests. The order Playwright recommends follows how the user perceives the page: first getByRole (role and accessible name), getByLabel for form fields, getByText for text without a role, and getByTestId last. getByRole('button', { name: 'Search' }) finds a button by the name a screen reader announces. For this locator to break, the button's text or role has to change, which means something the user sees. Renaming the class from btn-primary to something else does not affect the test.

A side effect of this order is useful too: getByLabel('Search books') only works if the input is tied to a label. A form field with a forgotten label shows up in the test first. It does not replace an accessibility check, but it makes inaccessible markup visible while you write the test.

Locators are strict: the target of an action must resolve to a single element. With four cards in the catalog, looking for the button by its name alone gave this:

Error: locator.click: Error: strict mode violation: getByRole('button', { name: 'Add to cart' }) resolved to 4 elements:
    1) <button type="button" class="btn-primary"> Add to cart </button> aka getByRole('listitem').filter({ hasText: 'Dune₺64.907 in stock Add to' }).getByRole('button')
    2) <button type="button" class="btn-primary"> Add to cart </button> aka getByRole('listitem').filter({ hasText: 'Emma₺39.503 in stock Add to' }).getByRole('button')
    3) <button type="button" class="btn-primary"> Add to cart </button> aka getByRole('listitem').filter({ hasText: 'Middlemarch₺48.755 in stock' }).getByRole('button')
    4) <button disabled type="button" class="btn-primary"> Add to cart </button> aka getByRole('listitem').filter({ hasText: 'The Hobbit₺52.00Out of stock' }).getByRole('button')

The error is much better than clicking the first match and putting the wrong book in the cart. Playwright also suggests a locator for each match, but the suggested hasText values include the price and stock text; they break when the price changes. The fix in the test is to narrow the card down by the book's title: filter({ has: page.getByRole('heading', { name: 'Emma' }) }), then look for the button inside the card. If you want it shorter, filter({ hasText: 'The Hobbit' }) also does the job.

A test id belongs where role and name tell the user nothing: the drawing area of a chart, elements that carry the same text and differ only by position, or a section whose text changes often through A/B tests and translations. By default Playwright looks at the data-testid attribute; it can be changed with testIdAttribute in the config. I have not used one in Bookstore so far; every test id adds a contract to the markup that the user never sees.

Auto-Waiting and Web-First Assertions

Before an action, Playwright runs actionability checks on the element: for click it waits for the element to be visible, stable (its animation finished), able to receive events (no other element covering it), enabled and, since 1.65, not inert; for fill it waits for it to be visible, enabled and editable. That is why there is no "wait for the page to load" or "wait for the button to be enabled" line in the test.

The waiting is limited to the action. The failure in the guessing section came from a line that read the state once after the action. Fixing it with "let's wait a bit" is tempting:

await page.getByRole('button', { name: 'Search' }).click();
await page.waitForTimeout(2000);
expect(await results.count()).toBe(1);

This test passed but took 2.3 seconds; the version with the web-first assertion does the same job in about 350 milliseconds. Time is not the real problem either: 2 seconds may not be enough when the CI runner is slow or the API starts cold, and on every run where it is enough, the rest is wasted. Assertions such as toHaveCount, toHaveText, toBeVisible, toBeDisabled and toHaveURL return the moment the condition is met. The rule is simple: pass a locator or a page into expect, not a value. When you see an awaited read (count(), textContent(), isVisible()), rethink that line.

Starting with Codegen, Fixing by Hand

npx playwright codegen http://localhost:4200 opens a browser and the Playwright Inspector next to it; every click and every piece of text you type on the page turns into test code. When choosing locators, codegen uses the same priority: role, label, text, test id. The "Pick locator" button in the Inspector shows the locator Playwright would generate for the element you hover over on the page; I use this feature most when adding a line to an existing test. The Visual Studio Code extension can do the same recording inside the editor.

What codegen produces is a starting point, not a test. The fixes I make after recording are always the same: turning the full address in page.goto into a path relative to baseURL, replacing the assertions the recording did not add, or limited to visibility, with the test's real claim, deleting needless clicks made while recording, replacing random recorded data with the test's own data, and collecting lines that look up the same card several times into a single locator variable.

Reading a Failed Test: Trace Viewer, Screenshots and UI Mode

Two lines in the config leave a trail behind a failing test: trace: 'retain-on-failure' and screenshot: 'only-on-failure'. When the test from the guessing section failed, the list reporter's output continued like this:

    attachment #1: screenshot (image/png) ──────────────────────────────────────────────────────────
    test-results/demo06-count-read-once-chromium/test-failed-1.png
    ────────────────────────────────────────────────────────────────────────────────────────────────

    Error Context: test-results/demo06-count-read-once-chromium/error-context.md

    attachment #3: trace (application/zip) ─────────────────────────────────────────────────────────
    test-results/demo06-count-read-once-chromium/trace.zip
    Usage:

        npx playwright show-trace test-results/demo06-count-read-once-chromium/trace.zip

The trace file was 95 KB. The trace viewer opened with show-trace walks through the test step by step: the list of actions on the left, a before and after DOM snapshot for each action (you can inspect it like a live page), which element the action was applied to, the API requests and their timings on the network tab, console messages and the matching line of test code. Seeing the guessing-section failure here takes a minute: in the "after" snapshot of the click action the list still has four books, and the /api/books?search=dune request finished after the count.

error-context.md is a file added in recent Playwright versions: it contains the error message and the page's accessibility tree at that moment as YAML. It is meant for asking an AI assistant "why did this test fail?", but for a human it also reads much faster than a screenshot; lines in the tree such as link "Cart (0)" are directly useful when writing locators.

Choosing a trace mode is a cost decision. 'on' records a trace for every test and slows the suite down. The common choice for CI is 'on-first-retry': a test fails on the first attempt and, if it is retried, the second attempt is recorded. Locally retries are off, so 'retain-on-failure' is more practical; every test is recorded but only the files of failing ones are kept. Video has the same options (video: 'retain-on-failure'); since the trace already carries step-by-step snapshots, I only turn on video when I need to watch a failure related to animation or timing.

While writing tests, the UI mode opened with npx playwright test --ui gives a faster loop: picking and running tests from a list, a watch mode that reruns when a file is saved, the same trace view and picking locators from the page, all in one window. --debug steps through the test line by line in the Inspector. In CI the trace of a failed test is attached to the HTML report; once the report is uploaded as an artifact, it can be opened locally with npx playwright show-report.

In Production

Pin the Playwright version and update the browsers together with it. Each version of @playwright/test is tested against specific browser builds; writing ^1.65.0 and pulling a different minor version in CI silently changes the browser download step and the behavior. In CI the browsers must be installed with npx playwright install --with-deps chromium, or Playwright's official Docker image must be used; on GitHub Actions these steps are in the Angular CI post of the GitHub Actions series.

Measure the duration. These three tests finish in under a second on their own, but the total time of the suite is decided by starting the application: the SQL Server container, the migration, the API and the first compilation of ng serve. Locally, reuseExistingServer makes you pay that cost once; in CI every run starts from zero. E2E tests are more expensive than integration tests, so there should be few of them and the flows they cover should matter. Which flow is worth an E2E test is the topic of the Test Strategy series.

Know the default timeouts: 30 seconds per test, 5 seconds per expect. Raising timeouts for a slow CI environment is sometimes necessary, but fixing a test that fails locally with a timeout only postpones the problem to the next run.

Common Pitfalls

Tying tests to CSS and XPath selectors. page.locator('.book-card > button.btn-primary') works today; it fails when the designer wraps the button in a div or renames the class, even though nothing has changed for the user. Worse, a CSS selector narrowed to a single element with .first() clicks the wrong book when the order of the cards changes, and the test can pass while verifying something else.

Waiting with waitForTimeout. In the measurement above, a two-second wait made the test six times slower and still gives no guarantee. The Playwright documentation also lists this method as discouraged and says tests that wait for time are inherently flaky. If there is a condition you need to wait for, write it as a web-first assertion; if you cannot find a condition to wait for, the test does not yet say what it verifies.

Committing codegen output as it is. A recorded test stays tied to the recorder's data on that day, the positions on the page and the order of clicks; its assertions are usually incomplete as well. A test that goes into a pull request without the fixes above becomes a test that fails on the first data change and that nobody understands.

Testing only in Chromium and saying "tested across browsers". Bookstore's config has a single project on purpose; adding Firefox and WebKit multiplies the run time. Which browsers you support is a product decision; every project you add should be the test counterpart of that decision.

A Small Experiment

★☆☆ Write a copy of the add-to-cart test with getByRole('button', { name: 'Add to cart' }).nth(1) and check only the "Cart (1)" link. Then add "Anna Karenina" to the seed data in the host. Which of the two tests failed, and which passed by putting the wrong book in the cart?

★★☆ Run the test from the guessing section with a trace and find in the trace viewer whether the /api/books?search=dune request finished before or after count(). Write the same test with toHaveCount and compare the two traces.

★★★ Move the add-to-cart test into the C# test project too, with the same locators. Compare the duration of the test in the two languages and the information you get from a failing test (report, trace, screenshot); what do you need to write on the .NET side to keep traces only for failing tests?

The tests walk around the catalog, but we used the host that brings up the database, the API and Angular as a closed box. In the next post we open that box: SQL Server with Testcontainers, the API on a real port with WebApplicationFactory.UseKestrel and Angular with webServer; then we test the flow of searching for a book, adding it to the cart, applying a coupon and placing an order end to end, preparing the test data through the API.