Teste de integração Java explicado com exemplos

Java Integration Testing Explained with Examples

Explore the world of Java integration testing with our comprehensive guide. Understand tools, processes and best practices, complemented with practical examples.

Imagem em destaque

As software systems become larger and more complex, with components and services interacting in complex ways, integration testing has become indispensable. By validating that all components and modules work correctly when combined, Java integration testing provides confidence that the overall system will work as intended.

With the emergence of modular architectures, microservices, and automated deployment, early verification of these complex interactions through integration testing is now a core discipline. Robust integration tests identify defects arising from component interactions that unit tests alone cannot detect. Leveraging a Java integration testing framework can help streamline the process by ensuring that all modules and components are thoroughly evaluated.

In today's world of continuous delivery and DevOps, where rapid iterations and frequent updates are the norm, reliable integration testing is a must to ensure quality and reduce technical debt.

This article explores the tools and techniques for effective integration testing in Java. To manage multiple integration tests efficiently, it is common to group them into a test suite. Each test suite usually consists of multiple test classes, where each class can represent a specific feature or component that is being tested. So whether you're working for a top Java development services company or you're a student looking to sharpen your testing skills, we've got you covered.

What is unit testing?

Unit testing is a software testing process where individual units of code, such as methods, classes, and modules, are tested to see if they work as expected. It is the first step in the software testing life cycle. Unit testing in Java is usually done with JUnit . The purpose of unit testing is to isolate and verify the correctness of individual units of your code. A failed unit test can provide early indications of potential problems, but integration tests will further ensure the cohesion and functionality of the entire system.

Imagine the process of assembling a car. Before assembling the components, each component is rigorously tested. This is more efficient and less time consuming in the long run. Now imagine if all the components were assembled together without proper testing and then the car didn't work. It will take a lot of time and effort to figure out which part is faulty, and a lot more to actually fix it.

This is why we need unit tests. It makes it easier to identify bugs early and save development time.

What is Java integration testing?

Integration testing is a software testing approach where different modules are coupled and tested. The objective is to verify that the modules work as intended when coupled. It is usually performed after unit testing and before system testing.

Integration testing is especially important for applications that consist of multiple layers and components that communicate with each other and with external systems or services.

Imagine you are building a personal computer (PC) from scratch. PC consists of various components such as motherboard, processor, memory, storage devices, graphics card and so on. You have tested all components previously. But when you integrate them into a system, they don't work. The reason is not because the individual components are defective, but rather because they are not compatible with each other. Integration tests help us identify these types of errors.

Differences between integration tests and unit tests

Scope: Unit Testing aims to test the smallest testable units of code, while Integration Testing focuses on testing the interaction of various components of a system.

Complexity: Unit tests tend to be simpler and more focused, as they deal with individual components in isolation. They can be written and executed with relative ease. Integration tests, on the other hand, are generally more complex due to the need to coordinate and verify interactions between multiple components. They require a higher level of installation and configuration to accurately simulate real-world scenarios.

Order of Execution: Generally, unit testing is performed before integration testing. First we need to check that the individual units are functional. Only then can we integrate them into larger modules and test their relationships.

Different approach to integration testing

There are different strategies for conducting integration testing. The most common are the Big Bang approach and the incremental approach (top-down and bottom-up).

big Bang

In this approach, most software modules are coupled and tested. It is suitable for small systems with fewer modules. In some cases, components of a system may be tightly coupled or highly interdependent, making incremental integration difficult or impractical.

Advantages of the Big Bang

  1. Convenient for smaller systems.
  2. It is less time consuming compared to other approaches.

Disadvantages of the Big Bang

  1. With this approach, however, it is difficult to locate the root cause of defects found during testing.
  2. Since you're testing most modules at once, it's easy to miss some integrations.
  3. More difficult to maintain as project complexity increases.
  4. You have to wait for most modules to be developed.

Bottom-up approach

The bottom-up approach focuses on developing and testing the lowest-level independent modules of a system before integrating them into larger modules. These modules are tested and then integrated into even larger modules until the entire system is integrated and tested.

In this approach, testing starts with the simplest components and then moves up, adding and testing higher-level components until the entire system is built and tested.

The biggest advantage of this type of testing is that it is not necessary to wait for all modules to be developed. Instead, you can write tests for those that have already been built. The biggest disadvantage is that you don't have much clarity about the behavior of your critical modules.

