Java Testing — JUnit 5 Complete Guide
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
- What is the difference between
@BeforeEachand@BeforeAll? - How does
@ParameterizedTestimprove test coverage compared to individual@Testmethods? - What does
verify()do in Mockito? - Why should you use Testcontainers instead of mocking for database tests?
- What is the purpose of
assertThrows()?
Answers:
@BeforeEachruns before every test method (for fresh per-test state).@BeforeAllruns once before all tests (for expensive setup like database containers). Use@BeforeEachfor mutable state to prevent test pollution.@ParameterizedTestlets 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@Testmethods.verify()checks that a mocked method was called with specific arguments. It validates interactions — for example, verifying thesave()method was called exactly once with the correct user object.- 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.
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:
- Returns the input unchanged if shorter than maxLength
- Truncates and appends "..." if longer
- Returns empty string for null input
- Uses at least 8 test cases with
@CsvSource - Verifies the exact output for each case
Real-World Task: API Contract Testing
Build a test suite for a REST API client that:
- Uses Mockito to mock the HTTP client
- Tests successful responses (200) with JSON parsing
- Tests error responses (404, 500) and Exception Handling
- Tests timeout behavior
- Uses
@ParameterizedTestfor different status codes
DodaTech uses this pattern to validate all REST API integrations across Doda Browser's backend services.
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