Как тестирование помогает улучшить дизайн кода?

Недавно я работал над приложением, предназначенным только для практики 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.

Оцените статью
Procodings.ru
Добавить комментарий