Benefits

  1. Bottom-up emphasizes initial coding and testing, which can begin as soon as the first module is specified.
  2. Since the testing process is started from the low-level module, there is a lot of clarity and it is easy to write tests.
  3. It is not necessary to know the details of the structural design.
  4. It's easier to develop test conditions in general since you start at the lowest level.

Disadvantages

  1. If the system consists of a larger number of submodules, this approach becomes time-consuming and complex.
  2. Developers do not have a clear idea about the behavior of critical modules.

Top-down approach

Top-down Java integration testing is a software testing approach where the testing process starts from the most critical modules of the software system and gradually progresses towards the lower-level modules. It involves testing the integration and interaction between different components of the software system in a hierarchical manner.

In top-down integration testing, higher-level modules are tested first, while lower-level modules are replaced with stubs or mock versions that provide the expected behavior of lower-level modules. As testing progresses, lower-level modules are gradually incorporated and tested together with higher-level modules.

Benefits

  1. Critical modules are tested first.
  2. Developers have a clear idea about the behavior of critical application functionalities.
  3. Easy to detect problems at the top level.
  4. It is easier to isolate interface and data transfer errors due to the incremental, top-down nature of the testing process.

Disadvantages

  1. Requires the use of simulations, stubs and spies.
  2. We still have to wait for the development of critical modules.

Mixed Approach (Sandwich)

The mixed or sandwich approach is the combination of the bottom up approach and the top down approach. Typically, with this approach you have multiple layers, each of which is built using either a top-down or bottom-up approach.

Steps involved in integration testing

Choosing the right tools and frameworks

Java is a popular high-level programming language. It has a vast ecosystem of frameworks and libraries for testing. Here are some of the most commonly used tools for integration testing:

  1. JUnit5: A widely used testing framework for Java that can be used to write unit tests and integration tests.
  2. TestNG: Another popular testing framework that provides features like parallel test execution and test configuration flexibility.
  3. Spring Boot Testing: If you are working with Spring Boot, this module provides extensive support for Java integration testing, including the @SpringBootTest annotation.
  4. Mockito: A powerful mocking framework that allows you to mock dependencies and focus on testing specific components in isolation.
  5. Test Containers: A Java library that allows you to define and run Docker containers for your dependencies during testing.

Throughout this article we will use JUnit5 as our main testing framework. We will have one or two examples with the Spring Boot Test framework. These frameworks have a very intuitive syntax, so it's easy to follow even if you're not using JUnit5.

Configuration Test Environment

Setting up a test environment is the first step to Java integration testing. Ideally, you would want to set up a database, mock some dependencies, and add test data.

Adding test data

Before starting a test, you can populate your database using the Junit5 @BeforeEach annotation.

 @DataJpaTest
 public class UserRepositoryIntegrationTest {

  @Autowired
  private UserRepository userRepository;

  @BeforeEach
  public void setUpTestData {
    User user1 = new User("John Doe", "(email protected)");
    User user2 = new User("Jane Smith", "(email protected)");

    userRepository.save(user1);    userRepository.save(user2);
  }
 }

Simulations and stubs

Mocking and stubbing help you isolate your dependencies or mimic a dependency that has not yet been implemented. In the example below, we are creating a mock for the UserRepository class.

 import org.junit.jupiter.api.Test;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.mockito.Mockito.*;

 public class UserServiceTest {

  @Test
  public void testCreateUser {
    UserRepository userRepository = mock(UserRepository.class); 
UserService userService = new UserService(userRepository);
 User user = new User("John Doe", "(email protected)");

 when(userRepository.save(user)).thenReturn(true);
 boolean result = userService.createUser(user);

 verify(userRepository, times(1)).save(user);
 assertEquals(true, result);
 }
 }

Configuring the H2 database

H2 is an in-memory database often used for testing as it is fast and lightweight. To configure H2 for integration tests, you can use the @DataJpaTest annotation provided by Spring Boot. This annotation sets up an in-memory database and you can use your JPA repositories to interact with it.

 import org.junit.jupiter.api.Test;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
 import static org.junit.jupiter.api.Assertions.assertEquals;

 @DataJpaTest
 public class UserRepositoryIntegrationTest {

  @Autowired 
private UserRepository userRepository;

 @Test
 public void testSaveUser {
 User user = new User("John", "(email protected)");
 userRepository.save(user);
 User savedUser = userRepository.findByEmail("(email protected)");
 assertEquals(user.getName , savedUser.getName );
 assertEquals(user.getEmail , savedUser.getEmail );
 }
 }

