No post anterior expliquei como o Flurl permite construir URLs, fazer e testar chamadas HTTP de forma muito mais legível. Também comentei que infelizmente a parte de testes do Flurl não funcionava com o HttpClient
, mas isso é só parcialmente verdade 👀, porque depois de investir um tempo lendo o código do Flurl consegui enganá-lo para que sua estrutura de testes funcione com o HttpClient
, e é isso que vou mostrar neste artigo.
O Flurl é muito interessante para projetos com chamadas HTTP, mas aqui na Lambda3 nós pegamos vários projetos com código pré-existente, e isso faz com que projetos que utilizam apenas o HttpClient
, sem o Flurl, sejam quase unanimidade, acredito que essa é a realidade de muitos projetos.
Mas e se eu quiser utilizar somente a parte de testes do Flurl para escrever testes de unidade para o HttpClient
de forma mais legível e mais simples?
Calma, tá tudo bem agora.
Flurl Testing
Recapitulando, o Flurl permite simular respostas de um servidor e ainda fazer asserts em cima de propriedades do Request para garantir que o código está chamando corretamente a API que está sendo consuminda.
Como exemplo, vou usar uma classe que usa o HttpClient
para fazer chamadas para uma API. Para este post usarei a API pública FakeJSON, ela é capaz de gerar dados fakes a partir de um template passado. Criei uma classe para encapsular essa chamada, dessa maneira:
using System.Net.Http; using System.Text; using System.Threading.Tasks; namespace HttpTestFlurl { public class FakeJsonService { private readonly HttpClient _httpClient; public FakeJsonService(HttpClient httpClient) { _httpClient = httpClient; } public async Task<string> GetFakeData() { var templateDados = @"{ ""token"": ""4UByP0KUbLPL_KMIZNWRbg"", ""data"": { ""id"": ""personNickname"", ""email"": ""internetEmail"", ""last_login"": { ""date_time"": ""dateTime|UNIX"", ""ip4"": ""internetIP4"" } } }"; var response = await _httpClient .PostAsync("https://app.fakejson.com/q", new StringContent(templateDados, Encoding.UTF8 , "application/json")); return await response.Content.ReadAsStringAsync(); } } }
Repare que minha classe recebe o
HttpClient
como parâmetro, é assim que eu conseguirei trocar oHttpClient
real por um fake quando for executar meus testes.
Quero ser capaz de garantir as seguintes informações: chamo a URL correta, com o método HTTP correto, com os headers corretos, com corpo correto, e que a chamada é feita apenas uma vez.
Sem utilizar os helpers de testes do Flurl, eu consigo fazer quase tudo isso dessa maneira:
request.RequestUri.Should().Be(new Uri("https://app.fakejson.com/q")); request.Method.Should().Be(HttpMethod.Post); request.Content.Headers.ContentType.MediaType.Should().Be("application/json"); (await request.Content.ReadAsStringAsync()).Should().BeEquivalentTo(templateDados);
Fica muito complexo garantir que a chamada foi feita apenas uma vez, por isso resolvi nem fazer isso no assert acima.
Agora veja como fica o mesmo teste com os helpers do Flurl, com o bônus de conseguir garantir a quantidade de chamadas feitas:
httpTest.ShouldHaveCalled("https://app.fakejson.com/q") .WithVerb(HttpMethod.Post) .WithContentType("application/json") .WithRequestBody(templateDados) .Times(1);
Bem melhor, né? Mas calma.
Só sair escrevendo os asserts com o HttpTest
do Flurl em cima do HttpClient
não vai funcionar: a chamada ainda não está sendo mockada, ou seja, a chamada vai bater no servidor do FakeJSON, o que tornaria o teste integrado, algo que eu não quero neste momento, portanto preciso trocar o HttpClient
por um fake.
Além disso, mesmo que eu utilize um HttpClient
fake para o teste, o HttpTest
do Flurl não está configurado para logar nenhuma das chamadas feitas pelo HttpClient
, porque ele não foi feito para isso. Como solucionar esses dois problemas, então?
HttpClient + Flurl Testing
Como o Flurl é open source abri o seu código para entender porque os testes não funcionavam com o HttpClient
. Depois de várias tentativas, descobri que é preciso basicamente fazer um wrapper para a classe de FlurlRequest
a cada chamada do HttpClient
.
O HttpClient possui uma estrutura peculiar, na verdade ele é basicamente um helper para construção de requests, o envio das chamadas em si é todo feito por uma classe interna, do tipo HttpMessageHandler, então para fazer qualquer tipo de alteração no envio de requests, é só implementar um novo HttpMessageHandler
e passá-lo para o HttpClient
, que aceita um handler no construtor.
Pensando nisso, criei um FakeHttpClientMessageHandler
para ser usado nos testes que seja capaz de enganar o Flurl 🕵️♂️:
using Flurl.Http; using Flurl.Http.Content; using Flurl.Http.Testing; using System; using System.Net.Http; using System.Text; using System.Threading; using System.Threading.Tasks; namespace HttpTestFlurl.Tests { public class FakeHttpClientMessageHandler : FakeHttpMessageHandler { protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { var flurlRequest = new FlurlRequest(request.RequestUri.ToString()); var stringContent = (request.Content as StringContent); if (stringContent != null) request.Content = new CapturedStringContent(await stringContent.ReadAsStringAsync(), GetEncodingFromCharSet(stringContent.Headers?.ContentType?.CharSet), stringContent.Headers?.ContentType?.MediaType); if (request?.Properties != null) request.Properties["FlurlHttpCall"] = new HttpCall() { FlurlRequest = flurlRequest, Request = request }; return await base.SendAsync(request, cancellationToken); } private Encoding GetEncodingFromCharSet(string charset) { try { return HasQuote(charset) ? Encoding.GetEncoding(charset.Substring(1, charset.Length - 2)) : Encoding.GetEncoding(charset); } catch (ArgumentException) { return null; } } private bool HasQuote(string text) => text.Length > 2 && text[0] == '\"' && text[text.Length - 1] == '\"'; } }
Com esse handler é possível utilizar toda a estrutura de testes do Flurl com o HttpClient nativo do .NET. Só é preciso instanciá-lo e utilizá-lo na construção do HttpClient
, então meu teste ficou assim:
using Flurl.Http.Testing; using HttpTestFlurl; using HttpTestFlurl.Tests; using NUnit.Framework; using System.Net.Http; using System.Threading.Tasks; namespace Tests { public class FakeJsonServiceTests { private FakeJsonService _fakeJsonService; [Test] public async Task GetFakeDataShouldCallFakeJsonServerWithCorrectParameters() { using (var httpTest = new HttpTest()) { _fakeJsonService = new FakeJsonService(new HttpClient(new FakeHttpClientMessageHandler())); var templateDados = @"{ ""token"": ""4UByP0KUbLPL_KMIZNWRbg"", ""data"": { ""id"": ""personNickname"", ""email"": ""internetEmail"", ""last_login"": { ""date_time"": ""dateTime|UNIX"", ""ip4"": ""internetIP4"" } } }"; var response = await _fakeJsonService.GetFakeData(); httpTest.ShouldHaveCalled("https://app.fakejson.com/q") .WithVerb(HttpMethod.Post) .WithContentType("application/json") .WithRequestBody(templateDados) .Times(1); } } } }
Pronto! Agora o teste passa, mesmo que eu não esteja fazendo a chamada com o Flurl.Http 🎉.
Os códigos que mostrei possuem algumas simplificações para tornar o post mais direto, no seu projeto você provavelmente deve se preocupar com algumas coisas a mais, como utilizar uma Factory de HttpClient para a classe de Service, colocar o HttpTest no SetUp/TearDown do seu teste, entre outros detalhes.
Conclusão
Utilizando o handler que criei é possível fazer testes de unidade para o HttpClient
sem a necessidade de utilizar o Flurl.Http no seu projeto, literalmente o Flurl só existe no seu projeto de testes e mais nada. Essa dica pode ser bem útil se você está num cenário como o meu, onde já existe um projeto com HttpClient
e você não tem o tempo para refatorar tudo agora, mas quer adicionar testes de unidade. Assim o impacto no código de produção é o mínimo possível inicialmente e você pode ir refatorando aos poucos, já colhendo os benefícios de testes simplificados com o Flurl. Happy testing 🤖!
Mahmoud Ali
Desenvolvedor de Software na Lambda3, Microsoft MVP, amante de um bom café ☕️ e uma boa cerveja 🍺.