playwright-blazor-testing
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseTesting Blazor Applications with Playwright
使用Playwright测试Blazor应用
When to Use This Skill
何时使用该技能
Use this skill when:
- Writing end-to-end UI tests for Blazor Server or WebAssembly applications
- Testing interactive components, forms, and user workflows
- Verifying authentication and authorization flows
- Testing SignalR-based real-time updates in Blazor Server
- Capturing screenshots for visual regression testing
- Testing responsive designs and mobile emulation
- Debugging UI issues with browser developer tools
在以下场景使用该技能:
- 为Blazor Server或WebAssembly应用编写端到端UI测试
- 测试交互式组件、表单和用户工作流
- 验证身份验证与授权流程
- 测试Blazor Server中基于SignalR的实时更新
- 捕获截图用于视觉回归测试
- 测试响应式设计与移动设备模拟
- 使用浏览器开发者工具调试UI问题
Core Principles
核心原则
- Wait for Rendering - Blazor renders asynchronously; use proper wait strategies
- Test Attributes - Use or
data-testattributes for stable selectorsdata-testid - Headless by Default - Run tests headless in CI, headed for local debugging
- Handle Error UI - Always check for to catch unhandled exceptions
#blazor-error-ui - Avoid Network Wait States - Blazor navigation doesn't trigger network loads; wait for DOM changes
- Pin Browser Channels - Use specific browser channels (msedge, chrome) for reproducibility
- 等待渲染完成 - Blazor采用异步渲染,需使用正确的等待策略
- 使用测试属性 - 使用或
data-test属性实现稳定的选择器data-testid - 默认无头模式 - 在CI环境中以无头模式运行测试,本地调试时使用有头模式
- 处理错误UI - 始终检查以捕获未处理的异常
#blazor-error-ui - 避免等待网络空闲 - Blazor导航不会触发网络加载,应等待DOM变化
- 固定浏览器渠道 - 使用特定浏览器渠道(msedge、chrome)确保测试可复现
Required NuGet Packages
所需NuGet包
xml
<ItemGroup>
<PackageReference Include="Microsoft.Playwright" Version="*" />
<PackageReference Include="Microsoft.Playwright.MSTest" Version="*" />
<!-- OR for xUnit -->
<PackageReference Include="xunit" Version="*" />
<PackageReference Include="xunit.runner.visualstudio" Version="*" />
</ItemGroup>xml
<ItemGroup>
<PackageReference Include="Microsoft.Playwright" Version="*" />
<PackageReference Include="Microsoft.Playwright.MSTest" Version="*" />
<!-- 或者使用xUnit -->
<PackageReference Include="xunit" Version="*" />
<PackageReference Include="xunit.runner.visualstudio" Version="*" />
</ItemGroup>Installation
安装步骤
Before running tests, install Playwright browsers:
bash
pwsh -Command "playwright install --with-deps"运行测试前,安装Playwright浏览器:
bash
pwsh -Command "playwright install --with-deps"Pattern 1: Basic Playwright Setup
模式1:基础Playwright配置
csharp
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();
}
}csharp
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,
// 对于CI/调试场景,你可能需要:
// Headless = Environment.GetEnvironmentVariable("CI") != null,
// SlowMo = 100 // 放慢操作速度以便调试
});
}
public async Task DisposeAsync()
{
if (_browser is not null)
await _browser.DisposeAsync();
_playwright?.Dispose();
}
}Pattern 2: Navigation in Blazor Apps
模式2:Blazor应用中的导航
Initial Page Load (Classic Navigation)
初始页面加载(传统导航)
csharp
[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')"));
}csharp
[Fact]
public async Task InitialPageLoad()
{
var page = await _fixture.Browser.NewPageAsync();
// 首次加载为传统HTTP导航
await page.GotoAsync("https://localhost:5001");
// 等待Blazor初始化完成
await page.WaitForSelectorAsync("h1:has-text('Welcome')");
Assert.True(await page.IsVisibleAsync("h1:has-text('Welcome')"));
}In-App Navigation (No Page Reload)
应用内导航(无页面重载)
Blazor uses client-side routing, so subsequent navigations don't trigger page reloads:
csharp
[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使用客户端路由,后续导航不会触发页面重载:
csharp
[Fact]
public async Task InternalNavigation()
{
var page = await _fixture.Browser.NewPageAsync();
await page.GotoAsync("https://localhost:5001");
// 方法1:点击导航链接
await page.GetByRole(AriaRole.Link, new() { Name = "Counter" })
.ClickAsync();
// 等待新页面内容(不要等待网络空闲!)
await page.WaitForSelectorAsync("h1:has-text('Counter')");
// 方法2:程序化导航(Blazor 8+)
await page.EvaluateAsync("window.Blazor.navigateTo('/fetchdata')");
await page.WaitForSelectorAsync("h1:has-text('Weather')");
// 方法3:直接URL导航(会触发完整重载)
await page.GotoAsync("https://localhost:5001/counter");
await page.WaitForSelectorAsync("h1:has-text('Counter')");
}Wait Strategies for Blazor
Blazor的等待策略
csharp
// ❌ 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");csharp
// ❌ 不要:等待网络空闲(Blazor不会重载页面)
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
// ✅ 要:等待特定DOM元素
await page.WaitForSelectorAsync("h1:has-text('My Page')");
// ✅ 要:等待元素可见
await page.Locator("[data-test='content']").WaitForAsync();
// ✅ 要:等待URL变化
await page.WaitForURLAsync("**/counter");Pattern 3: Stable Selectors with Test Attributes
模式3:使用测试属性实现稳定选择器
In Your Blazor Components
在Blazor组件中
razor
<!-- Add data-test attributes for stable selectors -->
<button data-test="submit-button" @onclick="HandleSubmit">
Submit
</button>
<input data-test="username-input" @bind="Username" />
<div data-test="result-container">
@Result
</div>razor
<!-- 添加data-test属性实现稳定选择器 -->
<button data-test="submit-button" @onclick="HandleSubmit">
提交
</button>
<input data-test="username-input" @bind="Username" />
<div data-test="result-container">
@Result
</div>In Your Tests
在测试代码中
csharp
[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);
}csharp
[Fact]
public async Task FormSubmission()
{
var page = await _fixture.Browser.NewPageAsync();
await page.GotoAsync(baseUrl);
// 对带有data-test属性的元素使用GetByTestId
await page.GetByTestId("username-input").FillAsync("testuser");
await page.GetByTestId("password-input").FillAsync("password123");
await page.GetByTestId("submit-button").ClickAsync();
// 验证结果
var result = await page.GetByTestId("result-container").TextContentAsync();
Assert.Contains("Success", result);
}Pattern 4: Handling Authentication
模式4:处理身份验证
Interactive Login
交互式登录
csharp
[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);
}csharp
[Fact]
public async Task LoginFlow()
{
var page = await _fixture.Browser.NewPageAsync();
await page.GotoAsync($"{baseUrl}/login");
// 填写登录表单
await page.FillAsync("input[name='username']", "alice");
await page.FillAsync("input[name='password']", "P@ssw0rd");
await page.ClickAsync("button[type='submit']");
// 等待重定向到仪表盘
await page.WaitForURLAsync("**/dashboard");
// 验证已登录
var username = await page.TextContentAsync("[data-test='user-name']");
Assert.Equal("alice", username);
}Cookie Injection (Faster)
Cookie注入(更快速)
csharp
[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
}csharp
[Fact]
public async Task AuthenticatedAccess_ViaCookie()
{
var page = await _fixture.Browser.NewPageAsync();
// 注入身份验证Cookie
await page.Context.AddCookiesAsync(new[]
{
new Cookie
{
Name = ".AspNetCore.Cookies",
Value = GenerateAuthCookie("alice"),
Url = baseUrl,
Secure = true,
HttpOnly = true
}
});
// 直接导航到受保护页面
await page.GotoAsync($"{baseUrl}/dashboard");
// 已完成身份验证!
var username = await page.TextContentAsync("[data-test='user-name']");
Assert.Equal("alice", username);
}
private string GenerateAuthCookie(string username)
{
// 生成有效的身份验证Cookie
// 这需要访问应用的Cookie加密密钥
// 或者使用生成有效Cookie的测试端点
// 或者执行一次实际登录并复用Cookie
}OAuth/External Provider Mocking
OAuth/外部提供程序模拟
csharp
// 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"
}
});
});csharp
// 使用路由拦截模拟OAuth重定向
await page.RouteAsync("**/signin-microsoft", async route =>
{
// 拦截OAuth重定向并返回模拟响应
await route.FulfillAsync(new()
{
Status = 302,
Headers = new Dictionary<string, string>
{
["Location"] = $"{baseUrl}/signin-callback?code=mock_auth_code"
}
});
});Pattern 5: Click Events and Touch Interactions
模式5:点击事件与触摸交互
csharp
[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);
}csharp
[Fact]
public async Task ClickInteractions()
{
var page = await _fixture.Browser.NewPageAsync();
await page.GotoAsync(baseUrl);
// 标准点击
await page.GetByText("Click Me").ClickAsync();
// 右键点击
await page.ClickAsync("[data-test='context-menu']", new()
{
Button = MouseButton.Right
});
// 双击
await page.DblClickAsync("[data-test='item']");
// 悬停后点击下拉菜单
var menu = page.Locator("#profile-menu");
await menu.HoverAsync();
await menu.GetByText("Sign out").ClickAsync();
// 触摸事件(移动设备模拟)
await page.EmulateMediaAsync(new() { Media = Media.Screen });
await page.Touchscreen.TapAsync(150, 300);
}Pattern 6: Form Handling
模式6:表单处理
csharp
[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']");
}csharp
[Fact]
public async Task ComplexForm()
{
var page = await _fixture.Browser.NewPageAsync();
await page.GotoAsync($"{baseUrl}/form");
// 文本输入
await page.FillAsync("[data-test='name']", "John Doe");
// 选择下拉框
await page.SelectOptionAsync("[data-test='country']", "US");
// 复选框
await page.CheckAsync("[data-test='terms']");
// 单选按钮
await page.CheckAsync("[data-test='option-a']");
// 文件上传
await page.SetInputFilesAsync("[data-test='file-input']",
"/path/to/test-file.pdf");
// 提交
await page.ClickAsync("[data-test='submit']");
// 等待成功消息
await page.WaitForSelectorAsync("[data-test='success-message']");
}Pattern 7: Handling Blazor Error UI
模式7:处理Blazor错误UI
Blazor shows an error overlay when unhandled exceptions occur. Always check for this:
csharp
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();
}当发生未处理异常时,Blazor会显示错误覆盖层。始终检查该元素:
csharp
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发生错误: {errorText}");
}
}
[Fact]
public async Task Page_ShouldNotHaveErrors()
{
var page = await _fixture.Browser.NewPageAsync();
await page.GotoAsync(baseUrl);
// 执行一些操作
await page.ClickAsync("[data-test='action-button']");
// 验证未发生错误
await page.AssertNoBlazorErrors();
}Pattern 8: Testing Real-Time Updates (SignalR)
模式8:测试实时更新(SignalR)
Blazor Server uses SignalR for real-time communication:
csharp
[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);
}Blazor Server使用SignalR实现实时通信:
csharp
[Fact]
public async Task RealTimeUpdates()
{
// 打开两个浏览器上下文(模拟两个用户)
var page1 = await _fixture.Browser.NewPageAsync();
var page2 = await _fixture.Browser.NewPageAsync();
await page1.GotoAsync($"{baseUrl}/drawing");
await page2.GotoAsync($"{baseUrl}/drawing");
// 用户1绘制内容
await page1.ClickAsync("[data-test='draw-button']");
await page1.Mouse.ClickAsync(100, 100);
// 用户2应能看到更新
await page2.WaitForSelectorAsync("[data-test='drawing-canvas']");
// 验证两个页面显示相同内容
var canvas1 = await page1.GetByTestId("drawing-canvas")
.GetAttributeAsync("data-strokes");
var canvas2 = await page2.GetByTestId("drawing-canvas")
.GetAttributeAsync("data-strokes");
Assert.Equal(canvas1, canvas2);
}Pattern 9: Screenshot and Visual Testing
模式9:截图与视觉测试
csharp
[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"
});
}csharp
[Fact]
public async Task CaptureScreenshots()
{
var page = await _fixture.Browser.NewPageAsync();
await page.GotoAsync(baseUrl);
// 全页面截图
await page.ScreenshotAsync(new()
{
Path = "screenshots/homepage.png",
FullPage = true
});
// 元素截图
var header = page.Locator("header");
await header.ScreenshotAsync(new()
{
Path = "screenshots/header.png"
});
// 指定视口大小截图
await page.SetViewportSizeAsync(1920, 1080);
await page.ScreenshotAsync(new()
{
Path = "screenshots/desktop.png"
});
// 移动端视口
await page.SetViewportSizeAsync(375, 667);
await page.ScreenshotAsync(new()
{
Path = "screenshots/mobile.png"
});
}Pattern 10: Running Against HTTPS with Dev Certs
模式10:使用开发证书针对HTTPS运行
csharp
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" }
});
}For stricter setups, export and trust the dev certificate:
bash
dotnet dev-certs https --export-path cert.pfx -p YourPasswordcsharp
public async Task InitializeAsync()
{
_playwright = await Playwright.CreateAsync();
_browser = await _playwright.Chromium.LaunchAsync(new()
{
Headless = true,
// 忽略本地开发证书的证书错误
Args = new[] { "--ignore-certificate-errors" }
});
}对于更严格的配置,导出并信任开发证书:
bash
dotnet dev-certs https --export-path cert.pfx -p YourPasswordCommon Selectors for Blazor Components
Blazor组件的常见选择器
csharp
// 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')]");csharp
// 按角色(最适合可访问性)
await page.GetByRole(AriaRole.Button, new() { Name = "Submit" });
await page.GetByRole(AriaRole.Link, new() { Name = "Home" });
await page.GetByRole(AriaRole.Heading, new() { Name = "Welcome" });
// 按测试ID
await page.GetByTestId("user-profile");
// 按文本内容
await page.GetByText("Hello, World!");
// 按标签(针对输入框)
await page.GetByLabel("Email Address");
// 按占位符
await page.GetByPlaceholder("Enter your name");
// CSS选择器(谨慎使用)
await page.Locator(".mud-button-primary");
await page.Locator("#login-form");
// XPath(最后手段)
await page.Locator("xpath=//button[contains(text(), 'Submit')]");Parallelization Considerations
并行化注意事项
Blazor Server uses SignalR websockets. Multiple Playwright tests can saturate connections:
csharp
// 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 doesn't have this limitation and can run fully parallel.
Blazor Server使用SignalR WebSocket连接。多个Playwright测试可能会耗尽连接:
csharp
// 限制Blazor Server测试的并行执行
[Collection("Blazor Server")]
public class BlazorServerTests { }
// 在AssemblyInfo.cs或测试启动代码中
[assembly: CollectionBehavior(MaxParallelThreads = 2)]Blazor WebAssembly没有此限制,可以完全并行运行。
CI/CD Integration
CI/CD集成
GitHub Actions
GitHub Actions
yaml
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"yaml
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"Debugging Tips
调试技巧
- Run Headed - Set to watch tests execute
Headless = false - Slow Motion - Add to slow down actions
SlowMo = 500 - Pause Execution - Call to open Playwright Inspector
await page.PauseAsync() - Console Logs - Capture browser console:
page.Console += (_, msg) => Console.WriteLine(msg.Text); - Network Traffic - Monitor requests:
page.Request += (_, req) => Console.WriteLine(req.Url); - Screenshots on Failure - Always capture screenshots in catch blocks
- 运行有头模式 - 设置以观察测试执行过程
Headless = false - 慢动作执行 - 添加以放慢操作速度
SlowMo = 500 - 暂停执行 - 调用打开Playwright Inspector
await page.PauseAsync() - 控制台日志 - 捕获浏览器控制台日志:
page.Console += (_, msg) => Console.WriteLine(msg.Text); - 网络流量 - 监控请求:
page.Request += (_, req) => Console.WriteLine(req.Url); - 失败时截图 - 始终在catch块中捕获截图
Best Practices
最佳实践
- Use data-test attributes - More stable than CSS classes or IDs
- Prefer semantic selectors - Use roles, labels, and text content
- Wait for specific elements - Don't use blanket delays
- Check for Blazor errors - Always verify is not visible
#blazor-error-ui - Test with multiple viewports - Verify responsive design
- Reuse browser contexts - Faster than creating new browsers
- Clean up resources - Always dispose pages and browsers
- Use collections for Blazor Server - Avoid SignalR connection saturation
- Capture screenshots on failure - Essential for debugging CI failures
- Pin browser channels - Use specific channels for reproducibility
- 使用data-test属性 - 比CSS类或ID更稳定
- 优先使用语义化选择器 - 使用角色、标签和文本内容
- 等待特定元素 - 不要使用固定延迟
- 检查Blazor错误 - 始终验证不可见
#blazor-error-ui - 使用多视口测试 - 验证响应式设计
- 复用浏览器上下文 - 比创建新浏览器更快
- 清理资源 - 始终释放页面和浏览器资源
- 对Blazor Server使用集合 - 避免SignalR连接耗尽
- 失败时捕获截图 - 调试CI失败的关键
- 固定浏览器渠道 - 使用特定渠道确保可复现性
Advanced: Custom Wait Helpers
进阶:自定义等待助手
csharp
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);
}
}csharp
public static class PlaywrightExtensions
{
public static async Task WaitForBlazorAsync(this IPage page)
{
// 等待Blazor完成渲染
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);
// 触发失焦以激活验证
await page.Locator(selector).BlurAsync();
// 等待验证完成
await Task.Delay(100);
}
}