A unit test is a small program that checks one behaviour of your code and reports pass or fail. JUnit 5 gives you the structure to write those tests - a method marked as a test, assertions that fail loudly, and a runner that executes them all. The goal is not coverage for its own sake; it is the confidence to change code without breaking it.
What is a unit test, really?
A unit test exercises one piece of behaviour in isolation, with no database, no network and no timing dependency. It should run in milliseconds and give the same answer every time. That isolation is what makes it useful: when a test fails, the failure points at a specific piece of logic rather than at a whole system.
Before JUnit, people wrote the checks by hand.
public class ManualHarness {
static int addDiscount(int price, int percent) {
return price - price * percent / 100;
}
public static void main(String[] args) {
check("full price", addDiscount(100, 0), 100);
check("ten percent", addDiscount(100, 10), 90);
check("twenty five percent", addDiscount(200, 25), 150);
System.out.println("all manual checks passed");
}
static void check(String label, int actual, int expected) {
if (actual == expected) {
System.out.println("PASS " + label + " -> " + actual);
} else {
System.out.println("FAIL " + label + " -> expected " + expected + " but got " + actual);
throw new AssertionError(label);
}
}
}PASS full price -> 100
PASS ten percent -> 90
PASS twenty five percent -> 150
all manual checks passedThat works for three checks. It does not work for three hundred, and it does not integrate with a build, produce a report, or tell you which test failed without reading the output line by line. JUnit replaces the hand-rolled harness with a convention that a build tool can run automatically.
What does a JUnit test look like?
Three things make a test a test. The class name usually ends in Test. A method carries the @Test annotation. Inside it, you call assertions that compare the actual result with what you expected.
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.DisplayName;
import org.junit.jupiter.api.Test;
class DiscountTest {
private PricingService pricing;
@BeforeEach
void setUp() {
pricing = new PricingService();
}
@Test
@DisplayName("applies no discount when the percentage is zero")
void appliesNoDiscount() {
assertEquals(100, pricing.discounted(100, 0));
}
@Test
@DisplayName("applies ten percent of the price")
void appliesTenPercent() {
assertEquals(90, pricing.discounted(100, 10));
}
@Test
@DisplayName("rejects a negative percentage")
void rejectsNegativePercentage() {
assertThrows(IllegalArgumentException.class, () -> pricing.discounted(100, -5));
}
}Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS
All tests passed.Read the three test methods. Each one states a single behaviour in its own words, and the @DisplayName annotation says the same thing in the test report, which is what you read when something fails at three in the morning. The third test asserts that an exception is thrown, using assertThrows - and that is a real assertion, not a comment.
That file is not executed here, because a JUnit test needs the JUnit library on the classpath. The output block shows what the test runner prints once the library is available, and the next section shows how to add it.
How do you add JUnit to a project?
With a build tool. The dependency block below is all a Maven project needs to compile and run tests - the scope of test keeps the library out of your shipped artefact.
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<groupId>com.tutorsbot</groupId>
<artifactId>pricing</artifactId>
<version>1.0.0</version>
<properties>
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<junit.version>5.10.2</junit.version>
</properties>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.5</version>
</plugin>
</plugins>
</build>
</project>With that in place, mvn test compiles the main sources, compiles the test sources, and runs every method annotated with @Test. The summary it prints looks like the block above: a count of tests run, a count of failures, and a build result.
Which assertions should you use?
The ones you reach for most are assertEquals, assertTrue, assertFalse, assertNull, assertNotNull, assertSame and assertThrows. Each takes an optional message as the last argument, and that message is what the failure report shows you - so write it.
Two distinctions trip people up. assertEquals compares values, so two different objects with the same contents are equal; assertSame compares identity, so it passes only for the very same instance. And a test that asserts a long chain of conditions tells you little when it fails - one assertion per behaviour gives you a precise failure message.
import java.util.List;
public class AssertionStyles {
record Order(String id, double amount, boolean paid) { }
static boolean isValid(Order order) {
return order.id() != null && !order.id().isBlank() && order.amount() >= 0;
}
public static void main(String[] args) {
Order good = new Order("A-1", 42.0, true);
Order bad = new Order(" ", -1, false);
System.out.println("good is valid : " + isValid(good));
System.out.println("bad is valid : " + isValid(bad));
System.out.println("equals style : " + good.equals(new Order("A-1", 42.0, true)));
System.out.println("message style : expected a non blank id but got "
+ (bad.id() == null || bad.id().isBlank() ? "blank" : "present"));
List<Order> orders = List.of(good);
System.out.println("contains : " + orders.contains(good));
System.out.println("all match : " + orders.stream().allMatch(Order::paid));
}
}good is valid : true
bad is valid : false
equals style : true
message style : expected a non blank id but got blank
contains : true
all match : trueThe third line of that output is the pattern to copy: instead of asserting isValid and losing the reason, the code computes the reason and includes it in the message. When the test fails at three in the morning, that message is the whole difference between a two-minute fix and an afternoon.
How should you structure a test class?
- One test class per production class, named after it.
- One test method per behaviour, named as a sentence about what should happen.
- Use
@BeforeEachfor setup that every test needs, so each test starts from a known state. - Use
@Nestedinner classes to group related cases without a long flat list. - Use
@DisplayNamewhen the method name cannot carry the meaning on its own. - Keep production code free of test-only branches - if a flag exists only for tests, the design is wrong.
That last point is the one experienced teams argue about. A production class that checks a isTest flag is carrying test knowledge into production code, and the test then verifies the flag rather than the behaviour.
What should you not unit test?
Do not test the language or your framework. A test that asserts list.add works is testing the collection library, and it will break when the library changes. Do not test getters that return a field and nothing else. Do not write a test that asserts a method throws because the implementation happens to throw it today - test the behaviour you care about.
And do not confuse a unit test with an integration test. A unit test runs in milliseconds with no external systems. A test that opens a database connection belongs in a different suite that runs at a different point in the pipeline.
The habits to keep
- One behaviour per test, with a name that says what it checks.
- Always write the failure message - it is the part you will read.
- Set up state in
@BeforeEachand reset it for every test. - Assert on values, not on implementation details.
- Keep tests fast and deterministic so the suite is worth running.
- Run the suite before every commit and before every release.
Try it yourself: take a small class with two or three methods, such as a discount calculator or a string formatter, and write a test class for it with one test per behaviour. Include one test that expects an exception, and one that checks a boundary value such as zero. Run the suite and deliberately break one line of the production code to confirm the right test fails with a message that tells you what broke.