MediumJUnit: A Complete Guide from Beginner to Advanced in Java | by Aishu Reddy | Oct, 2026 | Medium
Sitemap
Aishu Reddy

Aishu Reddy

Turning ideas into code and bugs into lessons. 💻✨

JUnit: A Complete Guide from Beginner to Advanced in Java

25 min readOct 16, 2026

--

Introduction

As Java developers, we write code to solve problems. But how do we know that our code works correctly? How can we make sure that a small change in one method does not break another part of the application?

We could run the application manually, enter different inputs, and check the output. This works for small programs, but as an application grows, manually testing every scenario becomes time-consuming and error-prone.

This is where JUnit comes into the picture.

JUnit is a popular, open-source testing framework for Java that allows developers to write and execute automated tests. It helps us verify that individual pieces of code behave as expected and quickly identify problems when the code changes.

In this blog, we will explore JUnit from the basics to advanced concepts, with practical examples, Maven configuration, annotations, assertions, exception testing, parameterized tests, Mockito, Spring Boot integration, and industry best practices.

1. What Is Software Testing?

Software testing is the process of checking whether an application behaves as expected.

Imagine that we are developing a calculator application. We have written a method to add two numbers:

public int add(int a, int b) {
return a + b;
}

We expect add(10, 20) to return 30.

We can run the application, call the method, and check the result manually. However, we must repeat this process whenever we modify the code.

Instead, we can write a test that checks the expected result automatically.

int result = add(10, 20);
System.out.println(result);

This displays the result, but it does not automatically tell us whether the result is correct. We would still have to inspect it.

A testing framework allows us to express the expected behavior as an assertion and reports whether the test passes or fails.

2. What Is Unit Testing?

Unit testing is a type of software testing in which we verify a small, isolated unit of code, usually a method or a class.

For example, suppose our application contains:

  • A calculator for arithmetic operations.
  • A service for calculating loan interest.
  • A utility for validating email addresses.
  • A service for calculating discounts.

We can write individual tests for each unit.

For a loan-interest calculation, we might verify that the method returns the correct interest for a given principal, rate, and duration.

The purpose of unit testing is to detect defects early, verify expected behavior, and make future code changes safer.

Unit testing versus manual testing

Manual testing requires a person to execute steps and inspect results. Automated unit testing allows the computer to repeat those checks consistently.

Automated tests are particularly useful when a method has many possible inputs and when the application changes frequently.

What makes a good unit test?

A good unit test should be:

  • Independent: One test should not rely on another test’s execution.
  • Repeatable: Running the same test under the same conditions should produce the same result.
  • Fast: Unit tests should usually execute quickly.
  • Readable: Developers should easily understand what is being tested.
  • Focused: A test should verify a specific behavior rather than many unrelated behaviors.

3. Why Do We Need JUnit?

Before using a testing framework, developers might write separate main() methods to test their application logic.

For example:

public class TestCalculator {
    public static void main(String[] args) {
Calculator calculator = new Calculator();
int result = calculator.add(10, 20); if (result == 30) {
System.out.println("Test passed");
} else {
System.out.println("Test failed");
}
}
}

This approach works for a small example, but it becomes difficult to manage when an application contains hundreds or thousands of test cases.

JUnit provides a structured way to write, organize, execute, and report test results.

Its advantages include:

  1. Automated execution of test cases.
  2. Assertions for comparing actual and expected results.
  3. Lifecycle methods for preparing and cleaning up test resources.
  4. Integration with IDEs such as IntelliJ IDEA and Eclipse.
  5. Integration with Maven and Gradle.
  6. Support for parameterized and repeated tests.
  7. Integration with continuous integration and continuous delivery pipelines.
  8. Easier detection of regressions after code changes.

4. What Is JUnit?

JUnit is a testing framework for Java that provides annotations, assertions, and test execution infrastructure.

Developers write test methods, mark them with JUnit annotations, and use assertions to verify the results.

For example:

import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;class CalculatorTest {    @Test
void shouldAddTwoNumbers() {
Calculator calculator = new Calculator();
int result = calculator.add(10, 20); assertEquals(30, result);
}
}

Here:

  • @Test tells JUnit that the method is a test.
  • calculator.add(10, 20) executes the application logic.
  • assertEquals(30, result) checks whether the actual result equals the expected result.
  • If the assertion succeeds, the test passes. If it fails, JUnit reports a failure.

Notice that we do not need a main() method in our test class. JUnit discovers and executes the test method.

5. Understanding JUnit Versions

JUnit has evolved through several versions.

JUnit 3

JUnit 3 used naming conventions and inheritance. Test classes commonly extended TestCase, and test methods started with test.

JUnit 4

JUnit 4 introduced annotations such as @Test, @Before, and @After, making tests more flexible and readable.

Example:

import org.junit.Test;
import static org.junit.Assert.assertEquals;public class CalculatorTest {    @Test
public void testAddition() {
assertEquals(30, 10 + 20);
}
}

JUnit 5

JUnit 5 introduced a more modular architecture, a new programming model, and an extension model.

It provides annotations such as:

  • @Test
  • @BeforeEach
  • @AfterEach
  • @BeforeAll
  • @AfterAll
  • @ParameterizedTest
  • @RepeatedTest
  • @Nested
  • @Tag
  • @Disabled

