DEV Community

Elixir: Testando chamadas de uma API externa

Neste post vamos criar testes automatizados para as chamadas da API externa que fizemos na nossa aplicação do post Elixir: Consumindo dados de uma API externa.

Como testar uma chamada de API Externa?

Vamos supor que a API externa calculasse o valor do frete de um produto e que nos deixasse realizar apenas 10 requisições/chamadas por minuto. Se fossemos realizar requisições de verdade nos testes, estaríamos diminuindo rápido a quantidade de requisições permitidas e poderíamos deixar o cliente esperando para receber esse calculo; isso poderia prejudicar a venda.

Para conseguirmos realizar os testes sem consumir a quantidade de requisições permitidas, precisamos simular as chamadas da API externa.

Criar uma versão falsa de um serviço externo ou interno que pode substituir o real, ajudando seus testes a serem executados de forma rápida e confiável é denominado de mock.

Tesla.Mock e Mox (simulações estáticas)

Existem várias abordagens para se testar chamadas de uma API externa. O próprio Tesla fornece um adapter chamado Tesla.Mock:

Image description

Outra biblioteca que segue esta mesma abordagem é a Mox. O José Valim, criador da linguagem Elixir, comenta sobre ela no post Mocks and explicit contracts.

O diferencial da Mox é que ela tem uma abordagem de criar contratos explícitos. Conforme José Valim, definir contratos nos permite ver a complexidade em nossas dependências, sendo que a nossa aplicação sempre terá complexidade, portanto, precisamos sempre deixar o mais explícito possível.

Vamos entender melhor sobre contratos e injeção de dependência mais pra frente quando fizermos os testes usando a Mox.

Com o Tesla.Mock ou com o Mox, teríamos um exemplo da chamada e da resposta, porém; de forma estática. A chamada não acontece de fato, nenhum middleware nesse caso seria executado e não conseguiríamos passar por todo o fluxo. Essa forma de testar pode esconder muitos comportamentos.

Assim, para testar o client vamos usar outra abordagem, o de servidor HTTP falso que ainda iremos conhecer mas, para os testes da criação do usuário, podemos usar a Mox.

Para instalar a Mox adicione ao seu arquivo mix.exs:

def deps do
  [
    {:mox, "~> 1.0", only: :test}
  ]
end
Enter fullscreen mode Exit fullscreen mode

Desafio: Trocar Mox por Hammox

Após finalizarmos a criação dos testes com a lib Mox tente migrar os mesmos para a lib Hammox que garante que suas simulações (mocks) e implementações cumpram o mesmo contrato.

Ele leva a Mox e sua filosofia ao limite, fornecendo testes automáticos de contrato com base em especificações de tipo de comportamento, mantendo total compatibilidade com o código que já usa Mox.

O Hammox visa capturar o maior número possível de bugs de contrato, ao mesmo tempo em que fornece rastreamentos profundos úteis para que possam ser facilmente rastreados e corrigidos.

Quando você estiver confortável com a lib Mox, acesse https://github.com/msz/hammox e faça a migração.

ExVCR (gravar e reproduzir as interações HTTP)

Existe outra abordagem chamada VCR (Videocassete). Essa abordagem faz uma chamada real na API externa apenas uma vez e grava a resposta em um arquivo json para depois ser utilizada nos próximos testes sem precisar fazer novamente a chamada na API externa. Existe uma biblioteca para isso em Elixir chamada ExVCR.

O ponto negativo é que a conexão também não é testada.

Bypass (servidor HTTP falso)

Como queremos ver todo o fluxo acontecendo nos testes de client, as chamadas sendo executadas com os middlewares e o json sendo transformado; podemos usar um servidor HTTP falso. Existe uma biblioteca para isso chamada Bypass.

O Bypass sobe um servidor local nos permitindo manipular a conexão. Ao invés de realizarmos chamadas para um servidor na ‘internet’, vamos realizar chamadas para um servidor local; que não precisa de ‘internet’. Desta forma, conseguimos testar realmente o comportamento do nosso client.

