Unit Testing with JUnit in Java
A teacher marking a hundred exam papers doesn't solve every question again for each paper. They prepare an answer key once, and checking each paper against it takes seconds. If a paper says 16,000 where the key says 16,500, the mistake jumps out. Unit tests are an answer key for your code: small programs that call your methods with known inputs and check the answers. Once written, they run in seconds on every build, so a mistake made today is caught today, not by a user next month. In this lesson, you will write tests with JUnit, the standard testing framework for Java, run them with Maven, read a failing test report, and learn the lifecycle and parameterized tests.
What Is Unit Testing?
Unit testing is the practice of writing code that is used to check one small unit of a program, usually one method or one class, in isolation, by calling it with chosen inputs and comparing the result with the expected answer. JUnit is the framework that finds these test methods, runs them, and reports which passed and which failed.
| Kind of test | What it checks | Speed |
|---|---|---|
| Unit test | One class or method, alone | Milliseconds |
| Integration test | Several parts together, such as code plus a real database | Seconds |
| End-to-end test | The whole application, as a user would use it | Minutes |
A healthy project has many unit tests, fewer integration tests and only a few end-to-end tests.
Setting Up JUnit
Add one dependency with test scope (see "Build Tools in Java: Maven and Gradle"):
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>6.1.3</version>
<scope>test</scope>
</dependency>Tests live in src/test/java, in the same package as the class they test, and their class names end in Test.
Your First Test Class
This is the class we will test:
package school.fees;
public class FeeCalculator {
private final int finePerDay;
public FeeCalculator(int finePerDay) {
if (finePerDay < 0) {
throw new IllegalArgumentException("finePerDay < 0");
}
this.finePerDay = finePerDay;
}
public int totalFee(int baseFee, int lateDays) {
if (lateDays < 0) {
throw new IllegalArgumentException("lateDays < 0");
}
return baseFee + lateDays * finePerDay;
}
}And this is its test class:
package school.fees;
import static org.junit.jupiter.api.Assertions.assertAll;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
@DisplayName("FeeCalculator")
class FeeCalculatorTest {
private FeeCalculator calc;
@BeforeEach
void setUp() {
calc = new FeeCalculator(500);
}
@Test
@DisplayName("adds 500 for each late day")
void addsFineForEachLateDay() {
assertEquals(16500, calc.totalFee(15000, 3));
}
@Test
@DisplayName("charges no fine when paid on time")
void noFineWhenOnTime() {
assertEquals(15000, calc.totalFee(15000, 0));
}
@Test
@DisplayName("rejects negative late days")
void rejectsNegativeLateDays() {
IllegalArgumentException e = assertThrows(
IllegalArgumentException.class,
() -> calc.totalFee(15000, -1));
assertEquals("lateDays < 0", e.getMessage());
}
@ParameterizedTest(name = "{0} late days cost {1}",
quoteTextArguments = false)
@CsvSource({"0, 15000", "1, 15500", "10, 20000", "45, 37500"})
void feeForLateDays(int lateDays, int expected) {
assertEquals(expected, calc.totalFee(15000, lateDays));
}
@Test
@DisplayName("checks several facts together")
void severalFacts() {
assertAll(
() -> assertEquals(15000, calc.totalFee(15000, 0)),
() -> assertTrue(calc.totalFee(15000, 1) > 15000));
}
@Test
@Disabled("waiting for the sibling discount rule")
void siblingDiscount() {
}
}Explanation:
@Testmarks a test method. It returnsvoid, takes no arguments, and needn't bepublic.assertEquals(expected, actual)passes if the two are equal. The expected value comes first, which matters for the failure message.@BeforeEachruns before every test, so each test gets a freshFeeCalculatorand tests can't affect each other.assertThrowspasses only if the code inside the lambda throws that exception, and returns it so you can check its message.@ParameterizedTestwith@CsvSourceruns the same test once per row: 4 runs from one method.quoteTextArguments = falsestops JUnit from putting quotes around values that came from text in the display name.assertAllruns every check even if an earlier one fails, and reports all failures together.@Disabledskips a test and shows the reason in the report.
Running the Tests
mvn test compiles everything and runs every test class in src/test/java:
mvn test[INFO] -------------------------------------------------------
[INFO] T E S T S
[INFO] -------------------------------------------------------
[INFO] Running FeeCalculator
[WARNING] Tests run: 9, Failures: 0, Errors: 0, Skipped: 1, Time elapsed: 0.051 s -- in FeeCalculator
[INFO]
[INFO] Results:
[INFO]
[INFO] Tests run: 9, Failures: 0, Errors: 0, Skipped: 1
[INFO]
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESSNine tests ran: five plain tests, four parameterized runs, and one of them was skipped ([WARNING] only because something was skipped). IDEs show the same results as a tree. JUnit's own console launcher, a single JAR you can download from Maven Central, prints that tree in a terminal:
java -jar junit-platform-console-standalone-6.1.3.jar execute \
--disable-banner -cp target/classes:target/test-classes \
--select-class school.fees.FeeCalculatorTest╷
├─ JUnit Platform Suite ✔
├─ JUnit Jupiter ✔
│ └─ FeeCalculator ✔
│ ├─ charges no fine when paid on time ✔
│ ├─ adds 500 for each late day ✔
│ ├─ rejects negative late days ✔
│ ├─ siblingDiscount() ↷ waiting for the sibling discount rule
│ ├─ checks several facts together ✔
│ └─ feeForLateDays(int, int) ✔
│ ├─ 0 late days cost 15000 ✔
│ ├─ 1 late days cost 15500 ✔
│ ├─ 10 late days cost 20000 ✔
│ └─ 45 late days cost 37500 ✔
└─ JUnit Vintage ✔The @DisplayName texts make the report read like a list of rules. Without them, JUnit shows the method names.
When a Test Fails
Suppose someone "simplifies" the fee formula and slips in an off-by-one bug:
return baseFee + (lateDays - 1) * finePerDay; // wrong[ERROR] Failures:
[ERROR] FeeCalculatorTest.addsFineForEachLateDay:27 expected: <16500> but was: <16000>
[ERROR] FeeCalculatorTest.feeForLateDays:49 expected: <15000> but was: <14500>
[ERROR] FeeCalculatorTest.feeForLateDays:49 expected: <15500> but was: <15000>
[ERROR] FeeCalculatorTest.feeForLateDays:49 expected: <20000> but was: <19500>
[ERROR] FeeCalculatorTest.feeForLateDays:49 expected: <37500> but was: <37000>
[ERROR] FeeCalculatorTest.noFineWhenOnTime:33 expected: <15000> but was: <14500>
[ERROR] FeeCalculatorTest.severalFacts:55 Multiple Failures (2 failures)
org.opentest4j.AssertionFailedError: expected: <15000> but was: <14500>
org.opentest4j.AssertionFailedError: expected: <true> but was: <false>
[INFO]
[ERROR] Tests run: 9, Failures: 7, Errors: 0, Skipped: 1
[INFO]
[INFO] ------------------------------------------------------------------------
[ERROR] BUILD FAILUREExplanation: each line gives the test method, the line number in the test, and expected versus actual. Every result is exactly 500 too low, which points straight at the formula. The build fails, so no JAR is produced from broken code. The assertAll test reported both of its failures, not just the first. A failure means an assertion was false; an error means the test threw an unexpected exception.
Assertions
All assertions are static methods in org.junit.jupiter.api.Assertions.
| Method | Description |
|---|---|
assertEquals(expected, actual) | It is used to check that two values are equal (with equals for objects). |
assertNotEquals(a, b) | It checks that two values differ. |
assertTrue(cond), assertFalse(cond) | They check a boolean condition. |
assertNull(x), assertNotNull(x) | They check for null. |
assertSame(a, b) | It checks that two references point to the same object. |
assertArrayEquals(a, b) | It compares arrays element by element. |
assertEquals(0.3, x, 0.0001) | It compares doubles within a tolerance. |
assertThrows(Type.class, () -> ...) | It checks that the code throws that exception, and returns it. |
assertAll(() -> ..., () -> ...) | It runs several checks and reports every failure. |
assertTimeout(Duration, () -> ...) | It checks that the code finishes in time. |
fail("message") | It fails the test immediately. |
Every assertion also accepts a message as the last argument, shown when it fails: assertEquals(16500, fee, "3 late days").
The Test Lifecycle
JUnit creates a new instance of the test class for every test method. Four annotations run code around the tests:
class LifecycleTest {
@BeforeAll
static void openDatabase() {
System.out.println("BeforeAll: open test database");
}
@BeforeEach
void setUp() {
System.out.println(" BeforeEach: fresh calculator");
}
@Test
void first() {
System.out.println(" test one");
}
@Test
void second() {
System.out.println(" test two");
}
@AfterEach
void tearDown() {
System.out.println(" AfterEach: clean up");
}
@AfterAll
static void closeDatabase() {
System.out.println("AfterAll: close test database");
}
}BeforeAll: open test database
BeforeEach: fresh calculator
test two
AfterEach: clean up
BeforeEach: fresh calculator
test one
AfterEach: clean up
AfterAll: close test databaseExplanation:
@BeforeAlland@AfterAllrun once for the class and must bestatic, because no instance exists yet. Use them for expensive setup shared by all tests.@BeforeEachand@AfterEachrun around every test.- Look at the order: test two ran before test one. JUnit's order is fixed but deliberately not the order in the file. Tests must never depend on each other; each one sets up what it needs.
| Annotation | Description |
|---|---|
@Test | It is used to mark a test method. |
@BeforeEach, @AfterEach | They run before or after every test. |
@BeforeAll, @AfterAll | They run once before or after all tests (static methods). |
@DisplayName("...") | It gives a readable name in reports. |
@Disabled("reason") | It skips a test or a class. |
@ParameterizedTest | It runs a test once per set of arguments. |
@CsvSource, @ValueSource, @MethodSource | They supply the arguments for a parameterized test. |
@Nested | It groups related tests in an inner class. |
@Tag("slow") | It labels tests so a build can include or exclude them. |
Good Unit Tests Are FIRST
| Letter | Meaning |
|---|---|
| Fast | Milliseconds each, so the whole suite runs on every build |
| Independent | No test depends on another or on run order |
| Repeatable | Same result every time, on every machine: no real clock, network or random data |
| Self-checking | Assertions decide pass or fail; nobody reads printed output |
| Timely | Written with the code (or before it, in test-driven development) |
When a class talks to a database, a web service or an email server, those parts make tests slow and unrepeatable. The next lesson, "Mocking with Mockito in Java", shows how to replace them with stand-ins.
Common Mistakes
Swapping expected and actual. assertEquals(actual, 16500) passes or fails the same way, but the message says the opposite of the truth.
Tests with no assertion. A test that only calls a method passes even if the result is wrong.
Sharing changing state between tests. A static list filled by one test breaks another. Create fresh objects in @BeforeEach.
Testing private methods directly. Test the public behavior; private methods are covered through it.
Catching the exception yourself. A try/catch with an empty catch hides failures. Use assertThrows.
Practice
- Easy: Add a test that
new FeeCalculator(-1)throwsIllegalArgumentExceptionwith the messagefinePerDay < 0. - Harder: Write a class
GradeCalculatorwith a methodgrade(int marks)returning"A"for 90 and above,"B"for 75 to 89,"C"for 50 to 74 and"F"below 50, and throwing for marks outside 0 to 100. Test it with one@ParameterizedTestthat covers every boundary (0, 49, 50, 74, 75, 89, 90, 100) and one test for the exceptions.
Key Takeaways
Was this tutorial helpful?