Mocking the Universe: Two Techniques for Testing gRPC with Mocks

Mocking the Universe: Two Techniques for Testing gRPC with Mocks

Daniel Sollis | Tuesday, Mar 28, 2023 |  Golang ProgrammingTestinggRPC

At Rotational, we use gRPC quite a bit. It’s great for specifying network APIs, but can challenge us to find new ways to thoroughly test the code. Follow along with this post to learn two ways of using mocking to test gRPC services!

Why Mock?

The main reason for using mocks is that they allow you to get around dependencies.

If you are testing a discrete piece of code, you want to test THAT piece of code, not every dependency it’s using as well (hopefully you have separate tests that cover those dependencies ๐Ÿ˜‰).

The same goes for code using gRPC. When a piece of code calls an RPC, you don’t want to have to test the entire gRPC service as well as the network (which can be unreliable and super hard to test). But how can we avoid stepping into the implementation details when testing gRPC?

The easiest way to get around this is to avoid gRPC altogether, with what we at Rotational refer to as “package mocks”. The second technique, which we call “universal mocks”, allows us to directly test gRPC methods in a more structured way. Read on to learn more about both and see some examples.

Package Mocks

In many cases, different gRPC service calls can be isolated into their own packages that contain structs (likely a gRPC client or a struct containing the client) that implement the service’s procedure calls. By mocking these structs, “package mocks” enable you to avoid not only the gRPC calls, but any other dependencies that might show up in the service’s implementation.

Lets imagine we have the following service defined:

syntax = "proto3";

service Database {
    rpc Get(GetRequest) returns (GetReply) {};
    rpc Put(PutRequest) returns (PutReply) {};
}

In our database package (let’s imagine it’s inside a file called database/database.go) we might have the following struct defined:

type Database struct {
    conf database.config
    client DatabaseNetworkClient
}

This struct will take care of all of the service’s RPC implementation and will also take care of any state (such as configuration or caching) that the service requires. If we add a new interface called Store, which the Database struct implements:

type Store {
    Get(GetRequest) GetReply
    Put(PutRequest) PutReply
}

We can now start defining a “package mock” that will allow us to circumvent calls to the Database service by creating a new struct that also implements Store.

const (
    MockDBGet = "MockDB specified error on call to Get"
    MockDBPut = "MockDB specified error on call to Put"
)

type MockDB struct {
    Calls      map[string]int
	ErrorOn    string
}

func NewMockDB(conf) *MockDB {
	return &MockDB{
		Calls: make(map[string]int),
	}
}

func (d *MockDB) Get(context.Context, GetRequest) (*GetReply) {
	d.Calls[MockDBGet]++
	if d.ErrorOn == MockPeersGet {
		return nil, errors.New(d.ErrorOn)
	}
	return &GetReply{
        Success: true
    }, nil
}

func (d *MockDB) Put(context.Context, PutRequest) (*PutReply) {
	d.Calls[MockDBPut]++
	if d.ErrorOn == MockPeersGet {
		return nil, errors.New(d.ErrorOn)
	}
	return &PutReply{
        Success: true
    }, nil
}

The fields we’ve added to the mock struct are particularly useful. By using the Calls map, you can verify that the number of calls you expect have actually happened. With the ErrorOn field, you can trigger any RPC call to fail when needed for testing purposes.

Universal Mocks

Package mocks work perfectly for testing code that uses the package that we are mocking, but what about testing the package and it’s RPC calls directly? This going to require a different technique, which we at Rotational refer to as “universal mocks”.

When testing a package that implements a gRPC service we seemingly won’t be able to get around having to deal with RPC calls, which bring with them all of the complications of dealing with an unreliable network and whatever dependencies are part of the RPC handling code on the other side of the network.

Fortunately for us gRPC comes with a built in solution, the Bufconn package. Bufconn provides a way to get around the network dependency by creating an in-memory buffer that simulates a real gRPC connection.

What about the service at the other end of the connection though? Doesn’t that still have dependencies that we don’t want to deal with? Well, we can mock it with a “universal mock”.

Let’s go back to our original Database definition:

type Store {
    Get(GetRequest) GetReply
    Put(PutRequest) PutReply
}

type Database struct {
    conf database.config
    client database.DatabaseNetworkClient
}

In order to test this struct we’ll need to create a mocked server with bufconn that this struct’s client can connect to, this will be the “universal mock”, and will look something like this:

type RemoteDB struct {
	conn        *bufconn.Listener
    srv         *grpc.Server
    Calls       map[string]int
    OnGet       func(context.Context, GetRequest) (*GetReply, error)
    OnPut       func(context.Context, PutRequest) (*PutRequest, error)
}

This looks a lot like the Package mock with a few modifications. We keep the Calls map to count specific RPC calls but add a bufconn and grpc.Server to serve with. The OnGet and OnPut field’s type is a bit odd and we’ll go over those later. Creating a new RemoteDB will be a bit different than with the package mock:

func NewRemoteDB() *RemoteDB {
	database := &RemoteDB{
		conn:  bufconn.Listen(bufsize),
		srv:   grpc.NewServer(),
		Calls: make(map[string]int),
	}
	pb.RegisterDatabaseNetworkServer(database.srv, database)
	go database.Serve()
	return database
}

func (r *RemoteDB) Serve() {
	if err := r.srv.Serve(r.conn); err != nil {
		fmt.Println(err.Error())
	}
}

