defmodule GrpcMock do @moduledoc """ GrpcMock is library for easy gRPC server mocking to be used with [grpc-elixir library](https://github.com/tony612/grpc-elixir). ### Concurrency Unlike `mox`, GrpcMock is not thread-safe and cannot be used in concurrent tests. ## Example As an example, imagine that your application is using a remote calculator, with API defined in .proto file like this: service Calculator { rpc Add(AddRequest) returns (AddResponse); rpc Mult(MultRequest) returns (MultResponse); } If you want to mock the calculator gRPC calls during tests, the first step is to define the mock, usually in your `test_helper.exs`: GrpcMock.defmock(CalcMock, for: Calculator) Now in your tests, you can define expectations and verify them: use ExUnit.Case test "invokes add and mult" do # Start the gRPC server Server.start(CalcMock, 50_051) # Connect to the serrver {:ok, channel} = GRPC.Stub.connect("localhost:50051") CalcMock |> GrpcMock.expect(:add, fn req, _ -> AddResponse.new(sum: req.x + req.y) end) |> GrpcMock.expect(:mult, fn req, _ -> AddResponse.new(sum: req.x * req.y) end) request = AddRequest.new(x: 2, y: 3) assert {:ok, reply} = channel |> Stub.add(request) assert reply.sum == 5 request = MultRequest.new(x: 2, y: 3) assert {:ok, reply} = channel |> Stub.mult(request) assert reply.sum == 6 GrpcMock.verify!(CalcMock) end """ alias GrpcMock.Server defmodule UnexpectedCallError do defexception [:message] end defmodule VerificationError do defexception [:message] end @doc """ Define mock in runtime based on specificatin on pb.ex file ## Example GrpcMock.defmock(CalcMock, for: Calculator) """ def defmock(name, options) do service = case Keyword.fetch(options, :for) do {:ok, svc} -> svc :error -> raise ArgumentError, ":for option is required on defmock" end body = service.__rpc_calls__() |> generate_mocked_funs(name) Module.create(name, [header(service) | body], Macro.Env.location(__ENV__)) end defp generate_mocked_funs(rpc_calls, name) do for {fname_camel_atom, _, _} <- rpc_calls do fname_snake = camel2snake(fname_camel_atom) quote do def unquote(fname_snake)(request, stream) do GrpcMock.__dispatch__(unquote(name), unquote(fname_snake), [request, stream]) end end end end @doc """ Expect the `name` operation to be called `n` times. ## Examples If `code_or_value` is a function, it will be invoked as stub body. To expect `add` to be called five times: expect(MyMock, :add, 5, fn request, stream -> ... end) If `code_or_value` is anything other than a function, it will be stub return value. To expect `add` to be called once: expect(CalcMock, :add, AddResponse.new(sum: 12) end) `expect/4` can be invoked multiple times for the same `name`, allowing different behaviours on each invocation. """ def expect(mock, name, n \\ 1, code_or_value), do: do_expect(mock, name, n, code_or_value) @doc """ Simmilar to `expect/4` but there can be only one stubbed function. Number of expected invocations cannot be defined. ## Example If `code_or_value` is a function, it will be invoked as stub body. stub(CalcMock, :add, fn(request, _) -> ... end) If `code_or_value` is anything other than a function, it will be stub return value. stub(CalcMock, :add, AddResponse.new(sum: 12) end) """ def stub(mock, name, code_or_value), do: do_stub(mock, name, code_or_value) @doc """ Verify that all operations for the specified mock are called expected number of times and remove all expectations for it. """ def verify!(mock) do pending = Server.verify(mock) messages = for {fname, total, remaining} <- pending do mfa = Exception.format_mfa(mock, fname, 2) called = total - remaining " * expected #{mfa} to be invoked #{times(total)} but it was invoked #{times(called)}" end if messages != [] do raise VerificationError, "error while verifying calls for mock #{mock}:\n\n" <> Enum.join(messages, "\n") end :ok end defp header(service) do quote do use GRPC.Server, service: unquote(service) end end defp camel2snake(atom) do atom |> Atom.to_string() |> Macro.underscore() |> String.to_atom() end defp do_expect(mock, name, n, code) when is_function(code) do calls = List.duplicate(code, n) :ok = Server.add_expectation(mock, name, {n, calls, nil}) mock end defp do_expect(mock, name, n, resp) do code = fn _request, _stream -> resp end do_expect(mock, name, n, code) end defp do_stub(mock, name, code) when is_function(code) do :ok = Server.add_expectation(mock, name, {0, [], code}) mock end defp do_stub(mock, name, resp) do code = fn _request, _stream -> resp end do_stub(mock, name, code) end def __dispatch__(mock, fname, args) do mock |> Server.fetch_fun(fname) |> case do :no_expectation -> mfa = Exception.format_mfa(mock, fname, args) raise UnexpectedCallError, "no expectation defined for #{mfa}" {:out_of_expectations, count} -> mfa = Exception.format_mfa(mock, fname, args) raise UnexpectedCallError, "expected #{mfa} to be called #{times(count)} but it has been " <> "called #{times(count + 1)}" {:ok, fun_to_call} -> apply(fun_to_call, args) end end defp times(1), do: "once" defp times(n), do: "#{n} times" end