paint-brush
Testing in Java: The Key Concepts [Part 1: Unit Testing]by@gromspys
1,628 reads
1,628 reads

Testing in Java: The Key Concepts [Part 1: Unit Testing]

by Sergei KorneevNovember 14th, 2023
Read on Terminal Reader
Read this story w/o Javascript

Too Long; Didn't Read

In this article, we will explore the key concepts of Java unit testing, including simple code examples, parametrization, exception testing, and annotations.
featured image - Testing in Java: The Key Concepts [Part 1: Unit Testing]
Sergei Korneev HackerNoon profile picture

Unit testing is a fundamental practice in software development that ensures the reliability and correctness of your code. In this article, we will explore the key concepts of Java unit testing, including simple code examples, parametrization, exception testing, annotations such as @Before, @BeforeEach, @After, and @AfterEach, as well as the use of mocks and stubs.


We'll also introduce the popular Mockito library for mocking and provide guidance on measuring code coverage using Maven plugins.

The Importance of Unit Testing

Unit testing is the practice of testing individual components or units of code in isolation to verify their correctness. The primary goal is to detect and fix bugs early in the development process, ensuring that each unit of code behaves as expected.


Let's start by creating a simple calculator class that we can use for our examples. The Calculator class contains basic arithmetic operations:

public class Calculator {
    public int add(int a, int b) {
        return a + b;
    }

    public int subtract(int a, int b) {
        return a - b;
    }

    public int multiply(int a, int b) {
        return a * b;
    }

    public int divide(int a, int b) {
        return a / b;
    }
}

Writing a Simple Unit Test

To create a unit test for our Calculator class, you can use the JUnit framework, a popular testing framework for Java. Let's write a simple unit test for the add method:

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

public class CalculatorTest {
    @Test
    public void testAdd() {
        Calculator calculator = new Calculator();
        int result = calculator.add(2, 3);
        assertEquals(5, result);
    }
}

In this test, we import the necessary JUnit classes and annotate the test method with @Test. We then create an instance of the Calculator class and assert that the result of the add method is equal to the expected value (5).

Parameterized Tests

Parameterized tests allow you to run the same test logic with multiple sets of input data. This is useful for testing a method with various input values. To do this in JUnit, you can use the @ParameterizedTest annotation and provide the input values and expected outcomes as parameters. Here's an example:

import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import static org.junit.jupiter.api.Assertions.assertEquals;

public class CalculatorTest {
    @ParameterizedTest
    @CsvSource({"2, 3, 5", "4, 7, 11", "0, 0, 0"})
    public void testAdd(int a, int b, int expected) {
        Calculator calculator = new Calculator();
        int result = calculator.add(a, b);
        assertEquals(expected, result);
    }
}

In this example, the @ParameterizedTest annotation allows us to provide multiple sets of input values and expected outcomes in a CSV format. The test method is executed for each combination, ensuring the correctness of the add method.

Exception Testing

Testing exceptions is crucial to ensure your code handles errors appropriately. To test exception cases, you can use the @Test annotation along with the assertThrows method provided by JUnit.


Here's an example:

javaCopy codeimport org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertThrows;

public class CalculatorTest {
    @Test
    public void testDivideByZero() {
        Calculator calculator = new Calculator();
        assertThrows(ArithmeticException.class, () -> calculator.divide(5, 0));
    }
}

In this test, we verify that dividing a number by zero in the Calculator class throws an ArithmeticException.

Using Annotations: @Before, @BeforeEach, @After, and @AfterEach

Annotations like @Before, @BeforeEach, @After, and @AfterEach are used to set up and tear down the test environment. These annotations help in managing common test initialization and cleanup tasks.


  • @Before and @After run once before and after all test methods in the test class, respectively.
  • @BeforeEach and @AfterEach run before and after each test method.


Here's an example:

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;

public class CalculatorTest {
    private Calculator calculator;

    @BeforeEach
    public void setUp() {
        calculator = new Calculator();
    }

    @AfterEach
    public void tearDown() {
        calculator = null;
    }

    @Test
    public void testAdd() {
        int result = calculator.add(2, 3);
        assertEquals(5, result);
    }

    @Test
    public void testSubtract() {
        int result = calculator.subtract(5, 3);
        assertEquals(2, result);
    }
}

In this example, the setUp method is annotated with @BeforeEach, and it initializes the Calculator object before each test. The tearDown method, annotated with @AfterEach, cleans up resources after each test.

Mocks and Stubs