JUnit 5 is commonly used with the Jupiter programming model in modern Java applications.

Understanding JUnit 5’s architecture

JUnit 5 is not simply one library. It consists of three major components:

1. JUnit Platform

The platform provides the infrastructure for discovering and launching tests. IDEs and build tools use it to execute tests.

2. JUnit Jupiter

Jupiter provides the programming model used to write JUnit 5 tests, including its annotations, assertions, lifecycle methods, and extension model.

3. JUnit Vintage

Vintage enables older JUnit 3 and JUnit 4 tests to run on the JUnit Platform when the relevant engine and dependencies are present.

A simple way to remember the architecture is:

  • Platform = runs and coordinates tests.
  • Jupiter = lets us write modern JUnit tests.
  • Vintage = supports legacy JUnit tests.

6. Setting Up a JUnit Project

Let’s create a Maven project and add JUnit 5.

Step 1: Create a Maven project

Create a standard Java Maven project in your IDE.

The project structure can look like this:

junit-demo/
├── pom.xml
└── src/
├── main/
│ └── java/
│ └── Calculator.java
└── test/
└── java/
└── CalculatorTest.java

The src/main/java directory contains application code.

The src/test/java directory contains test code.

This separation helps us keep testing code separate from the production application.

Step 2: Add the JUnit dependency

Add the following dependency inside the <dependencies> element of your pom.xml:

<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.2</version>
<scope>test</scope>
</dependency>

This is a pinned JUnit 5 example version, not a claim that it is the latest release. In a new project, choose a compatible version from the official JUnit documentation or your project’s dependency-management configuration.

The test scope means Maven makes the dependency available for compiling and running tests, without including it as a normal production dependency.

Step 3: Configure Maven Surefire

Maven Surefire is a Maven plugin responsible for running tests during the test phase.

For an explicitly configured JUnit 5 example, add the following inside <build><plugins>:

<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.5</version>
</plugin>

Modern Surefire versions support the JUnit Platform. Older plugin versions may need additional configuration, so make sure the plugin version is compatible with your chosen JUnit version.

Step 4: Create the Calculator class

Create Calculator.java inside src/main/java.

public class Calculator {
    public int add(int a, int b) {
return a + b;
}
public int subtract(int a, int b) {
return a - b;
}
public int multiply(int a, int b) {
return a * b;
}
public int divide(int a, int b) {
if (b == 0) {
throw new IllegalArgumentException(
"Divisor cannot be zero"
);
}
return a / b;
}
}

Step 5: Create the first test class

Create CalculatorTest.java inside src/test/java.

import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;class CalculatorTest {    @Test
void shouldAddTwoNumbers() {
Calculator calculator = new Calculator();
int result = calculator.add(10, 20); assertEquals(30, result);
}
}

Run the test using your IDE’s test runner.

Alternatively, run:

mvn test

Maven compiles the production and test code, discovers supported tests, executes them, and reports the results.

If the actual result is 30, the assertion succeeds. If the actual result is different, the test fails and reports the mismatch.

7. Writing Your First JUnit Test

Let’s understand the test step by step.

@Test
void shouldAddTwoNumbers() {
Calculator calculator = new Calculator();
    int result = calculator.add(10, 20);    assertEquals(30, result);
}

@Test

This annotation marks a method as a test method recognized by JUnit Jupiter.

shouldAddTwoNumbers()

This is the name of our test method. A descriptive name explains the behavior being tested.

Calculator calculator = new Calculator();

We create the object that contains the logic we want to test.

int result = calculator.add(10, 20);

We execute the behavior under test.

assertEquals(30, result);

We verify the expected outcome.

The Arrange–Act–Assert pattern

A useful pattern for writing tests is AAA:

  • Arrange: Prepare the objects and input.
  • Act: Execute the method being tested.
  • Assert: Verify the result.

For example:

@Test
void shouldSubtractTwoNumbers() {
    // Arrange
Calculator calculator = new Calculator();
// Act
int result = calculator.subtract(20, 10);
// Assert
assertEquals(10, result);
}

Following this structure makes tests easier to read and maintain.

8. Understanding Assertions in JUnit

Assertions are the core of testing. They compare the actual behavior of our application with the expected behavior.

JUnit Jupiter provides many assertion methods through org.junit.jupiter.api.Assertions.

8.1 assertEquals()

Checks whether the expected value equals the actual value.

assertEquals(30, calculator.add(10, 20));

It can also accept a message:

assertEquals(
30,
calculator.add(10, 20),
"Addition result should be 30"
);

8.2 assertNotEquals()

Checks that two values are not equal.

assertNotEquals(100, calculator.add(10, 20));

8.3 assertTrue()

Checks whether a condition is true.

assertTrue(calculator.add(10, 20) > 0);

8.4 assertFalse()

Checks whether a condition is false.

assertFalse(calculator.subtract(10, 20) > 0);

8.5 assertNull()

Checks whether a reference is null.

String value = null;
assertNull(value);

8.6 assertNotNull()

Checks that a reference is not null.

String value = "JUnit";
assertNotNull(value);

8.7 assertSame()

Checks whether two references point to the same object.

Object first = new Object();
Object second = first;
assertSame(first, second);

