The first time a bug reached production because of a change I made, I was told the same thing three times: "Did you test it?" I had. I had run the program, clicked around and it looked fine. But "it looked fine when I tried it" is not a test. It's a hope.
Unit tests turn that hope into a safety net. They're small programs that check your code for you, in milliseconds, every time you change something. In Java, the most popular tool for writing them is JUnit.
In this post, we'll look at how testing used to work, what unit testing and TDD are, how JUnit 5 differs from JUnit 4, how JUnit 5 is built, how to set it up with Maven and Surefire, and then the main assertions and annotations you'll use every day.
A quick version note: JUnit's newest major version is JUnit 6 (released in September 2025). It needs Java 17, and the Jupiter API you learn here carries over to it. I'm using JUnit 5 because it still runs on older Java versions and is everywhere in existing projects. To try JUnit 6, you mostly change the version number.
Table of contents
- Testing in the traditional SDLC
- Unit testing
- Test-driven development (TDD)
- JUnit 4 vs JUnit 5
- JUnit 5 architecture
- JUnit 5 in a Maven project
- Your first JUnit 5 test
- Assertions
- Annotations
- Test instance lifecycle (@TestInstance)
- More features worth knowing
- Which one should I use?
- Common mistakes
- Quick recap
- Try it yourself
Testing in the traditional SDLC
SDLC stands for Software Development Life Cycle: the steps a team follows to build software. In the traditional style, often called the waterfall model, each step happens after the previous one is finished:
Requirements --> Design --> Coding --> TESTING --> Release --> Maintenance
^
a separate testing (QA) team starts here,
only after ALL the code is "done"
Developers write all the code. Then they hand it over to a separate testing team, who run it, find problems and send them back. It's sometimes called "throwing the code over the wall."
This has real problems:
- Feedback comes late. A bug written in week 2 might be found in week 12. By then, the developer has forgotten the details of that code.
- Fixes are expensive. The later a bug is found, the more code is built on top of it, and the more places need to change.
- Handoffs cause friction. Developers wait for test results. Testers wait for builds. Each side may blame the other.
- Repeat work is manual. After every fix, someone must re-test the old features by hand to make sure nothing broke. This is called regression testing, and by hand it's slow and boring.
Today, most teams work differently. Developers test their own code as they write it, with automated tests that run on every change. A dedicated QA role still matters (for exploring the product, checking the whole system and thinking like a user), but it no longer carries the whole burden, and it doesn't wait until the end.
MODERN: write code + write tests --> run tests automatically on every commit
| |
+---- fast feedback, within minutes ----+
Unit testing
A unit test checks one small piece of code, usually a single method or class, in isolation. It's written by the developer, in code, and it runs automatically. A unit test answers one question: "Given this input, does this method give the output I expect?"
Why developers write them
- Bugs are caught early, while the code is still fresh in your mind.
- Refactoring becomes safe. You can improve the code and instantly know if you broke something.
- They work as living documentation. A test shows exactly how a method is supposed to behave.
- Regression testing becomes free. The whole suite runs in seconds.
- They give confidence. You can change code without fear.
What makes a good unit test?
A handy memory trick is FIRST:
- Fast: a test runs in milliseconds, so you can run thousands.
- Independent: no test depends on another test or on the order.
- Repeatable: it gives the same result every time, on any machine.
- Self-validating: it passes or fails by itself, with no human checking the output.
- Timely: written at the right time, ideally right alongside the code.
The Arrange-Act-Assert pattern
Most tests follow three steps:
@Test
void addsTwoNumbers() {
Calculator calc = new Calculator(); // Arrange: set things up
int result = calc.add(2, 3); // Act: do the thing
assertEquals(5, result); // Assert: check the result
}
The test pyramid
Unit tests aren't the only kind. A healthy project has many cheap tests at the bottom and few expensive ones at the top:
/\
/ \ END-TO-END tests: few, slow, test the whole app
/----\
/ \ INTEGRATION tests: some, test parts working together
/--------\
/ \ UNIT tests: many, very fast, test small pieces alone
/____________\
This post is about the bottom layer: unit tests.
Test-driven development (TDD)
TDD flips the usual order. Instead of writing code and then tests, you write the test first, watch it fail, and only then write the code to make it pass. It follows a short loop called Red, Green, Refactor:
+------------------------------------+
| |
v |
1. RED write a small test that FAILS (the code doesn't exist yet)
|
v
2. GREEN write the SIMPLEST code that makes the test pass
|
v
3. REFACTOR clean up the code, tests stay green
|
+------------------------------------> repeat with the next small test
Let's try it. The rule we want: orders of 1000 or more get 10% off, smaller orders get no discount, and negative amounts are invalid.
Step 1 (red). Write the first test before the class exists:
class DiscountCalculatorTest {
@Test
void noDiscountBelow1000() {
assertEquals(500, new DiscountCalculator().applyDiscount(500));
}
}
It doesn't even compile yet (cannot find symbol: class DiscountCalculator). That counts as red. Now write the simplest code that passes:
public class DiscountCalculator {
public int applyDiscount(int amount) {
return amount;
}
}
Green.
Step 2 (red). Add the next test:
@Test
void tenPercentOffAt1000() {
assertEquals(900, new DiscountCalculator().applyDiscount(1000));
}
[ERROR] DiscountCalculatorTest.tenPercentOffAt1000 expected: <900> but was: <1000>
Red. Now make it pass:
public int applyDiscount(int amount) {
if (amount >= 1000) {
return amount - amount / 10;
}
return amount;
}
Green.
Step 3 (red). Negative amounts should fail:
@Test
void rejectsNegativeAmounts() {
assertThrows(IllegalArgumentException.class,
() -> new DiscountCalculator().applyDiscount(-1));
}
[ERROR] Expected java.lang.IllegalArgumentException to be thrown, but nothing was thrown.
Make it pass, then refactor the magic numbers into constants. After the cleanup, all three tests still pass, which proves the refactoring didn't break anything:
public class DiscountCalculator {
private static final int THRESHOLD = 1000;
private static final int PERCENT = 10;
public int applyDiscount(int amount) {
if (amount < 0) {
throw new IllegalArgumentException("Amount cannot be negative");
}
if (amount >= THRESHOLD) {
return amount - amount * PERCENT / 100;
}
return amount;
}
}
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
TDD's benefits: you only write the code that's needed, every feature has a test from the start, and the code tends to be easier to test (and often simpler) because you thought about how it would be used first.
JUnit 4 vs JUnit 5
JUnit 4 served Java for over a decade. JUnit 5 (also called Jupiter, after the name of its new programming model) was a complete rewrite. The main reasons:
- JUnit 4 was one big jar, hard to extend, and it couldn't use modern Java features.
- Its extension tools (
@RunWithand@Rule) were awkward, and you couldn't combine several runners. - Tools like IDEs had to depend on its internal classes.
No backward compatibility
JUnit 5 is not backward compatible with JUnit 4. It uses new packages and new annotations:
- JUnit 4:
org.junit.Test - JUnit 5:
org.junit.jupiter.api.Test
A test class written with JUnit 4 annotations won't run on the Jupiter engine, and the other way around. They're simply different APIs.
The saving grace is the Vintage engine (we'll meet it in the architecture section). It lets the new platform run your old JUnit 4 tests, so you can migrate gradually, with old and new tests living side by side.
The same test, written both ways
JUnit 4:
import static org.junit.Assert.assertEquals;
import org.junit.Before;
import org.junit.Test;
public class CalculatorTest {
private Calculator calc;
@Before
public void setUp() {
calc = new Calculator();
}
@Test(expected = ArithmeticException.class)
public void divideByZero() {
calc.divide(1, 0);
}
}
JUnit 5:
import static org.junit.jupiter.api.Assertions.assertThrows;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
class CalculatorTest {
private Calculator calc;
@BeforeEach
void setUp() {
calc = new Calculator();
}
@Test
void divideByZero() {
assertThrows(ArithmeticException.class, () -> calc.divide(1, 0));
}
}
Notice that in JUnit 5, the class and methods don't need to be public. Package-private is fine.
The renamed pieces
JUNIT 4 JUNIT 5
org.junit.Test org.junit.jupiter.api.Test
@Before @BeforeEach
@After @AfterEach
@BeforeClass @BeforeAll
@AfterClass @AfterAll
@Ignore @Disabled
@Category @Tag
@RunWith / @Rule @ExtendWith (the extension model)
@Test(expected = X.class) assertThrows(X.class, ...)
@Test(timeout = 500) @Timeout or assertTimeout(...)
Assert (the class) Assertions (the class)
assertEquals(msg, exp, act) assertEquals(exp, act, msg) <- message moved to the END
That last row is a classic trap. In JUnit 4, the optional failure message came first. In JUnit 5, it comes last. If you migrate carelessly, your code may still compile (when everything is a String), but behave confusingly.
What's new in JUnit 5
-
@DisplayName: readable names with spaces and symbols. -
assertAll: check several things and see all failures at once. -
assertThrows: with lambdas, replaces@Test(expected=...). -
@Nested: group tests into inner classes. -
@ParameterizedTest: one test, many inputs. -
@RepeatedTest,@Tag,@Timeout,@TempDir. - Dynamic tests (
@TestFactory): create tests at run time. - Test classes and methods can be package-private.
- Dependency injection for test constructors and methods, through extensions.
- A real extension model that replaces runners and rules.
- It needs only Java 8 (JUnit 6 needs Java 17).
What changed in JUnit 6
Going from JUnit 5 to 6 is much gentler than going from 4 to 5. The org.junit.jupiter.api package and the annotations stay, so most JUnit 5 tests compile and run unchanged. The main changes:
- Java 17 is the minimum (JUnit 5 needed only Java 8). Kotlin projects need Kotlin 2.2 or newer.
-
One version number for everything. In JUnit 5, the versions didn't match: Jupiter and Vintage were
5.x, but the Platform was1.x(for example, Platform 1.14 went with Jupiter 5.14). In JUnit 6, the Platform, Jupiter and Vintage all share the same version, like6.1.0. That makes dependency management much simpler. -
Kotlin coroutine support. Kotlin tests can use
suspendfunctions directly. - A cancellation API in the Platform, so a running test run can be stopped early.
-
Clean-up of old modules.
junit-platform-runnerandjunit-platform-jfrwere removed, andjunit-jupiter-migrationsupport(a helper that let some JUnit 4 rules work inside Jupiter tests) is deprecated. - Standard Java rules are now followed for which fields and methods are visible or overridden.
- If you use
@CsvSourcewith complex or unusual CSV data, double-check those tests after upgrading.
To upgrade from JUnit 5, the usual steps are:
- Make sure your project builds on Java 17 or newer.
- Change the JUnit version to a 6.x version (a BOM makes this a one-line change).
- Read the official Migration Guide on the JUnit site.
- Move off anything that was removed or deprecated.
- Run your tests.
JUnit 5 architecture
JUnit 5 isn't one jar. It's a set of three parts, each with its own job:
JUnit 5 = JUnit PLATFORM + JUnit JUPITER + JUnit VINTAGE
1. JUnit Platform
The foundation. It does two things:
- It defines how test frameworks plug in, through the TestEngine API.
- It provides the Launcher, which finds tests and starts them. Build tools and IDEs call this Launcher.
2. JUnit Jupiter
The new programming model (the annotations and assertions you write) and the engine that runs them. When you write @Test from org.junit.jupiter.api, you're using Jupiter. It also contains the extension model for adding custom behavior.
3. JUnit Vintage
An engine that runs old JUnit 3 and JUnit 4 tests on the new platform. It's what makes gradual migration possible.
Here's the whole picture:
IDEs (IntelliJ IDEA, Eclipse, VS Code) Build tools (Maven Surefire, Gradle)
\ /
v v
+-------------------------------------------------------------------------+
| JUNIT PLATFORM |
| Launcher API (finds and starts tests) + TestEngine API |
+-------------------------------------------------------------------------+
| | |
v v v
JUnit Jupiter JUnit Vintage Third-party engines
(JUnit 5 tests and (runs JUnit 3/4 (Spock, Cucumber, TestNG...)
extension model) tests)
Why this design is smart
The IDEs and build tools talk only to the Platform's Launcher, not to any particular test framework. When IntelliJ IDEA shows green and red bars, or Maven runs your tests, they ask the Launcher to discover and run tests, and the Launcher asks each available engine to do its part. Because of this:
- Adding a new test framework doesn't require changing every IDE and build tool. It just needs a new engine.
- You can run JUnit 5 and JUnit 4 tests in the same project.
What happens when a test runs (behind the scenes)
Surefire or IDE
|
v
Launcher --> Jupiter engine: scans for classes with @Test methods (using reflection)
|
| for EACH test method:
| 1. create a NEW instance of the test class
| 2. run @BeforeEach methods
| 3. run the @Test method
| 4. run @AfterEach methods
|
v
report PASS / FAIL / SKIPPED back up to the Launcher --> shown in the IDE or Maven
The extension model
In JUnit 4, you extended behavior with @RunWith (only one allowed per class) and @Rule. In Jupiter, there's one concept: the extension. You add it with @ExtendWith, and you can add many. Libraries like Mockito and Spring plug in this way. We'll write a tiny one later.
JUnit 5 in a Maven project
You need two things in your pom.xml: the JUnit dependency and a recent Surefire plugin.
<properties>
<maven.compiler.release>17</maven.compiler.release>
<junit.version>5.14.3</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.5.0</version>
</plugin>
</plugins>
</build>
What each part does:
-
junit-jupiteris a convenient "aggregate" dependency. It brings in three libraries at once:junit-jupiter-api(what you write tests with),junit-jupiter-params(parameterized tests) andjunit-jupiter-engine(what runs them). -
<scope>test</scope>means the library is used only for testing, and it's not packaged into your final JAR. -
Surefire is the Maven plugin that runs your unit tests during the
testphase. JUnit 5 needs Surefire version 2.22.0 or newer, and I pin a recent one so builds are repeatable. -
To use JUnit 6 instead, change
junit.versionto a 6.x version and make sure you use Java 17 or newer. -
To keep running old JUnit 4 tests, add
junit-vintage-engineas another test dependency.
How Surefire finds your tests
- Test code goes in
src/test/java. - By default, Surefire runs classes named
*Test,Test*,*Testsor*TestCase. - When Surefire sees the Jupiter engine on the test classpath, it switches to its JUnit Platform provider, which uses the Launcher from the architecture section.
Run your tests:
mvn test
You can run a single class or method:
mvn test -Dtest=CalculatorTest
mvn test -Dtest=CalculatorTest#addsTwoNumbers
A trimmed result looks like this (numbers and times will differ):
[INFO] -------------------------------------------------------
[INFO] T E S T S
[INFO] -------------------------------------------------------
[INFO] Running com.example.CalculatorTest
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
Detailed reports are saved in target/surefire-reports. Note the difference between two counts: a Failure means an assertion didn't hold, and an Error means an unexpected exception happened.
(Surefire runs unit tests. For slower integration tests, Maven teams usually use the Failsafe plugin, with files named *IT.)
A quick note for Gradle users
In Gradle, add the same dependency under testImplementation, and turn on JUnit Platform in the test task:
tasks.test {
useJUnitPlatform()
}
Your first JUnit 5 test
Here's the class we'll test throughout the rest of the post:
package com.example;
public class Calculator {
public int add(int a, int b) {
return a + b;
}
public int divide(int a, int b) {
return a / b;
}
public boolean isEven(int n) {
return n % 2 == 0;
}
}
And its test, in src/test/java/com/example/CalculatorTest.java:
package com.example;
import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.Test;
class CalculatorTest {
private final Calculator calc = new Calculator();
@Test
void addsTwoNumbers() {
assertEquals(5, calc.add(2, 3));
}
}
Three things to notice:
-
@Testmarks a method as a test. - The
import static ...Assertions.*line lets us writeassertEquals(...)without the class name. - The class and method are package-private. No
publicneeded.
When a test fails, the report tells you exactly what happened. If we wrote assertEquals(6, calc.add(2, 3)):
[ERROR] Failures:
[ERROR] CalculatorTest.addsTwoNumbers:15 expected: <6> but was: <5>
[ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0
Assertions
An assertion is a check inside a test. If it holds, the test continues. If it doesn't, the test fails right there.
In JUnit 5, all the assertions are static methods of one class: org.junit.jupiter.api.Assertions. (Careful: in JUnit 4, the class was called Assert, singular. In JUnit 5 it's Assertions, plural.)
assertEquals and assertNotEquals
assertEquals(expected, actual) passes when the two values are equal. Always put the expected value first, because the failure message uses that order.
@Test
void equalityChecks() {
assertEquals(5, calc.add(2, 3));
assertNotEquals(4, calc.add(2, 3));
assertEquals("Java", "Ja" + "va");
}
For double and float, you must give a tolerance (called a delta), because of rounding errors:
@Test
void doublesNeedADelta() {
assertEquals(0.3, 0.1 + 0.2, 0.0001); // passes
assertEquals(0.3, 0.1 + 0.2); // FAILS
}
expected: <0.3> but was: <0.30000000000000004>
0.1 + 0.2 isn't exactly 0.3 in floating-point math. The delta says "close enough is fine."
For objects, assertEquals uses the object's .equals() method.
assertTrue and assertFalse
@Test
void booleanChecks() {
assertTrue(calc.isEven(4));
assertFalse(calc.isEven(7));
}
Use these when the thing you're checking is naturally a yes/no. If you're comparing two values, prefer assertEquals, because its failure message shows both values.
assertNull, assertNotNull, assertSame, assertNotSame
@Test
void nullAndSameChecks() {
Map<String, String> map = new HashMap<>();
assertNull(map.get("missing"));
map.put("k", "v");
assertNotNull(map.get("k"));
String a = new String("hi");
String b = new String("hi");
assertEquals(a, b); // same text
assertNotSame(a, b); // but two different objects
String c = a;
assertSame(a, c); // exactly the same object
}
assertEquals asks "are they equal?" (like .equals()). assertSame asks "are they the very same object?" (like ==).
assertArrayEquals and assertIterableEquals
Arrays don't override equals, so assertEquals compares their references, not their contents. Use assertArrayEquals:
@Test
void arrayChecks() {
int[] expected = {1, 2, 3};
int[] actual = {1, 2, 3};
assertArrayEquals(expected, actual); // passes: compares the contents
assertEquals(expected, actual); // FAILS: two different array objects
}
expected: <[I@6d06d69c> but was: <[I@7852e922>
For collections, assertIterableEquals compares the items one by one, in order, even if the two lists are different types:
assertIterableEquals(List.of(1, 2, 3), new ArrayList<>(List.of(1, 2, 3)));
There's also assertLinesMatch for comparing lists of text lines.
assertThrows and assertDoesNotThrow
assertThrows checks that some code throws a specific exception. It takes the exception class and a lambda (the code to run). It also returns the exception, so you can inspect it:
@Test
void divisionByZeroThrows() {
ArithmeticException ex = assertThrows(
ArithmeticException.class,
() -> calc.divide(10, 0));
assertEquals("/ by zero", ex.getMessage());
}
This is the replacement for JUnit 4's @Test(expected = ...), and it's better, because it checks that exactly this line throws, not just any line in the method.
Related methods:
-
assertThrowsExactly(X.class, ...)requires the exact type. (assertThrowsalso accepts a subclass.) -
assertDoesNotThrow(() -> calc.divide(10, 2))checks that nothing is thrown.
assertAll
Normally, the first failed assertion stops the test, so you only see one problem at a time. assertAll runs a group of checks and reports all the failures together:
@Test
void groupedChecks() {
String name = "Asha Rao";
assertAll("name checks",
() -> assertTrue(name.startsWith("Ravi")),
() -> assertTrue(name.endsWith("Sharma")),
() -> assertEquals(8, name.length())
);
}
org.opentest4j.MultipleFailuresError: name checks (2 failures)
org.opentest4j.AssertionFailedError: expected: <true> but was: <false>
org.opentest4j.AssertionFailedError: expected: <true> but was: <false>
Two checks failed, and both were reported in a single run. It's useful when checking several fields of one object.
assertTimeout
Checks that code finishes within a time limit:
@Test
void finishesQuickly() {
assertTimeout(Duration.ofMillis(500), () -> Thread.sleep(100));
}
-
assertTimeoutwaits for the code to finish, then fails if it took too long. -
assertTimeoutPreemptivelyruns the code in another thread and stops it as soon as the time is up. Use it with care, because things tied to the original thread (like some transactions) may not work.
You can also put @Timeout on a test method (see the annotations part).
fail()
fail() makes a test fail on purpose. It's handy for:
- a placeholder for a test you haven't written yet,
- a line that should never be reached.
@Test
void notWrittenYet() {
fail("TODO: write this test");
}
org.opentest4j.AssertionFailedError: TODO: write this test
Failure messages
Every assertion accepts an optional message as the last argument:
assertEquals(5, calc.add(2, 2), "adding 2 and 2");
adding 2 and 2 ==> expected: <5> but was: <4>
If building the message is expensive, pass a lambda. It's only evaluated when the assertion fails:
assertTrue(calc.isEven(3), () -> "Expected an even number but got " + 3);
Assumptions
Assumptions are different from assertions. If an assumption is false, the test is aborted (skipped), not failed. Use them when a test only makes sense in certain conditions:
import static org.junit.jupiter.api.Assumptions.assumeTrue;
@Test
void onlyOnCiServer() {
assumeTrue("CI".equals(System.getenv("ENV")), "Runs only on the CI server");
// the rest of the test runs only if the assumption holds
}
In the Surefire summary, this shows up as Skipped.
Annotations
Annotations tell JUnit what each method is for. Here are the core ones in one place:
-
@Test: this method is a test. -
@DisplayName: a readable name for the test. -
@Disabled: skip this test. -
@BeforeEach: run before each test. -
@AfterEach: run after each test. -
@BeforeAll: run once before all the tests in the class. -
@AfterAll: run once after all the tests in the class. -
@TestInstance: control how test class objects are created.
@test, @DisplayName and @Disabled
class CalculatorTest {
private final Calculator calc = new Calculator();
@Test
@DisplayName("2 + 3 = 5")
void addsTwoNumbers() {
assertEquals(5, calc.add(2, 3));
}
@Test
@Disabled("Bug #123: not fixed yet")
void brokenForNow() {
fail("Should not run");
}
}
-
@DisplayNameshows a friendly name in IDEs and reports, with spaces and symbols if you like. -
@Disabledskips the test. Always give a reason, so nobody forgets why it's off. It appears asSkipped.
@BeforeEach, @AfterEach, @BeforeAll and @AfterAll
These are lifecycle methods: they run at set moments around your tests. Use them for setup and cleanup. Let's print messages to see the order:
class LifecycleTest {
@BeforeAll
static void beforeAll() {
System.out.println("beforeAll");
}
@BeforeEach
void beforeEach() {
System.out.println(" beforeEach");
}
@Test
void testA() {
System.out.println(" testA");
}
@Test
void testB() {
System.out.println(" testB");
}
@AfterEach
void afterEach() {
System.out.println(" afterEach");
}
@AfterAll
static void afterAll() {
System.out.println("afterAll");
}
}
Output (testA and testB may swap places):
beforeAll
beforeEach
testA
afterEach
beforeEach
testB
afterEach
afterAll
Here is the timeline:
@BeforeAll <- once, at the start
@BeforeEach testA @AfterEach
@BeforeEach testB @AfterEach
@AfterAll <- once, at the end
The rules:
-
@BeforeEachand@AfterEachrun around every test. They're the right place to create fresh objects (like a newCalculator) so tests don't affect each other. -
@BeforeAlland@AfterAllrun once per class. They're for expensive setup, such as starting a test database. - By default,
@BeforeAlland@AfterAllmethods must bestatic. If they aren't, you get:
JUnit Jupiter @BeforeAll method 'beforeAll' must be static unless the test class is annotated with @TestInstance(Lifecycle.PER_CLASS).
That error message points to our next topic.
Test instance lifecycle (@TestInstance)
By default, JUnit creates a brand-new object of your test class for every single test method. This is the default setting, called PER_METHOD. It exists so that tests can't affect each other through fields. Watch:
class PerMethodTest {
int counter = 0;
@Test
void first() {
counter++;
System.out.println("counter = " + counter);
}
@Test
void second() {
counter++;
System.out.println("counter = " + counter);
}
}
Output:
counter = 1
counter = 1
Both tests print 1, because each test got its own fresh object with counter starting at 0.
You can change this with @TestInstance, which takes one of two options from the TestInstance.Lifecycle enum:
-
PER_METHOD(the default): a new instance for each test method. -
PER_CLASS: one instance shared by all the test methods in the class.
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class PerClassTest {
int counter = 0;
@Test
void first() {
counter++;
System.out.println("counter = " + counter);
}
@Test
void second() {
counter++;
System.out.println("counter = " + counter);
}
}
Output (the order of the two tests can change, but the counts keep rising):
counter = 1
counter = 2
Now the one object is shared, so counter carries over. Here are the two modes side by side:
PER_METHOD (default) PER_CLASS
@BeforeAll (static) @BeforeAll (can be non-static)
new instance #1 -> testA ONE instance -> testA
new instance #2 -> testB -> testB
@AfterAll (static) @AfterAll (can be non-static)
When should you use PER_CLASS?
- When you want
@BeforeAlland@AfterAllto be non-static, so they can use instance fields. - When you have expensive setup you want to share, and you accept the risk of shared state.
- When you want to write ordered or dependent steps (though independent tests are usually better).
The cost is that tests can now affect each other through the shared fields, so the independent rule of FIRST is easier to break. If you use it, reset the state in @BeforeEach.
More features worth knowing
Parameterized tests
Instead of copying a test with different inputs, write it once and feed it many values. (It uses the junit-jupiter-params library, which is included in junit-jupiter.)
@ParameterizedTest
@ValueSource(ints = {2, 4, 6, 8})
void evenNumbersAreEven(int n) {
assertTrue(calc.isEven(n));
}
@ParameterizedTest
@CsvSource({"1, 2, 3", "5, 5, 10", "-1, 1, 0"})
void addsManyPairs(int a, int b, int expected) {
assertEquals(expected, calc.add(a, b));
}
The first test runs 4 times, and the second 3 times. Each run is reported separately, so you see exactly which input failed. You can also use @MethodSource to feed data from a method.
Nested tests
@Nested lets you group tests into inner classes, which is nice for describing different situations:
class ListTest {
List<String> list;
@Nested
class WhenEmpty {
@BeforeEach
void setUp() {
list = new ArrayList<>();
}
@Test
void isEmpty() {
assertTrue(list.isEmpty());
}
}
@Nested
class WhenNotEmpty {
@BeforeEach
void setUp() {
list = new ArrayList<>(List.of("a"));
}
@Test
void hasOneItem() {
assertEquals(1, list.size());
}
}
}
Repeated tests, tags, timeouts and ordering
-
@RepeatedTest(3)runs the same test three times. -
@Tag("slow")labels a test. With Maven, you can include or exclude by tag:mvn test -Dgroups=slowor-DexcludedGroups=slow. -
@Timeout(2)fails the test if it runs longer than 2 seconds (the default unit is seconds). -
@TempDirgives your test a temporary folder that is deleted afterwards. -
@TestMethodOrderwith@Ordersets the order of test methods. Don't rely on order in normal tests. It's meant for special cases. -
Conditional tests like
@EnabledOnOs(OS.LINUX)or@EnabledIfEnvironmentVariablerun a test only in some environments.
Extensions
An extension is a class that hooks into the test lifecycle. Here's a tiny one that logs when each test starts and ends:
import org.junit.jupiter.api.extension.*;
class LoggingExtension implements BeforeEachCallback, AfterEachCallback {
@Override
public void beforeEach(ExtensionContext context) {
System.out.println("START " + context.getDisplayName());
}
@Override
public void afterEach(ExtensionContext context) {
System.out.println("END " + context.getDisplayName());
}
}
@ExtendWith(LoggingExtension.class)
class ExtensionDemoTest {
@Test
void works() {
System.out.println(" running");
}
}
Output:
START works()
running
END works()
Real libraries use the same mechanism. For example, @ExtendWith(MockitoExtension.class) sets up mock objects for you. And you can add as many extensions as you like.
Which one should I use?
- JUnit 4 or JUnit 5? For new projects, use JUnit 5 (or JUnit 6 if you're on Java 17 or newer). Keep JUnit 4 only for old code, and run it through the Vintage engine while you migrate.
-
assertThrowsor try/catch? AlwaysassertThrows. It's shorter, and it fails if nothing is thrown. -
assertEqualsorassertTrue(a.equals(b))?assertEquals. On failure, it shows both values. -
@BeforeEachor a field initializer? Either works for simple setup. Use@BeforeEachwhen setup needs several steps. Usefinalfields for simple, unchanging objects. -
PER_METHODorPER_CLASS? Stay with the defaultPER_METHODfor independent tests. UsePER_CLASSonly when you need non-static@BeforeAllor expensive shared setup. -
One test with a loop, or
@ParameterizedTest? Parameterized. Each input is reported on its own. -
JUnit's assertions or a fluent library? JUnit's built-in assertions are enough to start. Libraries like AssertJ and Hamcrest read more naturally for complex checks (JUnit 5 has no built-in
assertThat). - Real dependencies or mocks? A unit test should be isolated. When your class talks to a database or a web service, replace it with a mock (a fake). Mockito is the common tool, and it plugs into JUnit 5 as an extension.
Common mistakes
-
Mixing JUnit 4 and JUnit 5 imports. If you import
org.junit.Test(JUnit 4) but only the Jupiter engine is present, your test is never discovered, and it silently doesn't run. Check that your imports start withorg.junit.jupiter.api. -
Getting the argument order wrong. It's
assertEquals(expected, actual), and the message comes last in JUnit 5. Swapping the first two gives misleading failure messages ("expected 5 but was 4" when it's the other way round). -
Comparing arrays or doubles the wrong way.
assertEqualson arrays compares references, so useassertArrayEquals. Anddoublevalues need a delta. -
Letting tests depend on each other. Tests that share state, or only work in a certain order, will fail randomly. Keep each test independent, and be careful with
PER_CLASS. -
Tests that never run. The causes are an old Surefire (older than 2.22.0 can't find JUnit 5 tests), a class name that doesn't match
*Test, or tests placed outsidesrc/test/java. If Maven says "No tests to run," check these. -
Tests that can't fail. A test with no assertion always passes, which gives false comfort. And a try/catch inside a test can swallow the very exception you meant to catch. Use
assertThrows, and make sure every test asserts something.
Quick recap
- In the traditional SDLC, a separate team tested after coding was done, so bugs were found late and fixes were costly. Now developers write automated tests as they go.
- A unit test checks one small piece of code in isolation. Good tests are FIRST: fast, independent, repeatable, self-validating and timely. Use the Arrange-Act-Assert pattern.
- TDD is Red, Green, Refactor: write a failing test, write the simplest code to pass it, then clean up.
-
JUnit 5 is a rewrite and not backward compatible with JUnit 4: new package
org.junit.jupiter.api, new annotations. - Architecture: Platform (the Launcher and TestEngine API that IDEs and build tools use) + Jupiter (the new tests and extension model) + Vintage (runs JUnit 3 and 4 tests).
-
With Maven: add
junit-jupiterintestscope, and use Surefire 2.22.0 or newer. Runmvn test. -
Assertions live in
Assertions:assertEquals,assertNotEquals,assertTrue,assertFalse,assertNull,assertNotNull,assertSame,assertArrayEquals,assertIterableEquals,assertThrows,assertDoesNotThrow,assertAll,assertTimeoutandfail(). The message goes last. -
Annotations:
@Test,@DisplayName,@Disabled,@BeforeEach,@AfterEach,@BeforeAll,@AfterAll. -
@TestInstance:PER_METHOD(default, a new object per test) orPER_CLASS(one shared object, and non-static@BeforeAlland@AfterAll). - Also useful:
@ParameterizedTest,@Nested,@RepeatedTest,@Tag,@Timeoutand extensions through@ExtendWith.
Try it yourself
Here's a small practice idea: build a PasswordValidator class using TDD. The rules are: at least 8 characters, at least one digit and at least one uppercase letter. Write one failing test per rule first, make each pass with the simplest code, then refactor. Use a @ParameterizedTest with @CsvSource to check many valid and invalid passwords, and assertThrows for a null password.
For a bonus experiment, copy the LifecycleTest class from this post and add @TestInstance(TestInstance.Lifecycle.PER_CLASS). Make @BeforeAll non-static, and add a counter field to prove that the instance is shared. Then run a single test with mvn test -Dtest=CalculatorTest#addsTwoNumbers.
If this helped, or if you spotted something I should fix, tell me in the comments. I'm still learning too.
Top comments (0)