Day 23: JUnit 5 Testing
JUnit 5 is Java’s standard testing framework. Test code acts as an inspector that automatically verifies your program works correctly. Instead of manually checking every time, a single ./gradlew test command validates all functionality.
JUnit 5 Basic Tests
The basic structure of test classes and methods.
import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;
// Class under test
class Calculator {
int add(int a, int b) { return a + b; }
int subtract(int a, int b) { return a - b; }
int multiply(int a, int b) { return a * b; }
int divide(int a, int b) {
if (b == 0) throw new ArithmeticException("Cannot divide by zero");
return a / b;
}
}
// Test class
@DisplayName("Calculator Tests")
class CalculatorTest {
private Calculator calc;
@BeforeEach // Runs before each test method
void setUp() {
calc = new Calculator();
}
@Test
@DisplayName("Addition of two numbers works correctly")
void testAdd() {
assertEquals(5, calc.add(2, 3));
assertEquals(0, calc.add(-1, 1));
assertEquals(-5, calc.add(-2, -3));
}
@Test
@DisplayName("Subtraction of two numbers works correctly")
void testSubtract() {
assertEquals(1, calc.subtract(3, 2));
assertEquals(-2, calc.subtract(-1, 1));
}
@Test
@DisplayName("Division by zero throws ArithmeticException")
void testDivideByZero() {
ArithmeticException exception = assertThrows(
ArithmeticException.class,
() -> calc.divide(10, 0)
);
assertEquals("Cannot divide by zero", exception.getMessage());
}
@Test
@Disabled("Feature not yet implemented")
void testSquareRoot() {
// TODO: Write test after adding square root feature
}
@AfterEach // Runs after each test method
void tearDown() {
calc = null;
}
}
Various Assertion Methods
Core methods used for test verification.
import org.junit.jupiter.api.Test;
import java.time.Duration;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
class AssertionExamples {
@Test
void basicAssertions() {
// Equality comparison
assertEquals(4, 2 + 2, "2+2 should be 4");
assertNotEquals(5, 2 + 2);
// Boolean verification
assertTrue(10 > 5, "10 should be greater than 5");
assertFalse("".length() > 0);
// Null verification
String name = "Java";
String nullStr = null;
assertNotNull(name);
assertNull(nullStr);
// Same object (reference comparison)
String a = "hello";
String b = a;
assertSame(a, b);
}
@Test
void collectionAssertions() {
List<String> fruits = List.of("Apple", "Banana", "Grape");
// Size check
assertEquals(3, fruits.size());
// Contains check
assertTrue(fruits.contains("Apple"));
// Iterable element check (order-sensitive)
assertIterableEquals(
List.of("Apple", "Banana", "Grape"),
fruits
);
}
@Test
void exceptionAssertions() {
// Verify exception is thrown
assertThrows(NumberFormatException.class, () -> {
Integer.parseInt("abc");
});
// Verify no exception is thrown
assertDoesNotThrow(() -> {
Integer.parseInt("123");
});
}
@Test
void groupedAssertions() {
String name = "Alice";
int age = 25;
// assertAll: runs all assertions at once (remaining continue even if one fails)
assertAll("User info verification",
() -> assertNotNull(name, "Name should not be null"),
() -> assertTrue(name.length() > 0, "Name should not be empty"),
() -> assertTrue(age > 0, "Age should be positive"),
() -> assertTrue(age < 150, "Age should be less than 150")
);
}
@Test
void timeoutAssertions() {
// Verify execution within time limit
assertTimeout(Duration.ofSeconds(2), () -> {
Thread.sleep(500); // 0.5s -> passes as it's within 2s
});
}
}
Parameterized Tests
Run the same test repeatedly with various input values.
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.*;
import java.util.stream.Stream;
import static org.junit.jupiter.api.Assertions.*;
class StringValidator {
boolean isValidEmail(String email) {
return email != null && email.matches("[a-zA-Z0-9+_.-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}");
}
boolean isStrongPassword(String password) {
if (password == null || password.length() < 8) return false;
boolean hasUpper = password.chars().anyMatch(Character::isUpperCase);
boolean hasLower = password.chars().anyMatch(Character::isLowerCase);
boolean hasDigit = password.chars().anyMatch(Character::isDigit);
return hasUpper && hasLower && hasDigit;
}
}
class StringValidatorTest {
private final StringValidator validator = new StringValidator();
// @ValueSource: single value array
@ParameterizedTest(name = "Valid email: {0}")
@ValueSource(strings = {
"user@example.com",
"test.name@company.co.kr",
"admin+tag@gmail.com"
})
void validEmails(String email) {
assertTrue(validator.isValidEmail(email));
}
@ParameterizedTest(name = "Invalid email: {0}")
@ValueSource(strings = {"", "invalid", "@no-user.com", "no-domain@"})
@NullSource
void invalidEmails(String email) {
assertFalse(validator.isValidEmail(email));
}
// @CsvSource: multiple argument combinations
@ParameterizedTest(name = "{0} + {1} = {2}")
@CsvSource({
"1, 2, 3",
"0, 0, 0",
"-1, 1, 0",
"100, 200, 300"
})
void testAddition(int a, int b, int expected) {
assertEquals(expected, a + b);
}
// @MethodSource: arguments from a method
@ParameterizedTest(name = "Strong password: {0} -> {1}")
@MethodSource("passwordProvider")
void testStrongPassword(String password, boolean expected) {
assertEquals(expected, validator.isStrongPassword(password));
}
static Stream<Arguments> passwordProvider() {
return Stream.of(
Arguments.of("Abcdef1!", true),
Arguments.of("StrongP4ss", true),
Arguments.of("weak", false),
Arguments.of("nouppercase1", false),
Arguments.of("NOLOWERCASE1", false),
Arguments.of("NoDigitsHere", false),
Arguments.of(null, false)
);
}
// @EnumSource: test with enum values
@ParameterizedTest
@EnumSource(java.time.Month.class)
void allMonthsAreValid(java.time.Month month) {
assertTrue(month.getValue() >= 1 && month.getValue() <= 12);
}
}
Test Organization (Nested, Lifecycle)
Logically group tests and manage their lifecycle.
import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;
import java.util.ArrayList;
import java.util.List;
@DisplayName("Shopping Cart Tests")
class ShoppingCartTest {
private List<String> cart;
@BeforeAll // Runs once before all tests
static void initAll() {
System.out.println("Tests starting");
}
@BeforeEach
void setUp() {
cart = new ArrayList<>();
}
@Nested
@DisplayName("With an empty cart")
class EmptyCart {
@Test
@DisplayName("Item count is 0")
void isEmpty() {
assertTrue(cart.isEmpty());
assertEquals(0, cart.size());
}
@Test
@DisplayName("After adding an item, size becomes 1")
void addItem() {
cart.add("Laptop");
assertEquals(1, cart.size());
assertTrue(cart.contains("Laptop"));
}
}
@Nested
@DisplayName("With items in the cart")
class NonEmptyCart {
@BeforeEach
void addItems() {
cart.add("Laptop");
cart.add("Mouse");
cart.add("Keyboard");
}
@Test
@DisplayName("Item count is 3")
void hasThreeItems() {
assertEquals(3, cart.size());
}
@Test
@DisplayName("Can delete a specific item")
void removeItem() {
cart.remove("Mouse");
assertEquals(2, cart.size());
assertFalse(cart.contains("Mouse"));
}
@Test
@DisplayName("Clear all works")
void clearCart() {
cart.clear();
assertTrue(cart.isEmpty());
}
@Test
@DisplayName("Duplicate items can be added")
void duplicateItem() {
cart.add("Laptop");
assertEquals(4, cart.size());
assertEquals(2, cart.stream().filter("Laptop"::equals).count());
}
}
@AfterAll
static void tearDownAll() {
System.out.println("Tests complete");
}
}
Today’s Exercises
-
String Utility Tests: Create a
StringUtilsclass withreverse(),isPalindrome(), andcountVowels()methods, and write JUnit 5 tests for each. Include normal cases, edge cases, and null/empty string cases. -
Parameterized Tests: Create a phone number validation method and write parameterized tests using
@CsvSourceand@MethodSourcewith sets of valid and invalid numbers. -
TDD Practice: Implement a
Stack<T>class using the RED-GREEN-REFACTOR cycle. First write failing tests, then implement the minimum to pass them, then refactor. Testpush,pop,peek,isEmpty,size, and underflow exceptions.