mcpField
P
playwright-blazor-testing

playwright-blazor-testing

github.com/aarononthewebテスト・品質1.2k548 回インストール今日更新
リポジトリ

playwright-blazor-testing について

playwright-blazor-testing は、Playwright で Blazor Server と WebAssembly アプリの E2E テストを書くためのスキルです。レンダリング待機、セレクター、認証、SignalR 更新、ビジュアル確認を扱い、インタラクティブ UI をテストする開発者向けです。

主な機能

  1. 01Blazor に合わせたレンダリングとナビゲーション待機
  2. 02テスト属性による安定したセレクター
  3. 03認証と OAuth フローのテスト
  4. 04SignalR リアルタイム更新のテスト
  5. 05スクリーンショットとレスポンシブ表示の確認
  6. 06デバッグと CI テストのガイダンス

活用シーン

  1. 01Blazor のフォームとユーザーワークフローをテスト
  2. 02ログインと認可の動作を検証
  3. 03複数のブラウザーセッション間の SignalR 更新を確認
  4. 04ビジュアルリグレッション確認用のスクリーンショットを取得

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/aaronontheweb/dotnet-skills --skill playwright-blazor-testing

インストール後、Claude Code で/playwright-blazor-testingを入力して読み込まれたか確認します。

詳細

Playwright を使用した Blazor アプリケーションのテスト

このスキルを使用する場面

次の場合にこのスキルを使用します:

  • Blazor Server または WebAssembly アプリケーションのエンドツーエンド UI テストを作成する
  • インタラクティブなコンポーネント、フォーム、ユーザーワークフローをテストする
  • 認証および認可のフローを検証する
  • Blazor Server の SignalR ベースのリアルタイム更新をテストする
  • ビジュアルリグレッションテスト用のスクリーンショットを撮影する
  • レスポンシブデザインとモバイルエミュレーションをテストする
  • ブラウザーの開発者ツールを使って UI の問題をデバッグする

基本原則

  1. レンダリングを待つ - Blazor は非同期にレンダリングされるため、適切な待機方法を使用します
  2. テスト用属性を使う - 安定したセレクターには data-test または data-testid 属性を使用します
  3. デフォルトではヘッドレスで実行する - CI ではヘッドレスで実行し、ローカルでのデバッグ時はヘッドありで実行します
  4. エラー UI を処理する - 未処理の例外を検出できるよう、常に #blazor-error-ui を確認します
  5. ネットワーク待機状態を避ける - Blazor のナビゲーションではネットワーク読み込みが発生しないため、DOM の変更を待ちます
  6. ブラウザーチャネルを固定する - 再現性を確保するため、特定のブラウザーチャネル(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"

デバッグのヒント

  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 ブロックでは必ずスクリーンショットを撮影します

ベストプラクティス

  1. data-test 属性を使用する - CSS クラスや ID よりも安定しています
  2. セマンティックセレクターを優先する - ロール、ラベル、テキストコンテンツを使用します
  3. 特定の要素を待つ - 一律の遅延は使用しません
  4. Blazor のエラーを確認する - #blazor-error-ui が表示されていないことを必ず確認します
  5. 複数のビューポートでテストする - レスポンシブデザインを検証します
  6. ブラウザーコンテキストを再利用する - 新しいブラウザーを作成するより高速です
  7. リソースをクリーンアップする - ページとブラウザーを必ず破棄します
  8. Blazor Server ではコレクションを使用する - SignalR 接続の飽和を避けます
  9. 失敗時のスクリーンショットを撮影する - CI での失敗のデバッグに不可欠です
  10. ブラウザーチャネルを固定する - 再現性を確保するため、特定のチャネルを使用します

応用:カスタム待機ヘルパー

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

コメント