Como testes ajudam a melhorar o design do código?
Tags: elixir, testing, braziliandevs
Published: 2022-05-21 02:45:07

Recentemente eu estive trabalhando numa aplicação somente para praticar Elixir. E ao testar a aplicação, eu enfrentei diversos problemas que me ajudaram a deixar a aplicação mais extensível e legível.

## Side Effects
Um dos principais problemas que eu enfrentei escrevendo os testes foi de que o código que eu precisava testar dependia de arquivos do sistema:

```elixir
with {:ok, content} <- File.read(path),
     results <- Core.log_to_results(content, opts[:mode]) do
  {:ok, results}
end
```

Lidar com esse tipo de efeito colateral uma única vez não me causou nenhum problema, principalmente quando se tratava apenas de leitura de arquivos.

Porém, ao implementar um processo que notificava as mudanças nos arquivos de logs, acabei escrevendo testes que criavam arquivos temporários, como na imagem abaixo:
![Arquivos Temporários Criados pelos Testes](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/sek3ez2nbljruyyh2t9x.png)

Então enfrentei dois problemas com essa abordagem:

* Não pude mais criar testes assíncronos, pois esses arquivos eram criados e manipulados no sistema;
* Caso os testes falhassem, os arquivos temporários ainda continuavam existindo no file system, e eu tinha que apagá-los manualmente.

