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?
- Criando os testes de client com Bypass
- Criando os testes na regra de negócio com Mox
- Conclusão
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:
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
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
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
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:
- CEP válido;
- CEP não existe;
- CEP inválido;
- 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.
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
...
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:
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
...
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
...
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
...
Esse é o único comportamento diferente entre os clients
aqui. Se trocarmos de TeslaClient
para HttpoisonClient
:
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
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
Altere os arquivos httpoison_client
e tesla_client
adicionando o behaviour:
Se tentarmos retornar algo diferente para esta função, receberemos um aviso:
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)
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
...
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
-
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
...
Testando com MIX_ENV=test iex -S mix
:
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
Troque Client.get_cep_info(cep)
para client().get_cep_info(cep)
:
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
Se trocarmos o _cep
por 00000000
receberemos um erro:
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)
.
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.
Top comments (0)