Skip to content
wannacode

Tests with JUnit

Problem

The discount that broke quietly

The shop's rule is: the discount is in percent, and on a purchase of €100 or more you add another 5%. You write the method and try it by hand:

Main.java
class Discount {
// Discount in percent. From €100 up, add another 5%.
static double apply(double price, int percent) {
if (price > 100) {
percent = percent + 5;
}
return price * (100 - percent) / 100;
}
}
System.out.println(Discount.apply(50, 10));
System.out.println(Discount.apply(200, 10));
Output
45.0
170.0

€50 with 10% off is €45, and €200 with 15% off is €170. It looks right, so you ship it. A week later a customer with a purchase of exactly €100 pays €90 instead of €85. The condition is price > 100, and it should be price >= 100.

Nobody tried exactly €100. And on every later change to the method, you have to try all the cases by hand again.

The idea

Code that checks code

Instead of reading the output with your own eyes, you write small methods that call your code and compare the result with the expected one. That is a test. You run all the tests with one click, after every change, and they tell you what broke.

Kind What it checks Example
Unit test one small piece, usually one method, apart from the rest Discount.apply(100, 10) returns 85
Integration how the pieces work together: with a database, a file, another service the order is saved to the database and read back
End-to-end the whole application, the way a person uses it the customer adds to the cart, pays and gets an email
In this lesson we write unit tests. In Java you use the JUnit library for them.

Why it is worth it:

  • you see the bug right away, while you still remember what you changed, not when the customer complains;
  • the test shows how the code is used: which case returns what;
  • you can clean up the code boldly. If the tests are still green after the change, the behavior has not changed.
More precisely, if you are curious

There are other kinds of tests too: functional (does the program meet the requirements), performance (how many requests it can handle), security (are there any holes) and user interface (the buttons, the forms). All of them build on unit tests: there are the most of them, and they are the fastest and the cheapest to maintain.

See it

The first test

The tests for Discount are in a separate class, DiscountTest:
DiscountTest.java
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class DiscountTest {
@Test
void tenPercentOff() {
assertEquals(45.0, Discount.apply(50, 10));
}
@Test
void extraFivePercentFrom100() {
assertEquals(85.0, Discount.apply(100, 10));
}
}
  • @Test in front of the method tells JUnit "this is a test, run it". Tags like this with @ are called annotations. You have already seen @Override. How JUnit finds them, you will see in the last lesson of the module.
  • assertEquals(expected, actual) compares the two values. If they are different, the test fails.

We run the two tests against the code from the first card:

Result
FAIL extraFivePercentFrom100(): expected: <85.0> but was: <90.0>
PASS tenPercentOff()
Tests: 2, passed: 1, failed: 1

The first test passes. The second one fails and says exactly what happened: it expected 85.0, and the method returned 90.0. You fix the condition to price >= 100, run them again, and both are green:

Result
PASS extraFivePercentFrom100()
PASS tenPercentOff()
Tests: 2, passed: 2, failed: 0

Every test does three things: it prepares the values, calls the code and checks the result.

class DiscountTest {
@Test
void tenPercentOff() {
assertEquals(45.0, Discount.apply(50, 10));
}
@Test
void extraFivePercentFrom100() {
assertEquals(85.0, Discount.apply(100, 10));
}
}
Step by step, in words
  1. DiscountTest has two tests. JUnit runs each one on its own and lights a lamp next to it.
  2. Test 1, prepare: a price of €50 and 10% off.
  3. Act: Discount.apply(50, 10) returns 45.0.
  4. Check: assertEquals compares the expected 45.0 with the returned 45.0. They are the same, so the test passes.
  5. Test 2: exactly €100 and 10%. We expect 85.0, because from €100 up there is another 5%.
  6. The condition in the method is price > 100, and 100 is not greater than 100. The method returns 90.0.
  7. Expected 85.0, returned 90.0: the test fails and says exactly that.
  8. You fix the condition to price >= 100 and run the tests again.
  9. Now 100 passes the condition and the method returns 85.0.
  10. Expected and returned match. Both tests are green.
More precisely, if you are curious
  • In a real project the tests are in src/test/java, next to the code in src/main/java. IntelliJ shows a green arrow next to each test and next to the class: you click it and see the result.
Layout
src/main/java/Discount.java the code
src/test/java/DiscountTest.java the tests for it
  • You add JUnit as a dependency. With Maven this goes in pom.xml, and you run the tests with mvn test:
