Fala galera, tudo beleza?!
Recentemente precisei escrever testes de integração para uma aplicação Blazor Server orquestrada com .NET Aspire. A aplicação tem dependências reais: um banco PostgreSQL, um serviço externo mockado com WireMock, e uma interface Blazor com rendermode InteractiveServer. O desafio era subir tudo isso dentro dos testes, interagir com a interface como um usuário real faria, e validar que as peças se conectavam de verdade — do clique no botão até a chamada na API externa.
Nesse artigo vou contar a jornada: desde subir o AppHost nos testes com Aspire.Hosting.Testing, passando pela armadilha do AngleSharp com InteractiveServer, até fazer o Playwright baixar o próprio browser em C# sem depender de PowerShell.
Aspire.Hosting.Testing, ou: subir o mundo inteiro para testar
O .NET Aspire já facilita muito a orquestração local da aplicação, e o pacote Aspire.Hosting.Testing — que eu já uso para testes de integração de REST APIs — permite instanciar o AppHost inteiro dentro dos seus testes. O xUnit sobe o PostgreSQL em container, o WireMock, e a aplicação Blazor — tudo como se fosse o ambiente real. A diferença aqui foi aplicar isso num contexto com UI interativa, onde só ter o servidor no ar não é suficiente.
Para isso, criei uma AppHostFixture que implementa IAsyncLifetime do xUnit, garantindo que o ambiente sobe antes dos testes e é descartado ao final:
public sealed class AppHostFixture : IAsyncLifetime
{
public ProjectAppHost AppHost { get; private set; } = default!;
public HttpClient AppHostClient { get; private set; } = default!;
public HttpClient WireMock { get; private set; } = default!;
public async Task InitializeAsync()
{
AppHost = new ProjectAppHost();
await AppHost.StartAsync();
AppHostClient = AppHost.CreateHttpClient("minha-app-web");
WireMock = AppHost.CreateHttpClient("api-mock");
await ApplyMigrationsAsync();
}
public async Task DisposeAsync()
=> await AppHost.DisposeAsync();
private async Task ApplyMigrationsAsync()
{
var connectionString = await AppHost.GetConnectionString("Default");
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseNpgsql(connectionString)
.Options;
using var context = new ApplicationDbContext(options);
await context.Database.MigrateAsync(); // <- aplica as migrations no banco de teste
}
}
O CreateHttpClient("nome-do-serviço") retorna um HttpClient já apontando para a porta correta que o Aspire escolheu para aquele serviço. Simples assim.
Agora, sobre o ProjectAppHost: a documentação oficial do Aspire mostra o uso direto do DistributedApplicationTestingBuilder — que é a forma mais simples de subir o AppHost nos testes. Mas eu precisava de mais controle, então criei uma classe própria herdando de DistributedApplicationFactory:
public sealed class ProjectAppHost() : DistributedApplicationFactory(typeof(Projects.My_App))
{
protected override void OnBuilderCreating(DistributedApplicationOptions applicationOptions, HostApplicationBuilderSettings hostOptions)
{
// Suporte ao proxy de imagens Docker do GitLab CI
applicationOptions.ContainerRegistryOverride =
Environment.GetEnvironmentVariable("CI_DEPENDENCY_PROXY_DIRECT_GROUP_IMAGE_PREFIX");
applicationOptions.AllowUnsecuredTransport = true;
hostOptions.Args = ["--testmode=true"]; // <- flag lida no AppHost para ajustar comportamento em testes
}
protected override void OnBuilderCreated(DistributedApplicationBuilder builder)
=> builder.Services.ConfigureHttpClientDefaults(x => x.AddStandardResilienceHandler(c =>
{
var timeSpan = TimeSpan.FromMinutes(2);
c.AttemptTimeout.Timeout = timeSpan;
c.CircuitBreaker.SamplingDuration = timeSpan * 2;
c.TotalRequestTimeout.Timeout = timeSpan * 3; // <- containers demoram mais para subir em CI
}));
}
O OnBuilderCreating e o OnBuilderCreated são os hooks que a DistributedApplicationFactory expõe para você customizar antes e depois de o builder ser criado. Os dois motivos principais que me fizeram ir por esse caminho em vez do DistributedApplicationTestingBuilder padrão:
-
ContainerRegistryOverride: no pipeline CI/CD, usamos um proxy de imagens Docker do GitLab. Essa propriedade redireciona os pulls de container para esse proxy sem precisar alterar oAppHostoriginal. - Timeouts generosos: containers demoram mais para subir em agentes de CI do que na sua máquina. Sem ajustar os timeouts do resilience handler, os testes quebram por timeout antes mesmo de o ambiente estar pronto.
Se você não precisar dessas configurações, o DistributedApplicationTestingBuilder resolve com muito menos código — a documentação oficial mostra bem como usá-lo.
Também rodei as migrations direto no InitializeAsync, pegando a connection string dinâmica do AppHost. Assim o banco começa limpo e com o schema correto a cada sessão de testes.
Para amarrar a fixture com os testes, basta usar as collections do xUnit:
[CollectionDefinition("AppTestCollection", DisableParallelization = true)]
public class AppTestCollection : ICollectionFixture<AppHostFixture>;
O DisableParallelization = true é importante — os testes que sobem containers não se dão bem rodando em paralelo.
AngleSharp parecia a escolha óbvia, ou: InteractiveServer muda tudo
Com o ambiente no ar, a próxima etapa era interagir com a interface. Minha primeira tentativa foi o AngleSharp, uma lib C# para parsear e navegar HTML. Ela é leve, roda in-process, e já usei ela antes em projetos mais simples.
O problema foi o rendermode InteractiveServer.
O formulário já tinha o FormName configurado corretamente no EditForm. Mesmo assim, ao tentar submeter via AngleSharp, aparecia esse erro:
The POST request does not specify which form is being submitted.
To fix this, ensure <form> elements have a @formname attribute
with any unique value, or pass a FormName parameter if using <EditForm>.
O AngleSharp faz requisições HTTP comuns — ele não executa JavaScript e não entende o protocolo do Blazor Interactive. O rendermode InteractiveServer do Blazor Server usa SignalR para manter o circuit entre browser e servidor: é por meio dessa conexão que os eventos, o state binding e os submits do formulário são processados. O AngleSharp simplesmente não estabelece essa conexão.
No meu caso, a aplicação usa MudBlazor, e o rendermode InteractiveServer é necessário para que os componentes MudBlazor funcionem corretamente. Não era uma questão de ajustar o FormName — o problema era estrutural. A solução certa era usar um browser de verdade.
Microsoft.Playwright, ou: um browser real dentro do teste
O Microsoft.Playwright é o binding .NET do Playwright — permite controlar um browser real (Chromium, Firefox ou WebKit) de forma programática. Com ele, o teste navega pela interface como um usuário faria: preenche campos, clica em botões, espera elementos aparecerem.
O grande problema prático do Playwright é o processo de instalação do browser. O pacote NuGet não vem com o executável do Chromium — você precisa rodar um script PowerShell para baixar:
pwsh bin/Debug/net10.0/playwright.ps1 install
Isso funciona bem na sua máquina. Mas em pipelines de CI/CD? Vai depender de ter o PowerShell instalado no agente, o que em muitos ambientes — especialmente imagens Linux minimalistas — não é garantido. Não gosto de criar essa dependência.
nor0x.Playwright.BrowserDownloader, ou: adeus PowerShell
Foi aí que encontrei a lib nor0x.Playwright.BrowserDownloader. Ela resolve exatamente esse problema: permite fazer o download do browser dentro do próprio código C#, sem nenhuma dependência externa.
var downloader = new BrowserDownloader();
var executablePath = await downloader.DownloadBrowserAsync(BrowserInfo.Chromium, TargetPlatform.Win64);
Duas linhas. O DownloadBrowserAsync baixa o Chromium (ou Firefox, WebKit) para a plataforma especificada e retorna o caminho do executável. Aí é só passar esse path para o Playwright na hora de iniciar o browser:
await playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions
{
ExecutablePath = executablePath // <- sem esse path, o Playwright não encontra o Chromium
});
Isso é muito elegante!!! O pipeline de CI não precisa saber nada sobre Playwright — o próprio teste cuida de provisionar o browser.
O teste completo, ou: preenche, clica e verifica até a API externa
Com tudo no lugar, o teste ficou assim:
[Collection("AppTestCollection")]
public sealed class CreateVariablesHappyPathTests(AppHostFixture fixture)
{
private readonly AppHostFixture _fixture = fixture;
[Fact]
public async Task CreateVariablesPage_WithValidRequest_ShouldCreateVariablesAndPersistAuditLog()
{
// Arrange
const string REPO_URL = "https://gitlab.com/my-group/my-project";
const string SECRET_NAME = "my-secret-name";
const string HML_USERNAME = "user_hml";
const string HML_KEY = "key_hml_value";
const string PRD_USERNAME = "user_prd";
const string PRD_KEY = "key_prd_value";
var downloader = new BrowserDownloader();
var executablePath = await downloader.DownloadBrowserAsync(BrowserInfo.Chromium, TargetPlatform.Win64);
using var playwright = await Playwright.CreateAsync();
await using var browser = await playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions
{
ExecutablePath = executablePath
});
var context = await browser.NewContextAsync();
var page = await context.NewPageAsync();
// Act
await page.GotoAsync(_fixture.AppHostClient.BaseAddress.ToString());
await page.WaitForSelectorAsync("form");
await page.GetByTestId("repourl").FillAsync(REPO_URL);
await page.GetByTestId("secretname").FillAsync(SECRET_NAME);
await page.GetByTestId("hmlusername").FillAsync(HML_USERNAME);
await page.GetByTestId("hmlkey").FillAsync(HML_KEY);
await page.GetByTestId("prdusername").FillAsync(PRD_USERNAME);
await page.GetByTestId("prdkey").FillAsync(PRD_KEY);
var button = page.GetByRole(AriaRole.Button, new() { Name = "Criar Variáveis" });
await button.ClickAsync();
// Assert — interface
var alert = await page.WaitForSelectorAsync(".mud-alert-message");
var message = await alert.TextContentAsync();
message.Should().Be("Variáveis criadas com sucesso!");
// Assert — chamada na API externa via WireMock
var wiremock = RestClient.For<IWireMockAdminApi>(_fixture.WireMock.BaseAddress);
var requests = await wiremock.GetRequestsAsync();
requests.Should().Contain(r => r.MappingGuid == Guid.Parse("31D30833-3BB6-47F5-B0C7-4FD4143D3FBE"));
}
}
O que acontece nesse teste, de ponta a ponta:
- O Aspire sobe o PostgreSQL, o WireMock e a aplicação Blazor em containers reais
- O
BrowserDownloaderbaixa o Chromium - O Playwright abre o browser, navega para a aplicação e preenche o formulário
- O Blazor processa o submit, chama a API externa (que está mockada pelo WireMock) e persiste no banco
- O Playwright valida a mensagem de sucesso na interface
- A assertion final consulta o WireMock via
WireMock.Net.RestCliente verifica que a requisição com aquele mapping específico foi recebida
Esse último passo é o que torna o teste realmente valioso. Não basta a interface mostrar sucesso — eu consigo provar que a chamada HTTP para a API externa aconteceu do jeito certo.
O que ficou de aprendizado
Aspire.Hosting.Testing sobe o ambiente inteiro, mas migrations são sua responsabilidade. Ele cuida dos containers e das referências entre serviços com muito pouco código, e o CreateHttpClient com o nome do serviço já resolve os endereços dinâmicos. Mas as migrations do banco precisam ser aplicadas declarativamente no seu próprio código de teste — como no InitializeAsync da fixture — não acontecem automaticamente.
AngleSharp não serve para Blazor InteractiveServer. Não é limitação da lib — é a natureza do InteractiveServer. Se o componente precisa de SignalR para funcionar, você precisa de um browser real. Entender esse limite logo evita horas de tentativa e erro.
Nunca dependa de script externo para baixar browser em CI. O nor0x.Playwright.BrowserDownloader resolve isso elegantemente dentro do próprio código C#. O pipeline fica mais simples e autocontido.
WireMock + assertions nos requests fecha o ciclo do teste. Validar só a UI não é suficiente para testes de integração. Verificar que a requisição correta chegou no mock garante que toda a cadeia — UI → lógica → HTTP client — funcionou de verdade.
Use data-testid nos componentes desde o início. O GetByTestId do Playwright é a forma mais estável de localizar elementos. Não depende de texto, CSS ou estrutura do DOM — só do atributo que você mesmo colocou.
👉 Se você tiver dúvidas ou quiser trocar uma ideia sobre a abordagem, deixa nos comentários, vai ser muito legal!!!
Vou ficando por aqui, não deixe de comentar, um grande abraço!
#dotnet #blazor #aspire #playwright #testes #integrationtesting #csharp
Top comments (0)