This is Part 2 of a two-part series on Java Records and testing. This post covers hands-on implementation with code examples. If you haven’t already, read Part 1: Understanding Java Records: Features, Behavior, and Why Testing Matters for the conceptual foundation.
How to Test the Canonical Constructor and Field Accessors
While the canonical constructor and field accessors are implicitly generated, their correct functioning is foundational. Testing these elements, especially in the context of initial instantiation and retrieval, is a fundamental step to ensure the record behaves as expected.
Testing Basic Instantiation and Field Retrieval
The primary objective here is to confirm that a record can be instantiated correctly with valid data and that its accessor methods accurately return the component values provided during construction. This validates the most basic contract of a record: to hold and provide its data.
Example Scenario: Consider a simple record Product(String name, double price).
Test Steps:
- Instantiate the Record: Create an instance of the record with typical, valid values.
Product product = new Product("Laptop", 1200.00);
- Verify Field Accessors: Use the record’s accessor methods to retrieve the component values.
- Assert Correctness: Compare the retrieved values against the expected values that were used during instantiation.
import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.*; class ProductTest { record Product(String name, double price) {} @Test void shouldInstantiateAndRetrieveValues() { // Given String expectedName = "Smartphone"; double expectedPrice = 899.99; // When Product product = new Product( expectedName, expectedPrice ); // Then assertNotNull(product); assertEquals(expectedName, product.name()); assertEquals(expectedPrice, product.price(), 0.001); } }
In this example, assertEquals with a delta (0.001) is used for double comparisons to account for potential floating-point inaccuracies, a good practice for numerical checks.
Examples of Positive and Negative Test Cases
While the above covers a positive test case, it’s also prudent to consider boundary conditions or edge cases even for seemingly simple records, especially concerning null values if they are permissible, or default values.
Positive Test Cases (Valid Inputs)
Standard Values: As demonstrated above, normal valid inputs.
Edge Cases for Permissible Nulls: If a component can legitimately be null, ensure the record handles it gracefully.
record Book(String title, String author) {} @Test void shouldInstantiateWithNullAuthor() { // Given String expectedTitle = "The Great Novel"; String expectedAuthor = null; // When Book book = new Book(expectedTitle, expectedAuthor); // Then assertNotNull(book); assertEquals(expectedTitle, book.title()); assertEquals(expectedAuthor, book.author()); }
Negative Test Cases (Invalid Inputs)
For records with only implicit constructors (no compact constructor), directly instantiating with null for a non-primitive type usually won’t throw an exception unless the accessor subsequently tries to dereference a null and throws a NullPointerException.
When a compact constructor is present, this is where negative testing becomes crucial. If Product had a compact constructor:
record Product(String name, double price) { public Product { if (name == null || name.isBlank()) { throw new IllegalArgumentException( "Product name cannot be null or blank" ); } if (price <= 0) { throw new IllegalArgumentException( "Product price must be positive" ); } } }
Then, you would write negative tests like:
import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.*; class ProductValidationTest { @Test void shouldThrowExceptionForNullName() { IllegalArgumentException thrown = assertThrows( IllegalArgumentException.class, () -> new Product(null, 100.00) ); assertEquals( "Product name cannot be null or blank", thrown.getMessage() ); } @Test void shouldThrowExceptionForZeroPrice() { IllegalArgumentException thrown = assertThrows( IllegalArgumentException.class, () -> new Product("Widget", 0.00) ); assertEquals( "Product price must be positive", thrown.getMessage() ); } @Test void shouldThrowExceptionForNegativePrice() { IllegalArgumentException thrown = assertThrows( IllegalArgumentException.class, () -> new Product("Gadget", -50.00) ); assertEquals( "Product price must be positive", thrown.getMessage() ); } }
By systematically testing both positive and negative scenarios, especially when custom validation is introduced via compact constructors, you ensure the robustness and reliability of your record data structures.
Strategies for Testing Compact Constructors and Custom Validations
The compact constructor is a powerful feature of Java Records, providing a dedicated space for validating, normalizing, or transforming component values during instantiation. Consequently, testing the logic within compact constructors is paramount.
Validating Input Constraints and Edge Cases
The core purpose of a compact constructor is often to enforce invariants on the record’s components. This implies that testing must meticulously cover all defined constraints, including valid input ranges, invalid input conditions, and edge cases.
Key Considerations:
- Boundary Value Analysis: If a component has a numeric range (e.g., age between 0 and 120), test values at the minimum, maximum, and just inside/outside these boundaries.
- Equivalence Partitioning: Divide the input domain into partitions of equivalent behavior. Test one value from each partition.
- Null and Empty Checks: For
Stringor collection components, specifically test fornull, empty strings (""), or blank strings (" "). - Format Validation: If components must adhere to specific formats (e.g., email addresses, UUIDs), test valid and invalid formats.
Example: Range Validation
Consider a Score record where the score must be between 0 and 100, inclusive.
record Score(int value) { public Score { if (value < 0 || value > 100) { throw new IllegalArgumentException( "Score must be between 0 and 100" ); } } }
Test Cases for Score Compact Constructor:
import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; import static org.junit.jupiter.api.Assertions.*; class ScoreValidationTest { @ParameterizedTest @ValueSource(ints = {0, 50, 100}) void shouldAcceptValidScores(int validScore) { assertDoesNotThrow( () -> new Score(validScore) ); assertEquals(validScore, new Score(validScore).value()); } @ParameterizedTest @ValueSource(ints = {-1, 101}) void shouldRejectInvalidScores(int invalidScore) { IllegalArgumentException thrown = assertThrows( IllegalArgumentException.class, () -> new Score(invalidScore) ); assertEquals( "Score must be between 0 and 100", thrown.getMessage() ); } }
This example utilizes JUnit 5’s @ParameterizedTest with @ValueSource to efficiently test multiple valid and invalid score values.
Demonstrating Robustness Through Focused Tests
Beyond simple validation, compact constructors can also normalize or transform data. Testing such behavior demonstrates the robustness of the record.
Example: Trimming String Components
Consider a User record where the username should always be trimmed of leading/trailing whitespace.
record User(String username, String email) { public User { if (username == null || username.isBlank()) { throw new IllegalArgumentException( "Username cannot be null or blank" ); } username = username.trim(); // Normalization if (email == null || !email.contains("@")) { throw new IllegalArgumentException( "Invalid email format" ); } } }
Note: In a compact constructor, you reassign to the parameter name directly (e.g.,
username = username.trim()), not usingthis.username. The parameters are automatically assigned to the fields after the compact constructor body executes.
Test Case for Normalization:
import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.*; class UserNormalizationTest { @Test void shouldTrimUsernameWhitespace() { // Given String rawUsername = " john_doe "; String expectedUsername = "john_doe"; String email = "[email protected]"; // When User user = new User(rawUsername, email); // Then assertEquals(expectedUsername, user.username()); } }
Testing Custom equals(), hashCode(), and toString()
While Java Records automatically generate equals(), hashCode(), and toString() methods, there might be rare scenarios where you choose to override these. If you do, it becomes imperative to thoroughly test these custom implementations.
Note: It is generally advisable to rely on the compiler’s implementations unless there is an absolutely compelling reason to deviate, as custom implementations are a common source of bugs.
Verifying Semantic Equality for equals() and hashCode()
When you provide a custom implementation, you assume responsibility for upholding the equals() and hashCode() contracts.
equals() Contract Verification
- Reflexivity:
x.equals(x)must betrue. - Symmetry:
x.equals(y)must equaly.equals(x). - Transitivity: If
x.equals(y)andy.equals(z), thenx.equals(z). - Consistency: Multiple invocations must return the same result.
- Nullity:
x.equals(null)must befalse.
Example: Custom equals()/hashCode()
Imagine a ProductId record where equality is based only on serialNumber:
record ProductId(String serialNumber, String batchNumber) { @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) { return false; } ProductId productId = (ProductId) o; return serialNumber.equals(productId.serialNumber); } @Override public int hashCode() { return serialNumber.hashCode(); } }
Test Cases:
import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.*; import java.util.HashSet; import java.util.Set; class ProductIdEqualsHashCodeTest { @Test void equalsShouldBeReflexive() { ProductId p = new ProductId("SN123", "B001"); assertTrue(p.equals(p)); } @Test void equalsShouldBeSymmetric() { ProductId p1 = new ProductId("SN123", "B001"); ProductId p2 = new ProductId("SN123", "B002"); assertTrue(p1.equals(p2)); assertTrue(p2.equals(p1)); } @Test void equalsShouldHandleNull() { ProductId p = new ProductId("SN123", "B001"); assertFalse(p.equals(null)); } @Test void hashCodeShouldBeConsistentWithEquals() { ProductId p1 = new ProductId("SN123", "B001"); ProductId p2 = new ProductId("SN123", "B002"); assertTrue(p1.equals(p2)); assertEquals(p1.hashCode(), p2.hashCode()); } @Test void equalObjectsShouldBeFoundInHashSet() { ProductId p1 = new ProductId("SN123", "B001"); ProductId p2 = new ProductId("SN123", "B002"); Set<ProductId> set = new HashSet<>(); set.add(p1); assertTrue(set.contains(p2)); assertEquals(1, set.size()); } }
Integrating Java Records with Serialization
Java Records are designed to be naturally serializable, making them excellent candidates for data transfer objects (DTOs). However, ensuring correct serialization and deserialization across various formats requires dedicated testing.
Testing Record Serialization to JSON
Key Libraries:
- Jackson: A widely used JSON processing library in Java.
- Gson: Google’s JSON library.
Example: Jackson JSON Serialization
import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.*; class ProductSerializationTest { record Product(String id, String name, double price) {} private final ObjectMapper mapper = new ObjectMapper(); @Test void shouldSerializeToJson() throws Exception { // Given Product product = new Product( "P001", "Widget", 29.99 ); String expectedJson = "{\"id\":\"P001\",\"name\":\"Widget\",\"price\":29.99}"; // When String actualJson = mapper.writeValueAsString(product); // Then assertEquals(expectedJson, actualJson); } }
Validating Deserialization
@Test void shouldDeserializeFromJson() throws Exception { // Given String json = "{\"id\":\"P001\",\"name\":\"Widget\",\"price\":29.99}"; Product expected = new Product( "P001", "Widget", 29.99 ); // When Product actual = mapper.readValue(json, Product.class); // Then assertEquals(expected, actual); }
Schema Evolution Considerations
When your record structure changes, older serialized data might need to be deserialized into newer record versions. Test these scenarios by:
- Backward Compatibility: Test deserializing old schema JSON with new record classes
- Forward Compatibility: Test handling of unknown fields
- Data Migration: Test custom deserializers when component types change
Advanced Testing Patterns for Java Records
Mocking Record Dependencies
While records are primarily data carriers, there might be scenarios where a record’s custom method interacts with external components.
// Interface for price calculation interface PriceCalculator { double calculateAdjustedPrice( double basePrice, String currency ); } // Record that uses the calculator record OrderItem( String product, int quantity, double unitPrice, String currency ) { public double getTotalPrice(PriceCalculator calc) { double baseTotal = quantity * unitPrice; return calc.calculateAdjustedPrice(baseTotal, currency); } }
Test Case with Mockito:
import org.junit.jupiter.api.Test; import org.mockito.Mockito; import static org.junit.jupiter.api.Assertions.*; class OrderItemTest { @Test void shouldUsePriceCalculator() { // Given PriceCalculator mockCalc = Mockito.mock( PriceCalculator.class ); OrderItem item = new OrderItem( "Book", 2, 25.00, "USD" ); double expectedPrice = 45.00; // When Mockito .when(mockCalc.calculateAdjustedPrice(50.00, "USD")) .thenReturn(expectedPrice); double actualPrice = item.getTotalPrice(mockCalc); // Then assertEquals(expectedPrice, actualPrice, 0.001); Mockito.verify(mockCalc) .calculateAdjustedPrice(50.00, "USD"); } }
Property-Based Testing for Comprehensive Validation
Property-based testing (PBT) defines properties that your code should satisfy for any valid input, and the PBT framework generates random inputs to find counterexamples.
Key Libraries:
- jqwik: A popular PBT framework for Java and Kotlin.
Example: Property-Based Test for Score Record
import net.jqwik.api.*; import static org.junit.jupiter.api.Assertions.*; class ScorePropertyTest { record Score(int value) { public Score { if (value < 0 || value > 100) { throw new IllegalArgumentException( "Score must be between 0 and 100" ); } } } @Property void validScoresShouldNotThrow( @ForAll @IntRange(min = 0, max = 100) int score ) { assertDoesNotThrow(() -> new Score(score)); } @Property void invalidScoresShouldThrow( @ForAll @IntRange(min = -100, max = -1) int score ) { assertThrows( IllegalArgumentException.class, () -> new Score(score) ); } }
Best Practices for Maintainable Record Tests
Clear and Concise Test Naming Conventions
Recommended Naming Patterns:
should[Behavior]When[Condition][MethodName]Should[ExpectedResult]test[Scenario]For[Method/Constructor]
Examples:
shouldInstantiateSuccessfullyWithValidData()shouldThrowExceptionForNegativePrice()equalsShouldReturnTrueForEqualSerialNumbers()
Structuring Tests for Readability
- One Test Class per Record: Create a dedicated test class for each record.
- Logical Grouping: Group related tests together.
- Use Given/When/Then Comments: This pattern clearly delineates setup, execution, and verification.
@Test void shouldThrowExceptionForNegativePrice() { // Given String productName = "Test Item"; double invalidPrice = -10.00; // When / Then IllegalArgumentException thrown = assertThrows( IllegalArgumentException.class, () -> new Product(productName, invalidPrice) ); assertEquals( "Product price must be positive", thrown.getMessage() ); }
Promoting Reusability with Factory Methods
class TestData { public static Product createDefaultProduct() { return new Product("Default Widget", 99.99); } public static Product createProductWithPrice(double price) { return new Product("Custom Product", price); } } // In a test: Product product = TestData.createDefaultProduct();
Automating Java Record Tests with Diffblue Cover
While manual test writing is essential for custom business logic, AI-powered test generation tools can significantly accelerate the creation of comprehensive test suites. Diffblue Cover is a generative AI engine that automatically writes human-readable Java unit tests.
What is Diffblue Cover?
Diffblue Cover analyzes your project’s bytecode, runs your code in a secure sandbox, and produces tests that compile, execute, and validate the current behavior of your code. Cover is available as:
- Cover Plugin: An IntelliJ IDEA plugin for writing tests with one click
- Cover CLI: A command-line tool for generating tests across entire projects
- Cover Pipeline: CI/CD integration for automated test generation
Diffblue Cover supports Java 8, 11, 17, and 21, making it compatible with all Java versions that support Records (JDK 16+).
How Diffblue Cover Handles Java Records
Testing equals() and hashCode()
Cover automatically generates tests that verify the equals() and hashCode() contract:
@Test public void testEqualsAndHashCode() { // Arrange ProductRecord product = new ProductRecord( "Widget", 29.99 ); // Act and Assert assertEquals(product, product); int expectedHash = product.hashCode(); assertEquals(expectedHash, product.hashCode()); }
Handling Getters and Trivial Methods
Diffblue Cover deliberately does not test trivial methods such as getters, setters, and simple constructors directly. As the Diffblue documentation explains:
“There isn’t a benefit to be gained from testing these trivial methods directly as, for example, getters and setters simply access attributes so there is no actual logic to be tested.”
For Java Records, simple accessor methods are tested indirectly through other methods that use them.
Using Diffblue Cover
With Cover CLI
To generate tests for a specific record class:
dcover create com.example.model.ProductRecord
To generate tests for your entire project:
dcover create
With Cover Pipeline (CI/CD)
# Example GitHub Actions workflow - name: Generate Tests with Diffblue Cover run: | dcover create --batch git add **/*DiffblueTest.java git commit -m "Update unit tests"
Best Practices: Combining Manual and AI-Generated Tests
| Test Type | Recommended Approach |
|---|---|
equals()/hashCode() contract |
Let Diffblue Cover generate these |
| Basic accessor verification | Covered indirectly by Diffblue Cover |
| Compact constructor validation | Both: Cover for basic paths, manual for edge cases |
| Custom business logic methods | Both: Cover for baseline, manual for domain-specific |
| Serialization/Deserialization | Manual tests for schema evolution scenarios |
| Property-based tests | Manual (jqwik) for exhaustive validation |
Example: Diffblue Cover Output for a Record
Given a record with validation:
public record OrderItem( String productId, int quantity, double unitPrice ) { public OrderItem { if (productId == null || productId.isBlank()) { throw new IllegalArgumentException( "Product ID cannot be null or blank" ); } if (quantity <= 0) { throw new IllegalArgumentException( "Quantity must be positive" ); } if (unitPrice < 0) { throw new IllegalArgumentException( "Unit price cannot be negative" ); } } public double getTotalPrice() { return quantity * unitPrice; } }
Diffblue Cover might generate:
class OrderItemDiffblueTest { @Test void testNewOrderItem() { // Arrange and Act OrderItem item = new OrderItem( "P001", 5, 10.0 ); // Assert assertEquals("P001", item.productId()); assertEquals(5, item.quantity()); assertEquals(10.0, item.unitPrice()); } @Test void testNewOrderItemThrowsException() { // Arrange, Act and Assert assertThrows( IllegalArgumentException.class, () -> new OrderItem(null, 5, 10.0) ); } @Test void testGetTotalPrice() { // Arrange, Act and Assert OrderItem item = new OrderItem( "P001", 5, 10.0 ); assertEquals(50.0, item.getTotalPrice()); } }
Learn More
- What is Diffblue Cover?
- Writing Tests with Cover CLI
- Writing Tests with Cover Plugin
- Cover Pipeline for CI
Conclusion
Throughout this two-part series, we’ve explored the multifaceted landscape of testing Java Records.
Key Takeaways:
- Canonical constructors and field accessors should be tested for basic instantiation and correct value retrieval.
- Compact constructors containing custom validation logic require the most thorough testing—covering valid inputs, invalid inputs, and edge cases.
- Custom
equals(),hashCode(), andtoString()implementations, when present, must be tested against their contracts. - Serialization and deserialization testing ensures data integrity across formats.
- Advanced patterns like property-based testing and mocking enhance confidence in complex scenarios.
- AI-powered tools like Diffblue Cover can accelerate test creation while you focus on business logic.
While Java Records offer unparalleled conciseness and inherent immutability, they do not eliminate the need for rigorous testing. Rather, they shift the focus of your testing efforts to bespoke business logic, validation rules, and integration points.
By systematically applying the strategies and best practices outlined in this series, you can confidently ensure the robustness, reliability, and ultimate success of your Java Record implementations.





