Skip to content

From Playwright .NET

This guide ports upstream’s docs/migrate/playwright.mdx (tester-army/e2e at 7b1b8b27, with a60310f9, tester-army/e2e#1508, and 0852e61, tester-army/e2e#1545). Upstream maps Node Playwright. This guide maps Playwright for .NET (Microsoft.Playwright.NUnit, Microsoft.Playwright.Xunit, or Microsoft.Playwright) and keeps upstream’s order. Playwright .NET has no playwright.config.ts, so the config tables map BrowserNewContextOptions, the runner settings, and .runsettings instead.

E2E drives Chromium through Microsoft.Playwright, so both suites can share one browser install. The e2e tests live in their own test project, so dotnet test on the solution runs both suites and neither collects the other’s tests.

Migrate this project's Playwright .NET tests to e2e, one test class at a time. Docs: the "From Playwright .NET" page of the e2e-dotnet docs site (docs-site/src/content/docs/migrate/playwright.mdx).
1. Read the migration guide. It maps every Playwright .NET context option, Page call, and test attribute to e2e and marks what e2e does not have yet.
2. If the solution has no e2e test project, run `e2e init <directory> --yes` beside the Playwright project (add `--framework xunit` for xUnit v3). It creates the test project and example test, writes e2e.config.json, and installs the skill. In an existing test project it adds the missing package references to the .csproj.
3. Read the e2e skill (`e2e guide` prints it, `e2e guide <topic>` prints a topic) and follow it for every step below.
4. If you ran init, ask me which model to use for agent steps: a ChatGPT, GitHub Copilot, OpenCode Console, or SuperGrok subscription, an API key, or a local model. If it needs a sign-in, give me the `e2e login <provider>` command and wait until I confirm.
5. Port the context options and the base URL into e2e.config.json and the WebEngineOptions with the guide's config tables. Keep Microsoft.Playwright installed in the old project.
6. List the Playwright test classes and ask me which one to move first. Suggest a flow whose steps change often, such as a wizard or a checkout.
7. Rewrite that class with the same framework (NUnit or xUnit v3), deriving from E2ETest. Map each Page call to Screen, App, or Browser with the guide. Where a Playwright query matched a fragment, add exact: false or a Regex. Where the guide marks a feature missing, use the workaround it gives, or leave that test in Playwright and tell me why.
8. Keep navigation and the final assertions deterministic, with App.OpenAsync and Expect.That. Where a run of scripted interactions breaks often, propose an Agent.ActAsync goal for it instead.
9. Run the new class with `dotnet test --filter "FullyQualifiedName~<Class>"` and fix it until it passes. Keep the Playwright test until I confirm it can go.
10. Summarize which tests moved, which stayed in Playwright, and why.

For a migration that spans several sessions, the migrate-to-e2e skill records what moved in a progress file and keeps the old suite running until you retire each test. See Coding agents.

Install e2e and configure a model with the web guide.

Playwright .NET has no config file: a test class overrides ContextOptions(), and the runner reads .runsettings and environment variables. e2e reads e2e.config.json and a WebEngineOptions for the engine. The setup topic of the e2e skill lists every key.

Playwright .NET e2e Status
ContextOptions().BaseURL app.url on the target, or an override of BaseUrl on the test class renamed
a web server you start yourself or from a [OneTimeSetUp] app: { url, command } on the target; targets that declare the same command share one process renamed
a [OneTimeSetUp] or SetUpFixture that seeds data the framework’s setup, a [E2ESetup] test for sign-in same
the BROWSER environment variable, Playwright.BrowserName in .runsettings WebEngineOptions.BrowserName: BrowserName.Chromium, Firefox, or Webkit. Two browsers on one app are two targets renamed
ExpectTimeout in .runsettings, Expect.SetDefaultExpectTimeout assertionTimeout in the config, or the AssertionTimeout override renamed
Page.SetDefaultTimeout actionTimeout in the config, or the ActionTimeout override renamed
Page.SetDefaultNavigationTimeout timeout, the test budget; Browser.GotoAsync(url, new GotoOptions { Timeout }) overrides one call renamed
NUnit’s [Timeout], Retry, NumberOfTestWorkers the same attributes and settings: the framework owns them, and the config retries key is checked and not used. See defaults that differ by design
the HTML report, playwright.ps1 show-report report.json and .e2e/results/<result>/trace.md missing
the OutputDir of a PageTest .e2e, holding results/ and the replay cache cache/ renamed
Playwright .NET e2e Status
ViewportSize WebEngineOptions.Viewport, default 1280 by 720 renamed
Playwright.Selectors.SetTestIdAttribute WebEngineOptions.TestIdAttribute renamed
HttpCredentials WebEngineOptions.BasicAuth = new WebBasicAuth(username, secret) renamed
ExtraHTTPHeaders WebEngineOptions.Headers, sent to the app’s site only renamed
BrowserType.ConnectOverCDPAsync(url) WebEngineOptions.Connect = new WebConnectOptions { CdpEndpoint = ct => ... }, Chromium only renamed
BrowserType.ConnectAsync(wsEndpoint) Connect attaches over CDP only missing
StorageState, a setup test that saves it [E2ESetup("title", Saves = ["admin"])] with SaveSessionAsync("admin"), then [E2ESession("admin")] on a test; no file by design
StorageState with fixed cookies Browser.SetCookiesAsync(cookies) in a [SetUp] renamed
Tracing.StartAsync and StopAsync trace: off, on, retain-on-failure, on-first-retry, or on-all-retries, in the config, per target, or E2E_TRACE. A trace is a markdown page, trace.md, not a zip, with no viewer. No retain-on-first-failure, retain-on-failure-and-retries, or object form. The default differs, see below renamed
RecordVideoDir video, with the modes of trace and E2E_VIDEO; no RecordVideoSize renamed
Headless = false, HEADED=1 E2E_HEADLESS=0, or new WebEngineOptions { Headless = false } renamed
Locale, TimezoneId WebEngineOptions.Locale, TimezoneId renamed
ColorScheme, ReducedMotion WebEngineOptions.ColorScheme, ReducedMotion renamed
UserAgent WebEngineOptions.UserAgent renamed
Playwright.Devices["iPhone 15"], DeviceScaleFactor, IsMobile, HasTouch Viewport sets the size only missing
Permissions app.permissions on the target, { "clipboard-read": "grant" }: grant only, held by every page on any origin. See Granting browser permissions in the web guide renamed
Geolocation (the position; the permission is app.permissions above), IgnoreHTTPSErrors, BypassCSP, Offline, Proxy, LaunchOptions not yet missing

Start with a flow whose steps change often, such as a wizard or a checkout. Stable Playwright tests can stay where they are.

Playwright .NET (NUnit)
using Microsoft.Playwright;
using Microsoft.Playwright.NUnit;
public class CheckoutTests : PageTest
{
[Test]
public async Task Checkout()
{
await Page.GotoAsync("/cart");
await Page.GetByRole(AriaRole.Button, new() { Name = "Checkout" }).ClickAsync();
await Page.GetByLabel("Card number").FillAsync("4242424242424242");
await Page.GetByLabel("Expiry").FillAsync("12/30");
await Page.GetByLabel("CVC").FillAsync("123");
await Page.GetByRole(AriaRole.Button, new() { Name = "Pay" }).ClickAsync();
await Expect(Page.GetByRole(AriaRole.Heading)).ToHaveTextAsync("Order confirmed");
}
}
e2e (NUnit)
using E2E.NUnit;
public sealed class CheckoutTests : E2ETest
{
[Test]
public async Task Checkout()
{
await App.OpenAsync("/cart");
await Agent.ActAsync("pay with the test card 4242 4242 4242 4242, expiry 12/30, CVC 123");
await Expect.That(Screen.GetByRole("heading")).ToHaveTextAsync("Order confirmed");
}
}
e2e (xUnit v3)
using E2E.XUnit.V3;
public sealed class CheckoutTests : E2ETest
{
[Fact]
public async Task Checkout()
{
await App.OpenAsync("/cart");
await Agent.ActAsync("pay with the test card 4242 4242 4242 4242, expiry 12/30, CVC 123");
await Expect.That(Screen.GetByRole("heading")).ToHaveTextAsync("Order confirmed");
}
}

The agent does the checkout steps and adapts when the form changes. A test can also stay fully deterministic, with a Screen.GetBy* line for every Page.GetBy* line. Tag a test that calls a model with [Category("RealModel")] (NUnit) or [Trait("TestCategory", "RealModel")] (xUnit), so CI can skip it.

Status is same, renamed, web only, by design, or missing with what to do today. See What differs on purpose for the by design rows. Calls on Browser run only in a browser, so a device or document target fails them with UNSUPPORTED_CAPABILITY. Calls on Screen, App, Agent, and Expect.That(locator) run on every target.

Playwright .NET e2e Status
Page.GetByRole(AriaRole.Button, new() { Name = "Save" }) Screen.GetByRole("button", "Save"); the role is a string and the name is positional renamed
GetByLabel, GetByText, GetByPlaceholder, GetByTestId the same on Screen same
GetByRole, GetByLabel, GetByPlaceholder, GetByText with a string an exact, case-sensitive match, see What differs on purpose by design
GetByRole(AriaRole.Img) the same; img is an alias of image same
GetByAltText(alt) GetByRole("image", alt) renamed
GetByTitle the node carries no title missing
GetByRole(role, new() { IncludeHidden = true }) a role query never matches a hidden node; Visible = true on the other queries drops hidden twins by design
Filter(new() { HasText, HasNotText, Has, HasNot }), First, Last, Nth(i), CountAsync() Filter(hasText:, hasNotText:, has:, hasNot:), First(), Last(), Nth(i), CountAsync() same
Locator.Or(other) the same; a locator is strict, so add First() when both match same
Locator.And(other) not yet missing
AllAsync(), AllTextContentsAsync() the same; AllInnerTextsAsync() is AllTextContentsAsync() same
Page.Locator("css"), Page.Locator("xpath=...") Browser.Locator("css"): the string is a Playwright selector, so xpath= (or a leading //) and >> chains work too web only
Page.WaitForSelectorAsync(selector) Browser.Locator(selector).WaitForAsync(); a locator is strict renamed
Page.ClickAsync(selector), FillAsync(selector, value), GetAttributeAsync(selector, name), and the other Page methods that take a selector Browser.Locator(selector).TapAsync(), .FillAsync(value), .GetAttributeAsync(name). Playwright acts on the first match; a locator is strict, so add First() for the old behavior renamed
Locator.Locator("css") a semantic query inside the scope (cell.GetByRole("textbox") for cell.Locator("input")), Browser.Locator("css").GetByRole(...) the other way round, or one Browser.Locator selector for the whole path missing
Page.FrameLocator("#frame") Browser.FrameLocator("#frame"), then Screen queries inside it web only
Playwright .NET e2e Status
ClickAsync() TapAsync(); ClickAsync() is an alias same
ClickAsync(new() { Button = MouseButton.Right }) SecondaryTapAsync() renamed
DblClickAsync() DoubleTapAsync(); DblClickAsync() is an alias same
ClickAsync(new() { Position }) the tap position is not ported yet missing
ClickAsync(new() { Force }) not yet missing
ClickAsync(new() { Modifiers }), DblClickAsync(new() { Modifiers }) TapAsync(new ClickOptions { Modifiers = [...] }), DoubleTapAsync, SecondaryTapAsync; web only renamed
ClickAsync(new() { ClickCount, Delay, Trial }) not yet missing
Trial, Force, NoWaitAfter on CheckAsync, FillAsync, and the other actions no such option: the options type has no member for them missing
FillAsync(value) the same; also takes a Secret same
PressSequentiallyAsync(text, new() { Delay }) PressSequentiallyAsync(text); keystrokes through the platform keyboard, a Secret is refused same
PressAsync(key) the same; modifiers join with + same
ClearAsync(), FocusAsync(), HoverAsync(), SetInputFilesAsync(), DragToAsync() the same verbs, each with Timeout as the only option (ActionOptions): no Force, Trial, Position, Modifiers, or SourcePosition. SetInputFilesAsync([]) clears the input renamed
CheckAsync(), UncheckAsync() the same, except when the state updates late or the click removes the control, see What differs on purpose. UncheckAsync() on a checked radio, role=radio included, is NOT_ACTIONABLE by design
SelectOptionAsync("pro") SelectOptionAsync("Pro") selects the option whose value or label is the string same
SelectOptionAsync(new[] { "a", "b" }), SelectOptionAsync(new SelectOptionValue { Index }) one value or label per call missing
SetInputFilesAsync(new FilePayload { ... }), SetInputFilesAsync(Array.Empty<string>()) paths only, resolved from the project root; an empty list throws ArgumentException, so there is no clear missing
ScrollIntoViewIfNeededAsync() ScrollIntoViewAsync() renamed
InnerTextAsync() TextContentAsync(), whitespace collapsed to single spaces renamed
TextContentAsync() the rendered text, what InnerTextAsync() reads, see What differs on purpose by design
InputValueAsync() the same on an input, textarea, or select; "" on any other node, where Playwright throws. A checkbox or radio reads its value attribute, on when it has none by design
GetAttributeAsync(), IsVisibleAsync(), IsHiddenAsync(), IsEnabledAsync(), IsDisabledAsync(), BoundingBoxAsync() the same same
IsCheckedAsync() the same on a checkbox, radio, or aria-checked node; false on any other, see What differs on purpose by design
IsEditableAsync() not yet missing
WaitForAsync(new() { State = WaitForSelectorState.Visible }), Hidden, Attached, Detached WaitForAsync takes the same four states same
EvaluateAsync(fn), EvaluateAllAsync(fn) on a locator Browser.EvaluateAsync<T>(expression, arg) with a CSS selector in the argument missing
Locator.ScreenshotAsync() App.ScreenshotAsync(label) captures the viewport missing
InnerHTMLAsync(), DispatchEventAsync(), SelectTextAsync(), BlurAsync(), HighlightAsync() Browser.EvaluateAsync missing
AriaSnapshotAsync() not yet missing
Page.Keyboard.PressAsync, Keyboard.TypeAsync Browser.Keyboard.PressAsync, Browser.Keyboard.TypeAsync; plain text only, never a Secret web only
Keyboard.DownAsync, Keyboard.UpAsync Browser.Keyboard.DownAsync, UpAsync; keys and modifiers stay held until released web only
Keyboard.InsertTextAsync not yet missing
Page.Mouse.ClickAsync(x, y) no TapAt yet; Browser.Mouse.MoveAsync, DownAsync, UpAsync missing
Mouse.MoveAsync, DownAsync, UpAsync, WheelAsync Browser.Mouse.* web only
Page.DragAndDropAsync(source, target, new() { SourcePosition, TargetPosition }) source.DragToAsync(target), which takes no positions renamed

Expect(locator) of a PageTest becomes Expect.That(locator). The matcher names are the same.

Playwright .NET e2e Status
ToBeVisibleAsync, ToBeHiddenAsync, ToBeEnabledAsync, ToBeDisabledAsync, ToBeFocusedAsync, ToHaveValueAsync, ToHaveAttributeAsync, ToHaveCountAsync, ToHaveAccessibleNameAsync the same same
a Playwright ILocator passed to Expect.That from E2E Expect.That takes an e2e Locator, so the call does not compile. Rewrite the query on Screen missing
ToBeCheckedAsync() the same on a checkable node; on any other node .Not.ToBeCheckedAsync() passes, see What differs on purpose by design
ToHaveTextAsync(), ToContainTextAsync() compare the rendered text, see What differs on purpose by design
ToHaveTextAsync(new[] { "a", "b" }) on several matches the same: ToHaveTextAsync needs the count to match same
ToBeCheckedAsync(new() { Checked = false }), ToBeEnabledAsync(new() { Enabled }), ToBeVisibleAsync(new() { Visible }) ToBeCheckedAsync(false); the others use .Not renamed
IgnoreCase on ToHaveTextAsync, ToContainTextAsync ignoreCase: on the same matchers same
ToBeCheckedAsync(new() { Indeterminate }), UseInnerText not yet missing
ToHaveAttributeAsync("aria-selected", "true"), "aria-expanded" ToBeSelectedAsync(), ToBeExpandedAsync() renamed
a positive matcher on several matches LOCATOR_AMBIGUOUS at once, see What differs on purpose by design
.Not.ToBeVisibleAsync(), .Not.ToBeCheckedAsync(), .Not.ToBeEnabledAsync() ToBeHiddenAsync(), ToBeCheckedAsync(false), ToBeDisabledAsync(), which needs a match, so a control that is gone fails where .Not.ToBeEnabledAsync() passes in Playwright. A .Not locator matcher holds for up to 1 s before it passes, see What differs on purpose by design
ToBeAttachedAsync() the same; passes on a hidden match too same
ToBeEmptyAsync(), ToBeInViewportAsync(), ToBeEditableAsync(), ToHaveRoleAsync(), ToHaveAccessibleDescriptionAsync() not yet missing
Expect(locator).ToHaveClassAsync(cls) Expect.That(Browser).ToHaveClassAsync(locator, cls); a DOM fact is asserted through Browser; class="" matches an empty list and a missing attribute does not web only
ToHaveCSSAsync(), ToHaveIdAsync(), ToHaveJSPropertyAsync(), ToContainClassAsync(), ToHaveValuesAsync() Browser.EvaluateAsync<T> plus a value assert missing
Expect(Page).ToHaveURLAsync(), ToHaveTitleAsync() Expect.That(Browser).ToHaveURLAsync(), ToHaveTitleAsync(); ToHaveURLAsync takes ignoreCase web only
Expect(Page).ToHaveScreenshotAsync(), Expect(locator).ToHaveScreenshotAsync() Expect.That(Screen).ToHaveScreenshotAsync(name), Expect.That(locator).ToHaveScreenshotAsync(), web only, with Threshold, MaxDiffPixels, MaxDiffPixelRatio, Mask, MaskColor, and Timeout. Set E2E_UPDATE_SNAPSHOTS=1 to rewrite same
ToHaveScreenshotAsync(new() { FullPage, Clip, Animations, Caret, Scale, StylePath }) always the viewport, at CSS scale, animations stopped and the caret hidden missing
ToMatchAriaSnapshotAsync() not yet missing
Assert.That(value, ...), Assert.AreEqual the framework’s asserts, unchanged same
Expect(async () => { ... }).ToPassAsync() Expect.Poll(read).ToBeAsync(...) or ToSatisfyAsync(predicate) retries a read, not a block. Its timeout is 5 s (PollOptions.Timeout), not the test timeout, with one fixed PollOptions.Interval, default 100 ms renamed
Expect.Soft Expect.Soft(locator) and Expect.Soft(screen); kept failures fail the test at its end renamed
Expect(locator, new() { Message = "..." }) Expect.That(locator); a locator failure already names the locator, the matcher, and what was observed by design
a custom matcher not yet missing
Playwright .NET e2e Status
Page.GotoAsync(path) App.OpenAsync(path); Browser.GotoAsync(url, new GotoOptions { WaitUntil, Timeout }) for the browser options renamed
Page.GotoAsync("data:text/html,...") POLICY_DENIED, see What differs on purpose. Serve the HTML from a Browser.RouteAsync with route.FulfillAsync(new RouteFulfillResponse { Body = html, ContentType = "text/html" }) on an app URL by design
var response = await Page.GotoAsync() App.OpenAsync and Browser.GotoAsync return nothing; take the response from Browser.WaitForResponseAsync by design
Page.GoBackAsync() App.BackAsync(), or Browser.BackAsync() renamed
Page.GoForwardAsync(), Page.ReloadAsync() Browser.ForwardAsync(), Browser.ReloadAsync() web only
Page.Url, Page.TitleAsync() await Browser.UrlAsync(), await Browser.TitleAsync() web only
Page.WaitForURLAsync() Browser.WaitForURLAsync(). A string is an absolute URL, or a relative one resolved against app.url, matched exactly, not a glob: "**/orders" never matches, so pass a Regex web only
Page.WaitForLoadStateAsync() Browser.GotoAsync(url, new GotoOptions { WaitUntil }) on the navigation itself missing
Page.WaitForFunctionAsync(fn) Expect.Poll(() => Browser.EvaluateAsync<bool>(expression)).ToBeAsync(true) missing
Page.WaitForTimeoutAsync(ms) a locator assertion on the state you are waiting for: ToBeVisibleAsync() on what appears, ToBeHiddenAsync() on what goes away by design
Page.SetViewportSizeAsync() Browser.SetViewportAsync(), before or after the first navigation web only
Page.ViewportSize Browser.EvaluateAsync<T>("() => ({ width: innerWidth, height: innerHeight })") missing
Page.ScreenshotAsync() App.ScreenshotAsync(label): the viewport, redacted, attached to the report; no FullPage renamed
a fresh IBrowserContext per test every test starts in a fresh context; App.RestartAsync() recreates it, App.ClearStateAsync() also clears storage. No page opens before the first App.OpenAsync or Browser.GotoAsync, where Playwright starts on about:blank same
Page.PauseAsync() E2E_HEADLESS=0 to watch, e2e mcp to drive the app from a coding agent missing
Playwright .NET e2e Status
Page.RouteAsync(pattern, handler) Browser.RouteAsync(pattern, handler); a string or Regex pattern, no predicate function; the handler decides exactly once. A string is a glob on the full URL, never resolved against app.url. ? matches any one character, where Playwright reads it literally; {a,b} is literal web only
route.FulfillAsync(new() { Json, Body, Path, Status, Headers, ContentType }) route.FulfillAsync(new RouteFulfillResponse { ... }); one of Json, Body, or Path, and Path is relative to the project root. Body is text and bytes are BodyBytes; a \n-joined set-cookie sets each cookie web only
route.ContinueAsync(new() { Url, Method, Headers, PostData }), route.FallbackAsync() ContinueAsync(new RouteContinueOverrides { ... }), FallbackAsync(); a Url resolves against app.url and keeps the request’s http or https scheme. A header Playwright sets to undefined is named in DropHeaders web only
route.FetchAsync() argument-free: no options, and redirects return unfollowed; returns { Url, Status, Headers, BodyAsync(), TextAsync(), JsonAsync<T>() } without deciding the route web only
route.AbortAsync(errorCode) AbortAsync() takes no error code missing
Page.UnrouteAsync() Browser.UnrouteAsync(pattern) web only
Page.UnrouteAllAsync() Browser.UnrouteAllAsync(); configured site headers stay web only
Page.WaitForResponseAsync(pattern), RunAndWaitForResponseAsync Browser.WaitForResponseAsync(pattern): a string or Regex, no predicate; Url, Status, Headers, BodyAsync(), JsonAsync<T>(), TextAsync(). A string is the glob of Browser.RouteAsync, tested against the whole URL, query included: Playwright’s "/api/orders" is the full URL or "**/api/orders", and "\\?" is a literal ?. Start it before the step that sends the request, and await it after web only
a Timeout of 0 for no timeout a positive TimeSpan. TimeSpan.Zero on Browser.GotoAsync, WaitForDownloadAsync, or WaitForDownloadsAsync throws ArgumentOutOfRangeException. A locator action takes Timeout.InfiniteTimeSpan for no timeout missing
response.Ok, response.Request Status missing
Page.WaitForRequestAsync(), request.PostDataJSON() none yet for WaitForRequestAsync; route.Request.PostData is a string missing
RouteFromHARAsync(), RouteWebSocketAsync() not yet missing
Page.Dialog += handler await Browser.OnDialogAsync(DialogHandler.Accept), or a handler that calls AcceptAsync or DismissAsync web only
Page.RunAndWaitForDownloadAsync(...) Browser.WaitForDownloadAsync(() => trigger), and WaitForDownloadsAsync(trigger, count) for several files web only
Page.WaitForPopupAsync(), Context.Page += a window the app opens is not driven missing
Page.Console +=, Page.PageError += assert on what the page shows missing
Page.EvaluateAsync(fn, arg) Browser.EvaluateAsync<T>(expression, arg); JSON in and out, no handles web only
Page.AddInitScriptAsync(script), Context.AddInitScriptAsync() Browser.AddInitScriptAsync(source), or WebEngineOptions.InitScripts for every test web only
Page.EmulateMediaAsync(new() { ColorScheme, ReducedMotion }) Browser.EmulateMediaAsync(new WebMedia { ... }), for every open tab and every tab opened after it; Media, ForcedColors, and Contrast are not supported web only
Page.ExposeFunctionAsync(), AddStyleTagAsync(), SetExtraHTTPHeadersAsync(), Clock none yet; for AddStyleTagAsync, Browser.EvaluateAsync can append a <style> once the page is open missing
Context.AddCookiesAsync(), Context.CookiesAsync() Browser.SetCookiesAsync(), Browser.CookiesAsync() web only
Context.ClearCookiesAsync(options) Browser.ClearCookiesAsync(new CookieFilter { ... }) web only
Playwright.APIRequest, the Request fixture HttpClient by design
Context.StorageStateAsync(new() { Path }) no file, see What differs on purpose by design
Playwright .NET e2e Status
using Microsoft.Playwright.NUnit; and PageTest using E2E.NUnit; and E2ETest; for xUnit, using E2E.XUnit.V3; and its E2ETest renamed
[Test], [TestFixture], [SetUp], [TearDown] the same NUnit attributes. Your [SetUp] runs after the base class’s, so the session is ready there same
[OneTimeSetUp], [OneTimeTearDown] the same, with no session: the session starts for each test same
Page in a test Screen, App, Browser; no raw IPage in tests, see What differs on purpose by design
[Ignore], Assert.Ignore the same same
[Explicit], [Category("smoke")] the same NUnit attributes; for xUnit, [Fact(Explicit = true)] and [Trait("TestCategory", "smoke")] same
[Parallelizable(ParallelScope.Self)] a test class with ParallelScope.Children or All fails at setup unless it also has [FixtureLifeCycle(LifeCycle.InstancePerTestCase)]; xUnit runs classes in parallel by design
[Retry(n)] the same on NUnit: the first attempt can replay the cache, and a retry runs every agent step live. xUnit has no retry attribute same
TestContext.AddTestAttachment App.ScreenshotAsync(label) attaches a screenshot; every Screen, Browser, and Agent call is already a named step in the report, with its arguments redacted missing
ContextOptions() override per target in the config, or CreateEngine() on the test class; Browser.SetViewportAsync at runtime renamed
a base class with fixtures the same: a class that derives from E2ETest and adds protected members or helper methods same
[SetUpFixture] the framework’s own, with no session same
Playwright .NET e2e Status
dotnet test dotnet test on the e2e project same
dotnet test --filter "FullyQualifiedName~Login" the same; a class, a name, or a category also selects same
dotnet test -- Playwright.BrowserName=firefox one target per browser; set WebEngineOptions.BrowserName renamed
HEADED=1 dotnet test E2E_HEADLESS=0 dotnet test renamed
PWDEBUG=1 E2E_HEADLESS=0 to watch. E2E_DEBUG=1 prints phase timings and the agent step table, not an inspector missing
pwsh bin/Debug/net10.0/playwright.ps1 install e2e install chromium, with --with-deps on Linux. A run also installs a missing browser on its own; E2E_SKIP_BROWSER_INSTALL=1 turns that off renamed
playwright.ps1 codegen e2e explore walks the app from a goal and reports what it found; e2e mcp lets a coding agent drive the app while it writes the test. See Coding agents renamed
playwright.ps1 show-trace no viewer; open the trace.md that a failed test names by design
--logger "trx" and the framework’s other loggers the same, from the framework same
dotnet test -- NUnit.NumberOfTestWorkers=4 the same; see the parallelism row above same

A page object takes Screen for queries and actions, and Browser for keyboard, mouse, CSS selectors, and EvaluateAsync. A page object’s constructor takes the fixture members it needs, so a test builds it in a [SetUp] (NUnit) or in InitializeAsync (xUnit v3). Neither framework has a fixture-injection layer of e2e’s own.

AccountPage.cs
public sealed class AccountPage(Screen screen, Browser browser)
{
public Locator AccountName { get; } = screen.GetByTestId("account-name");
public async Task FilterByNoteAsync(string note)
{
await screen.GetByRole("button", "Filter").TapAsync();
await screen.GetByRole("button", "Note").TapAsync();
await browser.Keyboard.TypeAsync(note);
}
}
AccountTests.cs (NUnit)
public sealed class AccountTests : E2ETest
{
private AccountPage _account = null!;
[SetUp]
public void CreatePages() => _account = new AccountPage(Screen, Browser);
[Test]
public async Task Filters_by_note()
{
await App.OpenAsync("/account");
await _account.FilterByNoteAsync("rent");
await Expect.That(_account.AccountName).ToBeVisibleAsync();
}
}

A page object that reads Browser makes every test that uses it web only. ILocator.Page has no counterpart, so pass Browser with the locator. Drop Context.NewPageAsync() and Page.CloseAsync() from the hooks: every test starts in a fresh context.

  • A string name is exact. Playwright’s GetByRole names, GetByLabel, GetByPlaceholder, and GetByText match a case-insensitive substring, so Save also hits Save as draft. In e2e a string matches the whole text, case-sensitive. Pass exact: false or a .NET Regex for a fragment, and drop Playwright’s Exact = true, which is the default here. A query that misses passes a negated assertion, so check it with a positive one first.

    // <button>Save changes</button>, <label>Email address <input></label>
    Screen.GetByRole("button", "Save"); // no match
    Screen.GetByRole("button", "Save", exact: false); // matches
    Screen.GetByRole("button", "save changes"); // no match, case differs
    Screen.GetByLabel("Email"); // no match
  • Text reads are rendered text. On the web, TextContentAsync() and ToHaveTextAsync read what innerText returns, like Playwright’s UseInnerText = true. Playwright reads the DOM’s textContent by default. Queries still match the source text: GetByText("Draft") finds <span style="text-transform: uppercase">Draft</span>, and ToHaveTextAsync("DRAFT") passes on it. Browser.EvaluateAsync reads the raw DOM text.

  • A node with no checked state is unchecked. IsCheckedAsync() is false and .Not.ToBeCheckedAsync() passes on a button or a heading, where Playwright throws. Query a checkable control by role, so the query fails when the control changes kind.

  • A negated locator matcher passes only after its condition has been false for 1 s in a row, or for its whole timeout when that is shorter. The positive inverse, such as ToBeHiddenAsync(), passes on the first read that holds.

  • CheckAsync() and UncheckAsync() read the state back until the action timeout, so a controlled checkbox that updates late passes. A click that removes the control passes too, where Playwright fails. Assert on what replaced it.

  • One match or LOCATOR_AMBIGUOUS, IsVisibleAsync() included, where Playwright reads the first match silently. Narrow with Filter, First(), or Visible = true.

  • Navigation opens http, https, and about:blank only. data:, file:, and every other scheme are POLICY_DENIED, whether from App.OpenAsync, Browser.GotoAsync, or a route’s ContinueAsync Url.

  • Browser.EvaluateAsync<T> takes and returns JSON only, so the report can record and redact the result. Pass values in through arg.

  • A password is a Secret with no string value, and the model never sees it. See Signing in.

  • Sessions are per run. A [E2ESetup] test saves state under a name, and [E2ESession] restores it. The file is encrypted, kept under .e2e/sessions/<run id>, and deleted at cleanup, so there is no .auth/user.json to reuse across runs.

  • No raw IPage in tests. Nothing on a raw page is recorded, redacted, or masked.

Setting Playwright .NET e2e
Test timeout the framework’s (NUnit has none) 120 s, timeout
Action timeout 30 s 30 s, actionTimeout
Expect timeout 5 s 5 s, assertionTimeout
Navigation timeout 30 s 120 s, the test budget
Retries 0 0; NUnit [Retry]. CI demotes the cache to read-only
Workers the framework’s the framework’s
Trace off retain-on-failure