This guide explains how to use TestEntityBuilder to rapidly create test entity instances with auto-populated random values. It is written as practical instructions for developers and AI agents building tests for Ebean applications.
TestEntityBuilder eliminates boilerplate test setup by automatically generating realistic test data for all scalar fields, while respecting entity constraints and relationships. 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
The TestEntityBuilder class is provided by the ebean-test module.
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 that matches your Ebean runtime (ebean.version /
ebeanVersion), or replace with an explicit fixed version if your build does
not centralize dependency versions.
Minimum version:
TestEntityBuilderwas introduced inebean-test 17.5.0. If your existing Ebean version is below this, upgrade before proceeding — mismatched Ebean runtime and test versions are not supported.
import io.ebean.test.TestEntityBuilder;TestEntityBuilder uses a builder pattern for configuration:
TestEntityBuilder builder = TestEntityBuilder.builder(database).build();The Database parameter specifies which Ebean database instance to use for entity type
lookups and persistence operations. Pass the injected Database bean (see
Using with Dependency Injection below) rather than
DB.getDefault() when working in a Spring or Avaje Inject context. For the same reason,
use the injected database bean for all persistence operations in your tests
(database.save(), database.find(), etc.) rather than mixing in static DB.* calls.
The build() method creates an instance with populated fields without persisting to the database:
Product product = builder.build(Product.class);
// Fields are populated:
// - id: unset (typically 0 for primitive long, null for boxed Long)
// - name: random UUID-based string
// - price: random BigDecimal
// - inStock: true
// - createdAt: current instant
// - etc.
// Not persisted yet (`@Id` is still unset until the entity is persisted).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 database.find(Product.class, product.getId()) != null;The saveAll() method persists multiple pre-built entities in a single call:
Product p1 = builder.build(Product.class);
Product p2 = builder.build(Product.class);
builder.saveAll(p1, p2);
// Both are now in the database with assigned IDs:
assert p1.getId() != null;
assert p2.getId() != null;This is equivalent to database.saveAll(p1, p2) but avoids needing a separate
Database reference in tests that already hold a TestEntityBuilder.
The database() method returns the Database instance used internally by the builder.
This is useful in tests where you want a single injected object (TestEntityBuilder) but
still need to perform find(), delete(), or other database operations:
Product saved = builder.save(Product.class);
// Use builder.database() instead of injecting a separate Database bean:
Product found = builder.database().find(Product.class, saved.getId());
assert found != null;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 boilerplate entirely.
Add a @Bean method to your test-scoped @Factory class:
import io.ebean.Database;
import io.ebean.test.ContainerDatabase;
import io.avaje.inject.Bean;
import io.avaje.inject.Factory;
import io.avaje.inject.test.TestScope;
import io.ebean.test.TestEntityBuilder;
@TestScope
@Factory
class TestConfiguration {
@Bean
PostgresContainer postgres() {
return PostgresContainer.builder("17") // Postgres image version
.dbName("my_app") // database to create inside the container
.build()
.start();
}
@Bean
Database database(PostgresContainer container) {
return container.ebean()
.builder()
.build();
}
@Bean
TestEntityBuilder testEntityBuilder(Database database) {
return TestEntityBuilder.builder(database).build();
}
}Then inject it directly into test classes using @InjectTest:
@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
}
}Both patterns produce a single shared TestEntityBuilder instance, wired
from the managed Database bean — no @BeforeEach required.
Add a @TestConfiguration class that provides TestEntityBuilder as a bean:
@TestConfiguration
class TestConfig {
@Bean
PostgresContainer postgres() {
return PostgresContainer.builder("17") // Postgres image version
.dbName("my_app") // database to create inside the container
.build()
.start();
}
// use @Primary if your main application context also wires a Database bean
// or conditionally wire the main Database bean to exclude it from tests
@Primary
@Bean
Database database(PostgresContainer container) {
return container.ebean()
.builder()
.build();
}
@Bean
TestEntityBuilder testEntityBuilder(Database database) {
return TestEntityBuilder.builder(database).build();
}
}Then inject it directly into test classes:
@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
}
}TestEntityBuilder generates appropriate random values for each Java/SQL type. Customize this behavior by subclassing RandomValueGenerator (see "Custom Value Generators" below).
| Type | Generated Value | Notes |
|---|---|---|
String |
UUID-derived (8 chars by default) | Truncated to column length if @Column(length=...) is set |
| Email fields | uuid@domain.com format |
Detected when property name contains "email" (case-insensitive) |
Integer / int |
Random in [1, 1_000) |
|
Long / long |
Random in [1, 100_000) |
|
Short / short |
Random in [1, 100) |
See note on flag fields below |
Double / double |
Random in [1, 100) |
|
Float / float |
Random in [1, 100) |
|
BigDecimal |
Respects precision and scale | Precision and scale from @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() |
OffsetDateTime |
Current time with zone | Via OffsetDateTime.now() |
ZonedDateTime |
Current time with zone | Via ZonedDateTime.now() |
Enum |
First constant | Override in custom generator if needed |
| Other types | null |
Set these fields manually in tests |
TestEntityBuilder respects column length constraints defined in the entity:
@Entity
public class User {
@Column(length = 50)
private String username;
}
User user = builder.build(User.class);
assert user.getUsername().length() <= 50; // ✅ Constraint respectedFor BigDecimal fields, the builder respects the 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;Some legacy schemas use short to represent boolean-like flags (e.g. active = 1
means active, 0 means inactive). TestEntityBuilder generates a random short in
[1, 100), which will be non-zero but not necessarily 1. If your application
code checks entity.getActive() == 1 specifically, override the field after building:
Organisation org = builder.build(Organisation.class)
.setActive((short) 1); // explicit override — random short won't doRelationships 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;
// Before persist, @Id values are typically unset
// (0 for primitive IDs, null for boxed IDs).
// When saved, cascade handles both:
Order saved = builder.save(Order.class);
assert saved.getId() != null;
assert saved.getCustomer().getId() != null; // parent also savedRelationships without cascade persist are not auto-created — even if marked optional = false.
Create and save the related entity first (the builder works well here), then assign it manually
before saving the parent:
@Entity
public class BlogPost {
@ManyToOne
private Author author; // No cascade = left null by builder
}
BlogPost post = builder.build(BlogPost.class);
assert post.getAuthor() == null;
// Use the builder to create the related entity, then set it manually:
Author author = builder.save(Author.class);
post.setAuthor(author);
database.save(post);Collection relationships (@OneToMany, @ManyToMany) are left empty. On Ebean-enhanced
entities these fields are initialised to empty Ebean-managed lists (not null), so calling
.add() or .addAll() directly is safe:
@Entity
public class Author {
@OneToMany(mappedBy = "author")
private List<BlogPost> posts; // Left empty
}
Author author = builder.build(Author.class);
assert author.getPosts().isEmpty();
// Populate if needed for testing:
author.getPosts().addAll(Arrays.asList(post1, post2, post3));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:
// either person.org or person.org.founder is nullThe default RandomValueGenerator uses generic random values. For domain-specific testing, you may want:
- Email addresses with your company domain
- Realistic phone numbers
- Product SKUs following a pattern
- Addresses in specific regions
- Monetary amounts within realistic ranges
Subclass RandomValueGenerator and override individual random*() methods:
class CompanyTestDataGenerator extends RandomValueGenerator {
@Override
protected String randomString(String propName, int maxLength) {
if (propName != null && propName.toLowerCase().contains("email")) {
// Use company domain instead of generic @domain.com
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);
}
// Override other methods as needed:
@Override
protected Object randomEnum(Class<?> type) {
if (type == OrderStatus.class) {
// Bias towards common statuses for realistic test data
return ThreadLocalRandom.current().nextDouble() < 0.8
? OrderStatus.PENDING
: OrderStatus.COMPLETED;
}
return super.randomEnum(type);
}
}Pass the custom generator when building:
TestEntityBuilder builder = TestEntityBuilder.builder(database)
.valueGenerator(new CompanyTestDataGenerator())
.build();
User user = builder.build(User.class);
assert user.getEmail().endsWith("@mycompany.com");In a DI context, register this as the bean:
// Spring Boot
@Bean
TestEntityBuilder testEntityBuilder(Database database) {
return TestEntityBuilder.builder(database)
.valueGenerator(new CompanyTestDataGenerator())
.build();
}public class MoneyValueGenerator extends RandomValueGenerator {
@Override
protected BigDecimal randomBigDecimal(int precision, int scale) {
// Generate prices in a 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);
}
}✅ Good: Integration test with database
@Test
void whenSaving_thenCanRetrieve() {
Product product = builder.save(Product.class);
Product found = database.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
// ... test proceeds
}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();
}For shared domain-specific setup, encapsulate build patterns in an instance helper class
rather than a static factory. In a DI context, this class can be registered 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;
}
public Order saveShippedOrder() {
Order order = builder.build(Order.class);
order.setStatus(OrderStatus.SHIPPED);
order.setShippedAt(Instant.now());
database.save(order);
return order;
}
}
// Usage in tests:
@SpringBootTest
class OrderControllerTest {
@Autowired OrderTestFactory orderFactory;
@Test
void whenOrderPending_thenCanUpdate() {
Order order = orderFactory.savePendingOrder();
// ... test logic
}
}Each call to build() or save() produces a new instance with fresh random values:
@Test
void whenFetchingMultipleOrders_thenAllUnique() {
Order order1 = builder.save(Order.class);
Order order2 = builder.save(Order.class);
Order order3 = builder.save(Order.class);
assertThat(order1.getId()).isNotEqualTo(order2.getId());
assertThat(order2.getId()).isNotEqualTo(order3.getId());
assertThat(order1.getOrderNumber()).isNotEqualTo(order2.getOrderNumber());
}Register TestEntityBuilder as a @TestConfiguration bean, then inject it alongside
the repository under test:
@TestConfiguration
class TestConfig {
@Bean
TestEntityBuilder testEntityBuilder(Database database) {
return TestEntityBuilder.builder(database).build();
}
}
@SpringBootTest
class OrderRepositoryTest {
@Autowired OrderRepository orderRepository;
@Autowired TestEntityBuilder builder;
@Test
void whenFindingOrdersByStatus_thenReturnsMatching() {
Order pending1 = builder.build(Order.class);
pending1.setStatus(OrderStatus.PENDING);
Order pending2 = builder.build(Order.class);
pending2.setStatus(OrderStatus.PENDING);
Order shipped = builder.build(Order.class);
shipped.setStatus(OrderStatus.SHIPPED);
builder.saveAll(pending1, pending2, shipped);
List<Order> pending = orderRepository.findByStatus(OrderStatus.PENDING);
assertThat(pending).hasSize(2);
}
}@TestScope
@Factory
class TestConfiguration {
@Bean
TestEntityBuilder testEntityBuilder(Database database) {
return TestEntityBuilder.builder(database).build();
}
}
@InjectTest
class OrderControllerTest {
@Inject TestEntityBuilder builder;
@Test
void whenFindingOrdersByStatus_thenReturnsMatching() {
Order pending1 = builder.build(Order.class);
pending1.setStatus(OrderStatus.PENDING);
Order pending2 = builder.build(Order.class);
pending2.setStatus(OrderStatus.PENDING);
Order shipped = builder.build(Order.class);
shipped.setStatus(OrderStatus.SHIPPED);
builder.saveAll(pending1, pending2, shipped);
// ... test assertions
}
}@Test
void whenBuildingOrderWithCustomer_thenBothPopulated() {
Order order = builder.build(Order.class);
// Customer is recursively built because of @ManyToOne(cascade=PERSIST)
assertThat(order.getCustomer()).isNotNull();
// Before persist, @Id values are typically unset
// (0 for primitive IDs, null for boxed IDs).
assertThat(order.getCustomer().getName()).isNotNull();
// Saving cascades to customer:
Order saved = builder.save(Order.class);
assertThat(saved.getId()).isNotNull();
assertThat(saved.getCustomer().getId()).isNotNull();
}// Custom generator for your domain
class ECommerceTestDataGenerator extends RandomValueGenerator {
@Override
protected BigDecimal randomBigDecimal(int precision, int scale) {
// Product prices typically range $10-$500
return BigDecimal.valueOf(
ThreadLocalRandom.current().nextDouble(10.0, 500.0)
).setScale(2, RoundingMode.HALF_UP);
}
}
@Test
void usingCustomGenerator() {
TestEntityBuilder builder = TestEntityBuilder.builder(database)
.valueGenerator(new ECommerceTestDataGenerator())
.build();
Product product = builder.build(Product.class);
assertThat(product.getPrice())
.isBetween(BigDecimal.TEN, BigDecimal.valueOf(500.0));
}Cause: The class you're trying to build is not registered as an Ebean entity.
Solution: Ensure the class is annotated with @Entity and registered with the Database:
@Entity
@Table(name = "products")
public class Product {
// ...
}Cause: TestEntityBuilder does not populate:
@Idfields (identity/primary key; left unset until persist)@Versionfields (optimistic locking; left unset until persist)@Transientfields@OneToManycollections- Non-cascade
@ManyToOnerelationships
Solution: Set only the fields your test scenario cares about, then persist.
@Id and @Version are usually database-managed and should typically be left
unset before save:
Product product = builder.build(Product.class);
product.setName("specific-name"); // test-specific override
database.save(product); // database assigns @Id/@VersionCause: Two or more entities mutually reference each other without cycle detection.
Solution: This should be handled automatically by cycle detection. If not, manually set one reference to null:
Person person = builder.build(Person.class);
person.getOrganization().setFounder(null); // Break cycleCause: Default RandomValueGenerator uses true random values, which aren't suitable when your test needs predictable data.
Solution: Create a custom generator that produces deterministic values:
class DeterministicTestDataGenerator extends RandomValueGenerator {
private int counter = 0;
@Override
protected String randomString(String propName, int maxLength) {
return "test_" + (counter++);
}
}TestEntityBuilder accelerates test development by:
- Reducing boilerplate — No need to manually set every field
- Improving readability — Tests focus on what matters, not setup
- Enabling variety — Each build produces distinct random values
- Respecting constraints — Column lengths and decimal scales are enforced
- Supporting customization — Extend
RandomValueGeneratorfor domain needs