Introduction

Unit testing is a fundamental practice in software development that helps ensure your code works correctly and continues to work as you make changes. In this lecture, we’ll explore unit testing in Java using JUnit 5, the latest version of Java’s most popular testing framework.

infoGood tests are as important as good code! They serve as documentation, catch bugs early, and give you confidence to refactor.

Why Unit Testing?

Benefits of Unit Testing
  • Find Bugs Early: Catch issues before they reach production
  • Enable Refactoring: Change code confidently knowing tests will catch breaks
  • Document Behavior: Tests show how code should be used
  • Improve Design: Writing tests often reveals design flaws
  • Regression Prevention: Ensure old bugs stay fixed

errorTests are code too! They need to be maintained, refactored, and kept clean just like production code.

What is JUnit 5?

JUnit 5 (also known as JUnit Jupiter) is the latest version of the JUnit testing framework. It provides a modern API with powerful features for writing and organizing tests.

JUnit 5 Architecture

JUnit 5 consists of three modules:

  1. JUnit Platform: Foundation for launching test frameworks
  2. JUnit Jupiter: New programming model and extension model
  3. JUnit Vintage: Support for running JUnit 3 and 4 tests

Setting Up JUnit 5 in Maven

Add these dependencies to your pom.xml:

<dependencies>
    <!-- JUnit 5 -->
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter</artifactId>
        <version>5.8.2</version>
        <scope>test</scope>
    </dependency>
    
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter-api</artifactId>
        <version>5.8.2</version>
        <scope>test</scope>
    </dependency>
    
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter-engine</artifactId>
        <version>5.8.2</version>
        <scope>test</scope>
    </dependency>
</dependencies>

And ensure the Surefire plugin is configured:

<build>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-surefire-plugin</artifactId>
            <version>2.22.2</version>
        </plugin>
    </plugins>
</build>

Your First Test

Let’s start with a simple test for the Note class:

package notes_app;

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

class NoteTest {
    
    @Test
    void testConstructorWithTitleAndContent() {
        // Arrange & Act
        Note note = new Note("Test Title", "Test Content");
        
        // Assert
        assertNotNull(note);
        assertNull(note.getId());
        assertEquals("Test Title", note.getTitle());
        assertEquals("Test Content", note.getContent());
        assertNotNull(note.getCreatedOn());
        assertNotNull(note.getUpdatedOn());
    }
}

Test Structure: Arrange-Act-Assert

Good tests follow the AAA pattern:

Arrange: Set up test data and conditions

Note note = new Note("Test Title", "Test Content");

Act: Execute the code being tested

String title = note.getTitle();

Assert: Verify the results

assertEquals("Test Title", title);

JUnit 5 Annotations

@Test

Marks a method as a test:

@Test
void testSomething() {
    // Test code
}

Naming conventions:

@BeforeEach and @AfterEach

Run before/after each test method:

class NoteServiceTest {
    private NoteService noteService;
    
    @BeforeEach
    void setUp() {
        // Runs before EACH test
        noteService = new NoteService();
    }
    
    @AfterEach
    void tearDown() {
        // Runs after EACH test
        noteService.clearAllNotes();
    }
    
    @Test
    void testAddNote() {
        Note note = noteService.addNote("Title", "Content");
        assertNotNull(note);
    }
}

@BeforeAll and @AfterAll

Run once before/after all tests (must be static):

class DatabaseTest {
    private static DatabaseConnection connection;
    
    @BeforeAll
    static void setUpClass() {
        // Runs once before ALL tests
        connection = new DatabaseConnection();
        connection.connect();
    }
    
    @AfterAll
    static void tearDownClass() {
        // Runs once after ALL tests
        connection.disconnect();
    }
    
    @Test
    void testQuery() {
        // Use connection
    }
}

@DisplayName

Provides a custom display name for tests:

@Test
@DisplayName("Should add note successfully with valid title and content")
void testAddNote() {
    // Test code
}

@Disabled

Temporarily disable a test:

@Test
@Disabled("Waiting for bug fix in external library")
void testFeatureNotReady() {
    // This test won't run
}

Assertions

Assertions verify that expected conditions are true.

Basic Assertions

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

// Equality
assertEquals(expected, actual);
assertEquals(expected, actual, "Custom failure message");

// Not equal
assertNotEquals(unexpected, actual);

// Null checks
assertNull(object);
assertNotNull(object);

// Boolean
assertTrue(condition);
assertFalse(condition);

