Encodera

Thanks for reading!

Donate to the site — we work hard for you.

Contents

Testing with JUnit

A unit test is a small program that checks that one piece of code works correctly: it calls a method with known data and compares the result with the expected one. Tests are run after every change, and if something breaks you find out right away. The most popular testing library for Java is JUnit; the current version is 6, and its API is almost the same as JUnit 5.

Adding JUnit

Tests aren't run as a single file: JUnit is an external library, usually added through a build tool. In Maven the dependency goes into pom.xml:

<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>6.1.3</version>
    <scope>test</scope>
</dependency>

In Gradle — into build.gradle.kts:

dependencies {
    testImplementation("org.junit.jupiter:junit-jupiter:6.1.3")
    testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
 
tasks.test {
    useJUnitPlatform()
}

The program code lives in the src/main/java folder and the tests in src/test/java, in the same package.

The code under test

We'll test a simple shopping cart:

// src/main/java/shop/Cart.java
package shop;
 
import java.util.ArrayList;
import java.util.List;
 
public class Cart {
    private final List<Double> prices = new ArrayList<>();
 
    public void add(double price) {
        if (price <= 0) {
            throw new IllegalArgumentException("Price must be positive: " + price);
        }
        prices.add(price);
    }
 
    public int size() {
        return prices.size();
    }
 
    public double total() {
        return prices.stream().mapToDouble(Double::doubleValue).sum();
    }
 
    public double totalWithDiscount(int percent) {
        return total() * (100 - percent) / 100;
    }
}

The first test

A test is a class with methods marked with the @Test annotation. Inside, a method prepares data, calls the code under test and compares the result with the expected value using assertEquals(expected, actual):

// src/test/java/shop/CartTest.java
package shop;
 
import static org.junit.jupiter.api.Assertions.assertEquals;
 
import org.junit.jupiter.api.Test;
 
class CartTest {
    @Test
    void totalAddsPrices() {
        Cart cart = new Cart();      // arrange
        cart.add(10.0);
        cart.add(5.5);
 
        double total = cart.total(); // act
 
        assertEquals(15.5, total);   // assert
    }
}

If the values match, the test passes. If not, assertEquals throws an error with a clear message and the test is marked as failed. Give test methods descriptive names — they tell you exactly what broke.

Assertions and setup

Besides assertEquals you'll often need assertTrue, assertFalse, assertNull and assertThrows — the last one checks that the code threw the expected exception and returns it. A method annotated with @BeforeEach runs before every test, so each test gets a fresh empty cart:

// src/test/java/shop/CartTest.java
package shop;
 
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
 
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
 
class CartTest {
    private Cart cart;
 
    @BeforeEach
    void setUp() {
        cart = new Cart();
    }
 
    @Test
    void newCartIsEmpty() {
        assertEquals(0, cart.size());
    }
 
    @Test
    void totalAddsPrices() {
        cart.add(10.0);
        cart.add(5.5);
        assertEquals(15.5, cart.total());
    }
 
    @Test
    void rejectsNegativePrice() {
        var e = assertThrows(IllegalArgumentException.class, () -> cart.add(-3));
        assertEquals("Price must be positive: -3.0", e.getMessage());
    }
 
    @ParameterizedTest
    @CsvSource({"0, 200.0", "10, 180.0", "50, 100.0"})
    void discount(int percent, double expected) {
        cart.add(200.0);
        assertEquals(expected, cart.totalWithDiscount(percent));
    }
}

The last method is a parameterized test: @ParameterizedTest runs it several times, and @CsvSource supplies the data — each line becomes one run with the parameters percent and expected. This way one check covers many cases without copying code.

Running the tests

In a project, tests are run with mvn test or gradle test, and in IntelliJ IDEA with the green button next to the test class. Without a build tool you can use the JUnit console launcher — the junit-platform-console-standalone-6.1.3.jar file from Maven Central:

javac -d out -cp junit-platform-console-standalone-6.1.3.jar src/main/java/shop/Cart.java src/test/java/shop/CartTest.java
java -jar junit-platform-console-standalone-6.1.3.jar execute --class-path out --select-class shop.CartTest

Output (shortened; your run time will differ):

├─ JUnit Jupiter ✔
│  └─ CartTest ✔
│     ├─ rejectsNegativePrice() ✔
│     ├─ discount(int, double) ✔
│     │  ├─ [1] "0", "200.0" ✔
│     │  ├─ [2] "10", "180.0" ✔
│     │  └─ [3] "50", "100.0" ✔
│     ├─ newCartIsEmpty() ✔
│     └─ totalAddsPrices() ✔
 
Test run finished after 469 ms
[         6 tests successful      ]
[         0 tests failed          ]

JUnit chooses the order of tests itself, so tests must not depend on each other.

Common mistake

double numbers are stored inexactly, and a direct comparison lets you down. Let's add this test to CartTest:

// src/test/java/shop/CartTest.java
@Test
void smallPrices() {
    cart.add(0.1);
    cart.add(0.2);
    assertEquals(0.3, cart.total());
}

Output:

├─ smallPrices() ✘ expected: <0.3> but was: <0.30000000000000004>

The cart code is correct, yet the test fails: 0.1 + 0.2 in double gives 0.30000000000000004. For fractional numbers pass an allowed tolerance as the third argument: assertEquals(0.3, cart.total(), 0.0001). And in real programs money is better kept in BigDecimal or in whole cents.

Thanks for reading!

Donate to the site — we work hard for you.