Union.Playwright.NUnit
0.8.1
See the version list below for details.
dotnet add package Union.Playwright.NUnit --version 0.8.1
NuGet\Install-Package Union.Playwright.NUnit -Version 0.8.1
<PackageReference Include="Union.Playwright.NUnit" Version="0.8.1" />
<PackageVersion Include="Union.Playwright.NUnit" Version="0.8.1" />
<PackageReference Include="Union.Playwright.NUnit" />
paket add Union.Playwright.NUnit --version 0.8.1
#r "nuget: Union.Playwright.NUnit, 0.8.1"
#:package Union.Playwright.NUnit@0.8.1
#addin nuget:?package=Union.Playwright.NUnit&version=0.8.1
#tool nuget:?package=Union.Playwright.NUnit&version=0.8.1
Union.Playwright.NUnit
A .NET 9.0 testing framework built on top of Microsoft Playwright and NUnit. It adds Page Object Model infrastructure with URL-based routing, automatic page resolution, reusable components, and dependency injection — so you can stop hand-wiring navigation and page state and focus on writing tests.
Why migrate from raw Playwright?
| Concern | Raw Playwright | Union.Playwright.NUnit |
|---|---|---|
| Page identity | You track which page is open manually | Framework matches the current URL to a registered page type automatically |
| Navigation | await page.GotoAsync("/users/42") — magic strings everywhere |
await service.Go.ToPage<UserProfilePage>() — type-safe, refactor-friendly |
| Path parameters | Assembled by hand: $"/users/{id}/orders/{orderId}" |
Declared once in AbsolutePath, extracted into Data dictionary on match |
| Query parameters | Parsed manually from URL | Extracted automatically into Params dictionary |
| Page components | Locators scattered across test methods | [UnionInit(".selector")] fields initialized automatically when the page activates |
| Reusable lists | Repeated boilerplate for "find all items, iterate" | ListBase<T> + ItemBase give you GetItemsAsync(), FindSingleAsync(), FindRandomAsync() |
| Multi-service tests | Separate setup for each origin | Register multiple UnionService instances; the framework resolves which service owns the current URL |
| Parallel isolation | Manual context management | TestAwareServiceContextsPool provides one browser context per service, thread-safe |
| DI | Not built-in | Full Microsoft.Extensions.DependencyInjection via TestSessionProvider |
Installation
dotnet add package Union.Playwright.NUnit
Then install Playwright browsers:
pwsh bin/Debug/net9.0/playwright.ps1 install
Core concepts
1. Pages
A page is a class that maps to a URL pattern.
public abstract class MyAppPage : UnionPage { }
public class LoginPage : MyAppPage
{
public override string AbsolutePath => "/login";
}
public class UserProfilePage : MyAppPage
{
public override string AbsolutePath => "/users/{userId}";
// Path parameters are extracted automatically
public string UserId => Data["userId"];
}
public class SearchResultsPage : MyAppPage
{
public override string AbsolutePath => "/search";
// Query parameters (?q=playwright) extracted automatically
public string Query => Params["q"];
}
Key points:
AbsolutePathsupports{paramName}placeholders — matched segments populate theDatadictionary.- Query string values populate the
Paramsdictionary. - Override
WaitLoadedAsync()to add custom wait logic (spinners, skeleton screens) that runs after every navigation.
2. Services
A service represents one application under test (one base URL).
public class MyAppService : UnionService<MyAppPage>
{
public MyAppService(IServiceContextsPool pool) : base(pool) { }
public override string BaseUrl => "https://myapp.example.com";
}
On construction the service automatically discovers and registers every non-abstract page class that derives from the base page type (MyAppPage) via reflection. No manual registration needed.
3. Navigation (Go)
// Navigate by type — URL is assembled from the page's AbsolutePath + BaseUrl
var loginPage = await myApp.Go.ToPage<LoginPage>();
// Navigate to a page with path parameters pre-filled
var profile = new UserProfilePage { Data = { ["userId"] = "42" } };
await myApp.Go.ToPage(profile);
// Browser navigates to https://myapp.example.com/users/42
// Raw URL navigation (still actualizes state afterward)
await myApp.Go.ToUrl("https://myapp.example.com/settings");
// Browser controls
await myApp.Go.Refresh();
await myApp.Go.Back();
After every navigation the framework:
- Matches the resulting URL against all registered pages.
- Creates a page instance with extracted
DataandParams. - Calls
page.Activate()— which triggers component initialization. - Calls
page.WaitLoadedAsync().
4. State
// Check what page the browser is on
bool isLogin = myApp.State.PageIs<LoginPage>();
// Get the current page as a specific type (null if it doesn't match)
var profile = myApp.State.PageAs<UserProfilePage>();
Console.WriteLine(profile?.UserId); // "42"
5. Actions (Action)
Click-and-wait methods that interact with the page and actualize browser state in one step. Uses Playwright's native waiting (no polling or Thread.Sleep).
// Click a link and wait for redirect — returns typed page or null
var dashboard = await myApp.Action
.ClickAndWaitForRedirectAsync<DashboardPage>(page.Locator("a.dashboard-link"));
if (dashboard != null)
{
// State is actualized — service knows we're on DashboardPage
myApp.State.PageIs<DashboardPage>().Should().BeTrue();
}
// Click a button and wait for a modal to appear — returns typed modal or null
var confirmDialog = await myApp.Action
.ClickAndWaitForAlertAsync<ConfirmDialog>(page.Locator("button.delete"));
if (confirmDialog != null)
{
// Modal is actualized — available via state as well
confirmDialog.Accept();
}
Both methods return null when the expected result doesn't happen (no redirect, or modal didn't appear within Playwright's timeout). This lets you handle unexpected states gracefully in tests instead of getting timeout exceptions.
6. Components
Components are reusable UI building blocks that attach to pages (or to other components) via the [UnionInit] attribute.
// A simple element wrapper
public class Element : ComponentBase
{
public Element(IUnionPage parentPage, string rootScss)
: base(parentPage, rootScss) { }
}
// A page with auto-initialized components
public class QuestionsPage : MyAppPage
{
public override string AbsolutePath => "/questions";
[UnionInit("div#questions")]
public QuestionList Questions { get; set; }
}
When the page is activated, every field and property marked with [UnionInit] is instantiated automatically. The selector argument is scoped relative to the page's (or parent component's) RootScss, so components compose cleanly.
Lists and items
ListBase<T> and ItemBase handle the common "collection of repeated elements" pattern:
public class QuestionList : ListBase<QuestionItem>
{
public override string ItemIdScss => "div.s-post-summary";
public override string IdAttribute => "data-post-id";
public QuestionList(IUnionPage parentPage, string rootScss)
: base(parentPage, rootScss) { }
}
public class QuestionItem : ItemBase
{
public override string ItemScss =>
$"div.s-post-summary[data-post-id='{this.Id}']";
[UnionInit("h3 a")]
public Element Title { get; set; }
public QuestionItem(IContainer container, string id)
: base(container, id) { }
}
Usage in tests:
var page = await myApp.Go.ToPage<QuestionsPage>();
List<string> ids = await page.Questions.GetIdsAsync();
List<QuestionItem> items = await page.Questions.GetItemsAsync();
QuestionItem single = await page.Questions.FindSingleAsync();
QuestionItem random = await page.Questions.FindRandomAsync();
bool visible = await single.Title.IsVisibleAsync();
7. SCSS selectors
The framework includes a custom selector language that compiles to both CSS and XPath. It extends standard CSS syntax with XPath-only features:
| Syntax | Meaning |
|---|---|
div .child |
Descendant (CSS) |
div > .child |
Direct child (CSS) |
#id, .class |
Standard CSS selectors |
[attr='value'] |
Attribute equals |
[attr~'value'] |
Attribute contains |
[text()='Login'] |
XPath text match |
['~partial'] |
Contains text |
[last()] |
XPath last() |
[1], [2] |
Positional index |
CSS-compatible selectors are emitted as CSS; others fall back to XPath automatically. Use InnerScss() in components to scope relative selectors to the component root.
Test setup
1. Define a test session
A test session groups the services available to a test:
public class MyTestSession : ITestSession
{
public MyAppService MyApp { get; }
public MyTestSession(MyAppService myApp)
{
this.MyApp = myApp;
}
public List<IUnionService> GetServices() => new() { this.MyApp };
}
2. Define a session provider
The provider configures dependency injection:
public class MySessionProvider : TestSessionProvider<MyTestSession>
{
public static readonly MySessionProvider Instance = new();
public override void ConfigureServices(IServiceCollection services)
{
services.AddScoped<MyAppService>();
}
}
3. Write tests
[TestFixture]
public class LoginTests : UnionTest<MyTestSession>
{
protected override TestSessionProvider<MyTestSession> GetSessionProvider()
=> MySessionProvider.Instance;
[Test]
public async Task NavigateToLogin()
{
var app = this.Session.MyApp;
var loginPage = await app.Go.ToPage<LoginPage>();
app.State.PageIs<LoginPage>().Should().BeTrue();
loginPage.Should().NotBeNull();
}
}
UnionTest<T>:
- Inherits from Playwright's
PageTest— you still havethis.Page,this.Context,this.Browser. [SetUp]creates a scoped session;[TearDown]disposes it.- Access the session via
this.Session. - Retrieve a service via
this.GetService<TService>().
Migration guide: before and after
Navigating to a page
Before:
await page.GotoAsync("https://myapp.com/login");
// hope the URL is correct, no compile-time safety
After:
var loginPage = await myApp.Go.ToPage<LoginPage>();
// URL assembled from BaseUrl + AbsolutePath, returns typed page object
Extracting path parameters
Before:
await page.GotoAsync($"https://myapp.com/users/{userId}/orders/{orderId}");
// later, to verify:
var url = page.Url;
var segments = new Uri(url).Segments; // manual parsing
After:
public class OrderPage : MyAppPage
{
public override string AbsolutePath => "/users/{userId}/orders/{orderId}";
public string UserId => Data["userId"];
public string OrderId => Data["orderId"];
}
var orderPage = myApp.State.PageAs<OrderPage>();
var userId = orderPage.UserId; // extracted automatically from URL
Page components
Before:
var submitBtn = page.Locator("#form .submit-button");
var nameInput = page.Locator("#form input[name='username']");
// locators duplicated across every test that touches this page
After:
public class LoginPage : MyAppPage
{
public override string AbsolutePath => "/login";
[UnionInit("#form .submit-button")]
public Element SubmitButton { get; set; }
[UnionInit("#form input[name='username']")]
public Element NameInput { get; set; }
}
// In test — components ready after navigation
var loginPage = await myApp.Go.ToPage<LoginPage>();
await loginPage.SubmitButton.IsVisibleAsync();
Repeated element lists
Before:
var items = page.Locator(".item");
var count = await items.CountAsync();
for (int i = 0; i < count; i++)
{
var title = await items.Nth(i).Locator("h3").TextContentAsync();
// ...
}
After:
var items = await page.ItemList.GetItemsAsync();
foreach (var item in items)
{
var visible = await item.Title.IsVisibleAsync();
// each item is a strongly-typed object with its own components
}
Click and wait for redirect
Before:
await page.Locator("a.dashboard").ClickAsync();
await page.WaitForURLAsync("**/dashboard");
// manual URL check, no page object
After:
var dashboard = await myApp.Action
.ClickAndWaitForRedirectAsync<DashboardPage>(page.Locator("a.dashboard"));
// returns typed page with extracted params, state actualized automatically
Click and wait for modal
Before:
await page.Locator("button.delete").ClickAsync();
await page.Locator(".confirm-dialog").WaitForAsync();
// manual locator management, no typed modal object
After:
var dialog = await myApp.Action
.ClickAndWaitForAlertAsync<ConfirmDialog>(page.Locator("button.delete"));
// returns typed modal, state.ModalWindow also set
dialog?.Accept();
Knowing which page you're on
Before:
Assert.That(page.Url, Does.Contain("/dashboard"));
// fragile string matching
After:
myApp.State.PageIs<DashboardPage>().Should().BeTrue();
// URL pattern matching with parameter extraction
API reference
UnionPage
| Member | Description |
|---|---|
abstract string AbsolutePath |
URL path pattern, e.g. /users/{userId} |
Dictionary<string, string> Data |
Extracted path parameters |
Dictionary<string, string> Params |
Extracted query parameters |
IPage PlaywrightPage |
Underlying Playwright page |
virtual Task WaitLoadedAsync() |
Override for custom load waits |
virtual string RootScss |
Root selector for component scoping |
UnionService<T>
| Member | Description |
|---|---|
abstract string BaseUrl |
Application base URL |
IBrowserGo Go |
Navigation API |
IBrowserAction Action |
Click-and-wait actions API |
IBrowserState State |
Current page state |
IBrowserGo
| Method | Description |
|---|---|
Task<T> ToPage<T>() |
Navigate to page by type |
Task ToPage(IUnionPage page) |
Navigate to pre-configured page instance |
Task ToUrl(string url) |
Navigate to raw URL |
Task Refresh() |
Refresh current page |
Task Back() |
Browser back |
IBrowserAction
| Method | Description |
|---|---|
Task<TPage?> ClickAndWaitForRedirectAsync<TPage>(ILocator) |
Click, wait for URL change, actualize, return typed page or null |
Task<TModal?> ClickAndWaitForAlertAsync<TModal>(ILocator) |
Click, wait for modal visible, actualize, return typed modal or null |
IBrowserState
| Member | Description |
|---|---|
IUnionPage? Page |
Current resolved page |
IUnionModal? ModalWindow |
Current visible modal (detected during actualization) |
T? PageAs<T>() |
Current page cast to T, or null |
bool PageIs<T>() |
Check if current page matches type |
ValueTask ActualizeAsync(IPage page) |
Re-resolve current URL to a page and detect visible modals |
ComponentBase
| Member | Description |
|---|---|
IUnionPage ParentPage |
Owning page |
string RootScss |
Scoped root selector |
ILocator Root |
Playwright locator for root element |
IPage PlaywrightPage |
Underlying Playwright page |
Task<bool> IsVisibleAsync() |
Check component visibility |
string InnerScss(string relative) |
Build scoped child selector |
ListBase<T>
| Member | Description |
|---|---|
abstract string ItemIdScss |
Selector for item elements |
virtual string IdAttribute |
Attribute to use as item ID (null = text content) |
Task<List<string>> GetIdsAsync() |
Get all item IDs |
Task<List<T>> GetItemsAsync() |
Get all items as typed objects |
Task<T> FindSingleAsync() |
Get the single item (asserts exactly one) |
Task<T> FindRandomAsync() |
Get a random item |
Attributes
| Attribute | Description |
|---|---|
[UnionInit(selector)] |
Auto-initialize this field/property as a component with the given selector |
[UnionInit(selector, ComponentName = "...")] |
Same, with a display name |
[UnionInit(selector, FrameXcss = "...")] |
Same, scoped to an iframe |
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 is compatible. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 was computed. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net8.0
- Microsoft.Extensions.Hosting (>= 8.0.0)
- Microsoft.Extensions.Hosting.Abstractions (>= 8.0.0)
- Microsoft.NET.Test.Sdk (>= 17.12.0)
- Microsoft.Playwright.NUnit (>= 1.49.0)
- NUnit (>= 4.2.2)
- NUnit3TestAdapter (>= 4.6.0)
-
net9.0
- Microsoft.Extensions.Hosting (>= 9.0.0)
- Microsoft.Extensions.Hosting.Abstractions (>= 9.0.0)
- Microsoft.NET.Test.Sdk (>= 17.12.0)
- Microsoft.Playwright.NUnit (>= 1.49.0)
- NUnit (>= 4.2.2)
- NUnit3TestAdapter (>= 4.6.0)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 0.9.3-beta | 100 | 4/24/2026 |
| 0.9.2-beta | 88 | 4/24/2026 |
| 0.9.1-beta | 110 | 4/24/2026 |
| 0.9.0-beta | 473 | 4/11/2026 |
| 0.8.1 | 111 | 3/22/2026 |
| 0.8.0 | 124 | 2/12/2026 |
| 0.7.0 | 118 | 2/9/2026 |
| 0.5.2 | 114 | 2/8/2026 |
| 0.5.1 | 129 | 2/7/2026 |
| 0.4.1 | 112 | 2/4/2026 |
| 0.4.0 | 110 | 2/4/2026 |
| 0.3.1 | 124 | 2/3/2026 |
| 0.2.0 | 115 | 2/2/2026 |
| 0.1.2 | 118 | 2/2/2026 |
| 0.1.1 | 109 | 2/2/2026 |
| 0.1.0 | 112 | 2/2/2026 |