3d illustration of a stylish chrome knot on water
Guides and Best Practices Tech Deep Dives

Taming Complexity in Server/Client(s) Testing in a CI Tool Using gRPC bufconn

For the uninitiated, gRPC is a modern open-source, high-performance Remote Procedure Call (RPC) framework that can run in any environment. It works on HTTP2 and uses binary protocols to efficiently transfer data between a client and a server.

The problem is that it can be tough to set up an environment for testing and targeting a live server to implement full API testing against the gRPC server. Even spinning up a server from the test file can lead to unintended consequences that require you to allocate a TCP port (such as parallel runs or multiple runs under same CI server). This dependency on ports makes testing difficult, both locally and even on CI/CD pipelines.

To solve this dependency on ports for testing, the gRPC community has introduced a package called bufconn under gRPC’s testing package. bufconn is a Go language (Golang) package that provides dialing and listening functionality using buffers to eliminate actual port dependency. bufconn comes with the gRPC’s GO module — which is already installed along with gRPC (so there is no install process). This blog post will explain how bufconn works and how to use it to run tests against the server without running it on real ports.

How it works

As I mentioned, bufconn lets you start and run a server without a real socket or port. Its value is that it mimics the streaming behavior and features of authentic OS-level resources, fooling the test client into thinking that it is talking to a real server over a real port. This allows you to run tests without the dependencies and potential complications.

bufconn provides a listener object that implements net.Conn. This listener can be leveraged in a gRPC server, allowing it to spin up a server that acts as a full-fledged server. The following is an example of how to use gRPC bufconn to test distributed systems in a CI tool, using a code snippet of a simple Greeter proto.

greeter.proto

package greeter;
 
option go_package = "proto/greeter";
 
// a service called Greeter that exposes a function called SayHello with an incoming 
// HelloRequest and returns a HelloReply

service Greeter {
    rpc SayHello (HelloRequest) returns (HelloReply) {}
}
 
// a message called HelloRequest that consists in a single field called name which is 
// a string message 

HelloRequest {
    string name = 1;
}
 
// a message called HelloReply that consists in a single field called message which is 
// a string message 

HelloReply {
    string message = 1;
}

With Port:

A port is assigned to the server below:

Listener:

lis, err := net.Listen("tcp", ":15051")

// Listen announces on the local network address.
// The network must be "tcp", "tcp4", "tcp6", "unix" or "unixpacket".

Read more about Listen (net package).

Server code snippet:

lis, _ := net.Listen("tcp", ":15051")
	s := grpc.NewServer()
	pb.RegisterGreeterServer(s, &server{})
	go func() {
		if err := s.Serve(lis); err != nil {
			log.Fatal(err)
		}
	}()

With bufconn:

Port is NOT required for the server, as it uses bufconn.

const bufSize = 1024 * 1024 
// buf contains the data in the pipe. It is a ring buffer of fixed capacity (bufSize),
// with r and w pointing to the offset to read and write. Data is read between [r, w) 
// and written to [w, r), wrapping around the end  of the slice, if necessary. The buffer 
// is empty if r == len(buf), otherwise, if r == w, it is full.
// w and r are always in the range [0, cap(buf)) and [0, len(buf)].

Server code snippet:

lis := bufconn.Listen(bufSize)
	s := grpc.NewServer()
	pb.RegisterGreeterServer(s, &server{})
	go func() {
		if err := s.Serve(lis); err != nil {
			log.Fatal(err)
		}
	}()

Client code snippet:

// Dial creates an in-memory full-duplex network connection, unblocks "Accept" by providing 
// it the server half of the connection, and returns the client half of the connection.
func bufDialer(ctx context.Context, address string) (net.Conn, error) {
         return lis.Dial()
}

ctx := context.Background()
	conn, err := grpc.DialContext(ctx, "bufnet", grpc.WithContextDialer(bufDialer), grpc.WithInsecure())
	if err != nil {
		t.Fatal(err)
	}
	defer conn.Close()

	client := pb.NewGreeterClient(conn)
	resp, err := client.SayHello(ctx, &pb.HelloRequest{Name: "gRPC"})
	if err != nil {
		t.Fatal(err)
	}

	if resp.GetMessage() != "gRPC" {
		t.Fatal("hello reply must be 'gRPC'")

	}

Wrapping up

Using bufconn not only eliminates the dependency on ports and permissions on the host machine, it makes the tests run faster, since the data does not have to flow through the TCP/IP layers. It also eliminates the consequences of incorrect cleanup, which would be required if we were using actual ports. Another advantage is that you can run multiple tests concurrently, even if they have dependencies on the same ports on the same machines. Since it is always smart to minimize external dependencies while testing, bufconn is a great option for gRPC in Golang.

Comments

Leave a Reply

Your email address will not be published.