Skip to main content

Browser tests with Playwright or Selenium in .NET

ProtoTest.Web gives each test a browser session behind page objects, with Playwright or Selenium underneath.

[ProtoTest]
public async Task Valid_credentials_sign_in()
{
var login = Proto.Context.Web().Page<LoginPage>();
await login.OpenAsync("/login");

await login.Form.Flow("Sign in")
.Fill(form => form.Username, "matthias")
.Fill(form => form.Password, "correct horse")
.Click(form => form.Submit)
.RunAsync();

await login.Form.Status.Should.HaveTextAsync("Signed in");
}

Run it with dotnet test. A green run prints Passed Valid_credentials_sign_in, and the trace lands at TestResults/prototest-{runId}.prototrace with a web.session.initialize operation holding every step the browser took. The page objects behind the test live under The tasks; the two backends that can run them are compared under Which backend.

What it adds​

ProtoTest.Web is a browser-testing model for pages, components, elements, tables, flows and login that doesn't depend on any particular browser driver. A backend package plugs a real driver in underneath:

  • ProtoTest.Web.Playwright - launches and manages browsers for you.
  • ProtoTest.Web.Selenium - drives any IWebDriver you create.

Your page objects and tests stay the same for both. The backends differ underneath, most visibly in locator translation, but everything in this section is backend-neutral.

  1. The test
  2. WebSession
  3. A page object
  4. WebLocator
  5. IWebBackendPlaywright or Selenium
  6. The traceweb.* operations, artifacts and coverage

Which backend​

Default to Playwright. It launches and manages the browsers for you, and:

  • downloads the browser on a clean machine with InstallBrowsers,
  • checks for an installed browser without launching one, so a test can skip,
  • records the native Playwright trace,
  • supports downloads,
  • accepts more locator combinations. Pick Selenium when the browsers are driven through WebDriver already: a grid, a driver setup, or a browser you provide yourself through your own IWebDriver factory.
QuestionPlaywrightSelenium
DefaultStart here for a new suiteWhen WebDriver drives your browsers already
Browser installInstallBrowsers = true downloads the selected browser before the first launch; Channel ("msedge", "chrome") uses an installed system browser insteadYou create the IWebDriver, so the browser must already be installed on the machine
Missing browser[RequiresPlaywrightBrowser] probes without launching and skips with a reason naming the install optionsNo probe; gate on the browser capability and handle the first session use (see Skip)
Sessions and poolingOne test-scoped pool; sessions with identical launch options share a browser process inside the testOne driver per session, no pooling, so sessions share no cookies or storage
Channels and system browsersChannel names an installed browser; Linux system libraries come from Playwright's own toolingAny driver your factory builds, with its own options and grid address
DownloadsDownloadAsync registers the file as a test attachmentNo download API in the WebDriver protocol; the call fails with WebBackendCapabilityException before the trigger runs
Locator parityARIA role resolution and native text matching, with the wider And combinationsImplicit HTML role mappings instead of ARIA resolution, XPath 1.0 deepest-match text, and only HasText as the right-hand side of And with no By.Css left side (see Locators)
Diagnostics artifactThe native Playwright trace (playwright-{session}-trace.zip, kept per TraceRetention), plus console, page-error and failed-request eventsProtoTest's own selenium-{session}-diagnostics.json with every actionability attempt (kept per DiagnosticTraceRetention); there is no second report system
What both shareThe same page objects and tests, the same web.* trace vocabulary with a web.backend.execute child per driver call, and the same failure screenshot, page HTML and location filesThe same

Install​

dotnet add package ProtoTest.Web.Playwright
# or
dotnet add package ProtoTest.Web.Selenium

Either backend brings ProtoTest.Web with it. The packages target net8.0, net9.0 and net10.0. The project templates default to net10.0, so pass --framework net8.0 or --framework net9.0 for an older baseline.

Browsers​

Playwright launches Chromium by default on Windows, Linux, and macOS. Set Channel ("msedge", "chrome") to use an installed system browser instead. InstallBrowsers = true downloads the selected browser through the Playwright driver before the first launch, so a clean machine or CI runner needs no separate step. It is ignored when Channel names a system browser:

builder.AddWeb(options =>
{
options.Browser = PlaywrightBrowser.Chromium; // the default
options.InstallBrowsers = true; // download it when missing
});