pom.xml
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.11.4</version>
<scope>test</scope>
</dependency>
  • Here, on the site, the tests are run from main through JUnit's programming interface (the Launcher API). The method below runs the classes and prints the results sorted by name. They are sorted because JUnit does not promise the order of the tests:
The runner
static void runTests(Class<?>... classes) {
LauncherDiscoveryRequest request = LauncherDiscoveryRequestBuilder.request()
.selectors(Arrays.stream(classes).map(DiscoverySelectors::selectClass).toList())
.build();
List<String> lines = new ArrayList<>();
int[] failed = { 0 };
TestExecutionListener listener = new TestExecutionListener() {
@Override
public void executionFinished(TestIdentifier id, TestExecutionResult result) {
if (!id.isTest()) return;
if (result.getStatus() == TestExecutionResult.Status.SUCCESSFUL) {
lines.add("PASS " + id.getDisplayName());
} else {
failed[0]++;
lines.add("FAIL " + id.getDisplayName() + ": " + result.getThrowable().map(Throwable::getMessage).orElse(""));
}
}
};
LauncherFactory.create().execute(request, listener);
Collections.sort(lines);
lines.forEach(System.out::println);
System.out.println("Tests: " + lines.size() + ", passed: " + (lines.size() - failed[0]) + ", failed: " + failed[0]);
}

Try it

VAT with double

The price with VAT is the price times 1.2. Two tests:

What will the tests show?

class Vat {
static double withVat(double price) {
return price * 1.2;
}
}
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class VatTest {
@Test
void coffee() {
assertEquals(12.0, Vat.withVat(10));
}
@Test
void tea() {
assertEquals(3.6, Vat.withVat(3));
}
}
Answer

The idea

Yes or no, and an expected exception

Besides equality, you often check a condition, or that the code refuses. Here is Account from the module on exceptions, with a new method, canPay:

Account.java
class InsufficientFundsException extends RuntimeException {
public InsufficientFundsException(int shortfall) {
super("Short by €" + shortfall);
}
}
class Account {
private int balance;
public Account(int balance) {
this.balance = balance;
}
public int getBalance() {
return balance;
}
public boolean canPay(int amount) {
return amount <= balance;
}
public void withdraw(int amount) {
if (amount <= 0) {
throw new IllegalArgumentException("The amount must be positive");
}
if (amount > balance) {
throw new InsufficientFundsException(amount - balance);
}
balance -= amount;
}
}
AccountTest.java
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class AccountTest {
@Test
void canPayWhatIsThere() {
Account account = new Account(100);
assertTrue(account.canPay(100));
assertFalse(account.canPay(101));
}
@Test
void withdrawTooMuch() {
Account account = new Account(100);
InsufficientFundsException e = assertThrows(InsufficientFundsException.class,
() -> account.withdraw(130));
assertEquals("Short by €30", e.getMessage());
assertEquals(100, account.getBalance());
}
}
Output
PASS canPayWhatIsThere()
PASS withdrawTooMuch()
Tests: 2, passed: 2, failed: 0
  • assertTrue and assertFalse check a condition.
  • assertThrows(Type.class, code) fails if the code does not throw an exception of that type. You pass the code as a lambda: assertThrows runs it itself and catches the exception. Then it returns it, so you can check the message too.

The second test also checks something that is easy to forget: after the refusal the balance is the same.

The idea

Every test starts clean

Here the two tests share one account with €100:

AccountTest.java
class AccountTest {
private static Account account = new Account(100);
@Test
@DisplayName("withdrawing €40 leaves €60")
void withdraw40() {
account.withdraw(40);
assertEquals(60, account.getBalance());
}
@Test
@DisplayName("withdrawing €70 leaves €30")
void withdraw70() {
account.withdraw(70);
assertEquals(30, account.getBalance());
}
}
Output
FAIL withdrawing €70 leaves €30: Short by €10
PASS withdrawing €40 leaves €60
Tests: 2, passed: 1, failed: 1

JUnit ran the test for €40 first. The second one has €60 left, and it withdraws €70. If the order were the other way around, the other one would fail. A test that depends on another test is a trap. So the object is created again before each test with @BeforeEach:

