<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>Forem: Alberto Monteiro</title>
    <description>The latest articles on Forem by Alberto Monteiro (@albertomonteiro).</description>
    <link>https://forem.com/albertomonteiro</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F382955%2F7243c841-e9bb-4b89-90c6-a395ee09bf29.jpeg</url>
      <title>Forem: Alberto Monteiro</title>
      <link>https://forem.com/albertomonteiro</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/albertomonteiro"/>
    <language>en</language>
    <item>
      <title>Como testei minha aplicação Blazor Server de ponta a ponta com Aspire e Playwright</title>
      <dc:creator>Alberto Monteiro</dc:creator>
      <pubDate>Sun, 29 Mar 2026 12:00:00 +0000</pubDate>
      <link>https://forem.com/albertomonteiro/como-testei-minha-aplicacao-blazor-server-de-ponta-a-ponta-com-aspire-e-playwright-3mhk</link>
      <guid>https://forem.com/albertomonteiro/como-testei-minha-aplicacao-blazor-server-de-ponta-a-ponta-com-aspire-e-playwright-3mhk</guid>
      <description>&lt;p&gt;Fala galera, tudo beleza?!&lt;/p&gt;

&lt;p&gt;Recentemente precisei escrever testes de integração para uma aplicação &lt;strong&gt;Blazor Server&lt;/strong&gt; orquestrada com &lt;strong&gt;.NET Aspire&lt;/strong&gt;. A aplicação tem dependências reais: um banco PostgreSQL, um serviço externo mockado com WireMock, e uma interface Blazor com &lt;code&gt;rendermode InteractiveServer&lt;/code&gt;. O desafio era subir &lt;strong&gt;tudo isso&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;Nesse artigo vou contar a jornada: desde subir o AppHost nos testes com &lt;code&gt;Aspire.Hosting.Testing&lt;/code&gt;, passando pela armadilha do AngleSharp com &lt;code&gt;InteractiveServer&lt;/code&gt;, até fazer o Playwright baixar o próprio browser em C# sem depender de PowerShell.&lt;/p&gt;




&lt;h2&gt;
  
  
  Aspire.Hosting.Testing, ou: subir o mundo inteiro para testar
&lt;/h2&gt;

&lt;p&gt;O &lt;code&gt;.NET Aspire&lt;/code&gt; já facilita muito a orquestração local da aplicação, e o pacote &lt;code&gt;Aspire.Hosting.Testing&lt;/code&gt; — que eu já uso para testes de integração de REST APIs — permite instanciar o &lt;code&gt;AppHost&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;Para isso, criei uma &lt;code&gt;AppHostFixture&lt;/code&gt; que implementa &lt;code&gt;IAsyncLifetime&lt;/code&gt; do xUnit, garantindo que o ambiente sobe antes dos testes e é descartado ao final:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AppHostFixture&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;IAsyncLifetime&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;ProjectAppHost&lt;/span&gt; &lt;span class="n"&gt;AppHost&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;!;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;HttpClient&lt;/span&gt; &lt;span class="n"&gt;AppHostClient&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;!;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;HttpClient&lt;/span&gt; &lt;span class="n"&gt;WireMock&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;!;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;InitializeAsync&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;AppHost&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;ProjectAppHost&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;AppHost&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;StartAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

        &lt;span class="n"&gt;AppHostClient&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;AppHost&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateHttpClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"minha-app-web"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;WireMock&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;AppHost&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateHttpClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"api-mock"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;ApplyMigrationsAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;DisposeAsync&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;AppHost&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;DisposeAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;ApplyMigrationsAsync&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;connectionString&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;AppHost&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetConnectionString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Default"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;options&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;DbContextOptionsBuilder&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ApplicationDbContext&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;()&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseNpgsql&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;connectionString&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Options&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;var&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;ApplicationDbContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Database&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MigrateAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// &amp;lt;- aplica as migrations no banco de teste&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;O &lt;code&gt;CreateHttpClient("nome-do-serviço")&lt;/code&gt; retorna um &lt;code&gt;HttpClient&lt;/code&gt; já apontando para a porta correta que o Aspire escolheu para aquele serviço. Simples assim.&lt;/p&gt;

