Testing gRPC #3: How to unit test a gRPC client
⏪ 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 also how can we 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:
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
We also pass in StreamObserver<Feature> responseObserver
which is used to return 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.
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.
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
If you see the exists() it just checks if the feature has a valid name
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
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 back for further processing
With above context, We can write a gRPC client for this listFeatures() method like below
Initialize client
Let’s unpack the client and understand how it works
We start with Initialising our client class RouteGuideClient
and setup our logger using standard java.util.logging.Logger;
Next, We declare a RouteGuideBlockingStub and a RouteGuideStub to make desired sync or async calls to the server and also initialize few other utilities like Random and TestHelper which may be used
We then pass in the channel to the client constructor and then initialize our blocking and async stubs using method from RouteGuideGrpc
listFeatures
We implement our listFeatures client method that accepts 4 int parameters, as we saw before they represent the co-ordinates for 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
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 builder pattern to construct our request
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
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
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()
}
I know, its 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
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.
Setup
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)
We also use a registry to allow different services to be registered for different test cases.
We then initialize the client and pass it the channel which is also registered for auto cleanup
We inject a mock object called testHelper for observing calls and verifying behavior. We’ll see that this is an interface that exposes
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:
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”
We create a thread-safe reference to the Rectangle object using Java AtomicReference
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
Inside the body, we set the rectangle passed in from the request into the AtomicReference<Rectangle>
rectangleDelivered we had defined earlier
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
Finally, we complete the RPC by calling onCompleted() method
We can see the complete fake service implementation below.
Now, we will add this fake service to our service registry as below
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.
After making the call, we should check whether the rectangle object returned from the response matches what we expect with the below
Lastly, we also need to verify if our client actually called our fake service. We can ensure that using verify() method from Mockito library
We use the verify() method from Mockito library to verify interactions with the mocked object testHelper as below
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
We also ensure that the onRpcError() method was never called since this is a positive case.
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
We initialize a fakeError of StatusRuntimeException type and then initialize it with an INVALID_ARGUMENT value
We again implement our fake service and repeat the same steps as above:
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.
We then register our service, call our client with the same input, and assert that the API returns us the similar output by asserting on the rectangle object
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
We define the ArgumentCaptor class from Mockito to capture any exception of type Throwable to our mocked method
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
We also assert that the captured error status matches expected fakeError.getStatus()by retrieving the captured Throwable using errorCaptor.getValue() and convert it into a GRPC Status object using Status.fromThrowable()
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 response
- We understood how the client method is written for this service
- Later, we dove into how can 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 error was returned using Mockito ArgumentCaptor
That was 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! 🌱
Originally published at https://automationhacks.io on March 7, 2024.