Skip to content

feat(playwright): add composition-based fixtures#5840

Open
thomhurst wants to merge 4 commits intomainfrom
feat/playwright-fixture-composition
Open

feat(playwright): add composition-based fixtures#5840
thomhurst wants to merge 4 commits intomainfrom
feat/playwright-fixture-composition

Conversation

@thomhurst
Copy link
Copy Markdown
Owner

Summary

Adds an additive composition-based fixture API to TUnit.Playwright as an alternative to the existing PlaywrightTest / BrowserTest / ContextTest / PageTest inheritance hierarchy. Users who already have their own base classes — or who want fine-grained control over which Playwright objects are shared at which scope — can now compose via [ClassDataSource<T>] instead of inheriting.

public class MyTests
{
    [ClassDataSource<PageFixture>]
    public required PageFixture Page { get; init; }

    [Test]
    public async Task Loads() => await Page.Page.GotoAsync("https://example.com");
}

Each fixture implements IAsyncInitializer/IAsyncDisposable and references the next layer via a property attributed with [ClassDataSource<T>(Shared = …)]:

  • PlaywrightFixture — owns IPlaywright
  • BrowserFixture[ClassDataSource<PlaywrightFixture>(PerTestSession)], owns IBrowser, supports the Microsoft Playwright Service env-var contract
  • ContextFixture[ClassDataSource<BrowserFixture>(PerTestSession)], owns IBrowserContext, performs W3C trace-header propagation
  • PageFixture[ClassDataSource<ContextFixture>], owns IPage

Multi-context use case

Two [ClassDataSource<PageFixture>] properties yield two distinct IBrowserContext instances (cookie / storage isolation) while sharing one IBrowser — useful for testing two accounts interacting:

[ClassDataSource<PageFixture>] public required PageFixture Alice { get; init; }
[ClassDataSource<PageFixture>] public required PageFixture Bob { get; init; }

Refactor

To avoid drift, two pieces of integration logic that previously lived only on the inheritance side were lifted to internal helpers and shared with both surfaces:

  • PlaywrightServiceConnector.LaunchAsync — used by both BrowserService.CreateBrowser and BrowserFixture.InitializeAsync
  • PlaywrightTelemetryHeaders.Merge — used by both BrowserTest.NewContext and ContextFixture.InitializeAsync

The existing PlaywrightTest / BrowserTest / ContextTest / PageTest classes are untouched in their public surface.

Test plan

  • dotnet build TUnit.Playwright/TUnit.Playwright.csproj clean across netstandard2.0;net8.0;net9.0;net10.0
  • Smoke test added to the Playwright template project (TwoContextFixtureTests.cs): two PageFixture injections; sets localStorage in Alice, asserts Bob's localStorage is empty; asserts Alice.Page != Bob.Page, Alice.ContextFixture.Context != Bob.ContextFixture.Context, Alice.…Browser == Bob.…Browser
  • Mirrored snapshot under TUnit.Templates.Tests/Snapshots/.../TUnit.Playwright/ keeps PlaywriteTemplateTests.InstantiationTest green
  • CI pipeline runs the template project via RunPlaywrightTestsModule (after InstallPlaywrightModule) — exercises the smoke test end-to-end against real Chromium

Adds PlaywrightFixture, BrowserFixture, ContextFixture, and PageFixture
as an additive alternative to the inheritance-based PageTest hierarchy.
Each implements IAsyncInitializer/IAsyncDisposable and composes via
[ClassDataSource<T>] property injection, letting users mix shared scopes
(e.g. PerTestSession browser, per-test context) without inheriting from
TUnit base classes and without conflicting with their own base classes.

Multi-account scenarios work naturally: two [ClassDataSource<PageFixture>]
properties yield two distinct IBrowserContext while sharing one IBrowser
via the PerTestSession scope on BrowserFixture.

Extracts Playwright Service launch logic into PlaywrightServiceConnector
and W3C trace-header merge into PlaywrightTelemetryHeaders so both the
inheritance hierarchy (BrowserService, BrowserTest) and the new fixtures
share the same implementation.

Smoke test in the template project covers two-context storage isolation
and verifies the underlying IBrowser is shared.
@codacy-production
Copy link
Copy Markdown

codacy-production Bot commented May 6, 2026

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 14 complexity

Metric Results
Complexity 14

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

Copy link
Copy Markdown
Contributor

