Unit Testing in Java

Write Reliable Code with JUnit 5 and Mockito - A Beginner's Complete Guide

Why Unit Testing? The Safety Net for Your Code

Imagine you're a chef preparing a complex dish. Would you wait until the entire meal is ready to taste it, or would you taste each component as you go? Smart chefs taste along the way - and smart developers test their code the same way.

Unit testing is like having a quality inspector for every small piece of your code. Each "unit" (usually a method or class) gets its own test to ensure it works correctly. When you make changes later, these tests catch any bugs you might accidentally introduce.

Catch Bugs Early

Finding a bug during development costs minutes. Finding it in production costs hours (or days) and damages user trust. Tests catch issues before they become problems.

Confidence to Refactor

Want to improve your code structure? With good tests, you can refactor fearlessly. If something breaks, your tests will tell you immediately.

Living Documentation

Tests show exactly how your code is supposed to work. New team members can read tests to understand what each method does.

Required in Enterprise

Professional teams require tests before code can be merged. Learning testing is essential for your career as a Java developer.

JUnit 5: Your Testing Framework

JUnit is the most popular testing framework for Java. Think of it as a special tool that runs your tests automatically and tells you if they pass or fail.

Setting Up JUnit 5

First, add JUnit 5 to your project. If you're using Maven, add this to your pom.xml:

<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>5.10.0</version>
    <scope>test</scope>
</dependency>

Your First Test

Let's say you have a simple Calculator class:

// src/main/java/Calculator.java
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) {
        if (b == 0) {
            throw new IllegalArgumentException("Cannot divide by zero");
        }
        return a / b;
    }
}

Here's how to test it:

// src/test/java/CalculatorTest.java
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.DisplayName;
import static org.junit.jupiter.api.Assertions.*;

class CalculatorTest {

    private Calculator calculator = new Calculator();

    @Test
    @DisplayName("Adding two positive numbers")
    void testAddition() {
        // Given: two numbers
        int a = 5;
        int b = 3;

        // When: we add them
        int result = calculator.add(a, b);

        // Then: we get the sum
        assertEquals(8, result);
    }

    @Test
    @DisplayName("Subtracting gives correct difference")
    void testSubtraction() {
        assertEquals(7, calculator.subtract(10, 3));
    }

    @Test
    @DisplayName("Multiplying two numbers")
    void testMultiplication() {
        assertEquals(15, calculator.multiply(3, 5));
        assertEquals(0, calculator.multiply(5, 0));
        assertEquals(-6, calculator.multiply(-2, 3));
    }

    @Test
    @DisplayName("Division by zero throws exception")
    void testDivisionByZero() {
        // This test expects an exception to be thrown
        assertThrows(IllegalArgumentException.class, () -> {
            calculator.divide(10, 0);
        });
    }
}

Understanding the Test Structure

  • @Test - Marks a method as a test case
  • @DisplayName - Gives your test a readable name
  • assertEquals(expected, actual) - Checks if two values are equal
  • assertThrows - Verifies that code throws an expected exception

Common Assertions: Your Testing Toolkit

Assertions are the "checks" in your tests. They compare what your code produces against what you expect. Here are the most useful ones:

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

class AssertionExamplesTest {

    @Test
    void basicAssertions() {
        // Check equality
        assertEquals(4, 2 + 2);
        assertEquals("hello", "hello");

        // Check not equal
        assertNotEquals(5, 2 + 2);

        // Check true/false
        assertTrue(5 > 3);
        assertFalse(3 > 5);

        // Check null
        String name = null;
        assertNull(name);

        name = "John";
        assertNotNull(name);
    }

    @Test
    void objectAssertions() {
        // Check same object reference
        String s1 = "hello";
        String s2 = s1;
        assertSame(s1, s2);

        // Check different object references
        String s3 = new String("hello");
        assertNotSame(s1, s3);

        // But they're still equal in value!
        assertEquals(s1, s3);
    }

    @Test
    void arrayAssertions() {
        int[] expected = {1, 2, 3};
        int[] actual = {1, 2, 3};

        // Check arrays are equal
        assertArrayEquals(expected, actual);
    }

    @Test
    void exceptionAssertions() {
        // Check that exception is thrown
        Exception exception = assertThrows(
            IllegalArgumentException.class,
            () -> {
                throw new IllegalArgumentException("Invalid input");
            }
        );

        // You can also check the exception message
        assertEquals("Invalid input", exception.getMessage());
    }

