Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Experimental: Type checking for stubs and expectations #77

Merged
merged 3 commits into from
Aug 31, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ Just add `:mimic` to your list of dependencies in `mix.exs`:
```elixir
def deps do
[
{:mimic, "~> 1.7", only: :test}
{:mimic, "~> 1.10", only: :test}
]
end
```
Expand Down Expand Up @@ -235,6 +235,27 @@ test "calls original function even if it has been is stubbed" do
end
```

## Experimental type checking for copied modules

One can pass `type_check: true` when a module is copied to also get the function expected/stubbed to
validate the arguments and return value using [Ham](https://github.com/edgurgel/ham) which is essentially
what [Hammox](https://github.com/msz/hammox) improved on Mox.

```elixir
Mimic.copy(:cowboy_req, type_check: true)
```

If there is any problem with the arguments or return values of the stubbed functions on your tests you might see
an error like this one:

```elixir
** (Mimic.TypeCheckError) :cowboy_req.parse_qs/1: 1st argument value %{} does not match 1st parameter's type :cowboy_req.req().
Could not find a map entry matching required(:method) => binary().
```

This feature is experimental at the moment which means that it might change a little bit how this
is configured and used. Feedback is welcome!

## Implementation Details & Performance

After calling `Mimic.copy(MyModule)`, calls to functions belonging to this module will first go through an ETS table to check which pid sees what (stubs, expects or call original).
Expand Down
11 changes: 8 additions & 3 deletions lib/mimic.ex
Original file line number Diff line number Diff line change
Expand Up @@ -352,13 +352,18 @@ defmodule Mimic do
## Arguments:

* `module` - the name of the module to copy.
* `opts` - Extra options

## Options:

* `type_check` - Must be a boolean defaulting to `false`. If `true` the arguments and return value
are validated against the module typespecs or the callback typespecs in case of a behaviour implementation.
"""
@spec copy(module()) :: :ok | no_return
def copy(module) do
@spec copy(module(), keyword) :: :ok | no_return
def copy(module, opts \\ []) do
with :ok <- ensure_module_not_copied(module),
{:module, module} <- Code.ensure_compiled(module),
:ok <- Mimic.Server.mark_to_copy(module) do
:ok <- Mimic.Server.mark_to_copy(module, opts) do
ExUnit.after_suite(fn _ -> Mimic.Server.reset(module) end)
:ok
else
Expand Down
20 changes: 8 additions & 12 deletions lib/mimic/module.ex
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ defmodule Mimic.Module do
:ok
end

@spec replace!(module) :: :ok | {:cover.file(), binary}
def replace!(module) do
@spec replace!(module, keyword) :: :ok | {:cover.file(), binary}
def replace!(module, opts) do
backup_module = original(module)

result =
Expand All @@ -34,7 +34,7 @@ defmodule Mimic.Module do

rename_module(module, backup_module)
Code.compiler_options(ignore_module_conflict: true)
create_mock(module)
create_mock(module, Map.new(opts))
Code.compiler_options(ignore_module_conflict: false)

result
Expand Down Expand Up @@ -111,8 +111,8 @@ defmodule Mimic.Module do

defp rename_attribute([h | t], new_name), do: [h | rename_attribute(t, new_name)]

defp create_mock(module) do
mimic_info = module_mimic_info()
defp create_mock(module, opts) do
mimic_info = module_mimic_info(opts)
mimic_behaviours = generate_mimic_behaviours(module)
mimic_functions = generate_mimic_functions(module)
mimic_struct = generate_mimic_struct(module)
Expand All @@ -132,20 +132,16 @@ defmodule Mimic.Module do
end
end

defp module_mimic_info do
quote do: def(__mimic_info__, do: :ok)
defp module_mimic_info(opts) do
quote do: def(__mimic_info__, do: {:ok, unquote(Macro.escape(opts))})
end

defp generate_mimic_functions(module) do
internal_functions = [__info__: 1, module_info: 0, module_info: 1]

for {fn_name, arity} <- module.module_info(:exports),
{fn_name, arity} not in internal_functions do
args =
0..arity
|> Enum.to_list()
|> tl()
|> Enum.map(&Macro.var(String.to_atom("arg_#{&1}"), Elixir))
args = Macro.generate_arguments(arity, module)

quote do
def unquote(fn_name)(unquote_splicing(args)) do
Expand Down
36 changes: 27 additions & 9 deletions lib/mimic/server.ex
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ defmodule Mimic.Server do
expectations: %{},
modules_beam: %{},
modules_to_be_copied: MapSet.new(),
reset_tasks: %{}
reset_tasks: %{},
modules_opts: %{}
end

defmodule Expectation do
Expand Down Expand Up @@ -90,9 +91,9 @@ defmodule Mimic.Server do
GenServer.call(__MODULE__, {:reset, module}, @long_timeout)
end

@spec mark_to_copy(module) :: :ok | {:error, {:module_already_copied, module}}
def mark_to_copy(module) do
GenServer.call(__MODULE__, {:mark_to_copy, module}, @long_timeout)
@spec mark_to_copy(module, keyword) :: :ok | {:error, {:module_already_copied, module}}
def mark_to_copy(module, opts) do
GenServer.call(__MODULE__, {:mark_to_copy, module, opts}, @long_timeout)
end

@spec marked_to_copy?(module) :: boolean
Expand Down Expand Up @@ -259,7 +260,8 @@ defmodule Mimic.Server do

def handle_call({:stub, module, fn_name, func, arity, owner}, _from, state) do
with {:ok, state} <- ensure_module_copied(module, state),
true <- valid_mode?(state, owner) do
true <- valid_mode?(state, owner),
func <- maybe_typecheck_func(module, fn_name, func) do
monitor_if_not_verify_on_exit(owner, state.verify_on_exit)

:ets.insert_new(__MODULE__, {{owner, module}, owner})
Expand Down Expand Up @@ -336,6 +338,7 @@ defmodule Mimic.Server do
will_be_mocked_functions
|> Enum.reduce(state.stubs, fn {fn_name, arity}, stubs ->
func = anonymize_module_function(mocking_module, fn_name, arity)
func = maybe_typecheck_func(mocked_module, fn_name, func)
put_in(stubs, [Access.key(owner, %{}), {mocked_module, fn_name, arity}], func)
end)

Expand All @@ -358,7 +361,8 @@ defmodule Mimic.Server do

def handle_call({:expect, {module, fn_name, func, arity}, num_calls, owner}, _from, state) do
with {:ok, state} <- ensure_module_copied(module, state),
true <- valid_mode?(state, owner) do
true <- valid_mode?(state, owner),
func <- maybe_typecheck_func(module, fn_name, func) do
monitor_if_not_verify_on_exit(owner, state.verify_on_exit)

:ets.insert_new(__MODULE__, {{owner, module}, owner})
Expand Down Expand Up @@ -462,15 +466,19 @@ defmodule Mimic.Server do
{:reply, marked_to_copy?(module, state), state}
end

def handle_call({:mark_to_copy, module}, _from, state) do
def handle_call({:mark_to_copy, module, opts}, _from, state) do
if marked_to_copy?(module, state) do
{:reply, {:error, {:module_already_copied, module}}, state}
else
# If cover is enabled call ensure_module_copied now
# Otherwise just store that the module that will be copied
# and ensure_module_copied/2 will copy it when
# expect, stub, reject is called
state = %{state | modules_to_be_copied: MapSet.put(state.modules_to_be_copied, module)}
state = %{
state
| modules_to_be_copied: MapSet.put(state.modules_to_be_copied, module),
modules_opts: Map.put(state.modules_opts, module, opts)
}

state =
if Cover.enabled_for?(module) do
Expand All @@ -484,6 +492,16 @@ defmodule Mimic.Server do
end
end

defp maybe_typecheck_func(module, fn_name, func) do
case module.__mimic_info__() do
{:ok, %{type_check: true}} ->
Mimic.TypeCheck.wrap(module, fn_name, func)

_ ->
func
end
end

defp marked_to_copy?(module, state) do
MapSet.member?(state.modules_to_be_copied, module)
end
Expand All @@ -501,7 +519,7 @@ defmodule Mimic.Server do
{:ok, state}

MapSet.member?(state.modules_to_be_copied, module) ->
case Mimic.Module.replace!(module) do
case Mimic.Module.replace!(module, state.modules_opts[module]) do
{beam_file, coverdata_path} ->
modules_beam = Map.put(state.modules_beam, module, {beam_file, coverdata_path})
{:ok, %{state | modules_beam: modules_beam}}
Expand Down
140 changes: 140 additions & 0 deletions lib/mimic/type_check.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
defmodule Mimic.TypeCheckError do
@moduledoc false
defexception [:mfa, :reasons]

@doc false
@impl Exception
def exception([mfa, reasons]), do: %__MODULE__{mfa: mfa, reasons: reasons}

@doc false
@impl Exception
def message(exception) do
{module, name, arity} = exception.mfa
mfa = Exception.format_mfa(module, name, arity)
"#{mfa}: #{Ham.TypeMatchError.message(exception)}"
end
end

defmodule Mimic.TypeCheck do
@moduledoc false

# Wrap an anoynomous function with type checking provided by Ham
@doc false
@spec wrap(module, atom, (... -> term)) :: (... -> term)
def wrap(module, fn_name, func) do
arity = :erlang.fun_info(func)[:arity]

behaviours =
module.module_info(:attributes)
|> Keyword.get_values(:behaviour)
|> List.flatten()

do_wrap(module, behaviours, fn_name, func, arity)
end

defp do_wrap(module, behaviours, fn_name, func, 0) do
fn ->
apply_and_check(module, behaviours, fn_name, func, [])
end
end

defp do_wrap(module, behaviours, fn_name, func, 1) do
fn arg1 ->
apply_and_check(module, behaviours, fn_name, func, [arg1])
end
end

defp do_wrap(module, behaviours, fn_name, func, 2) do
fn arg1, arg2 ->
apply_and_check(module, behaviours, fn_name, func, [arg1, arg2])
end
end

defp do_wrap(module, behaviours, fn_name, func, 3) do
fn arg1, arg2, arg3 ->
apply_and_check(module, behaviours, fn_name, func, [arg1, arg2, arg3])
end
end

defp do_wrap(module, behaviours, fn_name, func, 4) do
fn arg1, arg2, arg3, arg4 ->
apply_and_check(module, behaviours, fn_name, func, [arg1, arg2, arg3, arg4])
end
end

defp do_wrap(module, behaviours, fn_name, func, 5) do
fn arg1, arg2, arg3, arg4, arg5 ->
apply_and_check(module, behaviours, fn_name, func, [arg1, arg2, arg3, arg4, arg5])
end
end

defp do_wrap(module, behaviours, fn_name, func, 6) do
fn arg1, arg2, arg3, arg4, arg5, arg6 ->
apply_and_check(module, behaviours, fn_name, func, [arg1, arg2, arg3, arg4, arg5, arg6])
end
end

defp do_wrap(module, behaviours, fn_name, func, 7) do
fn arg1, arg2, arg3, arg4, arg5, arg6, arg7 ->
apply_and_check(module, behaviours, fn_name, func, [
arg1,
arg2,
arg3,
arg4,
arg5,
arg6,
arg7
])
end
end

defp do_wrap(module, behaviours, fn_name, func, 8) do
fn arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8 ->
apply_and_check(module, behaviours, fn_name, func, [
arg1,
arg2,
arg3,
arg4,
arg5,
arg6,
arg7,
arg8
])
end
end

defp do_wrap(module, behaviours, fn_name, func, 9) do
fn arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8, arg9 ->
apply_and_check(module, behaviours, fn_name, func, [
arg1,
arg2,
arg3,
arg4,
arg5,
arg6,
arg7,
arg8,
arg9
])
end
end

defp do_wrap(_module, _behaviours, _fn_name, _func, arity) when arity > 9 do
raise "Too many arguments!"
end

defp apply_and_check(module, behaviours, fn_name, func, args) do
return_value = Kernel.apply(func, args)

case Ham.validate(module, fn_name, args, return_value, behaviours: behaviours) do
:ok ->
:ok

{:error, error} ->
mfa = {module, fn_name, length(args)}
raise Mimic.TypeCheckError, [mfa, error.reasons]
end

return_value
end
end
3 changes: 2 additions & 1 deletion mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ defmodule Mimic.Mixfile do
use Mix.Project

@source_url "https://github.com/edgurgel/mimic"
@version "1.9.0"
@version "1.10.0"

def project do
[
Expand Down Expand Up @@ -32,6 +32,7 @@ defmodule Mimic.Mixfile do

defp deps do
[
{:ham, "~> 0.2"},
{:ex_doc, ">= 0.0.0", only: :dev, runtime: false},
{:credo, "~> 1.0", only: :dev}
]
Expand Down
1 change: 1 addition & 0 deletions mix.lock
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"earmark_parser": {:hex, :earmark_parser, "1.4.39", "424642f8335b05bb9eb611aa1564c148a8ee35c9c8a8bba6e129d51a3e3c6769", [:mix], [], "hexpm", "06553a88d1f1846da9ef066b87b57c6f605552cfbe40d20bd8d59cc6bde41944"},
"ex_doc": {:hex, :ex_doc, "0.34.1", "9751a0419bc15bc7580c73fde506b17b07f6402a1e5243be9e0f05a68c723368", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "d441f1a86a235f59088978eff870de2e815e290e44a8bd976fe5d64470a4c9d2"},
"file_system": {:hex, :file_system, "1.0.0", "b689cc7dcee665f774de94b5a832e578bd7963c8e637ef940cd44327db7de2cd", [:mix], [], "hexpm", "6752092d66aec5a10e662aefeed8ddb9531d79db0bc145bb8c40325ca1d8536d"},
"ham": {:hex, :ham, "0.2.0", "e9ca2bd88bfe56866bc6d772a89731036e05c97d806384609201384182bc74d5", [:mix], [], "hexpm", "24a01df506a955ba80a1c1230a1904571ccf6cd7c689341f23baf6c93934e01c"},
"jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"},
"makeup": {:hex, :makeup, "1.1.2", "9ba8837913bdf757787e71c1581c21f9d2455f4dd04cfca785c70bbfff1a76a3", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cce1566b81fbcbd21eca8ffe808f33b221f9eee2cbc7a1706fc3da9ff18e6cac"},
"makeup_elixir": {:hex, :makeup_elixir, "0.16.2", "627e84b8e8bf22e60a2579dad15067c755531fea049ae26ef1020cad58fe9578", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "41193978704763f6bbe6cc2758b84909e62984c7752b3784bd3c218bb341706b"},
Expand Down
Loading
Loading