8.8 assertNotSame()

Checks whether two references point to different objects.

Object first = new Object();
Object second = new Object();
assertNotSame(first, second);

Remember that assertSame() checks object identity, not whether two objects have equal contents.

8.9 assertArrayEquals()

Checks whether two arrays contain equal elements in the corresponding positions.

int[] expected = {1, 2, 3};
int[] actual = {1, 2, 3};
assertArrayEquals(expected, actual);

8.10 assertIterableEquals()

Checks whether two iterables contain corresponding equal elements in the same order.

List<Integer> expected = Arrays.asList(1, 2, 3);
List<Integer> actual = Arrays.asList(1, 2, 3);
assertIterableEquals(expected, actual);

8.11 assertAll()

Groups several assertions so that JUnit executes all of them and reports the failures together.

import static org.junit.jupiter.api.Assertions.assertAll;
import static org.junit.jupiter.api.Assertions.assertEquals;
@Test
void shouldVerifyCalculatorResults() {
Calculator calculator = new Calculator(); assertAll(
() -> assertEquals(30, calculator.add(10, 20)),
() -> assertEquals(10, calculator.subtract(20, 10)),
() -> assertEquals(200, calculator.multiply(10, 20))
);
}

This is useful when several independent properties of the same result or object should be checked together.

8.12 assertThrows()

Checks that an operation throws the expected exception.

import static org.junit.jupiter.api.Assertions.assertThrows;
@Test
void shouldThrowExceptionWhenDividingByZero() {
Calculator calculator = new Calculator(); assertThrows(
IllegalArgumentException.class,
() -> calculator.divide(10, 0)
);
}

This test passes when the expected exception type, or a subtype of it, is thrown.

If the operation does not throw an exception, or throws an incompatible exception, the test fails.

8.13 assertDoesNotThrow()

Checks that an operation completes without throwing an exception.

import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
@Test
void shouldDivideValidNumbers() {
Calculator calculator = new Calculator(); assertDoesNotThrow(() -> calculator.divide(10, 2));
}

Use this when the absence of an exception is part of the behavior you want to verify.

8.14 assertTimeout()

Checks whether an operation completes within a specified duration.

import java.time.Duration;
import static org.junit.jupiter.api.Assertions.assertTimeout;@Test
void shouldCompleteWithinOneSecond() {
assertTimeout(Duration.ofSeconds(1), () -> {
int result = 10 + 20;
assertEquals(30, result);
});
}

assertTimeout() executes the supplied operation in the calling thread and checks the elapsed time after it finishes. It does not interrupt an operation simply because the duration has expired.

8.15 assertTimeoutPreemptively()

This assertion runs the operation in a separate thread and attempts to stop it when the timeout is exceeded.

It can be useful for operations that might otherwise block indefinitely. However, it should be used carefully because executing code on another thread can affect thread-local state, transaction behavior, and frameworks such as Spring.

Choosing the right assertion

MethodPurpose

assertEquals()Expected and actual values are equal

assertNotEquals()Values differ

assertTrue()Condition is true

assertFalse()Condition is false

assertNull()Reference is null

assertNotNull()Reference is not null

assertSame()References point to the same object

assertArrayEquals()Arrays contain matching elements

assertAll()Execute a group of assertions

assertThrows()Expected exception is thrown

assertDoesNotThrow()Operation completes without an exception

assertTimeout()Operation completes within a duration

9. Testing Arrays and Collections

JUnit can test arrays, lists, sets, maps, and other collection-based results.

For example, suppose a method returns a list of numbers.

public List<Integer> getNumbers() {
return Arrays.asList(1, 2, 3);
}

We can test it using:

@Test
void shouldReturnExpectedNumbers() {
    List<Integer> expected = Arrays.asList(1, 2, 3);
List<Integer> actual = calculatorService.getNumbers();
assertEquals(expected, actual);
}

For lists, assertEquals() works when the expected and actual lists compare equal using their equals() implementations. For standard lists, this means the elements must match in order.

For arrays, use assertArrayEquals().

For maps, verify the expected keys and values, and consider checking whether unexpected entries are present.

The important point is to choose assertions that reflect the actual contract of the method.

10. Test-Driven Development (TDD)

Test-driven development is a software development approach in which we write a test before implementing the corresponding functionality.

It follows the Red–Green–Refactor cycle.

Red: Write a failing test

Suppose we want to implement addition.

First, write a test:

@Test
void shouldAddTwoNumbers() {
    Calculator calculator = new Calculator();    assertEquals(30, calculator.add(10, 20));
}

If the method does not exist yet, the project will not compile. Once the method exists but does not implement the expected behavior, the test can fail.

Green: Write the simplest implementation

Implement the required behavior:

public int add(int a, int b) {
return a + b;
}

Run the test again. It should now pass.

Refactor: Improve the code

Improve the implementation or test readability without changing the expected behavior. Then rerun the tests to make sure nothing has broken.

TDD encourages developers to think about expected behavior before implementation. It can also help reveal unclear requirements early.

11. Testing Exceptions

Applications often need to handle invalid inputs and exceptional situations.

For example, dividing by zero should produce a meaningful exception rather than an incorrect result.

We can verify that behavior with assertThrows():

