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.

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.