Como testes ajudam a melhorar o design do código?
Maxsuel Fernandes Maccari
Posted on May 21, 2022
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
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:
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
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
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
E configurar meus testes para utilizar esse adapter por padrão em tests.exs
:
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
:
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:
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:
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 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
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
Posted on May 21, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.