Essa é a abordagem que iremos usar nos nossos testes de client.

Para instalar o Bypass adicione ao seu arquivo mix.exs:

def deps do
  [
    {:bypass, "~> 2.1", only: :test}
  ]
end
Enter fullscreen mode Exit fullscreen mode

Criando os testes de Client com Bypass

Como o TeslaClient e o HttpoisonClient que criamos no primeiro post tem o mesmo comportamento, iremos criar apenas um arquivo de teste. Na verdade, a única diferença será no erro genérico, mas que vamos arrumar depois. Por Em uma aplicação real você teria apenas um lib/my_app/via_cep/client.ex.

Então seguindo essa estrutura teremos o arquivo de teste em test/my_app/via_cep/client_test.exs.

No setup configuramos que as chamadas não serão realizadas para a API externa, serão realizadas para o servidor local do Bypass:

defmodule MyApp.ViaCep.ClientTest do
  use ExUnit.Case, async: true

  alias Plug.Conn
  alias MyApp.ViaCep.TeslaClient, as: Client

  describe "get_cep_info/1" do
    setup do
      bypass = Bypass.open()

      {:ok, bypass: bypass}
    end

    test "when the cep is valid", %{bypass: bypass} do
      # vamos implementar depois
    end

    test "when the cep is not found", %{bypass: bypass} do
      # vamos implementar depois
    end

    test "when the cep is invalid", %{bypass: bypass} do
      # vamos implementar depois
    end

    test "when there is a generic error", %{bypass: bypass} 
      # vamos implementar depois
    do
  end

  defp endpoint_url(port), do: "http://localhost:#{port}/"
end
Enter fullscreen mode Exit fullscreen mode

Sobre a função endpoint_url, ao invés de fazermos chamadas para https://viacep.com.br/ws/95270000/json/, nós faremos chamadas para o servidor local do Bypass https://localhost:porta/ws/95270000/json/.

A porta sempre irá mudar, pois, como os testes são assíncronos, um servidor diferente subirá para cada chamada e, desta forma, um teste não irá interferir no outro.

Podemos agora partir para a criação dos testes:

  1. CEP válido;
  2. CEP não existe;
  3. CEP inválido;
  4. Erro genérico.

Quando o CEP é válido

Neste teste vamos utilizar a sigil ~s para gerar uma string com escape. Você pode obter mais detalhes sobre sigils na documentação do Elixir ou no Elixir School.

Veja o resultado da sigil ~s:
Image description

Sobre a função Bypass.expect e outras similares você pode encontrar na documentação do Bypass.

...
    test "when the cep is valid", %{bypass: bypass} do
      cep = "01001000"

      url = endpoint_url(bypass.port)

      body = ~s({
        "cep": "01001-000",
        "logradouro": "Praça da Sé",
        "complemento": "lado ímpar",
        "bairro": "Sé",
        "localidade": "São Paulo",
        "uf": "SP",
        "ibge": "3550308",
        "gia": "1004",
        "ddd": "11",
        "siafi": "7107"
      })

      # Bypass.expect(bypass, method, path, fun)
      Bypass.expect(bypass, "GET", "#{cep}/json/", fn conn ->
        conn
        # Coloque o `Conn.put_resp_header()` para executar o middleware do Tesla
        |> Conn.put_resp_header("content-type", "application/json")
        |> Conn.resp(200, body)
      end)

      response = Client.get_cep_info(url, cep)

      expected_response =
        {:ok,
         %{
           "bairro" => "Sé",
           "cep" => "01001-000",
           "complemento" => "lado ímpar",
           "ddd" => "11",
           "gia" => "1004",
           "ibge" => "3550308",
           "localidade" => "São Paulo",
           "logradouro" => "Praça da Sé",
           "siafi" => "7107",
           "uf" => "SP"
         }}

      assert response == expected_response
    end
...
Enter fullscreen mode Exit fullscreen mode

Se retirarmos a função Conn.put_resp_header("content-type", "application/json") o middleware do Tesla não irá funcionar e não irá transformar o json:
Image description

