Testing gRPC #4: How to E2E test a gRPC service
Explore how to write a Functional API test for gRPC API with the live service
In the previous blogs in this series, we learned what gRPC is, use web UI to explore the API, and then how to unit test the server and client code. Please feel free to catch up on those before reading this one.
What you’ll learn? 🌱
In this article, I’m gonna walk you through how to write a functional API test for a gRPC API using the live service. It's super important to write these end-to-end tests to make sure your API works the way it's supposed to. We'll start setting up the codebase and create a basic test client to talk to the gRPC API.
After that, I’ll show you an example of a functional API end-to-end test using TestNG and how to check if the API is responding properly. Let’s go! ⚡
Why write E2E tests?
Writing unit tests is probably the fastest way to get quick feedback about your services and codebase health. They are the lowest fidelity and fastest among the different types of tests that you can write.
However, would you be confident in shipping a unit-tested API directly to production?
Probably not!
You still would want confidence that the API works functionally.
Let’s assume your API is consumed by an upstream API, in such a case, you would want to make sure your API accepts valid input as per the contract that you’ve agreed with the dependent team and returns expected responses to the upstream service in positive, negative and edge test cases
You can get some of this isolated feedback with an integration test as well, however, if the workflow is complicated and you have multiple dependencies between APIs and databases, then writing integration tests could be tedious at best.
That's where a bunch of E2E tests come pretty handy.
While they run slower than a unit or integration test, you get realistic feedback about the state of your API, workflows, etc.
In this post, we’ll build on top of what we’ve learned so far and explore how we can write functional API tests for a gRPC service.
Hint ⚡: It’s not so much different 😉
Set up repo and codebase ⚙️
For this next section of the blog, I’ve copied the relevant files from grpc-java and created a new standalone project in my Github account that only covers the routeguide service.
This should help make it easier for someone new to not be overwhelmed by the huge project structure and see a lot of unrelated complex code.
Repo: https://github.com/automationhacks/grasp-grpc
We will clone the repository setup a new project in IntelliJ and ensure the build the successful by running these commands:
Using SSH
git@github.com:automationhacks/grasp-grpc.git
Using Github CLI
gh repo clone automationhacks/grasp-grpc
Let’s check if we can build the project without running tests for now
./gradlew clean build -x test
Let’s ensure that we have all the classes generated by building the project
./gradlew installDist
Let’s start our test server by running below
./build/install/grasp-grpc/bin/route-guide-server
We can see that our server is running at localhost:8980 port
Mar 19, 2024 9:52:42 PM io.automationhacks.routeguide.RouteGuideServer start
INFO: Server started, listening on 8980
Write an API test client
When we test a live API, it is usually hosted at some specific host and port on infrastructure that could be either our company's local data center or cloud platforms like Google Cloud, AWS, Azure, etc.
We’ll write a test client for our E2E API tests so that we can invoke our gRPC APIs and also maintain this as we add more functions
Below is how we can create a simple client with one method for getFeature() service method
package io.automationhacks.routeguide;
import io.grpc.ManagedChannel;
import io.grpc.ManagedChannelBuilder;
import java.util.logging.Logger;
public class RouteGuideTestClient {
private final Logger logger = Logger.getLogger(RouteGuideTestClient.class.getName());
private final RouteGuideGrpc.RouteGuideBlockingStub blockingStub;
public RouteGuideTestClient(String host, int port) {
ManagedChannel channel = ManagedChannelBuilder.forAddress(host, port).usePlaintext().build();
blockingStub = RouteGuideGrpc.newBlockingStub(channel);
}
public Feature getFeature(Point point) {
return blockingStub.getFeature(point);
}
}
Let’s unpack this,
We initialize RouteGuideBlockingStub to allow us to make API calls
private final RouteGuideGrpc.RouteGuideBlockingStub blockingStub;
Within the constructor, we accept the host and port where the server is running and then use ManagedChannelBuilder.forAddress() method to get a ManagedChannel instance
public RouteGuideTestClient(String host, int port) {
ManagedChannel channel = ManagedChannelBuilder.forAddress(host, port).usePlaintext().build();
}
We then use this channel to initialize our blockingStub
blockingStub = RouteGuideGrpc.newBlockingStub(channel);
And then write a method for our getFeature() API that accepts a Point and then returns the Feature at that specific point
public Feature getFeature(Point point) {
return blockingStub.getFeature(point);
}
While we can go ahead and implement other service methods, this single fine for now and we’ll implement them in the future while understanding other testing concepts
Writing an API E2E test
Let’s write a functional API test to verify the getFeature() service method is working fine.
Below is the complete test which you can also find in examples/src/test/java/io/grpc/examples/routeguide directory in the codebase
package io.automationhacks.routeguide;
import static com.google.common.truth.Truth.assertWithMessage;
import org.testng.annotations.Test;
public class RouteGuideE2ETest {
private static final String HOST = "localhost";
private static final int PORT = 8980;
@Test
public void testGetFeature() {
System.out.println("Executed testGetFeature()");
RouteGuideTestClient client = new RouteGuideTestClient(HOST, PORT);
int latitude = 407838351;
int longitude = -746143763;
Point point = Point.newBuilder().setLatitude(latitude).setLongitude(longitude).build();
Feature response = client.getFeature(point);
assertWithMessage(
"Could not find the feature at lat: %s long: %s".formatted(latitude, longitude))
.that(response.getName())
.isEqualTo("Patriots Path, Mendham, NJ 07945, USA");
}
}
Let’s understand our test
Within our test, we have specified our host and port where our server is running
⚡In a real framework, it’s a good idea to store these in an environment properties file and read it using Java classes to manage system properties. This would ensure if the host/port changes, we only have to change the properties file or build in flexibility to make the test environment agnostic
private static final String HOST = "localhost";
private static final int PORT = 8980;
We’ll use the TestNG test runner to run our E2E tests and we specify the test method by annotating our function with @Test
@Test
public void testGetFeature() {}
To make the API call, we need to initialize the test client that we had earlier created
RouteGuideTestClient client = new RouteGuideTestClient(HOST, PORT);
We use the builder provided by protocol buffers to create a point object and set latitude and longitude
int latitude = 407838351;
int longitude = -746143763;
Point point = Point.newBuilder().setLatitude(latitude).setLongitude(longitude).build();
We then make the API call via the client like below:
Feature response = client.getFeature(point);
Finally, we assert if the response name provided matches our expectation, we use fluent assertions from the Google Truth library
assertWithMessage(
"Could not find the feature at lat: %s long: %s".formatted(latitude, longitude))
.that(response.getName())
.isEqualTo("Patriots Path, Mendham, NJ 07945, USA");
How do we know this latitude/longitude would return this feature name?
When the server starts, we read from a JSON file specified called route_guide_db.json and store it in the in-memory collection.
public RouteGuideServer(int port) throws IOException {
this(port, RouteGuideUtil.getDefaultFeaturesFile());
}
Let’s run our tests using the below command
./gradlew clean runTests
We can see our test was executed since it printed our logger entry Executed testGetFeature() and our test passed 🎀
> Task :runTests
Gradle Test Executor 15 STANDARD_ERROR
SLF4J: Failed to load class "org.slf4j.impl.StaticLoggerBinder".
SLF4J: Defaulting to no-operation (NOP) logger implementation
SLF4J: See http://www.slf4j.org/codes.html#StaticLoggerBinder for further details.
Gradle suite > Gradle test > io.automationhacks.routeguide.RouteGuideE2ETest > testGetFeature STANDARD_OUT
Executed testGetFeature()
BUILD SUCCESSFUL in 2s
10 actionable tasks: 10 executed
Conclusion
Now you are well equipped to go ahead and write many E2E API tests for your gRPC APIs, so what are you waiting for? Go ahead and test some gRPC APIs 😀
Please let me know if you have questions or thoughts in the comments.
In the next post, we will grasp how to write a Non-functional load test on our API, we’ll attempt to gain more confidence that the service works when dealing with live load.
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.