func (r *RemoteDB) ClientOptions() (opts []grpc.DialOption) {
	// Create the dialer
	dialer := func(context.Context, string) (net.Conn, error) {
		return r.conn.Dial()
	}
	opts = append(opts, grpc.WithContextDialer(dialer))

	// Disable transport security
	opts = append(opts, grpc.WithInsecure())

	return opts
}

In the NewRemoteDB function we have to instantiate a new bufconn and gRPC server that we need to register and serve. We also have an additional function for serving with the gRPC server and a method for returning the preferred gRPC.DialOptions to make connecting to the mocked server easier.

The final piece of the puzzle is the RPC implementations; this is where those odd field types from before come in:

func (r *RemoteDB) Get(ctx context.Context, in GetRequest) (out *GetReply, err error) {
	r.Calls[GetRPC]++
	return r.OnGet(ctx, in)
}

func (r *RemoteDB) Put(ctx context.Context, in PutRequest) (out *PutRequest, err error) {
	r.Calls[PutRPC]++
	return r.OnPut(ctx, in)
}

The use of the Calls field is largely the same as with the package mock, but we are basically just calling the functions stored on the OnGet and OnPut fields. Because these reference stored functions (which can do arbitrary behavior), this allows us to specify whatever functionality we want the server to have for these RPCs!

For example if we want the server to simply return an empty reply:

mock := NewRemoteDB()
mock.OnGet = func(context.Context, GetRequest) (*GetReply, error) {
	return &GetReply{}, nil
}
reply := mock.Get()

Putting all of this together we get the following universal mock:

const(
    GetRPC  = "Database/Get"
	PutRPC  = "Database/Put"
	bufsize = 1024 * 1024
)

type RemoteDB struct {
	conn        *bufconn.Listener
    srv         *grpc.Server
    Calls       map[string]int
    OnGet       func(context.Context, GetRequest) (*GetReply, error)
    OnPut       func(context.Context, PutRequest) (*PutRequest, error)
}

func NewRemoteDB() *RemoteDB {
	database := &RemoteDB{
		conn:  bufconn.Listen(bufsize),
		srv:   grpc.NewServer(),
		Calls: make(map[string]int),
	}
	pb.RegisterDatabaseNetworkServer(database.srv, database)
	go database.Serve()
	return database
}

func (r *RemoteDB) Serve() {
	if err := r.srv.Serve(r.conn); err != nil {
		fmt.Println(err.Error())
	}
}

func (r *RemoteDB) ClientOptions() (opts []grpc.DialOption) {
	// Create the dialer
	dialer := func(context.Context, string) (net.Conn, error) {
		return r.conn.Dial()
	}
	opts = append(opts, grpc.WithContextDialer(dialer))

	// Disable transport security
	opts = append(opts, grpc.WithInsecure())

	return opts
}

func (r *RemoteDB) Get(ctx context.Context, in GetRequest) (out *GetReply, err error) {
	r.Calls[GetRPC]++
	return r.OnGet(ctx, in)
}

func (r *RemoteDB) Put(ctx context.Context, in PutRequest) (out *PutRequest, err error) {
	r.Calls[PutRPC]++
	return r.OnPut(ctx, in)
}

Connecting the Universal Mock

The last step is to have the Database struct’s client to the universal mock. We need to add a method to the struct that handles connecting to an RPC server:

func (d *Database) Connect(opts ...grpc.DialOption) (err error) {
	if d.client != nil {
		return nil
	}

	var cc *grpc.ClientConn
	if cc, err = grpc.Dial("bufnet", opts...); err != nil {
		return fmt.Errorf("could not dial database service: %s", err)
	}

	d.client = NewDatabaseNetworkClient(cc)
	return nil
}

With this method in place, creating and connecting to a universal mock becomes easy as can be:

mock := NewRemoteDB()
err = database.Connect(mock.ClientOptions()...)

Conclusion

At Rotational we frequently use both “package mocks” and “universal mocks”. The first allows us to test behavior that would normally engage external gRPC calls without actually having to make external calls. With the second type, we can also get around any gRPC calls by simulating gRPC connection and the expected or resultant behavior.

Together these techniques have enabled us to more easily test our gRPC services, including those for Ensign, our managed eventing platform that takes a lot of the stress out of building microservices. If you’re interested in reading more on how we use mocks, check out this post next.

Happy mocking!

Want early access to a platform and community for developers building event-driven apps? Check out our free beta of Ensign.

About This Post

Mocking can be an easy way to get around gRPC connections when testing.

Written by:

Share this post:

Recent Rotations butterfly

View all

To LLM or Not to LLM (Part 2): Starting Simple

Sick of hearing about hyped up AI solutions that sound like hot air? ๐Ÿง Let’s use boring old ML to detect hype in AI marketing text and see why starting with a simple ML approach is still your best bet 90% of the time.

Building an AI Text Detector - Lessons Learned

The LLMs boom has made differentiating text written by a person vs. generated by AI a highly desired technology. In this post, I’ll attempt to build an AI text detector from scratch!

May 15, 2024

To LLM or Not to LLM: Tips for Responsible Innovation

We’re seeing a proliferation of Large Language Models (LLMs) as companies seek to replicate OpenAI’s success. In this post, two AI engineers respond to LLM FAQs and offer tips for responsible innovation.

Enter Your Email To Subscribe