Learn Java

Lesson 22Phase 2: Objects

Testing your code with JUnit

Writing small automatic checks that prove a class works, and running them every time the code changes.

By Updated 55 min

  • unit test
  • JUnit
  • @Test
  • assertEquals
  • assertThrows
  • arrange, act, assert

Until now you have checked every class the same way: run main, read what it prints, and decide in your head whether the numbers look right. That works on the day you write the code. This lesson moves the checking into code. You will write small tests that know the right answers, run all of them in a second, and run them again after every change, so a mistake shows up minutes after you make it instead of months later.

By the end of this lesson
  • Explain what a unit test is, and why it beats printing results and checking them by eye.
  • Write a JUnit test class with @Test methods, run it, and read a failure: what the test expected and what the code gave.
  • Give every test the arrange, act, assert shape, one behaviour per test, and a name that says what should happen.
  • Choose between assertEquals (with a delta for doubles), assertTrue, assertFalse, assertNull and assertThrows.
  • Hunt for edge cases, keep tests independent with @BeforeEach, and run one test over many inputs.
  • Build a method test first with red, green, refactor, and run tests in an editor, with mvn test and with gradle test.
From before
You can write a class with private fields, a constructor and methods (constructors and encapsulation). In the encapsulation lesson a BankAccount refused a negative deposit by printing a message; this page gives it a better way to say no. You have used one annotation, @Override from overriding and super, a label that tells Java what you meant. JUnit runs on labels like that. From strings and string practice you have charAt loops, StringBuilder and a plan for a word counter that counts the spaces and adds one, and a test on this page finds the flaw in that plan. And reading errors and debugging named the bug where a program runs but gives the wrong answer: a logic error. Tests catch those early.

Checking by eye does not last#

Here is the BankAccount from the encapsulation lesson, cut down to whole amounts so the sums stay easy. The usual way to check a class like this is a main that calls a few methods and prints the results.

Main.java
1public class Main {
2 public static void main(String[] args) {
3 BankAccount account = new BankAccount(100);
4 account.deposit(50);
5 System.out.println("After deposit: " + account.getBalance());
6 account.withdraw(30);
7 System.out.println("After withdraw: " + account.getBalance());
8 }
9}
10
11class BankAccount {
12 private int balance;
13
14 BankAccount(int openingBalance) {
15 balance = openingBalance;
16 }
17
18 void deposit(int amount) {
19 balance = balance + amount;
20 }
21
22 void withdraw(int amount) {
23 balance = balance - amount;
24 }
25
26 int getBalance() {
27 return balance;
28 }
29}
Prints
›After deposit: 150
›After withdraw: 120

It prints After deposit: 150 and After withdraw: 120. To decide whether that is right, you work the sums out yourself: 100 plus 50 is 150, and 150 minus 30 is 120. The program printed numbers, and you did the checking.

That is fine today. Next month you add a fee to withdraw, or someone tidies up deposit. Is the class still right? To find out you would run main again, read every line and redo every sum, for every method you ever checked. Nobody does that after every change, so a mistake can sit in the code for weeks before anyone notices a wrong number.

Write the right answer into the code

The fix is to write the answer you expect into the program, next to the call, and let the computer compare the two. Then the program can say for itself whether it passed.

Main.java
1public class Main {
2 public static void main(String[] args) {
3 BankAccount account = new BankAccount(100);
4
5 account.deposit(50);
6 if (account.getBalance() == 150) {
7 System.out.println("PASS: deposit");
8 } else {
9 System.out.println("FAIL: deposit gave " + account.getBalance());
10 }
11
12 account.withdraw(30);
13 if (account.getBalance() == 120) {
14 System.out.println("PASS: withdraw");
15 } else {
16 System.out.println("FAIL: withdraw gave " + account.getBalance());
17 }
18 }
19}
20
21class BankAccount {
22 private int balance;
23
24 BankAccount(int openingBalance) {
25 balance = openingBalance;
26 }
27
28 void deposit(int amount) {
29 balance = balance + amount;
30 }
31
32 void withdraw(int amount) {
33 balance = balance - amount;
34 }
35
36 int getBalance() {
37 return balance;
38 }
39}
Prints
›PASS: deposit
›PASS: withdraw

This prints PASS: deposit and PASS: withdraw. The fact that the balance should be 150 now lives in the code, so the check can run again tomorrow, or next year, without you. Code like this, which runs a piece of a program and checks the result itself, is a test. A test of one small piece, usually one method of one class, is a unit test.

Hand-made checks soon grow tedious, though. Each one needs its own if, else and message. Both checks share one account, so if the deposit went wrong, the withdraw check would start from a wrong balance and fail too, even with nothing wrong in withdraw. And you still have to read the output to spot the word FAIL. A testing library takes care of all of that.

Key idea

A unit test runs one small piece of your code and checks the answer itself. The expected answer is written down, so every check can run again in seconds after any change.

Your first JUnit test#

JUnit is the testing library most Java projects use. A library is code that someone else wrote and you add to your project. JUnit does not come with the JDK, and the last section of this page shows how a real project adds it. In this course the engine has JUnit built in, so a test class runs with the Run button like any other program.

Here is the same BankAccount with a test class under it. There is no main. Press Run, then step through and watch the engine make a new BankAccountTest object for each test.

