How do I mock dependencies in unit tests?

To mock dependencies in unit tests, you usually use a mocking framework such as Mockito. Mocking lets you test one class in isolation without running the real logic of its collaborators.

Basic Mockito Example

Suppose you have a service that depends on another class:

@Service
public class MyService {

    private final MyDependency dependency;

    public MyService(MyDependency dependency) {
        this.dependency = dependency;
    }

    public void process() {
        dependency.doSomething();
    }
}

You can mock MyDependency in a unit test like this:

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.mockito.Mockito.verify;

@ExtendWith(MockitoExtension.class)
class MyServiceTest {

    @Mock
    private MyDependency dependency;

    @InjectMocks
    private MyService myService;

    @Test
    void processCallsDependency() {
        myService.process();

        verify(dependency).doSomething();
    }
}

What the annotations mean

  • @Mock creates a mock object.
  • @InjectMocks creates the class under test and injects the mocks into it.
  • @ExtendWith(MockitoExtension.class) enables Mockito support in JUnit 5.
  • verify(...) checks that a method was called.

Mocking return values

If the dependency returns a value, use when(...).thenReturn(...):

import static org.mockito.Mockito.when;

when(repository.findNameById(1L)).thenReturn("Alice");

Example:

@Test
void returnsMockedValue() {
    when(userRepository.findNameById(1L)).thenReturn("Alice");

    String result = userService.getUserName(1L);

    assertEquals("Alice", result);
}

Mocking exceptions

You can also make a mock throw an exception:

import static org.mockito.Mockito.when;

when(repository.findById(99L))
        .thenThrow(new EntityNotFoundException("User not found"));

For void methods, use doThrow(...):

import static org.mockito.Mockito.doThrow;

doThrow(new RuntimeException("Failure"))
        .when(dependency)
        .doSomething();

Verifying interactions

You can verify how your class interacted with its dependencies:

verify(dependency).doSomething();
verify(dependency, times(1)).doSomething();
verify(dependency, never()).doSomething();

Spring Boot unit test example

For a pure unit test, prefer Mockito without starting the Spring context:

@ExtendWith(MockitoExtension.class)
class MyServiceTest {

    @Mock
    private MyDependency dependency;

    @InjectMocks
    private MyService service;

    @Test
    void processCallsDependency() {
        service.process();

        verify(dependency).doSomething();
    }
}

Spring integration-style test

If you need the Spring context, use Spring’s test support and replace a bean with a mock:

@SpringBootTest
class MyServiceSpringTest {

    @MockitoBean
    private MyDependency dependency;

    @Autowired
    private MyService service;

    @Test
    void processCallsDependency() {
        service.process();

        verify(dependency).doSomething();
    }
}

Use this style when you want to test Spring wiring, configuration, transactions, security, or other framework behavior.

Rule of thumb

  • Use Mockito @Mock + @InjectMocks for fast unit tests.
  • Use Spring test annotations only when you need the Spring application context.
  • Mock external systems such as databases, APIs, message queues, and file systems.
  • Avoid mocking simple value objects or the class you are actually testing.

How do I use Mockito with JUnit?

To use Mockito with JUnit, you add Mockito to your test dependencies, enable Mockito in your JUnit test class, then create mocks and define their behavior.

Below is a simple JUnit 5 + Mockito example.

1. Add Dependencies

Maven

<dependencies>
    <!-- JUnit 5 -->
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter</artifactId>
        <version>5.11.4</version>
        <scope>test</scope>
    </dependency>

    <!-- Mockito Core -->
    <dependency>
        <groupId>org.mockito</groupId>
        <artifactId>mockito-core</artifactId>
        <version>5.14.2</version>
        <scope>test</scope>
    </dependency>

    <!-- Mockito integration for JUnit 5 -->
    <dependency>
        <groupId>org.mockito</groupId>
        <artifactId>mockito-junit-jupiter</artifactId>
        <version>5.14.2</version>
        <scope>test</scope>
    </dependency>
</dependencies>

Make sure your Maven Surefire plugin supports JUnit 5:

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

Gradle

dependencies {
    testImplementation 'org.junit.jupiter:junit-jupiter:5.11.4'
    testImplementation 'org.mockito:mockito-core:5.14.2'
    testImplementation 'org.mockito:mockito-junit-jupiter:5.14.2'
}

test {
    useJUnitPlatform()
}

2. Example Class to Test

Suppose you have a service that depends on a repository.

public class User {
    private final Long id;
    private final String name;

    public User(Long id, String name) {
        this.id = id;
        this.name = name;
    }

    public Long getId() {
        return id;
    }

    public String getName() {
        return name;
    }
}
import java.util.Optional;

public interface UserRepository {
    Optional<User> findById(Long id);
}
public class UserService {
    private final UserRepository userRepository;

    public UserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    public String getUserName(Long id) {
        return userRepository.findById(id)
                .map(User::getName)
                .orElse("Unknown User");
    }
}

3. Write a Mockito Test with JUnit 5

Use @ExtendWith(MockitoExtension.class) to enable Mockito support.

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 java.util.Optional;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

@ExtendWith(MockitoExtension.class)
class UserServiceTest {

    @Mock
    private UserRepository userRepository;

    @InjectMocks
    private UserService userService;

    @Test
    void shouldReturnUserNameWhenUserExists() {
        User user = new User(1L, "Alice");

        when(userRepository.findById(1L))
                .thenReturn(Optional.of(user));

        String result = userService.getUserName(1L);

        assertEquals("Alice", result);
        verify(userRepository).findById(1L);
    }

