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
(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 -
Your first test class
A test class is an ordinary class - by convention named after the class it tests, plus
Compile the classes with the JUnit library on the classpath, then run all tests with the console launcher -
OUTPUT of the above command
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
OUTPUT of running AverageTest
Explanation of the output
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
and in Gradle's
Then
@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 -
- Arrange - create the objects and input data the test needs.
- Act - call the method being tested.
- 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.
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?
}
}
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);
}
}
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
╷ ├─ 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 ]
@BeforeEachruns before every test, giving each one a freshPriceCalculator. Tests must not depend on each other or on the order they run in. (@AfterEach,@BeforeAlland@AfterAllalso exist, for clean-up and for expensive one-time setup.)assertEquals(expected, actual, delta)- fordoublevalues, 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.@ParameterizedTestwith@CsvSourceruns the same test once for each line of data - three tests from one method.@DisplayNameand thenamepattern make the report readable.- JUnit 5 test classes and methods do not need to be
public.
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);
}
}
╷
├─ 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 ]
- 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.
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>
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()
}
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.
@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.Core Java Tutorials
Introduction to Maven and Gradle for Java ProjectsClick to read tutorial: Introduction to Maven and Gradle for Java Projects Java Naming Conventions and Code Style GuideClick to read tutorial: Java Naming Conventions and Code Style Guide Writing Javadoc Comments in Java with ExamplesClick to read tutorial: Writing Javadoc Comments in Java with Examples Debugging Java Programs in Your IDE and with jdbClick to read tutorial: Debugging Java Programs in Your IDE and with jdb Stack vs Heap Memory in Java and Object ReferencesClick to read tutorial: Stack vs Heap Memory in Java and Object References
Introduction to Maven and Gradle for Java ProjectsClick to read tutorial: Introduction to Maven and Gradle for Java Projects Java Naming Conventions and Code Style GuideClick to read tutorial: Java Naming Conventions and Code Style Guide Writing Javadoc Comments in Java with ExamplesClick to read tutorial: Writing Javadoc Comments in Java with Examples Debugging Java Programs in Your IDE and with jdbClick to read tutorial: Debugging Java Programs in Your IDE and with jdb Stack vs Heap Memory in Java and Object ReferencesClick to read tutorial: Stack vs Heap Memory in Java and Object References