BankAccountTest.java
1import org.junit.jupiter.api.Test;
2
3import static org.junit.jupiter.api.Assertions.assertEquals;
4
5class BankAccount {
6 private int balance;
7
8 BankAccount(int openingBalance) {
9 balance = openingBalance;
10 }
11
12 void deposit(int amount) {
13 balance = balance + amount;
14 }
15
16 void withdraw(int amount) {
17 balance = balance - amount;
18 }
19
20 int getBalance() {
21 return balance;
22 }
23}
24
25class BankAccountTest {
26
27 @Test
28 void depositAddsToTheBalance() {
29 BankAccount account = new BankAccount(100);
30 account.deposit(50);
31 assertEquals(150, account.getBalance());
32 }
33
34 @Test
35 void withdrawTakesFromTheBalance() {
36 BankAccount account = new BankAccount(100);
37 account.withdraw(30);
38 assertEquals(70, account.getBalance());
39 }
40}
Prints
›BankAccountTest
› ✔ depositAddsToTheBalance()
› ✔ withdrawTakesFromTheBalance()
›2 tests: 2 passed, 0 failed

The console shows a report: the name of the test class, one line per test with a tick (✔) when it passed, and a count at the end, 2 tests: 2 passed, 0 failed.

Reading the test class

  • import org.junit.jupiter.api.Test; brings in JUnit's @Test, the way import java.util.ArrayList; brought in ArrayList. org.junit.jupiter.api is the package JUnit keeps it in.
  • import static org.junit.jupiter.api.Assertions.assertEquals; is a static import. It lets you write assertEquals(...) where you would otherwise need Assertions.assertEquals(...). The word static gets a full explanation in static and final; here it only means “let me use this method by its short name”.
  • class BankAccountTest is an ordinary class. By convention a test class is named after the class it tests, with Test on the end.
  • @Test marks a method as a test. Like @Override, it is an annotation, a label on the code. JUnit looks for methods with this label and runs each one.
  • Each test method is void and takes no parameters, because JUnit calls it with nothing to pass in and nothing to take back. Later on this page you will meet tests that do take a parameter.
  • assertEquals(150, account.getBalance()) is the check. The first argument is the value you expect, and the second is the value the code actually produced. If they are equal, the line does nothing and the test carries on. If they differ, the test stops on that line and counts as failed.
Who calls the test methods?
A test class has no main. A test runner, a program that comes with JUnit, finds the classes that have @Test methods. For each test it makes a new object of the test class and calls the test method on it. In this course the Run button is the test runner. In a real project, your editor or your build tool is.

When a test fails

A test earns its keep on the day it fails. Break the class on purpose: in withdraw, change the minus into a plus. It is an easy slip to make when you copy deposit to write withdraw.

BankAccountTest.java
1import org.junit.jupiter.api.Test;
2
3import static org.junit.jupiter.api.Assertions.assertEquals;
4
5class BankAccount {
6 private int balance;
7
8 BankAccount(int openingBalance) {
9 balance = openingBalance;
10 }
11
12 void deposit(int amount) {
13 balance = balance + amount;
14 }
15
16 void withdraw(int amount) {
17 balance = balance + amount;
18 }
19
20 int getBalance() {
21 return balance;
22 }
23}
24
25class BankAccountTest {
26
27 @Test
28 void depositAddsToTheBalance() {
29 BankAccount account = new BankAccount(100);
30 account.deposit(50);
31 assertEquals(150, account.getBalance());
32 }
33
34 @Test
35 void withdrawTakesFromTheBalance() {
36 BankAccount account = new BankAccount(100);
37 account.withdraw(30);
38 assertEquals(70, account.getBalance());
39 }
40}
Prints
›BankAccountTest
› ✔ depositAddsToTheBalance()
› ✘ withdrawTakesFromTheBalance(): expected: <70> but was: <130>
›2 tests: 1 passed, 1 failed

The second line of the report now has a cross (✘) and a message: withdrawTakesFromTheBalance(): expected: <70> but was: <130>. Read it in three parts. First comes the name of the test, which tells you which behaviour broke. Then expected: <70>, the first argument you gave assertEquals. Then but was: <130>, what getBalance() really returned. 130 is 100 plus 30, which points straight at the plus sign in withdraw.

depositAddsToTheBalance still passes. Each test reports on its own, so one broken method does not hide the results of the others.

Expected first, actual second
assertEqualstakes the right answer first and the code's answer second. Swap them and the test still passes and fails at the same moments, but its message points the wrong way: assertEquals(account.getBalance(), 70) reports expected: <130> but was: <70>, which reads as if the code had returned 70.
This report is the visualizer's own
The report under each test class in this course is a short format made for the visualizer: one line per test, then a count. JUnit's own tools print more, and the last section of this page shows what they look like. The failure messages, such as expected: <70> but was: <130>, are JUnit's exact wording.
Quick check
A test makes a new BankAccount(100), calls deposit(50), then runs assertEquals(150, account.getBalance()). It fails with expected: <150> but was: <100>. What does that tell you?

The shape of a test#

Look at depositAddsToTheBalance again. It does three things in order, and almost every good test does the same three.

  1. 1Arrange what the test needs. Here, an account with 100 in it.
  2. 2Act by calling the one method the test is about. Here, deposit 50.
  3. 3Assert that the result is right. Here, the balance should now be 150.

The pattern is called arrange, act, assert. It gives every test the same layout, so a reader always knows where to look. Some people mark the three parts with comments, and others leave a blank line between them. The two tests below use one style each, and they bring in one more annotation.

BankAccountTest.java
1import org.junit.jupiter.api.DisplayName;
2import org.junit.jupiter.api.Test;
3
4import static org.junit.jupiter.api.Assertions.assertEquals;
5
6class BankAccount {
7 private int balance;
8
9 BankAccount(int openingBalance) {
10 balance = openingBalance;
11 }
12
13 void deposit(int amount) {
14 balance = balance + amount;
15 }
16
17 int getBalance() {
18 return balance;
19 }
20}
21
22class BankAccountTest {
23
24 @Test
25 @DisplayName("A new account holds its opening balance")
26 void newAccountHoldsItsOpeningBalance() {
27 BankAccount account = new BankAccount(100);
28
29 int balance = account.getBalance();
30
31 assertEquals(100, balance);
32 }
33
34 @Test
35 @DisplayName("Depositing adds the amount to the balance")
36 void depositAddsToTheBalance() {
37 // Arrange
38 BankAccount account = new BankAccount(100);
39 // Act
40 account.deposit(50);
41 // Assert
42 assertEquals(150, account.getBalance());
43 }
44}
Prints
›BankAccountTest
› ✔ A new account holds its opening balance
› ✔ Depositing adds the amount to the balance
›2 tests: 2 passed, 0 failed