    @Test
    void shouldReturnUnknownUserWhenUserDoesNotExist() {
        when(userRepository.findById(99L))
                .thenReturn(Optional.empty());

        String result = userService.getUserName(99L);

        assertEquals("Unknown User", result);
        verify(userRepository).findById(99L);
    }
}

4. What the Mockito Annotations Mean

Annotation Purpose
@Mock Creates a mock object
@InjectMocks Creates the class under test and injects mocks into it
@ExtendWith(MockitoExtension.class) Enables Mockito support in JUnit 5

5. Common Mockito Methods

when(...).thenReturn(...)

Used to define mock behavior.

when(userRepository.findById(1L))
        .thenReturn(Optional.of(new User(1L, "Alice")));

verify(...)

Used to check whether a method was called.

verify(userRepository).findById(1L);

verify(..., times(...))

Used to check how many times a method was called.

verify(userRepository, times(1)).findById(1L);

You need this static import:

import static org.mockito.Mockito.times;

6. Mockito Without Annotations

You can also create mocks manually.

import org.junit.jupiter.api.Test;

import java.util.Optional;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

class UserServiceManualMockTest {

    @Test
    void shouldReturnUserName() {
        UserRepository userRepository = mock(UserRepository.class);
        UserService userService = new UserService(userRepository);

        when(userRepository.findById(1L))
                .thenReturn(Optional.of(new User(1L, "Alice")));

        String result = userService.getUserName(1L);

        assertEquals("Alice", result);
    }
}

7. Typical Mockito Test Structure

A clean Mockito test usually follows the Arrange, Act, Assert pattern:

@Test
void shouldReturnUserName() {
    // Arrange
    User user = new User(1L, "Alice");
    when(userRepository.findById(1L)).thenReturn(Optional.of(user));

    // Act
    String result = userService.getUserName(1L);

    // Assert
    assertEquals("Alice", result);
    verify(userRepository).findById(1L);
}

Summary

To use Mockito with JUnit 5:

  1. Add mockito-core and mockito-junit-jupiter.
  2. Add @ExtendWith(MockitoExtension.class) to your test class.
  3. Use @Mock for dependencies.
  4. Use @InjectMocks for the class being tested.
  5. Use when(...).thenReturn(...) to define behavior.
  6. Use verify(...) to check interactions.

How do I write tests for Java records?

Java records are compact classes designed to hold immutable data. Because records automatically provide a constructor, accessor methods, equals(), hashCode(), and toString(), testing them is usually simpler than testing ordinary classes.

In most cases, you do not need to test Java’s generated record behavior directly. Instead, test:

  • custom validation in the compact constructor
  • custom methods you add to the record
  • behavior that depends on equality or immutability
  • serialization/deserialization if the record is used with JSON or persistence frameworks

Example Record

Suppose you have this Java record:

public record User(String username, String email, int age) {

    public User {
        if (username == null || username.isBlank()) {
            throw new IllegalArgumentException("Username must not be blank");
        }

        if (email == null || !email.contains("@")) {
            throw new IllegalArgumentException("Email must be valid");
        }

        if (age < 0) {
            throw new IllegalArgumentException("Age must not be negative");
        }
    }

    public boolean isAdult() {
        return age >= 18;
    }
}

This record has:

  • three components: username, email, and age
  • validation in the compact constructor
  • a custom method named isAdult()

Basic JUnit 5 Test Class

Here is a simple JUnit 5 test class:

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.*;

class UserTest {

    @Test
    void shouldCreateUserWithValidData() {
        User user = new User("alice", "[email protected]", 25);

        assertEquals("alice", user.username());
        assertEquals("[email protected]", user.email());
        assertEquals(25, user.age());
    }

    @Test
    void shouldReturnTrueWhenUserIsAdult() {
        User user = new User("bob", "[email protected]", 20);

        assertTrue(user.isAdult());
    }

    @Test
    void shouldReturnFalseWhenUserIsNotAdult() {
        User user = new User("charlie", "[email protected]", 15);

        assertFalse(user.isAdult());
    }

    @Test
    void shouldRejectBlankUsername() {
        IllegalArgumentException exception = assertThrows(
                IllegalArgumentException.class,
                () -> new User("", "[email protected]", 25)
        );

        assertEquals("Username must not be blank", exception.getMessage());
    }

    @Test
    void shouldRejectInvalidEmail() {
        IllegalArgumentException exception = assertThrows(
                IllegalArgumentException.class,
                () -> new User("alice", "invalid-email", 25)
        );

        assertEquals("Email must be valid", exception.getMessage());
    }

    @Test
    void shouldRejectNegativeAge() {
        IllegalArgumentException exception = assertThrows(
                IllegalArgumentException.class,
                () -> new User("alice", "[email protected]", -1)
        );

        assertEquals("Age must not be negative", exception.getMessage());
    }
}

Testing Generated Accessor Methods

Record accessors use the component name directly. For example, if your record is:

public record Product(String name, double price) {
}

The accessors are:

product.name();
product.price();

not:

product.getName();
product.getPrice();

A basic test looks like this:

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertEquals;

class ProductTest {

    @Test
    void shouldExposeRecordComponents() {
        Product product = new Product("Keyboard", 49.99);

        assertEquals("Keyboard", product.name());
        assertEquals(49.99, product.price());
    }
}

However, for plain records with no validation or custom behavior, these tests often provide little value because they only verify Java-generated code.


Testing equals() and hashCode()

Records automatically generate equals() and hashCode() based on all record components.

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;

class ProductTest {

