Course Title: Mastering Unit Testing in Java Spring Boot

Target Audience:

Course Objectives:

Course Modules:

1. Why Unit Testing?

Theory: Why Unit Testing Matters and Its Benefits

Unit testing is a fundamental practice in software development that ensures individual components of the code work as expected. By writing unit tests, developers can:

Key Concepts
Challenges of Not Having Unit Tests
Hands-On: Setting Up a Spring Boot Application for Unit Testing
CalculatorService Code

Provide the following CalculatorService class to students so they don't lose time writing code:

import org.springframework.stereotype.Service;

@Service
public class CalculatorService {
    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) {
        if (b == 0) {
            throw new IllegalArgumentException("Division by zero is not allowed");
        }
        return a / b;
    }
}

2. Anatomy of a Good Unit Test

Theory: Test Structure and Characteristics of a Good Unit Test

A well-written unit test follows a clear and structured format. This structure helps make the test easy to read and understand, and ensures that it properly verifies the functionality of the code. The three key elements of a good unit test are:

Characteristics of a Good Unit Test
Common Problems in Unit Tests
Hands-On: Analyzing and Improving a Test



Hands-On Exercise for Students

4. Assertions with AssertJ

Theory: Using Assertions Effectively

Assertions are a critical component of unit tests because they verify whether the actual outcome of the code matches the expected outcome. AssertJ is a popular assertion library for Java that provides a fluent, easy-to-read API for making assertions. AssertJ is particularly favored for its readability and the wide range of built-in matchers, which make assertions more expressive and informative when tests fail.

Why Use AssertJ?
Common AssertJ Assertions
Example: Using AssertJ Assertions
Hands-On: Writing Assertions with AssertJ

5. Testing Controllers

Theory: Testing REST Controllers in Spring Boot

In Spring Boot applications, REST controllers are an integral part of building APIs. Properly testing controllers ensures that the APIs behave as expected under various conditions. Testing controllers can be done in two main ways:

  1. Unit Testing: Focuses on testing the controller in isolation by mocking its dependencies.
  2. Integration Testing: Involves testing the controller with actual dependencies and a running Spring context to ensure everything works end-to-end.

When testing controllers, MockMvc is a powerful tool in Spring Boot that helps simulate HTTP requests and validate responses. It provides an easy way to write both unit and integration tests for controllers.

Code Example: Using MockMvc for Testing

Suppose we have a UserController that manages user-related operations, such as retrieving user details.

UserController Code:

@RestController
@RequestMapping("/users")
public class UserController {
    @Autowired
    private UserService userService;

    @GetMapping("/{id}")
    public ResponseEntity<User> getUserById(@PathVariable int id) {
        User user = userService.getUserById(id);
        if (user != null) {
            return ResponseEntity.ok(user);
        } else {
            return ResponseEntity.notFound().build();
        }
    }
}

UserService Code:

@Service
public class UserService {
    public User getUserById(int id) {
        if (id == 1) {
            return new User(1, "John Doe", "john.doe@example.com");
        } else {
            return null;
        }
    }
}

Testing UserController Using ************MockMvc:

To test the UserController, we will use MockMvc to simulate HTTP requests and verify the controller's responses.

