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.Secretspara 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/UseHttpLoggingregistra 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:
UseSettingem vez deConfigureAppConfiguration. OProgram.cslêLivraria:Provedorainda na fase do builder, para decidir o que registrar.UseSettingentra como configuração de host, visível desde o início. Fontes acrescentadas por outros caminhos podem chegar tarde demais para oswitch. Se a aplicação ler configuração antes doBuild, este é o detalhe que decide se o teste usa o banco certo.ConfigureTestServicesroda depois dos registros doProgram.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.jsone comportamento de erros de produção, que é o que queremos verificar. IAsyncLifetimeno xUnit v2 usaTask. No xUnit v3 os métodos devolvemValueTask, 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
- Microsoft Learn — Options pattern in ASP.NET Core: https://learn.microsoft.com/aspnet/core/fundamentals/configuration/options
- Microsoft Learn — Safe storage of app secrets in development: https://learn.microsoft.com/aspnet/core/security/app-secrets
- Microsoft Learn — Compile-time logging source generation (
LoggerMessage): https://learn.microsoft.com/dotnet/core/extensions/logger-message-generator - Microsoft Learn — Integration tests in ASP.NET Core: https://learn.microsoft.com/aspnet/core/test/integration-tests
- Testcontainers for .NET — documentação: https://dotnet.testcontainers.org/
- Jimmy Bogard — Respawn: https://github.com/jbogard/Respawn
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.