From dotnet-skills
Automating browser tests in .NET. Playwright E2E, CI browser caching, trace viewer, codegen.
npx claudepluginhub wshaddix/dotnet-skillsThis skill uses the workspace's default tool permissions.
Searches, retrieves, and installs Agent Skills from prompts.chat registry using MCP tools like search_skills and get_skill. Activates for finding skills, browsing catalogs, or extending Claude.
Searches prompts.chat for AI prompt templates by keyword or category, retrieves by ID with variable handling, and improves prompts via AI. Use for discovering or enhancing prompts.
Compares coding agents like Claude Code and Aider on custom YAML-defined codebase tasks using git worktrees, measuring pass rate, cost, time, and consistency.
Playwright for .NET: browser automation and end-to-end testing. Covers browser lifecycle management, page interactions, assertions, CI caching of browser binaries, trace viewer for debugging failures, and codegen for rapid test scaffolding.
Version assumptions: Playwright 1.40+ for .NET, .NET 8.0+ baseline. Playwright supports Chromium, Firefox, and WebKit browsers.
Out of scope: Shared UI testing patterns (page object model, selectors, wait strategies) are in [skill:dotnet-ui-testing-core]. Testing strategy (when E2E vs unit vs integration) is covered by [skill:dotnet-testing-strategy]. Test project scaffolding is owned by [skill:dotnet-add-testing].
Prerequisites: Test project scaffolded via [skill:dotnet-add-testing] with Playwright packages referenced. Browsers installed via pwsh bin/Debug/net8.0/playwright.ps1 install or dotnet tool run playwright install.
Cross-references: [skill:dotnet-ui-testing-core] for page object model and selector strategies, [skill:dotnet-testing-strategy] for deciding when E2E tests are appropriate.
<PackageReference Include="Microsoft.Playwright" Version="1.*" />
<!-- For xUnit integration: -->
<PackageReference Include="Microsoft.Playwright.Xunit" Version="1.*" />
<!-- For NUnit integration: -->
<!-- <PackageReference Include="Microsoft.Playwright.NUnit" Version="1.*" /> -->
Playwright requires downloading browser binaries before tests can run:
# After building the test project:
pwsh bin/Debug/net8.0/playwright.ps1 install
# Or install specific browsers:
pwsh bin/Debug/net8.0/playwright.ps1 install chromium
pwsh bin/Debug/net8.0/playwright.ps1 install firefox
# Using dotnet tool:
dotnet tool install --global Microsoft.Playwright.CLI
playwright install
using Microsoft.Playwright;
using Microsoft.Playwright.Xunit;
// PageTest provides Page, Browser, BrowserContext, and Playwright properties
public class HomePageTests : PageTest
{
[Fact]
public async Task HomePage_Title_ContainsAppName()
{
await Page.GotoAsync("https://localhost:5001");
await Expect(Page).ToHaveTitleAsync(new Regex("My App"));
}
[Fact]
public async Task HomePage_NavLinks_AreVisible()
{
await Page.GotoAsync("https://localhost:5001");
var nav = Page.Locator("nav");
await Expect(nav.GetByRole(AriaRole.Link, new() { Name = "Home" }))
.ToBeVisibleAsync();
await Expect(nav.GetByRole(AriaRole.Link, new() { Name = "About" }))
.ToBeVisibleAsync();
}
}
public class ManualSetupTests : IAsyncLifetime
{
private IPlaywright _playwright = null!;
private IBrowser _browser = null!;
private IBrowserContext _context = null!;
private IPage _page = null!;
public async ValueTask InitializeAsync()
{
_playwright = await Playwright.CreateAsync();
_browser = await _playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions
{
Headless = true
});
_context = await _browser.NewContextAsync(new BrowserNewContextOptions
{
ViewportSize = new ViewportSize { Width = 1280, Height = 720 },
Locale = "en-US"
});
_page = await _context.NewPageAsync();
}
public async ValueTask DisposeAsync()
{
await _page.CloseAsync();
await _context.CloseAsync();
await _browser.CloseAsync();
_playwright.Dispose();
}
[Fact]
public async Task Login_ValidUser_RedirectsToDashboard()
{
await _page.GotoAsync("https://localhost:5001/login");
await _page.FillAsync("[data-testid='email']", "user@example.com");
await _page.FillAsync("[data-testid='password']", "P@ssw0rd!");
await _page.ClickAsync("[data-testid='login-btn']");
await Expect(_page).ToHaveURLAsync(new Regex("/dashboard"));
}
}
// BEST: Role-based (accessible and semantic)
var submitBtn = Page.GetByRole(AriaRole.Button, new() { Name = "Submit Order" });
// GOOD: Test ID (stable, explicit)
var emailInput = Page.Locator("[data-testid='email-input']");
// GOOD: Label text (user-visible, accessible)
var nameField = Page.GetByLabel("Full Name");
// GOOD: Placeholder (user-visible)
var searchBox = Page.GetByPlaceholder("Search products...");
// AVOID: CSS class (fragile, changes with styling)
var card = Page.Locator(".card-primary");
// AVOID: XPath (brittle, hard to read)
var cell = Page.Locator("//table/tbody/tr[1]/td[2]");
// Text input
await Page.FillAsync("[data-testid='name']", "Alice Johnson");
// Click
await Page.ClickAsync("[data-testid='submit']");
// Select dropdown
await Page.SelectOptionAsync("[data-testid='country']", "US");
// Checkbox / radio
await Page.CheckAsync("[data-testid='agree-terms']");
// File upload
await Page.SetInputFilesAsync("[data-testid='avatar']", "testdata/photo.jpg");
// Keyboard
await Page.Keyboard.PressAsync("Enter");
await Page.Keyboard.TypeAsync("search query");
// Hover (for dropdowns, tooltips)
await Page.HoverAsync("[data-testid='user-menu']");
Playwright assertions auto-retry until the condition is met or the timeout expires:
// Element visibility
await Expect(Page.Locator("[data-testid='success']")).ToBeVisibleAsync();
await Expect(Page.Locator("[data-testid='spinner']")).ToBeHiddenAsync();
// Text content
await Expect(Page.Locator("[data-testid='total']")).ToHaveTextAsync("$99.99");
await Expect(Page.Locator("[data-testid='status']")).ToContainTextAsync("Completed");
// Attribute
await Expect(Page.Locator("[data-testid='submit']")).ToBeEnabledAsync();
await Expect(Page.Locator("[data-testid='email']")).ToHaveValueAsync("user@example.com");
// Page-level
await Expect(Page).ToHaveURLAsync(new Regex("/orders/\\d+"));
await Expect(Page).ToHaveTitleAsync("Order Details - My App");
// Count
await Expect(Page.Locator("[data-testid='order-row']")).ToHaveCountAsync(5);
[Fact]
public async Task OrderList_WithMockedApi_DisplaysOrders()
{
// Intercept API calls and return mock data
await Page.RouteAsync("**/api/orders", async route =>
{
var json = JsonSerializer.Serialize(new[]
{
new { Id = 1, CustomerName = "Alice", Total = 99.99 },
new { Id = 2, CustomerName = "Bob", Total = 149.50 }
});
await route.FulfillAsync(new RouteFulfillOptions
{
Status = 200,
ContentType = "application/json",
Body = json
});
});
await Page.GotoAsync("https://localhost:5001/orders");
await Expect(Page.Locator("[data-testid='order-row']")).ToHaveCountAsync(2);
}
[Fact]
public async Task CreateOrder_SubmitForm_WaitsForApiResponse()
{
await Page.GotoAsync("https://localhost:5001/orders/new");
await Page.FillAsync("[data-testid='customer']", "Alice");
await Page.FillAsync("[data-testid='amount']", "99.99");
// Wait for the API call triggered by form submission
var responseTask = Page.WaitForResponseAsync(
response => response.Url.Contains("/api/orders") && response.Status == 201);
await Page.ClickAsync("[data-testid='submit']");
var response = await responseTask;
Assert.Equal(201, response.Status);
}
Downloading browser binaries on every CI run is slow (500MB+). Cache them to speed up builds.
# .github/workflows/e2e-tests.yml
jobs:
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: Build
run: dotnet build tests/MyApp.E2E/
- name: Cache Playwright browsers
id: playwright-cache
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('tests/MyApp.E2E/MyApp.E2E.csproj') }}
- name: Install Playwright browsers
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: pwsh tests/MyApp.E2E/bin/Debug/net8.0/playwright.ps1 install --with-deps
- name: Install Playwright system deps
if: steps.playwright-cache.outputs.cache-hit == 'true'
run: pwsh tests/MyApp.E2E/bin/Debug/net8.0/playwright.ps1 install-deps
- name: Run E2E tests
run: dotnet test tests/MyApp.E2E/
# azure-pipelines.yml
steps:
- task: Cache@2
inputs:
key: 'playwright | "$(Agent.OS)" | tests/MyApp.E2E/MyApp.E2E.csproj'
path: $(HOME)/.cache/ms-playwright
restoreKeys: |
playwright | "$(Agent.OS)"
cacheHitVar: PLAYWRIGHT_CACHE_RESTORED
displayName: Cache Playwright browsers
- script: pwsh tests/MyApp.E2E/bin/Debug/net8.0/playwright.ps1 install --with-deps
condition: ne(variables.PLAYWRIGHT_CACHE_RESTORED, 'true')
displayName: Install Playwright browsers
- script: pwsh tests/MyApp.E2E/bin/Debug/net8.0/playwright.ps1 install-deps
condition: eq(variables.PLAYWRIGHT_CACHE_RESTORED, 'true')
displayName: Install Playwright system deps (cached browsers)
- script: dotnet test tests/MyApp.E2E/
displayName: Run E2E tests
The cache key should include:
Playwright's trace viewer captures a full recording of test execution for debugging failures. Each trace includes screenshots, DOM snapshots, network logs, and console output.
public class TracedTests : IAsyncLifetime
{
private IPlaywright _playwright = null!;
private IBrowser _browser = null!;
private IBrowserContext _context = null!;
public IPage Page { get; private set; } = null!;
public async ValueTask InitializeAsync()
{
_playwright = await Playwright.CreateAsync();
_browser = await _playwright.Chromium.LaunchAsync();
_context = await _browser.NewContextAsync();
// Start tracing before each test
await _context.Tracing.StartAsync(new TracingStartOptions
{
Screenshots = true,
Snapshots = true,
Sources = true
});
Page = await _context.NewPageAsync();
}
public async ValueTask DisposeAsync()
{
// Save trace on failure (check test result in xUnit requires custom wrapper)
await _context.Tracing.StopAsync(new TracingStopOptions
{
Path = Path.Combine("test-results", "traces",
$"trace-{DateTime.UtcNow:yyyyMMdd-HHmmss}.zip")
});
await Page.CloseAsync();
await _context.CloseAsync();
await _browser.CloseAsync();
_playwright.Dispose();
}
}
# Open trace file in browser
pwsh bin/Debug/net8.0/playwright.ps1 show-trace test-results/traces/trace-20260101-120000.zip
# Or use the online trace viewer
# Upload the .zip to https://trace.playwright.dev/
Save traces only when tests fail to reduce storage:
// In a custom test class or middleware
public async Task RunWithTrace(Func<IPage, Task> testAction, string testName)
{
await _context.Tracing.StartAsync(new TracingStartOptions
{
Screenshots = true,
Snapshots = true,
Sources = true
});
try
{
await testAction(Page);
// Test passed -- discard trace
await _context.Tracing.StopAsync();
}
catch
{
// Test failed -- save trace for debugging
await _context.Tracing.StopAsync(new TracingStopOptions
{
Path = $"test-results/traces/{testName}.zip"
});
throw;
}
}
Playwright's code generator records browser interactions and generates test code. Use it to scaffold tests quickly, then refine the generated code.
# Open codegen with your app URL
pwsh bin/Debug/net8.0/playwright.ps1 codegen https://localhost:5001
# With specific browser
pwsh bin/Debug/net8.0/playwright.ps1 codegen --browser firefox https://localhost:5001
# With device emulation
pwsh bin/Debug/net8.0/playwright.ps1 codegen --device "iPhone 15" https://localhost:5001
# With saved authentication state
pwsh bin/Debug/net8.0/playwright.ps1 codegen --save-storage auth.json https://localhost:5001
data-testid or role-based locators immediately after generating.Expect() calls for expected outcomes.// GENERATED by codegen (fragile, no assertions):
await page.GotoAsync("https://localhost:5001/orders");
await page.Locator("#root > div > main > div:nth-child(2) > button").ClickAsync();
await page.GetByPlaceholder("Customer name").FillAsync("Alice");
await page.GetByPlaceholder("Amount").FillAsync("99.99");
await page.Locator("form > button[type='submit']").ClickAsync();
// REFINED (stable selectors, proper assertions):
await Page.GotoAsync("https://localhost:5001/orders");
await Page.ClickAsync("[data-testid='new-order-btn']");
await Page.FillAsync("[data-testid='customer-name']", "Alice");
await Page.FillAsync("[data-testid='amount']", "99.99");
await Page.ClickAsync("[data-testid='submit-order']");
await Expect(Page.Locator("[data-testid='success-toast']"))
.ToBeVisibleAsync();
await Expect(Page).ToHaveURLAsync(new Regex("/orders/\\d+"));
// Using Playwright xUnit base class with environment variable
// Set BROWSER=chromium|firefox|webkit via CLI or CI config
public class CrossBrowserTests : PageTest
{
[Fact]
public async Task OrderFlow_WorksAcrossBrowsers()
{
// This test runs in whichever browser BROWSER env var specifies
await Page.GotoAsync("https://localhost:5001/orders/new");
await Page.FillAsync("[data-testid='customer']", "Alice");
await Page.ClickAsync("[data-testid='submit']");
await Expect(Page.Locator("[data-testid='success']")).ToBeVisibleAsync();
}
}
# Run tests in each browser
BROWSER=chromium dotnet test
BROWSER=firefox dotnet test
BROWSER=webkit dotnet test
# GitHub Actions matrix for multi-browser
strategy:
matrix:
browser: [chromium, firefox, webkit]
steps:
- name: Run E2E tests
run: dotnet test tests/MyApp.E2E/
env:
BROWSER: ${{ matrix.browser }}
Expect) instead of raw xUnit Assert. Playwright assertions auto-retry with configurable timeouts, eliminating flaky timing issues.data-testid locators over CSS classes or XPath. See [skill:dotnet-ui-testing-core] for the full selector priority guide.Task.Delay for waiting. Playwright's auto-waiting and Expect assertions handle timing automatically. Adding delays makes tests slow and still flaky.localhost ports. Use configuration or environment variables for the base URL. CI environments may use different ports than local development.--with-deps on first CI install. Playwright browsers need system libraries (libgbm, libasound, etc.) on Linux. The --with-deps flag installs them. Subsequent cached runs only need install-deps.test-results/ directory that is git-ignored, and upload them as CI artifacts.IClassFixture or the Playwright xUnit base class to share a browser across tests in a class. Create a new BrowserContext per test for isolation.