Skip to content

Java Testing — JUnit 5 Complete Guide

DodaTech Updated 2026-06-20 8 min read

In this tutorial, you'll learn about Java Testing. We cover key concepts, practical examples, and best practices to help you understand and apply this topic effectively.

JUnit 5 is the standard framework for Java testing, providing annotations, assertions, and extension APIs to write automated unit and integration tests.

Why Testing Matters

Without automated tests, every code change risks breaking existing functionality. Manual testing doesn't scale — you'd need to re-test every feature after every change. DodaTech runs over 50,000 automated tests per build cycle across Doda Browser and Durga Antivirus Pro. Tests catch regressions before they reach users, document expected behavior, and give you confidence to refactor. Java testing with JUnit 5 is the industry standard.

Learning Path

graph LR
    A[Java Basics] --> B[JUnit 5 Fundamentals]
    B --> C[Assertions & Annotations]
    C --> D[Parameterized Tests]
    D --> E[Mocking with Mockito]
    E --> F[Integration Testing]
    F --> G[CI/CD Pipeline]
    style B fill:#f59e0b,color:#fff,stroke-width:3px

Setting Up JUnit 5

JUnit 5 requires Java 8+ and a build tool. With Maven, add this to pom.xml:

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

With Gradle:

testImplementation 'org.junit.jupiter:junit-jupiter:5.11.0'

JUnit 5 is split into three modules: Jupiter (the programming model with new annotations), Vintage (backward compatibility with JUnit 4), and Platform (the engine that runs tests in IDEs and build tools).

Your First Test

Let's write a test for a simple calculator.

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

class CalculatorTest {
    
    private final Calculator calculator = new Calculator();
    
    @Test
    void shouldAddTwoNumbers() {
        int result = calculator.add(3, 5);
        assertEquals(8, result);
    }
    
    @Test
    void shouldSubtractNumbers() {
        int result = calculator.subtract(10, 4);
        assertEquals(6, result);
    }
    
    @Test
    void shouldThrowOnDivisionByZero() {
        assertThrows(IllegalArgumentException.class, 
            () -> calculator.divide(10, 0));
    }
}

Notice the class doesn't extend anything (unlike JUnit 4's TestCase). Methods are annotated with @Test and use assertEquals, assertThrows, and other static assertions. The method names describe the expected behavior — shouldAddTwoNumbers reads like a specification.

Expected output (when running in IDE or build tool):

Tests passed: 3, Failed: 0

Test Lifecycle Annotations

JUnit 5 provides lifecycle hooks that run code before or after tests.

import org.junit.jupiter.api.*;
import java.io.*;
import java.nio.file.*;

class FileProcessorTest {
    
    private static Path testDir;
    private FileProcessor processor;
    
    @BeforeAll
    static void setupClass() throws IOException {
        testDir = Files.createTempDirectory("junit-test-");
    }
    
    @BeforeEach
    void setUp() {
        processor = new FileProcessor(testDir.toString());
    }
    
    @Test
    void shouldProcessValidFile() throws IOException {
        Path input = Files.writeString(
            testDir.resolve("input.txt"), "hello");
        
        String result = processor.process(input);
        
        assertEquals("HELLO", result);
    }
    
    @AfterEach
    void tearDown() throws IOException {
        processor.close();
    }
    
    @AfterAll
    static void cleanupClass() throws IOException {
        try (var files = Files.walk(testDir)) {
            files.sorted(Comparator.reverseOrder())
                 .forEach(path -> path.toFile().delete());
        }
    }
}

@BeforeAll runs once before all tests — perfect for creating temp directories. @BeforeEach runs before each test — good for creating fresh instances. @AfterEach cleans up after each test. @AfterAll runs once after all tests — clean up temp resources. This lifecycle prevents test pollution: one test's files won't affect the next.

Expected output:

Tests passed: 1, Failed: 0

Parameterized Tests

