Flurl HttpClient

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 o HttpClient 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 🍺.