// Same object reference
assertSame(expected, actual);
assertNotSame(unexpected, actual);

Example: Testing the Note Class

@Test
void testNoteConstructor() {
    Note note = new Note("Meeting Notes", "Discuss project timeline");
    
    assertNotNull(note, "Note should not be null");
    assertEquals("Meeting Notes", note.getTitle());
    assertEquals("Discuss project timeline", note.getContent());
    assertNull(note.getId(), "ID should be null for new notes");
    assertNotNull(note.getCreatedOn(), "CreatedOn should be set");
    assertNotNull(note.getUpdatedOn(), "UpdatedOn should be set");
}

Testing Exceptions

Use assertThrows to verify exceptions are thrown:

@Test
void testAddNoteWithNullTitle() {
    NoteService service = new NoteService();
    
    // Assert that IllegalArgumentException is thrown
    assertThrows(IllegalArgumentException.class, () -> {
        service.addNote(null, "Test Content");
    });
    
    // Can also capture the exception for further assertions
    Exception exception = assertThrows(IllegalArgumentException.class, () -> {
        service.addNote(null, "Test Content");
    });
    
    assertEquals("Title cannot be null or empty", exception.getMessage());
}

Testing Collections

import static org.junit.jupiter.api.Assertions.*;
import java.util.Arrays;
import java.util.List;

@Test
void testGetAllNotes() {
    NoteService service = new NoteService();
    service.addNote("Note 1", "Content 1");
    service.addNote("Note 2", "Content 2");
    
    List<Note> allNotes = service.getAllNotes();
    
    // Size assertion
    assertEquals(2, allNotes.size());
    
    // Check if list contains specific items
    assertTrue(allNotes.stream()
        .anyMatch(note -> note.getTitle().equals("Note 1")));
    
    // Asserting list is not empty
    assertFalse(allNotes.isEmpty());
}

Advanced: assertAll

Group multiple assertions together (all are checked even if one fails):

@Test
void testNoteProperties() {
    Note note = new Note("Title", "Content");
    note.setId(1L);
    
    assertAll("Note properties",
        () -> assertEquals(1L, note.getId()),
        () -> assertEquals("Title", note.getTitle()),
        () -> assertEquals("Content", note.getContent()),
        () -> assertNotNull(note.getCreatedOn()),
        () -> assertNotNull(note.getUpdatedOn())
    );
}

Complete Test Example: NoteServiceTest

Let’s write comprehensive tests for the NoteService class:

package notes_app;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.DisplayName;

import java.util.List;

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

class NoteServiceTest {
    
    private NoteService noteService;
    
    @BeforeEach
    void setUp() {
        noteService = new NoteService();
    }
    
    @Test
    @DisplayName("Should add note successfully with valid inputs")
    void testAddNote() {
        Note note = noteService.addNote("Test Title", "Test Content");
        
        assertNotNull(note);
        assertEquals(1L, note.getId());
        assertEquals("Test Title", note.getTitle());
        assertEquals("Test Content", note.getContent());
        assertNotNull(note.getCreatedOn());
        assertNotNull(note.getUpdatedOn());
        assertEquals(1, noteService.getNoteCount());
    }
    
    @Test
    @DisplayName("Should throw exception when title is null")
    void testAddNoteWithNullTitle() {
        assertThrows(IllegalArgumentException.class, () -> {
            noteService.addNote(null, "Test Content");
        });
        assertEquals(0, noteService.getNoteCount());
    }
    
    @Test
    @DisplayName("Should throw exception when title is empty")
    void testAddNoteWithEmptyTitle() {
        assertThrows(IllegalArgumentException.class, () -> {
            noteService.addNote("", "Test Content");
        });
    }
    
    @Test
    @DisplayName("Should trim whitespace from title")
    void testAddNoteTrimsTitle() {
        Note note = noteService.addNote("  Test Title  ", "Test Content");
        assertEquals("Test Title", note.getTitle());
    }
    
    @Test
    @DisplayName("Should update note successfully")
    void testUpdateNote() {
        Note originalNote = noteService.addNote("Original Title", "Original Content");
        Long noteId = originalNote.getId();
        
        Note updatedNote = noteService.updateNote(noteId, "New Title", "New Content");
        
        assertEquals("New Title", updatedNote.getTitle());
        assertEquals("New Content", updatedNote.getContent());
        assertEquals(noteId, updatedNote.getId());
    }
    