The report now shows sentences in place of method names. @DisplayName, imported from org.junit.jupiter.api like @Test, gives a test any name you like for the report, spaces included. JUnit still calls the method by its real name.

One behaviour per test

You could write one long test that deposits, withdraws and checks the balance after each step. It would work until something broke. Then its name could not tell you which step went wrong, and because a failed assertion ends the test on that line, the checks after it would never run. You would fix one bug, run again, and only then meet the next one. Two small tests give two separate answers at once.

One behaviour does not have to mean one assertEquals. A test that a refused deposit leaves the balance alone needs two checks, one for the refusal and one for the balance, and you will meet it in the next section. Both checks describe a single behaviour, so they belong in one test.

Names that say what should happen

A test's name is the first thing you read when it fails, so make it a sentence about the behaviour. withdrawTakesFromTheBalance tells you what broke, and test2 tells you nothing. Method names cannot contain spaces, so the words run together, each new word starting with a capital letter. When you want a real sentence in the report, add @DisplayName.

Key idea

Every test has the same shape: arrange what it needs, act once, assert the result. Test one behaviour at a time, and name each test after what should happen.

Put the lines in orderStringBuilderTest.java
A test can check any code, Java's own classes included. Put the lines in order so that this test of StringBuilder's reverse passes, with arrange, act and assert in that order inside the test method.

Tap a line, then tap where it should go. Or use the arrows.

The assertions you will use most#

assertEquals is one of many checks in JUnit's Assertions class. These are the ones you will reach for most often.

AssertionPasses whenExample
assertEquals(expected, actual)the two values are equal. Objects, strings included, are compared with equals, so two strings with the same text count as equal.assertEquals("555-0102", number)
assertEquals(expected, actual, delta)two double values are no further apart than deltaassertEquals(0.3, total, 0.000001)
assertTrue(condition)the condition is trueassertTrue(book.has("Ada"))
assertFalse(condition)the condition is falseassertFalse(book.has("Bjarne"))
assertNull(value)the value is nullassertNull(book.numberFor("Bjarne"))
assertNotNull(value)the value is anything except nullassertNotNull(book.numberFor("Ada"))
assertThrows(Type.class, () -> code)running the code throws that type of exceptionassertThrows(IllegalArgumentException.class, () -> account.deposit(-50))
All of these are methods of org.junit.jupiter.api.Assertions. The line import static org.junit.jupiter.api.Assertions.*; brings in every one of them at once.

True, false and null

A small phone book uses three of them. numberFor returns a person's number, or null when the name is not listed, and has answers yes or no.

PhoneBookTest.java
1import org.junit.jupiter.api.Test;
2
3import static org.junit.jupiter.api.Assertions.*;
4
5class PhoneBook {
6 private String[] names = {"Ada", "Grace", "Linus"};
7 private String[] numbers = {"555-0101", "555-0102", "555-0103"};
8
9 String numberFor(String name) {
10 for (int i = 0; i < names.length; i++) {
11 if (names[i].equals(name)) {
12 return numbers[i];
13 }
14 }
15 return null;
16 }
17
18 boolean has(String name) {
19 return numberFor(name) != null;
20 }
21}
22
23class PhoneBookTest {
24
25 @Test
26 void findsTheNumberForAListedName() {
27 PhoneBook book = new PhoneBook();
28 assertEquals("555-0102", book.numberFor("Grace"));
29 }
30
31 @Test
32 void saysYesForAListedName() {
33 PhoneBook book = new PhoneBook();
34 assertTrue(book.has("Ada"));
35 }
36
37 @Test
38 void saysNoForAnUnlistedName() {
39 PhoneBook book = new PhoneBook();
40 assertFalse(book.has("Bjarne"));
41 }
42
43 @Test
44 void givesNullForAnUnlistedName() {
45 PhoneBook book = new PhoneBook();
46 assertNull(book.numberFor("Bjarne"));
47 }
48}
Prints
›PhoneBookTest
› ✔ findsTheNumberForAListedName()
› ✔ saysYesForAListedName()
› ✔ saysNoForAnUnlistedName()
› ✔ givesNullForAnUnlistedName()
›4 tests: 4 passed, 0 failed

All four pass. assertEquals compared the two strings with equals, so it checked the text itself, which is what you want after the == trap from the references lesson. When one of these assertions fails, its message follows the pattern you already know. A failing assertTrue says expected: <true> but was: <false>, and a failing assertNull shows what it found instead, such as expected: <null> but was: <555-0102>.

Comparing doubles: the delta

Numbers with a decimal point need more care. Before you run this, decide what it prints.

Predict the outputMain.java
1public class Main {
2 public static void main(String[] args) {
3 double total = 0.1 + 0.2;
4 System.out.println(total);
5 System.out.println(total == 0.3);
6 }
7}

What do the two lines print?

Java prints 0.30000000000000004 and then false. A double stores numbers in binary, and most decimal fractions, 0.1 among them, have no exact binary form, just as 1/3 has no exact decimal form (0.333, with threes forever). Java stores the nearest value it can, and after some arithmetic the tiny gaps show.

So a test that compares doubles exactly can fail even when the code is right. This running log adds up distances in kilometres:

