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

Proposal: implementation of struct: option for defmock #91

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
52 changes: 51 additions & 1 deletion lib/mox.ex
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,14 @@ defmodule Mox do
Mox.defmock(MyMock, for: MyBehaviour, moduledoc: false)
Mox.defmock(MyMock, for: MyBehaviour, moduledoc: "My mock module.")

## Cloning structs

If you would like to clone the struct from an implementation into your mock,
you may do so with the `:struct` option. There may be only one such option,
and the corresponding module MUST implement all of the behaviours being
mocked.

Mox.defmock(MyMock, for: MyStructModule, struct: SomeImplementation)
"""
def defmock(name, options) when is_atom(name) and is_list(options) do
behaviours =
Expand All @@ -315,10 +323,11 @@ defmodule Mox do

doc_header = generate_doc_header(moduledoc)
compile_header = generate_compile_time_dependency(behaviours)
cloned_struct = generate_struct(behaviours, options)
callbacks_to_skip = validate_skip_optional_callbacks!(behaviours, skip_optional_callbacks)
mock_funs = generate_mock_funs(behaviours, callbacks_to_skip)

define_mock_module(name, behaviours, doc_header ++ compile_header ++ mock_funs)
define_mock_module(name, behaviours, doc_header ++ compile_header ++ cloned_struct ++ mock_funs)

name
end
Expand Down Expand Up @@ -356,6 +365,47 @@ defmodule Mox do
end
end

defp generate_struct(behaviours, options) do
struct_source = options[:struct]
case struct_source do
nil -> []
s when not(is_atom(s)) ->
raise ArgumentError, "the :struct option must be a module"
_ ->
clone_struct(behaviours, struct_source)
end
end

defp clone_struct(behaviours, struct_source) do
unless function_exported?(struct_source, :module_info, 0) do
raise ArgumentError, "the :struct option must be a module"
end
source_behaviours = struct_source.module_info()
|> Keyword.get(:attributes)
|> Enum.flat_map(fn
{:behaviour, mod} -> mod
_ -> []
end)
unless Enum.all?(behaviours, &(&1 in source_behaviours)) do
missing_behaviours = Enum.join(behaviours -- source_behaviours, " ")
raise ArgumentError,
"the :struct module must implement all behaviours: #{missing_behaviours}" <>
" was not implemented."
end
unless function_exported?(struct_source, :__struct__, 0) do
raise ArgumentError, "the :struct module must define a struct."
end

struct_data = struct_source.__struct__
|> Map.from_struct
|> Enum.to_list
|> Macro.escape

[quote do
defstruct unquote(struct_data)
end]
end

defp generate_mock_funs(behaviours, callbacks_to_skip) do
for behaviour <- behaviours,
{fun, arity} <- behaviour.behaviour_info(:callbacks),
Expand Down
36 changes: 36 additions & 0 deletions test/mox_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,42 @@ defmodule MoxTest do
end
end

test "can clone structs" do
assert %{ans: 0} = %MyStructMock1{}
assert %{ans: 0} = %MyStructMock2{}
end

test "cloned struct must be from a valid module" do
expected_error = "the :struct option must be a module"

assert_raise ArgumentError, expected_error, fn ->
defmock(MyMock, for: Calculator, struct: "foo")
end

assert_raise ArgumentError, expected_error, fn ->
defmock(MyMock, for: Calculator, struct: NotAModule)
end
end

test "cloned struct must have an associated struct" do
Code.ensure_loaded(CalculatorNoStruct)
expected_error = "the :struct module must define a struct."

assert_raise ArgumentError, expected_error, fn ->
defmock(MyMock, for: Calculator, struct: CalculatorNoStruct)
end
end

test "struct source module must implement all behaviours" do
Code.ensure_loaded(CalculatorStructOneBehaviour)
expected_error = "the :struct module must implement all behaviours: Elixir.ScientificCalculator was not implemented."

assert_raise ArgumentError, expected_error, fn ->
defmock(MyMock, for: [Calculator, ScientificCalculator],
struct: CalculatorStructOneBehaviour)
end
end

@tag :requires_code_fetch_docs
test "uses false for when moduledoc is not given" do
assert {:docs_v1, _, :elixir, "text/markdown", :hidden, _, _} =
Expand Down
3 changes: 3 additions & 0 deletions test/support/mocks.ex
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,6 @@ Mox.defmock(SciCalcMock, for: [Calculator, ScientificCalculator])
Mox.defmock(MyMockWithoutModuledoc, for: Calculator)
Mox.defmock(MyMockWithFalseModuledoc, for: Calculator, moduledoc: false)
Mox.defmock(MyMockWithStringModuledoc, for: Calculator, moduledoc: "hello world")

Mox.defmock(MyStructMock1, for: [Calculator], struct: CalculatorWithStruct)
Mox.defmock(MyStructMock2, for: [Calculator, ScientificCalculator], struct: CalculatorWithStruct)
22 changes: 22 additions & 0 deletions test/support/structs.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
defmodule CalculatorWithStruct do
@behaviour Calculator
@behaviour ScientificCalculator
def add(a, b), do: a + b
def mult(a, b), do: a * b
def exponent(a, b), do: a - b
def sin(a), do: a * 1.0
defstruct [ans: 0]
end

defmodule CalculatorStructOneBehaviour do
@behaviour Calculator
def add(a, b), do: a + b
def mult(a, b), do: a * b
defstruct [ans: 0]
end

defmodule CalculatorNoStruct do
@behaviour Calculator
def add(a, b), do: a + b
def mult(a, b), do: a * b
end