Configuração, logging e testes de integração: a API pronta para ser operada

Configuração, logging e testes de integração: a API pronta para ser operada

A API da Livraria pronta para operar: configuração em camadas com o padrão Options validado na partida, segredos fora do repositório, logs estruturados com LoggerMessage e testes de integração com WebApplicationFactory, Testcontainers e Respawn contra um PostgreSQL real.
Linguagem C#

• • 17 min de leitura

Uma API precisa funcionar e também precisa ser configurada sem recompilar, explicar o que está fazendo quando algo dá errado e provar que continua correta a cada mudança. Este artigo trata dessas três coisas.

Configuração: camadas e chaves

Em "ASP.NET Core por dentro" vimos que WebApplication.CreateBuilder monta a configuração em camadas: appsettings.json, appsettings.{Ambiente}.json, user secrets (só em Development), variáveis de ambiente e linha de comando, com a última fonte vencendo. Tudo vira um dicionário plano de chaves hierárquicas separadas por ::

{
  "Api": {
    "TamanhoMaximoDePagina": 100,
    "TentativasDeVenda": 3
  }
}

Esse JSON vira as chaves Api:TamanhoMaximoDePagina e Api:TentativasDeVenda. A variável de ambiente Api__TentativasDeVenda=5 sobrescreve a segunda. Um array vira chaves indexadas (Cors:Origens:0, Cors:Origens:1).

Ler strings soltas com builder.Configuration["Api:TentativasDeVenda"] espalha nomes mágicos pelo código, não valida nada e só falha quando o valor é usado, talvez numa sexta à noite. A solução do .NET é o padrão Options.

O padrão Options

Uma classe comum representa a seção, e os atributos de validação que já conhecemos de "Validação, ProblemDetails e OpenAPI" servem também aqui:

using System.ComponentModel.DataAnnotations;

public sealed class OpcoesDaApi
{
    public const string Secao = "Api";

    [Range(1, 1000)]
    public int TamanhoMaximoDePagina { get; set; } = 100;

    [Range(1, 10)]
    public int TentativasDeVenda { get; set; } = 3;
}
builder.Services.AddOptions<OpcoesDaApi>()
    .BindConfiguration(OpcoesDaApi.Secao)
    .ValidateDataAnnotations()
    .ValidateOnStart();

ValidateOnStart é a linha mais importante. Sem ela, a validação só acontece na primeira vez que alguém pede as opções. Com ela, um TentativasDeVenda=0 numa variável de ambiente impede a aplicação de subir, com uma mensagem que diz qual chave está errada. O deploy falha no health check, e não na décima requisição de um cliente. Para projetos com Native AOT, o atributo [OptionsValidator] gera o validador em tempo de compilação, sem reflexão.

As opções chegam aos consumidores por três interfaces, com tempos de vida diferentes:

Interface Tempo de vida Relê mudanças no arquivo? Uso típico
IOptions<T> singleton não, lê uma vez a maioria dos casos
IOptionsSnapshot<T> scoped sim, a cada requisição valores ajustáveis sem reiniciar
IOptionsMonitor<T> singleton sim, com evento OnChange serviços singleton que precisam reagir

O endpoint de listagem troca o 100 fixo pela opção:

static async Task<Ok<PaginaDe<ProdutoResposta>>> Listar(
    IRepositorioProdutos repositorio,
    IOptions<OpcoesDaApi> opcoes,
    CancellationToken ct,
    int pagina = 1,
    int tamanho = 20)
{
    pagina = Math.Max(pagina, 1);
    tamanho = Math.Clamp(tamanho, 1, opcoes.Value.TamanhoMaximoDePagina);
    // ...
}

E o ServicoDeVendas, que tinha as três tentativas embutidas desde "EF Core com Desempenho", recebe IOptions<OpcoesDeVendas> no construtor. Nesse caso a classe de opções mora em Livraria.Aplicacao, porque a regra pertence à aplicação e não à API. A camada de aplicação depende só de Microsoft.Extensions.Options, sem saber de onde o valor veio.

Segredos

