Testing Microservices Before Production Deployment: A Guide for Software Developers

This article covers all possible ways of testing a microservice before deploying it in PROD, essentially for Software Developers.

Pravinkumar Singh
12 min readFeb 10, 2024
image by Pravinkumar Singh

You’ve finished creating your microservice and are all set to launch it into production, but wait a minute — did you make sure to test it thoroughly? Testing isn’t something you can afford to overlook; it’s a crucial step for any software developer. Let’s explore all the ways you can test your microservice to ensure it’s ready for the big leagues.

Let’s say your microservice is crafted using REST API, which is a common method for building such services. Picture it as a small block of code that takes a request, churns out a response, and sends it back to whoever asked for it. Here’s what that might look like:

@RestController
@RequestMapping("/books")
public class BookController {

private Map<Long, String> bookRepository = new ConcurrentHashMap<>();

@PostMapping
public ResponseEntity<String> addBook(@RequestBody String name) {
Long id = (long) (bookRepository.size() + 1);
bookRepository.put(id, name);
return new ResponseEntity<>("Book added successfully with ID " + id, HttpStatus.CREATED);
}

@GetMapping("/{id}")
public ResponseEntity<String> getBook(@PathVariable Long id) {
String bookName = bookRepository.get(id);
if (bookName == null) {
return new ResponseEntity<>("Book not found", HttpStatus.NOT_FOUND);
}
return new ResponseEntity<>(bookName, HttpStatus.OK);
}
}

The BookController microservice you’ve coded in Java uses the Spring framework and includes two key endpoints: one for adding a book and another for fetching a book by its ID.

Now, there’s a whole set of tests you can run on your microservice, including:

  1. Unit Testing — Validates the functionality of individual units of code in isolation, which is foundational for code reliability.
  2. Integration Testing — Ensures that multiple microservices or components within a service work together as intended.
  3. Functional Testing — Focuses on testing the software against defined specifications and requirements, essentially checking if the software does what it is supposed to do from the user’s perspective.
  4. End-to-End Testing (E2E Testing) — Tests the flow of an application from start to finish, ensuring the system behaves correctly as a whole.
  5. Smoke Testing — A preliminary test to check the basic functionality of the application, acting as an initial assurance before more intense testing.
  6. Load Testing — Follows smoke tests to validate the application’s behaviour under normal and peak load conditions, ensuring the service can handle expected traffic.
  7. Stress Testing — Takes load testing further by evaluating the system’s behaviour beyond normal operational capacity, often to find its breaking point.
  8. Penetration Testing — A specialized type of Security Testing where simulated attacks are carried out to evaluate the system’s security robustness.
  9. Canary Testing — A pattern for rolling out updates to a small subset of users first, to monitor the real-world impact before a wide-scale release.

Got all that? Great! Let’s go through each of these tests with some real-world examples.

Unit Testing

Unit Testing is a big deal in the world of testing, he is the dude of the Testing world. It ensures every single piece of code, called a ‘unit’, does its job right, all by itself. This is super important for microservices, as they’re made up of many parts that have to be perfect on their own.

For those working with Java, JUnit is the go-to for Unit Testing. It’s straightforward to use for writing tests over and over again, helps organize your tests clearly, and lets you check if you’re getting the results you want.

Now, let’s look at how this works with the BookController microservice example:

import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.ResponseEntity;
import org.springframework.http.HttpStatus;

public class BookControllerUnitTest {

private BookController controller;
private Long uniqueID;

@BeforeEach
void setUp() {
controller = new BookController();
uniqueID = System.currentTimeMillis();
}

@Test
public void whenAddBook_thenRespondWithId() {
ResponseEntity<String> response = controller.addBook("2024");
assertTrue(response.getBody().contains("Book added successfully with ID "));
assertEquals(HttpStatus.CREATED, response.getStatusCode());
}

@Test
public void whenGetExistingBook_thenRespondWithName() {
controller.addBook("2024");
ResponseEntity<String> response = controller.getBook(uniqueID);
assertEquals("2024", response.getBody());
assertEquals(HttpStatus.OK, response.getStatusCode());
}

@Test
public void whenGetNonExistingBook_thenRespondNotFound() {
ResponseEntity<String> response = controller.getBook(uniqueID);
assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode());
}
}
  • Make sure your unit tests are focused; they should only test one functionality.
  • Use mocking frameworks like Mockito to simulate other dependencies’ behaviour and ensure tests remain focused on the unit of code.
  • Adopt Test-Driven Development (TDD) where you write tests before the implementation. It helps you think through the function’s design and edge cases.