Instead of writing five nearly identical tests for different inputs, use @ParameterizedTest.

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

class PasswordValidatorTest {
    
    private final PasswordValidator validator = new PasswordValidator();
    
    @ParameterizedTest
    @CsvSource({
        "Pass123!,   true",
        "short,      false",
        "n0numbers!, false",
        "ALL1234!,   false",
        "Valid1@abc, true"
    })
    void shouldValidatePasswordStrength(String password, boolean expected) {
        assertEquals(expected, validator.isStrong(password));
    }
    
    @ParameterizedTest
    @ValueSource(strings = {"", "   ", "\t\n"})
    void shouldRejectBlankPasswords(String blank) {
        assertFalse(validator.isStrong(blank));
    }
}

@CsvSource provides inline CSV data — each row becomes one test invocation. @ValueSource supplies single values. JUnit 5 runs each row as a separate test case, so you see exactly which input failed in the report. This is far more concise than writing five separate @Test methods.

Expected output:

shouldValidatePasswordStrength(String, boolean) ✓ 5 passed
shouldRejectBlankPasswords(String) ✓ 3 passed

Mocking with Mockito

Real applications have dependencies — databases, web services, file systems. You don't want your unit tests calling real databases. Mockito creates fake objects that simulate real behavior.

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

@ExtendWith(MockitoExtension.class)
class UserServiceTest {
    
    @Mock
    private UserRepository repository;
    
    @InjectMocks
    private UserService service;
    
    @Test
    void shouldFindUserByEmail() {
        User mockUser = new User(1L, "alice@example.com", "Alice");
        when(repository.findByEmail("alice@example.com"))
            .thenReturn(Optional.of(mockUser));
        
        User result = service.findByEmail("alice@example.com");
        
        assertEquals("Alice", result.getName());
        verify(repository).findByEmail("alice@example.com");
    }
    
    @Test
    void shouldThrowWhenUserNotFound() {
        when(repository.findByEmail(anyString()))
            .thenReturn(Optional.empty());
        
        assertThrows(UserNotFoundException.class,
            () -> service.findByEmail("unknown@example.com"));
    }
}

@Mock creates a fake repository that records interactions. @InjectMocks injects that mock into the service. when(...).thenReturn(...) defines what the mock returns for specific calls. verify(...) checks that a method was called. Mocking isolates the code under test — if the test fails, you know the service logic is wrong, not the database.

Expected output:

Tests passed: 2, Failed: 0

Common Errors in JUnit 5 Testing

Error Cause Fix
No tests found with test runner Missing @Test annotation or method not void Add @Test and make method void
ParameterResolutionException Missing parameter source for @ParameterizedTest Add @ValueSource, @CsvSource, or @MethodSource
Strict stubbing argument mismatch Mockito default leniency is strict in newer versions Use lenient() for irrelevant stubbings or remove unused stubs
UnnecessaryStubbingException Stubbed method was never called during test Remove unused when() calls or mark as lenient()
Test instance leak Static state persists between tests Use @BeforeEach/@AfterEach instead of @BeforeAll for mutable state
AssertionError: expected: null Test expected a non-null value but got null Check if the method under test returned null unexpectedly
Migration failed: JUnit 4 -> 5 Using JUnit 4 annotations in JUnit 5 Replace @Test(expected = ...) with assertThrows(), remove @RunWith(SpringRunner.class)

Integration Testing with Testcontainers

For testing against real databases, Testcontainers spins up disposable Docker containers.

import org.junit.jupiter.api.Test;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import java.sql.*;

@Testcontainers
class UserRepositoryIntegrationTest {
    
    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16")
            .withDatabaseName("testdb")
            .withUsername("test")
            .withPassword("test");
    