AccountTest.java
import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;
class AccountTest {
private Account account;
@BeforeEach
void newAccount() {
account = new Account(100);
}
@Test
@DisplayName("withdrawing €40 leaves €60")
void withdraw40() {
account.withdraw(40);
assertEquals(60, account.getBalance());
}
@Test
@DisplayName("withdrawing €70 leaves €30")
void withdraw70() {
account.withdraw(70);
assertEquals(30, account.getBalance());
}
}
Output
PASS withdrawing €40 leaves €60
PASS withdrawing €70 leaves €30
Tests: 2, passed: 2, failed: 0
@DisplayName gives the test a name in words. It is shown in the result instead of the method name.
More precisely, if you are curious
More annotations you will come across:
Annotations
@AfterEach after each test (cleanup)
@BeforeAll once before all the tests in the class (static method)
@AfterAll once after all of them (static method)
@Disabled("...") the test is skipped for now
@Timeout(5) the test fails if it takes longer than 5 seconds
@Nested a nested class with a group of related tests
@Tag("fast") a tag, so you can run only some of the tests
@RepeatedTest(5) the same test 5 times
@ValueSource(ints = { 1, 2, 3 }) one value per run of a parameterized test
@CsvFileSource(resources = "/data.csv") the rows come from a file
@MethodSource("prices") the arguments come from a static method
@TempDir Path dir a temporary folder, deleted after the test
@ExtendWith(...), @RegisterExtension JUnit extensions
@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class)
the names are made from the method names

The idea

One check, many cases

Four cases for the discount mean four almost identical methods. @ParameterizedTest runs one method with different values, and @CsvSource gives them as rows of "price, percent, expected":

DiscountTest.java
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import static org.junit.jupiter.api.Assertions.assertEquals;
class DiscountTest {
@ParameterizedTest(name = "€{0} with {1}% off = €{2}")
@CsvSource({
"50, 10, 45.0",
"99, 0, 99.0",
"100, 10, 85.0",
"200, 20, 150.0"
})
void apply(double price, int percent, double expected) {
assertEquals(expected, Discount.apply(price, percent));
}
}
Output
PASS €100 with 10% off = €85.0
PASS €200 with 20% off = €150.0
PASS €50 with 10% off = €45.0
PASS €99 with 0% off = €99.0
Tests: 4, passed: 4, failed: 0

Each row is a separate test with a separate result. name in @ParameterizedTest builds its name from the values: {0} is the first one, {1} the second. A new case is a new row, not a new method. The important cases are at the edges: exactly €100, €99, 0%.

Try it

Find the bug from the test

Delivery is €4, and free from €50 up. The test:

DeliveryTest.java
class DeliveryTest {
@ParameterizedTest(name = "order of €{0}: delivery €{1}")
@CsvSource({ "20, 4", "50, 0", "80, 0" })
void price(int total, int expected) {
assertEquals(expected, Delivery.price(total));
}
}
Output
FAIL order of €50: delivery €0: expected: <0> but was: <4>
PASS order of €20: delivery €4
PASS order of €80: delivery €0
Tests: 3, passed: 2, failed: 1

Which line of the code is the bug on?

Pick the line with the bug.

The idea

The test first

The checkout has to accept the promo code FALL10 for 10% off, whether it is in lowercase or uppercase. This time you write the tests first:

PromoTest.java
class PromoTest {
@Test
void knownCode() {
assertEquals(10, Promo.percent("FALL10"));
}
@Test
void codeIsCaseInsensitive() {
assertEquals(10, Promo.percent("fall10"));
}
@Test
void unknownCode() {
assertEquals(0, Promo.percent("NOPE"));
}
}
  1. Red. For now the method returns 0. The tests fail, and that is good: it means they really check something.
class Promo {
static int percent(String code) {
return 0;
}
}
Output
FAIL codeIsCaseInsensitive(): expected: <10> but was: <0>
FAIL knownCode(): expected: <10> but was: <0>
PASS unknownCode()
Tests: 3, passed: 1, failed: 2
  1. Green. You write the simplest code that makes them pass:
class Promo {
static int percent(String code) {
if (code.equalsIgnoreCase("FALL10")) {
return 10;
}
return 0;
}
}
Output
PASS codeIsCaseInsensitive()
PASS knownCode()
PASS unknownCode()
Tests: 3, passed: 3, failed: 0
  1. Clean up. Now you can move the codes into a HashMap, add new ones and clean up the method. The tests will tell you if you break something.