&lt;p&gt;Agora, sobre o &lt;code&gt;ProjectAppHost&lt;/code&gt;: a documentação oficial do Aspire mostra o uso direto do &lt;code&gt;DistributedApplicationTestingBuilder&lt;/code&gt; — 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 &lt;code&gt;DistributedApplicationFactory&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ProjectAppHost&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;DistributedApplicationFactory&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Projects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;My_App&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;OnBuilderCreating&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DistributedApplicationOptions&lt;/span&gt; &lt;span class="n"&gt;applicationOptions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;HostApplicationBuilderSettings&lt;/span&gt; &lt;span class="n"&gt;hostOptions&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Suporte ao proxy de imagens Docker do GitLab CI&lt;/span&gt;
        &lt;span class="n"&gt;applicationOptions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ContainerRegistryOverride&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
            &lt;span class="n"&gt;Environment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetEnvironmentVariable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"CI_DEPENDENCY_PROXY_DIRECT_GROUP_IMAGE_PREFIX"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="n"&gt;applicationOptions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AllowUnsecuredTransport&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="n"&gt;hostOptions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Args&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"--testmode=true"&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt; &lt;span class="c1"&gt;// &amp;lt;- flag lida no AppHost para ajustar comportamento em testes&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;OnBuilderCreated&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DistributedApplicationBuilder&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
       &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ConfigureHttpClientDefaults&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddStandardResilienceHandler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
       &lt;span class="p"&gt;{&lt;/span&gt;
           &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;timeSpan&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;TimeSpan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromMinutes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
           &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AttemptTimeout&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Timeout&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;timeSpan&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
           &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CircuitBreaker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SamplingDuration&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;timeSpan&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
           &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TotalRequestTimeout&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Timeout&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;timeSpan&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// &amp;lt;- containers demoram mais para subir em CI&lt;/span&gt;
       &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;O &lt;code&gt;OnBuilderCreating&lt;/code&gt; e o &lt;code&gt;OnBuilderCreated&lt;/code&gt; são os hooks que a &lt;code&gt;DistributedApplicationFactory&lt;/code&gt; 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 &lt;code&gt;DistributedApplicationTestingBuilder&lt;/code&gt; padrão:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ContainerRegistryOverride&lt;/code&gt;&lt;/strong&gt;: 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 &lt;code&gt;AppHost&lt;/code&gt; original.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Timeouts generosos&lt;/strong&gt;: 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.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Se você não precisar dessas configurações, o &lt;code&gt;DistributedApplicationTestingBuilder&lt;/code&gt; resolve com muito menos código — a &lt;a href="https://aspire.dev/testing/write-your-first-test/?testing-framework=xunit" rel="noopener noreferrer"&gt;documentação oficial&lt;/a&gt; mostra bem como usá-lo.&lt;/p&gt;

&lt;p&gt;Também rodei as migrations direto no &lt;code&gt;InitializeAsync&lt;/code&gt;, 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.&lt;/p&gt;

&lt;p&gt;Para amarrar a fixture com os testes, basta usar as collections do xUnit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;CollectionDefinition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"AppTestCollection"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;DisableParallelization&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AppTestCollection&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ICollectionFixture&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;AppHostFixture&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;O &lt;code&gt;DisableParallelization = true&lt;/code&gt; é importante — os testes que sobem containers não se dão bem rodando em paralelo.&lt;/p&gt;




&lt;h2&gt;
  
  
  AngleSharp parecia a escolha óbvia, ou: InteractiveServer muda tudo
&lt;/h2&gt;

&lt;p&gt;Com o ambiente no ar, a próxima etapa era interagir com a interface. Minha primeira tentativa foi o &lt;strong&gt;AngleSharp&lt;/strong&gt;, uma lib C# para parsear e navegar HTML. Ela é leve, roda in-process, e já usei ela antes em projetos mais simples.&lt;/p&gt;

&lt;p&gt;O problema foi o &lt;code&gt;rendermode InteractiveServer&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;O formulário já tinha o &lt;code&gt;FormName&lt;/code&gt; configurado corretamente no &lt;code&gt;EditForm&lt;/code&gt;. Mesmo assim, ao tentar submeter via AngleSharp, aparecia esse erro:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;The POST request does not specify which form is being submitted.
To fix this, ensure &amp;lt;form&amp;gt; elements have a @formname attribute
with any unique value, or pass a FormName parameter if using &amp;lt;EditForm&amp;gt;.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;O AngleSharp faz requisições HTTP comuns — ele não executa JavaScript e não entende o protocolo do Blazor Interactive. O &lt;code&gt;rendermode InteractiveServer&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;No meu caso, a aplicação usa &lt;strong&gt;MudBlazor&lt;/strong&gt;, e o &lt;code&gt;rendermode InteractiveServer&lt;/code&gt; é necessário para que os componentes MudBlazor funcionem corretamente. Não era uma questão de ajustar o &lt;code&gt;FormName&lt;/code&gt; — o problema era estrutural. A solução certa era usar um browser de verdade.&lt;/p&gt;




&lt;h2&gt;
  
  
  Microsoft.Playwright, ou: um browser real dentro do teste
&lt;/h2&gt;

&lt;p&gt;O &lt;strong&gt;Microsoft.Playwright&lt;/strong&gt; é 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.&lt;/p&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;pwsh&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;bin/Debug/net10.0/playwright.ps1&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;install&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;




&lt;h2&gt;
  
  
  nor0x.Playwright.BrowserDownloader, ou: adeus PowerShell
&lt;/h2&gt;

&lt;p&gt;Foi aí que encontrei a lib &lt;code&gt;nor0x.Playwright.BrowserDownloader&lt;/code&gt;. Ela resolve exatamente esse problema: permite fazer o download do browser &lt;strong&gt;dentro do próprio código C#&lt;/strong&gt;, sem nenhuma dependência externa.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;downloader&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;BrowserDownloader&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;executablePath&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;downloader&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;DownloadBrowserAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BrowserInfo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Chromium&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;TargetPlatform&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Win64&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Duas linhas. O &lt;code&gt;DownloadBrowserAsync&lt;/code&gt; 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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;playwright&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Chromium&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;LaunchAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;BrowserTypeLaunchOptions&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;ExecutablePath&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;executablePath&lt;/span&gt; &lt;span class="c1"&gt;// &amp;lt;- sem esse path, o Playwright não encontra o Chromium&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Isso é muito elegante!!! O pipeline de CI não precisa saber nada sobre Playwright — o próprio teste cuida de provisionar o browser.&lt;/p&gt;




&lt;h2&gt;
  
  
  O teste completo, ou: preenche, clica e verifica até a API externa
&lt;/h2&gt;

&lt;p&gt;Com tudo no lugar, o teste ficou assim:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;Collection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"AppTestCollection"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;CreateVariablesHappyPathTests&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AppHostFixture&lt;/span&gt; &lt;span class="n"&gt;fixture&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="n"&gt;AppHostFixture&lt;/span&gt; &lt;span class="n"&gt;_fixture&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fixture&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Fact&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;CreateVariablesPage_WithValidRequest_ShouldCreateVariablesAndPersistAuditLog&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Arrange&lt;/span&gt;
        &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;REPO_URL&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"https://gitlab.com/my-group/my-project"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;SECRET_NAME&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"my-secret-name"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;HML_USERNAME&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"user_hml"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;HML_KEY&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"key_hml_value"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;PRD_USERNAME&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"user_prd"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;PRD_KEY&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"key_prd_value"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;downloader&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;BrowserDownloader&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;executablePath&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;downloader&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;DownloadBrowserAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BrowserInfo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Chromium&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;TargetPlatform&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Win64&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;var&lt;/span&gt; &lt;span class="n"&gt;playwright&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;Playwright&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;var&lt;/span&gt; &lt;span class="n"&gt;browser&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;playwright&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Chromium&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;LaunchAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;BrowserTypeLaunchOptions&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;ExecutablePath&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;executablePath&lt;/span&gt;
        &lt;span class="p"&gt;});&lt;/span&gt;

        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;NewContextAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;NewPageAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

        &lt;span class="c1"&gt;// Act&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GotoAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_fixture&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AppHostClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BaseAddress&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToString&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WaitForSelectorAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"form"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetByTestId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"repourl"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;FillAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;REPO_URL&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetByTestId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"secretname"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;FillAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SECRET_NAME&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetByTestId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"hmlusername"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;FillAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;HML_USERNAME&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetByTestId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"hmlkey"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;FillAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;HML_KEY&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetByTestId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"prdusername"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;FillAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;PRD_USERNAME&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetByTestId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"prdkey"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;FillAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;PRD_KEY&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;button&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetByRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AriaRole&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Button&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;Name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Criar Variáveis"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;button&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ClickAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

        &lt;span class="c1"&gt;// Assert — interface&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;alert&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WaitForSelectorAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;".mud-alert-message"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;alert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;TextContentAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Should&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;Be&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Variáveis criadas com sucesso!"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="c1"&gt;// Assert — chamada na API externa via WireMock&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;wiremock&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;RestClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;For&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;IWireMockAdminApi&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;_fixture&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WireMock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BaseAddress&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;wiremock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetRequestsAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Should&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;Contain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MappingGuid&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;Guid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"31D30833-3BB6-47F5-B0C7-4FD4143D3FBE"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;O que acontece nesse teste, de ponta a ponta:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;O Aspire sobe o PostgreSQL, o WireMock e a aplicação Blazor em containers reais&lt;/li&gt;