    @Test
    void groupedAssertions() {
        User user = new User("John", 25, "john@email.com");

        // Check multiple things at once
        // All assertions run even if one fails
        assertAll("User properties",
            () -> assertEquals("John", user.getName()),
            () -> assertEquals(25, user.getAge()),
            () -> assertTrue(user.getEmail().contains("@"))
        );
    }
}

Test Lifecycle: Setup and Cleanup

Sometimes you need to prepare something before tests run (like creating objects) or clean up afterward (like closing connections). JUnit provides lifecycle annotations for this:

import org.junit.jupiter.api.*;

class UserServiceTest {

    private UserService userService;
    private Database database;

    @BeforeAll
    static void setupAll() {
        // Runs ONCE before all tests in this class
        System.out.println("Starting test suite...");
        // Good for: expensive setup like starting a test server
    }

    @BeforeEach
    void setup() {
        // Runs before EACH test method
        database = new Database();
        userService = new UserService(database);
        // Good for: creating fresh objects for each test
    }

    @Test
    void testCreateUser() {
        User user = userService.createUser("John", "john@email.com");
        assertNotNull(user.getId());
    }

    @Test
    void testFindUser() {
        userService.createUser("Jane", "jane@email.com");
        User found = userService.findByEmail("jane@email.com");
        assertEquals("Jane", found.getName());
    }

    @AfterEach
    void cleanup() {
        // Runs after EACH test method
        database.clear();
        // Good for: cleaning up data created during test
    }

    @AfterAll
    static void cleanupAll() {
        // Runs ONCE after all tests in this class
        System.out.println("Test suite completed!");
        // Good for: closing connections, stopping servers
    }
}

When to Use Each Annotation

  • @BeforeEach - Reset state before each test (most common)
  • @AfterEach - Clean up resources after each test
  • @BeforeAll - One-time expensive setup (must be static)
  • @AfterAll - One-time cleanup (must be static)

Mockito: Testing in Isolation

Here's a common problem: your class depends on other classes. For example, a UserService might depend on a Database. How do you test UserService without actually connecting to a database?

The answer is mocking. A mock is a fake object that pretends to be the real thing. With Mockito, you can create fake versions of dependencies and control exactly what they do.

Setting Up Mockito

<dependency>
    <groupId>org.mockito</groupId>
    <artifactId>mockito-core</artifactId>
    <version>5.7.0</version>
    <scope>test</scope>
</dependency>

<dependency>
    <groupId>org.mockito</groupId>
    <artifactId>mockito-junit-jupiter</artifactId>
    <version>5.7.0</version>
    <scope>test</scope>
</dependency>

Basic Mocking Example

Let's say you have a UserService that uses a UserRepository to access the database:

// The class we want to test
public class UserService {
    private UserRepository userRepository;  // Database access
    private EmailService emailService;      // Sends emails

    public UserService(UserRepository repo, EmailService email) {
        this.userRepository = repo;
        this.emailService = email;
    }

    public User registerUser(String name, String email) {
        // Check if email already exists
        if (userRepository.findByEmail(email) != null) {
            throw new IllegalArgumentException("Email already registered");
        }

        // Save user
        User user = new User(name, email);
        User saved = userRepository.save(user);

        // Send welcome email
        emailService.sendWelcomeEmail(email);

        return saved;
    }
}

Now let's test it with mocks:

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

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

@ExtendWith(MockitoExtension.class)  // Enable Mockito
class UserServiceTest {

    @Mock  // Create a fake UserRepository
    private UserRepository userRepository;

    @Mock  // Create a fake EmailService
    private EmailService emailService;

    @InjectMocks  // Create UserService with mocks injected
    private UserService userService;

    @Test
    void registerUser_Success() {
        // Given: email doesn't exist yet
        when(userRepository.findByEmail("john@email.com"))
            .thenReturn(null);

        // And: save returns the user with an ID
        when(userRepository.save(any(User.class)))
            .thenAnswer(invocation -> {
                User user = invocation.getArgument(0);
                user.setId(1L);
                return user;
            });

        // When: we register a new user
        User result = userService.registerUser("John", "john@email.com");

        // Then: user is created with ID
        assertNotNull(result);
        assertEquals(1L, result.getId());
        assertEquals("John", result.getName());

        // And: welcome email was sent
        verify(emailService).sendWelcomeEmail("john@email.com");
    }