@claude claude Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This is a well-designed addition. The composition-based fixture API is a natural complement to the inheritance hierarchy, and the shared-helper extraction (PlaywrightServiceConnector, PlaywrightTelemetryHeaders) is a clean, correct refactor.


Issue 1 — Misleading nullable annotation pattern (all four fixture files)

Every resource-owning fixture follows this pattern:

public IBrowser Browser { get; private set; } = null!;

public virtual async ValueTask DisposeAsync()
{
    if (Browser is not null)   // ← contradicts null!
        await Browser.CloseAsync().ConfigureAwait(false);
}

= null! tells the compiler "trust me, this will never be null," but the if (X is not null) guard immediately contradicts that promise. A reader has to hold both ideas at once: "the field is non-nullable… except it might be null at dispose time." The same tension appears in PlaywrightFixture where Playwright?.Dispose() is used.

Why the suggested fix is better: Declaring the backing field as truly nullable (T?) makes the intent explicit: "this starts unset and is assigned during InitializeAsync." The guard in DisposeAsync then reads naturally as a defensive check for the case where init never ran (e.g., an exception before InitializeAsync completed).

// Preferred in all four fixtures:
public IBrowser? Browser { get; private set; }

public virtual async ValueTask DisposeAsync()
{
    if (Browser is not null)
        await Browser.CloseAsync().ConfigureAwait(false);
}

The null! suppressor is fine when used to satisfy required init-only properties that are guaranteed set by the DI container — but for lazily-initialised resource fields it's the wrong tool.


Issue 2 — Hardcoded PerTestSession scope blocks customisation without subclassing workarounds

BrowserFixture and ContextFixture declare their dependencies at PerTestSession scope directly on their required properties:

// BrowserFixture.cs
[ClassDataSource<PlaywrightFixture>(Shared = SharedType.PerTestSession)]
public required PlaywrightFixture PlaywrightFixture { get; init; }

// ContextFixture.cs
[ClassDataSource<BrowserFixture>(Shared = SharedType.PerTestSession)]
public required BrowserFixture BrowserFixture { get; init; }

Because these are init-only and the attribute is sealed at the property level, a user who wants PerClass or PerAssembly browser sharing cannot override the scope — they'd need to author an entirely new fixture class. Subclassing won't help because you can't replace an attribute on an inherited property.

Why this matters: Power users writing suites that need e.g. one browser per test class (expensive launch amortised across a class, isolated across classes) have no lever to pull without forking the fixtures.

Architectural suggestion: Consider whether the Shared value belongs as a default that the user overrides at the test-class injection site, or whether the scope should be exposed as a way to configure the fixture itself. One option is to document this limitation explicitly in XML doc comments so users know they need their own fixture class to change scope. Another option (more complex) is a marker interface or static factory that lets the user supply the scope when injecting BrowserFixture.

Even if you decide the current defaults are the right trade-off for v1, a doc comment stating the intended defaults and the workaround would prevent confusion.


Minor observation — PageFixture silently defaults to a new ContextFixture per test

PageFixture omits Shared = on its ContextFixture dependency:

[ClassDataSource<ContextFixture>]   // no Shared = ...
public required ContextFixture ContextFixture { get; init; }