@WebMvcTest(UserController.class)
public class UserControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private UserService userService;

    @Test
    void shouldReturnUserWhenUserIdIsValid() throws Exception {
        // Arrange
        User user = new User(1, "John Doe", "john.doe@example.com");
        when(userService.getUserById(1)).thenReturn(user);

        // Act & Assert
        mockMvc.perform(get("/users/1"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.id").value(1))
                .andExpect(jsonPath("$.name").value("John Doe"))
                .andExpect(jsonPath("$.email").value("john.doe@example.com"));
    }

    @Test
    void shouldReturnNotFoundWhenUserIdIsInvalid() throws Exception {
        // Arrange
        when(userService.getUserById(2)).thenReturn(null);

        // Act & Assert
        mockMvc.perform(get("/users/2"))
                .andExpect(status().isNotFound());
    }
}
Explanation
Hands-On: Testing a Controller

6. Parameterized Tests

Theory: Leveraging Parameterized Tests in JUnit 5

Parameterized tests allow you to run the same test multiple times with different inputs, which is very useful for testing different scenarios without writing redundant code. JUnit 5 provides the @ParameterizedTest annotation, which makes it easy to implement tests that run multiple times with varying parameters.

Using parameterized tests increases test coverage and helps to verify that a function works correctly for a wide range of input values.

Code Example: Creating Parameterized Tests

Consider a simple utility class called MathUtils that provides a method to check if a number is even.

MathUtils Code:

public class MathUtils {
    public static boolean isEven(int number) {
        return number % 2 == 0;
    }
}

To verify that the isEven method works correctly for different input values, we can write parameterized tests.

Parameterized Test for MathUtils:

import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import static org.assertj.core.api.Assertions.assertThat;

public class MathUtilsTest {

    @ParameterizedTest
    @CsvSource({
            "2, true",
            "3, false",
            "4, true",
            "5, false",
            "10, true"
    })
    void shouldVerifyIfNumberIsEven(int number, boolean expected) {
        // Act
        boolean result = MathUtils.isEven(number);

        // Assert
        assertThat(result).isEqualTo(expected);
    }
}
Explanation

This parameterized test runs five times, once for each set of values provided in @CsvSource. This approach makes the test concise and easy to maintain.

Hands-On: Writing Parameterized Tests

7. Testing Repositories

Theory: Mocking vs. In-Memory Database for Repository Testing

Testing repositories is a crucial aspect of unit testing in Spring Boot applications, as repositories interact directly with the database. There are two common ways to test repositories:

  1. Mocking: Mocking the repository interactions can be useful when the focus is on the business logic rather than the actual database operations. However, this approach doesn't verify if the data is being correctly persisted or queried from the database.

  2. In-Memory Database: Using an in-memory database like H2 allows developers to perform realistic tests without the need for a full-fledged database setup. This approach is particularly useful for testing SQL queries and verifying data integrity.

In Spring Boot, the @DataJpaTest annotation is used to test the repository layer with an in-memory database. It configures Spring to create an in-memory database and scan only repository-related components.

Code Example: Repository Testing with H2 In-Memory Database

Consider a simple repository for managing products:

Product Entity:

import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;

@Entity
public class Product {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;
    private double price;

    // Constructors, getters, and setters
    public Product() {}

    public Product(String name, double price) {
        this.name = name;
        this.price = price;
    }

    public Long getId() { return id; }
    public String getName() { return name; }
    public double getPrice() { return price; }

    public void setId(Long id) { this.id = id; }
    public void setName(String name) { this.name = name; }
    public void setPrice(double price) { this.price = price; }
}

ProductRepository Interface:

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface ProductRepository extends JpaRepository<Product, Long> {
    Product findByName(String name);
}

Testing ProductRepository Using H2:

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.assertj.core.api.Assertions.assertThat;

@DataJpaTest
public class ProductRepositoryTest {

    @Autowired
    private ProductRepository productRepository;

    @Test
    void shouldSaveAndFindProductByName() {
        // Arrange
        Product product = new Product("Laptop", 1500.00);
        productRepository.save(product);

        // Act
        Product foundProduct = productRepository.findByName("Laptop");

        // Assert
        assertThat(foundProduct).isNotNull();
        assertThat(foundProduct.getName()).isEqualTo("Laptop");
        assertThat(foundProduct.getPrice()).isEqualTo(1500.00);
    }

    @Test
    void shouldReturnNullWhenProductDoesNotExist() {
        // Act
        Product foundProduct = productRepository.findByName("Smartphone");

        // Assert
        assertThat(foundProduct).isNull();
    }
}
Explanation
Hands-On: Testing Repositories

8. Test Coverage and Measuring Quality

Theory: Understanding Test Coverage and Quality Metrics

Test coverage is a metric used to determine how much of the codebase is covered by automated tests. While it is an important measure, relying solely on coverage percentage can be misleading, as it does not guarantee the quality of the tests or the correctness of the code. Instead, developers should use test coverage in combination with other quality metrics to gain a better understanding of the effectiveness of their tests.

Key Concepts of Test Coverage:

Pitfalls of Focusing Solely on Coverage:

Code Example: Using JaCoCo for Test Coverage

JaCoCo is a popular tool for measuring test coverage in Java applications. It integrates seamlessly with build tools like Maven and Gradle, providing detailed reports on test coverage.

Consider a CalculatorService class with basic arithmetic operations:

CalculatorService Code:

import org.springframework.stereotype.Service;

@Service
public class CalculatorService {
    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) {
        if (b == 0) {
            throw new IllegalArgumentException("Division by zero is not allowed");
        }
        return a / b;
    }
}

Unit Tests for CalculatorService:

import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

public class CalculatorServiceTest {

    private final CalculatorService calculatorService = new CalculatorService();

    @Test
    void shouldAddTwoNumbers() {
        // Act
        int result = calculatorService.add(2, 3);

        // Assert
        assertThat(result).isEqualTo(5);
    }

    @Test
    void shouldSubtractTwoNumbers() {
        // Act
        int result = calculatorService.subtract(5, 3);

        // Assert
        assertThat(result).isEqualTo(2);
    }

    @Test
    void shouldMultiplyTwoNumbers() {
        // Act
        int result = calculatorService.multiply(4, 3);

        // Assert
        assertThat(result).isEqualTo(12);
    }

    @Test
    void shouldDivideTwoNumbers() {
        // Act
        int result = calculatorService.divide(10, 2);

        // Assert
        assertThat(result).isEqualTo(5);
    }

    @Test
    void shouldThrowExceptionWhenDividingByZero() {
        // Act & Assert
        assertThatThrownBy(() -> calculatorService.divide(10, 0))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessage("Division by zero is not allowed");
    }
}

Measuring Coverage with JaCoCo:

To measure coverage, integrate JaCoCo with your build tool. Here is an example of how to configure JaCoCo with Maven:

Maven Configuration for JaCoCo:

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

After running mvn test, a coverage report will be generated, showing line coverage and branch coverage for each class in the project.

Hands-On: Measuring Test Coverage
Best Practices for Test Quality

9. Best Practices and Test Strategies

Theory: Best Practices for Writing High-Quality Unit Tests

Writing effective unit tests is an essential skill for ensuring software quality and maintainability. By adhering to best practices, developers can create tests that are reliable, easy to understand, and maintain. Here are some of the key best practices for writing unit tests:

1. The AAA Pattern (Arrange, Act, Assert)

The Arrange, Act, Assert (AAA) pattern is a simple and clear structure for writing unit tests. This pattern helps to make tests more readable and maintainable.

Example:

@Test
void shouldCalculateTotalPriceCorrectly() {
    // Arrange
    Product product = new Product("Laptop", 1500.00);
    int quantity = 2;

    // Act
    double totalPrice = product.getPrice() * quantity;

    // Assert
    assertThat(totalPrice).isEqualTo(3000.00);
}

2. Write Isolated Tests

Each unit test should be isolated from others, meaning that it should not rely on the outcome of any other test. This helps ensure that tests can run independently and consistently.

3. Use Descriptive Test Names

Test names should be descriptive and convey what is being tested. A good test name should indicate the method being tested, the scenario, and the expected outcome. For example, shouldReturnCorrectSumWhenAddingTwoNumbers().

4. Focus on Edge Cases

In addition to typical use cases, ensure your tests cover edge cases and potential failure scenarios. For example, test negative numbers, null values, empty lists, and boundary conditions.

5. Avoid Overuse of Mocks

While mocking is useful for isolating tests, overusing mocks can lead to brittle tests that are tightly coupled to implementation details. Mock only when necessary, and prefer using real objects when practical.

6. Don’t Test Trivial Code

Avoid writing tests for trivial code, such as getters and setters, unless they contain logic that needs validation. Focus your tests on business logic and complex behaviors.

Anti-Patterns to Avoid
Hands-On: Refactoring Poorly Written Tests

Step 1: Provide students with an example of a poorly written test that violates several best practices.

Example of Poorly Written Test:

@Test
void testOrderProcessing() {
    // Setup
    Order order = new Order("Laptop", 2, 1500.00);
    order.process();

    // Assertions
    assertThat(order.getStatus()).isEqualTo("PROCESSED");
    assertThat(order.getTotalPrice()).isEqualTo(3000.00);
    assertThat(order.getProductName()).isNotNull();
}

Issues:

Step 2: Ask students to refactor the test to follow best practices.

Improved Version:

@Test
void shouldSetStatusToProcessedWhenOrderIsProcessed() {
    // Arrange
    Order order = new Order("Laptop", 2, 1500.00);

    // Act
    order.process();

    // Assert
    assertThat(order.getStatus()).isEqualTo("PROCESSED");
}

@Test
void shouldCalculateTotalPriceCorrectly() {
    // Arrange
    Order order = new Order("Laptop", 2, 1500.00);

    // Act
    double totalPrice = order.getTotalPrice();

    // Assert
    assertThat(totalPrice).isEqualTo(3000.00);
}