Connection strings com senha, chaves de assinatura e chaves de APIs externas não vão para appsettings.json, porque o arquivo está no Git e o Git não esquece. As opções, do desenvolvimento à produção:

  • Desenvolvimento: dotnet user-secrets set "ConnectionStrings:Postgres" "Host=...". O valor fica num arquivo no perfil do usuário, fora do repositório, e é lido só em Development.
  • Contêineres e servidores: variáveis de ambiente injetadas pelo orquestrador a partir do seu cofre (secrets do Kubernetes, do Docker Swarm, do provedor de nuvem).
  • Nuvem: provedores de configuração que leem direto do cofre, como Azure.Extensions.AspNetCore.Configuration.Secrets para o Azure Key Vault, com rotação sem redeploy.

Uma regra que evita incidentes: nunca registre no log o objeto de configuração inteiro, nem mesmo "para depurar". É assim que connection strings acabam num sistema de logs que toda a empresa consulta.

Logging estruturado

A abstração é ILogger<T>, pedida por injeção de dependência como qualquer serviço. O T vira a categoria do log, normalmente o nome completo da classe, e é por ela que se filtra:

"Logging": {
  "LogLevel": {
    "Default": "Information",
    "Microsoft.AspNetCore": "Warning",
    "Microsoft.EntityFrameworkCore.Database.Command": "Warning",
    "Livraria": "Debug"
  }
}

Os níveis, do mais ao menos verboso: Trace, Debug, Information, Warning, Error e Critical. A regra de uso é mais importante que a lista. Information registra fatos de negócio que alguém vai querer contar (pedido registrado, produto cadastrado). Warning registra o que é anormal mas foi tratado (conflito de concorrência que o retry resolveu). Error registra o que falhou e precisa de atenção humana. Debug fica desligado em produção.

A ideia central é que um log não é uma string, é um evento com campos:

// ERRADO: interpolação gera uma string pronta; os valores se perdem
logger.LogInformation($"Pedido {pedido.Numero} registrado para {clienteId}");

// CERTO: template com marcadores; os valores viram campos
logger.LogInformation("Pedido {PedidoId} registrado para {ClienteId}", pedido.Numero, clienteId);

Na segunda forma o provedor de logs recebe o template e os valores separados. Num sistema de agregação (Seq, Elasticsearch, Loki, Application Insights) dá para consultar "todos os eventos com ClienteId = ana" ou "quantos PedidoId por hora", o que é impossível quando tudo virou texto livre. Os analisadores do SDK avisam sobre a interpolação (CA2254). A interpolação também tem custo: formata a string mesmo quando o nível está desligado.

Para os logs que acontecem a toda hora, o gerador LoggerMessage elimina alocações e deixa os eventos catalogados num só lugar:

public static partial class LogsDeVendas
{
    [LoggerMessage(EventId = 2001, Level = LogLevel.Information,
        Message = "Pedido {PedidoId} registrado para {ClienteId}: {QuantidadeDeItens} itens, total {Total}")]
    public static partial void PedidoRegistrado(
        this ILogger logger, int pedidoId, string clienteId, int quantidadeDeItens, decimal total);

    [LoggerMessage(EventId = 2002, Level = LogLevel.Warning,
        Message = "Conflito de concorrência ao vender o produto {ProdutoId}; tentativa {Tentativa} de {Maximo}")]
    public static partial void ConflitoNaVenda(
        this ILogger logger, int produtoId, int tentativa, int maximo);
}

// no ServicoDeVendas
_logger.PedidoRegistrado(pedido.Numero, clienteId, pedido.Itens.Count, pedido.Total);

É mais um uso dos geradores de "Source Generators": o método parcial é implementado em tempo de compilação, com o template já analisado e a checagem IsEnabled embutida.

Em produção, o formato de saída do console passa a ser JSON, um evento por linha, que qualquer coletor de logs entende:

if (!builder.Environment.IsDevelopment())
    builder.Logging.AddJsonConsole(o => o.IncludeScopes = true);

Com IncludeScopes, o CorrelationId que o middleware de "ASP.NET Core por dentro" abriu com BeginScope aparece em todos os eventos daquela requisição. A biblioteca Serilog é a alternativa mais popular ao provedor padrão, com dezenas de destinos (sinks) e enriquecimento de eventos. Ela se pluga por trás da mesma abstração ILogger<T>, então nenhum código de negócio muda.

