Bypass
Ao testar nossas aplicações, muitas vezes precisamos fazer chamadas a serviços externos. Podemos até mesmo querer simular diferentes situações como erros inesperados do servidor. Tratar isso de modo eficiente não é fácil no Elixir sem uma pequena ajuda.
Nesta lição vamos explorar como bypass pode nos ajudar rapidamente e tratar facilmente essas chamadas em nossos testes
O que é Bypass?
Bypass é descrito como “uma forma rápida de criar um plug customizado que pode substituir um servidor HTTP real para retornar respostas previamente definidas para requisições de clientes.
O que isso significa? Internamente, Bypass é uma aplicação OTP que atua como um servidor externo escutando e respondendo a requisições. Com respostas pré-definidas nós podemos testar qualquer número de possibilidades como interrupções inesperadas de serviço e erros, tudo sem fazer uma única chamada externa.
Usando Bypass
Para melhor ilustrar as funcionalidades do Bypass vamos construir uma aplicação utilitária simples para testar (ping) uma lista de domínios e garantir que eles estão online. Para fazer isso vamos construir um novo projeto supervisor e um GenServer para verificar os domínios em um intervalo configurável. Aproveitando ByPass em nossos testes poderemos verificar se nossa aplicação funcionará em muitos cenários diferentes.
Nota: Se você deseja avançar para o código final, dê uma olhada no repositório Clinic do Elixir School.
Neste ponto devemos estar confortáveis criando novos projetos Mix e adicionando nossas dependências, então focaremos nas partes do código que estamos testando. Se você precisar de uma atualização rápida, consulte a seção New Projects de nossa lição Mix.
Vamos começar criando um novo módulo que tratará de fazer as requisições para nossos domínios.
Com HTTPoison vamos criar uma função, ping/1, que recebe uma URL e retorna {:ok, body} para uma requisição HTTP 200 e {:error, reason} para todos os outros:
defmodule Clinic.HealthCheck do
  def ping(urls) when is_list(urls), do: Enum.map(urls, &ping/1)
  def ping(url) do
    url
    |> HTTPoison.get()
    |> response()
  end
  defp response({:ok, %{status_code: 200, body: body}}), do: {:ok, body}
  defp response({:ok, %{status_code: status_code}}), do: {:error, "HTTP Status #{status_code}"}
  defp response({:error, %{reason: reason}}), do: {:error, reason}
endVocê vai notar que não estamos fazendo um GenServer e isso é por uma boa razão: Separando nossa funcionalidade (e preocupações) do GenServer, podemos testar nosso código sem o obstáculo adicional de concorrência.
Com nosso código pronto, precisamos começar os testes.
Antes de usarmos Bypass precisamos garantir que ele está rodando.
Para fazer isso, vamos atualizar test/test_helper.exs da seguinte forma:
ExUnit.start()
Application.ensure_all_started(:bypass)
Agora que sabemos que Bypass vai rodar durante nossos testes, vamos avançar para o test/clinic/health_check_test.exs e terminar nossa configuração.
Para preparar o Bypass para aceitar chamadas precisamos abrir a conexão com Bypass.open/1, que pode ser feito na nossa callback de configuração do teste:
defmodule Clinic.HealthCheckTests do
  use ExUnit.Case
  setup do
    bypass = Bypass.open()
    {:ok, bypass: bypass}
  end
end
Por enquanto usaremos Bypass com sua porta padrão, mas se necessitarmos mudá-la (o que faremos mais tarde nessa seção), podemos chamar Bypass.open/1 com a opção :port e um valor como Bypass.open(port: 1337).
Agora estamos prontos para colocar o Bypass para trabalhar.
Vamos começar uma chamada bem sucedida primeiramente:
defmodule Clinic.HealthCheckTests do
  use ExUnit.Case
  alias Clinic.HealthCheck
  setup do
    bypass = Bypass.open()
    {:ok, bypass: bypass}
  end
  test "request with HTTP 200 response", %{bypass: bypass} do
    Bypass.expect(bypass, fn conn ->
      Plug.Conn.resp(conn, 200, "pong")
    end)
    assert {:ok, "pong"} = HealthCheck.ping("http://localhost:#{bypass.port}")
  end
