diff --git a/docs/guides/README.md b/docs/guides/README.md index e265d5187..f5106ec8d 100644 --- a/docs/guides/README.md +++ b/docs/guides/README.md @@ -19,6 +19,12 @@ existing Maven project. Complete the steps in order. |-------|-------------| | [Lombok with Ebean entity beans](lombok-with-ebean-entity-beans.md) | Which Lombok annotations to use and avoid on entity beans; why `@Data` is incompatible with Ebean; how to use `@Getter` + `@Setter` + `@Accessors(chain = true)` | +## Testing + +| Guide | Description | +|-------|-------------| +| [Testing with TestEntityBuilder](testing-with-testentitybuilder.md) | Rapidly create test entity instances with auto-populated random values; manage relationships and cascades; customize value generation for domain-specific testing needs | + ## Database migrations | Guide | Description | diff --git a/docs/guides/testing-with-testentitybuilder.md b/docs/guides/testing-with-testentitybuilder.md new file mode 100644 index 000000000..c7b9e60d5 --- /dev/null +++ b/docs/guides/testing-with-testentitybuilder.md @@ -0,0 +1,566 @@ +# Guide: Testing with TestEntityBuilder + +## Purpose + +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 + +--- + +## Setup & Dependencies + +### Add ebean-test to Your Project + +The `TestEntityBuilder` class is provided by the `ebean-test` module. + +**Maven:** +```xml + + io.ebean + ebean-test + 16.5.0 + test + +``` + +**Gradle:** +```gradle +testImplementation 'io.ebean:ebean-test:16.5.0' +``` + +Replace `16.5.0` with the version matching your Ebean installation. + +### Import the Class + +```java +import io.ebean.test.TestEntityBuilder; +``` + +--- + +## Basic Usage + +### Create a Builder Instance + +`TestEntityBuilder` uses a builder pattern for configuration: + +```java +TestEntityBuilder builder = TestEntityBuilder.builder(DB.getDefault()).build(); +``` + +The `Database` parameter (e.g., `DB.getDefault()`) specifies which Ebean database instance to use for entity type lookups and persistence operations. + +### Build an Entity (In-Memory) + +The `build()` method creates an instance with populated fields **without persisting to the database:** + +```java +Product product = builder.build(Product.class); + +// Fields are populated: +// - id: null (not populated) +// - name: random UUID-based string +// - price: random BigDecimal +// - inStock: true +// - createdAt: current instant +// - etc. + +// Not persisted yet: +assert product.getId() == null; +``` + +### Build and Save (Persist to Database) + +The `save()` method creates, persists, and returns an entity with the database-assigned `@Id`: + +```java +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; +assert found.getName().equals(product.getName()); +``` + +--- + +## Type-Specific Value Generation + +`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)` | | +| `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 | + +### String Length Constraints + +`TestEntityBuilder` respects column length constraints defined in the entity: + +```java +@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 the database column precision and scale: + +```java +@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: + +```java +@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 (caller can populate if needed): + +```java +@Entity +public class BlogPost { + @ManyToOne + private Author author; // No cascade = left null +} + +BlogPost post = builder.build(BlogPost.class); +assert post.getAuthor() == null; + +// Set it manually if needed: +post.setAuthor(manuallyCreatedAuthor); +``` + +### Collection Relationships: Left Empty + +Collection relationships (`@OneToMany`, `@ManyToMany`) are left empty: + +```java +@Entity +public class Author { + @OneToMany(mappedBy = "author") + private List 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)); +``` + +### 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: + +```java +@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 null +``` + +--- + +## Custom Value Generators + +### 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 +- Product SKUs following a pattern +- Addresses in specific regions +- Monetary amounts within realistic ranges + +### Creating a Custom Generator + +Subclass `RandomValueGenerator` and override individual `random*()` methods: + +```java +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); + } +} +``` + +### Using a Custom Generator + +Pass the custom generator when building: + +```java +TestEntityBuilder builder = TestEntityBuilder.builder(DB.getDefault()) + .valueGenerator(new CompanyTestDataGenerator()) + .build(); + +User user = builder.build(User.class); +assert user.getEmail().endsWith("@mycompany.com"); +``` + +### Example: Money Type + +```java +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); + } +} +``` + +--- + +## Best Practices + +### 1. Use for Integration Tests, Not Unit Tests + +✅ **Good:** Integration test with database +```java +@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 +```java +@Test +void whenNameIsBlank_thenThrowException() { + Product product = builder.build(Product.class); // name is random! + product.setName(""); // have to override anyway + // ... test proceeds +} +``` + +### 2. Override Values for Specific Test Scenarios + +When test requirements demand specific field values, manually override after building: + +```java +@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 + +Encapsulate common test entity setups in factory methods: + +```java +public class TestDataFactory { + + private static final TestEntityBuilder builder = + TestEntityBuilder.builder(DB.getDefault()).build(); + + public static Order createPendingOrder() { + Order order = builder.build(Order.class); + order.setStatus(OrderStatus.PENDING); + return order; + } + + public static Order createShippedOrder() { + Order order = builder.build(Order.class); + order.setStatus(OrderStatus.SHIPPED); + order.setShippedAt(Instant.now()); + return order; + } + + public static Order savePendingOrder() { + Order order = createPendingOrder(); + DB.save(order); + return order; + } +} + +// Usage in tests: +@Test +void whenOrderPending_thenCanUpdate() { + Order order = TestDataFactory.savePendingOrder(); + // ... test logic +} +``` + +### 4. Build Multiple Distinct Instances + +Each call to `build()` or `save()` produces a new instance with fresh random values: + +```java +@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()); +} +``` + +--- + +## Complete Examples + +### Example 1: Integration Test with Repository + +```java +@SpringBootTest +class OrderRepositoryTest { + + @Autowired + private OrderRepository orderRepository; + + private TestEntityBuilder builder; + + @BeforeEach + void setup() { + builder = TestEntityBuilder.builder(DB.getDefault()).build(); + } + + @Test + void whenFindingOrdersByStatus_thenReturnsMatching() { + // Create test data quickly + 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); + + DB.saveAll(pending1, pending2, shipped); + + // Test the query + List pending = orderRepository.findByStatus(OrderStatus.PENDING); + assertThat(pending).hasSize(2); + } +} +``` + +### Example 2: Recursive Relationship Building + +```java +@Test +void whenBuildingOrderWithCustomer_thenBothPopulated() { + Order order = builder.build(Order.class); + + // Customer is recursively built because of @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 + +```java +// 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(DB.getDefault()) + .valueGenerator(new ECommerceTestDataGenerator()) + .build(); + + Product product = builder.build(Product.class); + assertThat(product.getPrice()) + .isBetween(BigDecimal.TEN, BigDecimal.valueOf(500.0)); +} +``` + +--- + +## Troubleshooting + +### "No BeanDescriptor found for [Class] — is it an @Entity?" + +**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: +```java +@Entity +@Table(name = "products") +public class Product { + // ... +} +``` + +### Fields are null even though I expected them to be populated + +**Cause:** `TestEntityBuilder` does **not** populate: +- `@Id` fields (identity/primary key) +- `@Version` fields (optimistic locking) +- `@Transient` fields +- `@OneToMany` collections +- Non-cascade `@ManyToOne` relationships + +**Solution:** Set these fields manually in your test if needed: +```java +Product product = builder.build(Product.class); +product.setId(1L); // Set @Id manually +``` + +### Building recursive relationships causes StackOverflowError + +**Cause:** 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: +```java +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, which aren't suitable when your test needs predictable data. + +**Solution:** Create a custom generator that produces deterministic values: +```java +class DeterministicTestDataGenerator extends RandomValueGenerator { + private int counter = 0; + + @Override + protected String randomString(String propName, int maxLength) { + return "test_" + (counter++); + } +} +``` + +--- + +## Summary + +`TestEntityBuilder` accelerates test development by: + +1. **Reducing boilerplate** — No need to manually set every field +2. **Improving readability** — Tests focus on what matters, not setup +3. **Enabling variety** — Each build produces distinct random values +4. **Respecting constraints** — Column lengths and decimal scales are enforced +5. **Supporting customization** — Extend `RandomValueGenerator` for domain needs + +Use it for **integration tests and persistence layer tests** where you need representative data without specific values. For **validation tests** and **specific edge cases**, manually set the required field values.