Três cuidados:

  • Nunca registre senhas, tokens, números de cartão, nem dados pessoais além do necessário. A LGPD também vale para logs.
  • O middleware AddHttpLogging/UseHttpLogging registra requisições e respostas, mas por padrão omite corpos e cabeçalhos sensíveis por boas razões. Não ligue tudo em produção.
  • Log não substitui métricas nem rastreamento. Contar requisições por segundo lendo linhas de log é caro e frágil. Esse assunto chega em "Produção: CORS, rate limiting, versionamento e OpenTelemetry".

Testes de integração: a aplicação inteira em memória

Em "Testes Automatizados com xUnit" testamos classes isoladas, e em "Um repositório, três bancos" os testes de contrato verificaram os repositórios contra bancos reais. Falta testar a montagem: pipeline, rotas, binding, validação, ProblemDetails, autenticação e políticas, juntos. Quem faz isso é o WebApplicationFactory<TEntryPoint>, do pacote Microsoft.AspNetCore.Mvc.Testing. Ele executa o Program.cs de verdade, com o Kestrel substituído por um TestServer em memória, e entrega um HttpClient que fala com ele sem rede.

dotnet new xunit -n Livraria.Api.Testes -o tests/Livraria.Api.Testes
dotnet add tests/Livraria.Api.Testes reference src/Livraria.Api
dotnet add tests/Livraria.Api.Testes package Microsoft.AspNetCore.Mvc.Testing
dotnet add tests/Livraria.Api.Testes package Testcontainers.PostgreSql
dotnet add tests/Livraria.Api.Testes package Respawn

O TEntryPoint é a classe Program, que com top-level statements o compilador gera como internal. Até o .NET 9 era preciso acrescentar public partial class Program; ao fim do Program.cs para o projeto de testes enxergá-la. No .NET 10, o SDK web gera essa declaração automaticamente.

A fábrica

using System.Security.Cryptography;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.IdentityModel.Tokens;
using Testcontainers.PostgreSql;

public sealed class FabricaDaApi : WebApplicationFactory<Program>, IAsyncLifetime
{
    public const string Emissor = "testes";
    public const string Audiencia = "livraria-api";
    public static readonly SymmetricSecurityKey Chave =
        new(RandomNumberGenerator.GetBytes(32));

    private readonly PostgreSqlContainer _postgres =
        new PostgreSqlBuilder("postgres:17").Build();   // Testcontainers 4.x: a imagem vai no construtor

    public string ConnectionString => _postgres.GetConnectionString();

    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.UseEnvironment("Testes");
        builder.UseSetting("Livraria:Provedor", "postgres");
        builder.UseSetting("ConnectionStrings:Postgres", ConnectionString);

        builder.ConfigureTestServices(servicos =>
        {
            servicos.PostConfigure<JwtBearerOptions>(
                JwtBearerDefaults.AuthenticationScheme, o =>
                {
                    o.TokenValidationParameters = new TokenValidationParameters
                    {
                        ValidIssuer = Emissor,
                        ValidAudience = Audiencia,
                        IssuerSigningKey = Chave
                    };
                });
        });
    }

    public async Task InitializeAsync()
    {
        await _postgres.StartAsync();
        await AplicarMigrationsAsync();          // Database.MigrateAsync() num escopo
    }

    public new async Task DisposeAsync()
    {
        await _postgres.DisposeAsync();
        await base.DisposeAsync();
    }

    public HttpClient ClienteComo(string usuario, params string[] papeis)
    {
        var cliente = CreateClient();
        var token = EmissorDeTeste.Emitir(Chave, Emissor, Audiencia, usuario, papeis);
        cliente.DefaultRequestHeaders.Authorization = new("Bearer", token);
        return cliente;
    }

    private async Task AplicarMigrationsAsync()
    {
        await using var escopo = Services.CreateAsyncScope();
        var db = escopo.ServiceProvider.GetRequiredService<LivrariaContext>();
        await db.Database.MigrateAsync();
    }
}

