[Elixir] ExUnit: Asserções e mocks para chamadas de funções

Neste post, vou mostrar duas maneiras de criar mocks para testes automatizados utilizando Elixir. E também testes que verificam se uma função foi chamada ou não dentro de um fluxo.

Caso você ainda não conheça o conceito de "mock" ou "mockar" aqui vai uma breve descrição:

Quando dizemos que vamos realizar um mock, significa que criaremos uma função ou módulo que só existirá dentro do escopo do teste, e servirá para substituir as funções ou módulos que estão presentes na aplicação, nos retornando resultados específicos que definimos no próprio teste.

Utilizando a biblioteca Mock

Essa é a maneira mais simples, e pode ser conveniente caso sua aplicação seja pequena:

Inclua a biblioteca Mock no seu mix.exs e rode “$ mix depx.get”:

Essa biblioteca nos trás diversas funções úteis para criar mocks em testes como:

with_mock/2: Recebe um módulo e um keyword list contendo o nome da função e o comportamento a ser definido.

with_mocks/3: Recebe uma lista de keyword lists semelhantes ao with_mock/2.

called/1: Recebe uma função de um módulo que foi mockado com with_mock/2 ou with_mocks/3 e verifica se ela foi chamada dentro do fluxo de código que está sendo testado

Vamos aos exemplos

Imagine que temos que desenvolver uma aplicação onde precisaremos consultar dados de uma API externa.

Para isso, devemos ter um módulo responsável pelas consultas. Vamos criar um e nomea-lo de ExampleClient.

Agora adicione a função get_example_data/0, que retorna uma tupla {:ok, %{data: "hello”}}. (Não vamos requisitar uma API de verdade, o exemplo é apenas didático).

Com nosso módulo de consultas pronto, vamos disponibilizar o seu retorno em uma rota, através de um controller e uma view. Veja como ficaram os arquivos:

/lib/mock_example_web/controllers/example_controller.ex

/lib/mock_example_web/view/example_view.ex

/lib/mock_example_web/router.ex

Feito as alterações, o nosso endpoint já deve estar funcionando.

Testando o nosso ExampleClient

Com os módulos de implementação prontos, chegou a hora de testar:

Vamos garantir que nosso endpoint sempre retorne status 200 com o body “%{“data” => %{“data” => “hello”}}”, para isso devemos escrever o seguinte teste:

/test/mock_example_web/controllers/example_controller_test.exs

Criando o nosso Mock

Para mockar o retorno da função “get_example_data/0” em um teste, vamos utilizar o with_mock/2. Passamos como argumento o nome do módulo (“MockExample.ExampleClient”) e um keyword list contendo o nome da função que queremos substituir, e o seu novo comportamento (“[get_example_data: fn -> {:ok, %{data: “sou um novo retorno”}} end]”), veja como ficou no exemplo abaixo:

Dessa forma, sempre que o teste passar por um MockExample.ExampleClient.get_example_data/0 no fluxo testado, a bibliotexa irá substituir o modulo da implementação pelo nosso mock definido no teste com o with_mock/2

E se quisermos garantir que a nossa função será sempre chamada no fluxo do teste?

Nesse caso, utilizamos a função called/1 junto com a função assert, veja como fica:

Veja o que acontece ao rodarmos os testes, caso deixarmos de chamar a função “MockExample.ExampleCliente.get_example_data()” no corpo do nosso controller:

Os problemas da biblioteca “Mock”

Apesar de resolver o nosso problema e nos ajudar a criar “Mocks” rapidamente, a bibloteca utiliza um design que é prejudicial caso a sua aplicação cresça, veja:

  • Ao utilizarmos o with mock/2 ou with_mocks/2, todo o módulo da implementação é substituido pelo mock criado. Outras funções do módulo definidas no código da implementação deixarão de existir no escopo do teste. Caso o módulo que foi mockado tenha várias funções diferentes sendo chamadas no fluxo da implementação, teremos que definir um novo comportamento no código do teste para cada uma delas:
  • O design força o alto acoplamento dos mocks no teste com o código de implementação. Isso significa que, caso tivermos que mudar o módulo ExampleClient para um outro, OtherExampleClient por exemplo, vários testes podem quebrar, e teremos que alterar o nosso código de teste, substituindo todos os mocks. Seria muito mais conveniente se houvesse uma maneira de trocar em um lugar só, não é mesmo?

A solução ideal

A abordagem preferida da comunidade e bastante difundida por José Valim (Criador do Elixir) para a criação de mocks de maneira elegante e efetiva, é a que se baseia na separação da sua aplicação em “ambientes”:

  • Teste
  • Produção
  • Desenvolvimento

Ao invés de ficarmos criando diversos mocks dentro de testes, devemos criar um módulo específico para cada ambiente, de acordo com a sua necessidade:

  • ExampleClient.Homologation, responsável pelo comportamento no ambiente de desenvolvimento.
  • ExampleClient.Test, responsável pelo comportamento no ambiente de testes.
  • ExampleClient, responsável pelo comportamento no ambiente de produção

Para utilizar os três módulos, basta defini-los na configuração da aplicação:

E onde forem chamados, separamos em um atributo de módulo:

Dessa forma, de acordo com o ambiente que o Elixir estiver rodando, teremos um módulo “diferente”. No caso do ambiente de teste, teremos ExampleClientTest.

Rodando em ambiente de desenvolvimento com mix phx.server:

Rodando em ambiente de teste:

/test/mock_example_web/controllers/example_controller_test.exs:

Dado que Elixir têm o conceito de Pattern Matching, fica bem fácil e simples definirmos um comportamento específico Mockado para os diferentes tipos de chamadas e testes que a mesma função pode receber =)