    @Test
    @DisplayName("Should throw exception when updating non-existent note")
    void testUpdateNonExistentNote() {
        assertThrows(IllegalArgumentException.class, () -> {
            noteService.updateNote(999L, "New Title", "New Content");
        });
    }
    
    @Test
    @DisplayName("Should delete note successfully")
    void testDeleteNote() {
        Note note = noteService.addNote("Test Title", "Test Content");
        Long noteId = note.getId();
        
        boolean deleted = noteService.deleteNote(noteId);
        
        assertTrue(deleted);
        assertEquals(0, noteService.getNoteCount());
        assertNull(noteService.findNoteById(noteId));
    }
    
    @Test
    @DisplayName("Should return false when deleting non-existent note")
    void testDeleteNonExistentNote() {
        boolean deleted = noteService.deleteNote(999L);
        assertFalse(deleted);
    }
    
    @Test
    @DisplayName("Should find notes by title (case-insensitive)")
    void testFindNotesByTitle() {
        noteService.addNote("Java Programming", "Learn Java");
        noteService.addNote("Python Basics", "Learn Python");
        noteService.addNote("Advanced Java", "Advanced concepts");
        
        List<Note> javaNotes = noteService.findNotesByTitle("java");
        
        assertEquals(2, javaNotes.size());
        assertTrue(javaNotes.stream()
            .allMatch(note -> note.getTitle().toLowerCase().contains("java")));
    }
    
    @Test
    @DisplayName("Should return empty list when search title is null")
    void testFindNotesByTitleWithNull() {
        noteService.addNote("Test Title", "Test Content");
        List<Note> notes = noteService.findNotesByTitle(null);
        assertTrue(notes.isEmpty());
    }
    
    @Test
    @DisplayName("Should clear all notes and reset ID counter")
    void testClearAllNotes() {
        noteService.addNote("Title 1", "Content 1");
        noteService.addNote("Title 2", "Content 2");
        
        assertEquals(2, noteService.getNoteCount());
        
        noteService.clearAllNotes();
        
        assertEquals(0, noteService.getNoteCount());
        
        // Adding a new note should start with ID 1 again
        Note newNote = noteService.addNote("New Title", "New Content");
        assertEquals(1L, newNote.getId());
    }
}

Test Organization and Best Practices

Test Naming Conventions

Option 1: test + MethodName + Condition

testAddNote()
testAddNoteWithNullTitle()
testUpdateNoteWithEmptyTitle()

Option 2: should + ExpectedBehavior + Condition

shouldAddNoteSuccessfully()
shouldThrowExceptionWhenTitleIsNull()
shouldReturnEmptyListWhenNoNotesExist()

One Assert Per Test (When Possible)

Prefer focused tests that check one thing:

// Good: Focused test
@Test
void testNoteIdIsAssigned() {
    Note note = noteService.addNote("Title", "Content");
    assertNotNull(note.getId());
}

// Less ideal: Multiple unrelated assertions
@Test
void testNote() {
    Note note = noteService.addNote("Title", "Content");
    assertNotNull(note.getId());  // Testing ID assignment
    assertTrue(note.getTitle().length() > 0);  // Testing title
    assertEquals(1, noteService.getNoteCount());  // Testing count
}

Test Independence

Each test should be independent and not rely on other tests:

// Bad: Tests depend on order
@Test
void test1_addNote() {
    noteService.addNote("Title", "Content");
}

@Test
void test2_deleteNote() {
    // Assumes test1 ran first!
    noteService.deleteNote(1L);
}

// Good: Each test is independent
@Test
void testAddNote() {
    noteService.addNote("Title", "Content");
    assertEquals(1, noteService.getNoteCount());
}

@Test
void testDeleteNote() {
    Note note = noteService.addNote("Title", "Content");
    noteService.deleteNote(note.getId());
    assertEquals(0, noteService.getNoteCount());
}

Testing Edge Cases

Always test boundary conditions and edge cases:

@Test
void testAddNoteWithNullTitle() {
    assertThrows(IllegalArgumentException.class, () -> {
        noteService.addNote(null, "Content");
    });
}

@Test
void testAddNoteWithEmptyTitle() {
    assertThrows(IllegalArgumentException.class, () -> {
        noteService.addNote("", "Content");
    });
}

@Test
void testAddNoteWithWhitespaceTitle() {
    assertThrows(IllegalArgumentException.class, () -> {
        noteService.addNote("   ", "Content");
    });
}

@Test
void testFindNotesByTitleWithEmptyString() {
    List<Note> notes = noteService.findNotesByTitle("");
    assertTrue(notes.isEmpty());
}