    @Test
    void shouldCompareRecordsByComponentValues() {
        Product first = new Product("Keyboard", 49.99);
        Product second = new Product("Keyboard", 49.99);
        Product third = new Product("Mouse", 19.99);

        assertEquals(first, second);
        assertEquals(first.hashCode(), second.hashCode());
        assertNotEquals(first, third);
    }
}

Again, you usually do not need this test unless your application depends heavily on equality behavior, such as using records as keys in a Map or elements in a Set.


Testing toString()

Records also generate a readable toString() method:

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertEquals;

class ProductTest {

    @Test
    void shouldGenerateReadableToString() {
        Product product = new Product("Keyboard", 49.99);

        assertEquals("Product[name=Keyboard, price=49.99]", product.toString());
    }
}

Be careful with this kind of test. It can be brittle because it depends on the exact string format.

Test toString() mainly when:

  • you override it
  • logs or messages depend on its output
  • the string representation is part of your expected behavior

Testing Constructor Validation

Records are commonly used with compact constructors for validation.

public record EmailAddress(String value) {

    public EmailAddress {
        if (value == null || value.isBlank()) {
            throw new IllegalArgumentException("Email must not be blank");
        }

        if (!value.contains("@")) {
            throw new IllegalArgumentException("Email must contain @");
        }
    }
}

Test both valid and invalid cases:

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.*;

class EmailAddressTest {

    @Test
    void shouldCreateEmailAddressWhenValueIsValid() {
        EmailAddress email = new EmailAddress("[email protected]");

        assertEquals("[email protected]", email.value());
    }

    @Test
    void shouldRejectNullEmail() {
        IllegalArgumentException exception = assertThrows(
                IllegalArgumentException.class,
                () -> new EmailAddress(null)
        );

        assertEquals("Email must not be blank", exception.getMessage());
    }

    @Test
    void shouldRejectBlankEmail() {
        IllegalArgumentException exception = assertThrows(
                IllegalArgumentException.class,
                () -> new EmailAddress(" ")
        );

        assertEquals("Email must not be blank", exception.getMessage());
    }

    @Test
    void shouldRejectEmailWithoutAtSign() {
        IllegalArgumentException exception = assertThrows(
                IllegalArgumentException.class,
                () -> new EmailAddress("invalid-email")
        );

        assertEquals("Email must contain @", exception.getMessage());
    }
}

Using Parameterized Tests for Records

Parameterized tests are useful when a record has multiple invalid input values.

import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;

import static org.junit.jupiter.api.Assertions.assertThrows;

class EmailAddressTest {

    @ParameterizedTest
    @ValueSource(strings = {"", " ", "invalid-email", "user.example.com"})
    void shouldRejectInvalidEmailValues(String value) {
        assertThrows(
                IllegalArgumentException.class,
                () -> new EmailAddress(value)
        );
    }
}

For more complex data, use @CsvSource:

import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;

import static org.junit.jupiter.api.Assertions.assertThrows;

class UserTest {

    @ParameterizedTest
    @CsvSource({
            "'', [email protected], 25",
            "' ', [email protected], 25",
            "alice, invalid-email, 25",
            "alice, [email protected], -1"
    })
    void shouldRejectInvalidUserData(String username, String email, int age) {
        assertThrows(
                IllegalArgumentException.class,
                () -> new User(username, email, age)
        );
    }
}

Testing Custom Methods in Records

If your record contains business logic, test that logic directly.

public record Money(String currency, int amount) {

    public boolean isPositive() {
        return amount > 0;
    }

    public Money add(Money other) {
        if (!currency.equals(other.currency())) {
            throw new IllegalArgumentException("Currencies must match");
        }

        return new Money(currency, amount + other.amount());
    }
}

Tests:

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.*;

class MoneyTest {

    @Test
    void shouldReturnTrueForPositiveAmount() {
        Money money = new Money("USD", 100);

        assertTrue(money.isPositive());
    }

    @Test
    void shouldAddMoneyWithSameCurrency() {
        Money first = new Money("USD", 100);
        Money second = new Money("USD", 50);

        Money result = first.add(second);

        assertEquals(new Money("USD", 150), result);
    }

    @Test
    void shouldRejectAddingDifferentCurrencies() {
        Money first = new Money("USD", 100);
        Money second = new Money("EUR", 50);

        IllegalArgumentException exception = assertThrows(
                IllegalArgumentException.class,
                () -> first.add(second)
        );

        assertEquals("Currencies must match", exception.getMessage());
    }
}

Testing Immutability

Records are shallowly immutable. This means record components cannot be reassigned, but if a component refers to a mutable object, that object can still be changed.

Example:

import java.util.List;

public record Order(List<String> items) {
}

This record is not deeply immutable:

import java.util.ArrayList;
import java.util.List;

Order order = new Order(new ArrayList<>(List.of("Book")));
order.items().add("Pen");

To make it safer, copy the list:

import java.util.List;

public record Order(List<String> items) {

    public Order {
        items = List.copyOf(items);
    }
}

Then test it:

import org.junit.jupiter.api.Test;

import java.util.ArrayList;
import java.util.List;

import static org.junit.jupiter.api.Assertions.*;

class OrderTest {

    @Test
    void shouldDefensivelyCopyItems() {
        List<String> items = new ArrayList<>();
        items.add("Book");

        Order order = new Order(items);

        items.add("Pen");

        assertEquals(List.of("Book"), order.items());
    }

    @Test
    void shouldExposeUnmodifiableItems() {
        Order order = new Order(new ArrayList<>(List.of("Book")));

        assertThrows(
                UnsupportedOperationException.class,
                () -> order.items().add("Pen")
        );
    }
}

