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
nullbe 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.