Integration Testing

Integration testing is all about ensuring that different parts of a microservice work together correctly. It’s the bridge between unit tests, which cover individual components, and End-to-End Testing (E2E Testing) tests that validate the entire system’s functionality.

As each service in a microservices architecture often runs in its own environment, following 12-factor-app, with its own data store and dependencies, integration testing ensures that the communication contracts between services are honored.

For Java microservices, the Spring Boot Test framework is an excellent choice for crafting integration tests as it allows for launching an entire Spring application context or just parts of it. This makes it possible to test how different beans or services interact with one another.

Let’s see an integration test for the BookController microservice we developed, using Spring Boot to simulate the RESTful interactions between the controller and its clients, the Spring Boot Test framework is an excellent choice for creating integration tests as it allows for launching an entire Spring application context or just parts of it.

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.boot.web.server.LocalServerPort;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;

import static org.junit.jupiter.api.Assertions.*;

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class BookControllerIntegrationTest {

@LocalServerPort
private int port;

@Autowired
private TestRestTemplate restTemplate;

@Test
void whenPostRequestToBooks_thenCorrectResponse() {
ResponseEntity<String> response = restTemplate.postForEntity(
"http://localhost:" + port + "/books",
"Brave New World",
String.class
);

assertEquals(HttpStatus.CREATED, response.getStatusCode());
assertTrue(response.getBody().contains("Book added successfully with ID "));
}

@Test
void whenGetRequestToBooks_thenCorrectResponse() {
String bookName = "Brave New World";
restTemplate.postForEntity("http://localhost:" + port + "/books", bookName, String.class);

ResponseEntity<String> response = restTemplate.getForEntity(
"http://localhost:" + port + "/books/1",
String.class
);

assertEquals(HttpStatus.OK, response.getStatusCode());
assertEquals(bookName, response.getBody());
}
}

In the above tests, we’re simulating a real scenario where a client posts a new book to the controller and retrieves it afterward. Assuming the book is stored in a separate Database, this tests the integration of the database with REST API. Its like testing interation of 2 separate logical units.

  • Create your integration tests to mirror real-world use cases as closely as possible.
  • Always clean up your test data after an integration test has run to avoid side effects on subsequent tests.
  • Account for network delays and timeouts in your integration tests. Real-world communication is not instantaneous!

Functional Testing

Functional testing is about ensuring a software system does what it’s told to do in its user-specification. Although the units/components may function correctly in isolation (unit testing) and the services cooperating well with one another (integration testing), the functional tests verify the outcome of user actions by mimicing them.

Let’s see an example of functional testing with the BookController microservice. We are going to use REST-assured to write a functional test which will portray how the controller should perform in real-world scenarios, REST-assured has become a go-to framework for functional testing of REST APIs :

import io.restassured.RestAssured;
import static io.restassured.RestAssured.given;
import static org.hamcrest.CoreMatchers.equalTo;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

public class BookControllerFunctionalTest {

@BeforeEach
public void setup() {
RestAssured.port = 8080;
RestAssured.baseURI = "http://localhost";
RestAssured.basePath = "/books";
}

@Test
public void testAddBookFunctionality() {
given()
.contentType("text/plain")
.body("The Alchemist")
.when()
.post("/")
.then()
.statusCode(201)
.body(equalTo("Book added successfully with ID 1"));
}

@Test
public void testGetBookFunctionality() {
given()
.contentType("text/plain")
.body("The Alchemist")
.when()
.post("/")
.then()
.statusCode(201);

given()
.when()
.get("/1")
.then()
.statusCode(200)
.body(equalTo("The Alchemist"));
}
}

In the example above, we simulate two scenarios: adding a book and then retrieving the same book. These actions mimic what an end-user might attempt as per user specs.

  • Design tests around user stories to cover typical user journeys.
  • Maintain a separate set of test data to prevent impacting the actual data in your development and staging environments.
  • At least add testing for functionalities critical to your business logic and user experience.

End-to-End Testing (E2E Testing)

E2E Testing is very important because it simulates real user scenarios, ensuring that the system as a whole can handle tasks as it is intended to when deployed to the real world. Think of it like we are trying to test the REST API from the web browser. Gotcha?

