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:

  • @Id fields (identity)
  • @Version fields (optimistic locking)
  • @Transient fields
  • @OneToMany collections
  • Non-cascade @ManyToOne relationships

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++);
  }
}