jjh42 / mock

Mocking library for Elixir language

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

assert_called with pattern matching

dipth opened this issue · 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 :)