DEV Community

Cover image for Como testei minha aplicação Blazor Server de ponta a ponta com Aspire e Playwright
Alberto Monteiro
Alberto Monteiro

Posted on

Como testei minha aplicação Blazor Server de ponta a ponta com Aspire e Playwright

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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
       }));
}
Enter fullscreen mode Exit fullscreen mode

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 o AppHost original.
  • 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>;
Enter fullscreen mode Exit fullscreen mode

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>.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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
});
Enter fullscreen mode Exit fullscreen mode

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"));
    }
}
Enter fullscreen mode Exit fullscreen mode

O que acontece nesse teste, de ponta a ponta:

  1. O Aspire sobe o PostgreSQL, o WireMock e a aplicação Blazor em containers reais
  2. O BrowserDownloader baixa o Chromium
  3. O Playwright abre o browser, navega para a aplicação e preenche o formulário
  4. O Blazor processa o submit, chama a API externa (que está mockada pelo WireMock) e persiste no banco
  5. O Playwright valida a mensagem de sucesso na interface
  6. A assertion final consulta o WireMock via WireMock.Net.RestClient e 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)