Repository: edgurgel/mimic
Pull request #33 (2021-11-12 11:13:34)

add stub_with/2 for stubbing whole module
As per https://github.com/edgurgel/mimic/issues/30

Adding `stubs_with/2` function which could mock a module with another module

edgurgel: Thanks for sending this PR!  ❤️ 

I gave a quick look and it looks pretty good 👍 but at the moment pretty busy! I hope that I have some time next week to review it properly

Patch:
diff --git a/README.md b/README.md
index abe6fde..dad8865 100644
--- a/README.md
+++ b/README.md
@@ -179,6 +179,23 @@ To use DSL Mode `use Mimic.DSL` rather than `use Mimic` in your test.  DSL Mode
   end
 ```
 
+## Stubs with fake module
+`stub_with/2` enable substitute function call of a module with another similar module
+
+```elixir
+  defmodule BadCalculator do
+    def add(x, y), do: x*y
+    def mult(x, y), do: x+y
+  end
+
+  test "basic example" do
+    stub_with(Calculator, BadCalculator)
+
+    assert Calculator.add(2, 3) == 6
+    assert Calculator.mult(2, 3) == 5
+  end
+```
+
 
 ## Implementation Details & Performance
 
diff --git a/lib/mimic.ex b/lib/mimic.ex
index 5c2b3df..5a1f2f9 100644
--- a/lib/mimic.ex
+++ b/lib/mimic.ex
@@ -162,6 +162,42 @@ defmodule Mimic do
     )
   end
 
+  @doc """
+  Replace all public functions in `module` with public function in `mocking_module`.
+
+  If there's any public function in `module` that are not in `mocking_module`, it will raise if it's called
+
+  ## Arguments:
+
+    * `module` - The name of the module to stub.
+    * `mocking_module` - The name of the mocking module to stub the original module.
+
+  ## Raises:
+
+    * If `module` is not copied.
+    * If `function` is not called by the stubbing process.
+
+  ## Example
+      defmodule InverseCalculator do
+        def add(a, b), do: a - b
+      end
+
+      iex> Mimic.stub_with(Calculator, InverseCalculator)
+      ...> Calculator.add(2, 4)
+      ...> -2
+
+  """
+  @spec stub_with(module(), module()) :: module()
+  def stub_with(module, mocking_module) do
+    raise_if_not_copied!(module)
+
+    module
+    |> Server.stub_with(mocking_module)
+    |> validate_server_response(
+      "Stub cannot be called by the current process. Only the global owner is allowed."
+    )
+  end
+
   @doc """
   Define a stub which must be called within an example.
 
diff --git a/lib/mimic/server.ex b/lib/mimic/server.ex
index 20927c2..6c1e428 100644
--- a/lib/mimic/server.ex
+++ b/lib/mimic/server.ex
@@ -38,6 +38,10 @@ defmodule Mimic.Server do
     GenServer.call(__MODULE__, {:stub, module, self()})
   end
 
+  def stub_with(module, mocking_module) do
+    GenServer.call(__MODULE__, {:stub_with, module, mocking_module, self()})
+  end
+
   def expect(module, fn_name, arity, num_calls, func) do
     GenServer.call(__MODULE__, {:expect, {module, fn_name, func, arity}, num_calls, self()})
   end
@@ -63,7 +67,7 @@ defmodule Mimic.Server do
   end
 
   def reset(module) do
-    GenServer.call(__MODULE__, {:reset, module})
+    GenServer.call(__MODULE__, {:reset, module}, :infinity)
   end
 
   def apply(module, fn_name, args) do
@@ -256,6 +260,52 @@ defmodule Mimic.Server do
     end
   end
 
+  def handle_call({:stub_with, mocked_module, mocking_module, owner}, _from, state) do
+    if valid_mode?(state, owner) do
+      monitor_if_not_verify_on_exit(owner, state.verify_on_exit)
+
+      :ets.insert_new(__MODULE__, {{owner, mocked_module}, owner})
+
+      original_module = Mimic.Module.original(mocked_module)
+
+      internal_functions = [__info__: 1, module_info: 0, module_info: 1]
+
+      mocked_public_functions =
+        original_module.module_info[:exports]
+        |> Enum.filter(&(&1 not in internal_functions))
+        |> MapSet.new()
+
+      mocking_public_functions =
+        mocking_module.module_info[:exports]
+        |> Enum.filter(&(&1 not in internal_functions))
+        |> MapSet.new()
+
+      will_be_mocked_functions =
+        MapSet.intersection(mocking_public_functions, mocked_public_functions)
+
+      will_be_stubbed_functions =
+        MapSet.difference(mocked_public_functions, mocking_public_functions)
+
+      stubs =
+        will_be_mocked_functions
+        |> Enum.reduce(state.stubs, fn {fn_name, arity}, stubs ->
+          func = anonymize_module_function(mocking_module, fn_name, arity)
+          put_in(stubs, [Access.key(owner, %{}), {mocked_module, fn_name, arity}], func)
+        end)
+
+      stubs =
+        will_be_stubbed_functions
+        |> Enum.reduce(stubs, fn {fn_name, arity}, stubs ->
+          func = stub_function(mocked_module, fn_name, arity)
+          put_in(stubs, [Access.key(owner, %{}), {mocked_module, fn_name, arity}], func)
+        end)
+
+      {:reply, {:ok, mocked_module}, %{state | stubs: stubs}}
+    else
+      {:reply, {:error, :not_global_owner}, state}
+    end
+  end
+
   def handle_call({:expect, {module, fn_name, func, arity}, num_calls, owner}, _from, state) do
     if valid_mode?(state, owner) do
       monitor_if_not_verify_on_exit(owner, state.verify_on_exit)
@@ -387,6 +437,23 @@ defmodule Mimic.Server do
     fun
   end
 
+  defp anonymize_module_function(module, fn_name, arity) do
+    args =
+      0..arity
+      |> Enum.to_list()
+      |> tl
+      |> Enum.map(fn i -> Macro.var(String.to_atom("arg_#{i}"), nil) end)
+
+    clause =
+      quote do
+        unquote_splicing(args) ->
+          apply(unquote(module), unquote(fn_name), [unquote_splicing(args)])
+      end
+
+    {fun, _} = Code.eval_quoted({:fn, [], clause})
+    fun
+  end
+
   defp do_set_global_mode(owner_pid, state) do
     :ets.insert(__MODULE__, {:mode, :global, owner_pid})
     %{state | global_pid: owner_pid, mode: :global}
diff --git a/mix.exs b/mix.exs
index fe769bc..56a6afe 100644
--- a/mix.exs
+++ b/mix.exs
@@ -2,7 +2,7 @@ defmodule Mimic.Mixfile do
   use Mix.Project
 
   @source_url "https://github.com/edgurgel/mimic"
-  @version "1.5.1"
+  @version "1.5.2"
 
   def project do
     [


Test patch:
diff --git a/test/mimic_test.exs b/test/mimic_test.exs
index 83ef075..9005679 100644
--- a/test/mimic_test.exs
+++ b/test/mimic_test.exs
@@ -112,6 +112,23 @@ defmodule Mimic.Test do
     end
   end
 
+  describe "stub_with/1 private mode" do
+    setup :set_mimic_private
+
+    test "called multiple times" do
+      stub_with(Calculator, InverseCalculator)
+
+      assert Calculator.add(2, 3) == -1
+      assert Calculator.add(3, 2) == 1
+    end
+
+    test "stubs all functions which are not in mocking module" do
+      stub_with(Calculator, InverseCalculator)
+
+      assert_raise Mimic.UnexpectedCallError, fn -> Calculator.mult(4, 9) end
+    end
+  end
+
   describe "stub/3 private mode" do
     setup :set_mimic_private
 
diff --git a/test/support/test_modules.ex b/test/support/test_modules.ex
index 2a7a5cb..46fe8af 100644
--- a/test/support/test_modules.ex
+++ b/test/support/test_modules.ex
@@ -16,6 +16,12 @@ defmodule Calculator do
   def mult(x, y), do: x * y
 end
 
+defmodule InverseCalculator do
+  @moduledoc false
+  @behaviour AddAdapter
+  def add(x, y), do: x - y
+end
+
 defmodule Counter do
   @moduledoc false
   def inc(counter), do: counter + 1