Best Practices
- 1. Keep Tests Simple and Focused
- 2. Prefer Direct Property Setting Over Post-Conditions
- 3. Avoid Over-Specification in Tests
- 4. Make Tests Readable with Helper Methods
- 5. Configure Once, Reuse Everywhere
- 6. Test Edge Cases and Boundary Conditions
- 7. Make Tests Reproducible
- 8. Define Type-Specific Generation Rules
- 9. Configure Field-Level Rules for Complex Objects
On This Page
- 1. Keep Tests Simple and Focused
- 2. Prefer Direct Property Setting Over Post-Conditions
- 3. Avoid Over-Specification in Tests
- 4. Make Tests Readable with Helper Methods
- 5. Configure Once, Reuse Everywhere
- 6. Test Edge Cases and Boundary Conditions
- 7. Make Tests Reproducible
- 8. Define Type-Specific Generation Rules
- 9. Configure Field-Level Rules for Complex Objects
This guide provides practical tips and best practices for using Fixture Monkey effectively in your tests.
1. Keep Tests Simple and Focused
- Only customize what matters for the test: Don’t set values for fields that don’t affect the test’s behavior.
// Good - only set what's relevant to the test
@Test
void shouldCalculateDiscount() {
Product product = fixtureMonkey.giveMeBuilder(Product.class)
.set("price", 100.0) // Only price matters for discount calculation
.sample();
double discount = productService.calculateDiscount(product);
assertThat(discount).isEqualTo(10.0);
}
// Good - only set what's relevant to the test
@Test
fun shouldCalculateDiscount() {
val product = fixtureMonkey.giveMeBuilder<Product>()
.setExpGetter(Product::getPrice, 100.0) // Only price matters for discount calculation
.sample()
val discount = productService.calculateDiscount(product)
assertThat(discount).isEqualTo(10.0)
}
2. Prefer Direct Property Setting Over Post-Conditions
- Use direct property setting when possible: Instead of using
setPostCondition
which can cause performance issues due to rejection sampling, prefer direct configuration withset
orsize
.
// Less efficient - uses post-conditions with rejection sampling
@Test
void lessEfficientOrderTest() {
Order order = fixtureMonkey.giveMeBuilder(Order.class)
.setPostCondition(o -> o.getItems().size() > 0) // Performance cost: may reject many samples
.setPostCondition(o -> o.getTotalAmount() > 100) // Additional performance cost
.sample();
OrderResult result = orderService.process(order);
assertThat(result.isSuccessful()).isTrue();
}
// More efficient - uses direct property setting
@Test
void moreEfficientOrderTest() {
Order order = fixtureMonkey.giveMeBuilder(Order.class)
.size("items", 1, 5) // Directly set collection size
.set("totalAmount", Arbitraries.integers().greaterThan(100)) // Directly set valid range
.sample();
OrderResult result = orderService.process(order);
assertThat(result.isSuccessful()).isTrue();
}
// When to use setPostCondition - for truly complex validation that cannot be expressed with property setting
@Test
void complexValidationTest() {
// Only use setPostCondition for complex validations that cannot be expressed otherwise
Invoice invoice = fixtureMonkey.giveMeBuilder(Invoice.class)
.set("items", fixtureMonkey.giveMe(InvoiceItem.class, 3))
.set("customerType", CustomerType.BUSINESS)
.setPostCondition(inv -> inv.calculateTotal().compareTo(inv.getItems().stream()
.map(InvoiceItem::getAmount).reduce(BigDecimal.ZERO, BigDecimal::add)) == 0)
.sample();
assertThat(invoiceService.validate(invoice)).isTrue();
}
// Less efficient - uses post-conditions with rejection sampling
@Test
fun lessEfficientOrderTest() {
val order = fixtureMonkey.giveMeBuilder<Order>()
.setPostCondition { it.items.isNotEmpty() } // Performance cost: may reject many samples
.setPostCondition { it.totalAmount > 100 } // Additional performance cost
.sample()
val result = orderService.process(order)
assertThat(result.isSuccessful).isTrue()
}
// More efficient - uses direct property setting
@Test
fun moreEfficientOrderTest() {
val order = fixtureMonkey.giveMeBuilder<Order>()
.setExpSize(Order::getItems, 1, 5) // Directly set collection size
.setExpGetter(Order::getTotalAmount, Arbitraries.integers().greaterThan(100)) // Directly set valid range
.sample()
val result = orderService.process(order)
assertThat(result.isSuccessful).isTrue()
}
// When to use setPostCondition - for truly complex validation that cannot be expressed with property setting
@Test
fun complexValidationTest() {
// Only use setPostCondition for complex validations that cannot be expressed otherwise
val invoice = fixtureMonkey.giveMeBuilder<Invoice>()
.setExpGetter(Invoice::getItems, fixtureMonkey.giveMe<InvoiceItem>(3))
.setExpGetter(Invoice::getCustomerType, CustomerType.BUSINESS)
.setPostCondition { inv ->
inv.calculateTotal() == inv.items.map { it.amount }.fold(BigDecimal.ZERO, BigDecimal::add)
}
.sample()
assertThat(invoiceService.validate(invoice)).isTrue()
}
3. Avoid Over-Specification in Tests
- Don’t overspecify test requirements: Test only what needs to be tested.
// Bad - overspecified test with unnecessary details
@Test
void badTestTooManyDetails() {
User user = fixtureMonkey.giveMeBuilder(User.class)
.set("id", 1L)
.set("name", "John")
.set("email", "john@example.com")
.set("address.street", "123 Main St")
.set("address.city", "New York")
.set("address.zipCode", "10001")
.set("registrationDate", LocalDate.of(2023, 1, 1))
.sample();
// Test is just checking if email is valid
assertThat(userValidator.isEmailValid(user)).isTrue();
}
// Good - only specify what's needed for the test
@Test
void goodTestOnlyNeededDetails() {
User user = fixtureMonkey.giveMeBuilder(User.class)
.set("email", "john@example.com") // Only email matters for this test
.sample();
assertThat(userValidator.isEmailValid(user)).isTrue();
}
// Bad - overspecified test with unnecessary details
@Test
fun badTestTooManyDetails() {
val user = fixtureMonkey.giveMeBuilder<User>()
.setExpGetter(User::getId, 1L)
.setExpGetter(User::getName, "John")
.setExpGetter(User::getEmail, "john@example.com")
.setExpGetter(User::getAddress, { address ->
address.setExpGetter(Address::getStreet, "123 Main St")
.setExpGetter(Address::getCity, "New York")
.setExpGetter(Address::getZipCode, "10001")
})
.setExpGetter(User::getRegistrationDate, LocalDate.of(2023, 1, 1))
.sample()
// Test is just checking if email is valid
assertThat(userValidator.isEmailValid(user)).isTrue()
}
// Good - only specify what's needed for the test
@Test
fun goodTestOnlyNeededDetails() {
val user = fixtureMonkey.giveMeBuilder<User>()
.setExpGetter(User::getEmail, "john@example.com") // Only email matters for this test
.sample()
assertThat(userValidator.isEmailValid(user)).isTrue()
}
4. Make Tests Readable with Helper Methods
- Create helper methods to improve test readability: Encapsulate fixture setup for better readability.
@Test
void testOrderProcessing() {
// Helper methods returning ArbitraryBuilder for more flexibility
Order standardOrder = standardOrderBuilder()
.set("customerNote", "Please deliver quickly") // Test-specific customization
.sample();
Customer premiumCustomer = premiumCustomerBuilder()
.set("membershipYears", 5) // Test-specific customization
.sample();
OrderResult result = orderService.process(standardOrder, premiumCustomer);
assertThat(result.hasDiscount()).isTrue();
assertThat(result.getDiscount()).isGreaterThanOrEqualTo(standardOrder.getTotalAmount() * 0.1);
}
// Helper methods return ArbitraryBuilder instead of instances
private ArbitraryBuilder<Order> standardOrderBuilder() {
return fixtureMonkey.giveMeBuilder(Order.class)
.size("items", 3, 5)
.set("totalAmount", Arbitraries.integers().between(100, 500));
}
private ArbitraryBuilder<Customer> premiumCustomerBuilder() {
return fixtureMonkey.giveMeBuilder(Customer.class)
.set("premiumMember", true)
.set("membershipYears", 2);
}
@Test
void testOrderWithSpecialDiscount() {
// Reuse the same builder with different customizations
Order bulkOrder = standardOrderBuilder()
.size("items", 10, 20) // Different configuration for this test
.set("totalAmount", Arbitraries.integers().between(500, 1000))
.sample();
Customer vipCustomer = premiumCustomerBuilder()
.set("membershipYears", 10) // Different configuration for this test
.set("vipStatus", true)
.sample();
OrderResult result = orderService.processWithSpecialDiscount(bulkOrder, vipCustomer);
assertThat(result.getDiscount()).isGreaterThanOrEqualTo(bulkOrder.getTotalAmount() * 0.2);
}
@Test
fun testOrderProcessing() {
// Helper methods returning ArbitraryBuilder for more flexibility
val standardOrder = standardOrderBuilder()
.setExpGetter(Order::getCustomerNote, "Please deliver quickly") // Test-specific customization
.sample()
val premiumCustomer = premiumCustomerBuilder()
.setExpGetter(Customer::getMembershipYears, 5) // Test-specific customization
.sample()
val result = orderService.process(standardOrder, premiumCustomer)
assertThat(result.hasDiscount()).isTrue()
assertThat(result.discount).isGreaterThanOrEqualTo(standardOrder.totalAmount * 0.1)
}
// Helper methods return ArbitraryBuilder instead of instances
private fun standardOrderBuilder(): ArbitraryBuilder<Order> {
return fixtureMonkey.giveMeBuilder<Order>()
.setExpSize(Order::getItems, 3, 5)
.setExpGetter(Order::getTotalAmount, Arbitraries.integers().between(100, 500))
}
private fun premiumCustomerBuilder(): ArbitraryBuilder<Customer> {
return fixtureMonkey.giveMeBuilder<Customer>()
.setExpGetter(Customer::isPremiumMember, true)
.setExpGetter(Customer::getMembershipYears, 2)
}
@Test
fun testOrderWithSpecialDiscount() {
// Reuse the same builder with different customizations
val bulkOrder = standardOrderBuilder()
.setExpSize(Order::getItems, 10, 20) // Different configuration for this test
.setExpGetter(Order::getTotalAmount, Arbitraries.integers().between(500, 1000))
.sample()
val vipCustomer = premiumCustomerBuilder()
.setExpGetter(Customer::getMembershipYears, 10) // Different configuration for this test
.setExpGetter(Customer::isVipStatus, true)
.sample()
val result = orderService.processWithSpecialDiscount(bulkOrder, vipCustomer)
assertThat(result.discount).isGreaterThanOrEqualTo(bulkOrder.totalAmount * 0.2)
}
5. Configure Once, Reuse Everywhere
- Create specialized fixture configurations: Define common configurations once and reuse them.
// Define common configurations
public class TestFixtures {
public static final FixtureMonkey TEST_FIXTURE_MONKEY = FixtureMonkey.builder()
.nullInject(0.0) // No null values
.build();
public static ArbitraryBuilder<User> validUser() {
return TEST_FIXTURE_MONKEY.giveMeBuilder(User.class)
.set("email", "test@example.com")
.set("active", true);
}
}
// Use in tests
@Test
void testUserRegistration() {
User user = TestFixtures.validUser().sample();
userService.register(user);
assertThat(userRepository.findByEmail(user.getEmail())).isNotNull();
}
// Define common configurations
object TestFixtures {
val TEST_FIXTURE_MONKEY = FixtureMonkey.builder()
.nullInject(0.0) // No null values
.build()
fun validUser(): ArbitraryBuilder<User> {
return TEST_FIXTURE_MONKEY.giveMeBuilder<User>()
.setExpGetter(User::getEmail, "test@example.com")
.setExpGetter(User::isActive, true)
}
}
// Use in tests
@Test
fun testUserRegistration() {
val user = TestFixtures.validUser().sample()
userService.register(user)
assertThat(userRepository.findByEmail(user.email)).isNotNull()
}
6. Test Edge Cases and Boundary Conditions
- Generate test cases with boundary values: Test min/max values and edge cases.
@Test
void testUnderageUserCannotAccessAdultContent() {
// Test with underage user
User underage = fixtureMonkey.giveMeBuilder(User.class)
.set("age", 17) // Just below legal age
.sample();
assertThat(userService.canAccessAdultContent(underage)).isFalse();
}
@Test
void testOfAgeUserCanAccessAdultContent() {
// Test with exactly of-age user
User ofAge = fixtureMonkey.giveMeBuilder(User.class)
.set("age", 18) // Exactly legal age
.sample();
assertThat(userService.canAccessAdultContent(ofAge)).isTrue();
}
@Test
fun testUnderageUserCannotAccessAdultContent() {
// Test with underage user
val underage = fixtureMonkey.giveMeBuilder<User>()
.setExpGetter(User::getAge, 17) // Just below legal age
.sample()
assertThat(userService.canAccessAdultContent(underage)).isFalse()
}
@Test
fun testOfAgeUserCanAccessAdultContent() {
// Test with exactly of-age user
val ofAge = fixtureMonkey.giveMeBuilder<User>()
.setExpGetter(User::getAge, 18) // Exactly legal age
.sample()
assertThat(userService.canAccessAdultContent(ofAge)).isTrue()
}
7. Make Tests Reproducible
- Use a fixed seed for tests that need reproducibility: This ensures consistent test results.
@Test
@Seed(123L) // Makes the test reproducible
void testComplexBehavior() {
List<Order> orders = fixtureMonkey.giveMe(Order.class, 100);
OrderSummary summary = orderService.summarize(orders);
assertThat(summary.getTotalAmount()).isGreaterThan(0);
}
@Test
@Seed(123L) // Makes the test reproducible
fun testComplexBehavior() {
val orders = fixtureMonkey.giveMe<Order>(100)
val summary = orderService.summarize(orders)
assertThat(summary.totalAmount).isGreaterThan(0)
}
8. Define Type-Specific Generation Rules
- Register custom rules for specific types: Define how types should be generated consistently across all tests.
- When to use: Use this approach when you need to control how a specific type is generated everywhere it appears in your tests.
// Create custom Fixture Monkey with type-specific generation rules
FixtureMonkey fixtureMonkey = FixtureMonkey.builder()
// Register a custom value for a simple type
.register(String.class, it -> it.giveMeBuilder("custom-string"))
// Register a custom rule for Email type
// This affects ALL Email instances created by this fixture monkey
.register(Email.class, fixture -> fixture.giveMeBuilder(new Email("test@example.com")))
// Register a custom rule for a complex type with validation
// Applies these rules whenever User instances are created
.register(User.class, fixture -> fixture
.setPostCondition(user -> user.getAge() >= 18)
.set("status", "ACTIVE"))
// Register a factory method for a type
.register(Product.class, fixture -> fixture
.instantiate(factoryMethod("createDefault")
.parameter(String.class, "productName"))
.set("productName", "Standard Product"))
.build();
// Using the custom registered instance
String customString = fixtureMonkey.giveMeOne(String.class); // Returns "custom-string"
Email email = fixtureMonkey.giveMeOne(Email.class); // Returns Email with "test@example.com"
User user = fixtureMonkey.giveMeOne(User.class); // Returns an adult user with ACTIVE status
// Create custom Fixture Monkey with type-specific generation rules
val fixtureMonkey = FixtureMonkey.builder()
// Register a custom value for a simple type
.register(String::class.java) { it.giveMeBuilder("custom-string") }
// Register a custom rule for Email type
// This affects ALL Email instances created by this fixture monkey
.register(Email::class.java) { fixture ->
fixture.giveMeBuilder(Email("test@example.com"))
}
// Register a custom rule for a complex type with validation
// Applies these rules whenever User instances are created
.register(User::class.java) { fixture ->
fixture
.setPostCondition { user -> user.age >= 18 }
.set("status", "ACTIVE")
}
// Register a factory method for a type
.register(Product::class.java) { fixture ->
fixture
.instantiate(factoryMethod("createDefault")
.parameter(String::class.java, "productName"))
.set("productName", "Standard Product")
}
.build()
// Using the custom registered instance
val customString = fixtureMonkey.giveMeOne<String>() // Returns "custom-string"
val email = fixtureMonkey.giveMeOne<Email>() // Returns Email with "test@example.com"
val user = fixtureMonkey.giveMeOne<User>() // Returns an adult user with ACTIVE status
9. Configure Field-Level Rules for Complex Objects
- Define rules for individual fields within objects: Customize how individual properties are generated within complex objects.
- When to use: Use this approach when you need fine-grained control over individual fields within a complex object, applying different rules to different properties within the same class.
FixtureMonkey fixtureMonkey = FixtureMonkey.builder()
// Register rules for each field in a complex type
// This provides field-by-field control, unlike the type-level register method
.registerGroup(ProductDetails.class, group -> group
// Each field can have its own specific generation rules
.register("name", Arbitraries.strings().alpha().ofMinLength(3).ofMaxLength(50))
.register("sku", Arbitraries.strings().numeric().ofLength(10))
.register("description", Arbitraries.strings().ofMinLength(10).ofMaxLength(500))
.register("inStock", true) // Always in stock for tests
.register("price", Arbitraries.doubles().between(1.0, 999.99))
.register("weight", Arbitraries.doubles().between(0.1, 100.0))
)
.build();
// The generated ProductDetails will have each field following its specific rule
ProductDetails product = fixtureMonkey.giveMeOne(ProductDetails.class);
// name - alphabetic string between 3-50 chars
// sku - numeric string of exactly 10 chars
// description - any string between 10-500 chars
// inStock - always true
// price - between 1.0 and 999.99
// weight - between 0.1 and 100.0
val fixtureMonkey = FixtureMonkey.builder()
// Register rules for each field in a complex type
// This provides field-by-field control, unlike the type-level register method
.registerGroup(ProductDetails::class.java) { group ->
group
// Each field can have its own specific generation rules
.register("name", Arbitraries.strings().alpha().ofMinLength(3).ofMaxLength(50))
.register("sku", Arbitraries.strings().numeric().ofLength(10))
.register("description", Arbitraries.strings().ofMinLength(10).ofMaxLength(500))
.register("inStock", true) // Always in stock for tests
.register("price", Arbitraries.doubles().between(1.0, 999.99))
.register("weight", Arbitraries.doubles().between(0.1, 100.0))
}
.build()
// The generated ProductDetails will have each field following its specific rule
val product = fixtureMonkey.giveMeOne<ProductDetails>()
// name - alphabetic string between 3-50 chars
// sku - numeric string of exactly 10 chars
// description - any string between 10-500 chars
// inStock - always true
// price - between 1.0 and 999.99
// weight - between 0.1 and 100.0