Há muito tempo eu li um post do José Valim sobre [Mocks and explicit contracts](https://dashbit.co/blog/mocks-and-explicit-contracts), e isso me inspirou a refatorar o projeto para não depender mais de `File.read/1` e `File.stats/1`. Logo criei o behaviour `Log`:

```elixir
defmodule Q3Reporter.Log do
  @moduledoc false

  alias Q3Reporter.Log.FileAdapter

  @type read_return :: {:ok, String.t()} | {:error, atom()}
  @type mtime_return :: {:ok, NaiveDateTime.t()} | {:error, atom()}

  @callback read(String.t()) :: read_return
  @callback mtime(String.t()) :: mtime_return

  @default_adapter Application.compile_env(:q3_reporter, :log_adapter, FileAdapter)

  @spec read(String.t(), atom() | nil) :: read_return()
  def(read(path, adapter \\ @default_adapter))
  def read(path, adapter) when is_nil(adapter), do: @default_adapter.read(path)
  def read(path, adapter), do: adapter.read(path)

  @spec mtime(String.t(), atom() | nil) :: mtime_return()
  def mtime(path, adapter \\ @default_adapter)
  def mtime(path, adapter) when is_nil(adapter), do: @default_adapter.mtime(path)
  def mtime(path, adapter), do: adapter.mtime(path)
end
```

A vantagem dessa abordagem é de que eu não precisava mais saber como a leitura dos logs era feita. Era necessário apenas respeitar o contrato e receber `{:ok, conteúdo}` para o `read/1`. E também não precisei mais tratar o resultado do `File.stats/1`, já que o contrato de `mtime/1`, que era `{:ok, novo_mtime}`, já trazia o dado que eu precisava. Logo, a implementação do `Log.FileAdapter` ficou:

```elixir
defmodule Q3Reporter.Log.FileAdapter do
  @behaviour Q3Reporter.Log

  @impl true
  def read(path), do: File.read(path)

  @impl true
  def mtime(path) do
    case File.stat(path) do
      {:ok, stat} -> NaiveDateTime.from_erl(stat.mtime)
      {:error, _} = error -> error
    end
  end
end
```

Então eu pude criar uma implementação que simplesmente manipulava os "arquivos" na memória utilizando [ETS](https://elixir-lang.org/getting-started/mix-otp/ets.html):

```elixir
defmodule Q3Reporter.Log.ETSAdapter do
  @behaviour Q3Reporter.Log

  @table __MODULE__

  @impl true
  def read(name) do
    case :ets.lookup(@table, name) do
      [{_name, content, _mtime}] -> {:ok, content}
      _ -> {:error, :enoent}
    end
  end

  @impl true
  def mtime(name) do
    case :ets.lookup(@table, name) do
      [{_name, _content, mtime}] -> {:ok, mtime}
      _ -> {:error, :enoent}
    end
  end

  @doc false
  def init, do: :ets.new(@table, [:named_table, :set, :public])

  @doc false
  def close(name), do: :ets.delete(@table, name)

  @doc false
  def push(name, content \\ "", mtime \\ NaiveDateTime.utc_now()) do
    :ets.insert(@table, {name, content, mtime})
  end
end
```

E configurar meus testes para utilizar esse adapter por padrão em `tests.exs`:

```elixir
config :q3_reporter, log_adapter: Q3Reporter.Log.ETSAdapter
```

Logo, eu não dependia mais de criar e manipular arquivos do sistema. E caso precisasse realizar testes que liam arquivos reais, eu apenas tinha de passar o `Log.FileAdapter` para a função `Q3Reporter.parse/2`:

```elixir
test "should return game contents with valid file and default mode" do
  assert {:ok, results} = Q3Reporter.parse(@path, mode: :by_game, log_adapter: FileAdapter)
  assert %Results{entries: [%{}], mode: :by_game} = results
end
```

## Cobertura de Testes

Enfrentei outro problema ao tentar testar erros relacionados a `File.read/1`, como "falta de memória" e "permissões". Porém não é viável eu manipular arquivos os quais eu não tenho permissões no projeto, e nem lotar a memória do meu computador enquanto os testes eram realizados.

A minha primeira solução, no caso, foi ignorar esses trechos de código na cobertura de testes: 

```elixir
def parse(path, opts \\ []) do
    with {:ok, content} <- Log.read(path, opts[:log_adapter]),
         results <- Core.log_to_results(content, opts[:mode]) do
      {:ok, results}
    else
      {:error, :enoent} ->
        {:error, "'#{path}' not found..."}

      # coveralls-ignore-start
      {:error, :eacces} ->
        {:error, "You don't have permission to open '#{path}..."}

      {:error, :enomem} ->
        {:error, "There's no enough memory to open 'invalid'..."}

      {:error, _} ->
        {:error, "Error trying to open 'invalid'"}
      # coveralls-ignore-stop
    end
  end
```

Como a refatoração da sessão anterior me permitiu substituir o adapter, logo eu poderia testar esse trecho da seguinte maneira:

```elixir
test "should return error with invalid file path" do
    assert {:error, "'invalid' not found..."} =
               Q3Reporter.parse("invalid", log_adapter: FileAdapter)

    assert {:error, "You don't have permission to open 'invalid'..."} =
             Q3Reporter.parse("invalid", log_adapter: error_adapter(:eacces))

    assert {:error, "There's no enough memory to open 'invalid'..."} =
             Q3Reporter.parse("invalid", log_adapter: error_adapter(:enomem))

    assert {:error, "Error trying to open 'invalid'"} =
             Q3Reporter.parse("invalid", log_adapter: error_adapter(:unknown))
end
```

O `error_adapter/1` não é nada mais do que uma função que utiliza o [Mox](https://github.com/dashbitco/mox) para mockar um adapter, e retornar o erro esperado:

```elixir
def error_adapter(error) do
  module = module_name(error)
  Mox.defmock(module, for: Q3Reporter.Log)

  module
  |> expect(:read, fn _ -> {:error, error} end)
  |> expect(:mtime, fn _ -> {:error, error} end)
end
```

Logo, substituir o `File` por um contrato explícito através de `Log` me permitiu atingir uma cobertura de testes onde não era possível anteriormente, e eu pude tirar todos os `#coveralls-ignore` do projeto e ter 100% de cobertura de testes real.

É possível conferir o código de exemplo desse projeto em: https://github.com/maxmaccari/q3_reporter