@Test
void shouldHaveNonNullProductName() {
    // Arrange
    Order order = new Order("Laptop", 2, 1500.00);

    // Act & Assert
    assertThat(order.getProductName()).isNotNull();
}

Step 3: Execute the refactored tests and ensure they pass. Discuss the improvements and how the new structure follows best practices, making the tests easier to maintain and understand.

Testing Strategies
Hands-On: Designing Effective Tests

Step 1: Have students implement a feature using TDD. For example, ask them to create a DiscountService that calculates discounts based on different criteria, such as loyalty status or order value.

Step 2: Write tests first to define the behavior of DiscountService. For instance:

Step 3: Implement the DiscountService class to pass these tests.

Step 4: Review the test cases to ensure they follow best practices, including clear Arrange, Act, Assert sections, descriptive names, and focus on a single concern per test.

Summary

These best practices and strategies will help developers write reliable, maintainable, and effective unit tests that contribute to software quality.#####

Hands-On: Applying All Best Practices in a Comprehensive Exercise

Step 1: Create a ShoppingCartService that allows users to add products to a cart, remove products, and calculate the total price. The service should interact with a ProductRepository to verify product availability.

ShoppingCartService Code:

import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import java.util.HashMap; import java.util.Map;

@Service public class ShoppingCartService { @Autowired private ProductRepository productRepository;

private Map<Product, Integer> cart = new HashMap<>();

public void addProductToCart(int productId, int quantity) {
    Product product = productRepository.findById(productId).orElseThrow(() -> new IllegalArgumentException("Product not found"));
    cart.put(product, cart.getOrDefault(product, 0) + quantity);
}

public void removeProductFromCart(int productId) {
    Product product = productRepository.findById(productId).orElse(null);
    if (product != null) {
        cart.remove(product);
    }
}

public double calculateTotalPrice() {
    return cart.entrySet().stream()
            .mapToDouble(entry -> entry.getKey().getPrice() * entry.getValue())
            .sum();
}

}

Step 2: Write unit tests for ShoppingCartService covering all functionalities using Mockito to mock ProductRepository.

ShoppingCartServiceTest Code:

import org.junit.jupiter.api.Test; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; import org.junit.jupiter.api.extension.ExtendWith; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import java.util.Optional;

@ExtendWith(MockitoExtension.class) public class ShoppingCartServiceTest {

@Mock
private ProductRepository productRepository;

@InjectMocks
private ShoppingCartService shoppingCartService;

@Test
void shouldAddProductToCartWhenProductExists() {
    // Arrange
    Product product = new Product(1, "Laptop", 1500.00);
    Mockito.when(productRepository.findById(1)).thenReturn(Optional.of(product));

    // Act
    shoppingCartService.addProductToCart(1, 2);

    // Assert
    double totalPrice = shoppingCartService.calculateTotalPrice();
    assertThat(totalPrice).isEqualTo(3000.00);
}

@Test
void shouldThrowExceptionWhenAddingNonExistentProduct() {
    // Arrange
    Mockito.when(productRepository.findById(2)).thenReturn(Optional.empty());

    // Act & Assert
    assertThatThrownBy(() -> shoppingCartService.addProductToCart(2, 1))
            .isInstanceOf(IllegalArgumentException.class)
            .hasMessage("Product not found");
}

@Test
void shouldRemoveProductFromCart() {
    // Arrange
    Product product = new Product(1, "Laptop", 1500.00);
    Mockito.when(productRepository.findById(1)).thenReturn(Optional.of(product));
    shoppingCartService.addProductToCart(1, 2);

    // Act
    shoppingCartService.removeProductFromCart(1);

    // Assert
    double totalPrice = shoppingCartService.calculateTotalPrice();
    assertThat(totalPrice).isEqualTo(0.00);
}

}

Step 3: Execute the tests and ensure they pass successfully. Encourage students to think about additional edge cases, such as removing products that are not in the cart or adding products with negative quantities.

Summary

Following best practices such as the AAA pattern, writing isolated tests, and focusing on edge cases can greatly improve the quality of unit tests.

Avoid common anti-patterns like testing multiple concerns in a single test or writing tightly coupled tests.

Apply testing strategies like the Test Pyramid, CI/CD integration, and TDD to create a robust, reliable test suite that ensures code quality and maintainability.#####