Testando Aplicações Blazor com Playwright
Quando Usar Esta Skill
Use esta skill quando:
- Escrevendo testes de UI end-to-end para aplicações Blazor Server ou WebAssembly
- Testando componentes interativos, formulários e fluxos de trabalho do usuário
- Verificando fluxos de autenticação e autorização
- Testando atualizações em tempo real baseadas em SignalR no Blazor Server
- Capturando capturas de tela para testes de regressão visual
- Testando designs responsivos e emulação móvel
- Depurando problemas de UI com ferramentas de desenvolvedor do navegador
Princípios Fundamentais
- Aguarde a Renderização - O Blazor renderiza de forma assíncrona; use estratégias de espera adequadas
- Atributos de Teste - Use atributos
data-testoudata-testidpara seletores estáveis - Headless por Padrão - Execute testes headless em CI, com cabeçalho para depuração local
- Lide com a UI de Erro - Sempre verifique se há
#blazor-error-uipara capturar exceções não tratadas - Evite Estados de Espera de Rede - A navegação no Blazor não dispara carregamentos de rede; aguarde mudanças no DOM
- Fixar Canais do Navegador - Use canais de navegador específicos (msedge, chrome) para reprodutibilidade
Pacotes NuGet Necessários
<ItemGroup>
<PackageReference Include="Microsoft.Playwright" Version="*" />
<PackageReference Include="Microsoft.Playwright.MSTest" Version="*" />
<!-- OU para xUnit -->
<PackageReference Include="xunit" Version="*" />
<PackageReference Include="xunit.runner.visualstudio" Version="*" />
</ItemGroup>
Instalação
Antes de executar os testes, instale os navegadores Playwright:
pwsh -Command "playwright install --with-deps"
Padrão 1: Configuração Básica do 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,
// Para CI/depuração, você pode querer:
// Headless = Environment.GetEnvironmentVariable("CI") != null,
// SlowMo = 100 // Desacelera as ações para depuração
});
}
public async Task DisposeAsync()
{
if (_browser is not null)
await _browser.DisposeAsync();
_playwright?.Dispose();
}
}
Padrão 2: Navegação em Aplicações Blazor
Carregamento de Página Inicial (Navegação Clássica)
[Fact]
public async Task InitialPageLoad()
{
var page = await _fixture.Browser.NewPageAsync();
// O primeiro carregamento é uma navegação HTTP clássica
await page.GotoAsync("https://localhost:5001");
// Aguarde a inicialização do Blazor
await page.WaitForSelectorAsync("h1:has-text('Welcome')");
Assert.True(await page.IsVisibleAsync("h1:has-text('Welcome')"));
}
Navegação Interna (Sem Recarregamento de Página)
O Blazor usa roteamento do lado do cliente, portanto, navegações subsequentes não disparam recarregamentos de página:
[Fact]
public async Task InternalNavigation()
{
var page = await _fixture.Browser.NewPageAsync();
await page.GotoAsync("https://localhost:5001");
// Método 1: Clicar em um link de navegação
await page.GetByRole(AriaRole.Link, new() { Name = "Counter" })
.ClickAsync();
// Aguarde o novo conteúdo da página (NÃO network idle!)
await page.WaitForSelectorAsync("h1:has-text('Counter')");
// Método 2: Navegação programática (Blazor 8+)
await page.EvaluateAsync("window.Blazor.navigateTo('/fetchdata')");
await page.WaitForSelectorAsync("h1:has-text('Weather')");
// Método 3: Navegação por URL direta (causa recarregamento completo)
await page.GotoAsync("https://localhost:5001/counter");
await page.WaitForSelectorAsync("h1:has-text('Counter')");
}
Estratégias de Espera para Blazor
// ❌ NÃO FAÇA: Aguardar network idle (Blazor não recarrega páginas)
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
// ✅ FAÇA: Aguardar elementos específicos do DOM
await page.WaitForSelectorAsync("h1:has-text('My Page')");
// ✅ FAÇA: Aguardar visibilidade do elemento
await page.Locator("[data-test='content']").WaitForAsync();
// ✅ FAÇA: Aguardar mudança de URL
await page.WaitForURLAsync("**/counter");
Padrão 3: Seletores Estáveis com Atributos de Teste
Em Seus Componentes Blazor
<!-- Adicione atributos data-test para seletores estáveis -->
<button data-test="submit-button" @onclick="HandleSubmit">
Submit
</button>
<input data-test="username-input" @bind="Username" />
<div data-test="result-container">
@Result
</div>
Em Seus Testes
[Fact]
public async Task FormSubmission()
{
var page = await _fixture.Browser.NewPageAsync();
await page.GotoAsync(baseUrl);
// Use GetByTestId para elementos com atributos data-test
await page.GetByTestId("username-input").FillAsync("testuser");
await page.GetByTestId("password-input").FillAsync("password123");
await page.GetByTestId("submit-button").ClickAsync();
// Verifique o resultado
var result = await page.GetByTestId("result-container").TextContentAsync();
Assert.Contains("Success", result);
}
Padrão 4: Lidando com Autenticação
Login Interativo
[Fact]
public async Task LoginFlow()
{
var page = await _fixture.Browser.NewPageAsync();
await page.GotoAsync($"{baseUrl}/login");
// Preencha o formulário de login
await page.FillAsync("input[name='username']", "alice");
await page.FillAsync("input[name='password']", "P@ssw0rd");
await page.ClickAsync("button[type='submit']");
// Aguarde o redirecionamento para o dashboard
await page.WaitForURLAsync("**/dashboard");
// Verifique se está logado
var username = await page.TextContentAsync("[data-test='user-name']");
Assert.Equal("alice", username);
}
Injeção de Cookie (Mais Rápido)
[Fact]
public async Task AuthenticatedAccess_ViaCookie()
{
var page = await _fixture.Browser.NewPageAsync();
// Injete o cookie de autenticação
await page.Context.AddCookiesAsync(new[]
{
new Cookie
{
Name = ".AspNetCore.Cookies",
Value = GenerateAuthCookie("alice"),
Url = baseUrl,
Secure = true,
HttpOnly = true
}
});
// Navegue diretamente para a página protegida
await page.GotoAsync($"{baseUrl}/dashboard");
// Já autenticado!
var username = await page.TextContentAsync("[data-test='user-name']");
Assert.Equal("alice", username);
}
private string GenerateAuthCookie(string username)
{
// Gere um cookie de autenticação válido
// Isso requer acesso às chaves de criptografia de cookies do seu aplicativo
// OU use um endpoint de teste que gere cookies válidos
// OU faça o login real uma vez e reutilize o cookie
}
Simulação de Provedor Externo/OAuth
// Use intercepção de rota para simular redirecionamentos OAuth
await page.RouteAsync("**/signin-microsoft", async route =>
{
// Intercepte o redirecionamento OAuth e retorne uma resposta simulada
await route.FulfillAsync(new()
{
Status = 302,
Headers = new Dictionary<string, string>
{
["Location"] = $"{baseUrl}/signin-callback?code=mock_auth_code"
}
});
});
Padrão 5: Eventos de Clique e Interações de Toque
[Fact]
public async Task ClickInteractions()
{
var page = await _fixture.Browser.NewPageAsync();
await page.GotoAsync(baseUrl);
// Clique padrão
await page.GetByText("Click Me").ClickAsync();
// Clique com botão direito
await page.ClickAsync("[data-test='context-menu']", new()
{
Button = MouseButton.Right
});
// Clique duplo
await page.DblClickAsync("[data-test='item']");
// Passe o mouse e clique no menu suspenso
var menu = page.Locator("#profile-menu");
await menu.HoverAsync();
await menu.GetByText("Sign out").ClickAsync();
// Eventos de toque (emulação móvel)
await page.EmulateMediaAsync(new() { Media = Media.Screen });
await page.Touchscreen.TapAsync(150, 300);
}
Padrão 6: Manipulação de Formulários
[Fact]
public async Task ComplexForm()
{
var page = await _fixture.Browser.NewPageAsync();
await page.GotoAsync($"{baseUrl}/form");
// Entrada de texto
await page.FillAsync("[data-test='name']", "John Doe");
// Menu suspenso de seleção
await page.SelectOptionAsync("[data-test='country']", "US");
// Caixa de seleção
await page.CheckAsync("[data-test='terms']");
// Botão de rádio
await page.CheckAsync("[data-test='option-a']");
// Upload de arquivo
await page.SetInputFilesAsync("[data-test='file-input']",
"/path/to/test-file.pdf");
// Enviar
await page.ClickAsync("[data-test='submit']");
// Aguarde a mensagem de sucesso
await page.WaitForSelectorAsync("[data-test='success-message']");
}
Padrão 7: Lidando com a UI de Erro do Blazor
O Blazor mostra uma sobreposição de erro quando ocorrem exceções não tratadas. Sempre verifique isso:
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);
// Execute algumas ações
await page.ClickAsync("[data-test='action-button']");
// Verifique se nenhum erro ocorreu
await page.AssertNoBlazorErrors();
}
Padrão 8: Testando Atualizações em Tempo Real (SignalR)
O Blazor Server usa SignalR para comunicação em tempo real:
[Fact]
public async Task RealTimeUpdates()
{
// Abra dois contextos de navegador (simulando dois usuários)
var page1 = await _fixture.Browser.NewPageAsync();
var page2 = await _fixture.Browser.NewPageAsync();
await page1.GotoAsync($"{baseUrl}/drawing");
await page2.GotoAsync($"{baseUrl}/drawing");
// Usuário 1 desenha algo
await page1.ClickAsync("[data-test='draw-button']");
await page1.Mouse.ClickAsync(100, 100);
// Usuário 2 deve ver a atualização
await page2.WaitForSelectorAsync("[data-test='drawing-canvas']");
// Verifique se ambas as páginas mostram o mesmo conteúdo
var canvas1 = await page1.GetByTestId("drawing-canvas")
.GetAttributeAsync("data-strokes");
var canvas2 = await page2.GetByTestId("drawing-canvas")
.GetAttributeAsync("data-strokes");
Assert.Equal(canvas1, canvas2);
}
Padrão 9: Captura de Tela e Teste Visual
[Fact]
public async Task CaptureScreenshots()
{
var page = await _fixture.Browser.NewPageAsync();
await page.GotoAsync(baseUrl);
// Captura de tela da página inteira
await page.ScreenshotAsync(new()
{
Path = "screenshots/homepage.png",
FullPage = true
});
// Captura de tela de um elemento
var header = page.Locator("header");
await header.ScreenshotAsync(new()
{
Path = "screenshots/header.png"
});
// Captura de tela com tamanho de viewport
await page.SetViewportSizeAsync(1920, 1080);
await page.ScreenshotAsync(new()
{
Path = "screenshots/desktop.png"
});
// Viewport móvel
await page.SetViewportSizeAsync(375, 667);
await page.ScreenshotAsync(new()
{
Path = "screenshots/mobile.png"
});
}
Padrão 10: Executando Contra HTTPS com Certificados de Desenvolvimento
public async Task InitializeAsync()
{
_playwright = await Playwright.CreateAsync();
_browser = await _playwright.Chromium.LaunchAsync(new()
{
Headless = true,
// Ignore erros de certificado para certificados de desenvolvimento local
Args = new[] { "--ignore-certificate-errors" }
});
}
Para configurações mais rigorosas, exporte e confie no certificado de desenvolvimento:
dotnet dev-certs https --export-path cert.pfx -p YourPassword
Seletores Comuns para Componentes Blazor
// Por função (melhor para acessibilidade)
await page.GetByRole(AriaRole.Button, new() { Name = "Submit" });
await page.GetByRole(AriaRole.Link, new() { Name = "Home" });
await page.GetByRole(AriaRole.Heading, new() { Name = "Welcome" });
// Por ID de teste
await page.GetByTestId("user-profile");
// Por conteúdo de texto
await page.GetByText("Hello, World!");
// Por rótulo (para entradas)
await page.GetByLabel("Email Address");
// Por placeholder
await page.GetByPlaceholder("Enter your name");
// Seletores CSS (use com moderação)
await page.Locator(".mud-button-primary");
await page.Locator("#login-form");
// XPath (use como último recurso)
await page.Locator("xpath=//button[contains(text(), 'Submit')]");
Considerações sobre Paralelização
O Blazor Server usa websockets SignalR. Vários testes do Playwright podem saturar as conexões:
// Limite a execução paralela para testes do Blazor Server
[Collection("Blazor Server")]
public class BlazorServerTests { }
// Em AssemblyInfo.cs ou início do teste
[assembly: CollectionBehavior(MaxParallelThreads = 2)]
O Blazor WebAssembly não tem essa limitação e pode ser executado totalmente em paralelo.
Integração 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