关于 dotnet-playwright
dotnet-playwright 是面向 .NET 开发者的 Playwright 浏览器自动化与端到端测试技能,涵盖从交互和断言到故障排查的实践。它还介绍多浏览器测试、CI 浏览器缓存及代码生成后的完善方式。
主要功能
- 01浏览器生命周期与页面交互
- 02Playwright 断言与网络拦截
- 03Chromium、Firefox、WebKit 测试
- 04CI 浏览器二进制缓存
- 05Trace Viewer 故障排查
- 06代码生成与测试脚手架
适用场景
- 01为 .NET 应用编写端到端测试
- 02模拟 API 响应并验证页面
- 03在 CI 中运行多浏览器测试
- 04通过执行轨迹排查测试失败
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ wshaddix/ dotnet-skills --skill dotnet-playwright安装后在 Claude Code 中输入/dotnet-playwright确认已加载。
详细介绍
dotnet-playwright
适用于 .NET 的 Playwright:浏览器自动化和端到端测试。涵盖浏览器生命周期管理、页面交互、断言、CI 浏览器二进制文件缓存、用于调试故障的 Trace Viewer,以及用于快速搭建测试框架的 codegen。
**版本假设:**Playwright 1.40+ for .NET,.NET 8.0+ 基线。Playwright 支持 Chromium、Firefox 和 WebKit 浏览器。
**范围之外:**通用 UI 测试模式(页面对象模型、选择器、等待策略)请参阅 [skill:dotnet-ui-testing-core]。测试策略(何时使用 E2E 测试、单元测试或集成测试)请参阅 [skill:dotnet-testing-strategy]。测试项目脚手架由 [skill:dotnet-add-testing] 负责。
**前置条件:**通过 [skill:dotnet-add-testing] 搭建测试项目脚手架,并引用 Playwright 包。通过 pwsh bin/Debug/net8.0/playwright.ps1 install 或 dotnet tool run playwright install 安装浏览器。
交叉引用:[skill:dotnet-ui-testing-core] 介绍页面对象模型和选择器策略,[skill:dotnet-testing-strategy] 用于判断何时适合使用 E2E 测试。
包设置
<PackageReference Include="Microsoft.Playwright" Version="1.*" />
<PackageReference Include="Microsoft.Playwright.Xunit" Version="1.*" />
安装浏览器
运行测试前,Playwright 需要下载浏览器二进制文件:
# 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
基本测试结构
使用 Playwright xUnit 基类
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']", "[email protected]");
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']");
断言(Expect API)
Playwright 断言会自动重试,直到条件满足或超时:
// 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("[email protected]");
// 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);
网络拦截
模拟 API 响应
[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);
}
CI 浏览器缓存
每次 CI 运行都下载浏览器二进制文件会很慢(500MB+)。缓存这些文件可以加快构建速度。
GitHub Actions 缓存
# .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 DevOps 缓存
# 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
缓存键策略
缓存键应包含:
- **操作系统:**浏览器二进制文件因平台而异
- **项目文件哈希:**Playwright 版本决定浏览器版本;更改包版本会使缓存失效
- **回退键:**项目文件发生更改时,允许部分恢复缓存
Trace Viewer
Playwright 的 Trace Viewer 会完整记录测试执行过程,以便调试故障。每条跟踪都包含屏幕截图、DOM 快照、网络日志和控制台输出。
启用跟踪
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/
仅在失败时跟踪
仅在测试失败时保存跟踪,以减少存储占用:
// 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;
}
}
Codegen
Playwright 的代码生成器会记录浏览器交互并生成测试代码。可用它快速搭建测试框架,然后优化生成的代码。
运行 Codegen
# 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
Codegen 最佳实践
- **将 codegen 用作起点,**而非最终测试。生成的代码通常使用脆弱的选择器,且缺少适当的断言。
- 生成代码后立即将生成的选择器替换为
data-testid或基于角色的定位器。 - **添加有意义的断言。**Codegen 会记录操作,但不知道需要验证什么。添加
Expect()调用来验证预期结果。 - **从生成的代码中提取页面对象。**将相关交互归纳到页面对象方法中。
Codegen 优化前后对比
// 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
CI 矩阵策略
# 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 }}
核心原则
- 使用 Playwright 断言(
Expect),而不是原始 xUnitAssert。 Playwright 断言会自动重试,并支持配置超时时间,从而避免不稳定的时序问题。 - 在 CI 中缓存浏览器二进制文件。 每次运行都下载 500MB 以上的浏览器会浪费时间和带宽。请按操作系统和 Playwright 版本进行缓存。
- 启用 trace viewer 来调试 CI 故障。 Trace 会捕获复现故障所需的一切信息,无需重新运行测试。
- 先用 codegen 搭建测试,再进行完善。 生成的代码能让你快速起步;手动完善则能让测试更易维护。
- 优先使用基于角色或
data-testid的定位器,而不是 CSS 类或 XPath。完整的选择器优先级指南请参阅 [skill:dotnet-ui-testing-core]。
Agent 注意事项
- 添加 Playwright 包后,别忘了安装浏览器。 NuGet 包不包含浏览器二进制文件。构建后运行安装脚本。
- 不要使用
Task.Delay来等待。 Playwright 的自动等待和Expect断言会自动处理时序。添加延迟会让测试变慢,而且仍然不稳定。 - 不要硬编码
localhost端口。 使用配置或环境变量设置基准 URL。CI 环境使用的端口可能与本地开发不同。 - 首次在 CI 中安装时,不要省略
--with-deps。 Playwright 浏览器在 Linux 上需要系统库(libgbm、libasound 等)。--with-deps标志会安装这些库。之后使用缓存的运行只需install-deps。 - 不要将 trace 文件存储在代码库中。 Trace 是大型二进制文件。请将其写入已在 git 中忽略的
test-results/目录,并作为 CI 工件上传。 - 不要为每项测试都创建新的浏览器实例。 启动浏览器的开销很大。使用
IClassFixture或 Playwright xUnit 基类,在一个类的各项测试之间共享浏览器。每项测试都创建新的BrowserContext,以实现隔离。