07 · Unit Testing with JUnit 5¶
Every example so far has been verified by eyeballing printed output. JUnit 5 lets you write automated checks that run in milliseconds and fail loudly the moment behavior changes unexpectedly.
Adding JUnit 5 to a Maven project¶
Level 1, Module 9 showed the
junit-jupiter dependency briefly — here it is in context, in a full
pom.xml:
<!-- pom.xml -->
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>calculator-app</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<properties>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.2</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.5</version>
</plugin>
</plugins>
</build>
</project>
Test classes live in src/test/java, mirroring the package structure of
src/main/java, as shown in Level 1's standard Maven layout.
The class under test¶
// src/main/java/com/example/Calculator.java
package com.example;
public class Calculator {
public int add(int a, int b) {
return a + b;
}
public int divide(int a, int b) {
if (b == 0) {
throw new ArithmeticException("Cannot divide by zero");
}
return a / b;
}
}
@Test and basic assertions¶
// src/test/java/com/example/CalculatorTest.java
package com.example;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class CalculatorTest {
@Test
void addsTwoNumbers() {
Calculator calc = new Calculator();
int result = calc.add(2, 3);
assertEquals(5, result); // expected, actual
}
@Test
void divideThrowsOnZero() {
Calculator calc = new Calculator();
assertThrows(ArithmeticException.class, () -> calc.divide(10, 0));
}
@Test
void addIsCommutative() {
Calculator calc = new Calculator();
assertTrue(calc.add(2, 3) == calc.add(3, 2));
}
}
| Assertion | Checks |
|---|---|
assertEquals(expected, actual) |
Values are equal |
assertTrue(condition) / assertFalse(condition) |
Boolean condition |
assertThrows(Type.class, executable) |
Code throws the given exception type |
assertNull(obj) / assertNotNull(obj) |
Reference is/isn't null |
assertArrayEquals(expected, actual) |
Arrays have equal contents |
@BeforeEach and @AfterEach¶
These run before/after every @Test method in the class — ideal for
setting up (and tearing down) a fresh object so tests don't leak state
between each other.
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.AfterEach;
class CalculatorTest {
private Calculator calc;
@BeforeEach
void setUp() {
calc = new Calculator(); // fresh instance before each test
System.out.println("Setting up...");
}
@AfterEach
void tearDown() {
System.out.println("Tearing down...");
}
@Test
void addsTwoNumbers() {
assertEquals(5, calc.add(2, 3));
}
}
@ParameterizedTest¶
Run the same test logic against several inputs without copy-pasting the test method.
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
class CalculatorParamTest {
@ParameterizedTest
@ValueSource(ints = {2, 4, 6, 8, 100})
void addingZeroReturnsSameNumber(int n) {
Calculator calc = new Calculator();
assertEquals(n, calc.add(n, 0));
}
}
@ValueSource also accepts strings = {...}, doubles = {...}, booleans =
{...}, etc. — the annotated test method runs once per value supplied.
Testing a real class: Contact from Level 1¶
Here's a fuller example testing the Contact class from
Level 1's capstone project:
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class ContactTest {
@Test
void toCsvLineFormatsFieldsCorrectly() {
Contact c = new Contact("Alice", "555-1234", "alice@example.com");
assertEquals("Alice,555-1234,alice@example.com", c.toCsvLine());
}
@Test
void fromCsvLineParsesBackIntoAContact() {
Contact c = Contact.fromCsvLine("Bob,555-5678,bob@example.com");
assertEquals("Bob", c.getName());
assertEquals("555-5678", c.getPhone());
assertEquals("bob@example.com", c.getEmail());
}
@Test
void fromCsvLineRejectsMalformedInput() {
assertThrows(IllegalArgumentException.class,
() -> Contact.fromCsvLine("Not,Enough"));
}
}
Running the tests¶
[INFO] Running com.example.CalculatorTest
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
[INFO] Running com.example.ContactTest
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
[INFO]
[INFO] BUILD SUCCESS
mvn test compiles both src/main/java and src/test/java, then runs every
class matching JUnit's naming/annotation conventions and reports a summary —
the same mvn lifecycle introduced in
Level 1, Module 9 and expanded on in
Module 8.
How It Actually Works¶
JUnit 5 discovers and runs @Test methods entirely through
reflection: the platform's launcher scans compiled classes for the
@Test annotation (itself a runtime-retained annotation, stored as an
attribute in the class file and read back via Class.getAnnotations()),
builds an execution plan of TestDescriptor nodes, and invokes each
method with Method.invoke() on a fresh instance — JUnit 5 creates a
new test-class instance per test method by default specifically so
mutable instance state from one test can't leak into another.
@BeforeEach/@AfterEach aren't magic hooks the JVM knows about —
they're plain annotated methods that JUnit's TestExtensionContext
machinery looks up via reflection and calls, wrapped around your actual
test invocation, all still just ordinary method calls from JUnit's own
code, not a JVM feature.
Assertion failures work by throwing AssertionError (unchecked, extends
Error not Exception, signaling "this is not something you're meant
to catch") — JUnit's test runner specifically catches Throwable around
each invocation and classifies AssertionError as a failure versus any
other exception as an error, which is a design choice in the test
framework, not a distinction the JVM itself enforces.
Exercise¶
Write a StringUtils class with a static method isPalindrome(String s)
that returns true if s reads the same forwards and backwards (case-
insensitive). Write a StringUtilsTest class with: one @Test for a
positive case, one for a negative case, one @Test using assertThrows to
confirm it throws NullPointerException (or handles null however you decide
— just test that behavior), and one @ParameterizedTest with @ValueSource
covering at least four different palindrome strings.