Mockando requisições do cliente com Bypass

Primeiro vamos criar o teste que precisamos para quando o serviço está quebrado. Um erro 500 critico. Vamos adicionar o novo teste em nosso arquivo test/integrations/coffee/client_test.exs

test/integrations/coffee/client_test.exs
defmodule CoffeeShop.Integrations.Coffee.ClientTest do
  use ExUnit.Case

  alias CoffeeShop.Integrations.Coffee.Client

  describe "all_hot_coffees/0" do
    # ...
    
    test "service is crashed" do
      assert {:ok, %Response{status: status}} = Client.all_hot_coffees()
      assert status == 500
    end
  end
end

Em nosso teste queremos que o status seja 500. Mas ainda estamos batendo no serviço real. Precisamos configurar o bypass e utilizar em nosso teste, para conseguir simular as respostas que queremos.

test/integrations/coffee/client_test.exs
defmodule CoffeeShop.Integrations.Coffee.ClientTest do
  use ExUnit.Case

  alias CoffeeShop.Integrations.Coffee.Client
  alias CoffeeShop.Integrations.Coffee.Response

  describe "all_hot_coffees/0" do
    setup do
      bypass = Bypass.open(port: 3000)
      {:ok, bypass: bypass}
    end
    
    # ...

    test "service is crashed", %{bypass: bypass} do
      assert {:ok, %Response{status: status}} = Client.all_hot_coffees()
      assert status == 500
    end
  end
end

Adicionamos na função setup o Bypass.open/1, que tem como objetivo abrir um serviço falso. Esse serviço é levantado em http//localhost podendo ser adicionado a porta em nosso caso, escolhemos a porta 3000. Isso pode conflitar com outros serviços em seu computador, então, caso de problema, troque a porta.

Com o serviço de pé, precisamos ajeitar nosso mock. Para criar o mock, precisamos primeiro identificar qual a requisição queremos mockar. Para isso o Bypass possui o método expect_once e expect . Um espera apenas uma chamada, o outro uma ou mais.

test/integrations/coffee/client_test.exs
defmodule CoffeeShop.Integrations.Coffee.ClientTest do
  use ExUnit.Case

  alias CoffeeShop.Integrations.Coffee.Client
  alias CoffeeShop.Integrations.Coffee.Response

  describe "all_hot_coffees/0" do
    setup do
      bypass = Bypass.open()
      {:ok, bypass: bypass}
    end

    test "respond a list of hot coffees", %{bypass: bypass} do
      assert {:ok, %Response{status: status}} = Client.all_hot_coffees()
      assert status == 200
    end

    test "service is crashed", %{bypass: bypass} do
      response = "Server exploded"
      
      Bypass.expect_once(bypass, "GET", "/coffee/hot", fn conn ->
        Plug.Conn.resp(conn, 500, response)
      end)

      assert {:ok, %Response{status: status}} = Client.all_hot_coffees()
      assert status == 500
    end
  end
end

Chamamos a função Bypass.expect_once/4, passando a instância do serviço, o verbo que utilizamos, o recurso e a função de callback, que nada mais é que a resposta da requisição. Plug montara uma resposta HTTP para nós. É aqui que a informação falsa deve aparecer. Feito isso, vamos rodar nosso teste

mix test
> mix test 
.

  1) test all_hot_coffees/0 service is crashed (CoffeeShop.Integrations.Coffee.ClientTest)
     test/integrations/coffee/client_test.exs:18
     Assertion with == failed
     code:  assert status == 500
     left:  200
     right: 500
     stacktrace:
       test/integrations/coffee/client_test.exs:26: (test)

...
Finished in 0.8 seconds (0.00s async, 0.8s sync)
1 doctest, 4 tests, 1 failure

O teste falhou. Ainda temos um status 200. Isso quer dizer que estamos batendo no serviço real. Com toda certeza, ainda estamos indo para a URL base aplicado pelo plug e o novo serviço mora em http://localhost e precisamos bater la para fins de teste.

Com isso, temos que achar uma forma de poder reconfigurar a URL do nosso cliente quando estamos no teste. Existe a possiblidade de configurar pelas variáveis de ambiente, adicionando um para config/config.exs e um para config/test.exs. Porém, sou bem visual e usar isso:

plug(Tesla.Middleware.BaseUrl, Application.get_env(:coffee_shope, :coffee)[:base_url])

Ao invés disso:

plug(Tesla.Middleware.BaseUrl, "https://api.sampleapis.com")

