Unit Testing Basics with JUnit 5 in Java

Introduction How do you know your code works - and still works after the next change? Running the program and checking the output by eye does not scale. Unit tests are small pieces of code which call your methods with known inputs and check the results automatically, in seconds, as often as you like. JUnit is the standard testing framework for Java, and JUnit 5 (also called JUnit Jupiter) is the version used in modern projects. This tutorial covers the basics of unit testing with JUnit 5. We will start with what a unit test is, and write our first test class. We will then use the most important assertions, test for exceptions, and set up shared test data with @BeforeEach. Next we will run one test with many inputs using parameterized tests, see what a failing test looks like - and how a test catches a real bug. Lastly, we will run tests with Maven and Gradle, and go through good testing practices.

(Note - The examples are run with JUnit's console launcher, so they work without a build tool. In a real project, you would run them through Maven or GradleClick to read tutorial: Introduction to Maven and Gradle for Java Projects, or your IDE.) What is a unit test? A unit test checks one small unit of code - usually one method - in isolation. Each test follows the same three steps, often called Arrange, Act, Assert -
  1. Arrange - create the objects and input data the test needs.
  2. Act - call the method being tested.
  3. Assert - check that the result is what you expected. If it is not, the test fails, and JUnit reports exactly what was expected and what was actually returned.
Here is the class we will test - a small price calculator.
The class under test
//PriceCalculator.java
import java.util.List;

public class PriceCalculator {
  private final double taxRate;

  public PriceCalculator(double taxRate) {
    this.taxRate = taxRate;
  }

  public double withTax(double price) {
    return price * (1 + taxRate);
  }

  public double applyDiscount(double price, int percent) {
    if (percent < 0 || percent > 100) {
      throw new IllegalArgumentException("Discount must be 0 to 100, but was " + percent);
    }
    return price * (100 - percent) / 100;
  }

  public double average(List<Integer> values) {
    int sum = 0;
    for (int v : values) {
      sum += v;
    }
    return sum / values.size();          // a bug - can you spot it?
  }
}
Your first test class A test class is an ordinary class - by convention named after the class it tests, plus Test. Each test is a method annotated with @Test. Assertions such as assertEquals(expected, actual) come from the class org.junit.jupiter.api.Assertions, usually imported statically.
A JUnit 5 test class
//PriceCalculatorTest.java
import static org.junit.jupiter.api.Assertions.assertAll;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;

class PriceCalculatorTest {
  private PriceCalculator calculator;

  @BeforeEach
  void setUp() {
    calculator = new PriceCalculator(0.18);          // a fresh object for every test
  }

  @Test
  @DisplayName("adds 18% tax to a price")
  void withTaxAddsTax() {
    double result = calculator.withTax(100);
    assertEquals(118.0, result, 0.0001);             // a tolerance for floating-point values
  }

  @Test
  void discountOfZeroKeepsThePrice() {
    assertEquals(50.0, calculator.applyDiscount(50, 0), 0.0001);
  }

  @Test
  void invalidDiscountIsRejected() {
    IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
        () -> calculator.applyDiscount(50, 150));
    assertTrue(e.getMessage().contains("150"));
  }

  @Test
  void fullDiscountMakesItFree() {
    assertAll(
        () -> assertEquals(0.0, calculator.applyDiscount(80, 100), 0.0001),
        () -> assertEquals(0.0, calculator.applyDiscount(0, 100), 0.0001));
  }

  @ParameterizedTest(name = "{0} with {1}% off is {2}")
  @CsvSource({"100, 10, 90", "200, 25, 150", "19.99, 50, 9.995"})
  void discountCases(double price, int percent, double expected) {
    assertEquals(expected, calculator.applyDiscount(price, percent), 0.0001);
  }
}
Compile the classes with the JUnit library on the classpath, then run all tests with the console launcher -
javac -cp junit-platform-console-standalone-1.13.4.jar -d classes PriceCalculator.java PriceCalculatorTest.java
java -jar junit-platform-console-standalone-1.13.4.jar execute --class-path classes --select-class PriceCalculatorTest
 OUTPUT of the above command
╷
├─ JUnit Platform Suite ✔
├─ JUnit Jupiter ✔
│  └─ PriceCalculatorTest ✔
│     ├─ fullDiscountMakesItFree() ✔
│     ├─ invalidDiscountIsRejected() ✔
│     ├─ adds 18% tax to a price ✔
│     ├─ discountCases(double, int, double) ✔
│     │  ├─ 100 with 10% off is 90 ✔
│     │  ├─ 200 with 25% off is 150 ✔
│     │  └─ 19.99 with 50% off is 9.995 ✔
│     └─ discountOfZeroKeepsThePrice() ✔
└─ JUnit Vintage ✔

Test run finished after 334 ms
[         5 containers found      ]
[         0 containers skipped    ]
[         5 containers started    ]
[         0 containers aborted    ]
[         5 containers successful ]
[         0 containers failed     ]
[         7 tests found           ]
[         0 tests skipped         ]
[         7 tests started         ]
[         0 tests aborted         ]
[         7 tests successful      ]
[         0 tests failed          ]
All seven tests passed - five test methods, one of which ran three times. The JUnit Platform Suite and JUnit Vintage lines are two other test engines bundled with the console launcher (for test suites and for old JUnit 4 tests); they had nothing to run.
Explanation of the test class
  • @BeforeEach runs before every test, giving each one a fresh PriceCalculator. Tests must not depend on each other or on the order they run in. (@AfterEach, @BeforeAll and @AfterAll also exist, for clean-up and for expensive one-time setup.)
  • assertEquals(expected, actual, delta) - for double values, always pass a tolerance, because floating-point results are rarely exact.
  • assertThrows() checks that the code - given as a lambda - throws the expected exceptionClick to read tutorial: throw vs throws in Java with Examples, and returns it so its message can be checked too.
  • assertAll() runs several assertions and reports all failures, rather than stopping at the first.
  • @ParameterizedTest with @CsvSource runs the same test once for each line of data - three tests from one method. @DisplayName and the name pattern make the report readable.
  • JUnit 5 test classes and methods do not need to be public.
