Como testes ajudam a melhorar o design do código?

maxmaccari

Maxsuel Fernandes Maccari

Posted on May 21, 2022

Como testes ajudam a melhorar o design do código?

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:

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

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

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, 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:

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
Enter fullscreen mode Exit fullscreen mode

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:

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
Enter fullscreen mode Exit fullscreen mode

Então eu pude criar uma implementação que simplesmente manipulava os "arquivos" na memória utilizando ETS:

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
Enter fullscreen mode Exit fullscreen mode

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

config :q3_reporter, log_adapter: Q3Reporter.Log.ETSAdapter
Enter fullscreen mode Exit fullscreen mode

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:

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
Enter fullscreen mode Exit fullscreen mode

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:

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
Enter fullscreen mode Exit fullscreen mode

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

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
Enter fullscreen mode Exit fullscreen mode

O error_adapter/1 não é nada mais do que uma função que utiliza o Mox para mockar um adapter, e retornar o erro esperado:

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
Enter fullscreen mode Exit fullscreen mode

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

💖 💪 🙅 🚩
maxmaccari
Maxsuel Fernandes Maccari

Posted on May 21, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related