&lt;li&gt;O &lt;code&gt;BrowserDownloader&lt;/code&gt; baixa o Chromium&lt;/li&gt;
&lt;li&gt;O Playwright abre o browser, navega para a aplicação e preenche o formulário&lt;/li&gt;
&lt;li&gt;O Blazor processa o submit, chama a API externa (que está mockada pelo WireMock) e persiste no banco&lt;/li&gt;
&lt;li&gt;O Playwright valida a mensagem de sucesso na interface&lt;/li&gt;
&lt;li&gt;A assertion final consulta o WireMock via &lt;code&gt;WireMock.Net.RestClient&lt;/code&gt; e verifica que a requisição com aquele mapping específico foi recebida&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;h2&gt;
  
  
  O que ficou de aprendizado
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;Aspire.Hosting.Testing&lt;/code&gt; sobe o ambiente inteiro, mas migrations são sua responsabilidade.&lt;/strong&gt; Ele cuida dos containers e das referências entre serviços com muito pouco código, e o &lt;code&gt;CreateHttpClient&lt;/code&gt; 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 &lt;code&gt;InitializeAsync&lt;/code&gt; da fixture — não acontecem automaticamente.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;AngleSharp não serve para Blazor InteractiveServer.&lt;/strong&gt; Não é limitação da lib — é a natureza do &lt;code&gt;InteractiveServer&lt;/code&gt;. Se o componente precisa de SignalR para funcionar, você precisa de um browser real. Entender esse limite logo evita horas de tentativa e erro.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Nunca dependa de script externo para baixar browser em CI.&lt;/strong&gt; O &lt;code&gt;nor0x.Playwright.BrowserDownloader&lt;/code&gt; resolve isso elegantemente dentro do próprio código C#. O pipeline fica mais simples e autocontido.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;WireMock + assertions nos requests fecha o ciclo do teste.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use &lt;code&gt;data-testid&lt;/code&gt; nos componentes desde o início.&lt;/strong&gt; O &lt;code&gt;GetByTestId&lt;/code&gt; 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.&lt;/p&gt;




&lt;p&gt;👉 Se você tiver dúvidas ou quiser trocar uma ideia sobre a abordagem, deixa nos comentários, vai ser muito legal!!!&lt;/p&gt;

&lt;p&gt;Vou ficando por aqui, não deixe de comentar, um grande abraço!&lt;/p&gt;

&lt;p&gt;&lt;em&gt;#dotnet #blazor #aspire #playwright #testes #integrationtesting #csharp&lt;/em&gt;&lt;/p&gt;

</description>
    </item>
    <item>
      <title>NuGet Central Package Management: como criei uma dotnet tool pra limpar o lixo que ninguém limpa</title>
      <dc:creator>Alberto Monteiro</dc:creator>
      <pubDate>Tue, 24 Mar 2026 12:39:10 +0000</pubDate>
      <link>https://forem.com/albertomonteiro/nuget-central-package-management-como-criei-uma-dotnet-tool-pra-limpar-o-lixo-que-ninguem-limpa-2egn</link>
      <guid>https://forem.com/albertomonteiro/nuget-central-package-management-como-criei-uma-dotnet-tool-pra-limpar-o-lixo-que-ninguem-limpa-2egn</guid>
      <description>&lt;p&gt;Fala galera, tudo beleza?!&lt;/p&gt;

&lt;p&gt;Se você trabalha com .NET e tem uma solution com mais do que meia dúzia de projetos, eu tenho certeza que já passou por aquele momento: você precisa atualizar um pacote NuGet e descobre que ele está numa versão no projeto A, outra versão no projeto B, e uma terceira versão no projeto C. Já tive muita dor de cabeça com isso. E olha que eu gosto de facilitar minha vida.&lt;/p&gt;

&lt;p&gt;Foi aí que o &lt;strong&gt;Central Package Management (CPM)&lt;/strong&gt; do NuGet mudou o jogo pra mim. Mas como nem tudo são flores, ao usar o CPM no dia a dia, percebi que ele cria um probleminha silencioso que ninguém fala. E eu não fui o único — tem uma &lt;a href="https://github.com/NuGet/Home/issues/13562" rel="noopener noreferrer"&gt;issue aberta no repositório do NuGet&lt;/a&gt; sobre exatamente isso, e o time basicamente disse que não vai resolver.&lt;/p&gt;

&lt;p&gt;Então eu fui lá e fiz.&lt;/p&gt;

&lt;p&gt;Spoiler: ficou simples, prático e resolve uma dor real. Bora lá!&lt;/p&gt;




&lt;h2&gt;
  
  
  CPM do NuGet, ou: por que você deveria estar usando isso ontem
&lt;/h2&gt;

&lt;p&gt;Se você ainda não conhece o Central Package Management, deixa eu te explicar rapidinho. A ideia é simples: ao invés de cada &lt;code&gt;.csproj&lt;/code&gt; da sua solution declarar a versão dos pacotes NuGet que usa, você centraliza &lt;strong&gt;todas&lt;/strong&gt; as versões num único arquivo chamado &lt;code&gt;Directory.Packages.props&lt;/code&gt; na raiz do repositório.&lt;/p&gt;

&lt;p&gt;Nos seus projetos, o &lt;code&gt;PackageReference&lt;/code&gt; fica assim:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- No .csproj — sem Version! --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;PackageReference&lt;/span&gt; &lt;span class="na"&gt;Include=&lt;/span&gt;&lt;span class="s"&gt;"Newtonsoft.Json"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;E no &lt;code&gt;Directory.Packages.props&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;Project&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;PropertyGroup&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;ManagePackageVersionsCentrally&amp;gt;&lt;/span&gt;true&lt;span class="nt"&gt;&amp;lt;/ManagePackageVersionsCentrally&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/PropertyGroup&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;ItemGroup&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;PackageVersion&lt;/span&gt; &lt;span class="na"&gt;Include=&lt;/span&gt;&lt;span class="s"&gt;"Newtonsoft.Json"&lt;/span&gt; &lt;span class="na"&gt;Version=&lt;/span&gt;&lt;span class="s"&gt;"13.0.3"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/ItemGroup&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/Project&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Simples assim. Um arquivo, uma versão, todos os projetos alinhados.&lt;/p&gt;

&lt;p&gt;Os benefícios são enormes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Uma única fonte de verdade.&lt;/strong&gt; Acabou aquela história de "no projeto X tá na versão 6, no projeto Y tá na 7". Tudo num lugar só.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Upgrade de pacotes sem sofrimento.&lt;/strong&gt; Precisa atualizar o Entity Framework em 30 projetos? Muda uma linha. Uma. Linha.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Menos conflito de merge.&lt;/strong&gt; Sabe quando dois devs em branches diferentes atualizam pacotes e o merge vira um pesadelo? Com CPM a mudança de versão fica concentrada num arquivo só, os conflitos diminuem drasticamente.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Projetos mais limpos.&lt;/strong&gt; Os &lt;code&gt;.csproj&lt;/code&gt; ficam enxutos, sem aquele monte de &lt;code&gt;Version="x.y.z"&lt;/code&gt; espalhado.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Segurança da supply chain.&lt;/strong&gt; Combinando CPM com Package Source Mapping, você controla de qual feed cada pacote pode vir. Pacotes internos só do seu feed privado, pacotes públicos só do nuget.org. E com lock files (&lt;code&gt;packages.lock.json&lt;/code&gt;), o resultado é um grafo de dependências determinístico e auditável.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pinning de dependências transitivas.&lt;/strong&gt; Aquela dependência transitiva que veio com uma vulnerabilidade? Com o transitive pinning do CPM, você força a versão corrigida sem precisar ficar caçando de qual pacote ela veio.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Se você tem uma solution com mais de 3 ou 4 projetos, CPM é praticamente obrigatório. É mágico, é lindo!!!&lt;/p&gt;




&lt;h2&gt;
  
  
  O problema que ninguém fala, ou: o lixo silencioso no Directory.Packages.props
&lt;/h2&gt;

&lt;p&gt;Beleza, CPM é maravilhoso. Mas tem um detalhe que me incomodava faz tempo.&lt;/p&gt;

&lt;p&gt;No dia a dia do desenvolvimento, pacotes vão e vêm. Você adiciona um pacote pra testar algo, remove depois. Refatora um projeto e tira uma dependência. Troca uma lib por outra. O que acontece? O &lt;code&gt;PackageReference&lt;/code&gt; sai do &lt;code&gt;.csproj&lt;/code&gt;, mas a declaração de &lt;code&gt;&amp;lt;PackageVersion&amp;gt;&lt;/code&gt; &lt;strong&gt;continua lá&lt;/strong&gt; no &lt;code&gt;Directory.Packages.props&lt;/code&gt;, sossegada, juntando poeira.&lt;/p&gt;

&lt;p&gt;Com o tempo, aquele arquivo que deveria ser a sua fonte de verdade vira um cemitério de referências que ninguém mais usa. Num projeto grande, eu já vi &lt;code&gt;Directory.Packages.props&lt;/code&gt; com dezenas de entradas órfãs. Isso gera confusão: alguém novo no time olha e pensa que o pacote é usado, tenta entender onde, perde tempo. Ou pior, na hora de fazer upgrade, você atualiza versão de pacote que nem está sendo usado.&lt;/p&gt;

&lt;h3&gt;
  
  
  O detalhe que me fez criar a ferramenta: o Dependabot enlouquecendo
&lt;/h3&gt;

&lt;p&gt;E aqui vem a parte que dói de verdade. Se você usa &lt;strong&gt;Dependabot&lt;/strong&gt; (ou qualquer ferramenta de atualização automática de dependências), ele vai ler o &lt;code&gt;Directory.Packages.props&lt;/code&gt; e criar pull requests pra atualizar &lt;strong&gt;todos&lt;/strong&gt; os pacotes listados ali — inclusive os que ninguém mais usa. Ou seja, o time recebe PRs, revisa, aprova, faz merge... tudo pra atualizar versão de pacote que nem está no código. É trabalho jogado fora, é ruído no workflow, é tempo que podia estar indo pra coisa que importa.&lt;/p&gt;

