Playwright
There is a NuGet package to help with Playwright: TUnit.Playwright
Once that is installed, a test can be as simple as:
public class Tests : PageTest
{
[Test]
public async Task Test()
{
await Page.GotoAsync("https://www.github.com/thomhurst/TUnit");
}
}
By inheriting from PageTest, the base class handles setting up and disposing your playwright objects for you.
The following properties are available to use:
PageContextBrowserPlaywright
A Real Page Interaction Test
public class LoginPageTests : PageTest
{
[Test]
public async Task Login_Button_Is_Visible()
{
await Page.GotoAsync("https://example.com/login");
var loginButton = Page.Locator("button#login");
await Assert.That(await loginButton.IsVisibleAsync()).IsTrue();
}
[Test]
public async Task Successful_Login_Redirects_To_Dashboard()
{
await Page.GotoAsync("https://example.com/login");
await Page.FillAsync("#username", "testuser");
await Page.FillAsync("#password", "password123");
await Page.ClickAsync("button#login");
await Page.WaitForURLAsync("**/dashboard");
var heading = await Page.Locator("h1").TextContentAsync();
await Assert.That(heading).IsEqualTo("Dashboard");
}
}
Configuring Browser Options
Override the BrowserName property to control which browser is launched. The possible values are:
chromium(default, unless theBROWSERenvironment variable names another; see Headed Runs and Browser Selection)firefoxwebkit
Pass BrowserTypeLaunchOptions to the base constructor to configure headless mode, slow motion, and other launch settings:
public class HeadlessChromeTests : PageTest
{
public HeadlessChromeTests() : base(new BrowserTypeLaunchOptions
{
Headless = true,
SlowMo = 100 // adds 100ms delay between actions for debugging
})
{
}
public override string BrowserName => "chromium";
[Test]
public async Task Page_Title_Matches()
{
await Page.GotoAsync("https://example.com");
var title = await Page.TitleAsync();
await Assert.That(title).Contains("Example");
}
}
Headed Runs and Browser Selection
Switch to a visible browser or a different browser for a whole run with environment variables, without changing code. Like the Microsoft Playwright test adapters, TUnit reads:
HEADED=1(ortrue): launch visible browsers instead of headless ones.BROWSER=chromium|firefox|webkit: the defaultBrowserName. An unknown value fails the test with an error naming the variable.
HEADED=1 dotnet run --project Tests
BROWSER=firefox dotnet run --project Tests
Both apply to every BrowserTest, ContextTest, PageTest, BrowserFixture, ContextFixture and PageFixture consumer. CI runs without the variables stay headless on Chromium. Make sure the selected browser is installed, for example with pwsh bin/Debug/netX/playwright.ps1 install firefox, replacing netX with your test project's target framework.
HEADED only affects browsers launched locally. When PLAYWRIGHT_SERVICE_ACCESS_TOKEN and PLAYWRIGHT_SERVICE_URL are set, TUnit connects to the remote Playwright service instead, which ignores launch options, so HEADED=1 has no effect. BROWSER still selects the remote browser.
Headless mode is decided in this order:
- An explicit
BrowserTypeLaunchOptions.Headless, whether passed to the base constructor, returned fromBrowserFixture.GetLaunchOptions(), or set inPlaywrightSettings.DefaultBrowserTypeLaunchOptions. HEADED=1.- Playwright's default: headless.
PWDEBUG=1 is applied by Playwright itself and always opens a visible browser, even when Headless is true. Leave Headless unset to let HEADED decide. Overriding BrowserName likewise pins the browser regardless of BROWSER. The variables are read when a browser launches, so a [Before(TestSession)] hook can still set them.
Limiting Parallel Browser Tests
Browser tests can be resource-intensive. Use [ParallelLimiter<T>] to control how many run concurrently:
public class BrowserParallelLimit : IParallelLimit
{
public int Limit => 2;
}
[ParallelLimiter<BrowserParallelLimit>]
public class HeavyBrowserTests : PageTest
{
[Test]
public async Task Test_A()
{
await Page.GotoAsync("https://example.com/a");
await Assert.That(await Page.TitleAsync()).IsNotNull();
}
[Test]
public async Task Test_B()
{
await Page.GotoAsync("https://example.com/b");
await Assert.That(await Page.TitleAsync()).IsNotNull();
}
}
This ensures at most 2 tests from this class run at the same time, preventing browser resource exhaustion.
Recording Videos
Add [RecordVideo] to an individual test that inherits from ContextTest or PageTest, or uses per-test ContextFixture or PageFixture instances. Recording is enabled only for the selected test methods:
public class LoginPageTests : PageTest
{
[Test]
[RecordVideo]
public async Task Login_Button_Is_Visible()
{
await Page.GotoAsync("https://example.com/login");
var loginButton = Page.Locator("button#login");
await Assert.That(await loginButton.IsVisibleAsync()).IsTrue();
}
}
Once the test finishes, its recording is renamed to match the test (and attempt, if the test was retried) and attached to the test result, so it's easy to find in CI output alongside a dozen other recordings.
The same attribute works with composition:
public class CheckoutTests
{
[ClassDataSource<PageFixture>]
public required PageFixture BrowserPage { get; init; }
[Test]
[RecordVideo]
public async Task Checkout_Page_Is_Visible()
{
await BrowserPage.Page.GotoAsync("https://example.com");
await Assert.That(await BrowserPage.Page.Locator("body").IsVisibleAsync()).IsTrue();
}
}
Keep ContextFixture and PageFixture private to each test with their default SharedType.None. The underlying BrowserFixture can still be shared. Recording fixtures shared between tests are rejected because their videos cannot be attributed reliably to one test.
With [RecordVideo], each retry gets fresh contexts and pages before setup hooks run. Recordings are finalized after teardown hooks and attached with the attempt number. Multiple page fixtures and pages closed early are supported. Without the attribute, fixtures retain their normal lifetime across retries.
Overrides of ContextFixture.GetContextOptions() retain their custom options; recording settings are applied to a copy. If overriding fixture initialization or disposal, call the base implementation to preserve recording and cleanup.
Setting RecordVideoDir through DefaultBrowserNewContextOptions or GetContextOptions() without [RecordVideo] enables Playwright recording with the fixture's normal lifetime. These videos keep Playwright's filenames and are not automatically attached to a test result, because shared fixtures can record more than one test. Manage those artifacts yourself, or use [RecordVideo] with per-test fixtures for automatic naming and attachment.
Pass constructor arguments to control where recordings are written and the viewport size used while recording:
public class LoginPageTests : PageTest
{
[Test]
[RecordVideo(path: "videos", width: 1920, height: 1080)]
public async Task Login_Button_Is_Visible()
{
await Page.GotoAsync("https://example.com/login");
var loginButton = Page.Locator("button#login");
await Assert.That(await loginButton.IsVisibleAsync()).IsTrue();
}
}
path- directory recordings are written to, resolved relative to the test application's working directory unless given as an absolute path. Defaults to"playwright-artifacts".width- viewport width used for the recording, in pixels. Defaults to1280.height- viewport height used for the recording, in pixels. Defaults to1400.
Screenshots and Traces on Failure
Add [CaptureOnFailure] to capture a full-page screenshot of each open page and a Playwright trace when a test fails, times out or is cancelled. Both files are attached to the test result. Passing and skipped tests discard the trace and write no files.
[CaptureOnFailure]
public class LoginPageTests : PageTest
{
[Test]
public async Task Login_Button_Is_Visible()
{
await Page.GotoAsync("https://example.com/login");
await Assert.That(await Page.Locator("button#login").IsVisibleAsync()).IsTrue();
}
}
The attribute can go on a method, a class or an assembly ([assembly: CaptureOnFailure]); the closest one applies. It works for contexts created by ContextTest, PageTest and per-test ContextFixture and PageFixture instances.
Files are named <test id hash>_<display name>.png and <test id hash>_<display name>.trace.zip. The hash comes from the unique test id, so parallel tests in one class and [Arguments] variants of one method never write to the same file. Retries add -attemptN, and a test with more than one open page gets one screenshot per page (-page1, -page2). Open a trace with pwsh bin/Debug/netX/playwright.ps1 show-trace <file> or at trace.playwright.dev.
public class CheckoutTests : PageTest
{
[Test]
[CaptureOnFailure(path: "failures", Trace = false)]
public async Task Checkout_Page_Is_Visible()
{
await Page.GotoAsync("https://example.com/checkout");
await Assert.That(await Page.Locator("body").IsVisibleAsync()).IsTrue();
}
}
path- directory files are written to, resolved relative to the test application's working directory unless given as an absolute path. Defaults to"playwright-artifacts".Screenshot- take a full-page screenshot of each open page. Defaults totrue.Trace- record a trace with screenshots, DOM snapshots and sources. Defaults totrue.
Capture runs after your [After(Test)] hooks and before the context closes, so a test whose body passes but whose teardown hook throws is captured too. As with [RecordVideo], captured ContextFixture and PageFixture instances are created again for each retry attempt. Fixtures shared between tests are skipped, because no single test owns them. Fixture capture needs .NET 8 or later; on .NET Framework (the netstandard2.0 build) only ContextTest and PageTest are captured, because TUnit cannot tell whether a fixture is shared there. If a screenshot or trace cannot be saved, for example because the browser crashed, the reason is written to the test's error output and the test result is not changed.
Shared Browser Crashes
When a shared browser disconnects without TUnit closing it, for example because it crashed, every test that uses it fails with TargetClosedException: Target page, context or browser has been closed. TUnit records when the browser disconnected and reports it on each affected test:
- A fixture that cannot create its context or page fails with
The shared chromium browser disconnected at 2026-10-31 12:01:03.412 UTC, before this failure..., with the Playwright error as the inner exception. - A test that fails with a Playwright error after the disconnect gets the same note in its error output.
BrowserFixture.DisconnectedAt exposes the time. To keep a long run going after a crash, let the shared BrowserFixture launch a new browser for the next context it creates:
public class PlaywrightSetup
{
[Before(HookType.TestDiscovery)]
public static Task Configure(BeforeTestDiscoveryContext context)
{
context.Settings.PlaywrightSettings.RelaunchDisconnectedBrowser = true;
return Task.CompletedTask;
}
}
Tests that inherit BrowserTest, ContextTest or PageTest already get a new browser after a failed test.
Trace Context Propagation
TUnit can add W3C traceparent and baggage headers from the current test to the requests your page sends to the application under test. This lets the application's telemetry be correlated with the test that triggered it.
Headers are sent only to the origins under test. By default this is the origin of BaseURL in the context options. If no base URL is set, no headers are sent. List the origins explicitly with TraceContextOrigins:
public class DashboardTests : PageTest
{
public override IReadOnlyList<string>? TraceContextOrigins => ["https://localhost:7217"];
[Test]
public async Task Dashboard_Loads()
{
await Page.GotoAsync("https://localhost:7217/dashboard");
await Assert.That(await Page.TitleAsync()).IsEqualTo("Dashboard");
}
}
ContextFixture has the same property as a protected virtual member. Override PropagateTraceContext to return false to disable propagation.
Requests to other origins, such as web fonts or third-party APIs, never receive the headers. traceparent is not a CORS-safelisted header, so sending it to a third-party origin causes a CORS preflight that most servers reject, and it also leaks the test's trace id.
The headers are added by a context route, which has these limits:
- Playwright disables its HTTP cache for contexts with routes. Set
PropagateTraceContexttofalseto keep the cache. - Routes you add later run before TUnit's route. Call
route.FallbackAsync()instead ofroute.ContinueAsync()in them to keep the headers. - Context routes do not see WebSocket traffic or requests handled by a service worker, so those requests get no headers. To route requests that a service worker would handle, set
ServiceWorkers = ServiceWorkerPolicy.Blockin the context options. - When a request to an origin under test is redirected, Playwright keeps the headers on the redirected request, even if it goes to another origin.
For full Playwright API details, see the Playwright for .NET documentation.