Quando o CEP não existe

...
    test "when the cep is not found", %{bypass: bypass} do
      cep = "00000000"

      body = ~s({"erro": true})

      url = endpoint_url(bypass.port)

      Bypass.expect(bypass, "GET", "#{cep}/json/", fn conn ->
        conn
        |> Conn.put_resp_header("content-type", "application/json")
        |> Conn.resp(200, body)
      end)

      response = Client.get_cep_info(url, cep)

      expected_response = {:ok, %{"erro" => true}}

      assert response == expected_response
    end
...
Enter fullscreen mode Exit fullscreen mode

Quando o CEP é inválido

...
   test "when the cep is invalid", %{bypass: bypass} do
      cep = "123"

      url = endpoint_url(bypass.port)

      Bypass.expect(bypass, "GET", "#{cep}/json/", fn conn ->
        Conn.resp(conn, 400, "")
      end)

      response = Client.get_cep_info(url, cep)

      expected_response = {:error, %{result: "Invalid CEP!", status: :bad_request}}

      assert response == expected_response
    end
...
Enter fullscreen mode Exit fullscreen mode

Quando há um erro genérico

Esse teste não conseguiríamos simular se estivéssemos usando o Tesla.Mock. Vamos simular um timeout, uma falha de comunicação com o servidor.

...
    test "when there is a generic error", %{bypass: bypass} do
      cep = "00000000"

      url = endpoint_url(bypass.port)

      # fechar o servidor somente neste teste
      Bypass.down(bypass)

      response = Client.get_cep_info(url, cep)

      expected_response = {:error, %{result: :econnrefused, status: :bad_request}}

      assert response == expected_response
    end
...
Enter fullscreen mode Exit fullscreen mode

Esse é o único comportamento diferente entre os clients aqui. Se trocarmos de TeslaClient para HttpoisonClient:
Image description

Como a reason é :econnrefused, poderíamos arrumar isso no arquivo client do HTTPoison e o comportamento ficaria igual em ambos os clients:

defp handle_get({:error, %Error{id: _id, reason: reason}}) do
  {:error, %{status: :bad_request, result: reason}}
end
Enter fullscreen mode Exit fullscreen mode

Image description

Criando os testes na regra de negócio com Mox

Como comentamos anteriormente, iremos usar a lib Mox para os testes da criação do usuário. Faz mais sentido usarmos essa abordagem nos testes de negócio, pois essa camada está mais acima do client.

Behaviour para a função get_cep_info()

O Mox trabalha com behaviours para fazer injeção de dependencia nos testes.

Vamos definir um contrato para a função get_cep_info(). Quando essa função for implementada ela obrigatoriamente deve receber uma String como parâmetro e retornar sempre uma tupla de {:ok, map()} ou {:error, map()}.

Em lib/my_app/via_cep/behaviour.ex:

defmodule MyApp.ViaCep.Behaviour do
  @callback get_cep_info(String.t()) :: {:ok, map()} | {:error, map()}
end
Enter fullscreen mode Exit fullscreen mode

Altere os arquivos httpoison_client e tesla_client adicionando o behaviour:

Image description

Se tentarmos retornar algo diferente para esta função, receberemos um aviso:

Image description

Criando um Mock para implementar o behaviour

Vamos configurar o Mox para criar no teste um mock que também implementa esse behaviour.

Em test/test_helper.exs:

ExUnit.start()
Ecto.Adapters.SQL.Sandbox.mode(MyApp.Repo, :manual)

# adicione essa linha
Mox.defmock(MyApp.ViaCep.ClientMock, for: MyApp.ViaCep.Behaviour)
Enter fullscreen mode Exit fullscreen mode

Client para produção e client para teste (Injeção de Dependência)

Vamos injetar através do ambiente que estamos executando (dev, prod ou test) o Client que iremos utilizar.

Vamos criar uma configuração pra quando você estiver em ambiente de teste ser usado o mock MyApp.ViaCep.ClientMock, e quando estiver em um ambiente de dev ou produção ser usado a implementação MyApp.ViaCep.TeslaClient.

