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
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
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
@Testis one test. Test names describe the expected behaviour. @BeforeEachruns before every test, so each starts fresh.- Assertions check results:
assertEquals(expected, actual)(with a tolerance fordoubles),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:
PriceCalculatorTest > appliesDiscount() FAILED
org.opentest4j.AssertionFailedError: expected: <33.75> but was: <37.5>
Parameterised tests
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
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.
PASS no discount PASS 10% discount PASS rounding