RunLogTest.java
1import org.junit.jupiter.api.Test;
2
3import static org.junit.jupiter.api.Assertions.assertEquals;
4
5class RunLog {
6 private double totalKm;
7
8 void addRun(double km) {
9 totalKm = totalKm + km;
10 }
11
12 double getTotalKm() {
13 return totalKm;
14 }
15}
16
17class RunLogTest {
18
19 @Test
20 void addsTwoRunsExactly() {
21 RunLog log = new RunLog();
22 log.addRun(0.1);
23 log.addRun(0.2);
24 assertEquals(0.3, log.getTotalKm());
25 }
26
27 @Test
28 void addsTwoRunsWithinADelta() {
29 RunLog log = new RunLog();
30 log.addRun(0.1);
31 log.addRun(0.2);
32 assertEquals(0.3, log.getTotalKm(), 0.000001);
33 }
34}
Prints
›RunLogTest
› ✘ addsTwoRunsExactly(): expected: <0.3> but was: <0.30000000000000004>
› ✔ addsTwoRunsWithinADelta()
›2 tests: 1 passed, 1 failed

The first test fails with expected: <0.3> but was: <0.30000000000000004>. The second passes, because its assertEquals has a third argument, the delta: the largest difference you are willing to accept. assertEquals(0.3, total, 0.000001) passes when the two numbers are within 0.000001 of each other. Pick a delta far smaller than any difference that matters in your program, and give one every time you compare doubles.

Money needs exact numbers
A delta suits measurements such as distances and temperatures. Programs that handle money avoid double altogether and use BigDecimal, which stores decimal fractions exactly. It has its own lesson: money and exact numbers.

Testing that bad input is refused

In the encapsulation lesson, deposit refused a negative amount by printing a message and carrying on, and the code that called it never found out. The usual way for a Java method to refuse is to throw an exception: a signal that something went wrong, the same kind of signal that stopped your programs with an ArrayIndexOutOfBoundsException or a NullPointerException. The new deposit below starts with a check that ends in this line: throw new IllegalArgumentException("Deposit must be positive");

IllegalArgumentExceptionis the exception the JDK provides for “you passed a value I cannot accept”, and the text in quotes is its message. throw stops the method on the spot, so the balance never changes. If nothing deals with the exception, the whole program stops, as this one does. Step through it.

Main.java
1public class Main {
2 public static void main(String[] args) {
3 BankAccount account = new BankAccount(100);
4 account.deposit(50);
5 System.out.println("Balance: " + account.getBalance());
6 account.deposit(-50);
7 System.out.println("Balance: " + account.getBalance());
8 }
9}
10
11class BankAccount {
12 private int balance;
13
14 BankAccount(int openingBalance) {
15 balance = openingBalance;
16 }
17
18 void deposit(int amount) {
19 if (amount <= 0) {
20 throw new IllegalArgumentException("Deposit must be positive");
21 }
22 balance = balance + amount;
23 }
24
25 int getBalance() {
26 return balance;
27 }
28}
Prints
›Balance: 150
!Exception in thread "main" java.lang.IllegalArgumentException: Deposit must be positive
! at BankAccount.deposit(Main.java:20)
! at Main.main(Main.java:6)
Stops on line 20

IllegalArgumentException: Deposit must be positive

A method was given a value it does not accept.

The first deposit works and the program prints Balance: 150. The second call throws, and the program stops with Exception in thread "main" java.lang.IllegalArgumentException: Deposit must be positive before it reaches the last line.

Refusing bad input is behaviour, so it deserves a test. assertThrows checks that a piece of code throws a given type of exception.

BankAccountTest.java
1import org.junit.jupiter.api.Test;
2
3import static org.junit.jupiter.api.Assertions.assertEquals;
4import static org.junit.jupiter.api.Assertions.assertThrows;
5
6class BankAccount {
7 private int balance;
8
9 BankAccount(int openingBalance) {
10 balance = openingBalance;
11 }
12
13 void deposit(int amount) {
14 if (amount <= 0) {
15 throw new IllegalArgumentException("Deposit must be positive");
16 }
17 balance = balance + amount;
18 }
19
20 int getBalance() {
21 return balance;
22 }
23}
24
25class BankAccountTest {
26
27 @Test
28 void depositRejectsANegativeAmount() {
29 BankAccount account = new BankAccount(100);
30 assertThrows(IllegalArgumentException.class, () -> account.deposit(-50));
31 }
32
33 @Test
34 void rejectedDepositLeavesTheBalanceAlone() {
35 BankAccount account = new BankAccount(100);
36 assertThrows(IllegalArgumentException.class, () -> account.deposit(-50));
37 assertEquals(100, account.getBalance());
38 }
39}
Prints
›BankAccountTest
› ✔ depositRejectsANegativeAmount()
› ✔ rejectedDepositLeavesTheBalanceAlone()
›2 tests: 2 passed, 0 failed

Both tests pass. assertThrows takes two arguments. The first, IllegalArgumentException.class, names the type of exception you expect: .class after a class name refers to the class itself. The second, () -> account.deposit(-50), is the code to run.

That second argument is new syntax. Java normally runs a method call the moment it reaches it, and a call that throws would end the test before assertThrows could watch for anything. Writing () -> in front of the call packs the call up without running it and hands the package to assertThrows, which runs it inside its own safety net, catches what it throws and checks the type. For now, copy the shape: () -> followed by the call you want to test. The syntax is called a lambda, and it gets a full explanation in enums, lambdas and functional interfaces.

The second test checks two things about one behaviour, as promised: the deposit is refused, and the balance is still 100 afterwards.

Throwing now, catching later
On this page you only throw exceptions and let assertThrows catch them. Catching them in your own code, with try and catch, is the subject of exception handling.
Quick check
A deposit of 50 is perfectly valid. What happens to this test? assertThrows(IllegalArgumentException.class, () -> account.deposit(50));