Em config/config.exs:

...
config :my_app,
  ecto_repos: [MyApp.Repo]

# adicione
config :my_app,
       MyApp.Users.Create,
       via_cep_adapter: MyApp.ViaCep.TeslaClient
...
Enter fullscreen mode Exit fullscreen mode

Em config/config.exs os valores valem para qualquer ambiente e, se exister em outro ambiente com a mesma configuração, o valor é subscrito.

Testando com iex -S mix:

iex> Application.fetch_env!(:my_app, MyApp.Users.Create)[:via_cep_adapter]         
MyApp.ViaCep.TeslaClient
Enter fullscreen mode Exit fullscreen mode
  • fetch_env roda em tempo de execução.
  • compile_env em tempo de compilação.

Em config/test.exs:

...
config :my_app, MyApp.Repo,
  username: "postgres",
  password: "postgres",
  hostname: "localhost",
  database: "my_app_test#{System.get_env("MIX_TEST_PARTITION")}",
  pool: Ecto.Adapters.SQL.Sandbox,
  pool_size: 10

# adicione
# ClientMock ao invés de Client
config :my_app,
       MyApp.Users.Create,
       via_cep_adapter: MyApp.ViaCep.ClientMock
...
Enter fullscreen mode Exit fullscreen mode

Testando com MIX_ENV=test iex -S mix:
Image description

Em lib/my_app/users/create.ex:

  defp client do
    # assim:
    # Application.fetch_env!(:my_app, __MODULE__)[:via_cep_adapter]

    # ou:
    :my_app
    |> Application.fetch_env!(__MODULE__)
    |> Keyword.get(:via_cep_adapter)
  end
Enter fullscreen mode Exit fullscreen mode

Troque Client.get_cep_info(cep) para client().get_cep_info(cep):

Image description

Criando os testes de criação de usuário

Em test/my_app/users/create_test.exs:

defmodule MyApp.Users.CreateTest do
  use MyApp.DataCase, async: true

  import Mox

  alias MyApp.{User, Users.Create, ViaCep.ClientMock}

  describe "call/1" do
    test "when all params are valid" do
      params = %{
        first_name: "Mike",
        last_name: "Wazowski",
        cep: "95270000",
        email: "mike_wazowski@monstros_sa.com"
      }

      expect(ClientMock, :get_cep_info, fn _cep ->
        {:ok,
         %{
           "bairro" => "Sé",
           "cep" => "01001-000",
           "complemento" => "lado ímpar",
           "ddd" => "11",
           "gia" => "1004",
           "ibge" => "3550308",
           "localidade" => "São Paulo",
           "logradouro" => "Praça da Sé",
           "siafi" => "7107",
           "uf" => "SP"
         }}
      end)

      response = Create.call(params)

      assert {:ok, %User{id: _id, email: "mike_wazowski@monstros_sa.com"}} = response
    end

    test "when there are invalid params" do
      params = %{
        first_name: "Mike",
        last_name: "Wazowski",
        cep: "123",
        email: "monstros_sa.com"
      }

      response = Create.call(params)

      expected_response = %{
        cep: ["should be 8 character(s)"],
        email: ["has invalid format"]
      }

      assert {:error, changeset} = response

      assert errors_on(changeset) == expected_response
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Se trocarmos o _cep por 00000000 receberemos um erro:

Image description

No segundo teste "when there are invalid params", não precisemos usar o mock pois a função get_cep_info() não é acionada. O retorno acontece na validação da função User.validate_before_insert(changeset).

Image description

Conclusão

Quando falarmos em testes de client podemos pensar logo em usarmos a abordagem de um Servidor HTTP Falso usando a lib Bypass para testarmos todo o fluxo e todos os comportamentos de uma chamada externa. Já quando falarmos em camadas mais acima, camadas de regra de negócio, podemos criar contratos explícitos definindo o comportamento de funções usando uma lib como a Mox ou Hammox.

Discussion (0)