end
Nosso teste é bastante simples e, se o executarmos, veremos que ele passa, mas vamos nos aprofundar e ver o que cada parte está fazendo.
A primeira coisa que vemos em nosso teste é a função Bypass.expect/2:
Bypass.expect(bypass, fn conn ->
  Plug.Conn.resp(conn, 200, "pong")
end)
Bypass.expect/2 recebe nossa conexão Bypass e uma função de aridade simples que se espere que modifique a conexão e a retorne, isso também é uma oportunidade para fazer afirmações na chamada para verificar se ela está conforme esperado. Vamos atualizar a url do nosso teste para incluir /ping e afirmar (assert) o caminho da chamada e o método HTTP:
test "request with HTTP 200 response", %{bypass: bypass} do
  Bypass.expect(bypass, fn conn ->
    assert "GET" == conn.method
    assert "/ping" == conn.request_path
    Plug.Conn.resp(conn, 200, "pong")
  end)
  assert {:ok, "pong"} = HealthCheck.ping("http://localhost:#{bypass.port}/ping")
end
A última parte do nosso teste que usamos HealthCheck.ping/1 e afirmamos a resposta está conforme esperado, mas do que se trata o bypass.port?
Bypass está realmente escutando uma porta local e interceptando as requisições, onde estamos usando bypass.port para retornar a porta padrão uma vez que não definimos uma no Bypass.open/1.
Em seguida adicionamos casos de teste para erros.
Podemos começar com um teste muito parecido com nosso primeiro, com algumas pequenas mudanças: retornando 500 como código de status e afirmando que a tupla {:error, reason} é retornada:
test "request with HTTP 500 response", %{bypass: bypass} do
  Bypass.expect(bypass, fn conn ->
    Plug.Conn.resp(conn, 500, "Server Error")
  end)
  assert {:error, "HTTP Status 500"} = HealthCheck.ping("http://localhost:#{bypass.port}")
end
Não há nada de especial para este caso de teste, então vamos passar para o próximo: interrupções inesperadas do servidor, estas são as chamadas com as quais estamos mais preocupados.
Para fazer isso usaremos Bypass.down/1 ao invés Bypass.expect/2 para desligar a conexão:
test "request with unexpected outage", %{bypass: bypass} do
  Bypass.down(bypass)
  assert {:error, :econnrefused} = HealthCheck.ping("http://localhost:#{bypass.port}")
end
Se executarmos nossos testes veremos tudo passando conforme esperado!
Com nosso módulo HealthCheck testado, podemos seguir em frente e testá-lo juntamente como nosso scheduler baseado no GenServer.
Vários hosts externos
Para nosso projeto manteremos as estruturas do scheduler e usaremos Process.send_after/3 para alimentar nossas verificações reincidentes, para saber mais sobre o módulo Process dê uma olhada na documentação.
Nosso scheduler necessita de três opções: a coleção de sites, o intervalo de nossas verificações, e o módulo que implementa ping/1.
Ao passar no nosso módulo, separamos nossa funcionalidade e o nosso GenServer, permitindo-nos testar cada um em isolamento:
def init(opts) do
  sites = Keyword.fetch!(opts, :sites)
  interval = Keyword.fetch!(opts, :interval)
  health_check = Keyword.get(opts, :health_check, HealthCheck)
  Process.send_after(self(), :check, interval)
  {:ok, {health_check, sites}}