@Test
void shouldRejectDivisionByZero() {
    Calculator calculator = new Calculator();    IllegalArgumentException exception = assertThrows(
IllegalArgumentException.class,
() -> calculator.divide(10, 0)
);
assertEquals("Divisor cannot be zero", exception.getMessage());
}

This test checks both the exception type and its message.

We should not catch the exception and simply print a message, because the test must fail if the expected exception is not thrown.

Test exception behavior deliberately, especially when methods validate user input, process financial amounts, or enforce business rules.

12. JUnit Lifecycle Annotations

Sometimes tests require setup before execution and cleanup afterward. JUnit provides lifecycle annotations to handle these tasks.

12.1 @BeforeEach

Runs before every test method in the test instance.

@BeforeEach
void setUp() {
calculator = new Calculator();
}

It is useful for creating fresh objects or resetting test state.

12.2 @AfterEach

Runs after every test method in the test instance, including when a test fails.

@AfterEach
void tearDown() {
calculator = null;
}

It is commonly used to release resources created during a test.

12.3 @BeforeAll

Runs once before all test methods in the class, subject to the lifecycle configuration.

@BeforeAll
static void beforeAll() {
System.out.println("Starting calculator tests");
}

With the default test-instance lifecycle, the method must be static.

12.4 @AfterAll

Runs once after all test methods in the class.

@AfterAll
static void afterAll() {
System.out.println("Finished calculator tests");
}

With the default lifecycle, this method must also be static.

Complete lifecycle example

import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.assertEquals;class CalculatorTest {    private Calculator calculator;    @BeforeAll
static void beforeAll() {
System.out.println("Test class started");
}
@BeforeEach
void setUp() {
calculator = new Calculator();
System.out.println("Preparing test");
}
@Test
void shouldAddNumbers() {
assertEquals(30, calculator.add(10, 20));
}
@Test
void shouldSubtractNumbers() {
assertEquals(10, calculator.subtract(20, 10));
}
@AfterEach
void tearDown() {
calculator = null;
System.out.println("Cleaning up test");
}
@AfterAll
static void afterAll() {
System.out.println("Test class finished");
}
}

Typical lifecycle order

For a class with two test methods, the lifecycle is generally:

  1. @BeforeAll
  2. @BeforeEach
  3. First test method
  4. @AfterEach
  5. @BeforeEach
  6. Second test method
  7. @AfterEach
  8. @AfterAll

The order of the two test methods is not guaranteed by default. Tests should not depend on a particular execution order.

13. Test Instance Lifecycle

JUnit Jupiter supports two principal test-instance lifecycle modes.

PER_METHOD

This is the default.

JUnit creates a new test-class instance for each test method.

This helps isolate instance fields between tests.

PER_CLASS

JUnit creates one test-class instance for all test methods in the class.

You can enable it with:

@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class CalculatorTest {
// Tests and lifecycle methods
}

With PER_CLASS, non-static @BeforeAll and @AfterAll methods are permitted.

However, instance fields are shared across test methods, so mutable state can cause tests to influence each other.

Get Aishu Reddy’s stories in your inbox

Join Medium for free to get updates from this writer.

Best practice: Prefer independent tests and avoid shared mutable state unless it is intentional and carefully managed.

14. Naming and Organizing Test Methods

A good test name explains the behavior and the expected outcome.

Examples:

shouldAddTwoPositiveNumbers()
shouldReturnZeroWhenSubtractingEqualNumbers()
shouldRejectDivisionByZero()
shouldReturnEmptyListWhenNoRecordsExist()

Another common naming convention is:

methodName_condition_expectedResult

For example:

divide_byZero_throwsIllegalArgumentException()

Choose one convention and apply it consistently across the project.

15. Disabling and Grouping Tests

@Disabled

Temporarily disables a test or test class.

@Disabled("Waiting for the dependent feature")
@Test
void shouldVerifyUpcomingFeature() {
// Test implementation
}

Disabled tests are not executed. Avoid leaving tests disabled indefinitely, especially when they cover important behavior.

@DisplayName

Provides a readable name for a test.

@Test
@DisplayName("Should add two positive numbers")
void shouldAddTwoNumbers() {
assertEquals(30, 10 + 20);
}

This can improve the test reports produced by IDEs and build tools.

@Tag

Groups tests by category.

@Test
@Tag("fast")
void shouldAddNumbers() {
assertEquals(30, 10 + 20);
}

Tags can be used to select or exclude groups of tests through build-tool configuration.

For example, a project might group tests into fast, integration, and slow. The exact tag names are project conventions.

16. Parameterized Tests

Suppose we want to test addition for ten different pairs of numbers.

Writing ten nearly identical test methods would create unnecessary duplication.

Parameterized tests let us execute one test method with several sets of inputs.

JUnit Jupiter provides @ParameterizedTest and several argument-source annotations.

JUnit also accepts a lambda expression that supplies the message.

assertTrue(
actual > 0,
() -> "Expected a positive result, but got: " + actual
);

The message is generated only if the assertion fails. This is called lazy evaluation and can improve performance when generating the message is expensive.

16.1 @ValueSource

Suitable when each invocation needs a single value.