Other useful assertions are assertNotEquals(), assertTrue()/assertFalse(), assertNull()/assertNotNull(), assertSame(), assertArrayEquals(), assertIterableEquals() and assertTimeout(). A failing test - catching a real bug PriceCalculator.average() has the same integer division bug that we found with the debugger in Debugging Java ProgramsClick to read tutorial: Debugging Java Programs in Your IDE and with jdb. A test finds it without any debugging -
A test that exposes the bug
//AverageTest.java
import static org.junit.jupiter.api.Assertions.assertEquals;

import java.util.List;
import org.junit.jupiter.api.Test;

class AverageTest {
  @Test
  void averageOfWholeNumbers() {
    assertEquals(80.0, new PriceCalculator(0).average(List.of(70, 90)), 0.0001);
  }

  @Test
  void averageKeepsTheFraction() {
    assertEquals(81.6667, new PriceCalculator(0).average(List.of(70, 85, 90)), 0.0001);
  }
}
 OUTPUT of running AverageTest
╷
├─ JUnit Platform Suite ✔
├─ JUnit Jupiter ✔
│  └─ AverageTest ✔
│     ├─ averageKeepsTheFraction() ✘ expected: <81.6667> but was: <81.0>
│     └─ averageOfWholeNumbers() ✔
└─ JUnit Vintage ✔

Failures (1):
  JUnit Jupiter:AverageTest:averageKeepsTheFraction()
    MethodSource [className = 'AverageTest', methodName = 'averageKeepsTheFraction', methodParameterTypes = '']
    => org.opentest4j.AssertionFailedError: expected: <81.6667> but was: <81.0>
       org.junit.jupiter.api.AssertionFailureBuilder.build(AssertionFailureBuilder.java:151)
       org.junit.jupiter.api.AssertionFailureBuilder.buildAndThrow(AssertionFailureBuilder.java:132)
       org.junit.jupiter.api.AssertEquals.failNotEqual(AssertEquals.java:197)
       org.junit.jupiter.api.AssertEquals.assertEquals(AssertEquals.java:86)
       org.junit.jupiter.api.AssertEquals.assertEquals(AssertEquals.java:81)
       org.junit.jupiter.api.Assertions.assertEquals(Assertions.java:1014)
       AverageTest.averageKeepsTheFraction(AverageTest.java:15)
       java.base/java.lang.reflect.Method.invoke(Method.java:565)
       java.base/java.util.ArrayList.forEach(ArrayList.java:1604)
       java.base/java.util.ArrayList.forEach(ArrayList.java:1604)

Test run finished after 210 ms
[         4 containers found      ]
[         0 containers skipped    ]
[         4 containers started    ]
[         0 containers aborted    ]
[         4 containers successful ]
[         0 containers failed     ]
[         2 tests found           ]
[         0 tests skipped         ]
[         2 tests started         ]
[         0 tests aborted         ]
[         1 tests successful      ]
[         1 tests failed          ]
Explanation of the output
  • The first test passed - 70 and 90 average to a whole number, so the bug did not show. A test suite needs cases which exercise the tricky inputs, here a fractional average.
  • The second test failed, and the message - expected: <81.6667> but was: <81.0> - shows exactly what went wrong. The stack trace points to the failing line of the test.
  • Fixing the method with (double) sum / values.size() makes both tests pass. The test stays in the project, so if the bug ever comes back, it is caught immediately - a regression test.
Running tests with Maven and Gradle In a real project, JUnit is a test-only dependency, and the build tool finds and runs every test class in src/test/java automatically. In Maven's pom.xml -
Adding JUnit 5 to a Maven project
<dependencies>
  <dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>5.13.4</version>
    <scope>test</scope>
  </dependency>
</dependencies>
and in Gradle's build.gradle -
Adding JUnit 5 to a Gradle project
dependencies {
    testImplementation 'org.junit.jupiter:junit-jupiter:5.13.4'
    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}

test {
    useJUnitPlatform()
}
Then mvn test or gradle test compiles and runs all the tests, and the build fails if any test fails - which is what stops broken code from being released. IDEs show a green or red result next to every test, and can run a single test with one click. Good testing practices
  • Test one behaviour per test, and name the test after it - invalidDiscountIsRejected() says what broke when it fails.
  • Test the edges: zero, negative numbers, empty lists, nullClick to read tutorial: null and NullPointerException in Java: How to Avoid It, the largest values, and the exceptions - that is where bugs live.
  • Keep tests fast and independent: no shared state between tests, no reliance on order, and no slow external systems in unit tests.
  • Write a test for every bug before fixing it, so it can never come back unnoticed.
  • Run tests often - after every change - and let the build run them on every commit.
Summary In this tutorial we first saw what a unit test is, and the Arrange, Act, Assert pattern. We then wrote a JUnit 5 test class with @Test, @BeforeEach and @DisplayName, used assertEquals() with a tolerance, assertThrows() and assertAll(), and ran one test with many inputs using @ParameterizedTest. Next we watched a test fail and pinpoint the integer division bug - and stay as a regression test. We then added JUnit to Maven and Gradle projects, and lastly went through good testing practices. This completes the Java Fundamentals topic list - from your first Hello World to tested, documented, buildable code.