&lt;h3&gt;
  
  
  A issue no NuGet, ou: "não vamos resolver isso"
&lt;/h3&gt;

&lt;p&gt;Eu não fui o primeiro a perceber. Existe a &lt;a href="https://github.com/NuGet/Home/issues/13562" rel="noopener noreferrer"&gt;issue #13562 no repositório do NuGet&lt;/a&gt; que descreve exatamente esse problema: ao desinstalar um pacote, ele sai do &lt;code&gt;.csproj&lt;/code&gt; mas fica no &lt;code&gt;Directory.Packages.props&lt;/code&gt;. A resposta do time do NuGet? Que isso é &lt;strong&gt;intencional por design&lt;/strong&gt; — porque no contexto de um projeto individual, o NuGet não tem como saber quantos outros projetos usam aquele &lt;code&gt;Directory.Packages.props&lt;/code&gt;. Faz sentido do ponto de vista deles, mas o resultado prático é que a sujeira vai acumulando e ninguém limpa.&lt;/p&gt;

&lt;p&gt;A sugestão que ficou foi que &lt;em&gt;talvez&lt;/em&gt; no futuro isso virasse uma opção opt-in, um argumento de linha de comando ou uma opção na UI. Mas pelo que eu entendi, não está no radar de prioridades do time.&lt;/p&gt;

&lt;p&gt;Então eu pensei: se o time do NuGet não vai resolver, eu resolvo.&lt;/p&gt;




&lt;h2&gt;
  
  
  Nasce o nuget-cpm-cleaner, ou: resolvendo em minutos o que levaria horas na mão
&lt;/h2&gt;

&lt;p&gt;A ideia é direta: a ferramenta lê o &lt;code&gt;Directory.Packages.props&lt;/code&gt;, escaneia todos os &lt;code&gt;.csproj&lt;/code&gt; do repositório, e descobre quais pacotes estão declarados mas não são usados por ninguém. Sem mais delongas, o fluxo funciona assim:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Localiza o &lt;code&gt;Directory.Packages.props&lt;/code&gt;&lt;/strong&gt; — sobe a árvore de diretórios a partir da raiz que você indicou&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Parseia os pacotes declarados&lt;/strong&gt; — extrai todos os &lt;code&gt;&amp;lt;PackageVersion&amp;gt;&lt;/code&gt; do arquivo&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Escaneia os projetos&lt;/strong&gt; — roda &lt;code&gt;dotnet msbuild -getItem:PackageReference&lt;/code&gt; em cada &lt;code&gt;.csproj&lt;/code&gt; pra saber quais pacotes são realmente usados&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Calcula a diferença&lt;/strong&gt; — declarados menos usados = lixo&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Te deixa escolher&lt;/strong&gt; — ou mostra um prompt interativo pra você selecionar o que remover, ou remove tudo automaticamente&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  O detalhe que me custou um bom tempo de pesquisa
&lt;/h3&gt;

&lt;p&gt;Para descobrir quais pacotes cada projeto realmente usa, eu poderia simplesmente parsear o XML dos &lt;code&gt;.csproj&lt;/code&gt; procurando &lt;code&gt;PackageReference&lt;/code&gt;. Mas isso não pega tudo — pacotes podem vir de &lt;code&gt;Directory.Build.props&lt;/code&gt;, de imports condicionais, de targets dinâmicos. A solução robusta foi usar o próprio MSBuild pra avaliar o projeto:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet msbuild &lt;span class="nt"&gt;-getItem&lt;/span&gt;:PackageReference
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Esse comando retorna um JSON estruturado com todos os &lt;code&gt;PackageReference&lt;/code&gt; resolvidos do projeto, considerando todas as importações e condições. É o MSBuild te dizendo "olha, no final das contas, esses são os pacotes que esse projeto precisa". Muito mais confiável do que parsear XML na mão.&lt;/p&gt;




&lt;h2&gt;
  
  
  Instalação e uso, ou: duas linhas e já era
&lt;/h2&gt;

&lt;p&gt;Instala como global tool:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet tool &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; nuget-cpm-cleaner
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Modo interativo — ele mostra os pacotes não usados e você escolhe o que remover:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nuget-cpm-cleaner &lt;span class="nt"&gt;--root&lt;/span&gt; C:/repos/minha-solution
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Modo automático — remove tudo sem perguntar (ótimo pra CI ou quando você confia no resultado):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nuget-cpm-cleaner &lt;span class="nt"&gt;--root&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;--auto-remove&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A saída é bem clara:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Found C:\repos\minha-solution\Directory.Packages.props
Declared packages: 42
Referenced packages across .csproj files: 38

Found 4 unused package(s):

? Select packages to remove: (Press &amp;lt;space&amp;gt; to toggle, &amp;lt;enter&amp;gt; to confirm)
&amp;gt; [ ] Deprecated.Package
  [ ] OldLibrary.Core
  [ ] SomeUnused.Tool
  [ ] UnusedAnalyzer

Done. Removed 2 package(s) from Directory.Packages.props:
  - Deprecated.Package
  - OldLibrary.Core
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Simples assim. E de bônus: se você rodar isso antes de configurar o Dependabot (ou como step periódico no CI), nunca mais vai receber PR de atualização de pacote fantasma.&lt;/p&gt;