import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import static org.junit.jupiter.api.Assertions.assertTrue;class NumberTest {    @ParameterizedTest
@ValueSource(ints = {2, 4, 6, 8, 10})
void shouldIdentifyEvenNumbers(int number) {
assertTrue(number % 2 == 0);
}
}

JUnit executes the test separately for each supplied integer.

16.2 @CsvSource

Useful when each test invocation requires multiple values.

import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import static org.junit.jupiter.api.Assertions.assertEquals;class CalculatorTest {    private final Calculator calculator = new Calculator();    @ParameterizedTest
@CsvSource({
"10, 20, 30",
"5, 5, 10",
"-2, 3, 1",
"0, 0, 0"
})
void shouldAddDifferentNumbers(
int a, int b, int expected) {
assertEquals(expected, calculator.add(a, b));
}
}

Each row contains two input values and one expected result.

16.3 @CsvFileSource

Useful when test data is stored in a CSV file.

@ParameterizedTest
@CsvFileSource(
resources = "/calculator-data.csv",
numLinesToSkip = 1
)
void shouldAddNumbersFromFile(
int a, int b, int expected) {
    assertEquals(expected, calculator.add(a, b));
}

The file can be stored at src/test/resources/calculator-data.csv.

16.4 @MethodSource

Useful when test data requires objects, more complex values, or programmatically generated arguments.

import java.util.stream.Stream;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;
import static org.junit.jupiter.api.Assertions.assertEquals;class CalculatorTest { static Stream<org.junit.jupiter.params.provider.Arguments>
additionCases() {
return Stream.of(
org.junit.jupiter.params.provider.Arguments.of(1, 2, 3),
org.junit.jupiter.params.provider.Arguments.of(5, 5, 10),
org.junit.jupiter.params.provider.Arguments.of(-1, 1, 0)
);
}
@ParameterizedTest
@MethodSource("additionCases")
void shouldAddNumbers(
int a, int b, int expected) {
assertEquals(expected, new Calculator().add(a, b));
}
}

16.5 @NullSource, @EmptySource, and @NullAndEmptySource

These annotations help test methods that accept strings and other supported types.

@ParameterizedTest
@NullAndEmptySource
@ValueSource(strings = {" ", "\t"})
void shouldRejectBlankInput(String input) {
// Assert the behavior of your input validator.
}

The test body should contain the assertion appropriate to the validator being tested.

16.6 @EnumSource

Useful when testing behavior across enum values.

@ParameterizedTest
@EnumSource(value = DayOfWeek.class, names = {"MONDAY", "FRIDAY"})
void shouldHandleSelectedDays(DayOfWeek day) {
assertNotNull(day);
}

Why use parameterized tests?

They reduce duplication, make it easier to cover boundary cases, and help verify that the same behavior holds for different inputs.

Use them when the cases share the same logic. If two scenarios have substantially different behavior or setup, separate test methods may be clearer.

17. Repeated Tests

JUnit provides @RepeatedTest when you want to run a test a specified number of times.

import org.junit.jupiter.api.RepeatedTest;
import static org.junit.jupiter.api.Assertions.assertEquals;class CalculatorTest {    @RepeatedTest(5)
void shouldAddNumbersRepeatedly() {
assertEquals(30, new Calculator().add(10, 20));
}
}

This runs the test five times.

Repeated tests can help exercise behavior across repeated invocations, but they do not automatically prove that concurrent code is thread-safe or that a flaky test is correct.

18. Nested Tests

@Nested allows related test cases to be grouped inside nested test classes.

For example, a service might behave differently for valid and invalid inputs.

import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;class CalculatorTest {    private final Calculator calculator = new Calculator();    @Nested
class AdditionTests {
@Test
void shouldAddPositiveNumbers() {
assertEquals(30, calculator.add(10, 20));
}
@Test
void shouldAddNegativeNumbers() {
assertEquals(-30, calculator.add(-10, -20));
}
}
@Nested
class DivisionTests {
@Test
void shouldDivideNumbers() {
assertEquals(5, calculator.divide(10, 2));
}
@Test
void shouldRejectZeroDivisor() {
assertThrows(
IllegalArgumentException.class,
() -> calculator.divide(10, 0)
);
}
}
}

Nested classes help organize tests around behaviors or scenarios. They are especially useful when the test suite grows.

19. Test Information and Reporting

JUnit can provide information about the currently executing test.

For example, TestInfo can supply the test's display name, method, class, and tags.

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInfo;
class ExampleTest {    @Test
void shouldReadTestInformation(TestInfo testInfo) {
System.out.println(testInfo.getDisplayName());
}
}

JUnit also provides TestReporter, which allows tests to publish additional information to the test execution report.

These facilities are useful for diagnostics, but they should not replace assertions or be used to make tests depend on their names or execution order.

20. Running JUnit Tests Without Maven

JUnit does not require Maven as a concept. Maven is a build tool that can download dependencies and execute tests.

Tests can also be run using an IDE, Gradle, or the JUnit Platform Console Launcher.

Running a JUnit test without a build tool requires the correct JUnit libraries and their compatible dependencies on the classpath.

For a beginner, Maven or Gradle is usually the simplest choice because it manages dependencies and provides a repeatable test command.

21. JUnit and Maven Build Lifecycle

Maven defines several build lifecycle phases. The ones commonly used when testing Java applications include:

  • mvn test: Compiles the code and runs tests in the test phase.
  • mvn package: Runs the earlier required phases and packages the application.
  • mvn verify: Runs the required earlier phases and verification checks configured in the build.

For a normal Java project, mvn test is the common starting point.

In a larger project, you may configure Surefire for unit tests and Maven Failsafe for integration tests that run during the integration-test and verify phases.

A test that is not discovered by the configured test engine or plugin will not run merely because its source file exists. If a test is missing from reports, check its annotations, naming patterns, dependencies, and plugin configuration.

22. Introduction to Mockito

JUnit provides the structure for writing and running tests. But real applications often depend on databases, external APIs, email services, message queues, and other components.

For a unit test, we usually want to focus on the behavior of the class under test without requiring every external dependency to be available.

Mockito is a Java mocking framework that lets us create test doubles for dependencies, define their behavior, and verify interactions with them.

JUnit and Mockito solve different problems and are commonly used together.

Example: Testing a service with a dependency

Suppose we have a repository interface:

public interface UserRepository {
String findUserNameById(int id);
}

And a service:

public class UserService {
    private final UserRepository repository;    public UserService(UserRepository repository) {
this.repository = repository;
}
public String getUserName(int id) {
return repository.findUserNameById(id);
}
}

We want to test UserService without connecting to a real database.

Using Mockito:

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@Mock
private UserRepository repository;
@InjectMocks
private UserService userService;
@Test
void shouldReturnUserName() {
when(repository.findUserNameById(1))
.thenReturn("Asha");
String result = userService.getUserName(1); assertEquals("Asha", result);
}
}

The test uses a mocked repository that returns a predefined value. No real database is required.

For this example, add a compatible mockito-junit-jupiter test dependency to your Maven project.

Important Mockito concepts

  • @Mock: Creates a mock dependency.
  • @InjectMocks: Creates the object under test and attempts to inject its mocks.
  • when(...).thenReturn(...): Defines a mock's behavior.
  • verify(...): Verifies that an interaction occurred.
  • @ExtendWith(MockitoExtension.class): Integrates Mockito with JUnit Jupiter.

Mocks are useful when a class collaborates with other components. However, mocking every object can make tests brittle. Prefer real objects for simple value objects and straightforward business logic.

23. Stubbing and Verifying Interactions

Sometimes, checking the returned result is not enough. We may also need to verify that the service called a dependency correctly.

Mockito’s verify() method supports this.

import static org.mockito.Mockito.verify;
@Test
void shouldCallRepositoryWithCorrectId() {
when(repository.findUserNameById(1))
.thenReturn("Asha");
String result = userService.getUserName(1); assertEquals("Asha", result);
verify(repository).findUserNameById(1);
}

This checks that the repository method was called with ID 1.

Interaction verification is valuable when the interaction itself is part of the expected behavior, such as publishing a required event or calling a payment gateway.

Avoid verifying every internal method call without a reason. Tests should primarily describe observable behavior rather than implementation details.

24. JUnit in Spring Boot Applications

In real Java projects, especially backend services, JUnit is often used to test Spring components.

Spring Boot provides testing support through spring-boot-starter-test, which commonly includes JUnit Jupiter, Mockito, and other testing libraries.

For projects managed by Spring Boot, it is generally best to use the version of spring-boot-starter-test managed by the project's Spring Boot dependency management rather than manually mixing incompatible versions.

Unit test for a service

A service containing ordinary business logic can often be tested without starting the Spring application context.

class DiscountService {
    public double calculateDiscount(double amount) {
if (amount < 1000) {
return 0;
}
return amount * 0.10;
}
}

Test:

import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;class DiscountServiceTest {    private final DiscountService service = new DiscountService();    @Test
void shouldCalculateDiscountForEligibleAmount() {
assertEquals(
200.0,
service.calculateDiscount(2000),
0.001
);
}
}

The third argument to assertEquals() specifies a tolerance for floating-point comparison.

For financial calculations, prefer BigDecimal with explicit rounding rules rather than using double for monetary values.

Testing a Spring component with @SpringBootTest

@SpringBootTest loads a Spring application context for integration-style testing.

@SpringBootTest
class ApplicationContextTest {
    @Test
void shouldLoadApplicationContext() {
// If the test executes successfully,
// the application context loaded.
}
}

This test verifies that the application context can start successfully. It does not by itself prove that every service or endpoint behaves correctly.

Because context startup can be relatively expensive, use it when Spring configuration or bean integration is part of the behavior being tested, rather than for every simple business-logic test.

Testing MVC controllers with @WebMvcTest

@WebMvcTest focuses on Spring MVC components. It is useful for testing controller behavior, request handling, validation, and HTTP responses without loading the entire application context.

A controller test typically uses MockMvc and mocks the controller's service dependencies.

Testing repositories with @DataJpaTest

@DataJpaTest focuses on JPA-related components, such as repositories and entity mappings.

It is useful for checking repository queries, persistence behavior, and entity relationships. Depending on configuration, it may use an embedded database or another configured test database.

Testing REST APIs

Spring Boot applications commonly use MockMvc or WebTestClient to test HTTP behavior.