Mocks and stubs are essential in unit testing when you want to isolate the code being tested from external dependencies, such as databases or web services. They allow you to simulate the behavior of these dependencies.


  • Mock: A mock is an object that mimics the behavior of a real object but is under your control. It records interactions and allows you to verify that specific methods are called.


  • Stub: A stub provides canned responses to method calls. It returns predefined data and is used to simulate certain behaviors of an external system.


Let's expand the Calculator class to include a method that involves an external dependency and then create a stub to simulate that dependency.

public class Calculator {
    private ExternalService externalService;

    public Calculator(ExternalService externalService) {
        this.externalService = externalService;
    }

    public int performComplexCalculation(int a, int b) {
        int result = externalService.multiply(a, b);
        return result + externalService.getConstant();
    }
}

In this updated Calculator class, performComplexCalculation relies on an ExternalService for multiplication and obtaining a constant value.


Here's how you might create a stub to test the Calculator class without depending on the actual ExternalService.

import org.junit.jupiter.api.Test;

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

public class CalculatorTest {
    @Test
    public void testPerformComplexCalculation() {
        // Create a stub for ExternalService
        ExternalService externalServiceStub = new ExternalService() {
            @Override
            public int multiply(int a, int b) {
                return a * b;
            }

            @Override
            public int getConstant() {
                return 5; // Stubbed constant value
            }
        };

        // Create the Calculator with the stubbed ExternalService
        Calculator calculator = new Calculator(externalServiceStub);

        // Test the performComplexCalculation method
        int result = calculator.performComplexCalculation(2, 3);
        assertEquals(11, result); // 2*3 + 5 (constant) = 11
    }
}

In this test, ExternalService is stubbed within the test method by creating an anonymous class that overrides the necessary methods. This way, the Calculator test method runs without depending on the actual implementation of ExternalService.


Stubs are useful for simulating the behavior of external systems or dependencies to isolate and test the specific functionality of a class or method. This allows you to control the behavior of the stub and focus on the unit under test without the need for actual external services.

Introducing Mockito for Mocking

Mockito is a popular Java library for creating and managing mock objects. Let's say we have a PaymentService class that interacts with an external payment gateway. We can use Mockito to create a mock for the payment gateway:

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

public class PaymentServiceTest {
    @Test
    public void testProcessPayment() {
        PaymentGateway paymentGateway = mock(PaymentGateway.class);
        PaymentService paymentService = new PaymentService(paymentGateway);

        when(paymentGateway.charge(100.0)).thenReturn(true);

        boolean result = paymentService.processPayment(100.0);

        assertTrue(result);
        verify(paymentGateway, times(1)).charge(100.0);
    }
}

In this example, we create a mock PaymentGateway and use the when method to define its behavior. We then call the processPayment method on the PaymentService class and verify that the charge method was called with the expected parameters.

Code Coverage and Maven Plugin

Code coverage measures the percentage of code lines, branches, or statements that are executed by your unit tests. It helps you identify untested code and areas that may require additional testing.


Maven is a popular build tool for Java projects, and you can integrate code coverage analysis into your Maven project using plugins like JaCoCo. Here's how to add JaCoCo to your project:


  1. Open your project's pom.xml file.
  2. Add the JaCoCo Maven plugin configuration:
<build>
    <plugins>
        <plugin>
            <groupId>org.jacoco</groupId>
            <artifactId>jacoco-maven-plugin</artifactId>
            <version>0.8.7</version>
            <executions>
                <execution>
                    <goals>
                        <goal>prepare-agent</goal>
                    </goals>
                </execution>
                <execution>
                    <id>report</id>
                    <phase>test</phase>
                    <goals>
                        <goal>report</goal>
                    </goals>
                </execution>
            </executions>
        </plugin>
    </plugins>
</build>


Run your Maven build with the following command:

mvn clean verify


After running the build, you can find the code coverage report in the target/site/jacoco/index.html file. This report provides detailed information about the coverage of your code by your unit tests.

Conclusion

Java unit testing is an essential practice for ensuring the reliability and correctness of your code. With tools like JUnit and Mockito, you can write effective unit tests and mocks for your components.


By integrating code coverage analysis with Maven and JaCoCo, you can ensure that your tests cover a significant portion of your codebase. Regularly testing and maintaining unit tests will help you produce high-quality and robust Java applications.


In the next part of this series, we will explore integration testing, a critical aspect of software testing that involves testing the interactions between different components and services to ensure the overall system functions correctly.


Stay tuned for part two, where we will dive into the exciting world of integration testing!