One more case. A test that calls account.deposit(-50) without assertThrows fails too. The exception escapes the test method, and the report gives it as the reason: java.lang.IllegalArgumentException: Deposit must be positive. Any exception you did not expect fails the test.

A fresh start for every test#

Every BankAccount test so far begins with the same line, BankAccount account = new BankAccount(100);. When several tests share a starting point, you can move it into a field and a method marked @BeforeEach. JUnit runs that method before each test.

BankAccountTest.java
1import org.junit.jupiter.api.BeforeEach;
2import org.junit.jupiter.api.Test;
3
4import static org.junit.jupiter.api.Assertions.assertEquals;
5
6class BankAccount {
7 private int balance;
8
9 BankAccount(int openingBalance) {
10 balance = openingBalance;
11 }
12
13 void deposit(int amount) {
14 balance = balance + amount;
15 }
16
17 void withdraw(int amount) {
18 balance = balance - amount;
19 }
20
21 int getBalance() {
22 return balance;
23 }
24}
25
26class BankAccountTest {
27 BankAccount account;
28
29 @BeforeEach
30 void setUp() {
31 account = new BankAccount(100);
32 }
33
34 @Test
35 void depositAddsToTheBalance() {
36 account.deposit(50);
37 assertEquals(150, account.getBalance());
38 }
39
40 @Test
41 void withdrawTakesFromTheBalance() {
42 account.withdraw(30);
43 assertEquals(70, account.getBalance());
44 }
45}
Prints
›BankAccountTest
› ✔ depositAddsToTheBalance()
› ✔ withdrawTakesFromTheBalance()
›2 tests: 2 passed, 0 failed

Step through it and watch setUp run twice, once before each test, building a new account with 100 in it each time. The deposit test leaves 150 in its account, and the withdraw test never sees it: it starts from 100 and ends at 70.

Every test gets a new object

@BeforeEach is only half the story. In this test class, each test adds one to the field count, prints it and checks it. Predict the report.

Predict the outputCounterTest.java
1import org.junit.jupiter.api.Test;
2
3import static org.junit.jupiter.api.Assertions.assertEquals;
4
5class CounterTest {
6 int count = 0;
7
8 @Test
9 void first() {
10 count++;
11 System.out.println("count is " + count);
12 assertEquals(1, count);
13 }
14
15 @Test
16 void second() {
17 count++;
18 System.out.println("count is " + count);
19 assertEquals(1, count);
20 }
21}

Both tests add one to the same field. Which report does Run print?

Both tests print count is 1 and both pass. JUnit makes a new object of the test class for every test method, so each test starts with its fields freshly set: count is 0 again, and in BankAccountTest the field account holds a brand-new account. That is also why a field such as WordCounter counter = new WordCounter();, which you will meet in the next section, is safe in a test class. Every test gets its own.

This protects a rule that good tests follow: no test may depend on another. If the withdraw test only passed after the deposit test had run, its result would depend on the order, and JUnit does not promise to run tests in the order you wrote them. Its documentation says the order stays the same from run to run but is deliberately not obvious. A test that passes on its own and fails next to the others, or the other way round, can take hours to untangle.

The order in this course
The Run button runs tests from top to bottom, so the report reads in the same order as the code. A real JUnit run may list them in a different order. Write every test so that the order cannot matter.
Key idea

Each test builds its own starting point and leaves nothing behind for the next. JUnit makes a new test object for every test, and @BeforeEach rebuilds the shared setup each time.

Edge cases: where bugs hide#

The tests so far use ordinary inputs, like 100 in the account and a sensible deposit. Code usually works for ordinary inputs, because those are the ones you pictured while writing it. Bugs gather where you did not look: at the ends of the allowed range, and at inputs so small or odd that they never came to mind. Inputs like that are called edge cases, and they tend to turn up in the same places:

  • Empty input, such as the empty string "", an empty array or an account holding 0.
  • Zero and negative numbers, anywhere a number comes in.
  • Values right on a boundary. If 90 and above earns an A, test 90 and 89, the last value on each side.
  • Exactly one of something, such as a one-letter word or an array with one element.
  • Text with odd spacing, such as spaces at the start or the end, or two spaces in a row.

A test that finds a real bug

In string practice you planned a word counter: count the spaces and add one. For "hello there friend" that gives 3, which is right. Here is that plan as a class, with one ordinary test and three edge-case tests. Run it.

WordCounterTest.java
1import org.junit.jupiter.api.Test;
2
3import static org.junit.jupiter.api.Assertions.assertEquals;
4
5class WordCounter {
6 int countWords(String text) {
7 int spaces = 0;
8 for (int i = 0; i < text.length(); i++) {
9 if (text.charAt(i) == ' ') {
10 spaces++;
11 }
12 }
13 return spaces + 1;
14 }
15}
16
17class WordCounterTest {
18 WordCounter counter = new WordCounter();
19
20 @Test
21 void countsTheWordsInASentence() {
22 assertEquals(3, counter.countWords("hello there friend"));
23 }
24
25 @Test
26 void findsNoWordsInEmptyText() {
27 assertEquals(0, counter.countWords(""));
28 }
29
30 @Test
31 void ignoresSpacesAtTheEnds() {
32 assertEquals(2, counter.countWords(" hello world "));
33 }
34
35 @Test
36 void treatsTwoSpacesInARowAsOneGap() {
37 assertEquals(2, counter.countWords("hello world"));
38 }
39}
Prints
›WordCounterTest
› ✔ countsTheWordsInASentence()
› ✘ findsNoWordsInEmptyText(): expected: <0> but was: <1>
› ✘ ignoresSpacesAtTheEnds(): expected: <2> but was: <4>
› ✘ treatsTwoSpacesInARowAsOneGap(): expected: <2> but was: <3>
›4 tests: 1 passed, 3 failed