Me da dor de cabeça.

A opção que gosto é configurar com base do parâmetro opts passando para o cliente.

Client.all_hot_coffees(base_url: "http://localhost")

Não só a url base mas todo tipo de configuração gosto de passar pelos parâmetros. Isso facilita a configuração e a utilização para os testes.

O problema agora é que utilizamos o plug para configurar e não conseguimos alcançar ele dessa forma. Precisamos mudar a forma como nosso cliente esta rodando.

  • Remover plug de configuração

  • Passar url base para função

Vamos começar removendo o plug para evitar as configurações via macro.

lib/integrations/coffee/client.ex
defmodule CoffeeShop.Integrations.Coffee.Client do
  use Tesla

  alias CoffeeShop.Integrations.Coffee.Response

  def all_hot_coffees do
    "/coffee/hot"
    |> get()
    |> Response.build()
  end
end

Podemos rodar os testes e ver o tamanho do estrago.

mix test
mix test 
Compiling 1 file (.ex)


  1) test all_hot_coffees/0 respond a list of hot coffees (CoffeeShop.Integrations.Coffee.ClientTest)
     test/integrations/coffee/client_test.exs:13
     ** (FunctionClauseError) no function clause matching in CoffeeShop.Integrations.Coffee.Response.build/1

     The following arguments were given to CoffeeShop.Integrations.Coffee.Response.build/1:
     
         # 1
         {:error, {:no_scheme}}
     
     Attempted function clauses (showing 1 out of 1):
     
         def build({:ok, %Tesla.Env{status: status, body: body}})
     
     code: assert {:ok, %Response{status: status}} = Client.all_hot_coffees()
     stacktrace:
       (coffee_shop 0.1.0) lib/integrations/coffee/response.ex:4: CoffeeShop.Integrations.Coffee.Response.build/1
       test/integrations/coffee/client_test.exs:14: (test)



  2) test all_hot_coffees/0 service is crashed (CoffeeShop.Integrations.Coffee.ClientTest)
     test/integrations/coffee/client_test.exs:18
     ** (FunctionClauseError) no function clause matching in CoffeeShop.Integrations.Coffee.Response.build/1

     The following arguments were given to CoffeeShop.Integrations.Coffee.Response.build/1:
     
         # 1
         {:error, {:no_scheme}}
     
     Attempted function clauses (showing 1 out of 1):
     
         def build({:ok, %Tesla.Env{status: status, body: body}})
                                                                                                                                                                                                                                                                                                                                                     M:   CPU: 
     code: assert {:ok, %Response{status: status}} = Client.all_hot_coffees()
     stacktrace:
       (coffee_shop 0.1.0) lib/integrations/coffee/response.ex:4: CoffeeShop.Integrations.Coffee.Response.build/1
       test/integrations/coffee/client_test.exs:25: (test)

...
Finished in 0.1 seconds (0.00s async, 0.1s sync)
1 doctest, 4 tests, 2 failures

A vantagem de ter testes, é que saberemos se algo da errado em nossa mudança de forma prática e rápida. Caso quebre, basta resolver, ficando verde, temos um indicativo que tudo voltou a funcionar. Vamos fazer essas belezinhas passarem.

O problema aqui é não ter configurado a url base do cliente, uma vez que removemos o plug. Precisamos ir agora para a segunda parte, montar nossa função de criação do cliente.

  • Remover plug de configuração

  • Passar url base para função

Precisamos passar um parâmetro de configuração para dentro de nossa função de requisição all_hot_coffees/1. Utilizaremos uma lista para facilitar a adição de novos argumentos. Utilizaremos Keyword.get/3 para obter o dado da lista:

Keyword.get/3

  1. Primeiro argumento: Lista

  2. Segundo argumento: chave da lista

  3. Terceiro argumento: Valor padrão caso não encontre a chave

Funcionará assim:

defmodule CoffeeShop.Integrations.Coffee.Client do
  use Tesla

  alias CoffeeShop.Integrations.Coffee.Response

  def all_hot_coffees(opts \\ []) do
    base_url = Keyword.get(opts, :base_url, "https://api.sampleapis.com")

    "#{base_url}/coffee/hot"
    |> get()
    |> Response.build()
  end
end

Passamos o parâmetros de opts para nossa função e dissemos que caso não encontre em opts a chave base_url, utilizar o valor https://api.sampleapis.com. Nossa implementação continuará funcionando com a url base padrão, mas temos a possibilidade de alterar caso necessario, em nosso atual cenário, quando precisar bater no serviço de teste.

