lib/mock_grpc.ex

defmodule MockGRPC do
  @moduledoc """
  MockGRPC is a library for defining concurrent client mocks for [gRPC Elixir](https://github.com/elixir-grpc/grpc).

  It works by implementing a [client adapter](https://hexdocs.pm/grpc/GRPC.Client.Adapter.html)
  that intercepts requests using the mocks you defined, and performing expectations on them.

  ### Usage

  Imagine that you have a module calling a `say_hello` RPC.

      defmodule Demo do
        def say_hello(name) do
          {:ok, channel} = GRPC.Stub.connect("localhost:50051")
          GreetService.Stub.say_hello(channel, %SayHelloRequest{name: "John Doe"})
        end
      end

  The first step is to change the `connect` code to use an adapter coming from the app environment, so that
  you can use `MockGRPC` in test mode, and the default adapter in dev and production.

      {:ok, channel} =
        GRPC.Stub.connect(
          "localhost:50051",
          adapter: Application.get_env(:demo, :grpc_adapter)
        )

  Or if you're using [`ConnGRPC`](https://github.com/TheRealReal/conn_grpc), add `adapter` to the channel `opts`.

  Then, on your `config/test.exs`, set it to `MockGRPC.Adapter`:

      Application.put_env(:demo, :grpc_adapter, MockGRPC.Adapter)

  Now it's time to write your test. To enable mocks, add `use MockGRPC` to your test, and call
  `MockGRPC.expect/2` or `MockGRPC.expect/3` to set expectations.

      defmodule DemoTest do
        use ExUnit.Case, async: true

        use MockGRPC

        test "say_hello/1" do
          MockGRPC.expect(&GreetService.Stub.say_hello/2, fn req ->
            assert %SayHelloRequest{name: "John Doe"} == req
            {:ok, %SayHelloResponse{message: "Hello John Doe"}}
          end)

          assert {:ok, %SayHelloResponse{message: "Hello John Doe"}} = Demo.say_hello("John Doe")
        end
      end

  All expectations are defined based on the current process. This means that if you call gRPC from
  a separate process, and this process is not a `Task`*, it won't have access to the expectations
  by default. But there are ways to overcome that. See the "Multi-process collaboration" section.

  *`Task` is supported automatically with no extra code needed due to its native
  [caller tracking](https://hexdocs.pm/elixir/1.15.0/Task.html#module-ancestor-and-caller-tracking).

  ## Multi-process collaboration

  MockGRPC supports multi-process collaboration via two mechanisms:

    1. manually set context
    2. global mode

  ### Manually set context

  In order for other processes to have access to your mocks, you can call `set_context/1`
  on the external process passing the PID of the test.

  ### Global mode

  To support global mode, set your test `async` option to `false`. However, this won't allow
  your test file to execute in parallel with other tests.
  """

  require Logger

  @doc false
  def setup(context) do
    if Process.get(MockGRPC) do
      Logger.warning("Attempted to set up MockGRPC twice. Skipping...")
    else
      do_setup(context)
    end
  end

  defp do_setup(context) do
    test_key = if context.async, do: self(), else: :global
    Process.put(MockGRPC, test_key)

    start_server(test_key)

    # `on_exit` runs in a different process than the test, with its own process
    # dictionary, so we need to manually pass `test_key` over to the function
    # calls inside it.
    ExUnit.Callbacks.on_exit(fn ->
      try do
        verify!(test_key)
      after
        stop_server(test_key)
      end
    end)

    :ok
  end

  @doc """
  Makes `GRPC.Stub.connect/2` return successfully, reverting the `down/0` call.
  """
  def up do
    test_key = Process.get(MockGRPC)
    MockGRPC.Server.up(test_key)
  end

  @doc """
  Makes `GRPC.Stub.connect/2` return an error tuple, simulating that the server is down.

  If you're using `ConnGRPC`, you will need additional code to make it work.
  See [ConnGRPC - Simulating unavailable channel](guides/conn_grpc.md#simulating-unavailable-channel)
  """
  def down do
    test_key = Process.get(MockGRPC)
    MockGRPC.Server.down(test_key)
  end

  @doc """
  Adds an expectation using a gRPC service function capture.

  Example:

      MockGRPC.expect(&GreetService.Stub.say_hello/2, fn req ->
        assert %SayHelloRequest{name: "John Doe"} == req
        {:ok, %SayHelloResponse{message: "Hello John Doe"}}
      end)

      assert {:ok, %SayHelloResponse{message: "Hello John Doe"}} = Demo.say_hello("John Doe")
  """
  def expect(grpc_fun, mock_fun) when is_function(grpc_fun) and is_function(mock_fun) do
    case MockGRPC.Util.extract_grpc_fun(grpc_fun) do
      %{service_module: service_module, fun_name: fun_name} ->
        expect(service_module, fun_name, mock_fun)

      _ ->
        raise """
        Invalid function passed to `MockGRPC.expect/2`.
        Expected a stub function capture, e.g.: `&MyService.Stub.fun/2`. Received #{inspect(grpc_fun)}.
        """
    end
  end

  @doc """
  Adds an expectation using the gRPC service module and function name.

  Example:

      MockGRPC.expect(GreetService.Service, :say_hello, fn req ->
        assert %SayHelloRequest{name: "John Doe"} == req
        {:ok, %SayHelloResponse{message: "Hello John Doe"}}
      end)

      assert {:ok, %SayHelloResponse{message: "Hello John Doe"}} = Demo.say_hello("John Doe")
  """
  def expect(service_module, fun_name, mock_fun)
      when is_atom(service_module) and is_atom(fun_name) and is_function(mock_fun) do
    Code.ensure_loaded(service_module)

    unless function_exported?(service_module, :__meta__, 1) do
      raise """
      Invalid service module passed to `MockGRPC.expect/3`.
      Expected a module that uses `GRPC.Service`.
      """
    end

    test_key = Process.get(MockGRPC)

    if test_key == nil do
      raise """
      MockGRPC.expect called without MockGRPC being set up.
      Please make sure you've added `use MockGRPC` to your test file.
      """
    end

    MockGRPC.Server.expect(test_key, service_module, fun_name, mock_fun)
  end

  @doc false
  def start_server(test_key) do
    DynamicSupervisor.start_child(
      MockGRPC.DynamicSupervisor,
      {MockGRPC.Server, test_key}
    )
  end

  @doc false
  def stop_server(test_key) do
    [{pid, _}] = Registry.lookup(MockGRPC.Registry, test_key)
    DynamicSupervisor.terminate_child(MockGRPC.DynamicSupervisor, pid)
  end

  @doc false
  def verify!(test_key) do
    remaining_expectations = MockGRPC.Server.get_expectations(test_key)

    if remaining_expectations != [] do
      formatted_failures =
        Enum.map(remaining_expectations, fn %{service_module: mod, fun_name: fun} ->
          service_name = mod.__meta__(:name)
          "Expected to receive gRPC call to `#{service_name}/#{fun}` but didn't"
        end)

      raise Enum.join(formatted_failures, "\n")
    end
  end

  @doc """
  Set mock context, in case the mock is being called from another process in an async test.

  This is not needed for `Task` processes.

  Example:

      test "calling a mock from a different process" do
        parent = self()

        MockGRPC.expect(&GreetService.Stub.say_hello/2, fn req ->
          assert %SayHelloRequest{name: "John Doe"} == req
          {:ok, %SayHelloResponse{message: "Hello John Doe"}}
        end)

        # This is just an example to demonstrate the concept. In a real world scenario, you'd
        # be better off using the `Task` module, which doesn't require calling `set_context`
        spawn(fn ->
          # Ensure this process has access to the mocks
          MockGRPC.set_context(parent)

          {:ok, channel} = GRPC.Stub.connect("localhost:50051", adapter: Application.get_env(:demo, :grpc_adapter))
          response = GreetService.Stub.say_hello(channel, %SayHelloRequest{name: "John Doe"})

          # Do the assertion outside the process, to avoid a race condition where the test
          # finishes before this process completes execution.
          send(parent, {:my_process_result, response})
        end)

        assert_receive {:my_process_result, %SayHelloResponse{message: "Hello John Doe"}}
      end
  """
  def set_context(test_pid) do
    Process.put(MockGRPC, MockGRPC.Util.get_test_key(test_pid))
  end

  defmacro __using__(opts \\ []) do
    quote do
      if Keyword.get(unquote(opts), :setup, true) do
        setup context do
          MockGRPC.setup(context)
        end
      end
    end
  end
end