&lt;h2&gt;
  
  
  O que ficou de aprendizado
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;CPM não é opcional em solutions grandes.&lt;/strong&gt; Se você tem mais de 3 projetos e ainda gerencia versões de pacote em cada &lt;code&gt;.csproj&lt;/code&gt;, está acumulando dívida técnica todo dia. Centraliza isso.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Ferramentas boas resolvem dores pequenas que acumulam.&lt;/strong&gt; Um &lt;code&gt;&amp;lt;PackageVersion&amp;gt;&lt;/code&gt; órfão não quebra nada. Mas 30 deles deixam seu arquivo de configuração sujo, confuso, e fazem o Dependabot trabalhar à toa.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Se a ferramenta oficial não resolve, resolve você.&lt;/strong&gt; A issue tá aberta, o time do NuGet explicou por que não vai tratar por padrão. Em vez de esperar, criei uma tool que resolve em dois comandos. Open source é isso.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Use o MSBuild como fonte de verdade, não o XML cru.&lt;/strong&gt; Parsear &lt;code&gt;.csproj&lt;/code&gt; direto é tentador, mas o resultado real de um build depende de condições, imports, e targets que só o MSBuild resolve. O &lt;code&gt;-getItem&lt;/code&gt; é a forma correta.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Dotnet tools são subestimadas.&lt;/strong&gt; Criar uma CLI distribuível via NuGet é absurdamente fácil no .NET. &lt;code&gt;PackAsTool&lt;/code&gt;, &lt;code&gt;ToolCommandName&lt;/code&gt;, e pronto — qualquer dev instala com uma linha.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Para saber mais
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://learn.microsoft.com/en-us/nuget/consume-packages/central-package-management" rel="noopener noreferrer"&gt;Documentação oficial do CPM&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/NuGet/Home/issues/13562" rel="noopener noreferrer"&gt;Issue #13562 — Uninstalling a package does not remove it from Directory.Packages.props&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.mykolaaleksandrov.dev/posts/2025/11/central-package-management-dotnet/" rel="noopener noreferrer"&gt;Central Package Management in .NET — Real-world benefits&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.milanjovanovic.tech/blog/central-package-management-in-net-simplify-nuget-dependencies" rel="noopener noreferrer"&gt;Central Package Management in .NET - Simplify NuGet Dependencies&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;👉 Código fonte: &lt;a href="https://github.com/AlbertoMonteiro/nuget-cpm-cleaner" rel="noopener noreferrer"&gt;https://github.com/AlbertoMonteiro/nuget-cpm-cleaner&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Vou ficando por aqui. Se você já usa CPM e tem aquele &lt;code&gt;Directory.Packages.props&lt;/code&gt; precisando de uma faxina, experimenta a tool e me conta o que achou. E se você ainda não usa CPM... bom, agora não tem mais desculpa!!!&lt;/p&gt;

&lt;p&gt;Fique à vontade para comentar, vai ser muito legal trocar uma ideia!!!&lt;/p&gt;

&lt;p&gt;Um grande abraço!&lt;/p&gt;

&lt;p&gt;&lt;em&gt;#dotnet #nuget #cpm #centralpackagemanagement #dotnettools #csharp #opensource #dependabot&lt;/em&gt;&lt;/p&gt;

</description>
      <category>nuget</category>
      <category>cpm</category>
      <category>csharp</category>
      <category>dotnet</category>
    </item>
    <item>
      <title>Construindo um Terraform Provider em C# com Native AOT — e o que aprendi no caminho</title>
      <dc:creator>Alberto Monteiro</dc:creator>
      <pubDate>Sat, 21 Mar 2026 16:36:55 +0000</pubDate>
      <link>https://forem.com/albertomonteiro/construindo-um-terraform-provider-em-c-com-native-aot-e-o-que-aprendi-no-caminho-5o</link>
      <guid>https://forem.com/albertomonteiro/construindo-um-terraform-provider-em-c-com-native-aot-e-o-que-aprendi-no-caminho-5o</guid>
      <description>&lt;p&gt;Fala galera, tudo beleza?!&lt;/p&gt;

&lt;p&gt;Recentemente precisei integrar o &lt;strong&gt;BeyondTrust Secret Safe&lt;/strong&gt; ao Terraform e, ao pesquisar sobre como fazer isso, descobri que o ecossistema de providers é praticamente todo em Go. O problema é que o restante do projeto já estava em C#, e eu não queria trazer uma segunda linguagem só para isso. Então tomei uma decisão: vou fazer esse provider em C# mesmo, usando .NET 10 com Native AOT.&lt;/p&gt;

&lt;p&gt;Spoiler: funcionou, e foi uma aventura e tanto!&lt;/p&gt;

&lt;p&gt;Nesse artigo vou compartilhar as principais coisas que aprendi — inclusive as que me custaram horas de debug e que eu não encontrei documentadas em lugar nenhum.&lt;/p&gt;




&lt;h2&gt;
  
  
  O Terraform Plugin Protocol, ou: leia a spec com muito cuidado
&lt;/h2&gt;

&lt;p&gt;O Terraform se comunica com os providers via gRPC, implementando o &lt;strong&gt;Terraform Plugin Protocol v5.2&lt;/strong&gt;. Isso eu já sabia antes de começar. O que eu não sabia é que existe uma etapa de handshake que acontece &lt;strong&gt;antes&lt;/strong&gt; de qualquer chamada gRPC.&lt;/p&gt;

&lt;p&gt;Quando o Terraform inicializa o provider, ele espera receber uma linha específica no stdout:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1|5|tcp|{host}:{port}|grpc|{cert_base64}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Simples assim. O provider precisa escrever essa linha e só depois o Terraform vai tentar conectar via gRPC. Se qualquer outra coisa aparecer no stdout antes dessa linha, o Terraform simplesmente não consegue fazer o parse e o provider morre.&lt;/p&gt;

&lt;h3&gt;
  
  
  O detalhe que me custou horas de debug
&lt;/h3&gt;

&lt;p&gt;O certificado TLS que vai no final dessa linha precisa estar em base64 &lt;strong&gt;sem padding&lt;/strong&gt; — sem os &lt;code&gt;=&lt;/code&gt; no final. Isso porque a biblioteca go-plugin da HashiCorp usa internamente &lt;code&gt;base64.RawStdEncoding&lt;/code&gt;, que omite o padding.&lt;/p&gt;