This is a valuable test because it verifies your own defensive-copying behavior, not just Java-generated record behavior.


What Should You Actually Test?

For Java records, focus your tests on behavior you wrote yourself.

Good things to test:

Feature Should You Test It? Why
Accessor methods Usually no Generated by Java
equals() / hashCode() Sometimes Useful if equality is important in your domain
toString() Rarely Usually generated and brittle to assert
Compact constructor validation Yes This is your logic
Custom methods Yes This is your logic
Defensive copying Yes Important for immutability
Serialization/deserialization Yes, if used Important for APIs and persistence

Recommended Testing Style

Use clear test names:

@Test
void shouldRejectNegativeAge() {
    // test body
}

Follow the Arrange-Act-Assert pattern:

@Test
void shouldCreateUserWithValidData() {
    // Arrange
    String username = "alice";
    String email = "[email protected]";
    int age = 25;

    // Act
    User user = new User(username, email, age);

    // Assert
    assertEquals(username, user.username());
    assertEquals(email, user.email());
    assertEquals(age, user.age());
}

Summary

To test Java records:

  1. Do not over-test generated code.
  2. Test constructor validation.
  3. Test custom methods.
  4. Test defensive copying for mutable components.
  5. Test JSON or persistence integration only when records are used that way.
  6. Use JUnit 5 assertions such as assertEquals(), assertTrue(), assertFalse(), and assertThrows().

A plain record like this usually needs no dedicated unit test:

public record Point(int x, int y) {
}

But a record like this should be tested:

public record Age(int value) {

    public Age {
        if (value < 0) {
            throw new IllegalArgumentException("Age must not be negative");
        }
    }

    public boolean isAdult() {
        return value >= 18;
    }
}

Because it contains behavior that belongs to your application, not just Java’s generated record features.

How do I write tests for utility classes?

Utility classes are usually tested like any other unit: test their public behavior, especially calculations, transformations, validation rules, edge cases, and error handling.

The main difference is that utility classes often have static methods, so your tests usually call the method directly instead of creating an object.


1. Test Useful Behavior, Not the Fact That It Is a Utility Class

A utility class is worth testing when it contains logic such as:

  • formatting
  • parsing
  • validation
  • calculations
  • normalization
  • mapping
  • filtering
  • sorting
  • date/time handling
  • null/empty handling
  • error handling

Example utility class:

public final class StringUtils {

    private StringUtils() {
    }

    public static String normalizeEmail(String email) {
        if (email == null || email.isBlank()) {
            throw new IllegalArgumentException("Email must not be blank");
        }

        return email.trim().toLowerCase();
    }
}

A good test focuses on the behavior:

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;

class StringUtilsTest {

    @Test
    void normalizeEmailTrimsAndLowercasesEmail() {
        String result = StringUtils.normalizeEmail("  [email protected]  ");

        assertEquals("[email protected]", result);
    }

    @Test
    void normalizeEmailThrowsExceptionWhenEmailIsNull() {
        IllegalArgumentException exception = assertThrows(
                IllegalArgumentException.class,
                () -> StringUtils.normalizeEmail(null)
        );

        assertEquals("Email must not be blank", exception.getMessage());
    }

    @Test
    void normalizeEmailThrowsExceptionWhenEmailIsBlank() {
        IllegalArgumentException exception = assertThrows(
                IllegalArgumentException.class,
                () -> StringUtils.normalizeEmail("   ")
        );

        assertEquals("Email must not be blank", exception.getMessage());
    }
}

2. Static Utility Methods Are Fine to Test Directly

If the method is static, call it directly:

@Test
void calculatePercentageReturnsExpectedValue() {
    int result = MathUtils.percentageOf(25, 200);

    assertEquals(50, result);
}

You do not need Mockito or Spring for this kind of test.

Avoid loading the Spring context just to test a utility class:

@SpringBootTest
class MathUtilsTest {
    // Usually unnecessary for a utility class
}

Prefer a plain JUnit test:

class MathUtilsTest {
    // Fast unit tests
}

3. Cover Normal Cases, Edge Cases, and Failure Cases

For utility classes, edge cases are often the most valuable tests.

For example, for a number utility:

public final class NumberUtils {

    private NumberUtils() {
    }

    public static boolean isEven(int number) {
        return number % 2 == 0;
    }
}

Tests:

import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;

import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;

class NumberUtilsTest {

    @ParameterizedTest
    @ValueSource(ints = {2, 4, 0, -6})
    void isEvenReturnsTrueForEvenNumbers(int number) {
        assertTrue(NumberUtils.isEven(number));
    }

    @ParameterizedTest
    @ValueSource(ints = {1, 3, -5})
    void isEvenReturnsFalseForOddNumbers(int number) {
        assertFalse(NumberUtils.isEven(number));
    }
}

Good things to test:

  • normal valid input
  • zero
  • negative numbers
  • empty strings or collections
  • null, if allowed or explicitly rejected
  • boundary values
  • invalid input
  • rounding behavior
  • duplicate values
  • case sensitivity
  • timezone/date boundaries

4. Use Parameterized Tests for Repeated Input/Output Cases

Utility methods often have many input/output examples. Parameterized tests keep them clean.

Example:

import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;

import static org.junit.jupiter.api.Assertions.assertEquals;

class SlugUtilsTest {

