Unit Testing with JUnit

Unit tests are small programs that check your code works — and keeps working as it changes. JUnit is the standard testing framework for Java, used by virtually every project. This lesson shows how to write tests with JUnit 5 (Jupiter), what makes a good test, and how tests run in a real project.

The code under test

JAVA PriceCalculator.java
public class PriceCalculator {
    public double total(double unitPrice, int quantity, double discountPercent) {
        if (quantity <= 0) {
            throw new IllegalArgumentException("Quantity must be positive");
        }
        if (discountPercent < 0 || discountPercent > 100) {
            throw new IllegalArgumentException("Invalid discount: " + discountPercent);
        }
        double gross = unitPrice * quantity;
        return Math.round(gross * (1 - discountPercent / 100) * 100) / 100.0;
    }
}

A test class

JAVA PriceCalculatorTest.java
import static org.junit.jupiter.api.Assertions.*;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

class PriceCalculatorTest {
    private PriceCalculator calculator;

    @BeforeEach
    void setUp() {
        calculator = new PriceCalculator();
    }

    @Test
    void multipliesPriceByQuantity() {
        assertEquals(37.50, calculator.total(12.50, 3, 0), 0.001);
    }

    @Test
    @DisplayName("applies a percentage discount and rounds to cents")
    void appliesDiscount() {
        assertEquals(33.75, calculator.total(12.50, 3, 10), 0.001);
    }

    @Test
    void rejectsZeroQuantity() {
        var e = assertThrows(IllegalArgumentException.class,
            () -> calculator.total(10, 0, 0));
        assertTrue(e.getMessage().contains("Quantity"));
    }
}
  • Each method annotated @Test is one test. Test names describe the expected behaviour.
  • @BeforeEach runs before every test, so each starts fresh.
  • Assertions check results: assertEquals(expected, actual) (with a tolerance for doubles), assertTrue, assertNull, assertThrows…

Running tests

In a Maven or Gradle project, tests live in src/test/java, mirroring the packages in src/main/java, and run with mvn test or gradle test. IDEs show a green or red result for each test. A failing assertion reports what was expected and what actually happened:

TXT failure
PriceCalculatorTest > appliesDiscount() FAILED
    org.opentest4j.AssertionFailedError: expected: <33.75> but was: <37.5>

Parameterised tests

JAVA DiscountTest.java
import static org.junit.jupiter.api.Assertions.assertEquals;

import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;

class DiscountTest {
    private final PriceCalculator calculator = new PriceCalculator();

    @ParameterizedTest
    @CsvSource({
        "10.00, 1, 0,   10.00",
        "10.00, 2, 50,  10.00",
        "19.99, 3, 15,  50.97",
        "5.00,  4, 100, 0.00"
    })
    void calculatesTotals(double price, int qty, double discount, double expected) {
        assertEquals(expected, calculator.total(price, qty, discount), 0.001);
    }
}

One test method, many cases: each CSV row runs as a separate test. It's the easiest way to cover edge cases such as zero, 100% and rounding.

What to test

  • Normal cases — the main behaviour.
  • Edge cases — empty lists, zero, maximum values, boundaries like exactly 100.
  • Error cases — invalid input should fail clearly.
  • Bugs — when you fix one, add a test that would have caught it.

Good tests are fast, independent of each other, repeatable (no reliance on the current time or random values without a seed), and test behaviour rather than implementation details. The pattern Arrange – Act – Assert keeps each test readable.

Testing without the real dependencies

JAVA OrderServiceTest.java
interface PaymentGateway {
    boolean charge(String customer, double amount);
}

class OrderServiceTest {
    @Test
    void doesNotShipWhenPaymentFails() {
        PaymentGateway decliningGateway = (customer, amount) -> false;
        OrderService service = new OrderService(decliningGateway);

        assertFalse(service.placeOrder("ana", 49.90));
    }
}

When code depends on a database, an API or a payment provider, pass the dependency in through the constructor (dependency injection) as an interface. Tests can then supply a simple fake — here a one-line lambda — instead of the real thing. Libraries like Mockito create such test doubles for larger interfaces.

Beyond unit tests

Unit tests check single classes in isolation. Integration tests check pieces working together (often with a real database, e.g. using Testcontainers), and end-to-end tests drive the whole application. A healthy project has many fast unit tests and fewer, slower broad tests.

Try it yourself

Without JUnit, you can still practise the habit: write checks that print PASS or FAIL. Copy this, then add a check for a 100% discount.

Try it yourself · Main.java
Edit the code, copy it into Main.java and run java Main.java (Java 25+). How to set up Java
Output
PASS no discount
PASS 10% discount
PASS rounding