lib/mockable.ex

defmodule Mockable do
  require Logger

  @moduledoc """
  Documentation for `Mockable`.
  """

  @doc """
  Configures which module to use for a given mockable module.
  If no implementation is provided, the mockable module itself will be used.

  This is useful for testing your Mockable module.

  ```elixir
  Mockable.use(Client)
  ```

  Or to use a specific implementation:
  ```elixir
  Mockable.use(Client, ClientMock)
  ```

  This function stores the configuration in process memory so that it is compatible with async tests.
  """
  def use(mockable, implementation \\ nil) do
    implementation = implementation || mockable
    Process.put({Mockable, mockable}, implementation)
  end

  defmacro __using__(_opts) do
    quote do
      @before_compile unquote(__MODULE__)
      @behaviour __MODULE__
    end
  end

  defmacro __before_compile__(env) do
    module = env.module
    module_string = module |> Atom.to_string() |> String.replace_prefix("Elixir.", "")

    if Application.get_env(:mockable, module) do
      callback_specs =
        Module.get_attribute(module, :callback)
        |> Enum.map(fn {:callback, spec, _} ->
          case spec do
            {:"::", _, [{name, _, args}, _return_type]} ->
              {{name, length(args)}, spec}
          end
        end)
        |> Map.new()
        |> Map.take(Module.definitions_in(module, :def))

      wrapped_functions =
        for {{name, arity}, spec} <- callback_specs do
          args = Macro.generate_arguments(arity, __MODULE__)

          quote do
            defoverridable [{unquote(name), unquote(arity)}]

            @spec unquote(spec)
            def unquote(name)(unquote_splicing(args)) do
              implementation =
                Process.get({Mockable, unquote(module)}) ||
                  Application.get_env(:mockable, unquote(module))

              if implementation != unquote(module) do
                Mockable.log_implementation_usage(
                  implementation,
                  unquote(name),
                  unquote(arity),
                  unquote(module_string)
                )

                apply(implementation, unquote(name), [unquote_splicing(args)])
              else
                Mockable.log_implementation_usage(
                  implementation,
                  unquote(name),
                  unquote(arity),
                  unquote(module_string)
                )

                super(unquote_splicing(args))
              end
            end
          end
        end

      quote do
        require Logger
        (unquote_splicing(wrapped_functions))
      end
    end
  end

  if Application.compile_env(:mockable, :log, true) do
    def log_implementation_usage(implementation, function_name, arity, module) do
      Logger.debug("Using #{inspect(implementation)}.#{function_name}/#{arity} for #{module}")
    end
  else
    def log_implementation_usage(_implementation, _function_name, _arity, _module), do: :ok
  end
end