Skip to content

Conversation

brentjanderson
Copy link
Contributor

This introduces arg tracking for Mimic so that tests can examine args passed into mocks. This allows for more flexibility in writing certain types of tests, or in migrating from other mock libraries that offer the same affordances.

This introduces arg tracking for Mimic so that tests can examine args passed into mocks. This allows for more flexibility in writing certain types of tests, or in migrating from other mock libraries that offer the same affordances.
@whatyouhide
Copy link

@edgurgel what do you think about this? I just went through the issues seeing if someone proposed this cause I wanted to propose it myself, and just noticed the awesome Brent (who is a coworker of mine) had the same thought and did all the work 😄

@edgurgel
Copy link
Owner

Hey team I hate to be a party pooper but I don't feel this belongs inside Mimic. Mimic like Mox expects (no pun intended) that users rely on the anonymous functions to match/assert etc.

Most of what calls does could be achieved without a change to Mimic as you can see in the example below.

One could rely on this custom calls until they have migrated out of Mock/:meck and rely directly on the original Mimic.expect/4. If it's not easy to ascertain the order of the calls then an Agent can be used quite easily for each test.

Mix.install([{:mimic, "~> 1.0"}])

Application.ensure_all_started(:mimic)
# Don't do this at home! Copying an Elixir module for the sack of brevity

# Using an Agent but it could be an ETS table instead for better performance
{:ok, _pid} = Agent.start_link(fn -> %{} end, name: MimicCase)
Mimic.copy(URI)
ExUnit.start()

defmodule MimicCase do
  use ExUnit.CaseTemplate

  # Clean up after each test
  setup do
    caller = self()
    on_exit fn ->
      Agent.update(__MODULE__, fn state -> Map.delete(state, caller) end)
    end
    :ok
  end

  using do
    quote do
      import Mimic, except: [expect: 3]
      import MimicCase, only: [expect: 3, calls: 3]
    end
  end

  def expect(module, fn_name, func) do
    arity = Function.info(func)[:arity]
    arg_list = 1..arity |> Enum.map(&"arg#{&1}") |> Enum.join(", ")
    caller = self()

    fun_cmd =
      "fn(" <> arg_list <> ") -> MimicCase.store_call(caller, module, fn_name, arity, [" <> arg_list <> "]) ; func.(" <> arg_list <> ") end"

    {lambda, _} = Code.eval_string(fun_cmd, func: func, caller: caller, module: module, arity: arity, fn_name: fn_name)

    Mimic.expect(module, fn_name, lambda)
  end

  defp call_history(state, caller, module, fn_name, arity) do
    get_in(state, [Access.key(caller, %{}), {module, fn_name, arity}])
  end

  def store_call(caller, module, fn_name, arity, args) do
    Agent.update(__MODULE__, fn state ->
      call_history = call_history(state, caller, module, fn_name, arity) || []
      put_in(state,
      [Access.key(caller, %{}), {module, fn_name, arity}],
      [
        args | call_history
      ])
    end)
  end

  def calls(module, fn_name, arity) do
    caller = self()
    Agent.get(__MODULE__, fn state ->
      state
      |> get_in([caller, {module, fn_name, arity}])
      |> Enum.reverse()
    end)
  end
end

defmodule CalculatorTest do
  use MimicCase

  test "greets the world" do
    expect(URI, :decode_query, fn _, _, _ -> "decoded1" end)
    expect(URI, :decode_query, fn _, _, _ -> "decoded2" end)

    assert URI.decode_query("percent=oh+yes%21", %{}, :rfc3986) == "decoded1"
    assert URI.decode_query("q=example", %{}, :rfc3986) == "decoded2"

    assert calls(URI, :decode_query, 3) == [
              ["percent=oh+yes%21", %{}, :rfc3986],
              ["q=example", %{}, :rfc3986]
            ]
  end
end

@whatyouhide
Copy link

One could rely on this custom calls until they have migrated out of Mock/:meck

That's not really the point though. Sometimes, your use case is:

test "foo" do
  expect(MyMod, :my_fun, fn -> ... end)

  some_state_i_only_know_about_after_calling_some_code = call_some_code()
end

You cannot perform assertions on some_state_i_only_know_about_after_calling_some_code inside the expectations, so you'll have to send yourself a message or store the calls in an agent or something like that. For that, calls/3 would obviously help.

I do see the point in philosophically not wanting to do this though. I still think that stub + calls tends to simplify some categories of tests that otherwise have to do quite the dancing to assert on messages and whatnot—these tests become pretty easy assertions on lists with calls/3.

@edgurgel
Copy link
Owner

Right! It would be interesting to see a "real world" example as I personally haven't needed to reach out to Agents with mimic expectations. Do you mind sharing an example of a test?

@whatyouhide
Copy link

      stub(Events, :log, fn event, state ->
        # Apply a delay to the final event log action for the first batch step
        # to cause the second batch step to hit the timeout when attempting
        # to acquire a lock
        if some_condition() do
          Process.sleep(1500)
        end

        send(test_pid, {:log, event, state})

        :ok
      end)

      assert {:ok, %{id: workflow_run_id}} = call_under_test()

      assert_receive {:log, %Events.Evt1{attempt: 1}, %{id: ^workflow_run_id}}
      assert_receive {:log, %Events.Evt2{}, %{id: ^workflow_run_id}}

Very simplified example, but pretty much what I was talking about above.

@edgurgel
Copy link
Owner

edgurgel commented May 2, 2025

Ok I see what you mean now. Thanks for providing the example.

I think that's a fair addition to the library then 👍

I think we need to work on two things then:

  • I feel that the return of Mimic.calls/3 should be on the same order that they were called. I think they are reversed atm?
  • We probably want to clean up the call history so that we don't end up with a massive structure by the end of the test suite. On Mimic.Server we clean up stubs and expetations from a pid we should probably clean up the calls. Also on soft_reset I think?

Thanks @brentjanderson for starting this!

@whatyouhide
Copy link

Yes I agree that

  1. Returned calls should be in order
  2. We should "pop" the calls. The user can store them locally in the test if they need to then have all of them available.

@edgurgel
Copy link
Owner

edgurgel commented May 4, 2025

Why would we bother popping the calls? I think it's fine to just return them.

@whatyouhide
Copy link

@edgurgel I think it would be a slightly more intuitive API to "pop" the calls, because it's easy enough to do

calls1 = pop()
# ...
calls2 = pop()

calls = calls1 ++ calls2

but kind of nastier to do

calls1 = calls()

# ...

calls2 = calls() -- calls1 # ? doesn't necessarily work
calls2 = Enum.drop(calls(), length(calls1)) # meh?

@edgurgel
Copy link
Owner

edgurgel commented May 6, 2025

@whatyouhide oh I see your point, now!

For users that only want to get it once popping won't matter. For users that request more than once they can see the difference between those 2 moments. Fair enough 👍 Good point

@edgurgel edgurgel enabled auto-merge (squash) May 24, 2025 10:08
@edgurgel edgurgel merged commit c6a8cfb into edgurgel:main May 24, 2025
8 checks passed
@edgurgel
Copy link
Owner

Thanks, team! Should release a new version soon.

@whatyouhide
Copy link

@edgurgel looking forward to it! Let me know if I can help in any way, I’m really excited for this to be released 🙃

@edgurgel
Copy link
Owner

@whatyouhide @brentjanderson 1.12.0 is out! Thanks, team! 🎉

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants