Unit Test #
A bug found in production can cost hundreds of times more than a bug found during development. Unit tests are the first safety net: every method is tested in isolation, every edge case is made explicit in code, and every future change is immediately detected if it breaks existing behavior. In Java, JUnit 5 is the de facto standard for unit testing — expressive, flexible, and well integrated with all build tools and IDEs. This article covers how to write meaningful tests with JUnit 5, from basic assertions to parameterized tests, how to organize tests with lifecycle hooks, how to test exceptions, and the best practices that keep your test suite maintainable.
Overview #
A good unit test has three characteristics: fast (runs in milliseconds), isolated (doesn’t depend on databases, networks, or global state), and deterministic (always produces the same result for the same input).
flowchart LR
A["Production Code\n(src/main/java)"] -->|"tested by"| B["Tests\n(src/test/java)"]
B -->|"mvn test /"| C["Test Runner\n(JUnit Platform)"]
C --> D{"All\npassing?"}
D -- Yes --> E["✓ Build succeeds"]
D -- No --> F["✗ Build fails\n+ error report"]The most common pattern in writing tests is AAA (Arrange, Act, Assert):
@Test
void aaaPatternExample() {
// Arrange — prepare the data and objects needed
Calculator calculator = new Calculator();
int a = 5, b = 3;
// Act — run the code under test
int result = calculator.add(a, b);
// Assert — verify the result matches expectations
assertEquals(8, result);
}
JUnit 5 #
JUnit 5 (official name: JUnit Jupiter) is the latest version, bringing many improvements over JUnit 4 — more expressive annotations, built-in parameterized tests, more flexible extensions, and support for Java 8+ features.
Dependencies #
<!-- Maven — one dependency covers API + Engine -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.2</version>
<scope>test</scope>
</dependency>
<!-- Surefire plugin to run tests via mvn test -->
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.5</version>
</plugin>
</plugins>
</build>
// Gradle
dependencies {
testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2'
}
test {
useJUnitPlatform()
}
First Test — a Calculator #
Start with the class to be tested:
// src/main/java/com/example/Calculator.java
public class Calculator {
public int add(int a, int b) { return a + b; }
public int subtract(int a, int b) { return a - b; }
public int multiply(int a, int b) { return a * b; }
public double divide(double a, double b) {
if (b == 0) throw new ArithmeticException("Divisor must not be zero");
return a / b;
}
public boolean isEven(int n) { return n % 2 == 0; }
}
// src/test/java/com/example/CalculatorTest.java
import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;
class CalculatorTest {
private Calculator calculator;
@BeforeEach
void setUp() {
calculator = new Calculator(); // create a fresh object before every test
}
@Test
@DisplayName("add two positive numbers")
void addTwoPositiveNumbers() {
assertEquals(8, calculator.add(5, 3));
}
@Test
@DisplayName("add negative numbers")
void addNegativeNumbers() {
assertEquals(-2, calculator.add(-5, 3));
}
@Test
@DisplayName("subtract produces a negative when b > a")
void subtractProducesNegative() {
assertEquals(-1, calculator.subtract(2, 3));
}
@Test
@DisplayName("normal division")
void normalDivision() {
assertEquals(2.5, calculator.divide(5, 2), 0.001); // floating point tolerance
}
}
Complete Assertions #
JUnit 5 provides many assertion methods in the Assertions class. Choose the most descriptive one for the case being tested.
import static org.junit.jupiter.api.Assertions.*;
// Equality
assertEquals(42, result);
assertEquals(3.14, value, 0.001); // tolerance for doubles
assertNotEquals(0, result);
// Null
assertNull(object);
assertNotNull(object);
// Boolean
assertTrue(calculator.isEven(4));
assertFalse(calculator.isEven(7));
// Reference (whether it's the exact same object, not just equal)
assertSame(objectA, objectB);
assertNotSame(objectA, copy);
// Arrays and collections
assertArrayEquals(new int[]{1, 2, 3}, result);
assertIterableEquals(List.of("a", "b"), list);
// Multiple assertions at once — all run even if one fails
assertAll("product properties",
() -> assertEquals("Laptop", product.getName()),
() -> assertEquals(12_000_000.0, product.getPrice(), 0.01),
() -> assertTrue(product.getStock() > 0)
);
// Custom error message — shown when the assertion fails
assertEquals(5, result, "2+3 should equal 5");
assertEquals(5, result, () -> "The result is " + result + ", expected 5");
// Use a lambda so the message is only computed on failure (lazy)
Testing Exceptions #
@Test
@DisplayName("dividing by zero must throw ArithmeticException")
void divideByZeroThrowsException() {
// assertThrows: make sure the right exception is thrown
ArithmeticException ex = assertThrows(
ArithmeticException.class,
() -> calculator.divide(10, 0)
);
// Verify the exception message
assertEquals("Divisor must not be zero", ex.getMessage());
}
@Test
@DisplayName("normal division must NOT throw an exception")
void normalDivisionDoesNotThrow() {
// assertDoesNotThrow: make sure no exception is thrown
assertDoesNotThrow(() -> calculator.divide(10, 2));
}
@Test
@DisplayName("exception thrown within a time limit")
void exceptionWithinTimeout() {
assertTimeoutPreemptively(
java.time.Duration.ofSeconds(1),
() -> calculator.divide(0, 0) // make sure it's fast, no infinite loop
);
}
Lifecycle Hooks #
Lifecycle hooks let you set up and clean up state before/after tests — without duplicating code in every test method.
import org.junit.jupiter.api.*;
class DatabaseTest {
private static DBConnection connection; // created once for all tests
private Transaction transaction; // created fresh for every test
@BeforeAll // static: called once before all tests in this class
static void openConnection() {
connection = new DBConnection("jdbc:h2:mem:testdb");
System.out.println("DB connection opened");
}
@BeforeEach // called before every @Test method
void startTransaction() {
transaction = connection.startTransaction();
System.out.println("Transaction started");
}
@Test
void saveNewData() {
// this test has a fresh transaction
transaction.save(new Product("Laptop", 12_000_000));
assertEquals(1, transaction.countProducts());
}
@Test
void deleteData() {
transaction.save(new Product("Mouse", 150_000));
transaction.delete("Mouse");
assertEquals(0, transaction.countProducts());
}
@AfterEach // called after every @Test method
void rollback() {
transaction.rollback(); // make sure every test starts with clean state
System.out.println("Rollback performed");
}
@AfterAll // static: called once after all tests finish
static void closeConnection() {
connection.close();
System.out.println("DB connection closed");
}
}
The execution order for two tests:
@BeforeAll (once)
@BeforeEach → @Test saveNewData → @AfterEach
@BeforeEach → @Test deleteData → @AfterEach
@AfterAll (once)
Utility Annotations #
// Skip a test with a reason
@Test
@Disabled("Feature not implemented yet — see ticket #42")
void missingFeature() { /* ... */ }
// Conditional — run only on a specific OS
@Test
@EnabledOnOs(OS.LINUX)
void linuxOnly() { /* ... */ }
@Test
@DisabledOnOs(OS.WINDOWS)
void notOnWindows() { /* ... */ }
// Conditional based on the Java version
@Test
@EnabledForJreRange(min = JRE.JAVA_17)
void java17Feature() { /* ... */ }
// Mark as a test expected to fail (for documenting regressions)
@Test
@Disabled("Known bug — waiting for the fix in the next sprint")
void knownBug() {
fail("This must fail until the bug is fixed");
}
// Repeat a test several times (for non-deterministic tests)
@RepeatedTest(5)
void repeatedTest(RepetitionInfo info) {
System.out.println("Attempt " + info.getCurrentRepetition());
assertTrue(Math.random() >= 0); // always true
}
Parameterized Tests #
Often a method needs to be tested with many different inputs. Instead of writing a separate test for each case, use @ParameterizedTest to run one test with many data sets.
@ValueSource — Simple Single Inputs #
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
@ParameterizedTest(name = "{0} is even")
@ValueSource(ints = {2, 4, 6, 100, -8})
void evenNumbers(int number) {
assertTrue(calculator.isEven(number));
}
@ParameterizedTest
@ValueSource(strings = {"", " ", "\t", "\n"})
void blankOrSpaceStrings(String input) {
assertTrue(input.isBlank());
}
@CsvSource — Multiple Parameters per Case #
import org.junit.jupiter.params.provider.CsvSource;
@ParameterizedTest(name = "{0} + {1} = {2}")
@CsvSource({
"2, 3, 5",
"0, 0, 0",
"-5, 5, 0",
"100, -50, 50",
"Integer.MAX_VALUE, 0, 2147483647" // this is just a string, not evaluated
})
void addVariousCases(int a, int b, int expected) {
assertEquals(expected, calculator.add(a, b));
}
@MethodSource — Data from a Method #
For complex data (objects, nulls, lists), use @MethodSource, which references a factory method.
import org.junit.jupiter.params.provider.*;
import java.util.stream.Stream;
@ParameterizedTest
@MethodSource("divisionData")
void divideWithVariousInputs(double numerator, double denominator, double expected) {
assertEquals(expected, calculator.divide(numerator, denominator), 0.001);
}
// Factory method — must be static, returns Stream<Arguments>
static Stream<Arguments> divisionData() {
return Stream.of(
Arguments.of(10.0, 2.0, 5.0),
Arguments.of(9.0, 3.0, 3.0),
Arguments.of(7.0, 2.0, 3.5),
Arguments.of(0.0, 5.0, 0.0),
Arguments.of(-10.0, 2.0, -5.0)
);
}
// @NullSource and @EmptySource for null/empty cases
@ParameterizedTest
@NullSource
@EmptySource
@ValueSource(strings = {" ", "\t"})
void invalidStrings(String input) {
assertTrue(input == null || input.isBlank());
}
AssertJ — More Expressive Assertions #
AssertJ is an alternative assertion library that can be used alongside JUnit 5. Its syntax is more fluent (chainable) and its automatic error messages are more informative than JUnit’s built-in Assertions.
Dependencies #
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.25.3</version>
<scope>test</scope>
</dependency>
JUnit vs AssertJ Comparison #
// Built-in JUnit 5
assertEquals("Laptop", product.getName());
assertTrue(product.getPrice() > 0);
assertNotNull(product.getId());
assertTrue(list.contains("Apple"));
// AssertJ — easier to read, more informative error messages
import static org.assertj.core.api.Assertions.*;
assertThat(product.getName()).isEqualTo("Laptop");
assertThat(product.getPrice()).isPositive();
assertThat(product.getId()).isNotNull();
assertThat(list).contains("Apple");
// AssertJ shines for collections
assertThat(list)
.hasSize(3)
.contains("Apple", "Mango")
.doesNotContain("Durian")
.isSortedAccordingTo(String::compareTo);
// String assertions
assertThat(name)
.isNotBlank()
.startsWith("Bu")
.endsWith("di")
.hasSize(4);
// Exception assertions
assertThatThrownBy(() -> calculator.divide(1, 0))
.isInstanceOf(ArithmeticException.class)
.hasMessage("Divisor must not be zero");
// Object assertions
assertThat(product)
.extracting(Product::getName, Product::getPrice)
.containsExactly("Laptop", 12_000_000.0);
TestNG #
TestNG is a JUnit alternative with extra features like parallel testing, dependencies between tests, and XML configuration. More often used by teams that need more control over test execution.
Dependencies #
<!-- Maven -->
<dependency>
<groupId>org.testng</groupId>
<artifactId>testng</artifactId>
<version>7.9.0</version>
<scope>test</scope>
</dependency>
Writing Tests with TestNG #
import org.testng.Assert;
import org.testng.annotations.*;
public class CalculatorTestNG {
private Calculator calculator;
@BeforeClass // called once before all tests in this class
public void setUp() {
calculator = new Calculator();
}
@Test
public void addPositive() {
Assert.assertEquals(calculator.add(2, 3), 5, "2 + 3 must be 5");
}
@Test
public void subtractProducesNegative() {
Assert.assertEquals(calculator.subtract(2, 3), -1);
}
// Test with an expected exception
@Test(expectedExceptions = ArithmeticException.class,
expectedExceptionsMessageRegExp = ".*zero.*")
public void divideByZero() {
calculator.divide(10, 0);
}
// Test with a timeout (in milliseconds)
@Test(timeOut = 1000)
public void mustFinishQuickly() {
calculator.add(1, 1);
}
// A test that runs after another test finishes
@Test(dependsOnMethods = "addPositive")
public void dependentTest() {
Assert.assertTrue(calculator.isEven(calculator.add(2, 2)));
}
}
Parameterized Tests in TestNG #
import org.testng.annotations.*;
public class CalculatorParamTestNG {
private Calculator calculator = new Calculator();
// Data provider: the data source for parameterized tests
@DataProvider(name = "addData")
public Object[][] sourceData() {
return new Object[][] {
{2, 3, 5},
{0, 0, 0},
{-5, 5, 0},
{100, -50, 50}
};
}
@Test(dataProvider = "addData")
public void addWithVariousInputs(int a, int b, int expected) {
Assert.assertEquals(calculator.add(a, b), expected);
}
}
Parallel Testing with TestNG #
<!-- testng.xml: parallel execution configuration -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE suite SYSTEM "http://testng.org/testng-1.0.dtd">
<suite name="Parallel Suite" parallel="methods" thread-count="4">
<test name="Calculator Test">
<classes>
<class name="com.example.CalculatorTestNG"/>
</classes>
</test>
</suite>
Best Practices #
Descriptive Test Naming #
// ANTI-PATTERN: names don't explain what's being tested
@Test
void test1() { /* ... */ }
@Test
void testDivide() { /* ... */ }
// CORRECT: names explain the scenario and expectation
@Test
@DisplayName("dividing 10 by 2 produces 5.0")
void divideTwoPositiveNumbers_producesCorrectResult() { /* ... */ }
@Test
@DisplayName("dividing by zero must throw ArithmeticException")
void divideByZero_throwsArithmeticException() { /* ... */ }
One Test, One Concept #
// ANTI-PATTERN: one test verifies too many things
@Test
void testCalculator() {
assertEquals(5, calculator.add(2, 3));
assertEquals(1, calculator.subtract(3, 2));
assertEquals(6, calculator.multiply(2, 3));
assertThrows(ArithmeticException.class, () -> calculator.divide(1, 0));
// If add fails, we don't know whether subtract, multiply, divide are correct
}
// CORRECT: one test, one scenario
@Test void addTwoPositiveNumbers() { assertEquals(5, calculator.add(2, 3)); }
@Test void subtractProducesOne() { assertEquals(1, calculator.subtract(3, 2)); }
@Test void multiplyProducesSix() { assertEquals(6, calculator.multiply(2, 3)); }
@Test void divideByZeroThrowsError() { assertThrows(...); }
Group Tests with @Nested #
import org.junit.jupiter.api.Nested;
class CalculatorTest {
private Calculator calculator = new Calculator();
@Nested
@DisplayName("Addition Operations")
class AddTest {
@Test void twoPositive() { assertEquals(8, calculator.add(5, 3)); }
@Test void twoNegative() { assertEquals(-8, calculator.add(-5, -3)); }
@Test void positiveNegative(){ assertEquals(2, calculator.add(5, -3)); }
}
@Nested
@DisplayName("Division Operations")
class DivideTest {
@Test void normal() { assertEquals(2.5, calculator.divide(5, 2), 0.001); }
@Test void byZero() { assertThrows(ArithmeticException.class,
() -> calculator.divide(10, 0)); }
@Test void negativeResult() { assertEquals(-5.0, calculator.divide(-10, 2), 0.001); }
}
}
Test Directory Structure #
src/
├── main/java/com/example/
│ ├── Calculator.java
│ ├── service/
│ │ └── ProductService.java
│ └── repository/
│ └── ProductRepository.java
└── test/java/com/example/
├── CalculatorTest.java ← test for Calculator
├── service/
│ └── ProductServiceTest.java ← test for ProductService
└── repository/
└── ProductRepositoryTest.java
Running Tests #
# Maven — run all tests
mvn test
# Maven — run one test class
mvn test -Dtest=CalculatorTest
# Maven — run one test method
mvn test -Dtest=CalculatorTest#addTwoPositiveNumbers
# Maven — run tests matching a name pattern
mvn test -Dtest="*Calculator*"
# Maven — skip tests (for fast builds, not recommended)
mvn package -DskipTests
# Gradle — run all tests
./gradlew test
# Gradle — run specific tests
./gradlew test --tests "com.example.CalculatorTest"
./gradlew test --tests "com.example.CalculatorTest.addTwoPositiveNumbers"
# View the test report in your browser
# Maven: target/surefire-reports/index.html
# Gradle: build/reports/tests/test/index.html
When to Use JUnit vs TestNG #
Use JUNIT 5 when:
✓ New projects — it's the current de facto standard
✓ You need seamless Spring Boot integration (@SpringBootTest)
✓ The team is familiar with JUnit (broader ecosystem)
✓ You need flexible extensions with @ExtendWith
Use TESTNG when:
✓ You need parallel testing with granular XML control
✓ Dependencies between tests with @Test(dependsOnMethods)
✓ The team already uses TestNG on existing projects
✓ You need richer data providers with @DataProvider
Use ASSERTJ alongside JUnit 5 when:
✓ You want more informative error messages when assertions fail
✓ Many collection, string, or complex object tests
✓ You like readable fluent chaining style
Summary #
- Follow the AAA pattern — every test has three clear parts: Arrange (prepare), Act (run), Assert (verify). Separate them with blank lines or comments.
@BeforeEachfor initialization — create the object under test fresh before every test so no state leaks between tests.assertAll()to verify multiple properties — all assertions run even if one fails, so you get the full picture without having to run repeatedly.assertThrows()to test exceptions — capture the thrown exception object and verify its type and message explicitly.@ParameterizedTesteliminates duplication — one test with many inputs is far better than many nearly identical tests. Use@CsvSourcefor simple data,@MethodSourcefor complex data.@DisplayNamemakes reports more readable — method names can stay short in code, while the name shown in reports can be long and descriptive.@Nestedfor grouping scenarios — nested classes make the test structure clearer, especially when one class has many different operations.- One test, one concept — a failing test must immediately tell you what is wrong, not force you to debug twenty assertions at once.
- AssertJ complements JUnit — it doesn’t replace JUnit, but makes assertions more expressive, especially for collections, strings, and exceptions.