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.

How do I test private logic without testing private methods directly?

The best way to test private logic is to test the observable behavior that depends on it, usually through the class’s public methods.

Private methods are implementation details. If you test them directly, your tests become tightly coupled to how the class is written internally. That makes refactoring harder because changing a private method can break tests even when the public behavior still works correctly.

Short Answer

Do not test private methods directly.

Instead:

Test the public behavior that uses the private logic.

If a private method contains important logic, make sure that logic is covered by tests through the public API.


Example

Suppose you have a class like this:

public class DiscountService {

    public BigDecimal calculateFinalPrice(Customer customer, BigDecimal price) {
        if (isEligibleForDiscount(customer)) {
            return price.multiply(new BigDecimal("0.90"));
        }

        return price;
    }

    private boolean isEligibleForDiscount(Customer customer) {
        return customer.isActive()
                && customer.getOrdersCount() >= 5
                && !customer.isBlacklisted();
    }
}

You do not need to test isEligibleForDiscount() directly.

Instead, test calculateFinalPrice() with different inputs.

import org.junit.jupiter.api.Test;

import java.math.BigDecimal;

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

class DiscountServiceTest {

    private final DiscountService discountService = new DiscountService();

    @Test
    void calculateFinalPriceAppliesDiscountForEligibleCustomer() {
        Customer customer = new Customer(true, 5, false);

        BigDecimal result = discountService.calculateFinalPrice(
                customer,
                new BigDecimal("100.00")
        );

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

    @Test
    void calculateFinalPriceDoesNotApplyDiscountForInactiveCustomer() {
        Customer customer = new Customer(false, 5, false);

        BigDecimal result = discountService.calculateFinalPrice(
                customer,
                new BigDecimal("100.00")
        );

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

    @Test
    void calculateFinalPriceDoesNotApplyDiscountForBlacklistedCustomer() {
        Customer customer = new Customer(true, 5, true);

        BigDecimal result = discountService.calculateFinalPrice(
                customer,
                new BigDecimal("100.00")
        );

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

The private method is still tested indirectly because each test verifies a public result caused by the private logic.


Why Not Test Private Methods Directly?

Testing private methods directly usually causes problems:

Problem Explanation
Tests become fragile Renaming or reorganizing private methods breaks tests unnecessarily.
Refactoring becomes harder You may avoid improving code because tests depend on internals.
Tests focus on implementation Good tests should focus on behavior and outcomes.
Private methods may disappear A private helper may be merged, split, renamed, or replaced.

A good unit test should answer:

Given this input, what result or behavior should the class produce?

Not:

Which private helper method did the class use internally?


What If the Private Method Has Complex Logic?

If a private method is complex enough that you strongly want to test it directly, that is often a design signal.

You have a few better options.


Option 1: Test It Through Public Scenarios

This is usually the best option.

For example, if the private method has three rules, write public tests that trigger each rule.

@Test
void calculateFinalPriceDoesNotApplyDiscountWhenCustomerHasTooFewOrders() {
    Customer customer = new Customer(true, 4, false);

    BigDecimal result = discountService.calculateFinalPrice(
            customer,
            new BigDecimal("100.00")
    );

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

You are still covering the private logic, but your test remains focused on business behavior.


Option 2: Extract the Logic into a Separate Class

If the private logic is significant, reusable, or independently meaningful, extract it into its own class and test that class through its public method.

public class DiscountEligibilityPolicy {

    public boolean isEligible(Customer customer) {
        return customer.isActive()
                && customer.getOrdersCount() >= 5
                && !customer.isBlacklisted();
    }
}

Then your service becomes simpler:

public class DiscountService {

    private final DiscountEligibilityPolicy eligibilityPolicy;

    public DiscountService(DiscountEligibilityPolicy eligibilityPolicy) {
        this.eligibilityPolicy = eligibilityPolicy;
    }

    public BigDecimal calculateFinalPrice(Customer customer, BigDecimal price) {
        if (eligibilityPolicy.isEligible(customer)) {
            return price.multiply(new BigDecimal("0.90"));
        }

        return price;
    }
}

Now you can test the policy directly:

import org.junit.jupiter.api.Test;

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

class DiscountEligibilityPolicyTest {

    private final DiscountEligibilityPolicy policy = new DiscountEligibilityPolicy();

    @Test
    void isEligibleReturnsTrueForActiveCustomerWithEnoughOrders() {
        Customer customer = new Customer(true, 5, false);

        assertTrue(policy.isEligible(customer));
    }

    @Test
    void isEligibleReturnsFalseForBlacklistedCustomer() {
        Customer customer = new Customer(true, 5, true);

        assertFalse(policy.isEligible(customer));
    }
}

This is often the cleanest solution when the private method represents a real business rule.


Option 3: Use Package-Private Carefully

Sometimes you can make a helper method package-private instead of private and test it from a test class in the same package.

boolean isEligibleForDiscount(Customer customer) {
    return customer.isActive()
            && customer.getOrdersCount() >= 5
            && !customer.isBlacklisted();
}

But use this sparingly.

Only do this when:

  • the method represents meaningful behavior,
  • extracting a new class would be excessive,
  • and exposing it within the package does not harm the design.

Do not make methods less private just because testing through the public API is inconvenient.


Avoid Reflection for Private Method Testing

Technically, Java reflection can call private methods, but it is usually a bad idea for unit tests.

Avoid this style:

Method method = DiscountService.class.getDeclaredMethod(
        "isEligibleForDiscount",
        Customer.class
);
method.setAccessible(true);

boolean result = (boolean) method.invoke(discountService, customer);

This works, but it makes the test depend on implementation details. It also becomes noisy, fragile, and harder to maintain.

Use reflection for framework-level tools or special cases, not ordinary unit tests.


A Practical Rule

When you see private logic, ask:

  1. Can I observe its effect through a public method?
    Test through the public method.

  2. Is the private logic complex or independently meaningful?
    Extract it into a separate class and test that class.

  3. Is it just a small helper?
    Do not test it directly. Test the behavior it supports.

  4. Am I tempted to use reflection?
    Usually stop and reconsider the design.


Summary

You test private logic by testing the public behavior that uses it.

Use this guideline:

Situation Recommended approach
Private helper supports public behavior Test through the public method
Private logic has branches or edge cases Cover those cases via public inputs
Private logic is complex business logic Extract it into a separate class
Helper is useful only inside the class Keep it private
You need reflection to test it Usually avoid it

The goal is not to test every method. The goal is to test the behavior your code promises to provide.

How do I test code that depends on time?

Code that depends directly on the current date or time can be difficult to test because the result changes every time the test runs.

For example, code like this is hard to test reliably:

import java.time.LocalDate;

public class SubscriptionService {
    public boolean isExpired(LocalDate expiryDate) {
        return expiryDate.isBefore(LocalDate.now());
    }
}

The problem is LocalDate.now().
Today’s test result may be different from tomorrow’s.

The best solution is to avoid calling the system clock directly inside your business logic. Instead, inject a time source, usually java.time.Clock.


Using Clock in Production Code

Java’s java.time.Clock lets you control what “now” means.

import java.time.Clock;
import java.time.LocalDate;

public class SubscriptionService {
    private final Clock clock;

    public SubscriptionService(Clock clock) {
        this.clock = clock;
    }

    public boolean isExpired(LocalDate expiryDate) {
        LocalDate today = LocalDate.now(clock);
        return expiryDate.isBefore(today);
    }
}

Now the class no longer depends directly on the real system date.
Instead, it depends on a Clock.


Using the Real Clock in Application Code

In normal application code, pass the system clock:

import java.time.Clock;

public class Application {
    public static void main(String[] args) {
        SubscriptionService service =
                new SubscriptionService(Clock.systemDefaultZone());

        // Use the service normally
    }
}

You can also use UTC if your application should not depend on the server’s local timezone:

Clock.systemUTC()

Testing with a Fixed Clock

In tests, use Clock.fixed(...) to make time predictable.

import org.junit.jupiter.api.Test;

import java.time.Clock;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;

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

class SubscriptionServiceTest {

    @Test
    void isExpiredReturnsTrueWhenExpiryDateIsBeforeToday() {
        Clock fixedClock = Clock.fixed(
                Instant.parse("2026-07-13T10:00:00Z"),
                ZoneId.of("UTC")
        );

        SubscriptionService service = new SubscriptionService(fixedClock);

        boolean expired = service.isExpired(LocalDate.of(2026, 7, 12));

        assertTrue(expired);
    }

    @Test
    void isExpiredReturnsFalseWhenExpiryDateIsToday() {
        Clock fixedClock = Clock.fixed(
                Instant.parse("2026-07-13T10:00:00Z"),
                ZoneId.of("UTC")
        );

        SubscriptionService service = new SubscriptionService(fixedClock);

        boolean expired = service.isExpired(LocalDate.of(2026, 7, 13));

        assertFalse(expired);
    }

    @Test
    void isExpiredReturnsFalseWhenExpiryDateIsAfterToday() {
        Clock fixedClock = Clock.fixed(
                Instant.parse("2026-07-13T10:00:00Z"),
                ZoneId.of("UTC")
        );

        SubscriptionService service = new SubscriptionService(fixedClock);

        boolean expired = service.isExpired(LocalDate.of(2026, 7, 14));

        assertFalse(expired);
    }
}

Now the tests are stable because “today” is always 2026-07-13.


Testing Date and Time Logic

The same approach works with LocalDateTime, ZonedDateTime, Instant, and other classes from the java.time package.

Example:

import java.time.Clock;
import java.time.LocalDateTime;

public class GreetingService {
    private final Clock clock;

    public GreetingService(Clock clock) {
        this.clock = clock;
    }

    public String greeting() {
        int hour = LocalDateTime.now(clock).getHour();

        if (hour < 12) {
            return "Good morning";
        }

        if (hour < 18) {
            return "Good afternoon";
        }

        return "Good evening";
    }
}

Test:

import org.junit.jupiter.api.Test;

import java.time.Clock;
import java.time.Instant;
import java.time.ZoneId;

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

class GreetingServiceTest {

    @Test
    void greetingReturnsGoodMorningBeforeNoon() {
        Clock fixedClock = Clock.fixed(
                Instant.parse("2026-07-13T08:00:00Z"),
                ZoneId.of("UTC")
        );

        GreetingService service = new GreetingService(fixedClock);

        assertEquals("Good morning", service.greeting());
    }

    @Test
    void greetingReturnsGoodAfternoonAfterNoon() {
        Clock fixedClock = Clock.fixed(
                Instant.parse("2026-07-13T14:00:00Z"),
                ZoneId.of("UTC")
        );

        GreetingService service = new GreetingService(fixedClock);

        assertEquals("Good afternoon", service.greeting());
    }

    @Test
    void greetingReturnsGoodEveningInTheEvening() {
        Clock fixedClock = Clock.fixed(
                Instant.parse("2026-07-13T20:00:00Z"),
                ZoneId.of("UTC")
        );

        GreetingService service = new GreetingService(fixedClock);

        assertEquals("Good evening", service.greeting());
    }
}

Using Clock with Spring

In a Spring application, you can register a Clock as a bean.

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.time.Clock;

@Configuration
public class TimeConfig {

    @Bean
    public Clock clock() {
        return Clock.systemUTC();
    }
}

Then inject it into your service:

import org.springframework.stereotype.Service;

import java.time.Clock;
import java.time.LocalDate;

@Service
public class SubscriptionService {
    private final Clock clock;

    public SubscriptionService(Clock clock) {
        this.clock = clock;
    }

    public boolean isExpired(LocalDate expiryDate) {
        return expiryDate.isBefore(LocalDate.now(clock));
    }
}

In tests, replace the Clock bean with a fixed one:

import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;

import java.time.Clock;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;

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

class SubscriptionServiceSpringTest {

    @TestConfiguration
    static class FixedClockConfig {
        @Bean
        Clock clock() {
            return Clock.fixed(
                    Instant.parse("2026-07-13T10:00:00Z"),
                    ZoneId.of("UTC")
            );
        }
    }

    @Test
    void isExpiredUsesFixedClock() {
        SubscriptionService service =
                new SubscriptionService(FixedClockConfig.class.cast(null));
    }
}

A simpler unit test usually does not need Spring at all:

import org.junit.jupiter.api.Test;

import java.time.Clock;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;

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

class SubscriptionServiceTest {

    @Test
    void isExpiredUsesFixedClock() {
        Clock fixedClock = Clock.fixed(
                Instant.parse("2026-07-13T10:00:00Z"),
                ZoneId.of("UTC")
        );

        SubscriptionService service = new SubscriptionService(fixedClock);

        assertTrue(service.isExpired(LocalDate.of(2026, 7, 12)));
    }
}

Avoid These Approaches When Possible

Avoid scattering calls like these throughout your code:

LocalDate.now();
LocalDateTime.now();
Instant.now();
System.currentTimeMillis();
new Date();

They make tests harder because the current time cannot easily be controlled.

Also avoid adding arbitrary sleeps to tests:

Thread.sleep(1000);

Tests that depend on sleeping are usually slow and unreliable.


Good Rule of Thumb

Use this rule:

If your code needs the current time, inject a Clock.

This makes your code:

  • easier to test
  • more predictable
  • less dependent on the machine’s timezone
  • easier to reason about
  • safer around midnight and date-boundary bugs

For most Java applications, java.time.Clock is the cleanest way to test code that depends on time.

How do I use assumptions in JUnit tests?

In JUnit 5, assumptions let you run a test only when certain conditions are true. If an assumption fails, the test is skipped/aborted, not failed.

They are useful when a test depends on things like:

  • operating system
  • environment variables
  • external services
  • database availability
  • specific Java version
  • local developer setup

JUnit assumptions are available from:

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

Basic Example

import org.junit.jupiter.api.Test;

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

class EnvironmentTest {

    @Test
    void testOnlyRunsOnCiServer() {
        assumeTrue("true".equals(System.getenv("CI")));

        int result = 2 + 3;

        assertEquals(5, result);
    }
}

If the environment variable CI is not set to "true", this test is skipped.

It does not fail.

Using assumeTrue()

assumeTrue() allows the test to continue only if the condition is true.

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assumptions.assumeTrue;
import static org.junit.jupiter.api.Assertions.assertTrue;

class OperatingSystemTest {

    @Test
    void testOnlyOnLinux() {
        assumeTrue(System.getProperty("os.name").toLowerCase().contains("linux"));

        assertTrue(true);
    }
}

This test only runs on Linux.

Using assumeFalse()

assumeFalse() is the opposite. The test continues only if the condition is false.

import org.junit.jupiter.api.Test;

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

class LocalEnvironmentTest {

    @Test
    void testDoesNotRunInProduction() {
        assumeFalse("prod".equals(System.getenv("APP_ENV")));

        assertEquals(4, 2 + 2);
    }
}

If APP_ENV is "prod", the test is skipped.

Adding a Message

You can add a message to explain why the test was skipped.

import org.junit.jupiter.api.Test;

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

class JavaVersionTest {

    @Test
    void testOnlyOnSpecificJavaVersion() {
        assumeTrue(
                System.getProperty("java.version").startsWith("25"),
                "This test only runs on Java 25"
        );

        assertEquals(10, 5 + 5);
    }
}

The message helps explain the skipped test in the test report.

Using assumingThat()

assumingThat() lets you run only part of a test conditionally.

Unlike assumeTrue(), it does not skip the whole test. It only skips the block of code inside it.

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assumptions.assumingThat;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;

class ConditionalBlockTest {

    @Test
    void testWithConditionalSection() {
        assertEquals(4, 2 + 2);

        assumingThat(
                "dev".equals(System.getenv("APP_ENV")),
                () -> {
                    assertTrue(true);
                    System.out.println("Extra checks for development environment");
                }
        );

        assertEquals(6, 3 + 3);
    }
}

In this example:

  • the first assertion always runs
  • the conditional block runs only when APP_ENV is "dev"
  • the last assertion always runs

Difference Between Assertions and Assumptions

Feature Assertion Assumption
Purpose Verify expected behavior Check whether test should run
If condition fails Test fails Test is skipped
Common methods assertEquals(), assertTrue() assumeTrue(), assumeFalse()
Used for Validating code correctness Checking test prerequisites

Example: Skipping Test If Database Is Not Available

import org.junit.jupiter.api.Test;

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

class DatabaseTest {

    @Test
    void testDatabaseQuery() {
        assumeTrue(isDatabaseAvailable(), "Database is not available");

        String result = "John";

        assertEquals("John", result);
    }

    private boolean isDatabaseAvailable() {
        // In a real test, you might check a connection here.
        return false;
    }
}

Since isDatabaseAvailable() returns false, the test is skipped.

Common Assumption Methods

JUnit 5 provides these commonly used methods:

assumeTrue(condition);
assumeTrue(condition, message);

assumeFalse(condition);
assumeFalse(condition, message);

assumingThat(condition, executable);

When Should You Use Assumptions?

Use assumptions when the test depends on something outside the code being tested.

Good examples:

assumeTrue(System.getenv("CI") != null);
assumeTrue(System.getProperty("os.name").contains("Linux"));
assumeFalse("prod".equals(System.getenv("APP_ENV")));

Avoid using assumptions to hide broken tests. If the code is wrong, use assertions and let the test fail.

Summary

Assumptions in JUnit are used to skip tests when required conditions are not met.

Use:

assumeTrue(condition);

when a test should continue only if a condition is true.

Use:

assumeFalse(condition);

when a test should continue only if a condition is false.

Use:

assumingThat(condition, () -> {
    // conditional checks
});

when only part of a test should run conditionally.

How do I write repeated tests in JUnit?

In JUnit 5, repeated tests are written using the @RepeatedTest annotation.

A repeated test runs the same test method multiple times without requiring different input values.

Basic Example

import org.junit.jupiter.api.RepeatedTest;

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

class RandomNumberTest {

    @RepeatedTest(5)
    void randomNumberShouldBeLessThanTen() {
        int number = (int) (Math.random() * 10);

        assertTrue(number >= 0 && number < 10);
    }
}

This test runs 5 times.

Difference Between @Test and @RepeatedTest

Instead of writing:

import org.junit.jupiter.api.Test;

class ExampleTest {

    @Test
    void shouldRunOnce() {
        // test logic
    }
}

You write:

import org.junit.jupiter.api.RepeatedTest;

class ExampleTest {

    @RepeatedTest(3)
    void shouldRunThreeTimes() {
        // test logic
    }
}

Access the Current Repetition

JUnit can inject a RepetitionInfo parameter into a repeated test. This lets you know which repetition is currently running.

import org.junit.jupiter.api.RepeatedTest;
import org.junit.jupiter.api.RepetitionInfo;

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

class RetryExampleTest {

    @RepeatedTest(5)
    void repeatedTestWithInfo(RepetitionInfo repetitionInfo) {
        int current = repetitionInfo.getCurrentRepetition();
        int total = repetitionInfo.getTotalRepetitions();

        System.out.println("Running repetition " + current + " of " + total);

        assertTrue(current >= 1);
        assertTrue(current <= total);
    }
}

Example output:

Running repetition 1 of 5
Running repetition 2 of 5
Running repetition 3 of 5
Running repetition 4 of 5
Running repetition 5 of 5

Customize the Display Name

You can customize the name shown for each repeated test invocation.

import org.junit.jupiter.api.RepeatedTest;

class LoginTest {

    @RepeatedTest(value = 3, name = "Login attempt {currentRepetition} of {totalRepetitions}")
    void loginShouldSucceedRepeatedly() {
        // test login logic
    }
}

This may display as:

Login attempt 1 of 3
Login attempt 2 of 3
Login attempt 3 of 3

Common placeholders are:

{displayName}
{currentRepetition}
{totalRepetitions}

JUnit also provides predefined formats:

@RepeatedTest(value = 3, name = RepeatedTest.SHORT_DISPLAY_NAME)

or:

@RepeatedTest(value = 3, name = RepeatedTest.LONG_DISPLAY_NAME)

Example with @BeforeEach

Lifecycle methods such as @BeforeEach run before each repetition.

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

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

class CounterTest {

    private int counter;

    @BeforeEach
    void setUp() {
        counter = 0;
    }

    @RepeatedTest(3)
    void counterStartsAtZeroEachTime() {
        assertEquals(0, counter);

        counter++;
    }
}

Because @BeforeEach runs before every repetition, the counter starts at 0 each time.

When to Use Repeated Tests

Use @RepeatedTest when you want to run the same test multiple times, for example:

  • testing code that uses random values
  • checking for flaky behavior
  • verifying repeated operations
  • testing concurrency-related code
  • making sure state is reset between runs

@RepeatedTest vs Parameterized Tests

Use @RepeatedTest when the test logic is the same and the input does not change.

Use parameterized tests when you want to run the same test with different inputs.

@RepeatedTest(5)
void sameTestRepeatedSeveralTimes() {
    // same input or generated input each time
}
@ParameterizedTest
@ValueSource(ints = {1, 2, 3})
void sameTestWithDifferentValues(int value) {
    // runs once for each value
}

Maven Dependency

Make sure JUnit Jupiter is available in your project:

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

Gradle Dependency

testImplementation 'org.junit.jupiter:junit-jupiter:5.13.4'

test {
    useJUnitPlatform()
}

Summary

Use @RepeatedTest to run the same test multiple times:

import org.junit.jupiter.api.RepeatedTest;

class ExampleTest {

    @RepeatedTest(5)
    void shouldRunFiveTimes() {
        // test logic
    }
}

A repeated test is useful when you need the same test executed more than once, while a parameterized test is better when each run needs different input data.

How do I run only selected JUnit tests by tag?

To run only selected JUnit 5 tests by tag, mark your tests with @Tag, then configure your build tool or IDE to include only that tag.

1. Add tags to your tests

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

class PaymentServiceTest {

    @Test
    @Tag("fast")
    void calculatesTotal() {
        // test code
    }

    @Test
    @Tag("integration")
    void connectsToPaymentGateway() {
        // test code
    }
}

You can also tag an entire test class:

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

@Tag("integration")
class UserRepositoryTest {

    @Test
    void savesUser() {
        // test code
    }
}

All tests in that class inherit the integration tag.


2. Run tagged tests with Maven

If you use Maven Surefire, run tests with a specific tag like this:

mvn test -Dgroups=integration

To run multiple tags:

mvn test -Dgroups="fast,integration"

To exclude a tag:

mvn test -DexcludedGroups=slow

Example Maven configuration:

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

3. Run tagged tests with Gradle

For Gradle, configure the test task:

tasks.test {
    useJUnitPlatform {
        includeTags 'integration'
    }
}

Then run:

gradle test

You can include multiple tags:

tasks.test {
    useJUnitPlatform {
        includeTags 'fast', 'integration'
    }
}

Or exclude tags:

tasks.test {
    useJUnitPlatform {
        excludeTags 'slow'
    }
}

You can also pass tags from the command line:

tasks.test {
    useJUnitPlatform {
        if (project.hasProperty('includeTags')) {
            includeTags project.property('includeTags').split(',')
        }
    }
}

Then run:

gradle test -PincludeTags=integration

4. Run tagged tests in IntelliJ IDEA

In IntelliJ IDEA:

  1. Open Run | Edit Configurations…
  2. Create or select a JUnit run configuration.
  3. Set Test kind to Tags.
  4. Enter the tag name, for example:
    integration
    
  5. Run the configuration.

    You can use tag expressions such as:

    fast & !slow
    

    or:

    integration | smoke
    

Summary

Tool Example
JUnit annotation @Tag("integration")
Maven mvn test -Dgroups=integration
Gradle includeTags 'integration'
IntelliJ IDEA JUnit run configuration → Test kind: Tags

Use tags like fast, slow, unit, integration, or smoke to organize your test suite and run only the tests you need.

How do I use tags to group JUnit tests?

In JUnit 5, you can use the @Tag annotation to group tests into categories such as:

  • fast
  • slow
  • unit
  • integration
  • database
  • api
  • smoke

Tags are useful when you want to run only certain groups of tests, for example only fast unit tests during development, or only integration tests in a CI pipeline.


1. Basic Example

Use @Tag on a test method:

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

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

class CalculatorTest {

    @Test
    @Tag("fast")
    void shouldAddTwoNumbers() {
        Calculator calculator = new Calculator();

        int result = calculator.add(2, 3);

        assertEquals(5, result);
    }

    @Test
    @Tag("slow")
    void shouldCalculateLargeDataSet() {
        Calculator calculator = new Calculator();

        int result = calculator.processLargeDataSet();

        assertEquals(1000, result);
    }
}

In this example:

  • shouldAddTwoNumbers() belongs to the fast group.
  • shouldCalculateLargeDataSet() belongs to the slow group.

2. Tag an Entire Test Class

You can place @Tag on a class to apply the tag to all tests inside it.

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

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

@Tag("unit")
class StringUtilsTest {

    @Test
    void shouldReturnTrueForBlankString() {
        assertTrue(StringUtils.isBlank(""));
    }

    @Test
    void shouldReturnTrueForWhitespaceString() {
        assertTrue(StringUtils.isBlank("   "));
    }
}

All tests in StringUtilsTest are now tagged as unit.


3. Use Multiple Tags

A test can have more than one tag.

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

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

class UserServiceTest {

    @Test
    @Tag("unit")
    @Tag("fast")
    void shouldCreateUser() {
        UserService userService = new UserService();

        User user = userService.createUser("[email protected]");

        assertNotNull(user);
    }

    @Test
    @Tag("integration")
    @Tag("database")
    void shouldSaveUserToDatabase() {
        UserRepository userRepository = new UserRepository();

        User user = userRepository.save(new User("[email protected]"));

        assertNotNull(user.getId());
    }
}

This allows you to run tests by different categories.

For example:

  • Run all unit tests.
  • Run all fast tests.
  • Run all integration tests.
  • Exclude all database tests.

4. Run Tagged Tests with Maven

If you are using Maven Surefire, you can run tests with a specific tag like this:

mvn test -Dgroups=unit

To run multiple tags:

mvn test -Dgroups=unit,fast

To exclude a tag:

mvn test -DexcludedGroups=slow

Example:

mvn test -Dgroups=integration -DexcludedGroups=slow

This runs tests tagged with integration, but excludes tests tagged with slow.


5. Configure Tags in pom.xml

You can also configure Maven to include or exclude tags permanently.

<build>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-surefire-plugin</artifactId>
            <version>3.5.2</version>
            <configuration>
                <groups>unit</groups>
                <excludedGroups>slow</excludedGroups>
            </configuration>
        </plugin>
    </plugins>
</build>

This configuration runs tests tagged unit and excludes tests tagged slow.


6. Run Tagged Tests with Gradle

If you are using Gradle, configure the test task:

test {
    useJUnitPlatform {
        includeTags 'unit'
        excludeTags 'slow'
    }
}

Then run:

gradle test

You can also create separate test tasks:

tasks.register('integrationTest', Test) {
    useJUnitPlatform {
        includeTags 'integration'
    }
}

Run it with:

gradle integrationTest

7. Tag Integration Tests

A common use case is separating unit tests from integration tests.

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

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

@Tag("integration")
class UserRepositoryTest {

    @Test
    void shouldConnectToDatabase() {
        boolean connected = true;

        assertTrue(connected);
    }
}

Then you can run only integration tests:

mvn test -Dgroups=integration

Or exclude them during regular builds:

mvn test -DexcludedGroups=integration

8. Tag Nested Tests

Tags also work with nested test classes.

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

class OrderServiceTest {

    @Nested
    @Tag("validation")
    class ValidationTests {

        @Test
        void shouldRejectInvalidOrder() {
            // validation test
        }

        @Test
        void shouldAcceptValidOrder() {
            // validation test
        }
    }

    @Nested
    @Tag("pricing")
    class PricingTests {

        @Test
        void shouldCalculateDiscount() {
            // pricing test
        }

        @Test
        void shouldApplyTax() {
            // pricing test
        }
    }
}

Here:

  • All tests in ValidationTests are tagged validation.
  • All tests in PricingTests are tagged pricing.

9. Tag Naming Rules

JUnit tag names must follow a few rules:

  • A tag must not be blank.
  • A tag must not contain whitespace.
  • A tag must not contain ISO control characters.
  • A tag must not contain reserved characters:
    • ,
    • (
    • )
    • &
    • |
    • !

Good examples:

@Tag("unit")
@Tag("integration")
@Tag("fast")
@Tag("database")
@Tag("smoke")

Avoid:

@Tag("unit test")
@Tag("fast,unit")
@Tag("integration&database")

10. Best Practices

Use tags for broad execution groups, not for every small detail.

Good tag categories:

  • unit
  • integration
  • slow
  • fast
  • database
  • api
  • smoke

Avoid over-tagging tests with too many labels.

For example, this is usually enough:

@Test
@Tag("integration")
@Tag("database")
void shouldSaveCustomer() {
}

But this is probably too much:

@Test
@Tag("integration")
@Tag("database")
@Tag("repository")
@Tag("customer")
@Tag("save")
@Tag("positive")
void shouldSaveCustomer() {
}

Summary

Use JUnit 5 @Tag to group and filter tests.

@Test
@Tag("fast")
void shouldRunQuickly() {
}

You can apply tags to:

  • Individual test methods
  • Entire test classes
  • Nested test classes

Then run selected groups using Maven or Gradle:

mvn test -Dgroups=fast
test {
    useJUnitPlatform {
        includeTags 'fast'
    }
}

Tags are especially useful for separating unit tests, integration tests, slow tests, and database-dependent tests.

How do I use @EnumSource to test enum values?

@EnumSource is a JUnit 5 parameterized-test source that runs the same test once for each selected enum constant.

Basic usage

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

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

class DirectionTest {

    enum Direction {
        NORTH, SOUTH, EAST, WEST
    }

    @ParameterizedTest
    @EnumSource(Direction.class)
    void shouldTestAllDirections(Direction direction) {
        assertNotNull(direction);
    }
}

This runs the test 4 times: once with NORTH, SOUTH, EAST, and WEST.

Test only specific enum values

Use names to include selected constants:

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

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

class DirectionTest {

    enum Direction {
        NORTH, SOUTH, EAST, WEST
    }

    @ParameterizedTest
    @EnumSource(value = Direction.class, names = {"NORTH", "EAST"})
    void shouldTestOnlyNorthAndEast(Direction direction) {
        assertTrue(direction == Direction.NORTH || direction == Direction.EAST);
    }
}

Exclude specific enum values

Use mode = EnumSource.Mode.EXCLUDE:

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

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

class DirectionTest {

    enum Direction {
        NORTH, SOUTH, EAST, WEST
    }

    @ParameterizedTest
    @EnumSource(
        value = Direction.class,
        names = {"WEST"},
        mode = EnumSource.Mode.EXCLUDE
    )
    void shouldTestAllExceptWest(Direction direction) {
        assertNotEquals(Direction.WEST, direction);
    }
}

This runs for NORTH, SOUTH, and EAST.

Match enum names with a regex

Use mode = EnumSource.Mode.MATCH_ANY:

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

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

class DirectionTest {

    enum Direction {
        NORTH, SOUTH, EAST, WEST
    }

    @ParameterizedTest
    @EnumSource(
        value = Direction.class,
        names = {"N.*", "E.*"},
        mode = EnumSource.Mode.MATCH_ANY
    )
    void shouldTestNamesStartingWithNOrE(Direction direction) {
        assertTrue(direction.name().startsWith("N") || direction.name().startsWith("E"));
    }
}

This runs for NORTH and EAST.

Common modes

EnumSource.Mode.INCLUDE     // default; include listed names
EnumSource.Mode.EXCLUDE     // exclude listed names
EnumSource.Mode.MATCH_ANY   // include names matching any regex
EnumSource.Mode.MATCH_ALL   // include names matching all regexes

Typical real-world example

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

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

class OrderStatusTest {

    enum OrderStatus {
        NEW, PAID, SHIPPED, CANCELLED
    }

    @ParameterizedTest
    @EnumSource(value = OrderStatus.class, names = {"PAID", "SHIPPED"})
    void completedStatusesShouldBeProcessed(OrderStatus status) {
        assertTrue(status == OrderStatus.PAID || status == OrderStatus.SHIPPED);
    }
}

In short:

@ParameterizedTest
@EnumSource(MyEnum.class)
void test(MyEnum value) {
    // test each enum value
}

How do I use @MethodSource for dynamic test data?

In JUnit 5, @MethodSource lets you supply test arguments from one or more factory methods. It is commonly used with @ParameterizedTest when your test data is too complex for @ValueSource or @CsvSource.

Basic example

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

import java.util.stream.Stream;

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

class StringTest {

    @ParameterizedTest
    @MethodSource("blankStrings")
    void shouldDetectBlankStrings(String input) {
        assertTrue(input == null || input.isBlank());
    }

    static Stream<String> blankStrings() {
        return Stream.of(null, "", " ", "\t", "\n");
    }
}

The method referenced by @MethodSource("blankStrings") provides the test data.

Supplying multiple arguments

If your test method has multiple parameters, return Stream<Arguments>.

import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;

import java.util.stream.Stream;

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

class CalculatorTest {

    @ParameterizedTest
    @MethodSource("additionCases")
    void shouldAddNumbers(int a, int b, int expected) {
        assertEquals(expected, a + b);
    }

    static Stream<Arguments> additionCases() {
        return Stream.of(
                Arguments.of(1, 2, 3),
                Arguments.of(5, 7, 12),
                Arguments.of(-1, 1, 0)
        );
    }
}

Using a method source without naming it

If the source method has the same name as the test method, you can omit the method name.

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

import java.util.stream.Stream;

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

class NumberTest {

    @ParameterizedTest
    @MethodSource
    void isEven(int number) {
        assertTrue(number % 2 == 0);
    }

    static Stream<Integer> isEven() {
        return Stream.of(2, 4, 6, 8);
    }
}

Dynamic test data

You can generate test data dynamically inside the provider method.

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

import java.util.stream.IntStream;

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

class RangeTest {

    @ParameterizedTest
    @MethodSource("numbersFromOneToTen")
    void shouldBePositive(int number) {
        assertTrue(number > 0);
    }

    static IntStream numbersFromOneToTen() {
        return IntStream.rangeClosed(1, 10);
    }
}

Using objects as test data

import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;

import java.util.stream.Stream;

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

class UserValidationTest {

    @ParameterizedTest
    @MethodSource("invalidUsers")
    void shouldRejectInvalidUsers(User user) {
        assertFalse(user.isValid());
    }

    static Stream<Arguments> invalidUsers() {
        return Stream.of(
                Arguments.of(new User("", "[email protected]")),
                Arguments.of(new User("Alice", "")),
                Arguments.of(new User(null, "[email protected]"))
        );
    }

    static class User {
        private final String name;
        private final String email;

        User(String name, String email) {
            this.name = name;
            this.email = email;
        }

        boolean isValid() {
            return name != null && !name.isBlank()
                    && email != null && !email.isBlank();
        }
    }
}

Referencing an external method source

You can also put test data providers in another class.

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

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

class MathTest {

    @ParameterizedTest
    @MethodSource("com.example.TestData#additionCases")
    void shouldAddNumbers(int a, int b, int expected) {
        assertEquals(expected, a + b);
    }
}
package com.example;

import org.junit.jupiter.params.provider.Arguments;

import java.util.stream.Stream;

public class TestData {

    static Stream<Arguments> additionCases() {
        return Stream.of(
                Arguments.of(1, 2, 3),
                Arguments.of(10, 20, 30)
        );
    }
}

Depending on your setup, make the provider method public static if it is in a different package or class.

Common rules

For standard JUnit 5 usage:

  • The test must use @ParameterizedTest.
  • The provider method usually must be static.
  • The provider method can return:
    • Stream<T>
    • Stream<Arguments>
    • Collection<T>
    • Iterable<T>
    • arrays
    • primitive streams like IntStream, LongStream, or DoubleStream
  • Use Arguments.of(...) when passing multiple values.
  • The number and types of provided arguments must match the test method parameters.

Example with readable test names

import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;

import java.util.stream.Stream;

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

class DiscountTest {

    @ParameterizedTest(name = "{index}: price={0}, discount={1}, expected={2}")
    @MethodSource("discountCases")
    void shouldCalculateDiscount(double price, double discount, double expected) {
        assertEquals(expected, price * (1 - discount));
    }

    static Stream<Arguments> discountCases() {
        return Stream.of(
                Arguments.of(100.0, 0.10, 90.0),
                Arguments.of(200.0, 0.25, 150.0),
                Arguments.of(50.0, 0.00, 50.0)
        );
    }
}

In short, use @MethodSource when you want flexible, reusable, or dynamically generated test data for parameterized tests.