ProtoPharm

Stub your gRPCs with lab-grown proto objects. Life is better on the pharm.

Built on a great foundation by @ganmacs at ganmacs/grpc_mock.

Installation

Add this line to your application's Gemfile in the development/test group:

gem 'proto_pharm'

And then execute:

$ bundle

Or install it yourself as:

$ gem install proto_pharm

Let's go Pharming!

Before we dive into the code - all examples below will refer to a gRPC service called hello.hello with (among others) an rpc endpoint Hello which receives the proto hello.HelloRequest and responds with the proto hello.HelloResponse.

The local variable client is defined as follows:

client = Hello::Hello::Stub.new('localhost:8000', :this_channel_is_insecure)

See full definition of protocol buffers and gRPC generated code in spec/examples/hello.

RSpec Usage

To take full advantage of the helpers listed here, make sure to add the following to your spec_helper.rb or rails_helper.rb:

require 'proto_pharm/rspec'

Stubbing service responses

For the simplest use case, stub a response value for a given rpc endpoint like so:

allow_grpc_service(Hello::Hello)
  .to receive_rpc(:hello)
  .and_return(msg: 'Hello!')

client.hello(Hello::HelloRequest.new(msg: 'Hello?')) # => <Hello::HelloResponse: msg: "Hello!">

To stub a response for a specific request received:

allow_grpc_service(Hello::Hello)
  .to receive_rpc(:hello)
  .with(msg: 'Hola?')
  .and_return(msg: 'Bienvenidos!')

client.hello(Hello::HelloRequest.new(msg: 'Hello?')) # => Sent to network
client.hello(Hello::HelloRequest.new(msg: 'Hola?')) # => <Hello::HelloResponse: msg: "Bienvenidos!">

Or, stub the response with a block to perform some additional business logic:
(Note - the allow_grpc_service syntax currently does not support block-stubbing)

stub_grpc_action(Hello::Hello::Service, :Hello).to_return do |request|
  { msg: request.msg.reverse }
end

client.hello(Hello::HelloRequest.new(msg: 'Hello?')) # => <Hello::HelloResponse: msg: "?olleH">

Stub a failure response:

allow_grpc_service(Hello::Hello)
  .to receive_rpc(:hello)
  .and_fail_with(:not_found, "No one's here...")

client.hello(Hello::HelloRequest.new(msg: 'Hello?')) # => <GRPC::NotFound: 5:No one's here...>

Stub failure metadata:

allow_grpc_service(Hello::Hello)
  .to receive_rpc(:hello)
  .and_fail_with(:not_found, metadata: { people_here: :none })

begin
  client.hello(Hello::HelloRequest.new(msg: 'Hello?'))
rescue => e
  e  # => <GRPC::NotFound: 5:>
  e. # => {:people_here=>"none"}
end

Note here that the "none" value is a string - all metadata values will be cast as strings on response to emulate actual gRPC behavior.

Or, if you just want the call to fail and don't care about the failure type, it defaults to :invalid_argument:

allow_grpc_service(Hello::Hello).to receive_rpc(:hello).and_fail
client.hello(Hello::HelloRequest.new(msg: 'Hello?')) # => <GRPC::InvalidArgument: 3:>

# Or with some metadata...

allow_grpc_service(Hello::Hello).to receive_rpc(:hello).and_fail_with(metadata: { some: :meta_here })
client.hello(Hello::HelloRequest.new(msg: 'Hello?')) # => You get the picture

Asserting RPC reception

ProtoPharm also adds a matcher to assert rpc reception. For example:

allow_grpc_service(Hello::Hello)
  .to receive_rpc(:hello)
  .and_return(msg: 'Hello!')

client.hello(Hello::HelloRequest.new(msg: 'Hello?'))
expect(Hello::Hello).to have_received_rpc(:hello)

You can also assert the arguments received:

expect(Hello::Hello).to have_received_rpc(:hello).with(msg: 'Hello?')

Argument Flexibility

You may have noticed that the above examples stub proto objects without specifying the proto type (for example, .and_return(msg: 'Hello!')). No Hello::HelloRequest.news in sight! Both with and and_return will happily accept protos, hashes or keyword args. If you pass an invalid key to a stub method, you'll get an error:

allow_grpc_service(Hello::Hello).to receive_rpc(:hello).and_return(message: "Is this thing on?")
# => ArgumentError: Unknown field name 'message' in initialization map entry.

Happy stubbing!

Usage for Minitest etc.

