Hi @peerreynders,
It seems that you solved my problem and identified the false dichotomy I felt I was making initially.
Also, you are right about the wrong type of the return value of Shortlist.put_in
. Thank you.
Meanwhile, I kept experimenting with mocking with protocols (after all, the idea was suggested by @josevalim in his famous blog post “Mocks and explicit contracts”. I did find some degree of happiness with the following solution. I defined an extra property in the struct which implement the Storage protocol in tests. This enables individual tests to specify the desired return value of the protocol functions.
Example:
# PRODUCTION CODE
defprotocol Shortlist do
def put_in(shortlist, item)
end
defmodule Logic do
def submit_item(item, config) do
# ...
Shortlist.put_in(config.shortlist, item)
# ...
end
end
# TESTS
defmodule Shortlist.Mock do
defstruct [:put_in_result] # canned result specified by each test
defimpl Shortlist do
def put_in(shortlist_mock, item) do
# mocking is about expectations, so this line enables message-based-expectations, see the test
send self(), {:put_in, shortlist_mock, item}
shortlist_mock.put_in_result
end
end
end
test "item not already listed" do
config = %{
shortlist: %Shortlist.Mock{
# test specific canned result
put_in_result: {:ok, %Shortlist.Mock{}
}
}
an_item = ...
Logic.submit(an_item, config)
assert_received {:put_in, ^config.shortlist, ^an_item}
end
This solution is:
- polymorphic
- mockable
-
safely testable with
async: true
- it’s fast, there isn’t even a Registry process like I believe there is under the hood in Mox
-
I don’t have to wire modules (behaviour implementation and client) with
config.exs
, this feels very good, no need forApplication.get_env(:app, :behaviour_name)
scattered in the codebase, I can just pass a config (Map|Struct) argument around - unlike @peerreynders’s solution, every implementation details is stuffed inside a single argument by leveraging structs/protocols
This is for the “degree of happiness” mentioned above.
Now I’m not totally happy with this solution. It feels more verbose / require a bit of ceremony:
- I don’t have an assistant like Mox:
-
I don’t have a fancy equivalent of
Mox.expect(module, fun_name, fn ... end)
but I can make a private helper inside the test module to make this concise, explicit and readable -
I don’t have automatic verifications a-la
verify_on_exit!
so I have to manually verify withassert_received
-
I don’t have a fancy equivalent of
-
I have to define a test protocol implementation for each protocol to mock, instead of the simpler
defmock MockModule, for: Behaviour
, this is not as bad as it may sound though, see code above. -
I now have a mix of behaviours and protocols. My tests end up with a bit of Protocol mocking and a bit of Behaviour mocking with Mox. For example:
ItemValidator
is just an algorithm so I should make it a behaviour and use Mox, while Shortlist is a protocol. I thought I could makeItemValidator
a protocol too, leading me to no behaviours, ultimately leading to ditching Mox. The issue I found immediately is that It forced me to create aValidator
struct with no properties, because it’s just an algorithm. That’s a signal that I’m certainly pushing too hard in this direction.
This last sentence reveals my overall impression with this entire thing. I am really tempted to surrender and follow @peerreynders’s approach. Though, in theory Protocols are right-er in this context. Ditching them in favor of the more convenient behaviours doesn’t feel right either. Convenience doesn’t feel like the right reason to use behaviours instead of protocols, and if that’s the main reason then maybe a bit more of tooling a-la-Mox would change the deal?
Thoughts @peerreynders & @josevalim ?