Недавно я работал над приложением, предназначенным только для практики Elixir. Во время тестирования приложения я столкнулся с несколькими проблемами, которые помогли мне сделать приложение более расширяемым и читаемым.
Побочные эффекты
Одна из основных проблем, с которой я столкнулся при написании тестов, заключалась в том, что код, который мне нужно было протестировать, зависел от системных файлов:
with {:ok, content} <- File.read(path),
results <- Core.log_to_results(content, opts[:mode]) do
{:ok, results}
end
Столкнувшись с подобным побочным эффектом лишь однажды, я не испытал никаких проблем, особенно когда речь шла только о чтении файлов.
Однако, когда я реализовал процесс, который уведомлял об изменениях в файлах журнала, я закончил тем, что написал тесты, которые создавали временные файлы, как на изображении ниже:
Затем я столкнулся с двумя проблемами, связанными с этим подходом:
- Я больше не мог создавать асинхронные тесты, поскольку эти файлы создавались и манипулировались в системе;
- Если тесты не срабатывали, временные файлы продолжали существовать в файловой системе, и мне приходилось удалять их вручную.
Давным-давно я прочитал пост Жозе Валима о Mocks и явных контрактах, и это вдохновило меня на рефакторинг проекта, чтобы больше не зависеть от File.read/1
и File.stats/1
. Затем я создал поведение 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
Преимущество этого подхода в том, что мне больше не нужно было знать, как происходит чтение журнала. Необходимо было только соблюдать договор и получать {:ok, content}
для read/1
. Также мне больше не нужно было обрабатывать результат File.stats/1
, поскольку контракт mtime/1
, который был {:ok, new_mtime}
, уже принес нужные мне данные. Вскоре появилась реализация Log.FileAdapter
:
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
Затем я смог создать реализацию, которая просто манипулировала «файлами» в памяти с помощью 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
И настройте мои тесты на использование этого адаптера по умолчанию в tests.exs
:
config :q3_reporter, log_adapter: Q3Reporter.Log.ETSAdapter
Вскоре я перестал зависеть от создания и манипулирования системными файлами. А если мне нужно было запустить тесты, читающие реальные файлы, я просто передавал Log.FileAdapter
в функцию 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
Тестовое покрытие
Я столкнулся с другой проблемой, когда пытался протестировать ошибки, связанные с File.read/1
, такие как «недостаток памяти» и «разрешения». Однако для меня не представляется возможным манипулировать файлами, на которые у меня нет прав в проекте, а также заполнять память компьютера во время тестирования.
Моим первым решением в этом случае было игнорирование этих фрагментов кода в тестовом покрытии:
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
Поскольку рефакторинг предыдущего сеанса позволил мне переопределить адаптер, я мог вскоре протестировать этот фрагмент следующим образом:
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
error_adapter/1
— это не что иное, как функция, которая использует Mox для имитации адаптера и возвращает ожидаемую ошибку:
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
Таким образом, замена File
на явный контракт через Log
позволила мне достичь тестового покрытия там, где это было невозможно раньше, и я смог убрать все #coveralls-ignore
из проекта и получить 100% реальное тестовое покрытие.
Вы можете ознакомиться с примером кода из этого проекта по адресу: https://github.com/maxmaccari/q3_reporter.