Algumas decisões merecem explicação:

  • UseSetting em vez de ConfigureAppConfiguration. O Program.cs lê Livraria:Provedor ainda na fase do builder, para decidir o que registrar. UseSetting entra como configuração de host, visível desde o início. Fontes acrescentadas por outros caminhos podem chegar tarde demais para o switch. Se a aplicação ler configuração antes do Build, este é o detalhe que decide se o teste usa o banco certo.
  • ConfigureTestServices roda depois dos registros do Program.cs, e por isso pode substituir ou ajustar qualquer coisa. Aqui ajustamos o JWT para aceitar tokens assinados com uma chave gerada para esta execução. Não é um atalho que "desliga a autenticação": os tokens são validados de verdade, com emissor, audiência e assinatura. Só o emissor é outro.
  • Ambiente "Testes", que não é Development: nada de user secrets, nada de appsettings.Development.json e comportamento de erros de produção, que é o que queremos verificar.
  • IAsyncLifetime no xUnit v2 usa Task. No xUnit v3 os métodos devolvem ValueTask, e a estrutura é a mesma.

Um banco limpo por teste

Testes que compartilham um banco se contaminam: o produto criado por um teste muda a contagem esperada por outro. Recriar o contêiner a cada teste é lento. O Respawn resolve limpando as tabelas na ordem correta das chaves estrangeiras, em milissegundos:

public abstract class TesteDeApi(FabricaDaApi fabrica) : IClassFixture<FabricaDaApi>, IAsyncLifetime
{
    protected FabricaDaApi Fabrica { get; } = fabrica;
    private Respawner? _respawner;

    public async Task InitializeAsync()
    {
        await using var conexao = new NpgsqlConnection(Fabrica.ConnectionString);
        await conexao.OpenAsync();
        _respawner ??= await Respawner.CreateAsync(conexao, new RespawnerOptions
        {
            DbAdapter = DbAdapter.Postgres,
            TablesToIgnore = ["__EFMigrationsHistory"]
        });
        await _respawner.ResetAsync(conexao);
    }

    public Task DisposeAsync() => Task.CompletedTask;
}

Os testes dentro de uma mesma classe rodam em sequência no xUnit, e classes diferentes rodam em paralelo, cada uma com sua fixture e seu contêiner. Para compartilhar um único contêiner entre várias classes, existe ICollectionFixture, ao custo de serializar essas classes.

Os testes

public sealed class TestesDeProdutos(FabricaDaApi fabrica) : TesteDeApi(fabrica)
{
    private static readonly NovoLivro DomCasmurro = new()
    {
        Titulo = "Dom Casmurro", Autor = "Machado de Assis",
        Isbn = "9788535910667", Preco = 39.90m, PesoGramas = 350, Estoque = 1
    };

    [Fact]
    public async Task Cadastrar_sem_token_devolve_401()
    {
        var resposta = await Fabrica.CreateClient()
            .PostAsJsonAsync("/produtos/livros", DomCasmurro);

        Assert.Equal(HttpStatusCode.Unauthorized, resposta.StatusCode);
    }

    [Fact]
    public async Task Cadastrar_como_cliente_devolve_403()
    {
        var resposta = await Fabrica.ClienteComo("bruno")
            .PostAsJsonAsync("/produtos/livros", DomCasmurro);

        Assert.Equal(HttpStatusCode.Forbidden, resposta.StatusCode);
    }

    [Fact]
    public async Task Cadastrar_como_admin_devolve_201_com_location_que_funciona()
    {
        var admin = Fabrica.ClienteComo("ana", "admin");

        var resposta = await admin.PostAsJsonAsync("/produtos/livros", DomCasmurro);

        Assert.Equal(HttpStatusCode.Created, resposta.StatusCode);
        var obtido = await admin.GetFromJsonAsync<ProdutoResposta>(resposta.Headers.Location);
        Assert.Equal("Dom Casmurro", obtido!.Titulo);
    }

    [Fact]
    public async Task Isbn_invalido_devolve_400_apontando_o_campo()
    {
        var resposta = await Fabrica.ClienteComo("ana", "admin")
            .PostAsJsonAsync("/produtos/livros", DomCasmurro with { Isbn = "9788535910660" });

        Assert.Equal(HttpStatusCode.BadRequest, resposta.StatusCode);
        var problema = await resposta.Content.ReadFromJsonAsync<HttpValidationProblemDetails>();
        Assert.Contains("Isbn", problema!.Errors.Keys);
    }
}