Running Tests

From Command Line

# Run all tests
mvn test

# Run a specific test class
mvn test -Dtest=NoteServiceTest

# Run a specific test method
mvn test -Dtest=NoteServiceTest#testAddNote

# Run tests and show output
mvn test -Dorg.slf4j.simpleLogger.defaultLogLevel=debug

From IDE

Most IDEs (IntelliJ IDEA, Eclipse, VS Code) provide:

Code Coverage with JaCoCo

Code coverage measures how much of your code is executed by tests.

Adding JaCoCo Plugin

<plugin>
    <groupId>org.jacoco</groupId>
    <artifactId>jacoco-maven-plugin</artifactId>
    <version>0.8.10</version>
    <executions>
        <execution>
            <goals>
                <goal>prepare-agent</goal>
            </goals>
        </execution>
        <execution>
            <id>report</id>
            <phase>test</phase>
            <goals>
                <goal>report</goal>
            </goals>
        </execution>
        <execution>
            <id>check</id>
            <goals>
                <goal>check</goal>
            </goals>
            <configuration>
                <rules>
                    <rule>
                        <element>BUNDLE</element>
                        <limits>
                            <limit>
                                <counter>LINE</counter>
                                <value>COVEREDRATIO</value>
                                <minimum>0.80</minimum>
                            </limit>
                        </limits>
                    </rule>
                </rules>
            </configuration>
        </execution>
    </executions>
</plugin>

Generating Coverage Reports

# Run tests with coverage
mvn clean test

# Open the HTML report
open target/site/jacoco/index.html

The report shows:

info_outlineAim for 80%+ code coverage, but remember: 100% coverage doesn’t guarantee bug-free code. Focus on testing important behaviors!

Test-Driven Development (TDD)

TDD is a development approach where you write tests before code:

Red: Write a failing test

@Test
void testCalculateDiscount() {
    double discount = calculator.calculateDiscount(100, 10);
    assertEquals(10.0, discount);
}

Green: Write minimum code to pass

public double calculateDiscount(double price, double percentage) {
    return price * (percentage / 100);
}

Refactor: Improve code while keeping tests green

public double calculateDiscount(double price, double percentage) {
    validateInputs(price, percentage);
    return price * (percentage / 100);
}

Common Testing Mistakes

1. Testing Implementation Instead of Behavior

// Bad: Tests internal implementation
@Test
void testNoteUsesHashMap() {
    // Don't test how it's implemented internally
}

// Good: Tests public behavior
@Test
void testCanRetrieveAddedNote() {
    Note note = noteService.addNote("Title", "Content");
    Note retrieved = noteService.findNoteById(note.getId());
    assertEquals(note, retrieved);
}

2. Fragile Tests

// Bad: Test breaks if timestamp format changes
@Test
void testNoteTimestamp() {
    Note note = new Note("Title", "Content");
    assertEquals("2025-01-10T12:00:00", note.getCreatedOn().toString());
}

// Good: Test the behavior
@Test
void testNoteHasTimestamp() {
    Note note = new Note("Title", "Content");
    assertNotNull(note.getCreatedOn());
    assertTrue(note.getCreatedOn().isBefore(LocalDateTime.now().plusSeconds(1)));
}

3. Ignoring Test Failures

// Never do this!
@Test
@Disabled("Test fails sometimes")
void testSomething() {
    // Fix the test or the code, don't ignore it!
}

Summary

You’ve learned how to write effective unit tests in Java using JUnit 5!

Key takeaways:

Best Practices Checklist

✅ Tests are independent (don’t rely on each other)
✅ Tests have descriptive names
✅ One logical assertion per test (when reasonable)
✅ Edge cases and error conditions are tested
✅ Tests run fast (no unnecessary delays)
✅ Tests are maintained like production code
✅ Code coverage is above 80%
✅ Tests document expected behavior

Quick Reference

// Test class
class MyTest {
    @BeforeEach
    void setUp() { }
    
    @Test
    void testSomething() {
        // Arrange
        Object obj = new Object();
        
        // Act
        Object result = obj.doSomething();
        
        // Assert
        assertEquals(expected, result);
    }
}

// Common assertions
assertEquals(expected, actual);
assertNotNull(object);
assertTrue(condition);
assertThrows(Exception.class, () -> { code });

// Run tests
mvn test
mvn test -Dtest=ClassName
mvn test -Dtest=ClassName#methodName

infoGreat tests make refactoring fearless! Invest time in writing good tests—your future self will thank you.