Only the ordinary test passes. Each failure tells you something different.

  • findsNoWordsInEmptyText(): expected: <0> but was: <1>. Empty text has no spaces, so the method returns 0 plus 1.
  • ignoresSpacesAtTheEnds(): expected: <2> but was: <4>. The spaces before hello and after world count as gaps between words.
  • treatsTwoSpacesInARowAsOneGap(): expected: <2> but was: <3>. Two spaces side by side count as two gaps.

“Spaces plus one” was only ever right for text with single spaces between words and none at the ends. Running it on a normal sentence would never show that, and three short tests showed it at once. Step through the empty-text test to see the cause: the loop never runs, spaces stays 0, and the method returns 1.

ExerciseWordCounterTest.java
Rewrite countWords so that all four tests pass. Change only the method, and leave the tests as they are.
Key idea

Bugs gather at the edges: empty input, zero, negative numbers and the values right at a limit. Test them on purpose, because ordinary inputs rarely find them.

Quick check
A withdraw method should refuse any amount larger than the balance. A teammate writes the check as if (amount >= balance). On an account holding 100, which test catches the mistake?

One test, many inputs

Edge-case tests often look alike: the same call with a different input and a different expected answer. A parameterized test is written once and runs once per input. You mark the method @ParameterizedTest in place of @Test, give it a parameter, and add a source of values. @ValueSource lists values of one type. Here it feeds words to the palindrome checker from string practice.

PalindromesTest.java
1import org.junit.jupiter.params.ParameterizedTest;
2import org.junit.jupiter.params.provider.ValueSource;
3
4import static org.junit.jupiter.api.Assertions.assertFalse;
5import static org.junit.jupiter.api.Assertions.assertTrue;
6
7class Palindromes {
8 boolean isPalindrome(String word) {
9 for (int i = 0; i < word.length() / 2; i++) {
10 if (word.charAt(i) != word.charAt(word.length() - 1 - i)) {
11 return false;
12 }
13 }
14 return true;
15 }
16}
17
18class PalindromesTest {
19 Palindromes checker = new Palindromes();
20
21 @ParameterizedTest
22 @ValueSource(strings = {"racecar", "level", "noon", "a"})
23 void acceptsWordsThatReadTheSameBothWays(String word) {
24 assertTrue(checker.isPalindrome(word));
25 }
26
27 @ParameterizedTest
28 @ValueSource(strings = {"java", "ab", "racecars"})
29 void rejectsOtherWords(String word) {
30 assertFalse(checker.isPalindrome(word));
31 }
32}
Prints
›PalindromesTest
› ✔ acceptsWordsThatReadTheSameBothWays(String) [1] racecar
› ✔ acceptsWordsThatReadTheSameBothWays(String) [2] level
› ✔ acceptsWordsThatReadTheSameBothWays(String) [3] noon
› ✔ acceptsWordsThatReadTheSameBothWays(String) [4] a
› ✔ rejectsOtherWords(String) [1] java
› ✔ rejectsOtherWords(String) [2] ab
› ✔ rejectsOtherWords(String) [3] racecars
›7 tests: 7 passed, 0 failed

The report has one line per value, numbered from 1, with the value at the end, such as acceptsWordsThatReadTheSameBothWays(String) [1] racecar. Two short methods made seven checks. The word "a" is in the first list on purpose: a one-letter word is an edge case, and it does read the same both ways.

When each input has its own expected answer, use @CsvSource. Each string in it is one row, and the values in a row are separated by commas, like a line of a spreadsheet saved as CSV (comma-separated values). JUnit converts each value to the type of the matching parameter, so the row "90, A" becomes the int 90 and the String "A". This grader's table tests both sides of every boundary: 90 and 89, 80 and 79, 70 and 69.

GraderTest.java
1import org.junit.jupiter.params.ParameterizedTest;
2import org.junit.jupiter.params.provider.CsvSource;
3
4import static org.junit.jupiter.api.Assertions.assertEquals;
5
6class Grader {
7 String grade(int score) {
8 if (score > 90) {
9 return "A";
10 } else if (score >= 80) {
11 return "B";
12 } else if (score >= 70) {
13 return "C";
14 } else {
15 return "F";
16 }
17 }
18}
19
20class GraderTest {
21 Grader grader = new Grader();
22
23 @ParameterizedTest
24 @CsvSource({"100, A", "90, A", "89, B", "80, B", "79, C", "70, C", "69, F", "0, F"})
25 void givesTheRightLetter(int score, String expected) {
26 assertEquals(expected, grader.grade(score));
27 }
28}
Prints
›GraderTest
› ✔ givesTheRightLetter(int, String) [1] 100, A
› ✘ givesTheRightLetter(int, String) [2] 90, A: expected: <A> but was: <B>
› ✔ givesTheRightLetter(int, String) [3] 89, B
› ✔ givesTheRightLetter(int, String) [4] 80, B
› ✔ givesTheRightLetter(int, String) [5] 79, C
› ✔ givesTheRightLetter(int, String) [6] 70, C
› ✔ givesTheRightLetter(int, String) [7] 69, F
› ✔ givesTheRightLetter(int, String) [8] 0, F
›8 tests: 7 passed, 1 failed

One row fails: [2] 90, A: expected: <A> but was: <B>. The method says score > 90, so a score of exactly 90 falls through to B. The fix is score >= 90. A test with 95 would never have caught this. The row on the boundary did.

Parameterized tests need one more part of JUnit
@ParameterizedTest, @ValueSource and @CsvSource live in a separate part of JUnit called junit-jupiter-params, which is why their imports start with org.junit.jupiter.params. A real project usually adds JUnit as one dependency, junit-jupiter, and that already includes it.

Test first: red, green, refactor#

