As a Java program grows, every change risks breaking something that used to work. Checking by hand, running the program and trying different inputs, is slow and easy to skip, and the bugs it misses reach your users. Automated tests check your code for you, in seconds, every time you change it.
JUnit is the standard testing library for Java, used in almost every professional Java project. A test is just a method marked with @Test that calls your code with known inputs and asserts what the result should be. If an assertion fails, JUnit reports exactly which test, which line and what the actual value was.
In this lesson you'll set up a project for testing, write tests with JUnit 5's assertions, check that the right exceptions are thrown, run one test against many inputs, share setup code, and read the results. Once you've seen a failing test point straight at a bug, you won't want to write code without them.
Project setup
Real Java projects use a build tool, usually Maven or Gradle, which downloads libraries such as JUnit and runs your tests. Both use the same folder layout:
my-app/
pom.xml (Maven) or build.gradle (Gradle)
src/main/java/shop/PriceCalculator.java
src/test/java/shop/PriceCalculatorTest.javaTest classes live in src/test/java, in the same package as the code they test, and are usually named after it with Test on the end. Add the junit-jupiter dependency to the build file, then run mvn test or gradle test. Editors such as IntelliJ IDEA and VS Code also show a run button beside each test.
Your first test
import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.Test;
class PriceCalculatorTest {
@Test
void tenPercentOff() {
PriceCalculator calc = new PriceCalculator();
assertEquals(45.0, calc.applyDiscount(50.0, 10), 0.001);
}
}@Testmarks a method as a test. Test methods returnvoidand take no parameters.assertEquals(expected, actual)fails the test if the two differ. The expected value comes first.- For doubles, add a third argument, the delta: how far apart the values may be and still count as equal.
- Test classes and methods don't need to be public in JUnit 5.
Assertions
| Assertion | Passes when |
|---|---|
assertEquals(a, b) | a equals b |
assertNotEquals(a, b) | they differ |
assertTrue(x) / assertFalse(x) | x is true / false |
assertNull(x) / assertNotNull(x) | x is / isn't null |
assertThrows(Type.class, () -> ...) | the code throws that exception |
assertAll(...) | every grouped assertion passes; all failures are reported |
Every assertion takes an optional message as its last argument, shown when it fails. assertThrows returns the exception, so you can also check its message.
Setup and many inputs
A method marked @BeforeEach runs before every test, giving each one a fresh starting point, such as a new, empty shopping basket. @AfterEach runs after each test for clean-up.
To run the same test against many inputs, use @ParameterizedTest with a source of values:
@ParameterizedTest
@CsvSource({"100, A", "70, A", "69, B", "0, C"})
void gradeBoundaries(int score, String expected) {
assertEquals(expected, Grades.grade(score));
}Each row runs as a separate test, and the report shows exactly which values failed. Parameterized tests need the junit-jupiter-params dependency, which Maven and Gradle can include with JUnit.
JUnit Syntax
JUnit 5 essentials
import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.*;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
class ThingTest {
@BeforeEach void setUp() { ... }
@Test void doesSomething() { assertEquals(expected, actual); }
@Test void rejectsBadInput() {
assertThrows(IllegalArgumentException.class, () -> thing.run(-1));
}
@ParameterizedTest
@CsvSource({"1, 2", "3, 6"})
void doubles(int in, int out) { assertEquals(out, in * 2); }
}JUnit Summary
- JUnit tests are methods marked @Test that call your code and assert the results.
- Tests live in src/test/java and run with mvn test, gradle test or your editor.
- assertEquals takes the expected value first; use a delta for doubles.
- assertThrows checks exceptions; @BeforeEach gives each test a fresh setup.
- @ParameterizedTest with @CsvSource runs one test against many inputs.
JUnit Examples
Each example shows the code, the output it produces and what each part does.
Testing a basket class
// src/main/java/shop/Basket.java
package shop;
import java.util.ArrayList;
import java.util.List;
public class Basket {
private final List<Double> prices = new ArrayList<>();
public void add(double price) {
if (price < 0) throw new IllegalArgumentException("Negative price");
prices.add(price);
}
public double total() {
double sum = 0;
for (double p : prices) sum += p;
return sum;
}
public int count() { return prices.size(); }
}// src/test/java/shop/BasketTest.java
package shop;
import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
class BasketTest {
private Basket basket;
@BeforeEach
void setUp() {
basket = new Basket();
}
@Test
void emptyBasketTotalIsZero() {
assertEquals(0.0, basket.total(), 0.001);
}
@Test
void totalAddsEveryPrice() {
basket.add(2.50);
basket.add(3.25);
assertAll(
() -> assertEquals(2, basket.count()),
() -> assertEquals(5.75, basket.total(), 0.001)
);
}
@Test
void negativePriceIsRejected() {
var e = assertThrows(IllegalArgumentException.class, () -> basket.add(-1));
assertEquals("Negative price", e.getMessage());
}
}$ mvn test
[INFO] Running shop.BasketTest
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESSLine-by-line explanation
- Lines 11 to 14In BasketTest, @BeforeEach creates a fresh, empty basket before every test, so tests can't affect each other.
- Lines 25 to 28assertAll checks the count and total together, and reports both if both fail.
- Lines 33 to 34assertThrows passes only if the lambda throws IllegalArgumentException, and returns it so the message can be checked too.
A failing test points at the bug
// in Basket.java, a bug has crept in:
public double total() {
double sum = 0;
for (int i = 1; i < prices.size(); i++) sum += prices.get(i);
return sum;
}$ mvn test
[ERROR] Tests run: 3, Failures: 1, Errors: 0, Skipped: 0
[ERROR] BasketTest.totalAddsEveryPrice:25 Multiple Failures (1 failure)
org.opentest4j.AssertionFailedError: expected: <5.75> but was: <3.25>
[INFO] BUILD FAILURELine-by-line explanation
- Line 4The loop starts at 1, skipping the first price. In the report, "expected 5.75 but was 3.25" shows exactly 2.50 is missing, and the line number leads straight to the assertion. Changing i = 1 to i = 0 makes every test pass again.
One test, many inputs
import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
class ShippingTest {
@ParameterizedTest(name = "{0} kg costs {1}")
@CsvSource({
"0.5, 3.99",
"2.0, 3.99",
"2.1, 6.50",
"10.0, 6.50"
})
void costByWeight(double kg, double expected) {
assertEquals(expected, Shipping.cost(kg), 0.001);
}
}ShippingTest
✓ 0.5 kg costs 3.99
✓ 2.0 kg costs 3.99
✓ 2.1 kg costs 6.50
✓ 10.0 kg costs 6.50Line-by-line explanation
- Line 6name sets how each case is shown in the report.
- Lines 7 to 12Each line is one case. 2.0 and 2.1 sit either side of the price boundary, where bugs are most likely.
- Line 13JUnit converts the CSV text into a double and a double.
Common JUnit Mistakes
Expected and actual the wrong way round
assertEquals(basket.total(), 5.75); // failure message reads backwardsassertEquals(5.75, basket.total(), 0.001);Comparing doubles without a delta
assertEquals(0.3, 0.1 + 0.2); // fails: 0.30000000000000004assertEquals(0.3, 0.1 + 0.2, 0.0001);Tests that depend on each other
static Basket shared = new Basket(); // one test adds items, another expects it emptyprivate Basket basket;
@BeforeEach void setUp() { basket = new Basket(); }JUnit in Real Projects
Testing in Java teams
Professional Java projects have thousands of JUnit tests that run on every pull request, often through GitHub Actions, and a failing test blocks the merge. Libraries such as Mockito replace real databases and web services with stand-ins during tests, and AssertJ offers readable assertions like assertThat(total).isEqualTo(5.75). Many teams write the test first, then the code, a practice called test-driven development.
JUnit FAQ
What is the difference between JUnit 4 and JUnit 5?
JUnit 5, also called JUnit Jupiter, is the current version. Its annotations live in org.junit.jupiter.api, test classes and methods don't need to be public, @BeforeEach replaces @Before, and assertThrows replaces the expected attribute. New projects should use JUnit 5; older projects can run JUnit 4 tests alongside it with the vintage engine.
How do I run a single test class or method?
In an IDE, click the run icon next to the class or method. With Maven, run mvn test -Dtest=BasketTest, or -Dtest=BasketTest#emptyBasketTotalIsZero for one method. With Gradle, run gradle test --tests BasketTest.
Should I test private methods?
Usually not directly. Test them through the public methods that use them, because that's the behaviour other code depends on. If a private method is complicated enough to need its own tests, it's often a sign it belongs in a separate class with its own public interface.
What is mocking in Java testing?
A mock is a fake object that stands in for a real dependency, such as a payment service or a database, so a test runs quickly and predictably. Mockito is the most widely used Java mocking library: you create a mock, tell it what to return, and verify how your code called it.
How much test coverage do I need?
There's no universal number. Coverage tools such as JaCoCo show which lines tests ran, which helps you find untested code. High coverage with weak assertions proves little, so aim to test every important behaviour, boundary and error case, and treat the percentage as a guide rather than a goal.
JUnit Exercises
Answer each one and check it instantly. If you get stuck, open the hint before the answer.
Complete the annotation that marks a method as a JUnit test.
void addsTwoNumbers() {
assertEquals(4, 2 + 2);
}
In assertEquals(a, b), which argument is the expected value?
How do you check that withdraw(-5) throws IllegalArgumentException?
What does @BeforeEach do?
A @ParameterizedTest has 5 rows in its @CsvSource. How many tests does the report show for it?
JUnit Quiz
5 questions to check what you learned.
Where do test classes go in a Maven or Gradle project?
- src/main/java
- The project root
- A tests.txt file
- src/test/java
Why give assertEquals a delta when comparing doubles?
- It makes tests faster
- Doubles can't be compared otherwise
- Floating point results can differ by tiny amounts
- It's required for ints too
What does assertAll do?
- Runs several assertions and reports every failure, not just the first
- Runs every test in the class
- Asserts that a list contains all items
- Skips failing assertions
Which command runs the tests in a Maven project?
- java test
- junit run
- javac -test
- mvn test
You've found a bug in total(). What's a good first step?
- Delete the related tests
- Write a test that fails because of the bug, then fix the code until it passes
- Add a comment
- Make the method private