playwright-blazor-testing

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Testing 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

核心原则

  1. Wait for Rendering - Blazor renders asynchronously; use proper wait strategies
  2. Test Attributes - Use
    data-test
    or
    data-testid
    attributes for stable selectors
  3. Headless by Default - Run tests headless in CI, headed for local debugging
  4. Handle Error UI - Always check for
    #blazor-error-ui
    to catch unhandled exceptions
  5. Avoid Network Wait States - Blazor navigation doesn't trigger network loads; wait for DOM changes
  6. Pin Browser Channels - Use specific browser channels (msedge, chrome) for reproducibility
  1. 等待渲染完成 - Blazor采用异步渲染,需使用正确的等待策略
  2. 使用测试属性 - 使用
    data-test
    data-testid
    属性实现稳定的选择器
  3. 默认无头模式 - 在CI环境中以无头模式运行测试,本地调试时使用有头模式
  4. 处理错误UI - 始终检查
    #blazor-error-ui
    以捕获未处理的异常
  5. 避免等待网络空闲 - Blazor导航不会触发网络加载,应等待DOM变化
  6. 固定浏览器渠道 - 使用特定浏览器渠道(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 YourPassword
csharp
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 YourPassword

Common 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

调试技巧

  1. Run Headed - Set
    Headless = false
    to watch tests execute
  2. Slow Motion - Add
    SlowMo = 500
    to slow down actions
  3. Pause Execution - Call
    await page.PauseAsync()
    to open Playwright Inspector
  4. Console Logs - Capture browser console:
    page.Console += (_, msg) => Console.WriteLine(msg.Text);
  5. Network Traffic - Monitor requests:
    page.Request += (_, req) => Console.WriteLine(req.Url);
  6. Screenshots on Failure - Always capture screenshots in catch blocks
  1. 运行有头模式 - 设置
    Headless = false
    以观察测试执行过程
  2. 慢动作执行 - 添加
    SlowMo = 500
    以放慢操作速度
  3. 暂停执行 - 调用
    await page.PauseAsync()
    打开Playwright Inspector
  4. 控制台日志 - 捕获浏览器控制台日志:
    page.Console += (_, msg) => Console.WriteLine(msg.Text);
  5. 网络流量 - 监控请求:
    page.Request += (_, req) => Console.WriteLine(req.Url);
  6. 失败时截图 - 始终在catch块中捕获截图

Best Practices

最佳实践

  1. Use data-test attributes - More stable than CSS classes or IDs
  2. Prefer semantic selectors - Use roles, labels, and text content
  3. Wait for specific elements - Don't use blanket delays
  4. Check for Blazor errors - Always verify
    #blazor-error-ui
    is not visible
  5. Test with multiple viewports - Verify responsive design
  6. Reuse browser contexts - Faster than creating new browsers
  7. Clean up resources - Always dispose pages and browsers
  8. Use collections for Blazor Server - Avoid SignalR connection saturation
  9. Capture screenshots on failure - Essential for debugging CI failures
  10. Pin browser channels - Use specific channels for reproducibility
  1. 使用data-test属性 - 比CSS类或ID更稳定
  2. 优先使用语义化选择器 - 使用角色、标签和文本内容
  3. 等待特定元素 - 不要使用固定延迟
  4. 检查Blazor错误 - 始终验证
    #blazor-error-ui
    不可见
  5. 使用多视口测试 - 验证响应式设计
  6. 复用浏览器上下文 - 比创建新浏览器更快
  7. 清理资源 - 始终释放页面和浏览器资源
  8. 对Blazor Server使用集合 - 避免SignalR连接耗尽
  9. 失败时捕获截图 - 调试CI失败的关键
  10. 固定浏览器渠道 - 使用特定渠道确保可复现性

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);
    }
}