Test Frameworks
Four packages give tests base classes that manage browsers for them, one for each of xUnit, NUnit, MSTest, and TUnit:
- One browser launch per test run, shared by every test class, unless a class configures a launch of its own.
- An isolated browser and page for each test, closed when the test ends, so that no cookie or storage carries over from another test.
- Screenshots of a failed test's pages, and videos and a trace if asked for, saved to files and attached to the test's result.
- The browser chosen by environment variables, so that a CI matrix runs the same tests in each browser, or in code.
| Package | Framework |
|---|---|
Dramaturge.Xunit |
xUnit v3, 3.2.2 and later |
Dramaturge.NUnit |
NUnit 4 and later |
Dramaturge.MSTest |
MSTest 4 and later |
Dramaturge.TUnit |
TUnit 1.6 and later |
To start a test by recording it, dramaturge codegen writes a PageTest class for any of the four; see Code Generation.
The packages are for .NET 10. Each has the same two base classes, with the same members:
PageTestgives each test aBrowserand aPagein it. Most tests use it.BrowserTestgives each test theGroup, andNewBrowserAsyncto open browsers, for tests that need several, such as two users of a chat.
Registering the Shared Browser
Each test project registers the browser its test classes share, once. The registration closes the browser when the run ends; without it, the first test that needs the browser fails, with a message giving the code to add.
dotnet add package Dramaturge.Xunit
[assembly: AssemblyFixture(typeof(Dramaturge.Xunit.DramaturgeAssemblyFixture))]
Writing Tests
Derive the test class from PageTest, and use Page. Expect is a static method of Assertions, brought in with using static Dramaturge.Assertions;.
public class SignInTests : PageTest
{
[Fact]
public async Task SignsIn()
{
await this.Page.NavigateAsync("https://example.com/sign-in");
await this.Page.GetByLabel("Email").FillAsync("someone@example.com");
await this.Page.GetByRole("button", "Sign in").ClickAsync();
await Expect(this.Page.GetByRole("heading", "Welcome")).ToBeVisibleAsync();
}
}
A test that needs several isolated browsers derives from BrowserTest, and opens each with NewBrowserAsync. The examples in the rest of this article use xUnit; the members are the same in every package.
public class ChatTests : BrowserTest
{
[Fact]
public async Task MessageReachesTheOtherUser()
{
// Each browser has its own cookies and storage, so each signs in as a different user.
Page alice = await (await this.NewBrowserAsync()).NewPageAsync();
Page bob = await (await this.NewBrowserAsync()).NewPageAsync();
await alice.NavigateAsync("https://example.com/chat?user=alice");
await bob.NavigateAsync("https://example.com/chat?user=bob");
await alice.GetByLabel("Message").FillAsync("Hello, Bob");
await alice.GetByRole("button", "Send").ClickAsync();
await Expect(bob.GetByRole("log")).ToContainTextAsync("Hello, Bob");
}
}
The browsers a test opens are closed when it ends, whether it passes or fails. Group, Browser, and Page are available from when the test starts, and throw InvalidOperationException if read before.
Choosing the Browser
The shared browser is launched from BrowserLauncher.ConfigureFromEnvironment: Chrome, stable, and headless, unless environment variables say otherwise. Browsers are downloaded on first use, as they are anywhere else, and the CHROME_EXECUTABLE and other executable variables still pick an installed browser, as Browser Setup describes.
| Variable | Values | Default |
|---|---|---|
DRAMATURGE_BROWSER |
Chrome, Firefox, Edge, Safari |
Chrome |
DRAMATURGE_CHANNEL |
Stable, Beta, DeveloperPreview, Alpha, ExtendedSupport |
Stable |
DRAMATURGE_HEADED |
1 or true shows the browser, for debugging |
Headless |
A CI matrix sets DRAMATURGE_BROWSER to run the same tests in each browser:
strategy:
matrix:
browser: [chrome, firefox]
steps:
- run: dotnet test
env:
DRAMATURGE_BROWSER: ${{ matrix.browser }}
Configuring in Code
A test class overrides members of its base class:
| Member | Sets | For |
|---|---|---|
BrowserOptions |
The browser options of each browser a test opens without options of its own | Each test |
ConfigureLauncher |
The launcher, given the one the environment chooses; return it changed, or another | A class's own browser launch |
GroupOptions |
The group's options, such as timeouts | A class's own browser launch |
public class MobileLayoutTests : PageTest
{
// Applies to the browser of each test of this class.
protected override BrowserOptions? BrowserOptions => new() { Viewport = new Viewport() { Width = 390, Height = 844 }, Locale = "en-GB" };
// Overriding the launcher or the group's options gives the class a browser of its own, launched for its tests.
protected override BrowserLauncherBuilder ConfigureLauncher(BrowserLauncherBuilder builder)
{
return builder.WithLaunchTimeout(TimeSpan.FromMinutes(2));
}
[Fact]
public async Task MenuIsCollapsed()
{
await this.Page.NavigateAsync("https://example.com");
await Expect(this.Page.GetByRole("button", "Menu")).ToBeVisibleAsync();
}
}
A class that overrides ConfigureLauncher or GroupOptions has a browser launch of its own, shared by its tests and closed when the class finishes, because its tests need a browser launched differently from the others. To configure the shared browser, register a class derived from the shared browser's fixture in place of it, overriding the same members:
// Registered in place of DramaturgeAssemblyFixture:
// [assembly: AssemblyFixture(typeof(ShopFixture))]
public class ShopFixture : DramaturgeAssemblyFixture
{
protected override DramaturgeOptions? GroupOptions => new() { ActionTimeout = TimeSpan.FromSeconds(10), TestIdAttribute = "data-test" };
protected override BrowserLauncherBuilder ConfigureLauncher(BrowserLauncherBuilder builder)
{
string? grid = Environment.GetEnvironmentVariable("SELENIUM_GRID_URL");
return grid is null ? builder : BrowserLauncher.Configure(BrowserKind.Chrome).LaunchUsingRemoteGrid(new Uri(grid));
}
}
With NUnit, derive the set-up fixture from DramaturgeSetUpFixture as the registration does; with MSTest and TUnit, pass an object of the derived class to DramaturgeAssemblyFixture.Register in place of new().
A remote grid or a running browser cannot be launched headless, so a launcher from the environment changed to connect to one needs WithHeadlessOption(false); or start from BrowserLauncher.Configure, as the example does.
Screenshots of Failed Tests
When a test fails, every open page of every browser it opened is captured, before the browsers are closed, to TestResults/Dramaturge in the test assembly's directory: page-1.png, page-2.png, and so on, in a directory named for the test, which replaces any earlier run's. The directory's name keeps the letters, digits, ., -, and _ of the test's name, with others replaced by _, and at most 120 characters of it.
Each screenshot is also attached to the test's result, where the framework's reports and IDE show it:
| Framework | Attached with |
|---|---|
| xUnit | TestContext.AddAttachment, as an image/png attachment, video/webm for a video, or application/zip for a trace |
| NUnit | TestContext.AddTestAttachment |
| MSTest | TestContext.AddResultFile |
| TUnit | TestContext.Output.AttachArtifact |
A page that cannot be captured, and a browser that cannot be closed, are reported as a warning (xUnit) or in the test's output (the others), and never change the test's result.
ScreenshotOnFailure turns capturing off, and ArtifactsDirectory changes the directory, for a class in its constructor or for one test:
public class ReportTests : PageTest
{
public ReportTests()
{
this.ArtifactsDirectory = Path.Combine(AppContext.BaseDirectory, "screenshots");
}
[Fact]
public async Task PrintsWithoutScreenshots()
{
// This test's failures are not captured.
this.ScreenshotOnFailure = false;
await this.Page.NavigateAsync("https://example.com/report");
await Expect(this.Page.GetByRole("button", "Print")).ToBeEnabledAsync();
}
}
Videos
With VideoOnFailure on, every page a test opens is recorded from when it opens, in every browser the test opens, including popups. When the test fails, each page's video is saved beside the screenshots, as page-1.webm and so on, and attached to the test's result; when it passes, the videos are deleted. VideoOptions sets the videos' size and frame rate. Turn it on before the test opens a page, which for a PageTest means in the constructor:
public class CheckoutTests : PageTest
{
public CheckoutTests()
{
this.VideoOnFailure = true;
this.VideoOptions = new VideoRecordingOptions() { Width = 1280, Height = 720 };
}
[Fact]
public async Task PlacesAnOrder()
{
await this.Page.NavigateAsync("https://example.com/checkout");
await this.Page.GetByRole("button", "Place order").ClickAsync();
await Expect(this.Page.GetByRole("heading", "Thank you")).ToBeVisibleAsync();
}
}
Pages are numbered in the order they opened, across all of a test's browsers, so a page's screenshot and video share a number. A page closed before the test failed has a video but no screenshot, so the screenshots' numbers can skip it: page-1.png and page-3.png, beside page-1.webm, page-2.webm, and page-3.webm.
A browser that cannot record video, such as Chrome, is reported once, and the test runs as usual. A video that cannot be recorded or saved is reported for a failed test, as is one a browser on another machine, such as a remote grid's, wrote there; it is not copied from that machine.
Traces
With TraceOnFailure on, every browser a test opens records a trace from when it opens. When the test fails, the browsers' traces are merged into one, trace.zip, beside the screenshots, and attached to the test's result; each browser is a context of its own in the viewer, on one timeline. When the test passes, the traces are deleted. Turn it on before the test opens a browser, which for a PageTest means in the constructor:
public class CartTests : PageTest
{
public CartTests()
{
this.TraceOnFailure = true;
}
[Fact]
public async Task AddsAnItem()
{
await this.Page.NavigateAsync("https://example.com/catalog");
await this.Page.GetByRole("button", "Add to cart").ClickAsync();
await Expect(this.Page.GetByTestId("cart-count")).ToHaveTextAsync("1");
}
}
TraceOptions sets what the traces record; by default they have snapshots, screenshots, and sources. A trace that cannot start is reported for a failed test, and the test runs as usual. Open trace.zip at trace.playwright.dev or with npx playwright show-trace.
Lifecycle and Parallel Tests
Each package hooks into its framework's own lifecycle. A test class can add its own set-up and clean-up as usual:
| Framework | Browsers are opened and closed in | Notes |
|---|---|---|
| xUnit | InitializeAsync and DisposeAsync (IAsyncLifetime) |
An override must call the base method. |
| NUnit | SetUpBrowsersAsync and TearDownBrowsersAsync ([SetUp], [TearDown]) |
An override must call the base method. |
| MSTest | SetUpBrowsersAsync, then PageTest.OpenPageAsync ([TestInitialize]), and TearDownBrowsersAsync ([TestCleanup]) |
MSTest runs these before the test class's own [TestInitialize] methods and after its [TestCleanup] methods. |
| TUnit | SetUpBrowsersAsync and TearDownBrowsersAsync, on TUnit's test start and end events |
An override of SetUpBrowsersAsync must call the base method. |
Tests can run in parallel, each with its own browser. xUnit, MSTest, and TUnit create a test class object for each test. NUnit creates one for a fixture's tests, and a test's browsers are kept on it, so run the tests of one fixture in parallel only with [FixtureLifeCycle(LifeCycle.InstancePerTestCase)].