These tools can verify:

  • HTTP status codes.
  • Response bodies.
  • Request validation.
  • Headers.
  • Error handling.
  • Controller-to-service interactions.

For example, a REST endpoint that retrieves a user should be tested for both successful requests and appropriate error responses when the user does not exist.

25. JUnit Extensions

JUnit Jupiter supports extensions that integrate additional behavior into the test lifecycle.

Extensions can participate in test execution, manage resources, resolve parameters, and provide other reusable functionality.

Examples include:

  • MockitoExtension for Mockito integration.
  • Spring’s testing extensions for application-context integration.
  • Custom extensions for reusable setup or test infrastructure.

An extension is a way to add reusable testing behavior without repeating the same setup code in every test class.

Use extensions when the behavior is genuinely shared across tests and an extension makes the tests easier to understand.

26. Test Suites

A test suite groups selected tests so that they can be executed together.

JUnit Platform supports suites through its suite API and suite engine. This can be useful when a project has separate groups of tests for particular modules or workflows.

For example, a project might maintain a suite for fast unit tests and another for selected integration tests.

Test suites are especially useful when the test suite becomes large and teams need controlled ways to execute specific subsets of tests.

27. JUnit and Continuous Integration

JUnit tests are not limited to a developer’s local machine.

They can run automatically in a continuous integration pipeline whenever code is pushed or a pull request is created.

A typical workflow is:

  1. A developer pushes code.
  2. The build pipeline compiles the application.
  3. Automated tests run.
  4. The pipeline collects test reports.
  5. The team reviews failures before merging or deploying the change.

For a Maven project, the test command is commonly:

mvn test

Build systems can use the resulting reports to identify failed tests and prevent broken changes from progressing through the pipeline.

A passing unit-test suite is valuable, but it does not guarantee that an application is free of bugs. Integration, security, performance, and other relevant tests may still be required.

29. Best Practices for Writing JUnit Tests

1. Test behavior, not implementation details

Tests should describe what the application is expected to do. Avoid tying them unnecessarily to private methods or internal implementation choices.

2. Keep tests independent

One test should not require another test to run first. Avoid shared mutable state that can cause order-dependent failures.

3. Test positive and negative scenarios

For each important behavior, consider valid inputs, invalid inputs, boundary values, and exceptional conditions.

4. Use meaningful test names

A test name should communicate the scenario and expected behavior.

5. Avoid excessive mocking

Mocks are useful for dependencies, but over-mocking can make tests difficult to understand and sensitive to implementation changes.

6. Keep unit tests fast

Use unit tests for logic that does not need a database, web server, or complete Spring context. Reserve heavier tests for scenarios that genuinely need them.

7. Use assertions that match the requirement

Do not merely check that a method executes. Verify the output, state change, exception, or interaction that matters.

8. Avoid unnecessary sleeps

Tests that call Thread.sleep() to wait for an event can be slow and unreliable. Prefer deterministic synchronization or suitable testing utilities.

9. Test boundary cases

For example, a discount rule that applies at an amount of 1000 should be tested just below, exactly at, and just above that threshold.

10. Maintain tests as the application evolves

When expected behavior changes, update the tests deliberately. Do not remove a failing assertion simply to make the build pass.

30. Common JUnit Mistakes

Mistake 1: Using the wrong @Test import

JUnit 4 and JUnit Jupiter use different packages.

JUnit 4:

import org.junit.Test;

JUnit Jupiter:

import org.junit.jupiter.api.Test;

Make sure your imports match the JUnit version and test engine configured in the project.

Mistake 2: Forgetting the test dependency

If JUnit is missing from the test classpath, imports will fail or the tests will not compile.

Mistake 3: Using an incompatible Maven Surefire version

An old plugin configuration may not discover or execute Jupiter tests as expected.

Mistake 4: Writing tests that depend on execution order

JUnit does not guarantee a useful default order for ordinary tests. Each test should prepare the state it needs.

Mistake 5: Checking only the happy path

A method can work for normal input but fail for empty input, null, boundary values, or invalid parameters.

Mistake 6: Writing assertions that do not test the intended behavior

A test that only checks that a variable is not null may miss an incorrect result. Assertions should reflect the actual requirement.

How do we run JUnit tests in parallel?

By default, JUnit Jupiter runs tests sequentially. Parallel execution is an opt-in feature that can reduce execution time when a project has many independent tests.

For example, imagine you have:

  • Test A — takes 3 seconds
  • Test B — takes 3 seconds
  • Test C — takes 3 seconds

If they run sequentially, the total is approximately 9 seconds. If they run concurrently, the total could be closer to 3 seconds, depending on available resources and overhead.

Step 1: Create the configuration file

Create this file in your test resources directory:

src/test/resources/junit-platform.properties

Add the following properties:

junit.jupiter.execution.parallel.enabled=true
junit.jupiter.execution.parallel.mode.default=concurrent

What do they mean?

Property

Purpose

parallel.enabled=true

Enables parallel execution support

mode.default=concurrent

Allows tests to execute concurrently by default

Both settings matter: enabling parallel execution alone does not automatically make every test run concurrently.

Step 2: Write independent tests