Considering the BookController is just one component within a larger system, let's imagine a scenario where a user interacts with a web application to use the functionality provided by our controller. They would go through several steps, such as logging in, navigating to the books section, adding a new book, and checking that it appears in the list.

Since the BookController doesn’t have a UI, we'll create a high-level flow, using Selenium, assuming such interfaces exist:
Selenium is a powerful tool for automating browsers, allowing us to replicate user interactions with our microservice’s user interface as part of E2E Testing, assuming such interfaces exist:

import org.openqa.selenium.By;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.chrome.ChromeDriver;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

public class BookControllerE2ETest {

private WebDriver driver;

@BeforeEach
public void setup() {
System.setProperty("webdriver.chrome.driver", "chromedriver.exe");
driver = new ChromeDriver();
driver.get("http://localhost:8080");
}

@Test
public void testCompleteUserFlowForAddingBooks() {
// Assuming we're starting at the application's homepage
driver.findElement(By.id("login")).click();
driver.findElement(By.id("username")).sendKeys("testUser");
driver.findElement(By.id("password")).sendKeys("testPass");
driver.findElement(By.id("submitLogin")).click();

// Navigate to the books section
driver.findElement(By.id("navBooks")).click();

// Add a new book
driver.findElement(By.id("addBook")).click();
driver.findElement(By.id("bookName")).sendKeys("The Alchemist");
driver.findElement(By.id("submitBook")).click();

// Check that the book is now in the list
WebElement bookList = driver.findElement(By.id("bookList"));
assertTrue(bookList.getText().contains("The Alchemist"));
}

@AfterEach
public void teardown() {
if (driver != null) {
driver.quit();
}
}
}

In the above example, it will start a web browser using Chrome Driver and perform actions such as login, navigate book, add book, submit book, get Booklist and verify the added book.

Smoke Testing

Smoke Testing, sometimes referred to as ‘Build Verification Testing’, is a preliminary check to ensure that the most crucial functions of microservice are working after a new build or release. It’s called ‘smoke testing’ because it’s like turning on a machine for the first time and checking for smoke — which would imply something is immediately and obviously wrong.

In the context of microservices, Smoke Testing helps identify any early signs of trouble with key functionalities before spending time in more detailed assessments — the idea is to fail fast if there’s an issue.

Our BookController’s health could be determined by a quick test that ensures its main endpoint is reachable and responsive, let’s see with an example using JUnit and Spring Boot.

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertEquals;

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class BookControllerSmokeTest {

@Autowired
private TestRestTemplate restTemplate;

@Test
public void bookControllerBasicEndpointTest() {
ResponseEntity<String> response = restTemplate.getForEntity("/books/1", String.class);

// In this smoke test, we are not concerned with the actual content of the response, just the HTTP status
assertEquals(response.getStatusCode(), HttpStatus.OK);
}
}

The Smoke Testing is different from other testing, it is interested in confirming whether the key components are operational, supporting prompt go/no-go decisions post-build.

Load Testing

Load Testing is performed to validate that the microservice can sustain the anticipated number of concurrent users while maintaining acceptable response times and error rates. You can’t risk discovering that your microservice is as congested as Mumbai’s local trains during rush hour when it goes live.
Apache JMeter is a widely recognized tool for performance testing, particularly adept at simulating multiple users and measuring the application’s performance under load.
Let’s see the steps to load test our BookController microservice

1. Determine your peak load expectations for the `BookController` (e.g., 1,000 users trying to add books simultaneously).
2. Set up Apache JMeter to simulate multiple users performing the "add book" action by hitting the POST endpoint.
3. Configure JMeter to ramp up to the peak load gradually, and then maintain that level for a pre-defined period.
4. Execute the test and collect the results on response times, throughput, error rates, etc.
5. Analyze the data to identify any performance bottlenecks or failures under load.

This JMeter setup for our BookController would involve creating an HTTP request in a JMeter test plan, then defining the number of threads (users), ramp-up period (how quickly JMeter will add threads), and the number of times to execute the test.

  • Start with low traffic and gradually increase the load to the expected peak. This helps identify at which point the system begins to degrade.
  • Keep an eye on CPU, memory, and network I/O during testing to understand the system’s limitations.
  • Use the test results to identify performance bottlenecks, make optimizations, and repeat the tests until acceptable performance is achieved.

