assert_called with pattern matching
dipth opened this issue · 10 comments
Imagine that I want to assert that some method got called using an Ecto struct as the argument.
Currently I would do that with (here an example from a Phoenix controller test):
test_with_mock "it works", %{conn: conn, user: user},
get conn, "/"
assert_called VisitorLog.track!(user)
end
If the user I pre-built for the test does not have the same associations loaded as the user that will be loaded from the database in the actual controller, then the assertion will fail.
But from the perspective of the test, I don't really care about those details. I just want to test that the method was called, and that a User with a given id was passed as the argument.
So is there some way to make call assertions where you only match against certain aspects of the arguments?
For instance:
test_with_mock "it works", %{conn: conn, user: user},
get conn, "/"
assert_called VisitorLog.track!(%User{id: user.id})
end
In Ruby rspec you could use something like the hash-including matcher:
https://rspec.info/documentation/3.9/rspec-mocks/RSpec/Mocks/ArgumentMatchers.html#hash_including-instance_method
I wrote the following test and it passed for me:
test "assert_called partial" do
with_mock Map,
[get: fn(m, k) -> m[k] end] do
Map.get(%{"a" => 1, "b" => 2}, "a")
assert_called(Map.get(%{"a" => 1}, "a"))
Map.get(%{"a" => 1}, "a")
assert_called(Map.get(%{"a" => 1, "b" => 2}, "a"))
end
end
Would this not support your use case?
test "test mock" do
with_mock Helheim.CommentService, [foo!: fn(user) -> {:ok, user} end] do
user = insert(:user)
insert(:blog_post, user: user)
User.foo_test(user.id)
assert_called(Helheim.CommentService.foo!(%User{id: user.id}))
end
end
1) test test mock (Helheim.UserTest)
test/helheim/user_test.exs:617
Expected call but did not receive it. Calls which were received:
0. Elixir.Helheim.CommentService.foo!(%Helheim.User{partnership_status_custom: nil, comments: #Ecto.Association.NotLoaded<association :comments is not loaded>, gender: nil, verified_at: nil, confirmed_at: ~U[2019-11-03 21:50:39.232562Z], captcha: nil, blog_posts: [%Helheim.BlogPost{__meta__: #Ecto.Schema.Metadata<:loaded, "blog_posts">, body: "My Aweseome Text", comment_count: 0, comments: #Ecto.Association.NotLoaded<association :comments is not loaded>, hide_comments: false, id: 3, inserted_at: ~U[2019-11-03 21:50:39.264184Z], published: true, published_at: ~U[2019-11-03 21:50:39.262226Z], title: "My Awesome Title", updated_at: ~U[2019-11-03 21:50:39.264184Z], user: #Ecto.Association.NotLoaded<association :user is not loaded>, user_id: 4, visibility: "public", visitor_count: 0, visitor_log_entries: #Ecto.Association.NotLoaded<association :visitor_log_entries is not loaded>}], email: "foo-0@bar.com", partnership_status: nil, confirmation_token: "ZUhtaHI4R29mZVdYSjdVRUNvMWhzZz09", previous_login_at: nil, birthday: nil, forum_replies: #Ecto.Association.NotLoaded<association :forum_replies is not loaded>, donations: #Ecto.Association.NotLoaded<association :donations is not loaded>, profile_text: "Foo Bar", last_login_ip: nil, role: nil, ban_reason: nil, inserted_at: ~U[2019-11-03 21:50:39.256213Z], __meta__: #Ecto.Schema.Metadata<:loaded, "users">, last_donation_at: ~U[2019-10-04 21:50:39.236015Z], total_donated: 1000, received_private_messages: #Ecto.Association.NotLoaded<association :received_private_messages is not loaded>, banned_until: nil, session_id: nil, name: "Some User", password_hash: "$2b$12$Zc2AexPqKCRDCybgOJ28dOBJSGOd70xYSdyS2fb/vZbRQ/dhAnCym", avatar: nil, calendar_events: #Ecto.Association.NotLoaded<association :calendar_events is not loaded>, forum_topics: #Ecto.Association.NotLoaded<association :forum_topics is not loaded>, verifier: #Ecto.Association.NotLoaded<association :verifier is not loaded>, sent_private_messages: #Ecto.Association.NotLoaded<association :sent_private_messages is not loaded>, location: nil, verifier_id: nil, gender_custom: nil, previous_login_ip: nil, comment_count: 0, last_login_at: nil, updated_at: ~U[2019-11-03 21:50:39.256213Z], existing_password: nil, max_total_file_size: 26214400, username: "foobar-0", visitor_count: 0, authored_comments: #Ecto.Association.NotLoaded<association :authored_comments is not loaded>, password_confirmation: nil, notification_sound: nil, password_reset_token_updated_at: nil, mute_notifications: nil, password: nil, visitor_log_entries: #Ecto.Association.NotLoaded<association :visitor_log_entries is not loaded>, ...}) (returned {:ok, %Helheim.User{partnership_status_custom: nil, comments: #Ecto.Association.NotLoaded<association :comments is not loaded>, gender: nil, verified_at: nil, confirmed_at: ~U[2019-11-03 21:50:39.232562Z], captcha: nil, blog_posts: [%Helheim.BlogPost{__meta__: #Ecto.Schema.Metadata<:loaded, "blog_posts">, body: "My Aweseome Text", comment_count: 0, comments: #Ecto.Association.NotLoaded<association :comments is not loaded>, hide_comments: false, id: 3, inserted_at: ~U[2019-11-03 21:50:39.264184Z], published: true, published_at: ~U[2019-11-03 21:50:39.262226Z], title: "My Awesome Title", updated_at: ~U[2019-11-03 21:50:39.264184Z], user: #Ecto.Association.NotLoaded<association :user is not loaded>, user_id: 4, visibility: "public", visitor_count: 0, visitor_log_entries: #Ecto.Association.NotLoaded<association :visitor_log_entries is not loaded>}], email: "foo-0@bar.com", partnership_status: nil, confirmation_token: "ZUhtaHI4R29mZVdYSjdVRUNvMWhzZz09", previous_login_at: nil, birthday: nil, forum_replies: #Ecto.Association.NotLoaded<association :forum_replies is not loaded>, donations: #Ecto.Association.NotLoaded<association :donations is not loaded>, profile_text: "Foo Bar", last_login_ip: nil, role: nil, ban_reason: nil, inserted_at: ~U[2019-11-03 21:50:39.256213Z], __meta__: #Ecto.Schema.Metadata<:loaded, "users">, last_donation_at: ~U[2019-10-04 21:50:39.236015Z], total_donated: 1000, received_private_messages: #Ecto.Association.NotLoaded<association :received_private_messages is not loaded>, banned_until: nil, session_id: nil, name: "Some User", password_hash: "$2b$12$Zc2AexPqKCRDCybgOJ28dOBJSGOd70xYSdyS2fb/vZbRQ/dhAnCym", avatar: nil, calendar_events: #Ecto.Association.NotLoaded<association :calendar_events is not loaded>, forum_topics: #Ecto.Association.NotLoaded<association :forum_topics is not loaded>, verifier: #Ecto.Association.NotLoaded<association :verifier is not loaded>, sent_private_messages: #Ecto.Association.NotLoaded<association :sent_private_messages is not loaded>, location: nil, verifier_id: nil, gender_custom: nil, previous_login_ip: nil, comment_count: 0, last_login_at: nil, updated_at: ~U[2019-11-03 21:50:39.256213Z], existing_password: nil, max_total_file_size: 26214400, username: "foobar-0", visitor_count: 0, authored_comments: #Ecto.Association.NotLoaded<association :authored_comments is not loaded>, password_confirmation: nil, notification_sound: nil, password_reset_token_updated_at: nil, mute_notifications: nil, ...}})
code: assert_called(Helheim.CommentService.foo!(%User{id: user.id}))
stacktrace:
test/helheim/user_test.exs:622: (test)
To me it appears like I need to provide the full User struct precisely how it looked at the time when the method was called for the assert_called
assertion to pass.
I ended up doing a bit of a "hack" to support this use case, by adding a new support module:
defmodule Helheim.AssertCalledPatternMatching do
use ExUnit.CaseTemplate
using do
quote do
defp assert_called_with_pattern(entity, method, matcher) do
found = :meck.history(entity)
|> Enum.filter(fn({_, {_, m, _}, _}) -> m == method end)
|> Enum.any?(fn({_, {_, _, args}, _result}) ->
try do
matcher.(args)
true
rescue
MatchError -> false
end
end)
case found do
true -> true
false ->
calls = received_calls(entity)
raise ExUnit.AssertionError,
message: "Expected call but did not receive it. Calls which were received:\n\n#{calls}"
end
end
defp refute_called_with_pattern(entity, method, matcher) do
assert_raise ExUnit.AssertionError, ~r/Expected call but did not receive it/, fn ->
assert_called_with_pattern(entity, method, matcher)
end
end
defp received_calls(entity) do
entity
|> :meck.history()
|> Enum.with_index()
|> Enum.map(fn {{_, {m, f, a}, ret}, i} ->
"#{i}. #{m}.#{f}(#{a |> Enum.map(&Kernel.inspect/1) |> Enum.join(",")}) (returned #{inspect ret})"
end)
|> Enum.join("\n")
end
end
end
end
This then allows me to do the following:
describe "delete/2 when signed in" do
setup [:create_and_sign_in_user]
test_with_mock "it deletes the users account, signs him out and redirects to the landing page", %{conn: conn, user: user},
User, [:passthrough], [delete!: fn(_user) -> {:ok} end] do
conn = delete conn, "/account"
assert_called_with_pattern User, :delete!, fn(args) ->
user_id = user.id
[%User{id: ^user_id}] = args
end
assert redirected_to(conn) == page_path(conn, :index)
refute Guardian.Plug.current_resource(conn)
end
end
As I'm still fairly new to Elixir, I have no idea if this is good or bad use of pattern matching, but it seems to be working :)
Great investigation, I learnt a lot from your code!
I was able to reproduce the problem (exactly as you described) with the following sample code:
defmodule Foo.Bar.Struct do
defstruct foo: "foo", bar: "bar"
end
defmodule Foo.Bar.Processor do
def process_foo_bar(foo_bar), do: IO.inspect(foo_bar)
end
test "assert_called partial foo bar" do
with_mock Foo.Bar.Processor,
[process_foo_bar: fn(foo_bar) -> :ok end] do
foo_bar1 = %Foo.Bar.Struct{foo: "foo1", bar: "bar1"}
Foo.Bar.Processor.process_foo_bar(foo_bar1)
assert_called(Foo.Bar.Processor.process_foo_bar(%Foo.Bar.Struct{foo: "foo1"}))
end
end
The test above fails because we're not matching the whole strut, and all of its types, which is what you're describing.
I thought of this kind of hacky solution after seeing the context passed in to your tests:
setup do
{:ok, %{foo: "foo1"}}
end
test "assert_called partial foo bar 2", %{foo: foo} do
with_mock Foo.Bar.Processor,
[process_foo_bar: fn(foo_bar) -> assert foo_bar.foo == foo end] do
foo_bar1 = %Foo.Bar.Struct{foo: "foo1", bar: "bar1"}
Foo.Bar.Processor.process_foo_bar(foo_bar1)
end
Not how we simply do an assertion inside the mock function.
While it is not as elegant because it's not part of the lib, it's very short and straightforward. What do you think?
Ah yes, I see, that would of course also be a solution :)
I've run up against a similar issue and found a bit more elegant solution. In my case, I wanted to assert that a call argument matched a regex, but it should work for your use case as well.
Mock is a relatively light wrapper over :meck
's extensive functionality. You can simply provide a custom matcher using :meck.is/1
when calling assert_called
, like so:
test_with_mock "assert_called partial match", %{conn: conn, user: user} do
get conn, "/"
assert_called VisitorLog.track!(
:meck.is(fn u ->
assert u.__struct__ == User
assert u.id == user.id
true
end)
)
end
The only caveat is that your matcher predicate must return true, otherwise assert_called
will fail.
Hope this helps!
@superhawk610 I like that solution! :)
@superhawk610 That's a great solution! Do you mind submitting a PR in the README with this example?
I just added a table of contents so it'll be easier to provide various examples like these.
@Olshansk done! Feel free to review/edit as you see necessary :)
Merged! I think it'll be very helpful to other developers :)