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