So far you wrote the code first and the tests afterwards. Test-driven development, or TDD, turns that around: you write a test for behaviour that does not exist yet, watch it fail, and only then write the code. Kent Beck, one of JUnit's two authors, made the practice popular. It runs in a short loop.

  1. 1Write one small test for the next piece of behaviour and run it. It fails, and most tools show a failure in red, so this is the red step. Seeing the test fail proves that it can catch the missing behaviour.
  2. 2Write the least code that makes every test pass, and nothing more. The tests go green.
  3. 3Tidy the code, with clearer names, less repetition or a simpler loop, and run the tests after each change to make sure they are still green.

Changing how code is written without changing what it does is called refactoring, and it names the third step. After it you go round the loop again with the next test. The example below builds a method that turns a full name into initials, so that Ada Lovelace becomes AL. Step through it, and read the report under the code at every step.

Red, green, refactorstep 1 of 7
RedGreenRefactor

Write a test that fails

Start with what the method should do: Ada Lovelace gives AL. The class has a stub, a method that compiles but returns an empty string, so the test can run. It fails, which proves the test can catch the missing behaviour.

1import org.junit.jupiter.api.Test;
2
3import static org.junit.jupiter.api.Assertions.assertEquals;
4
5class Names {
6 String initials(String fullName) {
7 return "";
8 }
9}
10
11class NamesTest {
12 Names names = new Names();
13
14 @Test
15 void twoNamesGiveTwoInitials() {
16 assertEquals("AL", names.initials("Ada Lovelace"));
17 }
18}

What Run prints for this code

NamesTest
✘ twoNamesGiveTwoInitials(): expected: <AL> but was: <>
1 test: 0 passed, 1 failed

Three moves in that example are worth a second look.

  • Step 1 starts from a stub, a method with a placeholder body that compiles but does nothing useful. In a real project the very first run often fails even earlier, with a compile error, because initials does not exist yet. Adding the stub is the quickest way from that error to a real red test.
  • Step 2 returns "AL" for every name. That looks like cheating, and it is on purpose: it is the least code that passes, and it forces the next test to demand a real answer. Each step stays tiny, so when a test goes red you know which change caused it.
  • Step 7 rewrites the whole loop. Without tests that would be a gamble. With three tests that still pass after the change, you know the new code does everything the old code did.
Key idea

Red, green, refactor: write a failing test, make it pass with the simplest code, then tidy the code while the tests stay green. Then start again with the next test.

Writing the test first has a second benefit. It makes you decide what the method should do, its name, its inputs and its answer for each case, before you think about how. Some developers work this way for every change, and others save it for fiddly logic. Either way, the loop is worth practising until it feels normal.

Quick check
Why does TDD have you run a new test and watch it fail before you write the code?
ExerciseSongLengthTest.java
A music app shows each song's length in minutes and seconds, so 187 seconds is 3:07. The tests were written first. Write format so that all seven pass, including the one for a negative length.

Running tests in a real project#

The snippets on this page put a class and its tests in one file, so that the Run button can show both. A real project keeps them apart. The class goes under src/main/java and its tests under src/test/java, in a test class named after it:

Project layout
read along
1bank/
2├── pom.xml
3└── src/
4 ├── main/
5 │ └── java/
6 │ └── BankAccount.java
7 └── test/
8 └── java/
9 └── BankAccountTest.java

The test code can use every class in src/main/java, but it stays out of the finished program. Maven and Gradle, the two build tools most Java projects use, both expect this layout. The project also lists JUnit as a dependency, a library the build tool downloads for you. In Maven's build file, pom.xml, that is one entry:

pom.xml (part)
read along
1<dependencies>
2 <dependency>
3 <groupId>org.junit.jupiter</groupId>
4 <artifactId>junit-jupiter</artifactId>
5 <version>6.1.3</version>
6 <scope>test</scope>
7 </dependency>
8</dependencies>

<scope>test</scope>means JUnit is only used to compile and run the tests, and is never shipped with the program. Gradle declares the same library in its own format, together with JUnit's launcher, and adds useJUnitPlatform() to switch JUnit on. Setting up either tool from scratch is the job of Maven and Gradle. For now, read the output below as a preview.

In your editor

IntelliJ IDEA and VS Code show a small run button in the margin beside each test class and each test method. In Eclipse you right-click the class and choose Run As, then JUnit Test. The results appear in a panel that lists every test and marks which passed and which failed. Click a failed test to see the expected and actual values and to jump to the line that failed. Most editors can also create an empty test class for you, in the right folder.

From the terminal: mvn test

mvn testcompiles the project and runs every test class. Maven's test runner, a plug-in called Surefire, finds test classes by their names: by default, names that end in Test, Tests or TestCase, or start with Test. Here is the real output for the broken withdraw from earlier, with the compiling steps cut from the top:

Terminal
read along
1$ mvn test
2...
3[INFO] -------------------------------------------------------
4[INFO] T E S T S
5[INFO] -------------------------------------------------------
6[INFO] Running BankAccountTest
7[ERROR] Tests run: 2, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 0.034 s <<< FAILURE! -- in BankAccountTest
8[ERROR] BankAccountTest.withdrawTakesFromTheBalance -- Time elapsed: 0.008 s <<< FAILURE!
9org.opentest4j.AssertionFailedError: expected: <70> but was: <130>
10 at org.junit.jupiter.api.Assertions.assertEquals(Assertions.java:569)
11 at BankAccountTest.withdrawTakesFromTheBalance(BankAccountTest.java:18)
12
13[INFO]
14[INFO] Results:
15[INFO]
16[ERROR] Failures:
17[ERROR] BankAccountTest.withdrawTakesFromTheBalance:18 expected: <70> but was: <130>
18[INFO]
19[ERROR] Tests run: 2, Failures: 1, Errors: 0, Skipped: 0
20[INFO]
21[INFO] ------------------------------------------------------------------------
22[INFO] BUILD FAILURE
23[INFO] ------------------------------------------------------------------------

