Model Tester is a robust Java utility designed to eliminate boilerplate code when testing model classes (POJOs, Java Records, Lombok classes, etc.). It automatically verifies constructors, getters, setters, equals(), hashCode(), and toString() contracts with minimal effort—often in just one line of code.
| Feature | Description |
|---|---|
| Fluent API | Highly readable and easily configurable method chaining. |
| Java Records Support | Tests Record accessors natively using NamingStrategy.RECORD. |
| Fluent Setters | Built-in strategy for fluent setters (setXxx returning this) via NamingStrategy.FLUENT. |
| Deep Equality | Recursive comparison engine for List, Set, Map, arrays, and primitive types. |
| Auto Data Generation | Automatically generates synthetic test data for all Java field types. |
| Auto Proxying | Automatically creates proxies for Interface and Abstract class dependencies. |
| Thread-safe Cache | Optimized reflection caching to minimize overhead in large test suites. |
| Safe Mode | Alternative "safe" testing methods for legacy or highly complex models. |
| Detailed Reporting | Structured granular testing outcomes via TestResult objects. |
dependencies {
testImplementation "com.github.joutvhu:model-tester:1.1.1"
}<dependency>
<groupId>com.github.joutvhu</groupId>
<artifactId>model-tester</artifactId>
<version>1.1.1</version>
<scope>test</scope>
</dependency>Test an entire standard POJO in just one line of code:
@Test
void testUserModel() {
ModelTester.allOf(User.class).testAndThrows();
}allOf() automatically verifies everything: constructors, all getters/setters, equals(), hashCode(), and toString().
Assuming you have the following User class:
public class User {
private Long id;
private String name;
private String email;
public User() {}
public User(Long id, String name, String email) { ... }
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
// ... getters/setters, equals, hashCode, toString
}Approach 1 – Verify everything at once:
@Test
void testUser_allOf() {
// Automatically tests: constructors, getters/setters, equals, hashCode, toString
ModelTester.allOf(User.class).testAndThrows();
}Approach 2 – Granular control:
@Test
void testUser_step_by_step() {
ModelTester.of(User.class)
.constructors() // Tests all public/private constructors
.getterSetters() // Tests field accessors and mutators
.equalsMethod() // Tests the equals() contract
.hashCodeMethod() // Tests the hashCode() contract
.toStringMethod() // Ensures toString() doesn't crash and is consistent
.testAndThrows();
}Approach 3 – Return boolean instead of throwing:
@Test
void testUser_boolean() {
boolean passed = ModelTester.allOf(User.class).test();
Assertions.assertTrue(passed, "User model should pass all tests");
}Java Records use accessors that match field names exactly (without get/set prefixes). Use NamingStrategy.RECORD to handle them.
// Record definition
public record ProductRecord(Long id, String name, List<String> tags, Map<String, Object> metadata) {}@Test
void testProductRecord() {
ModelTester.of(ProductRecord.class)
.withNamingStrategy(NamingStrategy.RECORD)
.constructors()
.getterSetters() // Tests accessors: id(), name(), tags(), metadata()
.equalsMethod()
.hashCodeMethod()
.toStringMethod()
.testAndThrows();
}Note: Records do not have setters, so
getterSetters()only verifies the accessors (getters). The library automatically handles this when usingNamingStrategy.RECORD.
A fluent setter returns this instead of void, enabling method chaining.
// Using Lombok's @Accessors(chain = true)
@Data
@Accessors(chain = true)
public class Article {
private Long id;
private String title;
private String content;
private boolean published;
}@Test
void testFluentArticle() {
ModelTester.of(Article.class)
.withNamingStrategy(NamingStrategy.FLUENT)
.constructors()
.getterSetters()
.equalsMethod()
.hashCodeMethod()
.toStringMethod()
.testAndThrows();
}The library verifies that a fluent setter:
- Assigns the correct value to the field.
- Returns the correct
thisreference (not a different object instance).
Use include() to selectively test specific fields or methods:
@Test
void testUser_onlyPublicFields() {
ModelTester.of(User.class)
.include("id", "name", "email") // Only tests these three fields
.testAndThrows();
}Use exclude() to skip sensitive or non-standard fields:
@Test
void testUser_excludeSensitiveFields() {
ModelTester.of(User.class)
.exclude("passwordHash", "salt", "secretToken") // Skips these fields
.testAndThrows();
}Note:
include()andexclude()only apply to thegetterSetters()tests. They do not affectconstructors(),equalsMethod(),hashCodeMethod(), ortoStringMethod().
When your model requires specific constructor arguments or constraints:
Pass explicit parameter values:
@Test
void testUser_withCustomConstructor() {
ModelTester.of(User.class)
.constructor(100L, "joutvhu", "joutvhu@example.com") // Uses the 3-param constructor
.getterSetters()
.testAndThrows();
}Use internal Creator instances for fine-grained control:
@Test
void testOrder_withCreator() {
ModelTester.of(Order.class)
.constructor(
Creator.anyOf(Long.class), // Auto-generates a Long
Creator.byParams(String.class, "ORDER-001") // Fixed value
)
.getterSetters()
.testAndThrows();
}Test all discovered constructors at once:
@Test
void testUser_allConstructors() {
ModelTester.of(User.class)
.constructors() // Discovers and tests every constructor
.testAndThrows();
}ModelTester seamlessly validates fields and methods declared in parent classes:
// Parent class
public abstract class BaseEntity {
private Long id;
private Date createdAt;
// getters/setters...
}
// Child class
public class Customer extends BaseEntity {
private String fullName;
private String phone;
// getters/setters, equals, hashCode, toString...
}@Test
void testCustomer_withInheritance() {
ModelTester.allOf(Customer.class).testAndThrows();
// Tests inherited id, createdAt PLUS fullName, phone
}The library can fully validate Enum types by cycling through their available values:
public enum Status {
ACTIVE, INACTIVE, PENDING;
private final String label;
Status() { this.label = name().toLowerCase(); }
public String getLabel() { return label; }
}@Test
void testStatus_enum() {
ModelTester.allOf(Status.class).testAndThrows();
}If your model has fields that are Interfaces or Abstract classes, ModelTester automatically creates proxies for them:
public class OrderService {
private PaymentGateway gateway; // Interface
private AbstractProcessor processor; // Abstract class
public PaymentGateway getGateway() { return gateway; }
public void setGateway(PaymentGateway gateway) { this.gateway = gateway; }
// ...
}@Test
void testOrderService_autoProxy() {
// Automatically creates proxies for PaymentGateway and AbstractProcessor
ModelTester.allOf(OrderService.class).testAndThrows();
}Alternatively, inject your own mock objects:
@Test
void testOrderService_withMock() {
PaymentGateway mockGateway = mock(PaymentGateway.class); // Mockito mock
ModelTester.of(OrderService.class)
.constructor(mockGateway) // Inject mock via constructor
.getterSetters()
.testAndThrows();
}When dealing with large, legacy models where a strict contract test fails (e.g., due to missing copy constructors or complex circular dependencies), use the Safe methods to ensure basic consistency and null-safety without rigorous deep equality checks.
@Test
void testLegacyModel_safeShortcut() {
// safeOf() = constructors() + getterSetters() + equalsSafe() + hashCodeSafe() + toStringSafe()
ModelTester.safeOf(LegacyCustomer.class).testAndThrows();
}Or mix and match modes:
@Test
void testLegacyModel_mixedMode() {
ModelTester.of(LegacyCustomer.class)
.constructors()
.getterSetters() // Standard rigorous verification
.equalsSafe() // Only tests equals(itself) and equals(null)
.hashCodeSafe() // Tests that hashCode() implies internal consistency
.toStringSafe() // Tests that toString() executes successfully
.testAndThrows();
}Understanding Strict vs Safe Checks:
| Method | Verification Logic |
|---|---|
equalsMethod() |
Reflexivity, null-safety, symmetry with copy, and deep field-by-field inequality detection. |
equalsSafe() |
Only reflexivity (a.equals(a)) and null-safety (a.equals(null) == false). |
hashCodeMethod() |
hashCode(original) == hashCode(copy) ensures parity across objects with identical states. |
hashCodeSafe() |
hashCode() on the same object returns the same consistent value. |
toStringMethod() |
toString(original) == toString(copy). |
toStringSafe() |
Calling toString() does not throw an exception and is consistent. |
JUnit 5 – Recommended approach:
@Test
void testUser_junit5() {
// testAndThrows() throws a TesterException on first failure, failing the JUnit test immediately.
ModelTester.allOf(User.class).testAndThrows();
}JUnit 5 – assertDoesNotThrow:
@Test
void testUser_assertDoesNotThrow() {
assertDoesNotThrow(
() -> ModelTester.allOf(User.class).testAndThrows(),
"User model should pass all standard tests"
);
}JUnit 4:
@org.junit.Test
public void testUser_junit4() {
ModelTester.allOf(User.class).testAndThrows();
}TestNG:
@org.testng.annotations.Test
public void testUser_testng() {
ModelTester.allOf(User.class).testAndThrows();
}While .test() provides a simple boolean summary, you can inspect granular component-level outcomes using .getResults().
| Field | Type | Description |
|---|---|---|
className |
String |
Fully qualified name of the class tested |
component |
String |
The field or method failing the test (e.g., setName, equals(copy)) |
status |
TestStatus |
Enum value: PASS, FAIL, or ERROR |
message |
String |
Human-readable explanation of a failure |
error |
Throwable |
The root exception (present only when status == ERROR) |
| Status | Meaning | Typical Cause |
|---|---|---|
PASS |
Success | The component functions exactly as expected. |
FAIL |
Logic Failure | An assertion failed (e.g., getName() didn't return the value set by setName()). |
ERROR |
Technical Error | An unexpected exception occurred (e.g., a crash inside your model during instantiation). |
If you want to view exactly which getters/setters failed without aborting immediately:
@Test
void testUser_customReporting() {
ModelTester<User> tester = ModelTester.allOf(User.class);
boolean success = tester.test();
if (!success) {
List<TestResult> results = tester.getResults();
System.err.println("=== FAILED COMPONENTS ===");
results.stream()
.filter(r -> r.getStatus() != TestStatus.PASS)
.forEach(result -> {
System.err.printf("[%s] %s.%s: %s%n",
result.getStatus(),
result.getClassName(),
result.getComponent(),
result.getMessage() != null ? result.getMessage() : "(no message)");
if (result.getError() != null) {
result.getError().printStackTrace(System.err);
}
});
Assertions.fail("Model test failed. See FAILED COMPONENTS above.");
}
}@Test
void testUser_classifyResults() {
ModelTester<User> tester = ModelTester.allOf(User.class);
tester.test();
List<TestResult> results = tester.getResults();
long passCount = results.stream().filter(r -> r.getStatus() == TestStatus.PASS).count();
long failCount = results.stream().filter(r -> r.getStatus() == TestStatus.FAIL).count();
long errorCount = results.stream().filter(r -> r.getStatus() == TestStatus.ERROR).count();
System.out.printf("PASS: %d | FAIL: %d | ERROR: %d%n", passCount, failCount, errorCount);
// Assert that no technical exceptions occurred, even if logic fails exists
Assertions.assertEquals(0, errorCount, "Ensure no ERRORs occurred during tests");
}| Method | Description |
|---|---|
ModelTester.of(Class<T>) |
Creates an empty tester allowing full manual configuration. |
ModelTester.allOf(Class<T>) |
Automatically configures: constructors(), getterSetters(), equalsMethod(), hashCodeMethod(), toStringMethod(). |
ModelTester.safeOf(Class<T>) |
Configures safe operations: constructors(), getterSetters(), equalsSafe(), hashCodeSafe(), toStringSafe(). |
| Method | Description |
|---|---|
withNamingStrategy(NamingStrategy.DEFAULT) |
Standard POJOs: getXxx(), setXxx(), isXxx() (default behavior). |
withNamingStrategy(NamingStrategy.RECORD) |
Java Records: Accessors match field names perfectly, no setters allowed. |
withNamingStrategy(NamingStrategy.FLUENT) |
Fluent APIs: Setters return this instance rather than void. |
| Method | Description |
|---|---|
include("field1", "field2") |
Restricts getter/setter testing to exclusively these fields or methods. |
exclude("field1", "field2") |
Explicitly ignores these fields or methods during getter/setter testing. |
| Method | Target tested |
|---|---|
constructors() |
Discovers and validates all declared class constructors. |
constructor(Object... params) |
Forces tests to use a constructor with specific argument values. |
constructor(Creator<?>... params) |
Uses highly configurable parameterized Creator generators. |
getterSetters() |
Exercises all field accessors and mutators per the NamingStrategy. |
equalsMethod() |
Performs strict, comprehensive verification of the equals() contract. |
equalsSafe() |
Basic equals() null-safety and reflexivity. |
hashCodeMethod() |
Strict hashCode() state parity across objects. |
hashCodeSafe() |
Basic non-crashing execution parity for hashCode(). |
toStringMethod() |
Strict toString() state parity across identically stateful objects. |
toStringSafe() |
Basic non-crashing execution parity for toString(). |
| Method | Returns | Description |
|---|---|---|
test() |
boolean |
Executes all queued logic, returning true if completely successful. |
testAndThrows() |
void |
Executes all logic, throwing a TesterException on the first test failure. |
getResults() |
List<TestResult> |
Retrieve detailed granularity records following a .test() execution. |
The Creator utility automatically generates appropriate test data for your domain types during tests:
| Target Type | Generated Value |
|---|---|
String |
"" (Empty string) |
int, Integer |
0 |
long, Long |
0L |
boolean, Boolean |
true |
char, Character |
'c' |
double, Double |
0.0 |
float, Float |
0.0f |
BigInteger, BigDecimal |
0 |
List, Collection |
new ArrayList<>() |
Set |
new HashSet<>() |
Map |
new HashMap<>() |
Instant, Temporal |
Instant.now() |
SortedSet, NavigableSet |
new TreeSet<>() |
SortedMap, NavigableMap |
new TreeMap<>() |
| Arrays | Empty array of the component type |
| Enums | The first declared constant |
| Interfaces | JDK Dynamic Runtime Proxy |
| Abstract Classes | Javassist Dynamic Proxy |
For every inferred Property Pair:
- Generates test data identical to the field's type.
- Directly bypasses access constraints via reflection to set the field.
- Invokes the Getter and confirms it intercepts the newly seeded value.
- Conversely, invokes the Setter utilizing new generated test data and subsequently inspects the raw underlying field to verify the Setter's operational success.
- Reflexivity:
obj.equals(obj)must evaluate totrue. - Null-safety:
obj.equals(null)must reliably evaluate tofalse. - Symmetry with copy:
obj.equals(copy)must evaluate totruewhen copy maintains identical state. - Field sensitivity: Iteratively alters individual field states via reflection inside the copied object, strictly demanding that
obj.equals(mutatedCopy)evaluates tofalse.
- Strict mode: Asserts that
hashCode(original) == hashCode(copy), guaranteeing two identitical domain objects provide equivalent digest hashes. - Safe mode: Asserts two consecutive invocations of
hashCode()on a singular unchanged object provides identical hashes.
Lombok eliminates the writing of boilerplate code, and ModelTester guarantees that no Lombok annotations are mistakenly missed or malformed over the lifespan of a project.
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Product {
private Long id;
private String name;
private BigDecimal price;
}@Test
void testProduct_lombok() {
ModelTester.allOf(Product.class).testAndThrows();
}Reduce repetitive unit testing code by looping your application's domain model directory:
@Test
void testAllDomainModels() {
List<Class<?>> models = List.of(
User.class, Product.class, Order.class,
Category.class, Address.class
);
models.forEach(modelClass ->
ModelTester.allOf(modelClass).testAndThrows()
);
}Combine ModelTester with parameterized configurations to easily run automated class tests:
@ParameterizedTest
@ValueSource(classes = {User.class, Product.class, Order.class})
void testDomainModels(Class<?> modelClass) {
ModelTester.allOf(modelClass).testAndThrows();
}If your business domain enforces defensive constructors (e.g. enforcing positive integers or forbidding null initialization), leverage the constructor injection pattern:
@Test
void testInventoryItem_withConstraints() {
// Guarantees quantity >= 0 initially to bypass validation safeguards
ModelTester.of(InventoryItem.class)
.constructor(1L, "SKU-001", 10) // itemId, sku, quantity
.getterSetters()
.testAndThrows();
}public class Invoice {
private Long id;
private Customer customer; // Deep Nested POJO
private List<InvoiceLine> lines; // Deep Nested Collections
// ...
}@Test
void testInvoice_withNestedModels() {
// The internal Creator automatically recursively analyzes and generates instances
// for Customer and InvoiceLine when building the initial test Invoice state.
ModelTester.allOf(Invoice.class).testAndThrows();
}Review the CHANGELOG.md to explore granular historical progression timelines and iteration details.
This software project is directly sourced and distributed strictly under the structural governance provided by the MIT License.