This is intentional (it's what makes the two-Alice-and-Bob scenario work), and the TwoContextFixtureTests smoke test demonstrates it well. But it's asymmetric with the PerTestSession declarations further up the chain, and a user who injects two [ClassDataSource<PageFixture>] properties without reading the docs may be surprised to get two browser contexts.

A brief XML doc summary on PageFixture calling out this default would make the intent self-documenting without adding any runtime cost.


Strengths worth keeping

  • DateTime.UtcNow over DateTime.Now.ToUniversalTime() in PlaywrightServiceConnector — correct and cheaper.
  • All public-facing lifecycle methods are virtual, giving subclasses full control.
  • PlaywrightFixture.Expect(…) overloads are a nice ergonomic touch.
  • The TwoContextFixtureTests smoke test is an excellent, runnable specification of the scoping contract.
  • Keeping the existing PlaywrightTest hierarchy untouched avoids breaking changes.

…tRef + scope docs

Template smoke test references new PageFixture/ContextFixture/BrowserFixture
types added in this PR; CI's `dotnet restore` pulls TUnit.Playwright 1.43.x
from nuget.org, which doesn't have them yet. Adds the same Directory.Build.props
swap pattern already used for TUnit.Aspire so solution builds resolve to the
local TUnit.Playwright project, while end-user template instantiations keep
the floating PackageReference.

Also documents the hardcoded SharedType on BrowserFixture/ContextFixture
property attributes (cannot be overridden via subclassing — author a new
fixture class to change scope) and the SharedType.None default on PageFixture
that enables two-context-per-test scenarios.
@thomhurst
Copy link
Copy Markdown
Owner Author

Thanks for the review.

Issue 1 (null! field pattern) — pushing back. Switching Browser/Context/Page to nullable would force every consumer to deal with T? at the API surface (fixture.Browser!.NewContextAsync(...) everywhere, or null-check noise in test code). The current pattern matches every fixture-style class already in this repo — PlaywrightTest.Playwright, BrowserTest.Browser, ContextTest.Context, PageTest.Page, WorkerAwareTest._currentWorker, and the same applies in TUnit.Aspire.AspireFixture. Diverging here would create two conventions side-by-side. The dispose null-check is genuinely defensive (covers init-throws-before-assignment), and null! accurately describes the steady-state contract from the consumer's perspective. Happy to revisit if you'd like to align all fixture-style types together in a separate PR.

Issue 2 (hardcoded scope on inherited init properties) — fair call, added doc comments to BrowserFixture and ContextFixture calling out that the scope is not overridable via subclassing and the workaround is to author a new fixture class. Commit 0a52084.

Minor (PageFixture default scope) — added the doc note explaining SharedType.None is intentional for two-context-per-test scenarios. Same commit.

Also fixed the unrelated CI failure in the same commit: the template smoke test references new types added in this PR, but CI's restore pulls TUnit.Playwright 1.* from nuget.org which doesn't have them yet. Added a Directory.Build.props swap mirroring the existing TUnit.Aspire pattern, so solution builds resolve to the local project while end-user dotnet new instantiations keep the floating PackageReference.

Copy link
Copy Markdown
Contributor

@claude claude Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up Review — Round 2

Previous findings: status check

Issue 1 (null! contradiction) — Author's pushback is reasonable: diverging from the established PlaywrightTest/BrowserTest/AspireFixture pattern inside a single PR would create two conventions. Accepting the deferral to a separate alignment PR. No further action needed here.

Issue 2 (hardcoded scope docs) — XML doc comments added to BrowserFixture and ContextFixture. ✓ Confirmed.

Minor (PageFixture default scope) — XML doc explaining SharedType.None is intentional. ✓ Confirmed. Clear and accurate.


New finding: ContextFixture.GetContextOptions applies opinionated defaults absent from the inheritance hierarchy

// ContextFixture.cs — new composition path
protected virtual BrowserNewContextOptions GetContextOptions() =>
    new() { Locale = "en-US", ColorScheme = ColorScheme.Light };
// BrowserTest.cs — existing inheritance path
public async Task<IBrowserContext> NewContext(BrowserNewContextOptions options)
{
    options = PlaywrightTelemetryHeaders.Merge(options, PropagateTraceContext);
    // no locale / colour-scheme defaults applied

BrowserTest leaves Locale and ColorScheme at the browser default. ContextFixture silently pins them to en-US / Light. A user migrating a test suite from ContextTest : BrowserTest to [ClassDataSource<ContextFixture>] will get different context behaviour without any indication — locale-sensitive assertions or media-query tests could start failing unexpectedly.

Suggested fix (pick one):

  • Change the default to new() (no pinned locale/colour) and note the override point in a doc comment — keeps parity with the inheritance path and makes the intent explicit.
  • Or keep the opinionated defaults but add an XML doc note on GetContextOptions documenting that these diverge from the BrowserTest baseline and why they were chosen.

Either is fine; the second option is defensible if the defaults were deliberate UX hardening, but users need to know about the difference.


Minor: PlaywrightServiceConnector silently ignores BrowserTypeLaunchOptions in service mode

The options parameter is passed in from callers (who may have configured headless, slow-mo, etc.) but is never forwarded to ConnectAsyncBrowserTypeConnectOptions is used instead, which is correct for a remote session. However, the silent drop can confuse callers who do configure launch options. A one-line comment would help:

// BrowserTypeLaunchOptions are local-only; ConnectAsync uses BrowserTypeConnectOptions.
return await browserType.ConnectAsync(wsEndpoint, connectOptions).ConfigureAwait(false);

Minor: missing newline at end of Directory.Build.props

The diff shows \ No newline at end of file. Trivial, but consistent with the rest of the file.


Strengths

The refactoring is clean and well-scoped. PlaywrightServiceConnector and PlaywrightTelemetryHeaders are good internal extractions — they eliminate duplication without leaking implementation detail. The TwoContextFixtureTests example is the right kind of smoke test: it verifies the semantic contract (isolated storage, shared browser reference) rather than just checking that types instantiate. The Directory.Build.props CI fix correctly mirrors the Aspire pattern.

The ContextFixture.GetContextOptions delta is the only material concern; everything else is minor polish.

- Update PublicAPI .verified.txt snapshots for the 4 new public fixture
  types (PlaywrightFixture, BrowserFixture, ContextFixture, PageFixture)
  across all four TFMs.
- Add comment in PlaywrightServiceConnector explaining why
  BrowserTypeLaunchOptions are not forwarded to ConnectAsync (local-only;
  remote connect uses BrowserTypeConnectOptions).
- Add trailing newline to Directory.Build.props.
@thomhurst
Copy link
Copy Markdown
Owner Author

Round 2 follow-ups:

New issue (ContextFixture.GetContextOptions defaults) — pushing back. The defaults Locale = "en-US" and ColorScheme = ColorScheme.Light are not new — they're copied verbatim from ContextTest.ContextOptions (TUnit.Playwright/ContextTest.cs:19-21):

public virtual BrowserNewContextOptions ContextOptions(TestContext testContext)
{
    return new() { Locale = "en-US", ColorScheme = ColorScheme.Light, };
}

ContextTest is the inheritance counterpart of ContextFixture (the layer that actually owns the IBrowserContext). The review compared against BrowserTest instead, which is the layer below — BrowserTest doesn't own contexts, so its NewContext(options) accepts whatever the caller passes. A user migrating from ContextTest[ClassDataSource<ContextFixture>] keeps the exact same defaults. Skipping this one.

Minor (launch-options forwarding comment) — added one-liner in PlaywrightServiceConnector explaining BrowserTypeLaunchOptions are local-process only and don't carry to ConnectAsync. Commit 4b20368.

Minor (trailing newline on Directory.Build.props) — fixed. Same commit.

Also pushed: TUnit.PublicAPI .verified.txt snapshots for the four new public types across all TFMs (this was the actual cause of the previous CI failures — turned out the build was fine; the snapshot test legitimately tripped on the API surface delta).

Copy link
Copy Markdown
Contributor

@claude claude Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up Review — Round 3

Previous findings: status check

Round 1 / Issue 1 (null! contradiction) — Deferred to a follow-up alignment PR. No change. ✓ Accepted.

Round 1 / Issue 2 (hardcoded scope docs) — Doc comments added to BrowserFixture and ContextFixture. ✓ Confirmed.

Round 2 / Minor (PageFixture default scope) — XML doc on PageFixture is present and accurate. ✓ Confirmed.

Round 2 / Minor (PlaywrightServiceConnector silent options drop) — Inline comment // BrowserTypeLaunchOptions are local-process only; remote connect uses BrowserTypeConnectOptions. added. ✓ Confirmed.

Round 2 / Minor (Directory.Build.props newline) — Newline added. ✓ Confirmed.


Remaining open finding: ContextFixture.GetContextOptions still diverges silently from BrowserTest

ContextFixture.cs still has:

protected virtual BrowserNewContextOptions GetContextOptions() =>
    new() { Locale = "en-US", ColorScheme = ColorScheme.Light };

No XML doc comment was added to this method to explain the divergence from BrowserTest, which leaves Locale and ColorScheme at the Playwright default. This was the primary finding in round 2 and remains unaddressed.

The method itself is protected virtual, so users can override it, but there is no indication that the base defaults are opinionated or that they differ from the inheritance-based path. A migration from ContextTest : BrowserTest to [ClassDataSource<ContextFixture>] will produce different context behaviour without any hint in the API.

Minimal fix (no behaviour change required):

/// <summary>
/// Returns the options used when creating each <see cref="IBrowserContext"/>.
/// The default pins <c>Locale</c> to <c>en-US</c> and <c>ColorScheme</c> to <c>Light</c>
/// for deterministic cross-platform rendering — this differs from the
/// <see cref="BrowserTest.NewContext"/> path, which uses the browser's default locale
/// and colour scheme. Override to match your application's locale requirements or to
/// restore browser-default behaviour.
/// </summary>
protected virtual BrowserNewContextOptions GetContextOptions() =>
    new() { Locale = "en-US", ColorScheme = ColorScheme.Light };

This is the only remaining material concern. Everything else in the PR is clean and well-implemented.


Summary

The PR is in good shape overall. The composition-based fixture API is a solid addition, the shared-helper extractions eliminate meaningful duplication, and the smoke test gives a clear runnable specification of the scoping contract. One XML doc comment on GetContextOptions would close the last outstanding review finding.

…ure defaults

The local-build ProjectReference swap broke `dotnet run` on the template
TestProject with "test framework adapter has not been registered". When
NuGet resolves TUnit.Playwright as a package, build/TUnit.props is auto-
imported and sets IsTestingPlatformApplication=true plus the
TestingPlatformBuilderHook that registers TUnit. ProjectReference does
not propagate those build assets, so explicitly import TUnit.props in
the swap block.

Also adds an XML doc comment on ContextFixture.GetContextOptions
explaining that the en-US/Light defaults match ContextTest.ContextOptions
(the inheritance counterpart) for migration parity, and how to override
back to browser defaults.
@thomhurst
Copy link
Copy Markdown
Owner Author

Round 3 follow-ups in commit `d134b06`:

ContextFixture.GetContextOptions doc — implementing per the suggestion. Doc comment now states the defaults match ContextTest.ContextOptions for migration parity (same Locale = "en-US" / ColorScheme = Light) and how to restore browser defaults via new BrowserNewContextOptions(). My earlier reading still holds — those defaults exist verbatim in ContextTest.cs:19-21, so a migration from ContextTestContextFixture is a no-op behaviourally — but the doc makes that explicit for readers, which is the actually useful change.

Separately — fixed a real CI regression caused by my own swap. The earlier Directory.Build.props ProjectReference swap broke dotnet run on the template TestProject with "test framework adapter has not been registered". Root cause: when TUnit.Playwright is consumed as a NuGet package, build/TUnit.props is auto-imported and sets IsTestingPlatformApplication=true + registers TestingPlatformBuilderHook. ProjectReference doesn't propagate build assets, so the project compiled but had no test-framework entry point at runtime. Fix: explicit <Import Project="...\TUnit\TUnit.props" /> alongside the swap. Verified locally — test is now discovered and executes (fails at Playwright runtime because I don't have browsers installed locally; CI has them via InstallPlaywrightModule).

Copy link
Copy Markdown
Contributor

@claude claude Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up Review — Round 4 (Final)

Previous findings: status check

Round 1 / Issue 1 (null! contradiction) — Deferred to a follow-up alignment PR. No change. ✓ Accepted.

Round 1 / Issue 2 (hardcoded scope docs) — Doc comments on BrowserFixture and ContextFixture present. ✓ Confirmed.

Round 1 / Minor (PageFixture default scope) — XML doc explaining SharedType.None is intentional is present and accurate. ✓ Confirmed.

Round 2 / Minor (PlaywrightServiceConnector silent options drop) — Inline comment present. ✓ Confirmed.

Round 2 / Minor (Directory.Build.props newline) — Fixed. ✓ Confirmed.

Round 2→3 / Main finding (ContextFixture.GetContextOptions defaults) — Verified in d134b06. Doc comment is now present and accurate:

/// <summary>
/// Returns the options used when creating each <see cref="IBrowserContext"/>. Defaults
/// match <see cref="ContextTest.ContextOptions"/> — pinned <c>Locale = "en-US"</c> and
/// <c>ColorScheme = Light</c> for deterministic cross-platform rendering. Override to
/// match your application's locale or to restore browser-default behaviour
/// (<c>new BrowserNewContextOptions()</c>).
/// </summary>

This correctly documents parity with ContextTest.ContextOptions (not BrowserTest, as my round-2 review incorrectly compared against), and clearly explains both the rationale and the override escape hatch. ✓ Confirmed and closed.


No new findings

The PR is clean at d134b06. All four fixture classes are well-documented, the shared-helper extraction is correct, and the TwoContextFixtureTests smoke test gives a clear runnable specification of the scoping contract. The Directory.Build.props CI fix mirrors the established Aspire pattern and correctly handles the ProjectReference-doesn't-propagate-build-assets edge case.

This PR is good to merge.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant