Unit Testing with JUnit

Test Java code automatically with JUnit 5: @Test methods, assertions, testing exceptions, parameterized tests, setup with @BeforeEach, and running tests with Maven or Gradle.

Lesson 94 of 127Advanced17 min read

As a Java program grows, every change risks breaking something that used to work. Checking by hand, running the program and trying different inputs, is slow and easy to skip, and the bugs it misses reach your users. Automated tests check your code for you, in seconds, every time you change it.

JUnit is the standard testing library for Java, used in almost every professional Java project. A test is just a method marked with @Test that calls your code with known inputs and asserts what the result should be. If an assertion fails, JUnit reports exactly which test, which line and what the actual value was.

In this lesson you'll set up a project for testing, write tests with JUnit 5's assertions, check that the right exceptions are thrown, run one test against many inputs, share setup code, and read the results. Once you've seen a failing test point straight at a bug, you won't want to write code without them.

Project setup

Real Java projects use a build tool, usually Maven or Gradle, which downloads libraries such as JUnit and runs your tests. Both use the same folder layout:

Text
my-app/
  pom.xml                  (Maven) or build.gradle (Gradle)
  src/main/java/shop/PriceCalculator.java
  src/test/java/shop/PriceCalculatorTest.java

Test classes live in src/test/java, in the same package as the code they test, and are usually named after it with Test on the end. Add the junit-jupiter dependency to the build file, then run mvn test or gradle test. Editors such as IntelliJ IDEA and VS Code also show a run button beside each test.

Your first test

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

class PriceCalculatorTest {
    @Test
    void tenPercentOff() {
        PriceCalculator calc = new PriceCalculator();
        assertEquals(45.0, calc.applyDiscount(50.0, 10), 0.001);
    }
}
  • @Test marks a method as a test. Test methods return void and take no parameters.
  • assertEquals(expected, actual) fails the test if the two differ. The expected value comes first.
  • For doubles, add a third argument, the delta: how far apart the values may be and still count as equal.
  • Test classes and methods don't need to be public in JUnit 5.

Assertions

AssertionPasses when
assertEquals(a, b)a equals b
assertNotEquals(a, b)they differ
assertTrue(x) / assertFalse(x)x is true / false
assertNull(x) / assertNotNull(x)x is / isn't null
assertThrows(Type.class, () -> ...)the code throws that exception
assertAll(...)every grouped assertion passes; all failures are reported

Every assertion takes an optional message as its last argument, shown when it fails. assertThrows returns the exception, so you can also check its message.

Setup and many inputs

A method marked @BeforeEach runs before every test, giving each one a fresh starting point, such as a new, empty shopping basket. @AfterEach runs after each test for clean-up.

To run the same test against many inputs, use @ParameterizedTest with a source of values:

Java
@ParameterizedTest
@CsvSource({"100, A", "70, A", "69, B", "0, C"})
void gradeBoundaries(int score, String expected) {
    assertEquals(expected, Grades.grade(score));
}

Each row runs as a separate test, and the report shows exactly which values failed. Parameterized tests need the junit-jupiter-params dependency, which Maven and Gradle can include with JUnit.

JUnit Syntax

JUnit 5 essentials

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

class ThingTest {
    @BeforeEach void setUp() { ... }
    @Test void doesSomething() { assertEquals(expected, actual); }
    @Test void rejectsBadInput() {
        assertThrows(IllegalArgumentException.class, () -> thing.run(-1));
    }
    @ParameterizedTest
    @CsvSource({"1, 2", "3, 6"})
    void doubles(int in, int out) { assertEquals(out, in * 2); }
}

JUnit Summary

  • JUnit tests are methods marked @Test that call your code and assert the results.
  • Tests live in src/test/java and run with mvn test, gradle test or your editor.
  • assertEquals takes the expected value first; use a delta for doubles.
  • assertThrows checks exceptions; @BeforeEach gives each test a fresh setup.
  • @ParameterizedTest with @CsvSource runs one test against many inputs.

JUnit Examples

Each example shows the code, the output it produces and what each part does.

Example 1

Testing a basket class

Java
// src/main/java/shop/Basket.java
package shop;

import java.util.ArrayList;
import java.util.List;

public class Basket {
    private final List<Double> prices = new ArrayList<>();

    public void add(double price) {
        if (price < 0) throw new IllegalArgumentException("Negative price");
        prices.add(price);
    }

    public double total() {
        double sum = 0;
        for (double p : prices) sum += p;
        return sum;
    }

    public int count() { return prices.size(); }
}
Java
// src/test/java/shop/BasketTest.java
package shop;

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

class BasketTest {
    private Basket basket;

    @BeforeEach
    void setUp() {
        basket = new Basket();
    }

    @Test
    void emptyBasketTotalIsZero() {
        assertEquals(0.0, basket.total(), 0.001);
    }

    @Test
    void totalAddsEveryPrice() {
        basket.add(2.50);
        basket.add(3.25);
        assertAll(
            () -> assertEquals(2, basket.count()),
            () -> assertEquals(5.75, basket.total(), 0.001)
        );
    }

    @Test
    void negativePriceIsRejected() {
        var e = assertThrows(IllegalArgumentException.class, () -> basket.add(-1));
        assertEquals("Negative price", e.getMessage());
    }
}
Output
$ mvn test
[INFO] Running shop.BasketTest
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
Line-by-line explanation
  1. Lines 11 to 14In BasketTest, @BeforeEach creates a fresh, empty basket before every test, so tests can't affect each other.
  2. Lines 25 to 28assertAll checks the count and total together, and reports both if both fail.
  3. Lines 33 to 34assertThrows passes only if the lambda throws IllegalArgumentException, and returns it so the message can be checked too.