    @Test
    void registerUser_EmailAlreadyExists_ThrowsException() {
        // Given: email already exists
        User existingUser = new User("Jane", "john@email.com");
        when(userRepository.findByEmail("john@email.com"))
            .thenReturn(existingUser);

        // When/Then: registration fails
        assertThrows(IllegalArgumentException.class, () -> {
            userService.registerUser("John", "john@email.com");
        });

        // And: no user was saved
        verify(userRepository, never()).save(any());

        // And: no email was sent
        verify(emailService, never()).sendWelcomeEmail(any());
    }
}

Key Mockito Concepts

  • @Mock - Creates a fake version of a class
  • @InjectMocks - Creates the class under test with mocks injected
  • when().thenReturn() - Define what the mock should return
  • verify() - Check that a method was called
  • any() - Match any argument
  • never() - Verify method was never called

Test-Driven Development (TDD)

TDD is a development approach where you write tests BEFORE writing the actual code. It sounds backwards, but it leads to better-designed, more reliable code.

The TDD Cycle: Red-Green-Refactor

1. Red: Write a Failing Test

First, write a test for functionality that doesn't exist yet. Run it - it should fail (red). This proves your test actually tests something.

2. Green: Make It Pass

Write the minimum code needed to make the test pass. Don't worry about perfect code yet - just make it work.

3. Refactor: Improve the Code

Now clean up your code. Remove duplication, improve naming, optimize. Your tests ensure you don't break anything.

TDD Example: Building a Password Validator

// Step 1: RED - Write the test first (code doesn't exist yet!)

class PasswordValidatorTest {

    private PasswordValidator validator = new PasswordValidator();

    @Test
    void password_TooShort_Invalid() {
        assertFalse(validator.isValid("abc"));
    }

    @Test
    void password_NoNumbers_Invalid() {
        assertFalse(validator.isValid("abcdefgh"));
    }

    @Test
    void password_Valid() {
        assertTrue(validator.isValid("password123"));
    }
}

// This won't even compile yet! That's okay in TDD.
// Step 2: GREEN - Write minimum code to pass

public class PasswordValidator {

    public boolean isValid(String password) {
        // Minimum 8 characters
        if (password.length() < 8) {
            return false;
        }

        // Must contain at least one number
        boolean hasNumber = false;
        for (char c : password.toCharArray()) {
            if (Character.isDigit(c)) {
                hasNumber = true;
                break;
            }
        }

        return hasNumber;
    }
}

// Run tests - they should all pass now!
// Step 3: REFACTOR - Clean up the code

public class PasswordValidator {

    private static final int MIN_LENGTH = 8;

    public boolean isValid(String password) {
        return hasMinimumLength(password) && containsNumber(password);
    }

    private boolean hasMinimumLength(String password) {
        return password.length() >= MIN_LENGTH;
    }

    private boolean containsNumber(String password) {
        return password.chars().anyMatch(Character::isDigit);
    }
}

// Run tests again - still passing!

Testing Best Practices

1. One Assertion Per Concept

Each test should verify one specific behavior. This makes failures easy to understand.

// BAD: Testing too many things
@Test
void testUser() {
    User user = new User("John", 25);
    assertEquals("John", user.getName());
    assertEquals(25, user.getAge());
    assertTrue(user.isAdult());
    assertNotNull(user.getId());
}

// GOOD: Separate tests for each behavior
@Test void user_HasCorrectName() { ... }
@Test void user_HasCorrectAge() { ... }
@Test void user_IsAdult_WhenOver18() { ... }

2. Use Descriptive Test Names

Test names should describe what's being tested and expected outcome.

// BAD
@Test void test1() { ... }
@Test void testLogin() { ... }

// GOOD
@Test void login_WithValidCredentials_ReturnsUser() { ... }
@Test void login_WithWrongPassword_ThrowsException() { ... }

3. Follow the AAA Pattern

Arrange-Act-Assert (or Given-When-Then) makes tests readable:

@Test
void withdraw_SufficientBalance_DecreasesBalance() {
    // Arrange (Given)
    BankAccount account = new BankAccount(100.0);

    // Act (When)
    account.withdraw(30.0);

    // Assert (Then)
    assertEquals(70.0, account.getBalance());
}

4. Test Edge Cases

Don't just test the happy path. Think about boundaries and errors:

@Test void divide_ByZero_ThrowsException() { ... }
@Test void list_WhenEmpty_ReturnsEmptyList() { ... }
@Test void search_NullInput_ThrowsException() { ... }
@Test void age_AtExactly18_IsAdult() { ... }

5. Keep Tests Independent

Tests should not depend on each other. Each test should set up its own data.

Learn Testing the Right Way

Master unit testing with hands-on projects and expert mentorship. Write code you can trust.