名称: dotnet-playwright 描述: “在.NET中自动化浏览器测试。Playwright端到端测试,CI浏览器缓存,跟踪查看器,代码生成。” 用户可调用: false
dotnet-playwright
Playwright for .NET:浏览器自动化和端到端测试。涵盖浏览器生命周期管理、页面交互、断言、CI缓存浏览器二进制文件、跟踪查看器用于调试失败,以及代码生成用于快速测试脚手架。
版本假设: Playwright 1.40+ for .NET,.NET 8.0+ 基线。Playwright支持Chromium、Firefox和WebKit浏览器。
范围
- 浏览器生命周期管理(Chromium、Firefox、WebKit)
- 页面交互和基于定位器的断言
- CI缓存浏览器二进制文件
- 跟踪查看器用于调试测试失败
- 代码生成用于快速测试脚手架
超出范围
- 共享UI测试模式(页面对象模型、选择器、等待策略)——参见 [技能:dotnet-ui-testing-core]
- 测试策略(何时使用E2E vs 单元 vs 集成)——参见 [技能:dotnet-testing-strategy]
- 测试项目脚手架——参见 [技能:dotnet-add-testing]
先决条件: 通过 [技能:dotnet-add-testing] 搭建的测试项目,并引用Playwright包。通过 pwsh bin/Debug/net8.0/playwright.ps1 install 或 dotnet tool run playwright install 安装浏览器。
交叉引用: [技能:dotnet-ui-testing-core] 用于页面对象模型和选择器策略,[技能:dotnet-testing-strategy] 用于决定何时E2E测试合适。
包设置
<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需要下载浏览器二进制文件才能运行测试:
# 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']", "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']");
断言(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("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);
网络拦截
模拟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
缓存键策略
缓存键应包括:
- OS: 浏览器二进制文件是平台特定的
- 项目文件哈希: Playwright版本决定浏览器版本;更改包版本会使缓存失效
- 回退键: 当项目文件更改时允许部分缓存恢复
跟踪查看器
Playwright的跟踪查看器捕获测试执行的完整记录用于调试失败。每个跟踪包括屏幕截图、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;
}
}
代码生成
Playwright的代码生成器记录浏览器交互并生成测试代码。用它快速搭建测试,然后优化生成的代码。
运行代码生成
# 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或基于角色的定位器,生成后立即进行。 - 添加有意义的断言。 代码生成记录动作但不知道要验证什么。为预期结果添加
Expect()调用。 - 从生成的代码中提取页面对象。 将相关交互分组到页面对象方法中。
代码生成优化前后对比
// 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版本缓存。
- 启用跟踪查看器用于调试CI失败。 跟踪捕获重现失败所需的一切,而无需重新运行测试。
- 使用代码生成引导测试,然后优化。 生成的代码让你快速入门;手动优化使测试可维护。
- 优先使用基于角色或
data-testid的定位器 而不是CSS类或XPath。参见 [技能:dotnet-ui-testing-core] 获取完整选择器优先级指南。
代理陷阱
- 添加Playwright包后不要忘记安装浏览器。 NuGet包不包含浏览器二进制文件。构建后运行安装脚本。
- 不要使用
Task.Delay进行等待。 Playwright的自动等待和Expect断言自动处理时间。添加延迟使测试变慢且仍不稳定。 - 不要硬编码
localhost端口。 使用配置或环境变量设置基础URL。CI环境可能使用与本地开发不同的端口。 - 不要在第一次CI安装时跳过
--with-deps。 Playwright浏览器在Linux上需要系统库(如libgbm、libasound等)。--with-deps标志安装它们。后续缓存运行仅需要install-deps。 - 不要将跟踪文件存储在仓库中。 跟踪是大型二进制文件。将它们写入被git忽略的
test-results/目录,并作为CI工件上传。 - 不要为每个测试创建新的浏览器实例。 浏览器启动是昂贵的。使用
IClassFixture或Playwright xUnit基类在类中跨测试共享浏览器。为隔离,每个测试创建一个新的BrowserContext。