On Linux, the system libraries a bundled browser needs come from Playwright's own tooling. ProtoTest only runs the driver's install command for the browser itself.

Selenium takes the driver factory you provide, so the browser itself must already be installed on the machine.

A machine may have no browser at all. Gate Playwright tests with the opt-in skip condition from the Playwright package (see Skip):

[RequiresPlaywrightBrowser] // the configured browser
[RequiresPlaywrightBrowser(PlaywrightBrowser.Firefox)]
[RequiresPlaywrightBrowser(channel: "msedge")]
public async Task ...() { ... }

It checks for the installed browser before the test starts, without launching one, and skips with a reason naming Playwright and the install options. With InstallBrowsers = true it never skips. Selenium has no equivalent probe (see Skip).

A session uses its application address from ProtoTest:Applications:{application}:BaseUrl. It appends Endpoints:{endpoint} when the session names one. Infrastructure that started an application with the run publishes that same setting. So a browser journey runs against a standalone instance, an application container or an in-process loopback listener without extra code. ASP.NET Core has the recipes.

Compose​

Registration is the same AddWeb(...) call regardless of backend; the reference you install selects the backend. On a host builder:

public static IProtoHostBuilder AddWeb(
this IProtoHostBuilder builder,
Action<PlaywrightWebOptions>? configure = null); // Playwright

public static IProtoHostBuilder AddWeb(
this IProtoHostBuilder builder,
Func<IWebDriver> createDriver,
Action<SeleniumWebOptions>? configure = null); // Selenium

On an application builder, the form the sample suite uses, so the application's REST and GraphQL clients can share one address:

public static IProtoApplicationBuilder AddWeb(
this IProtoApplicationBuilder application,
Action<PlaywrightWebOptions>? configure = null);

public static IProtoApplicationBuilder AddWeb(
this IProtoApplicationBuilder application,
Func<IWebDriver> createDriver,
Action<SeleniumWebOptions>? configure = null);

The shipped backends compose shared helpers from ProtoTest.Web, so a hand-written backend behaves the same way. See Writing a backend.

Sessions are not declared at registration: a test names the sessions it needs with Proto.Context.Web(name) (below). AddWebBackend keeps the first registration. A host resolves exactly one IWebBackendFactory. Zero or more than one throws InvalidOperationException. For Playwright, every AddWeb(...) call still runs its configure callback, but only the first supplies the skip-check defaults. For Selenium, a repeated call on an application does nothing.

Options and keys​

Both backends also read their options from configuration, so CI can run headless on another browser without code changes. Values are applied in this order, later winning:

  1. your AddWeb(...) callback,
  2. ProtoTest:Web:Playwright or ProtoTest:Web:Selenium, the run's backend options.

Started infrastructure settings are merged over static configuration before binding, so they win over the same key in appsettings.json. Selenium's ActionTimeout/PollInterval and Playwright's ActionTimeout are checked after binding. A value of zero or less throws ArgumentOutOfRangeException. TimeSpan values bind as "hh:mm:ss(.fffffff)" and enums bind by name.

Playwright options​

Key names below are relative to ProtoTest:Web:Playwright (see PlaywrightWebOptions):

KeyTypeDefaultNotes
BrowserPlaywrightBrowserChromiumChromium, Firefox or Webkit
Headlessbooltrue
SlowMofloat? (ms)nulldelay between actions, for watching a run
Channelstring?nulle.g. "msedge" or "chrome" to use an installed browser
InstallBrowsersboolfalsedownload the selected browser before the first launch when it is missing
ActionTimeoutTimeSpan5 show long a read or action waits for its element before failing with the documented resolution/actionability exception
Locale, TimezoneId, UserAgent, ViewportWidth, ViewportHeight, StorageStatePathstring/int?unsetthe context settings ProtoTest models; a viewport needs both dimensions
MaxTraceByteslong32 MiB (33554432)the largest attached native trace; 0 reads without a cap
TraceRetentionPlaywrightTraceRetentionOnWebFailureOff, OnWebFailure, Always
CorrelateTraceGroupsbooltruegroup Playwright trace actions under ProtoTest operations
ConsoleCapturePlaywrightConsoleCaptureWarningsAndErrorsOff, Errors, WarningsAndErrors, All
CapturePageErrorsbooltrueuncaught page exceptions
CaptureRequestFailuresbooltruefailed network requests

