Index

Unit Testing and Design with JUnit

Java Object-Oriented Design

22.1 Writing Unit Tests for OOP Code

Unit testing is a foundational practice in modern software development, especially critical in object-oriented design (OOD). It involves writing automated tests that verify the correctness of individual units—typically classes or methods—of your application. This practice promotes higher code quality, facilitates refactoring, and helps catch defects early in the development cycle.

Purpose and Benefits of Unit Testing in OOD

In OOD, systems are composed of interacting objects encapsulating state and behavior. Unit tests ensure that each object behaves as expected in isolation. The benefits are multifold:

Introduction to JUnit Basics

JUnit is the most widely used testing framework for Java, offering annotations and assertion methods that simplify writing and running tests.

Key Components:

Practical Example: Testing a Simple Class

Consider a basic Calculator class:

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

    public int divide(int numerator, int denominator) {
        if (denominator == 0) {
            throw new IllegalArgumentException("Denominator cannot be zero");
        }
        return numerator / denominator;
    }
}

A corresponding JUnit 5 test class might look like this:

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

public class CalculatorTest {
    private Calculator calculator;

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

    @Test
    void testAdd() {
        assertEquals(5, calculator.add(2, 3), "2 + 3 should equal 5");
        assertEquals(0, calculator.add(-2, 2), "Adding negative and positive should work");
    }

    @Test
    void testDivide() {
        assertEquals(2, calculator.divide(6, 3), "6 / 3 should equal 2");
    }

    @Test
    void testDivideByZero() {
        Exception exception = assertThrows(IllegalArgumentException.class, () -> {
            calculator.divide(5, 0);
        });
        assertEquals("Denominator cannot be zero", exception.getMessage());
    }
}

Here:

Testing Classes with More Complexity

For classes that manage collections or collaborate with other objects, tests should verify state changes and interactions.

Example: Testing a TaskManager that adds/removes tasks:

public class TaskManager {
    private final List<String> tasks = new ArrayList<>();

    public void addTask(String task) {
        if (task == null || task.isEmpty()) {
            throw new IllegalArgumentException("Task cannot be empty");
        }
        tasks.add(task);
    }

    public boolean removeTask(String task) {
        return tasks.remove(task);
    }

    public List<String> getTasks() {
        return new ArrayList<>(tasks);
    }
}

Test example:

@Test
void testAddAndRemoveTasks() {
    TaskManager manager = new TaskManager();
    manager.addTask("Write tests");
    assertTrue(manager.getTasks().contains("Write tests"));

    assertTrue(manager.removeTask("Write tests"));
    assertFalse(manager.getTasks().contains("Write tests"));
}

@Test
void testAddEmptyTaskThrows() {
    TaskManager manager = new TaskManager();
    assertThrows(IllegalArgumentException.class, () -> manager.addTask(""));
}

Best Practices for Writing Effective Unit Tests

  1. Isolate Tests: Each test should test one behavior and run independently.
  2. Use Descriptive Names: Test methods like testDivideByZeroThrowsException() communicate intent clearly.
  3. Arrange-Act-Assert: Structure tests into setup (Arrange), action (Act), and verification (Assert) phases.
  4. Avoid Test Interdependence: Don’t share mutable state across tests to prevent flaky results.
  5. Test Both Happy and Edge Cases: Cover typical use, boundary conditions, and error scenarios.
  6. Keep Tests Fast: Quick tests encourage frequent runs and early feedback.

Common Pitfalls and How to Avoid Them

Summary

Writing unit tests is an essential skill in object-oriented design, ensuring code correctness and maintainability. JUnit provides a straightforward way to create effective tests, from simple method checks to complex object interactions. By following best practices and avoiding common pitfalls, developers can produce a robust test suite that facilitates confident development and easier refactoring.

Index

22.2 Test-Driven Development (TDD)

Test-Driven Development (TDD) is a disciplined software development practice that reverses the traditional order of coding and testing: instead of writing code first and then tests, TDD emphasizes writing tests before the implementation code. This approach has transformed how developers think about design and quality by making tests the starting point for all development.

The TDD Cycle: Red-Green-Refactor

TDD follows a simple but powerful iterative cycle often summarized as Red-Green-Refactor:

  1. Red: Write a failing test that defines a new piece of functionality or behavior. At this point, the test fails because the feature is not yet implemented.
  2. Green: Write the minimum amount of production code necessary to make the failing test pass. This code does not have to be perfect—just enough to satisfy the test.
  3. Refactor: Improve the newly written code, cleaning up duplication and enhancing design without changing its behavior. The tests serve as a safety net ensuring that the refactoring preserves correctness.

This cycle repeats continuously for each small feature or behavior.

How TDD Guides Design and Improves Code Quality

By forcing developers to write tests first, TDD naturally encourages thoughtful design:

Step-by-Step Example: Developing a Simple BankAccount Feature Using TDD

Suppose we need to implement a deposit method on a BankAccount class. Here’s how TDD would proceed:

Step 1: Write the failing test (Red)

@Test
void depositIncreasesBalance() {
    BankAccount account = new BankAccount();
    account.deposit(100);
    assertEquals(100, account.getBalance());
}

This test expects that after depositing 100 units, the balance should reflect the deposit. Since BankAccount doesn’t exist or deposit is not implemented yet, this test will fail.