Read it from the bottom. Tests run: 2, Failures: 1 is the count, and the list under Failures: names the test, the line in BankAccountTest.javawhere it failed (18), and JUnit's message. Maven also counts Errors. A test whose assertion failed is a failure, and a test stopped by an exception nobody expected is an error. Once withdraw is fixed, the same command ends with Tests run: 2, Failures: 0, Errors: 0, Skipped: 0 and BUILD SUCCESS.

From the terminal: gradle test

Gradle says less. When every test passes, it prints only that the build succeeded. A failure looks like this, and a project that comes with the Gradle wrapper runs ./gradlew test and gets the same output:

Terminal
read along
1$ gradle test
2
3> Task :test FAILED
4
5BankAccountTest > withdrawTakesFromTheBalance() FAILED
6 org.opentest4j.AssertionFailedError at BankAccountTest.java:18
7
82 tests completed, 1 failed
9
10FAILURE: Build failed with an exception.
11
12* What went wrong:
13Execution failed for task ':test'.
14> There were failing tests. See the report at: file:///home/you/bank/build/reports/tests/test/index.html
15
16* Try:
17> Run with --scan to get full insights from a Build Scan (powered by Develocity).
18
19BUILD FAILED in 3s
203 actionable tasks: 1 executed, 2 up-to-date

Gradle names the failing test and its line, and saves the full details, JUnit's message included, in the HTML report it points to. Open that file in a browser to read them.

JUnit's own report

Editors and build tools all hand the work to the same engine, the JUnit Platform: the part of JUnit that finds tests and runs them. JUnit also ships a console launcher that runs the platform from the terminal, and its output shows JUnit's own report style. For the same two tests:

Terminal
read along
1$ javac -d out -cp junit-platform-console-standalone-6.1.3.jar BankAccount.java BankAccountTest.java
2$ java -jar junit-platform-console-standalone-6.1.3.jar execute --class-path out --scan-class-path
3
4Thanks for using JUnit! Support its development at https://junit.org/sponsoring
5
6╷
7├─ JUnit Platform Suite ✔
8├─ JUnit Jupiter ✔
9│ └─ BankAccountTest ✔
10│ ├─ depositAddsToTheBalance() ✔
11│ └─ withdrawTakesFromTheBalance() ✘ expected: <70> but was: <130>
12└─ JUnit Vintage ✔
13
14Failures (1):
15 JUnit Jupiter:BankAccountTest:withdrawTakesFromTheBalance()
16 MethodSource [className = 'BankAccountTest', methodName = 'withdrawTakesFromTheBalance', methodParameterTypes = '']
17 => org.opentest4j.AssertionFailedError: expected: <70> but was: <130>
18 org.junit.jupiter.api.Assertions.assertEquals(Assertions.java:569)
19 BankAccountTest.withdrawTakesFromTheBalance(BankAccountTest.java:18)
20
21Test run finished after 73 ms
22[ 4 containers found ]
23[ 0 containers skipped ]
24[ 4 containers started ]
25[ 0 containers aborted ]
26[ 4 containers successful ]
27[ 0 containers failed ]
28[ 2 tests found ]
29[ 0 tests skipped ]
30[ 2 tests started ]
31[ 0 tests aborted ]
32[ 1 tests successful ]
33[ 1 tests failed ]

JUnit draws a tree. At the top sit its engines: Jupiter runs the tests on this page, and the launcher also brings Vintage, for old JUnit 4 tests, and Platform Suite, for grouping test classes. Under Jupiter come the test class and each test with a tick or a cross, then the failures in detail, then a table of counts. The visualizer's report is a shorter version of the same information: one line per test with JUnit's own messages, then a count. Whichever tool you use, you are looking for the same three things: which tests failed, what each one expected, and what the code gave instead.

Try it

Answer each one in your head or on paper first, then open the answer.

1
A test reads Playlist list = new Playlist();, then list.add("Blue Monday");, then assertEquals(1, list.size());. Which line arranges, which acts, and which asserts?
2
A teammate's test says assertEquals(total, 30); and fails with expected: <25> but was: <30>. Apart from the failure itself, what is wrong with the test?
3
Why can assertEquals(0.3, log.getTotalKm()) fail when the code is right, and what do you write instead?
4
List at least four edge cases worth testing for a method int largest(int[] numbers) that returns the biggest number in an array.
5
This test class sets account in a field, BankAccount account = new BankAccount(100);, and both tests deposit 50 and expect 150. Do both pass?
6
In assertThrows(IllegalArgumentException.class, () -> account.deposit(-5)), what does the () -> do?

Practice

Work through these on your own. Each one builds on this page.

1
Write a test class for the Counter from the methods lesson, with its increment and add methods. Cover an ordinary case for each method, then decide what add(0) and add(-3) should do and write a test for each decision.
2
Give BankAccount a withdraw that refuses an amount of zero or less, and an amount larger than the balance, each with an IllegalArgumentException. Build it test first, one rule at a time, with red, green, refactor. Include the boundary: withdrawing the whole balance must work and leave 0.
3
Use @CsvSource to test the vowel counter from string practice with at least six rows. Include a word with no vowels, such as rhythm, a word in capital letters, and the empty string, which a CSV row writes as two single quotes: "'', 0".
4
Build String reverseWords(String sentence) test first, so that "hello world" gives "world hello". Start with one word, then two, then three, then the edge cases: empty text and extra spaces. Write down the red message you see at each step.
5
Break it on purpose. Take the finished BankAccountTest with its @BeforeEach, and put one bug at a time into BankAccount: a plus in place of a minus, a constructor that ignores the opening balance, a deposit that adds 1 whatever the amount. Run the tests after each bug. If any bug survives with every test green, write the test that catches it.

Further reading#

Spotted a mistake, or a part that did not make sense? Report it by email