Selenium options​

Key names below are relative to ProtoTest:Web:Selenium (see SeleniumWebOptions):

KeyTypeDefaultNotes
ActionTimeoutTimeSpan5 show long an action retries until the element is actionable
PollIntervalTimeSpan50 mshow often it re-checks
WaitForStableBoundsbooltruewait until the element stops moving before clicking
CheckClickObstructionbooltruefail if something covers the element
DiagnosticTraceRetentionSeleniumDiagnosticTraceRetentionOnWebFailureOff, OnWebFailure, Always

Demanding a session​

A test creates a session by asking for it in code. Demand it in code with the attribute:

[WebSession("Admin", Application = "BackOffice", Endpoint = "Admin", DiscoverRoutes = true,
Open = "/back-office")]

or inside the test with the accessor:

var session = Proto.Context.Web("Admin", application: "BackOffice", endpoint: "Admin");
MemberEffect
namethe session name; context.Web() uses the application's Web: binding, then "Default"
Applicationthe ProtoTest:Applications:{application} section that supplies the address and owns the session's coverage; defaults to the test's application, then the session name
Endpointjoins ProtoTest:Applications:{application}:Endpoints:{endpoint} to the base URL, like an HTTP client
DiscoverRoutesrecords the session's Vue Router routes as page inventory after the first navigation
Opennavigates the session during setup; a relative value resolves against the application address

Configuration holds the environment, never the session: addresses under ProtoTest:Applications:{application}, backend options under ProtoTest:Web:{backend}.

From configuration​

{
"ProtoTest": {
"Web": {
"Playwright": {
"Browser": "Firefox",
"Headless": true,
"TraceRetention": "Always",
"Locale": "nl-BE",
"ViewportWidth": 1280,
"ViewportHeight": 720
}
},
"Applications": {
"ControlPlane": { "BaseUrl": "https://ops.example.test" }
}
}
}

The context settings ProtoTest models (Locale, TimezoneId, UserAgent, ViewportWidth, ViewportHeight, StorageStatePath) bind from configuration. Anything else on Playwright's BrowserNewContextOptions is set in code through the ConfigureContext action, which runs after the settings above:

builder.AddWeb(options => options.ConfigureContext = context =>
context.ColorScheme = ColorScheme.Dark);

Options are bound once, the first time a session opens a browser.

Sessions​

Proto.Context.Web(sessionName = null, application = null, endpoint = null, discoverRoutes = false) returns the test's WebSession. The browser is created lazily on the first operation, so a test that never touches the browser never starts one, and the session is completed and disposed at teardown. application defaults to the test's selected application. sessionName defaults to the application's Web client name, then "Default".

Without application, Web(name) returns a session the test already has under that name: the selected application's first, otherwise the one under another application, such as a session [WebSession(Application = ...)] opened. Two or more under other applications throw, naming them. An explicit application is exact.

  1. First operationcreates the session
  2. Launchthe browser starts: web.session.initialize
  3. Operationsnavigate, click, assert
  4. Teardownartifacts published, the browser disposed: web.session.complete
public sealed class WebSession : IAsyncDisposable
{
string Name { get; }
string Application { get; }
Uri? BaseUrl { get; }
string BackendName { get; }
TPage Page<TPage>() where TPage : WebPage, new();
TBackend GetBackend<TBackend>() where TBackend : class, IWebBackend;
ValueTask<TBackend> GetBackendAsync<TBackend>(CancellationToken cancellationToken = default)
where TBackend : class, IWebBackend;
ValueTask WaitUntilAsync(
Func<CancellationToken, ValueTask<bool>> condition,
TimeSpan? timeout = null, // default 5 s
string? description = null, // defaults to the predicate's source text
CancellationToken cancellationToken = default);
}

WaitUntilAsync polls until the condition holds or the timeout passes, throwing WebAssertionException with the expectation when it doesn't. Inside the predicate, "element not found yet" and "not actionable yet" (WebElementResolutionException, WebActionabilityException) count as "not yet". Any other exception fails the wait at once.

Several sessions in one test​

Sessions are per-test: the builder registers only the backend, and a test names the sessions it needs. Each named session is an isolated browser context (Playwright) or driver (Selenium), created lazily on first use and closed at teardown. Pages are cached per session, so Web("Admin").Page<T>() returns the same object each time.

