Testen von Blazor-Anwendungen mit Playwright
Wann diese Fähigkeit anwenden
Verwende diese Fähigkeit, wenn:
- Du End-to-End-UI-Tests für Blazor Server- oder WebAssembly-Anwendungen schreibst
- Du interaktive Komponenten, Formulare und Benutzerabläufe testest
- Du Authentifizierungs- und Autorisierungsabläufe überprüfst
- Du SignalR-basierte Echtzeitaktualisierungen in Blazor Server testest
- Du Screenshots für visuelle Regressionstests aufnimmst
- Du responsive Designs und mobile Emulation testest
- Du UI-Probleme mit den Entwicklertools des Browsers debuggen möchtest
Kernprinzipien
- Auf Rendering warten – Blazor rendert asynchron; verwende geeignete Wartestrategien
- Testattribute – Verwende
data-test- oderdata-testid-Attribute für stabile Selektoren - Standardmäßig Headless – Führe Tests in CI headless aus, lokal mit Fenster zum Debuggen
- Fehler-UI behandeln – Überprüfe immer auf
#blazor-error-ui, um unbehandelte Ausnahmen abzufangen - Netzwerk-Wartezustände vermeiden – Blazor-Navigation löst keine Netzwerklasten aus; warte auf DOM-Änderungen
- Browserkanäle festlegen – Verwende spezifische Browserkanäle (msedge, chrome) für Reproduzierbarkeit
Erforderliche NuGet-Pakete
<ItemGroup>
<PackageReference Include="Microsoft.Playwright" Version="*" />
<PackageReference Include="Microsoft.Playwright.MSTest" Version="*" />
<!-- ODER für xUnit -->
<PackageReference Include="xunit" Version="*" />
<PackageReference Include="xunit.runner.visualstudio" Version="*" />
</ItemGroup>
Installation
Installiere vor dem Ausführen der Tests die Playwright-Browser:
pwsh -Command "playwright install --with-deps"
Muster 1: Einfaches Playwright-Setup
using Microsoft.Playwright;
public class PlaywrightFixture : IAsyncLifetime
{
private IPlaywright? _playwright;
private IBrowser? _browser;
public IBrowser Browser => _browser
?? throw new InvalidOperationException("Browser nicht initialisiert");
public async Task InitializeAsync()
{
_playwright = await Playwright.CreateAsync();
_browser = await _playwright.Chromium.LaunchAsync(new()
{
Headless = true,
// Für CI/Debugging könntest du Folgendes verwenden:
// Headless = Environment.GetEnvironmentVariable("CI") != null,
// SlowMo = 100 // Verlangsamt Aktionen zum Debuggen
});
}
public async Task DisposeAsync()
{
if (_browser is not null)
await _browser.DisposeAsync();
_playwright?.Dispose();
}
}
Muster 2: Navigation in Blazor-Apps
Erster Seitenaufruf (Klassische Navigation)
[Fact]
public async Task InitialPageLoad()
{
var page = await _fixture.Browser.NewPageAsync();
// Erster Aufruf ist klassische HTTP-Navigation
await page.GotoAsync("https://localhost:5001");
// Warte auf Initialisierung von Blazor
await page.WaitForSelectorAsync("h1:has-text('Welcome')");
Assert.True(await page.IsVisibleAsync("h1:has-text('Welcome')"));
}
Interne Navigation (Kein Seitenneuladen)
Blazor verwendet clientseitiges Routing, daher lösen nachfolgende Navigationen kein Seitenneuladen aus:
[Fact]
public async Task InternalNavigation()
{
var page = await _fixture.Browser.NewPageAsync();
await page.GotoAsync("https://localhost:5001");
// Methode 1: Klicke einen Navigationslink an
await page.GetByRole(AriaRole.Link, new() { Name = "Counter" })
.ClickAsync();
// Warte auf den neuen Seiteninhalt (NICHT NetworkIdle!)
await page.WaitForSelectorAsync("h1:has-text('Counter')");
// Methode 2: Programmatische Navigation (Blazor 8+)
await page.EvaluateAsync("window.Blazor.navigateTo('/fetchdata')");
await page.WaitForSelectorAsync("h1:has-text('Weather')");
// Methode 3: Direkte URL-Navigation (führt zu vollständigem Neuladen)
await page.GotoAsync("https://localhost:5001/counter");
await page.WaitForSelectorAsync("h1:has-text('Counter')");
}
Wartestrategien für Blazor
// ❌ NICHT: Auf Netzwerkinaktivität warten (Blazor lädt keine Seiten neu)
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
// ✅ DO: Auf bestimmte DOM-Elemente warten
await page.WaitForSelectorAsync("h1:has-text('My Page')");
// ✅ DO: Auf Sichtbarkeit eines Elements warten
await page.Locator("[data-test='content']").WaitForAsync();
// ✅ DO: Auf URL-Änderung warten
await page.WaitForURLAsync("**/counter");
Muster 3: Stabile Selektoren mit Testattributen
In deinen Blazor-Komponenten
<!-- Füge data-test-Attribute für stabile Selektoren hinzu -->
<button data-test="submit-button" @onclick="HandleSubmit">
Absenden
</button>
<input data-test="username-input" @bind="Username" />
<div data-test="result-container">
@Result
</div>
In deinen Tests
[Fact]
public async Task FormSubmission()
{
var page = await _fixture.Browser.NewPageAsync();
await page.GotoAsync(baseUrl);
// Verwende GetByTestId für Elemente mit data-test-Attributen
await page.GetByTestId("username-input").FillAsync("testuser");
await page.GetByTestId("password-input").FillAsync("password123");
await page.GetByTestId("submit-button").ClickAsync();
// Ergebnis überprüfen
var result = await page.GetByTestId("result-container").TextContentAsync();
Assert.Contains("Erfolg", result);
}
Muster 4: Authentifizierung behandeln
Interaktiver Login
[Fact]
public async Task LoginFlow()
{
var page = await _fixture.Browser.NewPageAsync();
await page.GotoAsync($"{baseUrl}/login");
// Login-Formular ausfüllen
await page.FillAsync("input[name='username']", "alice");
await page.FillAsync("input[name='password']", "P@ssw0rd");
await page.ClickAsync("button[type='submit']");
// Warte auf Weiterleitung zum Dashboard
await page.WaitForURLAsync("**/dashboard");
// Überprüfe, ob angemeldet
var username = await page.TextContentAsync("[data-test='user-name']");
Assert.Equal("alice", username);
}
Cookie-Injektion (Schneller)
[Fact]
public async Task AuthenticatedAccess_ViaCookie()
{
var page = await _fixture.Browser.NewPageAsync();
// Authentifizierungs-Cookie injizieren
await page.Context.AddCookiesAsync(new[]
{
new Cookie
{
Name = ".AspNetCore.Cookies",
Value = GenerateAuthCookie("alice"),
Url = baseUrl,
Secure = true,
HttpOnly = true
}
});
// Direkt zur geschützten Seite navigieren
await page.GotoAsync($"{baseUrl}/dashboard");
// Bereits authentifiziert!
var username = await page.TextContentAsync("[data-test='user-name']");
Assert.Equal("alice", username);
}
private string GenerateAuthCookie(string username)
{
// Generiere ein gültiges Authentifizierungs-Cookie
// Dies erfordert Zugriff auf die Cookie-Verschlüsselungsschlüssel deiner App
// ODER verwende einen Testendpunkt, der gültige Cookies generiert
// ODER führe einmal einen tatsächlichen Login durch und verwende das Cookie erneut
}
OAuth/Externen Anbieter mocken
// Verwende Routenabfang, um OAuth-Weiterleitungen zu mocken
await page.RouteAsync("**/signin-microsoft", async route =>
{
// OAuth-Weiterleitung abfangen und Mock-Antwort zurückgeben
await route.FulfillAsync(new()
{
Status = 302,
Headers = new Dictionary<string, string>
{
["Location"] = $"{baseUrl}/signin-callback?code=mock_auth_code"
}
});
});
Muster 5: Klickereignisse und Touch-Interaktionen
[Fact]
public async Task ClickInteractions()
{
var page = await _fixture.Browser.NewPageAsync();
await page.GotoAsync(baseUrl);
// Standardklick
await page.GetByText("Click Me").ClickAsync();
// Rechtsklick
await page.ClickAsync("[data-test='context-menu']", new()
{
Button = MouseButton.Right
});
// Doppelklick
await page.DblClickAsync("[data-test='item']");
// Hover und dann auf Dropdown klicken
var menu = page.Locator("#profile-menu");
await menu.HoverAsync();
await menu.GetByText("Sign out").ClickAsync();
// Touch-Ereignisse (mobile Emulation)
await page.EmulateMediaAsync(new() { Media = Media.Screen });
await page.Touchscreen.TapAsync(150, 300);
}
Muster 6: Formularverarbeitung
[Fact]
public async Task ComplexForm()
{
var page = await _fixture.Browser.NewPageAsync();
await page.GotoAsync($"{baseUrl}/form");
// Texteingabe
await page.FillAsync("[data-test='name']", "Max Mustermann");
// Dropdown auswählen
await page.SelectOptionAsync("[data-test='country']", "DE");
// Checkbox
await page.CheckAsync("[data-test='terms']");
// Optionsfeld
await page.CheckAsync("[data-test='option-a']");
// Datei-Upload
await page.SetInputFilesAsync("[data-test='file-input']",
"/pfad/zur/test-datei.pdf");
// Absenden
await page.ClickAsync("[data-test='submit']");
// Auf Erfolgsmeldung warten
await page.WaitForSelectorAsync("[data-test='success-message']");
}
Muster 7: Blazor-Fehler-UI behandeln
Blazor zeigt eine Fehlerüberlagerung an, wenn unbehandelte Ausnahmen auftreten. Überprüfe dies immer:
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-Fehler aufgetreten: {errorText}");
}
}
[Fact]
public async Task Page_ShouldNotHaveErrors()
{
var page = await _fixture.Browser.NewPageAsync();
await page.GotoAsync(baseUrl);
// Einige Aktionen ausführen
await page.ClickAsync("[data-test='action-button']");
// Überprüfen, dass keine Fehler aufgetreten sind
await page.AssertNoBlazorErrors();
}
Muster 8: Echtzeitaktualisierungen testen (SignalR)
Blazor Server verwendet SignalR für die Echtzeitkommunikation:
[Fact]
public async Task RealTimeUpdates()
{
// Zwei Browserkontexte öffnen (zwei Benutzer simulieren)
var page1 = await _fixture.Browser.NewPageAsync();
var page2 = await _fixture.Browser.NewPageAsync();
await page1.GotoAsync($"{baseUrl}/drawing");
await page2.GotoAsync($"{baseUrl}/drawing");
// Benutzer 1 zeichnet etwas
await page1.ClickAsync("[data-test='draw-button']");
await page1.Mouse.ClickAsync(100, 100);
// Benutzer 2 sollte die Aktualisierung sehen
await page2.WaitForSelectorAsync("[data-test='drawing-canvas']");
// Überprüfen, dass beide Seiten denselben Inhalt anzeigen
var canvas1 = await page1.GetByTestId("drawing-canvas")
.GetAttributeAsync("data-strokes");
var canvas2 = await page2.GetByTestId("drawing-canvas")
.GetAttributeAsync("data-strokes");
Assert.Equal(canvas1, canvas2);
}
Muster 9: Screenshot und visuelle Tests
[Fact]
public async Task CaptureScreenshots()
{
var page = await _fixture.Browser.NewPageAsync();
await page.GotoAsync(baseUrl);
// Vollseiten-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 mit Viewport-Größe
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"
});
}
Muster 10: Gegen HTTPS mit Entwicklerzertifikaten ausführen
public async Task InitializeAsync()
{
_playwright = await Playwright.CreateAsync();
_browser = await _playwright.Chromium.LaunchAsync(new()
{
Headless = true,
// Zertifikatsfehler für lokale Entwicklerzertifikate ignorieren
Args = new[] { "--ignore-certificate-errors" }
});
}
Für strengere Setups: Exportiere und vertraue dem Entwicklerzertifikat:
dotnet dev-certs https --export-path cert.pfx -p YourPassword
Häufige Selektoren für Blazor-Komponenten
// Nach Rolle (am besten für Barrierefreiheit)
await page.GetByRole(AriaRole.Button, new() { Name = "Submit" });
await page.GetByRole(AriaRole.Link, new() { Name = "Home" });
await page.GetByRole(AriaRole.Heading, new() { Name = "Welcome" });
// Nach Test-ID
await page.GetByTestId("user-profile");
// Nach Textinhalt
await page.GetByText("Hello, World!");
// Nach Label (für Eingabefelder)
await page.GetByLabel("Email Address");
// Nach Platzhalter
await page.GetByPlaceholder("Enter your name");
// CSS-Selektoren (sparsam verwenden)
await page.Locator(".mud-button-primary");
await page.Locator("#login-form");
// XPath (als letzte Option)
await page.Locator("xpath=//button[contains(text(), 'Submit')]");
Überlegungen zur Parallelisierung
Blazor Server verwendet SignalR-Websockets. Mehrere Playwright-Tests können die Verbindungen sättigen:
// Parallele Ausführung für Blazor Server-Tests begrenzen
[Collection("Blazor Server")]
public class BlazorServerTests { }
// In AssemblyInfo.cs oder beim Teststart
[assembly: CollectionBehavior(MaxParallelThreads = 2)]
Blazor WebAssembly hat diese Einschränkung nicht und kann vollständig parallel ausgeführt werden.
CI/CD-Integration
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: .NET einrichten
uses: actions/setup-dotnet@v3
with:
dotnet-version: 9.0.x
- name: Playwright-Browser installieren
run: pwsh -Command "playwright install --with-deps"
- name: Erstellen
run: dotnet build -c Release
- name: Playwright-Tests ausführen
run: |
dotnet test tests/YourApp.UITests \
--no-build \
-c Release \
--logger trx
- name: Screenshots hochladen
uses: actions/upload-artifact@v3
if: failure()
with:
name: playwright-screenshots
path: "**/screenshots/"
- name: Testergebnisse hochladen
uses: actions/upload-artifact@v3
if: always()
with:
name: playwright-test-results
path: "**/TestResults/"
Fehlerbehebung
Häufige Probleme
- Blazor initialisiert nicht: Warte auf
#blazor-loading-indicatorund dannh1oderapp - SignalR-Verbindungsfehler: Überprüfe, ob der Blazor Server-Host läuft. Verwende
page.on("console")zum Debuggen - Elemente nicht interagierbar: Stelle sicher, dass die Blazor-Rendering abgeschlossen ist. Verwende
page.WaitForSelectorAsyncstattTask.Delay - Test-Flickering: Verwende stabilere Selektoren (
data-testid) und erhöhe implizite Wartezeiten
Debugging
// Konsolenausgabe während des Tests erfassen
page.Console += (_, msg) =>
{
Console.WriteLine($"[Browser] {msg.Text}");
};
// Hör auf Seitenfehler
page.PageError += (_, error) =>
{
Console.WriteLine($"[Blazor Error] {error}");
};
// Aktuelle HTML-Struktur speichern (nützlich für Fehleranalyse)
await File.WriteAllTextAsync("page-content.html", await page.ContentAsync());