From 79cfb90b07d145afc7394959c967753ad4b066c5 Mon Sep 17 00:00:00 2001 From: Riccardo Binetti Date: Sat, 4 Nov 2023 15:51:17 +0100 Subject: [PATCH] Add `Mox.deny/3` (#146) Allow denying a call to a mock with clearer intentions. Closes #145. --- lib/mox.ex | 38 +++++++++++++++++++++++++++----- test/mox_test.exs | 55 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 5 deletions(-) diff --git a/lib/mox.ex b/lib/mox.ex index 4e3e00b..712c136 100644 --- a/lib/mox.ex +++ b/lib/mox.ex @@ -507,7 +507,7 @@ defmodule Mox do expect(MockWeatherAPI, :get_temp, 5, fn _ -> {:ok, 30} end) - To expect `MockWeatherAPI.get_temp/1` not to be called: + To expect `MockWeatherAPI.get_temp/1` not to be called (see also `deny/3`): expect(MockWeatherAPI, :get_temp, 0, fn _ -> {:ok, 30} end) @@ -538,7 +538,31 @@ defmodule Mox do def expect(mock, name, n \\ 1, code) when is_atom(mock) and is_atom(name) and is_integer(n) and n >= 0 and is_function(code) do calls = List.duplicate(code, n) - add_expectation!(mock, name, code, {n, calls, nil}) + arity = arity(code) + add_expectation!(mock, name, arity, {n, calls, nil}) + mock + end + + @doc """ + Ensures that `name`/`arity` in `mock` is not invoked. + + When `deny/3` is invoked, any previously declared `stub` for the same `name` and arity will + be removed. This ensures that `deny` will fail if the function is called. If a `stub/3` is + invoked **after** `deny/3` for the same `name` and `arity`, the stub will be used instead, so + `deny` will have no effect. + + ## Examples + + To expect `MockWeatherAPI.get_temp/1` to never be called: + + deny(MockWeatherAPI, :get_temp, 1) + + """ + @doc since: "1.2.0" + @spec deny(mock, atom(), non_neg_integer()) :: mock when mock: t() + def deny(mock, name, arity) + when is_atom(mock) and is_atom(name) and is_integer(arity) and arity >= 0 do + add_expectation!(mock, name, arity, {0, [], nil}) mock end @@ -563,7 +587,8 @@ defmodule Mox do @spec stub(mock, atom(), function()) :: mock when mock: t() def stub(mock, name, code) when is_atom(mock) and is_atom(name) and is_function(code) do - add_expectation!(mock, name, code, {0, [], code}) + arity = arity(code) + add_expectation!(mock, name, arity, {0, [], code}) mock end @@ -631,9 +656,12 @@ defmodule Mox do |> List.flatten() end - defp add_expectation!(mock, name, code, value) do + defp arity(code) do + :erlang.fun_info(code)[:arity] + end + + defp add_expectation!(mock, name, arity, value) do validate_mock!(mock) - arity = :erlang.fun_info(code)[:arity] key = {mock, name, arity} unless function_exported?(mock, name, arity) do diff --git a/test/mox_test.exs b/test/mox_test.exs index 74bb68b..906f2bd 100644 --- a/test/mox_test.exs +++ b/test/mox_test.exs @@ -345,6 +345,61 @@ defmodule MoxTest do end end + describe "deny/3" do + test "allows asserting that function is not called" do + deny(CalcMock, :add, 2) + + msg = ~r"expected CalcMock.add/2 to be called 0 times but it has been called once" + + assert_raise Mox.UnexpectedCallError, msg, fn -> + CalcMock.add(2, 3) == 5 + end + end + + test "raises if a non-mock is given" do + assert_raise ArgumentError, ~r"could not load module Unknown", fn -> + deny(Unknown, :add, 2) + end + + assert_raise ArgumentError, ~r"module String is not a mock", fn -> + deny(String, :add, 2) + end + end + + test "raises if function is not in behaviour" do + assert_raise ArgumentError, ~r"unknown function oops/2 for mock CalcMock", fn -> + deny(CalcMock, :oops, 2) + end + + assert_raise ArgumentError, ~r"unknown function add/3 for mock CalcMock", fn -> + deny(CalcMock, :add, 3) + end + end + + test "raises even when a stub is defined" do + stub(CalcMock, :add, fn _, _ -> :stub end) + deny(CalcMock, :add, 2) + + assert_raise Mox.UnexpectedCallError, fn -> + CalcMock.add(2, 3) + end + end + + test "raises if you try to add expectations from non global process" do + set_mox_global() + + Task.async(fn -> + msg = + ~r"Only the process that set Mox to global can set expectations/stubs in global mode" + + assert_raise ArgumentError, msg, fn -> + deny(CalcMock, :add, 2) + end + end) + |> Task.await() + end + end + describe "verify!/0" do test "verifies all mocks for the current process in private mode" do set_mox_private()