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

최신 제품은 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.

부작용



Um dos principais problemas que eu enfrentei escrevendo os testes foi de que o código que eu precisava testar dependsia 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 dependser mais de File.read/1 e 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
    


    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 precisioni 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 emtests.exs:

    config :q3_reporter, log_adapter: Q3Reporter.Log.ETSAdapter
    


    로고는 시스템에 의존하지 않고 조작할 수 없습니다. 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
    


    코베르투라 데 고환



    Enfrentei outro problema ao tentar testar erros relacionados a File.read/1 , como "falta de memória"및 "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 mokar 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

    좋은 웹페이지 즐겨찾기