End-to-End Testing with Playwright
Key Points
- Microsoft.Playwright for .NET is the modern E2E choice in 2026. Selenium still works but is legacy — Playwright bakes in auto-wait, single API across Chromium/Firefox/WebKit, and built-in trace viewer.
- Browser contexts provide cheap isolation: one browser instance, many independent contexts (each like an "incognito profile" — own cookies, storage, cache).
- Storage state file is the canonical pattern for auth: log in once, persist
state.json, reuse across tests. No re-login per test. - Selector priority:
GetByTestId(data-testid) >GetByRole>GetByText> CSS/XPath. CSS selectors break under any restyle. page.RouteAsyncmocks network for deterministic tests — intercept third-party calls, force errors, freeze time-sensitive endpoints.- Trace viewer (
trace.zipopened innpx playwright show-trace) shows DOM snapshots, network, console, and source for every action. Single best debugging tool in E2E. - CI: install browser binaries with
playwright install --with-deps, run headless, parallelize per file. Capture screenshots/video/trace on failure only. - E2E tests are the top of the pyramid — slow, brittle, expensive. Cover the critical user journeys, not every form field.
Concepts (deep dive)
Why Playwright over Selenium
Selenium has been the default since 2010. In 2026, for new .NET projects, Playwright wins on:
| Capability | Selenium | Playwright |
|---|---|---|
| Auto-wait on actions | Manual WebDriverWait | Built-in |
| Cross-browser API | One driver per browser | Single API |
| Driver mismatch | Frequent (chromedriver vs Chrome) | No drivers — bundled binaries |
| Network interception | Limited (CDP add-on) | First-class RouteAsync |
| Trace/debugger | Third-party | Built-in trace viewer |
| Mobile emulation | Limited | First-class device descriptors |
| .NET maturity | Mature | Mature (since 2021) |
Selenium is fine for legacy suites — don't rewrite working tests just for fashion. For greenfield E2E, pick Playwright.
Install
dotnet add package Microsoft.Playwright
dotnet add package Microsoft.Playwright.NUnit # or Microsoft.Playwright.Xunit
dotnet build
pwsh bin/Debug/net8.0/playwright.ps1 install # downloads browser binaries
# or in CI: dotnet tool install --global Microsoft.Playwright.CLI && playwright install --with-deps
The playwright install step downloads Chromium, Firefox, and WebKit binaries (~400 MB). Cache them in CI.
First test (NUnit)
using Microsoft.Playwright.NUnit;
public class HomePageTests : PageTest
{
[Test]
public async Task Home_renders_title()
{
await Page.GotoAsync("https://example.com");
await Expect(Page).ToHaveTitleAsync("Example Domain");
}
}
PageTest base class (NUnit) gives you a fresh IPage per test. xUnit equivalent: PageTest from Microsoft.Playwright.Xunit or roll your own fixture.
Browser, context, page — the hierarchy
Browser (1 process — Chromium / Firefox / WebKit)
└── BrowserContext (isolated profile: cookies, storage, cache)
└── Page (single tab/window)
Open one browser per test session, many contexts inside it (each test gets its own context). Contexts are cheap (~10 ms); browsers are expensive (~500 ms).
Auth: storage state pattern
Most apps require login. Logging in via UI on every test is slow and flaky. Better: log in once, save the cookies + localStorage to a file, reuse it.
// One-time setup (global setup hook):
var ctx = await browser.NewContextAsync();
var page = await ctx.NewPageAsync();
await page.GotoAsync("/login");
await page.FillAsync("[data-testid=email]", "[email protected]");
await page.FillAsync("[data-testid=password]", "secret");
await page.ClickAsync("[data-testid=login-btn]");
await page.WaitForURLAsync("**/dashboard");
await ctx.StorageStateAsync(new() { Path = "state.json" }); // 💡 persist
// Per-test reuse:
var ctx = await browser.NewContextAsync(new() { StorageStatePath = "state.json" });
var page = await ctx.NewPageAsync();
await page.GotoAsync("/dashboard"); // already logged in
For multi-role tests, generate one state file per role (admin.json, member.json) and pick at test time.
Selectors — the gold standard hierarchy
// ✅ Best: test-id (immune to styling changes)
await page.GetByTestId("submit-order").ClickAsync();
// ✅ Good: ARIA role (also accessibility-friendly)
await page.GetByRole(AriaRole.Button, new() { Name = "Submit" }).ClickAsync();
// ⚠️ OK: visible text (breaks on i18n / copy changes)
await page.GetByText("Submit Order").ClickAsync();
// ❌ Brittle: CSS selectors (break on any restyle)
await page.Locator(".btn-primary.col-md-4").ClickAsync();
// ❌❌ Worst: XPath
await page.Locator("//div[3]/form/button").ClickAsync();
Add data-testid attributes in components purely for tests. They're the cheapest "test API" you'll ever build.
Auto-wait — the killer feature
Selenium's pain: clicking a button before it's enabled, asserting text before the AJAX completes. Playwright auto-waits on every action:
await page.ClickAsync("[data-testid=submit]");
// Playwright internally waits for the element to be:
// - attached to DOM
// - visible
// - stable (not animating)
// - receiving events (not covered)
// - enabled
// Then clicks. No explicit wait needed.
Default timeout is 30s. If the element never satisfies the conditions, the test fails with a clear error pointing to which check failed.
Network mocking
Deterministic tests demand control over flaky third-party calls (analytics, feature flags, payment gateways).
// Block all analytics
await page.RouteAsync("**/analytics.com/**", route => route.AbortAsync());
// Mock the feature-flag endpoint
await page.RouteAsync("**/api/flags", route => route.FulfillAsync(new()
{
Status = 200,
ContentType = "application/json",
Body = """{"newCheckout": true}"""
}));
// Force an error to test error UI
await page.RouteAsync("**/api/orders", route => route.FulfillAsync(new()
{
Status = 500,
Body = "boom"
}));
Glob patterns (**, *) match URLs. Routes apply in registration order; later routes can override earlier ones.
Trace viewer
Playwright's debugging superpower. Trace records every action, network call, console log, and DOM snapshot.
await ctx.Tracing.StartAsync(new() { Screenshots = true, Snapshots = true, Sources = true });
try
{
await page.GotoAsync("/checkout");
// ... test steps ...
}
finally
{
await ctx.Tracing.StopAsync(new() { Path = "trace.zip" });
}
Open with:
You get a time-travel debugger: scrub the timeline, see DOM at each step, inspect network, view console. For a flaky CI failure, the trace usually tells you the answer in 30 seconds.
Screenshots and video on failure
// Per-test setup
await using var ctx = await browser.NewContextAsync(new()
{
RecordVideoDir = "videos/",
ViewportSize = new() { Width = 1280, Height = 720 }
});
// On failure (TestContext or AfterEach):
if (TestContext.CurrentContext.Result.Outcome != ResultState.Success)
{
await page.ScreenshotAsync(new() { Path = $"failure-{TestContext.CurrentContext.Test.Name}.png" });
}
Best practice: trace-on-retry — record a trace only when a test fails, then keep the artifact for CI download. Don't trace passing tests; storage adds up fast.
Page Object Model — minimal version
public class CheckoutPage(IPage page)
{
public ILocator EmailInput => page.GetByTestId("email");
public ILocator SubmitButton => page.GetByTestId("submit");
public async Task FillEmailAsync(string email) => await EmailInput.FillAsync(email);
public async Task SubmitAsync() => await SubmitButton.ClickAsync();
}
[Test]
public async Task Checkout_completes()
{
var checkout = new CheckoutPage(Page);
await Page.GotoAsync("/checkout");
await checkout.FillEmailAsync("[email protected]");
await checkout.SubmitAsync();
await Expect(Page).ToHaveURLAsync("**/thank-you");
}
Don't over-engineer. POMs become bloated. Just enough to dedupe selectors. If a POM has more methods than the page has interactions, you've gone too far.
Parallelism
Playwright runs one worker per file by default in NUnit/xUnit (each file = one browser context tree). Tests within a file run serially.
// xUnit: parallel collections by default
[Collection("UI")] // serialize within collection if needed
public class CheckoutTests { }
// NUnit: configure in .runsettings
// <ParallelizeAssembly>true</ParallelizeAssembly>
// <ParallelizeTestCollections>true</ParallelizeTestCollections>
Aim for fully parallel — every test should set up its own state. Shared state across tests = flaky CI.
CI: GitHub Actions / Azure DevOps
# GitHub Actions
- uses: actions/setup-dotnet@v4
with: { dotnet-version: '8.0.x' }
- run: dotnet build
- run: pwsh tests/bin/Debug/net8.0/playwright.ps1 install --with-deps
- run: dotnet test --logger "trx;LogFileName=results.trx"
- if: failure()
uses: actions/upload-artifact@v4
with: { name: traces, path: '**/trace.zip' }
--with-deps installs OS packages required by browsers (libnss3, fonts, etc.) on Linux runners. Skip on macOS/Windows.
For Azure DevOps, the same shape — dotnet test task plus an artifact publish for trace.zip.
Flakiness — the core enemy
Causes ranked by frequency:
- Animations not waited on → Playwright auto-waits for stability, but CSS
transition: all 0.3scan still race. Disable in test mode (prefers-reduced-motion). - Network nondeterminism → mock with
RouteAsync. - Time-sensitive UI (countdown, "X seconds ago") → mock the clock or scrub from assertions.
- Race conditions in app code → real bugs surfacing in tests; fix the app, not the test.
- Resource contention in CI → reduce parallelism if CI box is small.
Auto-wait kills 80% of flakiness Selenium had; the remaining 20% are real app bugs.
How it works under the hood
Playwright drives browsers via the Chrome DevTools Protocol (Chromium) and WebKit/Firefox patched protocols (custom protocols Microsoft contributes upstream). The .NET binding is a thin WebSocket JSON-RPC client — your PageClickAsync call serializes to JSON, ships over WebSocket to the bundled Node.js driver, which then talks the browser protocol.
[ Test code (.NET) ]
↓ JSON-RPC over stdio/WebSocket
[ playwright-driver (Node.js) ]
↓ CDP / patched WebKit / patched Firefox
[ Browser binary (Chromium / Firefox / WebKit) ]
Auto-wait is implemented in the driver: each action call polls the element's actionability state until satisfied or timed out. The browser-binary version is pinned to the Playwright version — no driver mismatch.
Trace recording uses CDP's screencast + DOM snapshot APIs to dump zipped artifacts you replay in the trace viewer.
Code: correct vs wrong
❌ Wrong: log in via UI on every test
[SetUp]
public async Task LoginAsync()
{
await Page.GotoAsync("/login");
await Page.FillAsync("#email", "[email protected]");
await Page.FillAsync("#password", "pw");
await Page.ClickAsync("#submit");
}
10 tests = 10 logins = 30 extra seconds and 10 extra flaky paths.
✅ Correct: storage state once, reuse
// global-setup.cs runs once
var ctx = await browser.NewContextAsync();
var page = await ctx.NewPageAsync();
await LoginViaApi(page); // or UI — once
await ctx.StorageStateAsync(new() { Path = "state.json" });
// Per test:
var ctx = await browser.NewContextAsync(new() { StorageStatePath = "state.json" });
❌ Wrong: CSS selectors
One CSS class rename and the test breaks.
✅ Correct: test-id
❌ Wrong: Thread.Sleep
await page.ClickAsync("#submit");
await Task.Delay(2000); // 💀 hard wait
await page.ClickAsync("#confirm");
Slow on fast machines, flaky on slow ones.
✅ Correct: assert state, then act
await page.ClickAsync("[data-testid=submit]");
await Expect(page.GetByTestId("confirm-dialog")).ToBeVisibleAsync();
await page.ClickAsync("[data-testid=confirm]");
❌ Wrong: hitting prod APIs from E2E
Network slowness, rate limits, leaked test data, breaks if upstream is down.
✅ Correct: mock external calls
await page.RouteAsync("**/external.com/**", r => r.FulfillAsync(new() { Status = 200, Body = "{}" }));
Design patterns for this topic
Pattern 1 — "Storage state per role"
- Intent: generate
admin.json,viewer.json,editor.jsononce; tests pick the role they need.
Pattern 2 — "Test-ID-first selectors"
- Intent: every interactive element gets
data-testid; tests never use CSS.
Pattern 3 — "Trace on retry"
- Intent: record trace only on failure or retry to control storage cost.
Pattern 4 — "Network-mocked happy path, real-network smoke"
- Intent: fast deterministic suite + a tiny smoke suite that hits real backends.
Pattern 5 — "Page object only when sharing"
- Intent: dedupe selectors across multiple tests; don't wrap one-off interactions.
Pattern 6 — "Critical-journey suite"
- Intent: E2E covers ~10 top user flows; integration tests cover the rest.
Pros & cons / trade-offs
| Aspect | Pros | Cons |
|---|---|---|
| Coverage | Validates real browser behavior | Slow, expensive |
| Reliability | Auto-wait kills most flakes | Still flakier than unit |
| Tooling | Trace viewer is best-in-class | Trace artifacts are large |
| Cross-browser | One API, three engines | WebKit ≠ Safari (close, not identical) |
| CI cost | Parallel workers help | Browser binaries inflate cache size |
When to use / when to avoid
- ✅ Use for critical user journeys: login, checkout, primary CRUD flows.
- ✅ Use for cross-browser regression on a smoke subset.
- ✅ Use when integration tests can't reach UI behavior (focus, keyboard nav).
- ❌ Avoid for behavior provable by integration test (
WebApplicationFactoryis 50× faster). - ❌ Avoid as your primary regression net — pyramid keeps E2E small.
- ❌ Avoid running E2E on every PR if suite > 5 minutes — gate on smoke, full nightly.
Interview Q&A
Q1. Why Playwright over Selenium in 2026? Auto-wait, single API across browsers, no driver mismatch, built-in trace viewer, first-class network mocking. Selenium remains for legacy suites.
Q2. What is a browser context? An isolated profile within a single browser process — own cookies, storage, cache. Cheap to create; the recommended unit of test isolation.
Q3. Storage state pattern? Log in once, persist cookies+localStorage to JSON, reuse across tests. Eliminates per-test login latency and login flakiness.
Q4. Best selector strategy? data-testid → role → text → CSS. Test IDs are stable across restyle and i18n.
Q5. How does auto-wait work? Before each action, Playwright polls the element for actionability (attached, visible, stable, enabled, receiving events). No manual waits needed in 95% of cases.
Q6. How do you mock network calls? page.RouteAsync("**/api/...", route => route.FulfillAsync(...)). Pattern matches URL globs; you can fulfill, abort, or modify.
Q7. What is the trace viewer? A time-travel debugger packaged in a trace.zip. Shows DOM snapshots, network, console, sources for every action. Best E2E debugging tool available.
Q8. How to run in CI? Install browser binaries (playwright install --with-deps), run headless, parallel by file, capture trace/screenshot only on failure, upload as artifact.
Q9. How do you reduce flakiness? Use auto-wait (don't Thread.Sleep), mock third-party network, disable animations in test mode, isolate state per test, use stable selectors.
Q10. How do contexts compare to incognito? Same isolation model — own cookies, own storage, own cache. Multiple contexts share one browser process for performance.
Q11. Why minimal page objects? Over-abstraction obscures intent. POMs should dedupe selectors only; don't wrap business workflows in nested POM methods.
Q12. When NOT to use E2E? When an integration test (WebApplicationFactory) can prove the same behavior — 50× faster, no browser flakes, no binaries to manage.
Gotchas / common mistakes
- ⚠️ Forgetting
playwright installin CI → "Executable doesn't exist" errors. - ⚠️ Hard waits (
Task.Delay) instead ofExpect(...).ToBeVisibleAsync(). - ⚠️ Reusing a single browser context across tests → cookie pollution → flakes.
- ⚠️ CSS selectors that include layout classes → break on any restyle.
- ⚠️ Logging in via UI per test → slow + flaky; use storage state.
- ⚠️ Recording video/trace for every test → CI artifacts balloon.
- ⚠️ Hitting real third-party APIs → flake when upstream slows or rate-limits.
- ⚠️ Mismatched Playwright + browser version (mixing CLI versions) → bizarre errors.
- ⚠️ Animations not disabled in test mode → race on
Stableactionability check. - ⚠️ Sharing storage state across roles → tests step on each other's auth.