Java records are compact classes designed to hold immutable data. Because records automatically provide a constructor, accessor methods, equals(), hashCode(), and toString(), testing them is usually simpler than testing ordinary classes.
In most cases, you do not need to test Java’s generated record behavior directly. Instead, test:
- custom validation in the compact constructor
- custom methods you add to the record
- behavior that depends on equality or immutability
- serialization/deserialization if the record is used with JSON or persistence frameworks
Example Record
Suppose you have this Java record:
public record User(String username, String email, int age) {
public User {
if (username == null || username.isBlank()) {
throw new IllegalArgumentException("Username must not be blank");
}
if (email == null || !email.contains("@")) {
throw new IllegalArgumentException("Email must be valid");
}
if (age < 0) {
throw new IllegalArgumentException("Age must not be negative");
}
}
public boolean isAdult() {
return age >= 18;
}
}
This record has:
- three components:
username,email, andage - validation in the compact constructor
- a custom method named
isAdult()
Basic JUnit 5 Test Class
Here is a simple JUnit 5 test class:
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class UserTest {
@Test
void shouldCreateUserWithValidData() {
User user = new User("alice", "[email protected]", 25);
assertEquals("alice", user.username());
assertEquals("[email protected]", user.email());
assertEquals(25, user.age());
}
@Test
void shouldReturnTrueWhenUserIsAdult() {
User user = new User("bob", "[email protected]", 20);
assertTrue(user.isAdult());
}
@Test
void shouldReturnFalseWhenUserIsNotAdult() {
User user = new User("charlie", "[email protected]", 15);
assertFalse(user.isAdult());
}
@Test
void shouldRejectBlankUsername() {
IllegalArgumentException exception = assertThrows(
IllegalArgumentException.class,
() -> new User("", "[email protected]", 25)
);
assertEquals("Username must not be blank", exception.getMessage());
}
@Test
void shouldRejectInvalidEmail() {
IllegalArgumentException exception = assertThrows(
IllegalArgumentException.class,
() -> new User("alice", "invalid-email", 25)
);
assertEquals("Email must be valid", exception.getMessage());
}
@Test
void shouldRejectNegativeAge() {
IllegalArgumentException exception = assertThrows(
IllegalArgumentException.class,
() -> new User("alice", "[email protected]", -1)
);
assertEquals("Age must not be negative", exception.getMessage());
}
}
Testing Generated Accessor Methods
Record accessors use the component name directly. For example, if your record is:
public record Product(String name, double price) {
}
The accessors are:
product.name();
product.price();
not:
product.getName();
product.getPrice();
A basic test looks like this:
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class ProductTest {
@Test
void shouldExposeRecordComponents() {
Product product = new Product("Keyboard", 49.99);
assertEquals("Keyboard", product.name());
assertEquals(49.99, product.price());
}
}
However, for plain records with no validation or custom behavior, these tests often provide little value because they only verify Java-generated code.
Testing equals() and hashCode()
Records automatically generate equals() and hashCode() based on all record components.
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
class ProductTest {
@Test
void shouldCompareRecordsByComponentValues() {
Product first = new Product("Keyboard", 49.99);
Product second = new Product("Keyboard", 49.99);
Product third = new Product("Mouse", 19.99);
assertEquals(first, second);
assertEquals(first.hashCode(), second.hashCode());
assertNotEquals(first, third);
}
}
Again, you usually do not need this test unless your application depends heavily on equality behavior, such as using records as keys in a Map or elements in a Set.
Testing toString()
Records also generate a readable toString() method:
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class ProductTest {
@Test
void shouldGenerateReadableToString() {
Product product = new Product("Keyboard", 49.99);
assertEquals("Product[name=Keyboard, price=49.99]", product.toString());
}
}
Be careful with this kind of test. It can be brittle because it depends on the exact string format.
Test toString() mainly when:
- you override it
- logs or messages depend on its output
- the string representation is part of your expected behavior
Testing Constructor Validation
Records are commonly used with compact constructors for validation.
public record EmailAddress(String value) {
public EmailAddress {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException("Email must not be blank");
}
if (!value.contains("@")) {
throw new IllegalArgumentException("Email must contain @");
}
}
}
Test both valid and invalid cases:
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class EmailAddressTest {
@Test
void shouldCreateEmailAddressWhenValueIsValid() {
EmailAddress email = new EmailAddress("[email protected]");
assertEquals("[email protected]", email.value());
}
@Test
void shouldRejectNullEmail() {
IllegalArgumentException exception = assertThrows(
IllegalArgumentException.class,
() -> new EmailAddress(null)
);
assertEquals("Email must not be blank", exception.getMessage());
}
@Test
void shouldRejectBlankEmail() {
IllegalArgumentException exception = assertThrows(
IllegalArgumentException.class,
() -> new EmailAddress(" ")
);
assertEquals("Email must not be blank", exception.getMessage());
}
@Test
void shouldRejectEmailWithoutAtSign() {
IllegalArgumentException exception = assertThrows(
IllegalArgumentException.class,
() -> new EmailAddress("invalid-email")
);
assertEquals("Email must contain @", exception.getMessage());
}
}
Using Parameterized Tests for Records
Parameterized tests are useful when a record has multiple invalid input values.
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import static org.junit.jupiter.api.Assertions.assertThrows;
class EmailAddressTest {
@ParameterizedTest
@ValueSource(strings = {"", " ", "invalid-email", "user.example.com"})
void shouldRejectInvalidEmailValues(String value) {
assertThrows(
IllegalArgumentException.class,
() -> new EmailAddress(value)
);
}
}
For more complex data, use @CsvSource:
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import static org.junit.jupiter.api.Assertions.assertThrows;
class UserTest {
@ParameterizedTest
@CsvSource({
"'', [email protected], 25",
"' ', [email protected], 25",
"alice, invalid-email, 25",
"alice, [email protected], -1"
})
void shouldRejectInvalidUserData(String username, String email, int age) {
assertThrows(
IllegalArgumentException.class,
() -> new User(username, email, age)
);
}
}
Testing Custom Methods in Records
If your record contains business logic, test that logic directly.
public record Money(String currency, int amount) {
public boolean isPositive() {
return amount > 0;
}
public Money add(Money other) {
if (!currency.equals(other.currency())) {
throw new IllegalArgumentException("Currencies must match");
}
return new Money(currency, amount + other.amount());
}
}
Tests:
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class MoneyTest {
@Test
void shouldReturnTrueForPositiveAmount() {
Money money = new Money("USD", 100);
assertTrue(money.isPositive());
}
@Test
void shouldAddMoneyWithSameCurrency() {
Money first = new Money("USD", 100);
Money second = new Money("USD", 50);
Money result = first.add(second);
assertEquals(new Money("USD", 150), result);
}
@Test
void shouldRejectAddingDifferentCurrencies() {
Money first = new Money("USD", 100);
Money second = new Money("EUR", 50);
IllegalArgumentException exception = assertThrows(
IllegalArgumentException.class,
() -> first.add(second)
);
assertEquals("Currencies must match", exception.getMessage());
}
}
Testing Immutability
Records are shallowly immutable. This means record components cannot be reassigned, but if a component refers to a mutable object, that object can still be changed.
Example:
import java.util.List;
public record Order(List<String> items) {
}
This record is not deeply immutable:
import java.util.ArrayList;
import java.util.List;
Order order = new Order(new ArrayList<>(List.of("Book")));
order.items().add("Pen");
To make it safer, copy the list:
import java.util.List;
public record Order(List<String> items) {
public Order {
items = List.copyOf(items);
}
}
Then test it:
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
class OrderTest {
@Test
void shouldDefensivelyCopyItems() {
List<String> items = new ArrayList<>();
items.add("Book");
Order order = new Order(items);
items.add("Pen");
assertEquals(List.of("Book"), order.items());
}
@Test
void shouldExposeUnmodifiableItems() {
Order order = new Order(new ArrayList<>(List.of("Book")));
assertThrows(
UnsupportedOperationException.class,
() -> order.items().add("Pen")
);
}
}
This is a valuable test because it verifies your own defensive-copying behavior, not just Java-generated record behavior.
What Should You Actually Test?
For Java records, focus your tests on behavior you wrote yourself.
Good things to test:
| Feature | Should You Test It? | Why |
|---|---|---|
| Accessor methods | Usually no | Generated by Java |
equals() / hashCode() |
Sometimes | Useful if equality is important in your domain |
toString() |
Rarely | Usually generated and brittle to assert |
| Compact constructor validation | Yes | This is your logic |
| Custom methods | Yes | This is your logic |
| Defensive copying | Yes | Important for immutability |
| Serialization/deserialization | Yes, if used | Important for APIs and persistence |
Recommended Testing Style
Use clear test names:
@Test
void shouldRejectNegativeAge() {
// test body
}
Follow the Arrange-Act-Assert pattern:
@Test
void shouldCreateUserWithValidData() {
// Arrange
String username = "alice";
String email = "[email protected]";
int age = 25;
// Act
User user = new User(username, email, age);
// Assert
assertEquals(username, user.username());
assertEquals(email, user.email());
assertEquals(age, user.age());
}
Summary
To test Java records:
- Do not over-test generated code.
- Test constructor validation.
- Test custom methods.
- Test defensive copying for mutable components.
- Test JSON or persistence integration only when records are used that way.
- Use JUnit 5 assertions such as
assertEquals(),assertTrue(),assertFalse(), andassertThrows().
A plain record like this usually needs no dedicated unit test:
public record Point(int x, int y) {
}
But a record like this should be tested:
public record Age(int value) {
public Age {
if (value < 0) {
throw new IllegalArgumentException("Age must not be negative");
}
}
public boolean isAdult() {
return value >= 18;
}
}
Because it contains behavior that belongs to your application, not just Java’s generated record features.