import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;class CalculatorTest {    @Test
void testAddition() throws InterruptedException {
Thread.sleep(2000);
assertEquals(30, 10 + 20);
System.out.println("Addition: " +
Thread.currentThread().getName());
}
@Test
void testSubtraction() throws InterruptedException {
Thread.sleep(2000);
assertEquals(10, 20 - 10);
System.out.println("Subtraction: " +
Thread.currentThread().getName());
}
@Test
void testMultiplication() throws InterruptedException {
Thread.sleep(2000);
assertEquals(200, 10 * 20);
System.out.println("Multiplication: " +
Thread.currentThread().getName());
}
}

The Thread.sleep() calls are only to demonstrate the difference in execution time. Avoid using sleeps in real unit tests when possible.

Run the tests normally through your IDE or with Maven:

mvn test

If the configuration is loaded and the execution environment has enough resources, the tests can overlap rather than waiting for each other.

Step 3: Configure the number of concurrent tests

If you want to control the desired parallelism, add these properties instead:

junit.jupiter.execution.parallel.enabled=true
junit.jupiter.execution.parallel.mode.default=concurrent
junit.jupiter.execution.parallel.config.strategy=fixed
junit.jupiter.execution.parallel.config.fixed.parallelism=4

This requests a fixed parallelism of four. It does not necessarily guarantee that no more than four threads will ever be active, since JUnit’s concurrency and thread-pool behavior depend on its execution configuration.

31. A Complete Calculator Test Class

Let’s bring together the main concepts in one example.

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.DisplayName;
import static org.junit.jupiter.api.Assertions.*;class CalculatorTest {    private Calculator calculator;    @BeforeEach
void setUp() {
calculator = new Calculator();
}
@Test
@DisplayName("Should add two numbers")
void shouldAddNumbers() {
assertEquals(30, calculator.add(10, 20));
}
@Test
void shouldSubtractNumbers() {
assertEquals(10, calculator.subtract(20, 10));
}
@Test
void shouldMultiplyNumbers() {
assertEquals(200, calculator.multiply(10, 20));
}
@Test
void shouldDivideNumbers() {
assertEquals(5, calculator.divide(10, 2));
}
@Test
void shouldRejectDivisionByZero() {
IllegalArgumentException exception = assertThrows(
IllegalArgumentException.class,
() -> calculator.divide(10, 0)
);
assertEquals(
"Divisor cannot be zero",
exception.getMessage()
);
}
@Test
void shouldAddNegativeNumbers() {
assertEquals(-30, calculator.add(-10, -20));
}
@Test
void shouldAddZero() {
assertEquals(10, calculator.add(10, 0));
}
}

This test class demonstrates lifecycle setup, descriptive test names, assertions, and exception testing.

As the project grows, parameterized tests can reduce repetition in cases where several inputs follow the same behavior.

32. Interview Questions on JUnit

1. What is JUnit?

JUnit is a Java testing framework used to write and execute automated tests.

2. What is unit testing?

Unit testing verifies a small, isolated part of an application, usually a method or class.

3. What is the difference between JUnit 4 and JUnit 5?

JUnit 5 introduced the Platform, Jupiter, and Vintage architecture, along with a new programming and extension model. It also uses different annotation packages and provides modern features such as parameterized tests.

4. What is the purpose of @Test?

It identifies a test method for JUnit to discover and execute.

5. What is the difference between @BeforeEach and @BeforeAll?

@BeforeEach runs before each test method, while @BeforeAll runs once before all tests in the class under the configured lifecycle.

6. What is the difference between assertEquals() and assertSame()?

assertEquals() checks equality, while assertSame() checks whether both references point to the same object.

7. How do you test exceptions in JUnit 5?

Use assertThrows() to verify that the expected exception is thrown.

8. What is a parameterized test?

It executes the same test logic with multiple sets of input data.

9. What is TDD?

Test-driven development follows the Red–Green–Refactor cycle: write a failing test, implement the behavior, and then improve the code while keeping tests passing.

10. What is Mockito?

Mockito is a mocking framework used to create test doubles, define dependency behavior, and verify interactions.

11. What is the difference between a unit test and an integration test?

A unit test focuses on an isolated unit, while an integration test checks interactions between components.

12. What is @SpringBootTest?

It loads a Spring application context for testing application configuration and integrated components.

13. What is the purpose of Maven Surefire?

It executes tests during Maven’s test phase.

14. What is @TestInstance?

It controls whether JUnit creates a new test-class instance for each test method or shares one instance across methods.

15. Why should unit tests be independent?

Independent tests are more reliable because their results do not depend on execution order or state left behind by other tests.

Conclusion

JUnit is an essential tool in Java development because it helps us verify application behavior automatically and detect regressions early.

We started with software testing and unit testing, created a Maven project, wrote our first test, explored assertions and lifecycle annotations, and then moved into parameterized tests, Mockito, Spring Boot testing, build integration, and best practices.

The key lesson is that testing is not just about checking whether code runs. It is about verifying whether the code behaves correctly under expected, unexpected, and boundary conditions.

Start with small tests for simple Java methods. Gradually introduce mocks, integration tests, and advanced JUnit features as your applications become more complex.

Write code with confidence, test it with care, and let every test make your application a little more reliable. ✨

--

--

Aishu Reddy
Aishu Reddy

Turning ideas into code and bugs into lessons. 💻✨