    @ParameterizedTest
    @CsvSource({
            "'Hello World', 'hello-world'",
            "' Java  Testing ', 'java-testing'",
            "'Spring Boot', 'spring-boot'",
            "'already-clean', 'already-clean'"
    })
    void toSlugReturnsNormalizedSlug(String input, String expected) {
        String result = SlugUtils.toSlug(input);

        assertEquals(expected, result);
    }
}

This is better than writing four nearly identical tests.


5. Test Exceptions Clearly

If the utility method rejects invalid input, test that explicitly.

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;

class DateUtilsTest {

    @Test
    void parseDateThrowsExceptionWhenDateHasInvalidFormat() {
        IllegalArgumentException exception = assertThrows(
                IllegalArgumentException.class,
                () -> DateUtils.parseIsoDate("07/13/2026")
        );

        assertEquals("Date must use ISO format: yyyy-MM-dd", exception.getMessage());
    }
}

You do not always need to assert the exception message, but it can be useful when the message is part of the behavior you care about.


6. Avoid Testing Trivial Wrappers Around Libraries

This is usually not valuable:

public static String upper(String value) {
    return value.toUpperCase();
}

A test for this mostly tests Java itself:

assertEquals("ABC", StringUtils.upper("abc"));

That is probably not worth it unless your method adds meaningful behavior, such as locale handling, null handling, trimming, or business-specific normalization.

More useful:

public static String normalizeCode(String value) {
    if (value == null || value.isBlank()) {
        throw new IllegalArgumentException("Code must not be blank");
    }

    return value.trim().toUpperCase(Locale.ROOT);
}

This has behavior worth testing.


7. Usually Do Not Test the Private Constructor

Many Java utility classes have a private constructor:

public final class MoneyUtils {

    private MoneyUtils() {
    }

    public static BigDecimal roundToCents(BigDecimal amount) {
        return amount.setScale(2, RoundingMode.HALF_UP);
    }
}

In most cases, do not write a test just to call the private constructor for coverage. That test gives little confidence and only exists to satisfy a coverage number.

Focus on the public methods instead.

If your team enforces 100% coverage, you may see reflection-based tests for private constructors, but they are usually low-value.


8. Keep Utility Tests Framework-Free When Possible

For a simple utility class, your test usually needs only JUnit:

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertEquals;

class MoneyUtilsTest {

    @Test
    void roundToCentsRoundsHalfUp() {
        BigDecimal result = MoneyUtils.roundToCents(new BigDecimal("10.235"));

        assertEquals(new BigDecimal("10.24"), result);
    }
}

Avoid unnecessary:

  • @SpringBootTest
  • database setup
  • mocks
  • dependency injection
  • web layer testing tools

A utility test should usually be fast and isolated.


9. Test Static Methods by Result, Not Implementation

Avoid tests that depend on how the utility method works internally.

Prefer:

@Test
void maskEmailHidesLocalPartExceptFirstCharacter() {
    String result = MaskingUtils.maskEmail("[email protected]");

    assertEquals("a****@example.com", result);
}

Avoid trying to verify internal helper calls or private methods. If you refactor the internals, the test should still pass as long as the behavior stays the same.


10. Example: Testing a Calculation Utility

Production code:

import java.math.BigDecimal;
import java.math.RoundingMode;

public final class TaxUtils {

    private TaxUtils() {
    }

    public static BigDecimal calculateTax(BigDecimal amount, BigDecimal taxRate) {
        if (amount == null) {
            throw new IllegalArgumentException("Amount must not be null");
        }

        if (taxRate == null) {
            throw new IllegalArgumentException("Tax rate must not be null");
        }

        if (amount.signum() < 0) {
            throw new IllegalArgumentException("Amount must not be negative");
        }

        return amount.multiply(taxRate)
                .setScale(2, RoundingMode.HALF_UP);
    }
}

Test code:

import org.junit.jupiter.api.Test;

import java.math.BigDecimal;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;

class TaxUtilsTest {

    @Test
    void calculateTaxReturnsRoundedTaxAmount() {
        BigDecimal result = TaxUtils.calculateTax(
                new BigDecimal("19.99"),
                new BigDecimal("0.0825")
        );

        assertEquals(new BigDecimal("1.65"), result);
    }

    @Test
    void calculateTaxReturnsZeroWhenAmountIsZero() {
        BigDecimal result = TaxUtils.calculateTax(
                BigDecimal.ZERO,
                new BigDecimal("0.0825")
        );

        assertEquals(new BigDecimal("0.00"), result);
    }

    @Test
    void calculateTaxThrowsExceptionWhenAmountIsNull() {
        IllegalArgumentException exception = assertThrows(
                IllegalArgumentException.class,
                () -> TaxUtils.calculateTax(null, new BigDecimal("0.0825"))
        );

        assertEquals("Amount must not be null", exception.getMessage());
    }

    @Test
    void calculateTaxThrowsExceptionWhenTaxRateIsNull() {
        IllegalArgumentException exception = assertThrows(
                IllegalArgumentException.class,
                () -> TaxUtils.calculateTax(new BigDecimal("19.99"), null)
        );

        assertEquals("Tax rate must not be null", exception.getMessage());
    }

    @Test
    void calculateTaxThrowsExceptionWhenAmountIsNegative() {
        IllegalArgumentException exception = assertThrows(
                IllegalArgumentException.class,
                () -> TaxUtils.calculateTax(
                        new BigDecimal("-1.00"),
                        new BigDecimal("0.0825")
                )
        );

        assertEquals("Amount must not be negative", exception.getMessage());
    }
}

Quick Checklist