&lt;p&gt;Se você colocar o padding, o Terraform vai falhar com um erro de "certificate parse error" que não te diz absolutamente nada útil. Eu fiquei batendo cabeça nesse erro por horas até achar a causa!!!&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;cert&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Convert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToBase64String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;certificate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RawData&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"\r"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"\n"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;TrimEnd&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sc"&gt;'='&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// &amp;lt;- essa linha é a chave de tudo&lt;/span&gt;

&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;$"1|5|tcp|&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;address&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;|grpc|&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;cert&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;\n"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;bytes&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Encoding&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ASCII&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetBytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;var&lt;/span&gt; &lt;span class="n"&gt;stdout&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;OpenStandardOutput&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;stdout&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;stdout&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Flush&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Esse &lt;code&gt;.TrimEnd('=')&lt;/code&gt; não está em nenhuma documentação oficial. É o tipo de coisa que você só descobre lendo o código fonte da lib do HashiCorp ou... sofrendo.&lt;/p&gt;




&lt;h2&gt;
  
  
  Native AOT: adeus reflexão, olá disciplina
&lt;/h2&gt;

&lt;p&gt;Compilar com &lt;code&gt;PublishAot=true&lt;/code&gt; e &lt;code&gt;TrimMode=full&lt;/code&gt; muda bastante a forma como você escreve código. Basicamente, tudo que depende de reflexão em tempo de execução vai quebrar:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;Type.GetType()&lt;/code&gt; — não vai funcionar&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;MethodInfo.Invoke()&lt;/code&gt; — não vai funcionar&lt;/li&gt;
&lt;li&gt;Serialização JSON "mágica" — não vai funcionar&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Para tudo isso, você precisa de &lt;strong&gt;source generators&lt;/strong&gt;. Para JSON usei o &lt;code&gt;JsonSourceGenerationContext&lt;/code&gt; do &lt;code&gt;System.Text.Json&lt;/code&gt;, e para a injeção de dependência tudo tem que ser registrado explicitamente na inicialização. E aqui entra um terceiro serializer que merece atenção especial.&lt;/p&gt;

&lt;h3&gt;
  
  
  MessagePack: o protocolo binário do Terraform
&lt;/h3&gt;

&lt;p&gt;O Terraform Plugin Protocol define um tipo chamado &lt;code&gt;DynamicValue&lt;/code&gt; no protobuf, que pode chegar como JSON &lt;strong&gt;ou&lt;/strong&gt; como MessagePack. Isso significa que o provider precisa saber desserializar os dois formatos. Para o MessagePack usei a lib &lt;a href="https://github.com/MessagePack-CSharp/MessagePack-CSharp" rel="noopener noreferrer"&gt;MessagePack-CSharp&lt;/a&gt;, que é muito performática — mas que, por padrão, usa reflexão para descobrir como serializar cada tipo.&lt;/p&gt;

&lt;p&gt;Adivinha: reflexão e AOT não combinam.&lt;/p&gt;

&lt;p&gt;A solução é o &lt;code&gt;[GeneratedMessagePackResolver]&lt;/code&gt;, um source generator que gera todo o código de serialização em tempo de compilação. Você cria uma classe parcial vazia com o atributo e o generator cuida do resto!!!&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;GeneratedMessagePackResolver&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="k"&gt;partial&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;MessagePackResolverPlaceholder&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;E nos modelos, você anota com &lt;code&gt;[MessagePackObject]&lt;/code&gt; e &lt;code&gt;[Key]&lt;/code&gt; para mapear as propriedades:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;MessagePackObject&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ProviderConfiguration&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;Key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"runas"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;required&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;RunAs&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;Key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"key"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;required&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Key&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;Key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"baseUrl"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;required&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;BaseUrl&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;Key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"pwd"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;Pwd&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Com isso, o &lt;code&gt;SmartSerializer&lt;/code&gt; consegue tratar os dois formatos de forma transparente — se o &lt;code&gt;DynamicValue&lt;/code&gt; que chegou tem dados em msgpack, desserializa com MessagePack; se veio em JSON, usa o &lt;code&gt;System.Text.Json&lt;/code&gt;. Nenhuma reflexão em runtime, zero problema com AOT:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;Deserialize&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;DynamicValue&lt;/span&gt; &lt;span class="n"&gt;dynamicValue&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;dynamicValue&lt;/span&gt; &lt;span class="k"&gt;switch&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;Msgpack&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsEmpty&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;MessagePackSerializer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Deserialize&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;dynamicValue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Msgpack&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Memory&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;Json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsEmpty&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;    &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;JsonSerializer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Deserialize&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;dynamicValue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Span&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Default&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Options&lt;/span&gt;&lt;span class="p"&gt;)!,&lt;/span&gt;
        &lt;span class="n"&gt;_&lt;/span&gt;                          &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Isso é muito elegante!!! O provider não precisa saber o que o Terraform vai enviar — ele simplesmente trata os dois casos.&lt;/p&gt;

&lt;p&gt;No começo parece trabalhoso, mas a recompensa é muito boa:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Binário final de ~15 MB, completamente self-contained&lt;/li&gt;
&lt;li&gt;Startup sub-segundo (sem JIT, sem carregamento de assembly)&lt;/li&gt;
&lt;li&gt;Zero dependências de runtime no servidor&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Para um provider que o Terraform spawna e mata várias vezes durante uma execução de &lt;code&gt;terraform apply&lt;/code&gt;, isso faz uma diferença real!!!&lt;/p&gt;