This is TDD (test-driven development). Writing the test first makes you decide what exactly the method has to do, before you think about how.

More precisely

Suites and fake objects

A suite is a group of test classes that run together, for example all the tests of the checkout:

CheckoutSuite.java
import org.junit.platform.suite.api.SelectClasses;
import org.junit.platform.suite.api.Suite;
import org.junit.platform.suite.api.SuiteDisplayName;
@Suite
@SuiteDisplayName("Checkout tests")
@SelectClasses({ DiscountTest.class, AccountTest.class, DeliveryTest.class })
class CheckoutSuite {
}

When you run it, it runs the tests of the three classes.

More precisely, if you are curious
  • Sometimes the code depends on something you do not want to call in a test: an exchange rate from a bank, a payment, an email. Then you pass in a fake object (a mock) that returns whatever you tell it to. The best known library for this is Mockito:
PriceInUsdTest.java
import static org.mockito.Mockito.*;
interface ExchangeRates {
double eurTo(String currency);
}
class PriceInUsdTest {
@Test
void convertsWithTodaysRate() {
ExchangeRates rates = mock(ExchangeRates.class);
when(rates.eurTo("USD")).thenReturn(1.10);
Converter converter = new Converter(rates);
assertEquals(11.0, converter.toUsd(10), 0.001);
verify(rates).eurTo("USD");
}
}

mock makes an object that implements the interface, when(...).thenReturn(...) says what to return, and verify checks that the method was called. Here on the site Mockito is only compiled: to run, it needs one more library (byte-buddy).

  • More checks besides assertEquals, assertTrue and assertThrows:
Checks
assertNotEquals(a, b) are they different
assertSame(a, b) is it the same object (==), not just equal
assertNotSame(a, b)
assertNull(x), assertNotNull(x)
assertArrayEquals(a, b) the arrays have the same elements
assertIterableEquals(a, b) two lists with the same elements in the same order
assertLinesMatch(a, b) lines of text (regular expressions are allowed too)
assertAll(() -> ..., () -> ...) runs all the checks and reports all the failures
assertTimeout(Duration.ofMillis(100), () -> ...)
fail("message") the test fails right away

Exercise

Write the tests

Solve these in your editor, with JUnit 5 in the project.

1. The discount calculator. Write DiscountTest for Discount.apply from this lesson. Cover these cases: €80 with 10% off is €72, €100 with 0% off is €95, €150 with 20% off is €112.50, €0 with 30% off is €0, and exactly €100 with 10% off is €85. Run them against the broken version with price > 100 too. Exactly the two cases with €100 should fail:

Result against the broken version
FAIL exactly100GetsTheExtra(): expected: <85.0> but was: <90.0>
FAIL €100 with 0% off = €95.0: expected: <95.0> but was: <100.0>
PASS €0 with 30% off = €0.0
PASS €150 with 20% off = €112.5
PASS €80 with 10% off = €72.0
Tests: 5, passed: 3, failed: 2

2. The account. Write AccountTest for Account from the card "Yes or no, and an expected exception". Create the account with €100 in @BeforeEach. Check that:

  • withdrawing €30 leaves €70;
  • you can withdraw everything, and 0 is left;
  • withdrawing €150 throws InsufficientFundsException with the message "Short by €50", and the balance stays €100;
  • 0 and -20 throw IllegalArgumentException (one parameterized test with @ValueSource(ints = { 0, -20 })).

3. The bug in the average grade. The method looks fine:

Grades.java
class Grades {
static double average(int[] grades) {
int sum = 0;
for (int g : grades) {
sum += g;
}
return sum / grades.length;
}
}
Write two tests: the average of 4 and 6 is 5.0, and the average of 5 and 6 is 5.5 (with an allowed difference of 0.001). One of them fails:
Result
FAIL averageWithAFraction(): expected: <5.5> but was: <5.0>
PASS wholeAverage()
Tests: 2, passed: 1, failed: 1

Find the cause and fix the method, so that both pass.

Test

Check yourself

1. What does a unit test check?
2. The test fails with "expected: <85.0> but was: <90.0>". What did the method return?
3. Why is the account created in @BeforeEach and not once in a field?
4. How do you check that withdraw(130) throws InsufficientFundsException?
5. What is the order in TDD?

Made by Ivo Gergov as part of Evo Tech's campaign for accessible programming education ·