builder.AddWeb(); // register the backend once
var admin = Proto.Context.Web("Admin").Page<BackOfficePage>();
var customer = Proto.Context.Web("Customer").Page<StorefrontPage>();

await admin.Orders.RowMatching(By.HasText("42")).Approve.ClickAsync();

// The customer waits until the admin's change is reflected, then verifies it.
await customer.Orders.RowMatching(By.HasText("42")).Status.Should.HaveTextAsync("Approved");

The Should* methods on an element poll until they pass, so they double as cross-session waits. For any other condition, including one that spans sessions, use WaitUntilAsync. Its description defaults to the predicate's source text:

await customer.WaitUntilAsync(async ct =>
await customer.Orders.RowMatching(By.HasText("42")).Status.TextAsync(ct) == "Approved");

await customer.WaitUntilAsync(
async _ => await customer.Total.TextAsync() == "€0.00",
timeout: TimeSpan.FromSeconds(10));

Sessions can also be declared on the test, so setup creates (and optionally navigates) them before the body. [WebSession] defaults to Order = -10, so it runs before [LoginAs]:

[WebSession("Admin", Open = "/back-office")]
[WebSession("Customer")]
[LoginAs<BackOfficeLogin>("billing.admin", Session = "Admin")]
public async Task ...() { ... }

Application = "ControlPlane" on the attribute names the application the session targets, defaulting to the test's application and then the session name. A relative Open resolves against the session's application address, ProtoTest:Applications:{application}:BaseUrl, optionally joined with the named endpoint.

Dropping down to the driver​

When you need something the model doesn't offer, get the native backend:

var backend = await Proto.Context.Web().GetBackendAsync<PlaywrightWebBackend>();
await backend.Page.SetContentAsync(html);

GetBackendAsync creates the browser if needed. The synchronous GetBackend throws until the backend exists. Asking for the wrong type throws WebBackendCapabilityException.

The tasks​

A page object describes the page, and a test drives it. These are the smallest page objects behind the sign-in test at the top of this page. The sample's full journey is in WebJourney.cs:

public sealed class LoginPage : WebPage
{
public LoginForm Form => Component<LoginForm>(By.TestId("login"));
}

public sealed class LoginForm : WebComponent
{
public WebElement Username => Element(By.Label("Username"));
public WebElement Password => Element(By.Label("Password"));
public WebElement Submit => Element(By.Role(WebRole.Button, "Sign in"));
public WebElement Status => Element(By.Role(WebRole.Status));
}

The driving test is the one at the top of this page. The flow behind it records one web.flow operation with a step per action, and the final read records assert.web.

Going further​

  • Pages and components - page objects, scoping, lazy collections and tables.
  • Page coverage - visited versus verified, report rows, and the inventory sources.
  • Locators - roles, labels, text, And, and how each backend translates them.
  • Actions and assertions - Should/ShouldNot, polling, redaction.
  • Flows - name a sequence of steps so the trace reads as one operation.
  • Logging in - an application-owned IWebLoginStrategy applied with [LoginAs].
  • Waits and middleware - your application's notion of ready, applied once.
  • Diagnostics and artifacts - screenshots, console output, traces and the full trace reference.
  • ASP.NET Core - when the application is hosted in-process, its page inventory comes with it.

In the trace and coverage​

Every operation is a ProtoTest trace entry; a passing assertion is also what makes a page count as covered:

WhatRecorded as
Navigate, click, fill, check, select, press, reads, countweb.navigate, web.click, web.fill, web.check, web.select_option, web.press, web.read_text, web.read_value, web.is_visible, web.is_enabled, web.is_checked, web.count
Assertionsassert.web with web.expectation, web.assert.negated and web.assert.timeout
Wait-untilweb.wait.until
Flowsweb.flow with web.flow.step_count
Loginweb.login during setup
Session lifetimeweb.session.initialize / web.session.complete
Backend callchild web.backend.execute, linked by web.correlation_id
Coverageobservations web.page.visited, web.page.verified, web.page.available

The complete tables for common attributes, backend events, artifacts and the Selenium diagnostics schema live on Diagnostics and artifacts.

Page coverage​