When testing utility classes, ask:

  • Does the method contain real logic?
  • What is the normal expected output?
  • What are the edge cases?
  • What invalid inputs should fail?
  • Should null be allowed or rejected?
  • Are there rounding, formatting, locale, or timezone concerns?
  • Can similar cases be written as a parameterized test?
  • Am I testing my behavior, not Java/Spring/a library?
  • Can this test run without Spring, a database, or mocks?

In short: test utility classes through their public methods, keep the tests small and fast, and focus on behavior that could realistically break.

How do I structure unit tests in a real Java project?

In a real Java project, unit tests should be organized so they are easy to find, run, understand, and maintain. A good structure follows the same shape as your production code and keeps tests focused on behavior.

Most Java projects use this standard layout:

project-root
├── src
│   ├── main
│   │   └── java
│   │       └── com
│   │           └── example
│   │               ├── service
│   │               │   └── PriceCalculator.java
│   │               ├── repository
│   │               │   └── ProductRepository.java
│   │               └── controller
│   │                   └── ProductController.java
│   │
│   └── test
│       └── java
│           └── com
│               └── example
│                   ├── service
│                   │   └── PriceCalculatorTest.java
│                   ├── repository
│                   │   └── ProductRepositoryTest.java
│                   └── controller
│                       └── ProductControllerTest.java

The key idea is simple:

Put test classes under src/test/java, using the same package structure as the class being tested.


1. Mirror the Production Package Structure

If your production class is here:

src/main/java/com/example/service/DiscountService.java

Then the test class usually goes here:

src/test/java/com/example/service/DiscountServiceTest.java

Example:

package com.example.service;

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertEquals;

class DiscountServiceTest {

    @Test
    void appliesTenPercentDiscount() {
        DiscountService service = new DiscountService();

        double result = service.applyDiscount(100.00, 0.10);

        assertEquals(90.00, result, 0.001);
    }
}

This makes tests easy to locate. Developers can quickly jump from DiscountService to DiscountServiceTest.


2. Use Clear Test Class Names

A common naming convention is:

Production class: UserService
Test class:       UserServiceTest

Examples:

OrderService.java          -> OrderServiceTest.java
InvoiceCalculator.java     -> InvoiceCalculatorTest.java
EmailValidator.java        -> EmailValidatorTest.java
UserRegistrationService.java -> UserRegistrationServiceTest.java

For integration tests, many teams use names like:

UserRepositoryIntegrationTest
UserControllerWebMvcTest
OrderServiceIT

The exact naming style is less important than being consistent.


3. Keep Unit Tests Separate from Integration Tests

A real project often has different kinds of tests.

Unit tests

Unit tests check a small piece of code in isolation.

They should usually be:

  • Fast
  • Independent
  • Focused on one class or behavior
  • Free from real databases, servers, or network calls

Example:

class PriceCalculatorTest {

    private final PriceCalculator calculator = new PriceCalculator();

    @Test
    void calculatesTotalWithTax() {
        Money result = calculator.totalWithTax(new Money("100.00"), new BigDecimal("0.10"));

        assertEquals(new Money("110.00"), result);
    }
}

Integration tests

Integration tests check whether several parts work together.

They may involve:

  • Spring application context
  • Database
  • JPA mappings
  • Web layer
  • External configuration
  • Test containers
  • File system
  • Messaging systems

Example naming:

UserRepositoryIntegrationTest
PaymentControllerIntegrationTest
ApplicationStartupTest

A common structure is:

src/test/java
└── com/example
    ├── service
    │   └── UserServiceTest.java
    ├── repository
    │   └── UserRepositoryIntegrationTest.java
    └── controller
        └── UserControllerWebMvcTest.java

Some projects also separate them by source set:

src/test/java
src/integrationTest/java

That is useful in larger builds, but not necessary for every project.


4. Structure Each Test Class Consistently

A clean test class usually follows this order:

class SomeServiceTest {

    fields / mocks

    setup methods

    test methods

    helper methods
}

Example:

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertEquals;

class ShippingCostCalculatorTest {

    private ShippingCostCalculator calculator;

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

    @Test
    void returnsFreeShippingWhenOrderTotalIsOverOneHundred() {
        Order order = orderWithTotal(new BigDecimal("120.00"));

        ShippingCost result = calculator.calculate(order);

        assertEquals(ShippingCost.free(), result);
    }

    @Test
    void returnsStandardShippingWhenOrderTotalIsBelowOneHundred() {
        Order order = orderWithTotal(new BigDecimal("80.00"));

        ShippingCost result = calculator.calculate(order);

        assertEquals(ShippingCost.standard(), result);
    }

    private Order orderWithTotal(BigDecimal total) {
        return new Order(total);
    }
}

Helper methods should usually go near the bottom, so the actual test cases remain easy to scan.


5. Follow the Arrange-Act-Assert Pattern

Most unit tests should clearly show three steps:

Arrange - prepare objects and input
Act     - call the method being tested
Assert  - verify the result

Example:

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertEquals;

class TaxCalculatorTest {

    @Test
    void calculatesTenPercentTax() {
        // Arrange
        TaxCalculator calculator = new TaxCalculator();
        BigDecimal amount = new BigDecimal("100.00");

        // Act
        BigDecimal tax = calculator.calculateTax(amount, new BigDecimal("0.10"));

        // Assert
        assertEquals(new BigDecimal("10.00"), tax);
    }
}

You do not always need the comments, but the structure should be obvious.


6. Group Related Tests with Nested Classes

When a test class grows, use JUnit 5 @Nested classes to group tests by method or scenario.

Example:

import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;

class UserRegistrationServiceTest {