One thing to note is that your application may not be using H2 as its primary database. In this case, you are not mimicking your production environment. If you want to use PostgreSQL or MongoDB databases in your test environment, you must use containers (discussed later).

Recording and Reporting

One of the simplest ways to record your tests is through logs. Logs can help you quickly identify or replicate the root cause of any defects in your application.

Here is a simple logging setup using Logback and SLF4J. Logback helps us personalize logged messages. We can provide details like date, time, thread, log level, trace and more. To get started, create a logback.xml file and add the configuration as shown below.

 <configuration>
  <appender name="STDOUT" >
    <encoder>
      <pattern>%d{HH:mm:ss.SSS} (%thread) %level - %msg%n</pattern>
    </encoder>
  </appender>

  <root level="debug">
    <appender-ref ref="STDOUT" />
  </root>
 </configuration>

Here, %d{HH:mm:ss.SSS} prints the time (H for hours, m for minutes, s for seconds, and S for milliseconds). %thread, %level, %msg and %n print the thread name, log level, message and newline respectively. The appender created above displays information to standard output. But you can do things like store the logs in a file.

Now we can use SLF4J's Logger facade in our java code.

 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;

 public class ExampleTest {
  private static final Logger logger
      = LoggerFactory.getLogger(ExampleTest.class);

  public static void main(String args) {
    logger.info("Hello from {}", ExampleTest.class.getSimpleName );
  }
 }

If you run it, the output will be something like this.

 14:45:01.260 (main) INFO - Hello from ExampleTest

Running tests in containers

Docker containers are one of the best ways to mimic your production environment, since in many cases your own application is running inside some remote containers. For these demos, we will write a simple test and then run it inside a docker container.

 // SampleController.java
 @RestController
 public class SampleController {
  @GetMapping("/hello")
  public String sayHello {
    return "Hello world";
  }
 }

 // SampleApplication.java 
@SpringBootApplication
 public class SampleApplication {
 public static void main(String args) {
 SpringApplication.run(SampleApplication.class, args);
 }
 }

The code above creates a simple REST API that returns “Hello world”. We can use Spring Boot Test to test this API. Here we are simply checking whether the API returns a response or not and the response body should contain “Hello world”.

 @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
 class SampleApplicationTests {

  @LocalServerPort
  private int port;

  @Autowired
  private TestRestTemplate restTemplate;

  @Test
  public void testHelloEndpoint {
    ResponseEntity<String> response = restTemplate.getForEntity(" + port + "/hello", String.class);
    assertEquals(HttpStatus.OK, response.getStatusCode );
    assertEquals("Hello, World!", response.getBody );
  }
 }

Now you can create a Dockerfile and add the following configuration. The Dockerfile contains instructions on how to build your application.

 FROM openjdk:17-jre-slim
 WORKDIR /app
 COPY target/sample-application.jar .
 CMD ("java", "-jar", "sample-application.jar")

Run the following command to build and start your docker container.

 docker build -t sample-application .
 docker run -d -p 8080:8080 sample-application

You can use docker-compose to orchestrate multiple containers. One challenge with docker compose is when you run integration tests; you cannot change the configuration during runtime. Plus, your containers will continue to work even if your tests fail. Instead, we'll use a library called testcontainers to dynamically start and stop containers.

Test containers

Testcontainers is a Java library that simplifies the process of running isolated, disposable containers for integration testing. It provides lightweight, pre-configured containers for popular databases (e.g. PostgreSQL, MySQL, Oracle, MongoDB), message brokers (e.g. Kafka, RabbitMQ), web servers (e.g. Tomcat, Jetty) and more more.

Using Testcontainers, you can define and manage containers in your Java integration tests, allowing you to test against real instances of dependencies without the need for external infrastructure. Testcontainers handles container lifecycle management, automatic provisioning, and integration with testing frameworks like JUnit or TestNG.

 import org.junit.jupiter.api.Test;
 import org.testcontainers.containers.PostgreSQLContainer;
 import org.testcontainers.junit.jupiter.Container;
 import org.testcontainers.junit.jupiter.Testcontainers;
 import static org.junit.Assert.*;

 @Testcontainers 
public class PostgreSQLIntegrationTest {

 @Container
 private static final PostgreSQLContainer<?> postgresContainer = new PostgreSQLContainer<>("postgres:latest")
 .withDatabaseName("db_name")
 .withUsername("johndoe")
 .withPassword("random_password");

 @Test
 public void testPostgreSQLContainer {
 assertTrue(postgresContainer.isRunning )
 }
 }

Best practices when writing integration tests

Start writing unit and integration tests from scratch

In the traditional waterfall approach, tasks are executed sequentially. Testing usually comes into play in the last stages of the development cycle. Since your application is tested later, the chances of bugs going unnoticed and reaching production are quite high.

In contrast, with the agile approach you start writing your tests from the beginning. It ensures that every time you make a small change to your codebase, you will receive immediate feedback on whether your changes have any effect on the existing codebase. If a unit test fails and you realize there is a problem, you can resolve it immediately before it becomes a big problem in later stages. This is the main advantage of the agile approach, where writing tests in advance provides continuous feedback, making it difficult to introduce bugs at any stage.

Prioritizing tests

Integration tests can be slow, and in scenarios where they require significant time and resources, running them repeatedly becomes impractical. In these situations, prioritizing your tests can save a lot of valuable time. You can prioritize testing based on factors such as the level of risk associated with a failure, the complexity of the functionality being tested, and the potential impact on end users or the system as a whole.

In Junit5, using test classes, you can add tags to your tests and prioritize them. Here is an example.

 // SampleTests.java
 public class SampleTests {
 @Test
 @Tag("HIGH")
 public void testCriticalFunctionality {}

 @Test
 @Tag("LOW")
 public void testLessCriticalFunctionality {}
 }

 // HighPriorityTestSuite.java
 // only testing the high priority integrations
 import org.junit.platform.suite.api.IncludeTags;
 import org.junit.platform.suite.api.SelectPackages;
 import org.junit.platform.suite.api.Suite;

 @Suite
 @IncludeTags("HIGH") 
public class HighPriorityTestSuite {}

Mimic production environments as closely as possible

Create test environments that resemble the production environment as closely as possible. This includes configuring configurations, databases, network conditions, and any similar external dependencies.

Design test cases for all scenarios

Design test cases and decide the right testing method for all scenarios. Create test cases and test methods that cover multiple scenarios and edge cases to ensure maximum coverage. Test positive scenarios and perform negative integration tests to verify the system's behavior in different situations.

Record and report your tests

When an integration test fails, especially in large software projects, it can be time-consuming to identify the cause. After a failed integration test, it is beneficial to have test runs recorded so that you can easily identify the root cause or reproduce the issue.

Conclusion

In this article, we explore the concept, benefits, and various Java integration testing frameworks used for integration testing in Java. We also cover how to use frameworks like JUnit5, Spring Boot Test, TestContainers, and Logback to write effective integration tests. Integration testing is essential, so integration testing plays a crucial role in ensuring the performance, quality and functionality of our Java applications. It also allows us to check the interactions and dependencies between different components and layers. We encourage you to explore more about the topic discussed here.

Common questions

How can I ensure test data consistency during integration tests?

For integration testing, it is essential to configure and manage consistent test data for the various components. You can achieve this by using test data factories, test databases, or data propagation mechanisms.

What are common challenges in Java integration testing?

Challenges in Java integration testing can include handling external dependencies such as databases, services, or APIs, managing test environment configurations, and ensuring that tests run efficiently and repeatably.

What is a “contract test” in the context of integration testing?

Contract testing is a form of integration testing that checks compatibility and communication between different services or components based on their defined contracts (e.g. API specifications or message formats).

How can I ensure code quality before running integration tests?

Before running integration tests, it is beneficial to perform static code analysis. This process involves examining software code without actually running it, with the aim of detecting vulnerabilities, potential bugs, and areas for improvement. Static code analysis ensures that code meets quality standards, follows best practices, and is free from common errors, laying a solid foundation for subsequent testing phases.

What Java build tools can help with setting up integration test environments?

Maven and Gradle are Java build tools that help you set up integration testing environments. Its plugins and configurations manage dependencies and run test suites to standardize testing across teams.

If you liked this, be sure to check out one of our other Java articles:

  • The Pros and Cons of Java Development
  • Top 10 most popular Java frameworks
  • What is Java used for? 8 things you can create
  • 7 Best Java Testing Frameworks in 2021
  • Clutch.co names BairesDev as top PHP and Java developers

Source: BairesDev

Back to blog

Leave a comment

Please note, comments need to be approved before they are published.