Step 2: Write minimal code to pass the test (Green)

public class BankAccount {
    private int balance = 0;

    public void deposit(int amount) {
        balance += amount;
    }

    public int getBalance() {
        return balance;
    }
}

Now, running the test passes successfully.

Step 3: Refactor if needed (Refactor)

The code is simple and clean, so no changes are needed. But if there were duplication or unclear naming, now is the time to fix that.

Benefits of Adopting TDD

Challenges of TDD in Real Projects

Despite its benefits, TDD can present challenges:

However, many teams find that the long-term gains in reliability and maintainability outweigh these challenges.

TDD and Object-Oriented Principles

TDD complements key OOP principles perfectly:

In essence, TDD is not just a testing technique—it is a design technique that ensures code is both correct and well-structured.

Summary

Test-Driven Development reshapes how developers write code by making tests the foundation of the design process. Through its Red-Green-Refactor cycle, TDD fosters cleaner code, better design decisions, and higher confidence in software quality. While adopting TDD can be challenging, the benefits for maintainability, extensibility, and bug reduction are significant, making it a powerful practice for any object-oriented software project.

Index

22.3 Mocking and Dependency Injection

Unit testing is most effective when tests isolate the behavior of the class under test from external dependencies, such as databases, web services, or other classes. This isolation ensures tests are reliable, fast, and focused on a single unit of behavior. However, in realistic systems, classes rarely work alone—they collaborate with other components. To test a class in isolation, we often need to mock or simulate these dependencies.

Why Mock Dependencies?

Imagine you are testing a PaymentService class that depends on an external PaymentGateway interface to process transactions. If your tests call the real payment gateway, they might:

To avoid these issues, mocking replaces real collaborators with controlled fake implementations that simulate expected behavior. This way, you test only the logic inside PaymentService while assuming collaborators behave as specified.

Introduction to Mocking Frameworks

Manually writing mocks is tedious and error-prone. Mocking frameworks simplify this by automatically creating mock objects and specifying their behavior. One of the most popular frameworks in Java is Mockito.

Mockito allows you to:

Basic Mockito Usage Example

Suppose we have this interface and class:

public interface PaymentGateway {
    boolean processPayment(double amount);
}

public class PaymentService {
    private PaymentGateway gateway;

    public PaymentService(PaymentGateway gateway) {
        this.gateway = gateway;
    }

    public boolean pay(double amount) {
        if (amount <= 0) {
            throw new IllegalArgumentException("Amount must be positive");
        }
        return gateway.processPayment(amount);
    }
}

Here’s a unit test for PaymentService using Mockito:

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

class PaymentServiceTest {

    @Test
    void testSuccessfulPayment() {
        // Create a mock PaymentGateway
        PaymentGateway mockGateway = mock(PaymentGateway.class);
        
        // Define behavior: any amount returns true
        when(mockGateway.processPayment(anyDouble())).thenReturn(true);
        
        PaymentService service = new PaymentService(mockGateway);
        boolean result = service.pay(100);
        
        // Verify result and interaction
        assertTrue(result);
        verify(mockGateway).processPayment(100);
    }
}

In this test, the actual payment processing is never performed. Instead, the PaymentGateway is mocked to always return true, allowing us to test PaymentService in isolation.

Click to view full runnable Code

import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.anyDouble;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

import org.junit.jupiter.api.Test;

class PaymentServiceTest {

    @Test
    void testSuccessfulPayment() {
        // Create a mock PaymentGateway
        PaymentGateway mockGateway = mock(PaymentGateway.class);

        // Define behavior: any amount returns true
        when(mockGateway.processPayment(anyDouble())).thenReturn(true);

        PaymentService service = new PaymentService(mockGateway);
        boolean result = service.pay(100);

        // Verify result and interaction
        assertTrue(result);
        verify(mockGateway).processPayment(100);
    }
}

interface PaymentGateway {
    boolean processPayment(double amount);
}

class PaymentService {
    private PaymentGateway gateway;

    public PaymentService(PaymentGateway gateway) {
        this.gateway = gateway;
    }

    public boolean pay(double amount) {
        if (amount <= 0) {
            throw new IllegalArgumentException("Amount must be positive");
        }
        return gateway.processPayment(amount);
    }
}

Dependency Injection: Facilitating Mocking and Decoupling

To effectively mock dependencies, you must be able to inject them into the class under test. Dependency Injection (DI) is a design technique where an object's dependencies are supplied externally rather than hard-coded inside the class. DI promotes loose coupling and testability.

There are several forms of dependency injection:

Constructor injection is the most popular and recommended for mandatory dependencies because it ensures immutability and clear dependencies.

Simple Dependency Injection Example

Revisiting the PaymentService class, note that the PaymentGateway is injected via the constructor:

public PaymentService(PaymentGateway gateway) {
    this.gateway = gateway;
}

This simple pattern enables:

In tests, as shown above, we can inject a mock PaymentGateway. In production, the real implementation is injected.

Benefits of Dependency Injection in Testing and Design

Summary and Best Practices

Mocking and dependency injection are powerful techniques that complement each other in improving unit test quality and software design:

By embracing these practices, developers can write reliable, maintainable, and testable code, ultimately leading to higher quality software.

Index