Mas isso não é tudo que precisamos fazer. Se rodar o teste estaremos ainda com o problema de sempre acessar o serviço real. Para resolver isso, precisamos passar base_url nos parâmetros da chamada em nosso teste como esperado.

test/integrations/coffee/client_test.exs
defmodule CoffeeShop.Integrations.Coffee.ClientTest do
  use ExUnit.Case

  alias CoffeeShop.Integrations.Coffee.Client
  alias CoffeeShop.Integrations.Coffee.Response

  describe "all_hot_coffees/0" do
    setup do
      bypass = Bypass.open(port: 3000)
      {:ok, bypass: bypass}
    end

    test "respond a list of hot coffees" do
      assert {:ok, %Response{status: status}} = Client.all_hot_coffees()
      assert status == 200
    end

    test "service is crashed", %{bypass: bypass} do
      response = "Server explode"

      Bypass.expect_once(bypass, "GET", "/coffee/hot", fn conn ->
        Plug.Conn.resp(conn, 500, response)
      end)
      
      opts = [
        base_url: "http://localhost:3000"
      ]

      assert {:ok, %Response{status: status}} = Client.all_hot_coffees(opts)

      assert status == 500
    end
  end
end

Rodaresmos o teste

mix test
> mix test
.....
Finished in 0.6 seconds (0.00s async, 0.6s sync)
1 doctest, 4 tests, 0 failures

Randomized with seed 431636

Nada mal em? Criamos um mecanismo simples de configuração de nosso cliente. Podendo adicionar mais opções, que vamos explorar mais a frente. Por hora, conseguimos criar chamadas falsas do jeito que quisermos.

Tem duas coisas me incomodando:

  1. Estamos usando uma macro do Tesla que não tras muito ganho para nós.

  2. Ainda estamos batendo no serviço real em nosso primeiro teste, é interessante não fazer isso

Vamos começar removendo a macro. A macro do Tesla nos da poder de usar algumas funções sem precisar utilizar o prefixo, como se pertencesse a esse modulo. Mas além de criar um acoplamento forte também não fica muito legal visualmente.

"#{base_url}/coffee/hot"
|> get()
|> Response.build()

Podemos chamar a mesma função usando Tesla.get/1, não precisando da macro.

lib/integrations/coffee/client.ex
defmodule CoffeeShop.Integrations.Coffee.Client do
  alias CoffeeShop.Integrations.Coffee.Response

  def all_hot_coffees(opts \\ []) do
    base_url = Keyword.get(opts, :base_url, "https://api.sampleapis.com")

    "#{base_url}/coffee/hot"
    |> Tesla.get()
    |> Response.build()
  end
end

Sem mais macros. Sem mais magia obscura.

  1. Estamos usando uma macro do Tesla que não trás muito ganho para nós.

  2. Ainda estamos batendo no serviço real em nosso primeiro teste, é interessante não fazer isso

Vamos atualizar nosso teste para que use o dado mockado e pare de realizar a requisição para o serviço real.

test/integrations/coffee/client_test.exs
defmodule CoffeeShop.Integrations.Coffee.ClientTest do
  use ExUnit.Case

  alias CoffeeShop.Integrations.Coffee.Client
  alias CoffeeShop.Integrations.Coffee.Response

  describe "all_hot_coffees/0" do
    setup do
      bypass = Bypass.open(port: 3000)
      {:ok, bypass: bypass}
    end

    test "respond a list of hot coffees", %{bypass: bypass} do
      response = ""

      Bypass.expect_once(bypass, "GET", "/coffee/hot", fn conn ->
        Plug.Conn.resp(conn, 200, response)
      end)

      opts = [
        base_url: "http://localhost:3000"
      ]

      assert {:ok, %Response{status: status}} = Client.all_hot_coffees(opts)
      assert status == 200
    end

    # ...
  end
end

Uma vez o mecanismo pronto, basta apenas definir o mock e configurar a url base para nosso serviço falso.

  • Estamos usando uma macro do Tesla que não trás muito ganho para nós.

  • Ainda estamos batendo no serviço real em nosso primeiro teste, é interessante não fazer isso

Estamos 100% independentes agora. Não irão mais nos cobrar por requisições de teste. Também conseguimos limpar nosso cliente e remover todas os acoplamentos e dependências que não queremos.

Vamos mais a frente, temos muito chão ainda.

Atualizado