    @Test
    void shouldConnectToDatabase() throws SQLException {
        String jdbcUrl = postgres.getJdbcUrl();
        String username = postgres.getUsername();
        String password = postgres.getPassword();
        
        try (Connection conn = DriverManager.getConnection(jdbcUrl, username, password);
             Statement stmt = conn.createStatement()) {
            
            stmt.execute("CREATE TABLE users (id SERIAL PRIMARY KEY, name TEXT)");
            stmt.execute("INSERT INTO users (name) VALUES ('Alice')");
            
            ResultSet rs = stmt.executeQuery("SELECT COUNT(*) FROM users");
            rs.next();
            assertEquals(1, rs.getInt(1));
        }
    }
}

Testcontainers uses @Container to manage the PostgreSQL container lifecycle. The container starts before tests and stops after. Each test gets a fresh database. This is how DodaTech tests its backend services against real PostgreSQL, MySQL, and Redis instances — catching SQL dialect and schema issues that mock tests miss.

Expected output:

Tests passed: 1, Failed: 0

Practice Questions

  1. What is the difference between @BeforeEach and @BeforeAll?
  2. How does @ParameterizedTest improve test coverage compared to individual @Test methods?
  3. What does verify() do in Mockito?
  4. Why should you use Testcontainers instead of mocking for database tests?
  5. What is the purpose of assertThrows()?

Answers:

  1. @BeforeEach runs before every test method (for fresh per-test state). @BeforeAll runs once before all tests (for expensive setup like database containers). Use @BeforeEach for mutable state to prevent test pollution.
  2. @ParameterizedTest lets you run the same test logic with multiple inputs. This catches edge cases you'd miss if you only test one or two values, and reduces code duplication compared to writing separate @Test methods.
  3. verify() checks that a mocked method was called with specific arguments. It validates interactions — for example, verifying the save() method was called exactly once with the correct user object.
  4. Mocks verify behavior but not real SQL compatibility. Testcontainers runs actual PostgreSQL/MySQL, catching SQL syntax errors, schema mismatches, and dialect differences that mocks cannot detect.
  5. assertThrows() asserts that a specific exception type is thrown by a lambda expression. It replaces JUnit 4's @Test(expected = ...) and allows testing the exception message and cause as well.

Challenge

Write a parameterized test suite for a utility method StringUtils.truncate(String input, int maxLength) that:

  1. Returns the input unchanged if shorter than maxLength
  2. Truncates and appends "..." if longer
  3. Returns empty string for null input
  4. Uses at least 8 test cases with @CsvSource
  5. Verifies the exact output for each case

Real-World Task: API Contract Testing

Build a test suite for a REST API client that:

  1. Uses Mockito to mock the HTTP client
  2. Tests successful responses (200) with JSON parsing
  3. Tests error responses (404, 500) and Exception Handling
  4. Tests timeout behavior
  5. Uses @ParameterizedTest for different status codes

DodaTech uses this pattern to validate all REST API integrations across Doda Browser's backend services.

What is the difference between JUnit 4 and JUnit 5?

JUnit 5 (Jupiter) uses Java 8 features, new annotations (@Test, @ParameterizedTest, @Nested), and a modular architecture with extension APIs. JUnit 4 is older with @RunWith and @Test(expected = ...). JUnit 5 also supports JUnit 4 tests via the Vintage engine.

When should I use mocking vs integration tests?

Use mocking for unit tests that verify business logic in isolation — fast and deterministic. Use integration tests (Testcontainers, WireMock) for code that interacts with databases, external APIs, or file systems. A healthy suite has 70% unit tests, 20% integration tests, and 10% end-to-end tests.

How do I run JUnit 5 tests in a CI/CD pipeline?

JUnit 5 integrates with Maven (mvn test) and Gradle (gradle test). It produces XML reports in JUnit format, which CI tools like Jenkins, GitHub Actions, and GitLab CI parse to display test results, trends, and failure analytics.

Related tutorials: Java I/O — File Handling & NIO Guide, Java Build Tools — Maven & Gradle Guide

Next lesson: Java Annotations & Reflection — Complete Guide

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro