Purpose & Benefits
TestEntityBuilder is a utility class for rapidly creating test entity instances
with auto-populated random values. It eliminates boilerplate test setup code by automatically
generating realistic test data for all scalar fields while respecting entity constraints.
This is particularly valuable for:
- Integration tests that need representative data without caring about specific values
- Persistence layer tests that verify save/update/delete operations work correctly
- Query and filter tests where you need multiple entities with varied data
- Rapid test setup that reduces test code verbosity and improves readability
Rather than manually constructing test entities like this:
Product product = new Product();
product.setName("XYZABC123");
product.setPrice(new BigDecimal("99.99"));
product.setInStock(true);
product.setDescription("Test product");
// ... set more fields ...
You can now do this:
Product product = builder.build(Product.class);
// All fields automatically populated with realistic random values
Note for AI Agents: Step-by-step guides for implementing TestEntityBuilder are available at docs/guides/testing-with-testentitybuilder.md in the Ebean repository.
Setup & Dependencies
Add ebean-test to Your Project
TestEntityBuilder is provided by the ebean-test module. Add it as a test dependency:
Maven:
<dependency>
<groupId>io.ebean</groupId>
<artifactId>ebean-test</artifactId>
<version>${ebean.version}</version>
<scope>test</scope>
</dependency>
Gradle:
testImplementation "io.ebean:ebean-test:${ebeanVersion}"Use a version matching your Ebean runtime. If your build does not centralise versions, replace with an explicit fixed version.
Using with Dependency Injection
Most applications using Ebean also use a DI framework. The recommended pattern is to
register TestEntityBuilder as a bean in the test DI context so it can be
injected directly into test classes — eliminating @BeforeEach setup entirely.
Spring Boot — @TestConfiguration:
@TestConfiguration
class TestConfig {
@Bean
TestEntityBuilder testEntityBuilder(Database database) {
return TestEntityBuilder.builder(database).build();
}
}
@SpringBootTest
class OrderControllerTest {
@Autowired Database database;
@Autowired TestEntityBuilder builder;
@Test
void findByStatus() {
var order = builder.build(Order.class).setStatus(OrderStatus.PENDING);
database.save(order);
// ... test assertions
}
}
Avaje Inject — @TestScope @Factory:
@TestScope
@Factory
class TestConfiguration {
@Bean
TestEntityBuilder testEntityBuilder(Database database) {
return TestEntityBuilder.builder(database).build();
}
}
@InjectTest
class OrderControllerTest {
@Inject Database database;
@Inject TestEntityBuilder builder;
@Test
void findByStatus() {
var order = builder.build(Order.class).setStatus(OrderStatus.PENDING);
database.save(order);
// ... test assertions
}
}
Basic Usage
Build an Entity (In-Memory)
The build() method creates an instance with populated fields without persisting to the database:
Product product = builder.build(Product.class);
// Fields are automatically populated:
// - id: null (not populated)
// - name: random string (e.g., "f7d2e4a1")
// - price: random BigDecimal (e.g., 42.50)
// - inStock: true (default for boolean)
// - createdAt: current instant
// - etc.
assert product.getId() == null; // Not persisted yet
assert product.getName() != null;
Build and Save (Persist to Database)
The save() method creates, persists, and returns an entity with the database-assigned @Id:
Product product = builder.save(Product.class);
// Entity is now in the database:
assert product.getId() != null;
// Verify it was saved:
Product found = DB.find(Product.class, product.getId());
assert found != null;
Build Multiple Distinct Instances
Each call to build() or save() produces a new instance with fresh random values:
Order order1 = builder.save(Order.class);
Order order2 = builder.save(Order.class);
Order order3 = builder.save(Order.class);
// All have different IDs and values:
assert order1.getId() != order2.getId();
assert order1.getOrderNumber() != order2.getOrderNumber();
Type-Specific Value Generation
TestEntityBuilder generates appropriate random values for each Java/SQL type. This table shows what gets generated by default:
| Java Type | Generated Value | Notes |
|---|---|---|
String |
UUID-derived string | Truncated to column length if @Column(length=...) is set |
| Email fields | uuid@domain.com |
Detected when property name contains "email" |
Integer, int |
Random in [1, 1000) | Positive random integer |
Long, long |
Random in [1, 100000) | Positive random long |
Short, short |
Random in [1, 100) | If used as a boolean flag (e.g. active = 1), override explicitly after building |
Double, double |
Random in [1, 100) | Floating point value |
Float, float |
Random in [1, 100) | Floating point value |
BigDecimal |
Respects precision and scale | Uses @Column(precision=..., scale=...) |
Boolean, boolean |
true |
Override in custom generator if needed |
UUID |
Random UUID | Via UUID.randomUUID() |
LocalDate |
Today's date | Via LocalDate.now() |
LocalDateTime |
Current datetime | Via LocalDateTime.now() |
Instant |
Current instant | Via Instant.now() |
Enum |
First constant | Override in custom generator if needed |
| Other types | null |
Set these fields manually in tests |
String Length Constraints
The builder respects column length constraints:
@Entity
public class User {
@Column(length = 50)
private String username;
}
User user = builder.build(User.class);
assert user.getUsername().length() <= 50; // Constraint respected
BigDecimal Precision and Scale
For BigDecimal fields, the builder respects database column precision and scale:
@Entity
public class LineItem {
@Column(precision = 10, scale = 2) // max 99_999_999.99
private BigDecimal amount;
}
LineItem item = builder.build(LineItem.class);
assert item.getAmount().scale() == 2;
Entity Relationships
Cascade Persist Relationships: Recursively Built
Relationships marked with cascade = PERSIST are recursively populated:
@Entity
public class Order {
@ManyToOne(cascade = CascadeType.PERSIST)
private Customer customer;
}
Order order = builder.build(Order.class);
// Both order and customer are built:
assert order != null;
assert order.getCustomer() != null;
assert order.getCustomer().getId() == null; // not persisted yet
// When saved, cascade handles both:
Order saved = builder.save(Order.class);
assert saved.getId() != null;
assert saved.getCustomer().getId() != null; // parent also saved
Non-Cascade Relationships: Left Null
Relationships without cascade persist are left null:
@Entity
public class BlogPost {
@ManyToOne // No cascade = left null
private Author author;
}
BlogPost post = builder.build(BlogPost.class);
assert post.getAuthor() == null;
// Set it manually if needed for your test:
post.setAuthor(manuallyCreatedAuthor);
Collection Relationships: Left Empty
Collection relationships (@OneToMany, @ManyToMany) are left empty:
@Entity
public class Author {
@OneToMany(mappedBy = "author")
private List<BlogPost> posts;
}
Author author = builder.build(Author.class);
assert author.getPosts().isEmpty();
// Populate if needed:
author.getPosts().addAll(Arrays.asList(post1, post2, post3));
Cycle Detection: Prevents Infinite Recursion
If two entities reference each other with cascade persist, the builder detects the cycle and breaks it by leaving one reference null:
@Entity
public class Person {
@ManyToOne(cascade = CascadeType.PERSIST)
private Organization org;
}
@Entity
public class Organization {
@ManyToOne(cascade = CascadeType.PERSIST)
private Person founder;
}
Person person = builder.build(Person.class);
// One reference will be null to break the cycle
Customization via RandomValueGenerator
Why Customize?
The default RandomValueGenerator uses generic random values. For domain-specific testing, you may want:
- Email addresses with your company domain
- Realistic phone numbers or SKUs
- Monetary amounts within realistic ranges
- Addresses in specific regions
Creating a Custom Generator
Subclass RandomValueGenerator and override individual methods:
class CompanyTestDataGenerator extends RandomValueGenerator {
@Override
protected String randomString(String propName, int maxLength) {
if (propName != null && propName.toLowerCase().contains("email")) {
String localPart = UUID.randomUUID().toString().substring(0, 8);
String email = localPart + "@mycompany.com";
if (maxLength > 0 && email.length() > maxLength) {
return email.substring(0, maxLength);
}
return email;
}
return super.randomString(propName, maxLength);
}
}
Using a Custom Generator
Pass the custom generator when building. In a DI context, configure it in your
@TestConfiguration or @TestScope @Factory class:
// Spring Boot
@TestConfiguration
class TestConfig {
@Bean
TestEntityBuilder testEntityBuilder(Database database) {
return TestEntityBuilder.builder(database)
.valueGenerator(new CompanyTestDataGenerator())
.build();
}
}
// Without DI — pass database directly:
TestEntityBuilder builder = TestEntityBuilder.builder(database)
.valueGenerator(new CompanyTestDataGenerator())
.build();
User user = builder.build(User.class);
assert user.getEmail().endsWith("@mycompany.com");
Example: Domain-Specific Money Type
public class MoneyValueGenerator extends RandomValueGenerator {
@Override
protected BigDecimal randomBigDecimal(int precision, int scale) {
// Generate prices in realistic range: $5.00 to $999.99
BigDecimal price = BigDecimal.valueOf(
ThreadLocalRandom.current().nextDouble(5.0, 1000.0)
);
return price.setScale(2, RoundingMode.HALF_UP);
}
}
Best Practices
1. Use for Integration Tests, Not Unit Tests
Good: Integration test with database
@Test
void whenSaving_thenCanRetrieve() {
Product product = builder.save(Product.class);
Product found = DB.find(Product.class, product.getId());
assertThat(found).isNotNull();
}
Poor: Validation test requiring specific values
@Test
void whenNameIsBlank_thenThrowException() {
Product product = builder.build(Product.class); // name is random!
product.setName(""); // have to override anyway
// ...
}
2. Override Values for Specific Test Scenarios
When test requirements demand specific field values, manually override after building:
@Test
void whenStockIsLow_thenShowWarning() {
Product product = builder.build(Product.class);
product.setQuantity(2); // Specific value for this test
boolean shouldWarn = product.shouldShowLowStockWarning();
assertThat(shouldWarn).isTrue();
}
3. Create Fixture Factories for Common Patterns
For shared domain-specific setup, encapsulate build patterns in an instance helper class
that takes TestEntityBuilder and Database via its constructor.
In a DI context, register it as a bean alongside TestEntityBuilder:
// Spring Boot
@TestConfiguration
class TestConfig {
@Bean
TestEntityBuilder testEntityBuilder(Database database) {
return TestEntityBuilder.builder(database).build();
}
@Bean
OrderTestFactory orderTestFactory(TestEntityBuilder builder, Database database) {
return new OrderTestFactory(builder, database);
}
}
public class OrderTestFactory {
private final TestEntityBuilder builder;
private final Database database;
public OrderTestFactory(TestEntityBuilder builder, Database database) {
this.builder = builder;
this.database = database;
}
public Order savePendingOrder() {
Order order = builder.build(Order.class);
order.setStatus(OrderStatus.PENDING);
database.save(order);
return order;
}
}
// Usage in tests:
@SpringBootTest
class OrderControllerTest {
@Autowired OrderTestFactory orderFactory;
@Test
void whenOrderPending_thenCanUpdate() {
Order order = orderFactory.savePendingOrder();
// ...
}
}
Complete Examples
Example 1: Integration Test with Repository
@SpringBootTest
class OrderRepositoryTest {
@Autowired Database database;
@Autowired OrderRepository orderRepository;
@Autowired TestEntityBuilder builder;
@Test
void whenFindingOrdersByStatus_thenReturnsMatching() {
Order pending1 = builder.build(Order.class).setStatus(OrderStatus.PENDING);
Order pending2 = builder.build(Order.class).setStatus(OrderStatus.PENDING);
Order shipped = builder.build(Order.class).setStatus(OrderStatus.SHIPPED);
database.saveAll(pending1, pending2, shipped);
List<Order> pending = orderRepository.findByStatus(OrderStatus.PENDING);
assertThat(pending).hasSize(2);
}
}
Example 2: Recursive Relationship Building
@Test
void whenBuildingOrderWithCustomer_thenBothPopulated() {
Order order = builder.build(Order.class);
// Customer is recursively built due to @ManyToOne(cascade=PERSIST)
assertThat(order.getCustomer()).isNotNull();
assertThat(order.getCustomer().getId()).isNull(); // not persisted yet
assertThat(order.getCustomer().getName()).isNotNull();
// Saving cascades to customer:
Order saved = builder.save(Order.class);
assertThat(saved.getId()).isNotNull();
assertThat(saved.getCustomer().getId()).isNotNull();
}
Example 3: Custom Generator for Domain Values
class ECommerceTestDataGenerator extends RandomValueGenerator {
@Override
protected BigDecimal randomBigDecimal(int precision, int scale) {
// Product prices typically $10-$500
return BigDecimal.valueOf(
ThreadLocalRandom.current().nextDouble(10.0, 500.0)
).setScale(2, RoundingMode.HALF_UP);
}
}
// Register in @TestConfiguration (or @TestScope @Factory for Avaje Inject):
@TestConfiguration
class TestConfig {
@Bean
TestEntityBuilder testEntityBuilder(Database database) {
return TestEntityBuilder.builder(database)
.valueGenerator(new ECommerceTestDataGenerator())
.build();
}
}
// Then use the injected builder in tests:
@Test
void usingCustomGenerator() {
Product product = builder.build(Product.class);
assertThat(product.getPrice())
.isBetween(BigDecimal.TEN, BigDecimal.valueOf(500.0));
}
Troubleshooting
Error: "No BeanDescriptor found for [Class] — is it an @Entity?"
Cause: The class is not registered as an Ebean entity.
Solution: Ensure the class is annotated with @Entity:
@Entity
@Table(name = "products")
public class Product {
// ...
}
Fields are null even though I expected them populated
Cause: TestEntityBuilder does not populate:
@Idfields (identity)@Versionfields (optimistic locking)@Transientfields@OneToManycollections- Non-cascade
@ManyToOnerelationships
Solution: Set these fields manually if needed. For @Id fields,
use builder.save(Product.class) instead of builder.build(Product.class)
— saving persists the entity and returns it with a DB-assigned id:
// build() — in-memory only, @Id remains null
Product product = builder.build(Product.class);
// save() — persisted, @Id is DB-assigned
Product saved = builder.save(Product.class);
assertThat(saved.getId()).isNotNull();
StackOverflowError with Recursive Relationships
Cause: Two or more entities mutually reference each other without cycle detection.
Solution: This should be handled automatically. If not, manually break the cycle:
Person person = builder.build(Person.class);
person.getOrganization().setFounder(null); // Break cycle
Values Generated are Too Random for My Test
Cause: Default RandomValueGenerator uses true random values, not suitable when your test needs predictable data.
Solution: Create a deterministic custom generator:
class DeterministicTestDataGenerator extends RandomValueGenerator {
private int counter = 0;
@Override
protected String randomString(String propName, int maxLength) {
return "test_" + (counter++);
}
}