&lt;p&gt;Uma coisa legal que vale mencionar: em debug uso &lt;code&gt;WebApplication.CreateBuilder()&lt;/code&gt; para ter mais logs e facilidade de debug, mas em release uso &lt;code&gt;WebApplication.CreateSlimBuilder()&lt;/code&gt; que é compatível com AOT e gera um binário menor. Fica assim:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="cp"&gt;#if DEBUG
&lt;/span&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;WebApplication&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateBuilder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="cp"&gt;#else
&lt;/span&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;WebApplication&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateSlimBuilder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="cp"&gt;#endif
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  mTLS com certificado gerado na hora — e por que isso faz sentido
&lt;/h2&gt;

&lt;p&gt;O Terraform usa mutual TLS (mTLS) para se comunicar com o provider. A primeira ideia que vem à cabeça é usar certificados de uma CA, mas nesse caso não precisa — e seria até um exagero.&lt;/p&gt;

&lt;p&gt;O provider gera um certificado auto-assinado &lt;strong&gt;a cada inicialização&lt;/strong&gt;, embute ele no handshake e descarta quando termina. Funciona porque:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;O Terraform spawna o provider na mesma máquina — a relação de confiança é implícita&lt;/li&gt;
&lt;li&gt;Não precisa de PKI, CA ou rotação de certificados&lt;/li&gt;
&lt;li&gt;Cada instância tem identidade própria e efêmera&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;No servidor, configurei &lt;code&gt;AllowAnyClientCertificate()&lt;/code&gt; porque o Terraform também apresenta um certificado cliente como parte do mTLS, e não precisamos validar esse certificado — ele é o único cliente possível. Isso simplifica muito o gerenciamento, sem abrir mão da criptografia!!!&lt;/p&gt;




&lt;h2&gt;
  
  
  Testes com Aspire + WireMock: vale cada byte de complexidade
&lt;/h2&gt;

&lt;p&gt;Testar um provider Terraform é diferente de testar uma API comum. Você precisa testar o comportamento gRPC completo, a autenticação com o BeyondTrust, o fluxo de sessão — tudo integrado.&lt;/p&gt;

&lt;p&gt;A solução que encontrei foi usar o &lt;strong&gt;Aspire&lt;/strong&gt; (&lt;code&gt;DistributedApplicationFactory&lt;/code&gt;) para orquestrar o ambiente de testes, subindo:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Um container &lt;strong&gt;WireMock&lt;/strong&gt; mockando a API HTTP do BeyondTrust Secret Safe&lt;/li&gt;
&lt;li&gt;O &lt;strong&gt;provider server&lt;/strong&gt; rodando de verdade&lt;/li&gt;
&lt;li&gt;Um &lt;strong&gt;test client&lt;/strong&gt; que chama os métodos gRPC e valida as respostas&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;O WireMock usa arquivos JSON como mappings para definir os mocks — sem código C# misturado com infra de teste, sem acoplamento. É muito mais fácil de manter e de visualizar o que está sendo mockado.&lt;/p&gt;

&lt;p&gt;Mas aprendi uma lição cara sobre o WireMock: um typo no formato do campo &lt;code&gt;headers&lt;/code&gt; dentro do mapping fazia ele silenciosamente ignorar o mapeamento inteiro, resultando em um 404 que eu não conseguia entender de forma alguma. Fiquei um bom tempo depurando isso até descobrir que o problema era a estrutura do JSON do mapping.&lt;/p&gt;

&lt;p&gt;Depois disso, passei a sempre validar os mappings contra o schema do WireMock antes de confiar neles. Parece bobo, mas salvou muito tempo depois!!!&lt;/p&gt;




&lt;h2&gt;
  
  
  O que ficou de aprendizado
&lt;/h2&gt;

&lt;p&gt;Algumas coisas que ficaram gravadas depois de tudo isso:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Documente o não óbvio.&lt;/strong&gt; O padding do base64 é o melhor exemplo: não está em documentação nenhuma, não dá nenhum erro claro, mas quebra tudo. Se você descobrir uma dessas, documenta na hora.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;AOT desde o início.&lt;/strong&gt; Tentar adaptar código que usa reflexão para AOT depois é bem mais doloroso do que já começar com as restrições em mente. Se sabe que vai usar AOT, já escreva pensando nisso — e mapeie quais libs precisam de source generators (JSON, MessagePack, DI).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Testes de integração valem o custo.&lt;/strong&gt; A complexidade de subir WireMock + Aspire parece exagerada no começo, mas é exatamente esse tipo de teste que vai pegar bugs que testes unitários jamais encontrariam — bugs no serialização, no fluxo de autenticação, em comportamentos do gRPC.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Entenda os contratos do protocolo.&lt;/strong&gt; O Terraform Plugin Protocol é estrito. Não dá para ir no feeling; você precisa ler o &lt;code&gt;.proto&lt;/code&gt;, entender cada RPC e implementar exatamente o que está definido.&lt;/p&gt;




&lt;p&gt;O código está aberto no GitHub. Se você trabalha com BeyondTrust Secret Safe, quer entender como implementar um Terraform provider fora do ecossistema Go, ou está curioso sobre Native AOT no .NET 10, dá uma olhada!&lt;/p&gt;

&lt;p&gt;👉 &lt;a href="https://github.com/AlbertoMonteiro/terraform-provider-beyondtrust-secretsafe" rel="noopener noreferrer"&gt;github.com/AlbertoMonteiro/terraform-provider-beyondtrust-secretsafe&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Se você já passou por algo parecido ou tem alguma dúvida, deixa nos comentários, vai ser muito legal trocar uma ideia!!!&lt;/p&gt;

&lt;p&gt;Vou ficando por aqui, um grande abraço!&lt;/p&gt;




&lt;p&gt;&lt;em&gt;#Terraform #CSharp #DotNet #NativeAOT #DevOps #InfrastructureAsCode #BeyondTrust #gRPC #DotNet10&lt;/em&gt;&lt;/p&gt;

</description>
      <category>terraform</category>
      <category>csharp</category>
      <category>nativeaot</category>
      <category>dotnet</category>
    </item>
  </channel>
</rss>