    @Nested
    class Register {

        @Test
        void createsUserWhenInputIsValid() {
            // test valid registration
        }

        @Test
        void throwsExceptionWhenEmailIsInvalid() {
            // test invalid email
        }

        @Test
        void throwsExceptionWhenPasswordIsWeak() {
            // test weak password
        }
    }

    @Nested
    class ChangePassword {

        @Test
        void changesPasswordWhenCurrentPasswordMatches() {
            // test successful password change
        }

        @Test
        void throwsExceptionWhenCurrentPasswordDoesNotMatch() {
            // test failed password change
        }
    }
}

This is useful when a class has many behaviors.

Good grouping styles include:

UserServiceTest
├── Register
├── UpdateProfile
└── DeactivateAccount

Or:

CheckoutServiceTest
├── WhenCartIsEmpty
├── WhenCartHasItems
└── WhenPaymentFails

7. Use One Test Class per Important Production Class

A simple rule is:

One production class -> one main test class

Example:

src/main/java/com/example/order/OrderService.java
src/test/java/com/example/order/OrderServiceTest.java

But do not be too rigid. If one class has many behaviors, it can be reasonable to split tests by feature:

OrderServiceCreateOrderTest.java
OrderServiceCancelOrderTest.java
OrderServiceRefundTest.java

This can be helpful when a single test class becomes too large.


8. Use Descriptive Test Method Names

Avoid vague names:

@Test
void testCalculate() {
}

Prefer behavior-focused names:

@Test
void calculatesTotalPriceIncludingTax() {
}
@Test
void throwsExceptionWhenQuantityIsNegative() {
}
@Test
void returnsEmptyListWhenCustomerHasNoOrders() {
}

A good test name should explain the expected behavior without needing to read the whole test body.


9. Keep Test Data Readable

Real projects often have noisy object creation. Avoid making every test hard to read with long constructors or repeated setup.

Instead of this:

@Test
void calculatesOrderTotal() {
    Order order = new Order(
            new Customer("Alice", "[email protected]", true),
            List.of(
                    new OrderItem("BOOK-001", "Book", new BigDecimal("19.99"), 2),
                    new OrderItem("PEN-001", "Pen", new BigDecimal("2.50"), 1)
            ),
            "USD"
    );

    BigDecimal total = order.calculateTotal();

    assertEquals(new BigDecimal("42.48"), total);
}

You can use helper methods:

@Test
void calculatesOrderTotal() {
    Order order = orderWithItems(
            item("Book", "19.99", 2),
            item("Pen", "2.50", 1)
    );

    BigDecimal total = order.calculateTotal();

    assertEquals(new BigDecimal("42.48"), total);
}

private Order orderWithItems(OrderItem... items) {
    return new Order(List.of(items));
}

private OrderItem item(String name, String price, int quantity) {
    return new OrderItem(name, new BigDecimal(price), quantity);
}

In larger projects, teams sometimes use:

  • Test data builders
  • Object mothers
  • Fixture factory methods
  • Factory classes under src/test/java

Example:

src/test/java/com/example/testsupport
├── CustomerTestBuilder.java
├── OrderTestBuilder.java
└── ProductFixtures.java

Use these only when they make tests clearer.


10. Put Reusable Test Utilities in a Test Support Package

If multiple test classes need the same helper, put it in a shared test package.

Example:

src/test/java/com/example/testsupport
├── TestUsers.java
├── TestOrders.java
├── InMemoryUserRepository.java
└── FixedClockConfig.java

Example utility:

package com.example.testsupport;

public final class TestUsers {

    private TestUsers() {
    }

    public static User activeUser() {
        return new User("[email protected]", true);
    }

    public static User inactiveUser() {
        return new User("[email protected]", false);
    }
}

Then in a test:

import static com.example.testsupport.TestUsers.activeUser;

class UserPolicyTest {

    @Test
    void allowsLoginForActiveUser() {
        User user = activeUser();

        boolean allowed = user.canLogin();

        assertTrue(allowed);
    }
}

Keep shared helpers simple. If helpers become too clever, they can hide important details and make tests harder to understand.


11. Mock Dependencies at the Boundary

For a service class, it is common to mock repositories, gateways, clients, and message senders.

Example with Mockito:

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

class UserRegistrationServiceTest {

    private UserRepository userRepository;
    private PasswordEncoder passwordEncoder;
    private UserRegistrationService service;

    @BeforeEach
    void setUp() {
        userRepository = mock(UserRepository.class);
        passwordEncoder = mock(PasswordEncoder.class);
        service = new UserRegistrationService(userRepository, passwordEncoder);
    }

    @Test
    void savesUserWithEncodedPassword() {
        when(passwordEncoder.encode("plain-password")).thenReturn("encoded-password");

        service.register("[email protected]", "plain-password");

        verify(userRepository).save(new User("[email protected]", "encoded-password"));
    }
}

Mock things that are slow, external, or outside the unit:

  • Repositories
  • HTTP clients
  • Email senders
  • Payment gateways
  • Message brokers
  • File storage clients
  • Clocks, when time matters

Avoid mocking simple value objects or the class being tested.


12. Do Not Unit Test Framework Wiring

In a Spring, Jakarta EE, or JPA project, avoid unit tests that only verify annotations or dependency injection.

Usually not useful as a unit test:

@Service
@RequiredArgsConstructor
public class UserService {

    private final UserRepository userRepository;
}

You do not need a unit test just to prove Spring can inject UserRepository.

Better unit test target:

public User register(String email, String password) {
    if (!emailValidator.isValid(email)) {
        throw new InvalidEmailException(email);
    }

    if (password.length() < 12) {
        throw new WeakPasswordException();
    }

    return userRepository.save(new User(email, passwordEncoder.encode(password)));
}

That has behavior worth testing.


13. Separate Tests by Layer

In a typical Java application, you might structure tests by layer.

Domain tests

Test business rules in domain objects.

src/test/java/com/example/domain/OrderTest.java
src/test/java/com/example/domain/InvoiceTest.java

These should usually use real objects and no mocks.

Service tests

Test application logic and interactions with dependencies.

src/test/java/com/example/service/UserServiceTest.java
src/test/java/com/example/service/CheckoutServiceTest.java

These often use Mockito for repositories or external services.

Repository tests

For Spring Data JPA repositories, simple derived methods often do not need unit tests. For custom queries, use integration tests.

src/test/java/com/example/repository/UserRepositoryIntegrationTest.java

Controller tests

For Spring MVC controllers, use web-layer tests rather than plain unit tests when request mapping, validation, and JSON matter.

src/test/java/com/example/controller/UserControllerWebMvcTest.java

14. Example Real Project Test Structure

Here is a realistic structure for a small Spring-style Java application:

src
├── main
│   └── java
│       └── com/example/shop
│           ├── ShopApplication.java
│           ├── controller
│           │   └── OrderController.java
│           ├── domain
│           │   ├── Order.java
│           │   └── OrderItem.java
│           ├── repository
│           │   └── OrderRepository.java
│           └── service
│               ├── CheckoutService.java
│               └── ShippingCostCalculator.java
│
└── test
    └── java
        └── com/example/shop
            ├── controller
            │   └── OrderControllerWebMvcTest.java
            ├── domain
            │   └── OrderTest.java
            ├── repository
            │   └── OrderRepositoryIntegrationTest.java
            ├── service
            │   ├── CheckoutServiceTest.java
            │   └── ShippingCostCalculatorTest.java
            └── testsupport
                ├── OrderFixtures.java
                └── CustomerFixtures.java

This makes it clear which tests are unit tests and which are heavier tests.


15. Example of a Well-Structured Unit Test

package com.example.shop.service;

import com.example.shop.domain.Order;
import com.example.shop.domain.ShippingCost;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;

import java.math.BigDecimal;

import static org.junit.jupiter.api.Assertions.assertEquals;

class ShippingCostCalculatorTest {

    private ShippingCostCalculator calculator;

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

    @Nested
    class Calculate {

        @Test
        void returnsFreeShippingWhenOrderTotalIsAtLeastOneHundred() {
            Order order = orderWithTotal("100.00");

            ShippingCost result = calculator.calculate(order);

            assertEquals(ShippingCost.free(), result);
        }

        @Test
        void returnsStandardShippingWhenOrderTotalIsBelowOneHundred() {
            Order order = orderWithTotal("99.99");

            ShippingCost result = calculator.calculate(order);

            assertEquals(ShippingCost.standard(), result);
        }

        @Test
        void returnsExpressShippingForPriorityOrder() {
            Order order = priorityOrderWithTotal("50.00");

            ShippingCost result = calculator.calculate(order);

            assertEquals(ShippingCost.express(), result);
        }
    }

    private Order orderWithTotal(String total) {
        return new Order(new BigDecimal(total), false);
    }

    private Order priorityOrderWithTotal(String total) {
        return new Order(new BigDecimal(total), true);
    }
}

This test is structured well because:

  • The class name matches the production class.
  • Setup is simple.
  • Tests are grouped by behavior.
  • Test names describe expected behavior.
  • Each test checks one scenario.
  • Helper methods make test data readable.

16. What Should Go in src/test/resources?

Use src/test/resources for files needed only by tests.

Examples:

src/test/resources
├── application-test.properties
├── sample-order.json
├── test-data
│   └── products.csv
└── sql
    └── init-test-data.sql

Use it for:

  • Sample JSON
  • CSV test data
  • SQL scripts
  • Test-specific configuration
  • Expected output files

Avoid using large, unclear fixture files when a small object in the test would be easier to understand.


17. Maven and Gradle Defaults

Most Java build tools already expect this structure.

Maven

src/main/java
src/main/resources
src/test/java
src/test/resources

Run tests with:

mvn test

Gradle

src/main/java
src/main/resources
src/test/java
src/test/resources

Run tests with:

./gradlew test

18. Practical Rules for Real Projects

Use these rules as a starting point:

  1. Mirror production packages under src/test/java.
  2. Name test classes after the class being tested, such as PaymentServiceTest.
  3. Keep unit tests fast and avoid real databases or network calls.
  4. Use integration tests for framework/database behavior.
  5. Follow Arrange-Act-Assert.
  6. Use descriptive test names.
  7. Put common test builders or fixtures in a testsupport package.
  8. Do not overuse shared helpers.
  9. Use mocks for boundaries, not for simple value objects.
  10. Group large test classes with @Nested.
  11. Keep tests independent and order-free.
  12. Test behavior, not private implementation details.

Quick Checklist

Before committing a unit test, ask:

  • Is the test under the correct package in src/test/java?
  • Does the test class name match the production class?
  • Does the test name describe behavior?
  • Is the test independent?
  • Is the test fast?
  • Does it follow Arrange-Act-Assert?
  • Does it avoid unnecessary framework setup?
  • Does it test one clear behavior?
  • Are mocks used only where they add value?
  • Would this test still pass after a safe internal refactor?

A well-structured test suite should feel like executable documentation for your project’s behavior.