Coverage for a browser journey is measured in pages, not lines: a page counts as covered only when a test verified something on it. The visit-to-verified states, the report rows, and the inventory sources live on Page coverage.

Skip​

  • AddWeb(...) registers a browser capability named Playwright or Selenium, so [RequiresCapability(ProtoCapabilityKinds.Browser, CapabilityName = "…")] proves the backend is composed. The required capability name must match the backend package.
  • [RequiresPlaywrightBrowser] is the stronger gate: it probes the installed browser without launching one and skips with a reason. A recognized channel is accepted as-is, because only a real launch can resolve a system browser; InstallBrowsers = true always passes.
  • Selenium ships no browser probe. Combine the capability gate with a try/catch around the first session that uses the browser, as shown in skip conditions.
  • The core web package registers no skip condition of its own: sessions are per-test, and the driver is only created on first use.

Limits​

  • One backend per host. Resolving zero or more than one IWebBackendFactory throws InvalidOperationException, and AddWebBackend keeps the first registration.
  • HasText cannot stand alone: it is a filter and must be composed with And. Selenium also accepts only HasText on the right and rejects a By.Css on the left. Playwright accepts more combinations. See Locators.
  • WaitUntilAsync only absorbs WebElementResolutionException and WebActionabilityException; any other exception fails it immediately.
  • Scanner: a relative Source may not leave the test assembly's base directory. Nuxt 2 underscore dynamics are not mapped, only absolute route literals are collected, and there is no runtime React discovery.
  • Vue discovery latches after the first non-null route table, so a router that later adds routes in the same session is not re-read.
  • Page origin: an external redirect contributes no visited or verified coverage, and a backend that cannot report an address still passes the test.
  • Playwright:
    • The browser pool belongs to one test. Identical launch options share a process only inside that test.
    • Reads and actions use Playwright's own auto-waiting, up to ActionTimeout (5 s by default). A timeout becomes the same resolution or actionability exception Selenium raises.
    • InstallBrowsers does nothing when Channel is set.
    • Trace groups run one at a time, and are skipped when TraceRetention = Off.
    • Console, page-error and request-failure text is cut at 4096 characters.
    • The skip check starts the Playwright driver.
  • Selenium:
    • One driver per session, without pooling, so sessions share no cookies or storage.
    • Native failures surface as WebActionabilityException after ActionTimeout.
    • CheckAsync and SelectOptionAsync check the selected state after the click. A click the page ignored fails, as with Playwright's auto-wait, instead of passing silently.
    • SelectOptionAsync requires exactly one option with the requested value.

Writing a backend​

A hand-written backend composes the same helpers the shipped backends use, so its behavior matches theirs:

IProtoHostBuilder AddWebBackend(this IProtoHostBuilder builder, IWebBackendFactory factory);

IProtoHostBuilder AddWebMiddleware<TMiddleware>(this IProtoHostBuilder builder)
where TMiddleware : class, IWebOperationMiddleware;

IProtoHostBuilder AddWebWait<TCondition>(
this IProtoHostBuilder builder,
WebWaitTiming timing,
TimeSpan? timeout = null, // default 5 s
TimeSpan? pollInterval = null, // default 50 ms
params WebOperationKind[] operations) // default Navigate, Click, Fill
where TCondition : class, IWebWaitCondition;
  • WebBackendOptions.Resolve<TOptions>(context, configure, validate) - the options precedence (code callback, started infrastructure, ProtoTest:Web:{backend} section, validation).
  • WebBackendErrors - the resolution and actionability failures (NotPresent, NotActionable, MultipleMatch) with the documented wording.
  • WebFailureArtifacts.CaptureAsync(…) - the screenshot, DOM and location attachments with the documented naming rule; TraceArtifactFailure records a capture that itself failed.
  • WebProbeLoop with WebProbe - the retry loop whose observation form treats WebElementResolutionException and WebActionabilityException as "not yet", polling at the backend's PollInterval.
  • WebBackendDefaults - the 5 s action timeout and 50 ms poll interval defaults; IWebBackend.PollInterval returns WebBackendDefaults.DefaultPollInterval unless the backend has its own interval option.
  • WebArtifactNames.SafeName - the lowercased, dash-sanitized identifier rule for artifact file names.
  • WebMediaTypes.Guess - the best-effort media type for a downloaded file, with WebMediaTypes.Default as the fallback.

Next​