Stubbed request based on path and with the default response

ProtoPharm.stub_request("/hello.hello/Hello").to_return(Hello::HelloResponse.new(msg: 'test'))

client.hello(Hello::HelloRequest.new(msg: 'hi')) # => Hello::HelloResponse.new(msg: 'test')

Stubbing requests based on path and request

ProtoPharm.stub_request("/hello.hello/Hello").with(Hello::HelloRequest.new(msg: 'hi')).to_return(Hello::HelloResponse.new(msg: 'test'))

client.hello(Hello::HelloRequest.new(msg: 'hello')) # => send a request to server
client.hello(Hello::HelloRequest.new(msg: 'hi'))    # => Hello::HelloResponse.new(msg: 'test') (without any requests to server)

Stubbing per-action requests based on parametrized request

ProtoPharm.stub_grpc_action(Hello::Hello::Service, :Hello).with(msg: 'hi').to_return(msg: 'test')

client.hello(Hello::HelloRequest.new(msg: 'hello')) # => send a request to server
client.hello(Hello::HelloRequest.new(msg: 'hi'))    # => Hello::HelloResponse.new(msg: 'test') (without any requests to server)

Stubbing with a block

ProtoPharm.stub_grpc_action(Hello::Hello::Service, :Hello).to_return do |request|
  { msg: request.msg.reverse }
end

client.hello(Hello::HelloRequest.new(msg: 'Hello?')) # => <Hello::HelloResponse: msg: "?olleH">

You can use either proto objects or hash for stubbing requests

ProtoPharm.stub_grpc_action(Hello::Hello::Service, :Hello).with(Hello::HelloRequest.new(msg: 'hi')).to_return(msg: 'test')
# or
ProtoPharm.stub_grpc_action(Hello::Hello::Service, :Hello).with(msg: 'hi').to_return(Hello::HelloResponse.new(msg: 'test'))

client.hello(Hello::HelloRequest.new(msg: 'hello')) # => send a request to server
client.hello(Hello::HelloRequest.new(msg: 'hi'))    # => Hello::HelloResponse.new(msg: 'test') (without any requests to server)

Real requests to network can be allowed or disabled

client = Hello::Hello::Stub.new('localhost:8000', :this_channel_is_insecure)

ProtoPharm.disable_net_connect!
client.hello(Hello::HelloRequest.new(msg: 'hello')) # => Raise NetConnectNotAllowedError error

ProtoPharm.allow_net_connect!
Hello::Hello::Stub.new('localhost:8000', :this_channel_is_insecure) # => send a request to server

Stubbing Failures

Specific gRPC failure codes can be stubbed with metadata

ProtoPharm.
  stub_grpc_action(Hello::Hello::Service, :Hello).
    with(Hello::HelloRequest.new(msg: 'hi')).
    to_fail_with(:invalid_argument, "This message is optional", metadata: { put: :your, metadata: :here })

begin 
  client.hello(Hello::HelloRequest.new(msg: 'hi'))
rescue => e
  e # => #<GRPC::InvalidArgument: 3:This message is optional>
  e.metadata # => { :put => :your, :metadata => here }

By default, The failure code is invalid_argument and the message is optional - so if the code under test doesn't rely on those for any downstream behavior, you can simplify the stubbing by passing only metadata:

stub_grpc_action(Hello::Hello::Service, :Hello).
  to_fail_with(metadata: { important_things: [:in, :here] })
client.hello(Hello::HelloRequest.new(msg: 'hi')) 
# => #<GRPC::InvalidArgument: 3:>
exception. 
# => { :important_things => [:in, :here] }

...or by passing nothing at all:

stub_grpc_action(Hello::Hello::Service, :Hello).to_fail
client.hello(Hello::HelloRequest.new(msg: 'hi')) 
# => #<GRPC::InvalidArgument: 3:>

Raising errors

Exception declared by class

ProtoPharm.stub_request("/hello.hello/Hello").to_raise(StandardError)

client = Hello::Hello::Stub.new('localhost:8000', :this_channel_is_insecure)
client.hello(Hello::HelloRequest.new(msg: 'hi')) # => Raise StandardError

or by exception instance

ProtoPharm.stub_request("/hello.hello/Hello").to_raise(StandardError.new("Some error"))

or by string

ProtoPharm.stub_request("/hello.hello/Hello").to_raise("Some error")

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/Freshly/proto_pharm. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the Contributor Covenant code of conduct.

License

The gem is available as open source under the terms of the MIT License.