关于 playwright-blazor
此 Skill 指导开发者为 Blazor Server 和 WebAssembly 应用编写 Playwright 端到端 UI 测试,涵盖交互、身份验证、实时行为、响应式检查和截图,并提供 Blazor 专用等待与错误处理模式。
主要功能
- 01测试 Blazor 导航、表单和交互
- 02使用稳定的测试属性和语义选择器
- 03测试身份验证与授权流程
- 04跨浏览器会话测试 SignalR 更新
- 05截取截图进行视觉检查
- 06处理异步渲染和 Blazor 错误界面
适用场景
- 01自动化 Blazor 登录和表单提交流程
- 02检查 Blazor Server 应用的实时更新
- 03通过移动设备模拟检查响应式界面
- 04用截图和浏览器日志调试失败的 UI 测试
安装
复制下面的命令,在终端中运行。
$
npx skills add https:/ / github.com/ wshaddix/ dotnet-skills --skill playwright-blazor安装后在 Claude Code 中输入/playwright-blazor确认已加载。
详细介绍
使用 Playwright 测试 Blazor 应用程序
何时使用此技能
适用于以下场景:
- 为 Blazor Server 或 WebAssembly 应用程序编写端到端 UI 测试
- 测试交互式组件、表单和用户工作流
- 验证身份验证和授权流程
- 测试 Blazor Server 中基于 SignalR 的实时更新
- 捕获屏幕截图以进行视觉回归测试
- 测试响应式设计和移动设备模拟
- 使用浏览器开发者工具调试 UI 问题
核心原则
- 等待渲染 - Blazor 会异步渲染;请使用适当的等待策略
- 测试属性 - 使用
data-test或data-testid属性作为稳定的选择器 - 默认无头运行 - 在 CI 中无头运行测试,在本地调试时以有头模式运行
- 处理错误 UI - 始终检查
#blazor-error-ui,以捕获未处理的异常 - 避免等待网络状态 - Blazor 导航不会触发网络加载;请等待 DOM 变化
- 固定浏览器通道 - 使用特定的浏览器通道(msedge、chrome)以确保可复现
必需的 NuGet 包
<ItemGroup>
<PackageReference Include="Microsoft.Playwright" Version="*" />
<PackageReference Include="Microsoft.Playwright.MSTest" Version="*" />
<PackageReference Include="xunit" Version="*" />
<PackageReference Include="xunit.runner.visualstudio" Version="*" />
</ItemGroup>
安装
运行测试前,请安装 Playwright 浏览器:
pwsh -Command "playwright install --with-deps"
模式 1:基本的 Playwright 设置
using Microsoft.Playwright;
public class PlaywrightFixture : IAsyncLifetime
{
private IPlaywright? _playwright;
private IBrowser? _browser;
public IBrowser Browser => _browser
?? throw new InvalidOperationException("Browser not initialized");
public async Task InitializeAsync()
{
_playwright = await Playwright.CreateAsync();
_browser = await _playwright.Chromium.LaunchAsync(new()
{
Headless = true,
// For CI/debugging, you might want:
// Headless = Environment.GetEnvironmentVariable("CI") != null,
// SlowMo = 100 // Slow down actions for debugging
});
}
public async Task DisposeAsync()
{
if (_browser is not null)
await _browser.DisposeAsync();
_playwright?.Dispose();
}
}
模式 2:在 Blazor 应用中导航
初始页面加载(传统导航)
[Fact]
public async Task InitialPageLoad()
{
var page = await _fixture.Browser.NewPageAsync();
// First load is classic HTTP navigation
await page.GotoAsync("https://localhost:5001");
// Wait for Blazor to initialize
await page.WaitForSelectorAsync("h1:has-text('Welcome')");
Assert.True(await page.IsVisibleAsync("h1:has-text('Welcome')"));
}
应用内导航(不重新加载页面)
Blazor 使用客户端路由,因此后续导航不会触发页面重新加载:
[Fact]
public async Task InternalNavigation()
{
var page = await _fixture.Browser.NewPageAsync();
await page.GotoAsync("https://localhost:5001");
// Method 1: Click a navigation link
await page.GetByRole(AriaRole.Link, new() { Name = "Counter" })
.ClickAsync();
// Wait for the new page content (NOT network idle!)
await page.WaitForSelectorAsync("h1:has-text('Counter')");
// Method 2: Programmatic navigation (Blazor 8+)
await page.EvaluateAsync("window.Blazor.navigateTo('/fetchdata')");
await page.WaitForSelectorAsync("h1:has-text('Weather')");
// Method 3: Direct URL navigation (causes full reload)
await page.GotoAsync("https://localhost:5001/counter");
await page.WaitForSelectorAsync("h1:has-text('Counter')");
}
Blazor 的等待策略
// ❌ DON'T: Wait for network idle (Blazor doesn't reload pages)
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
// ✅ DO: Wait for specific DOM elements
await page.WaitForSelectorAsync("h1:has-text('My Page')");
// ✅ DO: Wait for element visibility
await page.Locator("[data-test='content']").WaitForAsync();
// ✅ DO: Wait for URL change
await page.WaitForURLAsync("**/counter");
模式 3:使用测试属性实现稳定的选择器
在 Blazor 组件中
<button data-test="submit-button" @onclick="HandleSubmit">
Submit
</button>
<input data-test="username-input" @bind="Username" />
<div data-test="result-container">
@Result
</div>
在测试中
[Fact]
public async Task FormSubmission()
{
var page = await _fixture.Browser.NewPageAsync();
await page.GotoAsync(baseUrl);
// Use GetByTestId for elements with data-test attributes
await page.GetByTestId("username-input").FillAsync("testuser");
await page.GetByTestId("password-input").FillAsync("password123");
await page.GetByTestId("submit-button").ClickAsync();
// Verify result
var result = await page.GetByTestId("result-container").TextContentAsync();
Assert.Contains("Success", result);
}
模式 4:处理身份验证
交互式登录
[Fact]
public async Task LoginFlow()
{
var page = await _fixture.Browser.NewPageAsync();
await page.GotoAsync($"{baseUrl}/login");
// Fill login form
await page.FillAsync("input[name='username']", "alice");
await page.FillAsync("input[name='password']", "P@ssw0rd");
await page.ClickAsync("button[type='submit']");
// Wait for redirect to dashboard
await page.WaitForURLAsync("**/dashboard");
// Verify logged in
var username = await page.TextContentAsync("[data-test='user-name']");
Assert.Equal("alice", username);
}
注入 Cookie(更快)
[Fact]
public async Task AuthenticatedAccess_ViaCookie()
{
var page = await _fixture.Browser.NewPageAsync();
// Inject authentication cookie
await page.Context.AddCookiesAsync(new[]
{
new Cookie
{
Name = ".AspNetCore.Cookies",
Value = GenerateAuthCookie("alice"),
Url = baseUrl,
Secure = true,
HttpOnly = true
}
});
// Navigate directly to protected page
await page.GotoAsync($"{baseUrl}/dashboard");
// Already authenticated!
var username = await page.TextContentAsync("[data-test='user-name']");
Assert.Equal("alice", username);
}
private string GenerateAuthCookie(string username)
{
// Generate a valid authentication cookie
// This requires access to your app's cookie encryption keys
// OR use a test endpoint that generates valid cookies
// OR perform actual login once and reuse the cookie
}
模拟 OAuth/外部提供方
// Use route interception to mock OAuth redirects
await page.RouteAsync("**/signin-microsoft", async route =>
{
// Intercept OAuth redirect and return mock response
await route.FulfillAsync(new()
{
Status = 302,
Headers = new Dictionary<string, string>
{
["Location"] = $"{baseUrl}/signin-callback?code=mock_auth_code"
}
});
});
模式 5:点击事件和触控交互
[Fact]
public async Task ClickInteractions()
{
var page = await _fixture.Browser.NewPageAsync();
await page.GotoAsync(baseUrl);
// Standard click
await page.GetByText("Click Me").ClickAsync();
// Right-click
await page.ClickAsync("[data-test='context-menu']", new()
{
Button = MouseButton.Right
});
// Double-click
await page.DblClickAsync("[data-test='item']");
// Hover then click dropdown
var menu = page.Locator("#profile-menu");
await menu.HoverAsync();
await menu.GetByText("Sign out").ClickAsync();
// Touch events (mobile emulation)
await page.EmulateMediaAsync(new() { Media = Media.Screen });
await page.Touchscreen.TapAsync(150, 300);
}
模式 6:处理表单
[Fact]
public async Task ComplexForm()
{
var page = await _fixture.Browser.NewPageAsync();
await page.GotoAsync($"{baseUrl}/form");
// Text input
await page.FillAsync("[data-test='name']", "John Doe");
// Select dropdown
await page.SelectOptionAsync("[data-test='country']", "US");
// Checkbox
await page.CheckAsync("[data-test='terms']");
// Radio button
await page.CheckAsync("[data-test='option-a']");
// File upload
await page.SetInputFilesAsync("[data-test='file-input']",
"/path/to/test-file.pdf");
// Submit
await page.ClickAsync("[data-test='submit']");
// Wait for success message
await page.WaitForSelectorAsync("[data-test='success-message']");
}
模式 7:处理 Blazor 错误 UI
发生未处理的异常时,Blazor 会显示错误浮层。请始终检查:
public static async Task AssertNoBlazorErrors(this IPage page)
{
var errorUi = page.Locator("#blazor-error-ui");
if (await errorUi.IsVisibleAsync())
{
var errorText = await errorUi.InnerTextAsync();
Assert.Fail($"Blazor error occurred: {errorText}");
}
}
[Fact]
public async Task Page_ShouldNotHaveErrors()
{
var page = await _fixture.Browser.NewPageAsync();
await page.GotoAsync(baseUrl);
// Perform some actions
await page.ClickAsync("[data-test='action-button']");
// Verify no errors occurred
await page.AssertNoBlazorErrors();
}
模式 8:测试实时更新(SignalR)
Blazor Server 使用 SignalR 进行实时通信:
[Fact]
public async Task RealTimeUpdates()
{
// Open two browser contexts (simulating two users)
var page1 = await _fixture.Browser.NewPageAsync();
var page2 = await _fixture.Browser.NewPageAsync();
await page1.GotoAsync($"{baseUrl}/drawing");
await page2.GotoAsync($"{baseUrl}/drawing");
// User 1 draws something
await page1.ClickAsync("[data-test='draw-button']");
await page1.Mouse.ClickAsync(100, 100);
// User 2 should see the update
await page2.WaitForSelectorAsync("[data-test='drawing-canvas']");
// Verify both pages show the same content
var canvas1 = await page1.GetByTestId("drawing-canvas")
.GetAttributeAsync("data-strokes");
var canvas2 = await page2.GetByTestId("drawing-canvas")
.GetAttributeAsync("data-strokes");
Assert.Equal(canvas1, canvas2);
}
模式 9:屏幕截图和视觉测试
[Fact]
public async Task CaptureScreenshots()
{
var page = await _fixture.Browser.NewPageAsync();
await page.GotoAsync(baseUrl);
// Full page screenshot
await page.ScreenshotAsync(new()
{
Path = "screenshots/homepage.png",
FullPage = true
});
// Element screenshot
var header = page.Locator("header");
await header.ScreenshotAsync(new()
{
Path = "screenshots/header.png"
});
// Screenshot with viewport size
await page.SetViewportSizeAsync(1920, 1080);
await page.ScreenshotAsync(new()
{
Path = "screenshots/desktop.png"
});
// Mobile viewport
await page.SetViewportSizeAsync(375, 667);
await page.ScreenshotAsync(new()
{
Path = "screenshots/mobile.png"
});
}
模式 10:使用开发证书通过 HTTPS 运行
public async Task InitializeAsync()
{
_playwright = await Playwright.CreateAsync();
_browser = await _playwright.Chromium.LaunchAsync(new()
{
Headless = true,
// Ignore certificate errors for local dev certs
Args = new[] { "--ignore-certificate-errors" }
});
}
对于更严格的设置,请导出并信任开发证书:
dotnet dev-certs https --export-path cert.pfx -p YourPassword
Blazor 组件的常用选择器
// By role (best for accessibility)
await page.GetByRole(AriaRole.Button, new() { Name = "Submit" });
await page.GetByRole(AriaRole.Link, new() { Name = "Home" });
await page.GetByRole(AriaRole.Heading, new() { Name = "Welcome" });
// By test ID
await page.GetByTestId("user-profile");
// By text content
await page.GetByText("Hello, World!");
// By label (for inputs)
await page.GetByLabel("Email Address");
// By placeholder
await page.GetByPlaceholder("Enter your name");
// CSS selectors (use sparingly)
await page.Locator(".mud-button-primary");
await page.Locator("#login-form");
// XPath (use as last resort)
await page.Locator("xpath=//button[contains(text(), 'Submit')]");
并行化注意事项
Blazor Server 使用 SignalR WebSocket。多个 Playwright 测试可能会使连接饱和:
// Limit parallel execution for Blazor Server tests
[Collection("Blazor Server")]
public class BlazorServerTests { }
// In AssemblyInfo.cs or test startup
[assembly: CollectionBehavior(MaxParallelThreads = 2)]
Blazor WebAssembly 没有此限制,可以完全并行运行。
CI/CD 集成
GitHub Actions
name: Playwright Tests
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup .NET
uses: actions/setup-dotnet@v3
with:
dotnet-version: 9.0.x
- name: Install Playwright Browsers
run: pwsh -Command "playwright install --with-deps"
- name: Build
run: dotnet build -c Release
- name: Run Playwright Tests
run: |
dotnet test tests/YourApp.UITests \
--no-build \
-c Release \
--logger trx
- name: Upload Screenshots
uses: actions/upload-artifact@v3
if: failure()
with:
name: playwright-screenshots
path: "**/screenshots/"
- name: Upload Test Results
uses: actions/upload-artifact@v3
if: always()
with:
name: test-results
path: "**/TestResults/*.trx"
调试技巧
- 以有头模式运行 - 设置
Headless = false以观察测试执行 - 慢动作 - 添加
SlowMo = 500以减慢操作 - 暂停执行 - 调用
await page.PauseAsync()以打开 Playwright 检查器 - 控制台日志 - 捕获浏览器控制台:
page.Console += (_, msg) => Console.WriteLine(msg.Text); - 网络流量 - 监控请求:
page.Request += (_, req) => Console.WriteLine(req.Url); - 失败时截取屏幕截图 - 始终在 catch 块中捕获屏幕截图
最佳实践
- 使用 data-test 属性 - 比 CSS 类或 ID 更稳定
- 优先使用语义化选择器 - 使用角色、标签和文本内容
- 等待特定元素 - 不要使用笼统的延迟
- 检查 Blazor 错误 - 始终确认
#blazor-error-ui不可见 - 使用多个视口进行测试 - 验证响应式设计
- 重用浏览器上下文 - 比创建新浏览器更快
- 清理资源 - 始终释放页面和浏览器
- 对 Blazor Server 使用集合 - 避免 SignalR 连接饱和
- 失败时截取屏幕截图 - 这对调试 CI 故障至关重要
- 固定浏览器通道 - 使用特定通道以确保可复现
高级:自定义等待辅助方法
public static class PlaywrightExtensions
{
public static async Task WaitForBlazorAsync(this IPage page)
{
// Wait for Blazor to finish rendering
await page.EvaluateAsync(@"
() => new Promise(resolve => {
if (typeof Blazor !== 'undefined') {
resolve();
} else {
const interval = setInterval(() => {
if (typeof Blazor !== 'undefined') {
clearInterval(interval);
resolve();
}
}, 100);
}
})
");
}
public static async Task WaitForNoSpinnersAsync(
this IPage page,
int timeout = 5000)
{
var locator = page.Locator(".spinner, .loading");
await locator.WaitForAsync(new()
{
State = WaitForSelectorState.Hidden,
Timeout = timeout
});
}
public static async Task FillWithValidationAsync(
this IPage page,
string selector,
string value)
{
await page.FillAsync(selector, value);
// Trigger blur to activate validation
await page.Locator(selector).BlurAsync();
// Wait a bit for validation to complete
await Task.Delay(100);
}
}
评论
支持 Markdown