Example 2

A failing test points at the bug

Java
// in Basket.java, a bug has crept in:
public double total() {
    double sum = 0;
    for (int i = 1; i < prices.size(); i++) sum += prices.get(i);
    return sum;
}
Output
$ mvn test
[ERROR] Tests run: 3, Failures: 1, Errors: 0, Skipped: 0
[ERROR] BasketTest.totalAddsEveryPrice:25 Multiple Failures (1 failure)
	org.opentest4j.AssertionFailedError: expected: <5.75> but was: <3.25>
[INFO] BUILD FAILURE
Line-by-line explanation
  1. Line 4The loop starts at 1, skipping the first price. In the report, "expected 5.75 but was 3.25" shows exactly 2.50 is missing, and the line number leads straight to the assertion. Changing i = 1 to i = 0 makes every test pass again.
Example 3

One test, many inputs

Java
import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;

class ShippingTest {
    @ParameterizedTest(name = "{0} kg costs {1}")
    @CsvSource({
        "0.5, 3.99",
        "2.0, 3.99",
        "2.1, 6.50",
        "10.0, 6.50"
    })
    void costByWeight(double kg, double expected) {
        assertEquals(expected, Shipping.cost(kg), 0.001);
    }
}
Output
ShippingTest
  ✓ 0.5 kg costs 3.99
  ✓ 2.0 kg costs 3.99
  ✓ 2.1 kg costs 6.50
  ✓ 10.0 kg costs 6.50
Line-by-line explanation
  1. Line 6name sets how each case is shown in the report.
  2. Lines 7 to 12Each line is one case. 2.0 and 2.1 sit either side of the price boundary, where bugs are most likely.
  3. Line 13JUnit converts the CSV text into a double and a double.

Common JUnit Mistakes

Expected and actual the wrong way round

Don't
assertEquals(basket.total(), 5.75);   // failure message reads backwards
Do
assertEquals(5.75, basket.total(), 0.001);

Comparing doubles without a delta

Don't
assertEquals(0.3, 0.1 + 0.2);         // fails: 0.30000000000000004
Do
assertEquals(0.3, 0.1 + 0.2, 0.0001);

Tests that depend on each other

Don't
static Basket shared = new Basket();  // one test adds items, another expects it empty
Do
private Basket basket;
@BeforeEach void setUp() { basket = new Basket(); }

JUnit in Real Projects

Testing in Java teams

Professional Java projects have thousands of JUnit tests that run on every pull request, often through GitHub Actions, and a failing test blocks the merge. Libraries such as Mockito replace real databases and web services with stand-ins during tests, and AssertJ offers readable assertions like assertThat(total).isEqualTo(5.75). Many teams write the test first, then the code, a practice called test-driven development.

JUnit FAQ

What is the difference between JUnit 4 and JUnit 5?

JUnit 5, also called JUnit Jupiter, is the current version. Its annotations live in org.junit.jupiter.api, test classes and methods don't need to be public, @BeforeEach replaces @Before, and assertThrows replaces the expected attribute. New projects should use JUnit 5; older projects can run JUnit 4 tests alongside it with the vintage engine.

How do I run a single test class or method?

In an IDE, click the run icon next to the class or method. With Maven, run mvn test -Dtest=BasketTest, or -Dtest=BasketTest#emptyBasketTotalIsZero for one method. With Gradle, run gradle test --tests BasketTest.

Should I test private methods?

Usually not directly. Test them through the public methods that use them, because that's the behaviour other code depends on. If a private method is complicated enough to need its own tests, it's often a sign it belongs in a separate class with its own public interface.

What is mocking in Java testing?

A mock is a fake object that stands in for a real dependency, such as a payment service or a database, so a test runs quickly and predictably. Mockito is the most widely used Java mocking library: you create a mock, tell it what to return, and verify how your code called it.

How much test coverage do I need?

There's no universal number. Coverage tools such as JaCoCo show which lines tests ran, which helps you find untested code. High coverage with weak assertions proves little, so aim to test every important behaviour, boundary and error case, and treat the percentage as a guide rather than a goal.

JUnit Exercises

Answer each one and check it instantly. If you get stuck, open the hint before the answer.

Fill in the blankseasy

Complete the annotation that marks a method as a JUnit test.


void addsTwoNumbers() {
    assertEquals(4, 2 + 2);
}
Multiple choiceeasy

In assertEquals(a, b), which argument is the expected value?

Multiple choicemedium

How do you check that withdraw(-5) throws IllegalArgumentException?

Multiple choicemedium

What does @BeforeEach do?

Predict the outputhard

A @ParameterizedTest has 5 rows in its @CsvSource. How many tests does the report show for it?

JUnit Quiz

5 questions to check what you learned.

  1. Where do test classes go in a Maven or Gradle project?

    • src/main/java
    • The project root
    • A tests.txt file
    • src/test/java
  2. Why give assertEquals a delta when comparing doubles?

    • It makes tests faster
    • Doubles can't be compared otherwise
    • Floating point results can differ by tiny amounts
    • It's required for ints too
  3. What does assertAll do?

    • Runs several assertions and reports every failure, not just the first
    • Runs every test in the class
    • Asserts that a list contains all items
    • Skips failing assertions
  4. Which command runs the tests in a Maven project?

    • java test
    • junit run
    • javac -test
    • mvn test
  5. You've found a bug in total(). What's a good first step?

    • Delete the related tests
    • Write a test that fails because of the bug, then fix the code until it passes
    • Add a comment
    • Make the method private