Testing gRPC #3: How to unit test a gRPC client
Grasp internals of how a client is written, and unit test it with a positive and negative case with a fake service implementation.
⏪ Recap:
In the previous blog, we understood how to write a unit test for a gRPC server using InProcessChannel, and InProcessServer and how to set automatic cleanup using GrpcCleanupRule. In case you missed it, please feel free to read it here
⏩ What we’ll learn:
In this blog, we’ll understand how a client could be written for our Grpc service and how we can unit test that.
Let’s go ⚡
System under test
Before we understand how the client is written, let’s first grasp how the service is implemented for this method since we have to write a client method for essentially this.
You can see the complete listFeatures() method in RouteGuideServer.java below:
RouteGuideServer.java
@Override
public void listFeatures(Rectangle request, StreamObserver<Feature> responseObserver) {
int left = min(request.getLo().getLongitude(), request.getHi().getLongitude());
int right = max(request.getLo().getLongitude(), request.getHi().getLongitude());
int top = max(request.getLo().getLatitude(), request.getHi().getLatitude());
int bottom = min(request.getLo().getLatitude(), request.getHi().getLatitude());
for (Feature feature : features) {
if (!RouteGuideUtil.exists(feature)) {
continue;
}
int lat = feature.getLocation().getLatitude();
int lon = feature.getLocation().getLongitude();
if (lon >= left && lon <= right && lat >= bottom && lat <= top) {
responseObserver.onNext(feature);
}
}
responseObserver.onCompleted();
}
Let’s walk through the code and grasp how this server method is implemented
If you observe the method signature, We pass in a Rectangle request, and StreamObserver<Feature> responseObserver
We know from route_guide.proto, that Rectangle is a collection of 2 Point (lo and hi), such that each point has a latitude and longitude. This represents a bounding rectangle in a map. Think of a rectangle that covers the central part of Bangalore (of course, there could be much better representation to create a geofence but for the sake of simplicity let’s go with this 😏)
Below is the proto in case you need a recap.
route_guide.proto
// Points are represented as latitude-longitude pairs in the E7 representation
// (degrees multiplied by 10**7 and rounded to the nearest integer).
// Latitudes should be in the range +/- 90 degrees and longitude should be in
// the range +/- 180 degrees (inclusive).
message Point {
int32 latitude = 1;
int32 longitude = 2;
}
// A latitude-longitude rectangle, represented as two diagonally opposite
// points "lo" and "hi".
message Rectangle {
// One corner of the rectangle.
Point lo = 1;
// The other corner of the rectangle.
Point hi = 2;
}
We also pass in StreamObserver<Feature> responseObserver, which returns a stream of Feature objects to the client. The stream could loosely be understood as an array that the client can read either all at once or one object at a time.
public void listFeatures(Rectangle request, StreamObserver<Feature> responseObserver)
Let’s look at the service methods body, We then extract 4 corners of the rectangle by finding min and max values from lo and hi longitude and latitudes to get an integer that represents each corner.
int left = min(request.getLo().getLongitude(), request.getHi().getLongitude());
int right = max(request.getLo().getLongitude(), request.getHi().getLongitude());
int top = max(request.getLo().getLatitude(), request.getHi().getLatitude());
int bottom = min(request.getLo().getLatitude(), request.getHi().getLatitude());
Nice, We then iterate in the features already stored in our database (in this example, the features are stored in an in-memory Collection but in a real application this could be any other data store) and check if a feature exists
for (Feature feature : features) {
if (!RouteGuideUtil.exists(feature)) {
continue;
}
If you see the exists() it just checks if the feature has a valid name
RouteGuideUtil.java
/**
* Indicates whether the given feature exists (i.e. has a valid name).
*/
public static boolean exists(Feature feature) {
return feature != null && !feature.getName().isEmpty();
}
If the feature exists then we check
if that feature longitude lies between left and right ranges
and latitude lies between the bottom and top ranges
if we find such a feature then we add it to the responseObserver’s onNext method
int lat = feature.getLocation().getLatitude();
int lon = feature.getLocation().getLongitude();
if (lon >= left && lon <= right && lat >= bottom && lat <= top) {
responseObserver.onNext(feature);
}
Finally, we call the responseObserver.onCompleted(); method to indicate that the rpc call is complete.
How to Implement a Client
So now we understand what our service is actually doing.
Let’s understand how a client method could be written for listFeatures() method
Any client for a service method/operation usually performs 3 functions:
Construct the request for the service
Call the service method using either a blockingStub or an asyncStub (in other words either sync or async call)
Logs or return the response for further processing
With the above context, We can write a gRPC client for this listFeatures() method like below
RouteGuideClient.java
public class RouteGuideClient {
private static final Logger logger = Logger.getLogger(RouteGuideClient.class.getName());
private final RouteGuideBlockingStub blockingStub;
private final RouteGuideStub asyncStub;
private Random random = new Random();
private TestHelper testHelper;
/** Construct client for accessing RouteGuide server using the existing channel. */
public RouteGuideClient(Channel channel) {
blockingStub = RouteGuideGrpc.newBlockingStub(channel);
asyncStub = RouteGuideGrpc.newStub(channel);
}
/**
* Blocking server-streaming example. Calls listFeatures with a rectangle of interest. Prints each
* response feature as it arrives.
*/
public void listFeatures(int lowLat, int lowLon, int hiLat, int hiLon) {
info("*** ListFeatures: lowLat={0} lowLon={1} hiLat={2} hiLon={3}", lowLat, lowLon, hiLat,
hiLon);
Rectangle request =
Rectangle.newBuilder()
.setLo(Point.newBuilder().setLatitude(lowLat).setLongitude(lowLon).build())
.setHi(Point.newBuilder().setLatitude(hiLat).setLongitude(hiLon).build()).build();
Iterator<Feature> features;
try {
features = blockingStub.listFeatures(request);
for (int i = 1; features.hasNext(); i++) {
Feature feature = features.next();
info("Result #" + i + ": {0}", feature);
if (testHelper != null) {
testHelper.onMessage(feature);
}
}
} catch (StatusRuntimeException e) {
warning("RPC failed: {0}", e.getStatus());
if (testHelper != null) {
testHelper.onRpcError(e);
}
}
}}
Initialize client
Let’s unpack the client and understand how it works
We start with Initialising our client class RouteGuideClient and setting up our logger using standard java.util.logging.Logger;
public class RouteGuideClient {
private static final Logger logger = Logger.getLogger(RouteGuideClient.class.getName());
Next, We declare a RouteGuideBlockingStub and a RouteGuideStub to make desired sync or async calls to the server and also initialize a few other utilities like Random and TestHelper which may be used
private final RouteGuideBlockingStub blockingStub;
private final RouteGuideStub asyncStub;
private Random random = new Random();
private TestHelper testHelper;
We then pass in the channel to the client constructor and then initialize our blocking and async stubs using the method from RouteGuideGrpc
/** Construct client for accessing RouteGuide server using the existing channel. */
public RouteGuideClient(Channel channel) {
blockingStub = RouteGuideGrpc.newBlockingStub(channel);
asyncStub = RouteGuideGrpc.newStub(channel);
}
listFeatures
We implement our listFeatures client method that accepts 4 int parameters, as we saw before they represent the coordinates for the bottom left and upper right corners of the bounding rectangle
lowLat → Latitude for a lower point of the rectangle
lowLon → Longitude for a lower point of the rectangle
hiLat → Latitude for the upper point of the rectangle
hiLon → Longitude for the upper point of the rectangle
public void listFeatures(int lowLat, int lowLon, int hiLat, int hiLon) {
info("*** ListFeatures: lowLat={0} lowLon={1} hiLat={2} hiLon={3}", lowLat, lowLon, hiLat,
hiLon);
For our client, we prepare our request by constructing the Rectangle object that the service expects. We can directly use the newBuilder() exposed for each proto and use the builder pattern to construct our request
Rectangle request =
Rectangle.newBuilder()
.setLo(Point.newBuilder().setLatitude(lowLat).setLongitude(lowLon).build())
.setHi(Point.newBuilder().setLatitude(hiLat).setLongitude(hiLon).build()).build();
Next, we make the actual request to get our list of features using blockingStub and store it in a collection called Iterator<Feature> that we can iterate upon
Iterator<Feature> features;
try {
features = blockingStub.listFeatures(request);
We then iterate and log each feature found within the specified coordinates. If we catch a StatusRuntimeException then we log this as a warning
In a real application, the client might do further processing based on these features but for now, we just log the features received
try {
features = blockingStub.listFeatures(request);
for (int i = 1; features.hasNext(); i++) {
Feature feature = features.next();
info("Result #" + i + ": {0}", feature);
if (testHelper != null) {
testHelper.onMessage(feature);
}
}
} catch (StatusRuntimeException e) {
warning("RPC failed: {0}", e.getStatus());
if (testHelper != null) {
testHelper.onRpcError(e);
}
}
How to unit test a client
So now, we understand the basics
How the service method under test is implemented
How is the client for such a service written
Let’s come to the fun part.
How will we ensure our client works fine?
Of course, we write a test
You can find the complete test examples/src/test/java/io/grpc/examples/routeguide/RouteGuideClientTest.java but we’ll focus on how to write a test for listFeatures()
RouteGuideClientTest.java
@RunWith(JUnit4.class)
public class RouteGuideClientTest {
/**
* This rule manages automatic graceful shutdown for the registered server at the end of test.
*/
@Rule
public final GrpcCleanupRule grpcCleanup = new GrpcCleanupRule();
private final MutableHandlerRegistry serviceRegistry = new MutableHandlerRegistry();
private final TestHelper testHelper = mock(TestHelper.class);
private final Random noRandomness =
new Random() {
int index;
boolean isForSleep;
/**
* Returns a number deterministically. If the random number is for sleep time, then return
* -500 so that {@code Thread.sleep(random.nextInt(1000) + 500)} sleeps 0 ms. Otherwise, it
* is for list index, then return incrementally (and cyclically).
*/
@Override
public int nextInt(int bound) {
int retVal = isForSleep ? -500 : (index++ % bound);
isForSleep = ! isForSleep;
return retVal;
}
};
private RouteGuideClient client;
@Before
public void setUp() throws Exception {
// Generate a unique in-process server name.
String serverName = InProcessServerBuilder.generateName();
// Use a mutable service registry for later registering the service impl for each test case.
grpcCleanup.register(InProcessServerBuilder.forName(serverName)
.fallbackHandlerRegistry(serviceRegistry).directExecutor().build().start());
client = new RouteGuideClient(grpcCleanup.register(
InProcessChannelBuilder.forName(serverName).directExecutor().build()));
client.setTestHelper(testHelper);
}
/**
* Example for testing blocking server-streaming.
*/
@Test
public void listFeatures() {
final Feature responseFeature1 = Feature.newBuilder().setName("feature 1").build();
final Feature responseFeature2 = Feature.newBuilder().setName("feature 2").build();
final AtomicReference<Rectangle> rectangleDelivered = new AtomicReference<Rectangle>();
// implement the fake service
RouteGuideImplBase listFeaturesImpl =
new RouteGuideImplBase() {
@Override
public void listFeatures(Rectangle rectangle, StreamObserver<Feature> responseObserver) {
rectangleDelivered.set(rectangle);
// send two response messages
responseObserver.onNext(responseFeature1);
responseObserver.onNext(responseFeature2);
// complete the response
responseObserver.onCompleted();
}
};
serviceRegistry.addService(listFeaturesImpl);
client.listFeatures(1, 2, 3, 4);
assertEquals(Rectangle.newBuilder()
.setLo(Point.newBuilder().setLatitude(1).setLongitude(2).build())
.setHi(Point.newBuilder().setLatitude(3).setLongitude(4).build())
.build(),
rectangleDelivered.get());
verify(testHelper).onMessage(responseFeature1);
verify(testHelper).onMessage(responseFeature2);
verify(testHelper, never()).onRpcError(any(Throwable.class));
}
/**
* Example for testing blocking server-streaming.
*/
@Test
public void listFeatures_error() {
final Feature responseFeature1 =
Feature.newBuilder().setName("feature 1").build();
final AtomicReference<Rectangle> rectangleDelivered = new AtomicReference<Rectangle>();
final StatusRuntimeException fakeError = new StatusRuntimeException(Status.INVALID_ARGUMENT);
// implement the fake service
RouteGuideImplBase listFeaturesImpl =
new RouteGuideImplBase() {
@Override
public void listFeatures(Rectangle rectangle, StreamObserver<Feature> responseObserver) {
rectangleDelivered.set(rectangle);
// send one response message
responseObserver.onNext(responseFeature1);
// let the rpc fail
responseObserver.onError(fakeError);
}
};
serviceRegistry.addService(listFeaturesImpl);
client.listFeatures(1, 2, 3, 4);
assertEquals(Rectangle.newBuilder()
.setLo(Point.newBuilder().setLatitude(1).setLongitude(2).build())
.setHi(Point.newBuilder().setLatitude(3).setLongitude(4).build())
.build(),
rectangleDelivered.get());
ArgumentCaptor<Throwable> errorCaptor = ArgumentCaptor.forClass(Throwable.class);
verify(testHelper).onMessage(responseFeature1);
verify(testHelper).onRpcError(errorCaptor.capture());
assertEquals(fakeError.getStatus(), Status.fromThrowable(errorCaptor.getValue()));
}
}
I know, it’s a lot. 🤟
Let’s break it down step by step and grasp how this works
Test Structure
The code is a test class named RouteGuideClientTest designed to test a client for communicating with a service called RouteGuide. It uses JUnit4 as we saw before
@RunWith(JUnit4.class)
public class RouteGuideClientTest {}
Automatic Cleanup
The @Rule annotation tells JUnit to manage an instance of GrpcCleanupRule.
This rule ensures that test servers are automatically shut down after each test, keeping the test environment clean.
/**
* This rule manages automatic graceful shutdown for the registered server at the end of test.
*/
@Rule
public final GrpcCleanupRule grpcCleanup = new GrpcCleanupRule();
Setup
@Before
public void setUp() throws Exception {
// Generate a unique in-process server name.
String serverName = InProcessServerBuilder.generateName();
// Use a mutable service registry for later registering the service impl for each test case.
grpcCleanup.register(InProcessServerBuilder.forName(serverName)
.fallbackHandlerRegistry(serviceRegistry).directExecutor().build().start());
client = new RouteGuideClient(grpcCleanup.register(
InProcessChannelBuilder.forName(serverName).directExecutor().build()));
client.setTestHelper(testHelper);
}
We write @Before annotated setUp method to ensure each test method starts with a unique instance of the client
We create a unique in-process server (running within the same process)
// Generate a unique in-process server name.
String serverName = InProcessServerBuilder.generateName();
We also use a registry to allow different services to be registered for different test cases.
// Use a mutable service registry for later registering the service impl for each test case.
grpcCleanup.register(InProcessServerBuilder.forName(serverName) .fallbackHandlerRegistry(serviceRegistry).directExecutor().build().start());
We then initialize the client and pass it to the channel which is also registered for auto-cleanup
client = new RouteGuideClient(grpcCleanup.register(
InProcessChannelBuilder.forName(serverName).directExecutor().build()))
We inject a mock object called testHelper for observing calls and verifying behavior. We’ll see that this is an interface that exposes
client.setTestHelper(testHelper);
Tests
We then write 2 unit tests that do below on a high level:
listFeatures: This tests successful communication with the server simulating a positive case
Creates a fake service implementation to control server behavior.
Calls the client's listFeatures method to initiate communication.
Asserts that the correct request was sent and the expected responses were received.
listFeatures_error: Tests how the client handles errors.
Set up a fake service that throws an error.
Verifies that the client properly propagates the error to the test helper.
✅Positive case
Let’s unpack the positive case first to understand this a bit better
Below is the complete test at a glance. We will do a walkthrough on it below:
/**
* Example for testing blocking server-streaming.
*/
@Test
public void listFeatures() {
final Feature responseFeature1 = Feature.newBuilder().setName("feature 1").build();
final Feature responseFeature2 = Feature.newBuilder().setName("feature 2").build();
final AtomicReference<Rectangle> rectangleDelivered = new AtomicReference<Rectangle>();
// implement the fake service
RouteGuideImplBase listFeaturesImpl =
new RouteGuideImplBase() {
@Override
public void listFeatures(Rectangle rectangle, StreamObserver<Feature> responseObserver) {
rectangleDelivered.set(rectangle);
// send two response messages
responseObserver.onNext(responseFeature1);
responseObserver.onNext(responseFeature2);
// complete the response
responseObserver.onCompleted();
}
};
serviceRegistry.addService(listFeaturesImpl);
client.listFeatures(1, 2, 3, 4);
assertEquals(Rectangle.newBuilder()
.setLo(Point.newBuilder().setLatitude(1).setLongitude(2).build())
.setHi(Point.newBuilder().setLatitude(3).setLongitude(4).build())
.build(),
rectangleDelivered.get());
verify(testHelper).onMessage(responseFeature1);
verify(testHelper).onMessage(responseFeature2);
verify(testHelper, never()).onRpcError(any(Throwable.class));
}
Alright, let’s break this down.
We first annotate our method with @Test and then build two feature objects by using the builder as before by passing in the name as “feature 1” and “feature 2”
@Test
public void listFeatures() {
final Feature responseFeature1 = Feature.newBuilder().setName("feature 1").build();
final Feature responseFeature2 = Feature.newBuilder().setName("feature 2").build();
We create a thread-safe reference to the Rectangle object using Java AtomicReference
final AtomicReference<Rectangle> rectangleDelivered = new AtomicReference<Rectangle>();
In this test, we are only interested in testing our client, thus we create a fake service implementation for listFeatures method. This ensures isolation for this unit test.
To do so, we construct new RouteGuideImplBase() and define our anonymous subclass by overriding the listFeatures method like below
// implement the fake service
RouteGuideImplBase listFeaturesImpl =
new RouteGuideImplBase() {
@Override
public void listFeatures(Rectangle rectangle, StreamObserver<Feature> responseObserver) {
Inside the body, we set the rectangle passed in from the request into the AtomicReference<Rectangle> rectangleDelivered we had defined earlier
rectangleDelivered.set(rectangle);
We also want the server to stream and return the two features we had earlier created to the client, thus we use the onNext() method in responseObserver to do so. This is a way for gRPC to provide server-side streaming
// send two response messages
responseObserver.onNext(responseFeature1);
responseObserver.onNext(responseFeature2);
Finally, we complete the RPC by calling onCompleted() method
// complete the response
responseObserver.onCompleted();
We can see the complete fake service implementation below.
// implement the fake service
RouteGuideImplBase listFeaturesImpl =
new RouteGuideImplBase() {
@Override
public void listFeatures(Rectangle rectangle, StreamObserver<Feature> responseObserver) {
rectangleDelivered.set(rectangle);
// send two response messages
responseObserver.onNext(responseFeature1);
responseObserver.onNext(responseFeature2);
// complete the response
responseObserver.onCompleted();
}
};
Now, we will add this fake service to our service registry as below
serviceRegistry.addService(listFeaturesImpl);
Awesome, Let’s make our service call via the client. Here 1, 2, 3, and 4 are the lat and longs for the lower left and upper right corner of the rectangle as we saw before.
client.listFeatures(1, 2, 3, 4);
After making the call, we should check whether the rectangle object returned from the response matches what we expect with the below
assertEquals(Rectangle.newBuilder()
.setLo(Point.newBuilder().setLatitude(1).setLongitude(2).build())
.setHi(Point.newBuilder().setLatitude(3).setLongitude(4).build())
.build(),
rectangleDelivered.get());
Lastly, we also need to verify if our client called our fake service. We can ensure that using the verify() method from the Mockito library
We use the verify() method from Mockito library to verify interactions with the mocked object testHelper as below
verify(testHelper).onMessage(responseFeature1);
verify(testHelper).onMessage(responseFeature2);
This interface is defined in RouteGuideClient.java as below and if we go over its definition, it exposes two methods onMessage() and onRpcError. We can use this to ensure the client calls the onMessage() method with responseFeature1 and then with responseFeature2
/**
* Only used for helping unit test.
*/
@VisibleForTesting
interface TestHelper {
/**
* Used for verify/inspect message received from server.
*/
void onMessage(Message message);
/**
* Used for verify/inspect error received from server.
*/
void onRpcError(Throwable exception);
}
We also ensure that the onRpcError() method was never called since this is a positive case.
verify(testHelper, never()).onRpcError(any(Throwable.class));
You can see other examples of verify() and never() method calls and their usages on mockito docs
⛔Negative test
How would we write a negative test for our client?
In this test, We want to check what happens when our service throws an RPC error
The initial setup is identical, except in this case we only prepare one responseFeature1
@Test
public void listFeatures_error() {
final Feature responseFeature1 =
Feature.newBuilder().setName("feature 1").build();
final AtomicReference<Rectangle> rectangleDelivered = new AtomicReference<Rectangle>();
We initialize a fakeError of StatusRuntimeException type and then initialize it with an INVALID_ARGUMENT value
final StatusRuntimeException fakeError = new StatusRuntimeException(Status.INVALID_ARGUMENT);
We again implement our fake service and repeat the same steps as above:
// implement the fake service
RouteGuideImplBase listFeaturesImpl =
new RouteGuideImplBase() {
@Override
public void listFeatures(Rectangle rectangle, StreamObserver<Feature> responseObserver) {
rectangleDelivered.set(rectangle);
// send one response message
responseObserver.onNext(responseFeature1);
// let the rpc fail
responseObserver.onError(fakeError);
}
};
Notice, the last step. 🔵
We now use the onError method to return the fake error that we had created earlier. In a production app, the service may use this to communicate to the client that something went wrong in processing.
responseObserver.onError(fakeError);
We then register our service, call our client with the same input, and assert that the API returns us a similar output by asserting on the rectangle object
serviceRegistry.addService(listFeaturesImpl);
client.listFeatures(1, 2, 3, 4);
assertEquals(Rectangle.newBuilder()
.setLo(Point.newBuilder().setLatitude(1).setLongitude(2).build())
.setHi(Point.newBuilder().setLatitude(3).setLongitude(4).build())
.build(),
rectangleDelivered.get());
To verify the service works for our negative scenario
We perform below 4 steps:
Set up a capture mechanism for errors.
Verify that a message was received by the service (likely before the error).
Verify that an RPC error occurred and capture the specific error.
Assert that the captured error's status matches the expected error status.
Let’s see how this could be implemented using the Mockito library
We check that the onMessage() method of the mocked object testHelper was invoked with the specific argument responseFeature1
verify(testHelper).onMessage(responseFeature1);
We define the ArgumentCaptor class from Mockito to capture any exception of type Throwable to our mocked method
ArgumentCaptor<Throwable> errorCaptor = ArgumentCaptor.forClass(Throwable.class);
Then, we ensure that onRpcError() method of mocked testHelper is called and by using capture() method of ArgumentCaptor, we capture the error returned by the GRPC service
verify(testHelper).onRpcError(errorCaptor.capture());
We also assert that the captured error status matches expected fakeError.getStatus() by retrieving the captured Throwable using errorCaptor.getValue() and converting it into a GRPC Status object using Status.fromThrowable()
assertEquals(fakeError.getStatus(), Status.fromThrowable(errorCaptor.getValue()));
Conclusion
Let’s recap what we learned in this post:
We understood how
listFeature()
method is implemented and how can a gRPC server return a streaming responseWe understood how the client method is written for this service
Later, we dove into how we unit-test the client
We looked into how to write a fake service
We implemented a positive case and checked if calls were made using Mockito verify()
We implemented a negative case and then checked if the error was returned using Mockito ArgumentCaptor
That was a lot of ground 🌆, and this wraps up what I wanted to cover with unit testing. There are other aspects to it but I’ll leave that to you to explore with other methods.
Please let me know if you have questions or thoughts in the comments.
In the next post, we will grasp how to write a functional API test for this to get confidence that the service works when dealing with live data.
Thanks for the time you spent reading this 🙌. If you found this post helpful, please share it and follow me (@automationhacks) for more such insights in Software Testing and Automation. Until next time 👋, Happy Testing 🕵🏻 and Learning! 🌱| Newsletter | YouTube | Blog | LinkedIn | Twitter.