Os nomes dos testes descrevem comportamentos observáveis do contrato, não detalhes internos. Nenhum teste sabe que existe um IExceptionHandler ou um source generator de validação. Se amanhã a validação migrar para FluentValidation, os testes continuam valendo, e é isso que os torna úteis numa refatoração.

O teste mais valioso da suíte é o que prova, através de todo o sistema, a garantia que construímos com cuidado desde a Fase 5: o último exemplar não é vendido duas vezes.

[Fact]
public async Task Duas_compras_simultaneas_do_ultimo_exemplar_so_uma_vence()
{
    var admin = Fabrica.ClienteComo("ana", "admin");
    var criado = await admin.PostAsJsonAsync("/produtos/livros", DomCasmurro);   // estoque 1
    var produto = await criado.Content.ReadFromJsonAsync<ProdutoResposta>();

    var pedido = new NovoPedido { Itens = [new(produto!.Id, 1)] };

    var respostas = await Task.WhenAll(
        Fabrica.ClienteComo("bruno").PostAsJsonAsync("/pedidos", pedido),
        Fabrica.ClienteComo("carla").PostAsJsonAsync("/pedidos", pedido));

    var codigos = respostas.Select(r => r.StatusCode).OrderBy(c => c).ToArray();
    Assert.Equal([HttpStatusCode.Created, HttpStatusCode.Conflict], codigos);

    var final = await admin.GetFromJsonAsync<ProdutoResposta>($"/produtos/{produto.Id}");
    Assert.Equal(0, final!.Estoque);
}

Task.WhenAll dispara as duas requisições juntas, e o TestServer as processa em paralelo, cada uma com seu escopo e seu DbContext. Um único teste não prova que a corrida nunca acontece, porque o escalonamento pode serializar as duas por acaso. Rodá-lo em laço cem vezes num job noturno aumenta muito a confiança, e é o tipo de teste que teria pegado um erro no update condicional de "ADO.NET: Conexões, Comandos Parametrizados e o Perigo do SQL Injection".

Para cobrir os três provedores, a mesma suíte pode ser parametrizada: uma fábrica base abstrata e três derivadas (FabricaPostgres, FabricaMySql, FabricaMongo), cada uma com seu contêiner, e classes de teste genéricas sobre a fábrica. É a ideia do teste de contrato de "Um Repositório, Três Bancos", agora aplicada no nível HTTP.

A API agora tem configuração tipada que falha na partida em vez de em produção, logs que viram dados consultáveis e uma suíte que sobe a aplicação real contra um PostgreSQL real e prova autenticação, validação, contrato e a regra mais cara do negócio. Com essa rede de proteção, dá para mexer no que é sensível.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Crie OpcoesDeCors com uma lista Origens (string[]) que deve ter pelo menos um item, cada item sendo uma URL absoluta HTTPS. Registre com validação na partida. Como expressar a regra "cada item é HTTPS", que não cabe em [MinLength]?

Ver resposta

✓ Resposta: A classe tem [MinLength(1)] public string[] Origens { get; set; } = [];, registrada com AddOptions<OpcoesDeCors>().BindConfiguration("Cors").ValidateDataAnnotations().Validate(o => o.Origens.All(u => Uri.TryCreate(u, UriKind.Absolute, out var uri) && uri.Scheme == Uri.UriSchemeHttps), "Todas as origens de CORS devem ser URLs HTTPS absolutas.").ValidateOnStart();. O método Validate aceita um predicado com mensagem, e é o caminho para regras que atributos não expressam. Para regras mais longas, uma classe que implementa IValidateOptions<OpcoesDeCors>, registrada como singleton, é mais testável. No appsettings a lista fica "Cors": { "Origens": ["https://livraria.exemplo"] }, e por variável de ambiente, Cors__Origens__0=https://....

Exercício 2

Um serviço singleton NotificadorDeEstoqueBaixo precisa do limite EstoqueMinimo, e a equipe quer mudá-lo no appsettings.json sem reiniciar. Qual interface de options ele deve usar, e por que IOptionsSnapshot não serve?

Ver resposta

