16 minute read

Image showing male and female developers coding APIs with a screen at the back and some icons like Java etc
Image powered by DALL-E 3

⏪ 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:

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 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.

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 back for further processing

With 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 setup our logger using standardjava.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 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 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 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
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 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, 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

@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 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 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 testHelperas 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 the 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 convert 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 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! 🌱 Newsletter YouTube Blog LinkedIn Twitter.

Comments