Stress Testing

Stress Testing is the act of pushing a system to its limits to gauge its threshold. Its purpose is to identify the breaking point of a system under extreme conditions. This kind of testing is crucial, as it helps in understanding how the system behaves under extreme stress and ensures that it fails gracefully and recovers without losing critical information.

Apache JMeter, much like for Load Testing, can also be used for Stress Testing to incrementally increase the load on the system until it reaches its breaking point.

Again, since the BookController interact with databases and other services, Stress Testing involves exerting it beyond its expected operational loads. Here's an outline to employ JMeter in a way that would stress test the controller:

1. Set up a JMeter test plan similar to Load Testing, but with the goal of finding the upper limits.
2. Begin with a load level above the expected peak and continue to ramp up the load until the 'BookController' service starts to show errors or response times become unacceptable.
3. Monitor the behavior of the service under stress, noting any unexpected behaviors or failures.
4. Once the breaking point is found, slowly reduce the load to determine recovery points and the service's ability to handle subsequent requests.

Actual JMeter configuration will involve defining thread groups, loop counts, and assertions to detect any failures in the service as it undergoes Stress Testing.

Load Testing checks performance under expected conditions, while Stress Testing evaluates reliability beyond normal operational capabilities. Think of Load Testing as normal day-to-day use, whereas Stress Testing is like the season finale or a special event that attracts an extraordinary surge of users.

Penetration Testing

As the name suggest, it is about exploiting the public exposed APIs, as some microservices are often exposed to the internet, they are vulnerable to external threats. Pen-testing helps to identify and fix security flaws before they can be exploited by real-world attackers.

The Open Web Application Security Project (OWASP) provides a tool called Zed Attack Proxy (ZAP) which is one of the world’s most popular free security tools and is actively maintained by hundreds of international volunteers. It can help you automatically find security vulnerabilities in your web applications while you are developing and testing your applications.

Penetration Testing typically involves a suite of tools and techniques to probe and attack the system. While you won’t find a single block of code that constitutes a pen-test, here’s what you might focus on when pen-testing the ‘BookController’:

1. Enumerate the 'BookController' endpoints to gather information about potential attack vectors.
2. Utilize scanning tools like OWASP ZAP to perform automated scans for vulnerabilities such as SQL injection, Cross-Site Scripting (XSS), and insecure deserialization.
3. Manually test for logic flaws or improper access controls that automated tools may not catch.
4. If vulnerabilities are found, document them and work with the development team to remediate before retesting.

Penetration Testing distinguishes itself by focusing solely on security aspects, unlike functional or performance testing.

Canary Testing

Named after canaries that were once used in coal mines to detect toxic gases, this type of testing serves as an early warning system.

Canary Testing is vital for microservices as it involves rolling out changes to a tiny subset of users, ensuring that they don’t disrupt the service for everyone if an issue arises. It’s a good way to catch unforeseen bugs and user experience issues that didn’t surface during prior testing stages.

Modern deployment tools like Spinnaker or service meshes like Istio are often used to orchestrate canary deployments. They can route specific percentages of traffic to the new version of the microservice, enabling precise control over the canary test.

Canary Testing doesn’t involve writing test cases like unit or integration testing. Instead, it’s about the deployment strategy. For our ‘BookController’, imagine you’re rolling out a new feature — let’s say it now recommends books based on user preferences. Canary Testing would look like this in practice:

1. Deploy the new version of 'BookController' alongside the stable version.
2. Use Istio to route a small percentage of live traffic, say 5%, to the new version, while the majority still hits the stable version.
3. Monitor performance and errors closely. Tools like Prometheus and Grafana, integrated with your application's metrics, can provide real-time insights.
4. If the service is stable, gradually increase the traffic percentage to the new version. If issues arise, rollback is possible with minimal user impact.
  • Integrate extensive monitoring and alerting tools for real-time observation of the canary release’s performance.
  • Before deploying, define the performance metrics or errors that will trigger an automatic rollback.

In conclusion, including a testing component in your application is essential. You have the freedom to choose the types of testing that best suit your project’s needs based on how critical each feature is. And a personal recommendation — consider adopting the Test-Driven Development (TDD) approach.

Phew, and with that, I’ll bring this article to a close. If you’ve stuck with me this far, I hope you’ve found some valuable insights.

Peace out!

--

--