✓ Resposta: IOptionsMonitor<OpcoesDeEstoque>, lendo monitor.CurrentValue a cada uso ou reagindo via monitor.OnChange(...). IOptionsSnapshot<T> é registrado como scoped. Um singleton que o pedisse no construtor seria uma captive dependency, rejeitada pelo ValidateScopes em Development e, sem validação, congelada no valor da primeira resolução. IOptions<T> é singleton, mas lê o valor uma vez só. O recarregamento depende de reloadOnChange: true no provedor de arquivo, que o CreateBuilder já liga para os appsettings. Variáveis de ambiente não recarregam.

Exercício 3

Reescreva esta linha como log estruturado com LoggerMessage e explique os dois problemas da original: _logger.LogWarning($"Estoque do produto {p.Titulo} ({p.Id}) caiu para {p.Estoque} após pedido de {usuario.Identity!.Name}");

Ver resposta

✓ Resposta: [LoggerMessage(EventId = 3001, Level = LogLevel.Warning, Message = "Estoque do produto {ProdutoId} caiu para {Estoque} após o pedido {PedidoId}")] public static partial void EstoqueBaixo(this ILogger logger, int produtoId, int estoque, int pedidoId);. Problema 1: a interpolação produz texto pronto. Os valores não viram campos consultáveis, a string é formatada mesmo com o nível desligado e o template varia a cada evento, o que impede agrupar ocorrências. Problema 2: o log registra o nome do usuário, um dado pessoal sem necessidade, já que o pedido identifica o cliente para quem tem acesso ao banco. Também registra o título, redundante com o id. Em logs, prefira identificadores a dados pessoais.

Exercício 4

Escreva o teste de integração que prova que um cliente não vê o pedido de outro cliente, e explique por que a asserção deve ser sobre 404, e não "diferente de 200".

Ver resposta

✓ Resposta: Bruno cria um pedido (POST /pedidos com Fabrica.ClienteComo("bruno")) e lê o Location. Carla faz GET nesse endereço com o próprio token, e o teste afirma Assert.Equal(HttpStatusCode.NotFound, resposta.StatusCode). Complementos: Bruno recebe 200 no mesmo endereço, e um admin também. "Diferente de 200" passaria com um 500 causado por um bug, ou com um 403 que vaza a existência do pedido, contrariando a decisão de projeto do artigo anterior. O teste deve fixar o comportamento exato do contrato, porque é justamente essa escolha que alguém pode desfazer sem perceber.

Exercício 5

O teste de venda simultânea passou mil vezes no seu computador. Que mudança no código de produção faria ele passar por acaso mesmo com o bug de venda dupla presente, e como tornar o teste mais agressivo?

Ver resposta

✓ Resposta: Qualquer coisa que serialize as requisições sem corrigir a lógica: um SemaphoreSlim estático no endpoint, que funciona com um processo e falha com duas instâncias; um pool de conexões com tamanho 1; ou um middleware lento que desencontre as duas chegadas. O bug real, ler o estoque, checar e gravar sem condição no UPDATE, só aparece quando as duas leituras acontecem antes das duas escritas. Para agravar a disputa: disparar N requisições (por exemplo 20) contra um estoque de 5 e afirmar exatamente 5 × 201, 15 × 409 e estoque final 0; usar uma barreira (Barrier ou TaskCompletionSource) para liberar todas ao mesmo tempo; e, se o código tiver uma costura para isso, injetar um atraso artificial entre a leitura e a escrita. O teste mais forte de todos é contra duas instâncias da WebApplicationFactory apontando para o mesmo banco, o que elimina qualquer proteção que só funciona dentro de um processo.

Comentários

Mais em Linguagem C#

Performance e distribuição: BenchmarkDotNet, Native AOT e ferramentas de linha de comando
Performance e distribuição: BenchmarkDotNet, Native AOT e ferramentas de linha de comando

Medir antes de otimizar com BenchmarkDotNet e as ferramentas de diagnóstico do…

Arquitetura de jogo e por que não alocar dentro do loop
Arquitetura de jogo e por que não alocar dentro do loop

A Biblioteca em Apuros ganha arquitetura: máquinas de estados para a…

Cache com Redis, HybridCache e output caching: responder sem ir ao banco
Cache com Redis, HybridCache e output caching: responder sem ir ao banco

Onde guardar respostas prontas da Livraria e quando jogá-las fora: cache HTTP…