mcpField
P
playwright-blazor

playwright-blazor

github.com/wshaddixテスト・品質8672 回インストール35 週間前に更新
リポジトリ

playwright-blazor について

このスキルは、Blazor ServerおよびWebAssemblyアプリ向けのPlaywrightエンドツーエンドUIテストを案内します。操作、認証、リアルタイム動作、レスポンシブ確認、スクリーンショットに加え、Blazor特有の待機とエラー処理を扱います。

主な機能

  1. 01Blazorのナビゲーション、フォーム、操作をテスト
  2. 02安定したテスト属性とセマンティックセレクターを使用
  3. 03認証と認可のフローをテスト
  4. 04複数ブラウザーセッションでSignalR更新をテスト
  5. 05視覚確認用のスクリーンショットを取得
  6. 06非同期描画とBlazorエラーUIに対応

活用シーン

  1. 01Blazorのログインとフォーム送信を自動テスト
  2. 02Blazor Serverアプリのリアルタイム更新を確認
  3. 03モバイルエミュレーションでレスポンシブUIを確認
  4. 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 の問題をデバッグする

基本原則

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

コメント