end
Agora precisamos definir a função handle_info/2 para a mensagem :check enviada send_after/2.
Para manter as coisas simples vamos passar nossos sites para a HealthCheck.ping/1 e logar nossos resultados com Logger.info ou Logger.error no caso de erros.
Vamos configurar nosso código de forma que nos habilite a melhorar a capacidade de relatórios mais tarde:
def handle_info(:check, {health_check, sites}) do
  sites
  |> health_check.ping()
  |> Enum.each(&report/1)
  {:noreply, {health_check, sites}}
end
defp report({:ok, body}), do: Logger.info(body)
defp report({:error, reason}) do
  reason
  |> to_string()
  |> Logger.error()
end
Conforme discutido, passamos nossos sites para a HealthCheck.ping/1 então iteramos os resultados com Enum.each/2 aplicando nossa função report/1 em cada uma delas.
Com essas funções nosso scheduler está pronto e podemos nos concentrar em testá-lo.
Não vamos focar muito em fazer testes unitários para os schedulers uma vez que eles não necessitam Bypass, então podemos pular para o código final:
defmodule Clinic.SchedulerTest do
  use ExUnit.Case
  import ExUnit.CaptureLog
  alias Clinic.Scheduler
  defmodule TestCheck do
    def ping(_sites), do: [{:ok, "pong"}, {:error, "HTTP Status 404"}]
  end
  test "health checks are run and results logged" do
    opts = [health_check: TestCheck, interval: 1, sites: ["http://example.com", "http://example.org"]]
    output =
      capture_log(fn ->
        {:ok, _pid} = GenServer.start_link(Scheduler, opts)
        :timer.sleep(10)
      end)
    assert output =~ "pong"
    assert output =~ "HTTP Status 404"
  end
end
Confiamos em uma implementação de teste de nossos health checks com TestCheck juntamente com CaptureLog.capture_log/1 para afirmar que as mensagens apropriadas são logadas.
Agora trabalhamos nos módulos Scheduler e HealthCheck, vamos escrever um teste de integração para verificar tudo funcionando junto. Precisaremos do Bypass para este teste e teremos que tratar múltiplas chamadas com Bypass por teste, veremos como fazer isso.
Lembra do bypass.port de mais cedo?  Quando precisamos simular múltiplos sites, a opção :port vem a calhar. Como você provavelmente adivinhou, podemos criar múltiplas conexões Bypass cada uma com uma porta diferente, estas que simularão sites independentes. Começaremos revisando nosso arquivo atualizado test/clinic_test.exs:
defmodule ClinicTest do
  use ExUnit.Case
  import ExUnit.CaptureLog
  alias Clinic.Scheduler
  test "sites are checked and results logged" do
    bypass_one = Bypass.open(port: 1234)
    bypass_two = Bypass.open(port: 1337)
    Bypass.expect(bypass_one, fn conn ->
      Plug.Conn.resp(conn, 500, "Server Error")
    end)
    Bypass.expect(bypass_two, fn conn ->
      Plug.Conn.resp(conn, 200, "pong")
    end)
    opts = [interval: 1, sites: ["http://localhost:1234", "http://localhost:1337"]]
    output =
      capture_log(fn ->
        {:ok, _pid} = GenServer.start_link(Scheduler, opts)
        :timer.sleep(10)
      end)
    assert output =~ "[info]  pong"
    assert output =~ "[error] HTTP Status 500"
  end
end
Não deve haver nada de tão surpreendente no teste acima. Ao invés de criar uma simples conexão Bypass no setup, estamos criando duas no teste e especificando suas portas como 1234 e 1337. Em seguida, vemos nossas chamadas Bypass.expect/2 e finalmente o mesmo código que temos no SchedulerTest para iniciar o scheduler e afirmar que logamos as mensagens apropriadas.
É isso aí! Construímos um utilitário para nos manter informados se houver qualquer problema em nossos domínios e aprendemos como usar o Bypass para escrever melhores testes com serviços externos.
Caught a mistake or want to contribute to the lesson? Edit this lesson on GitHub!