Type-safe, fluent assertion library for Java that makes testing a breeze.
Fabut eliminates boilerplate in your tests by generating type-safe assertion builders from your domain classes. No more string-based property names, no more forgetting to assert fields.
Why Fabut?
Before Fabut:
@Test void testCreateOrder() { Order order = orderService.create(customer, items); assertNotNull(order.getId()); assertEquals("PENDING", order.getStatus()); assertEquals(customer.getId(), order.getCustomerId()); assertEquals(new BigDecimal("99.99"), order.getTotal()); assertNotNull(order.getCreatedAt()); // Did I forget any fields? 🤔 }
With Fabut:
@Test void testCreateOrder() { Order order = orderService.create(customer, items); OrderAssert.created(order) .id_is_not_null() .status_is("PENDING") .customerId_is(customer.getId()) .total_is(new BigDecimal("99.99")) .createdAt_is_not_null() .verify(); // Fails if any field is not covered ✓ }
Features
- Compile-time safety - Typos in field names? Impossible.
- Complete coverage - Fabut fails if you forget to assert a field
- Minimal boilerplate - Just
created(object)- no need to passthis - Optional support - First-class support for
Optional<T>fields - Default assertions - Declare field defaults with
@AssertDefault— no need to assert them in every test - Auto-ignore fields - Mark audit fields as ignored once, never think about them again
- Snapshot testing - Track database changes automatically
- Usage tracking - Detect suboptimal data fetching with automatic field-level access analysis
- IDE friendly - Full autocomplete for all assertion methods
Quick Start
1. Add dependency
<dependency> <groupId>cloud.alchemy</groupId> <artifactId>fabut</artifactId> <version>5.6.0-RELEASE</version> <scope>test</scope> </dependency>
2. Annotate your class
@Assertable(ignoredFields = {"version", "createdAt", "updatedAt"}) public class Order { private Long id; private String status; private Long customerId; private BigDecimal total; private Optional<String> notes; private LocalDateTime createdAt; private LocalDateTime updatedAt; private Long version; // getters... }
3. Configure annotation processor
<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <version>3.14.0</version> <configuration> <annotationProcessors> <annotationProcessor>cloud.alchemy.fabut.processor.AssertableProcessor</annotationProcessor> </annotationProcessors> </configuration> </plugin>
4. Write clean tests
class OrderServiceTest extends Fabut { @Test void createOrder_withValidData_createsOrder() { Order order = orderService.create(customerId, items); OrderAssert.created(order) .id_is_not_null() .status_is("PENDING") .customerId_is(customerId) .total_is(new BigDecimal("149.99")) .notes_is_empty() .verify(); } }
Real-World Examples
Testing CRUD Operations
@Test void updateOrder_changesStatusAndAddsNote() { // Arrange Order order = createTestOrder(); takeSnapshot(); // Act orderService.ship(order.getId(), "Shipped via FedEx"); // Assert - only specify what changed OrderAssert.updated(order) .status_is("SHIPPED") .notes_is("Shipped via FedEx") .verify(); } @Test void cancelOrder_deletesOrder() { Order order = createTestOrder(); takeSnapshot(); orderService.cancel(order.getId()); OrderAssert.deleted(order).verify(); }
Testing Optional Fields
@Assertable public class UserProfile { private Long id; private String username; private Optional<String> bio; private Optional<String> avatarUrl; // getters... } @Test void createProfile_withoutOptionalFields() { UserProfile profile = profileService.create("john_doe"); UserProfileAssert.created(profile) .id_is_not_null() .username_is("john_doe") .bio_is_empty() // Optional.empty() .avatarUrl_is_empty() .verify(); } @Test void updateProfile_addsBio() { UserProfile profile = createTestProfile(); takeSnapshot(); profileService.updateBio(profile.getId(), "Hello, world!"); UserProfileAssert.updated(profile) .bio_is("Hello, world!") // Unwraps Optional for you .verify(); }
Testing Complex Objects
@Assertable(ignoredFields = {"audit"}) public class Invoice { private Long id; private String invoiceNumber; private InvoiceStatus status; private BigDecimal subtotal; private BigDecimal tax; private BigDecimal total; private Optional<LocalDate> dueDate; private AuditInfo audit; // getters... } @Test void generateInvoice_calculatesCorrectTotals() { Invoice invoice = invoiceService.generate(orderId); InvoiceAssert.created(invoice) .id_is_not_null() .invoiceNumber_is_not_null() .status_is(InvoiceStatus.DRAFT) .subtotal_is(new BigDecimal("100.00")) .tax_is(new BigDecimal("10.00")) .total_is(new BigDecimal("110.00")) .dueDate_is_empty() .verify(); }
Testing with Ignored Fields
Fields in ignoredFields are automatically skipped - perfect for audit columns, versions, and timestamps:
@Assertable(ignoredFields = {"id", "version", "createdAt", "createdBy", "updatedAt", "updatedBy"}) public class Product { private Long id; private String name; private BigDecimal price; private Long version; private LocalDateTime createdAt; private String createdBy; private LocalDateTime updatedAt; private String updatedBy; } @Test void createProduct_setsNameAndPrice() { Product product = productService.create("Widget", new BigDecimal("29.99")); // No need to handle id, version, or audit fields! ProductAssert.created(product) .name_is("Widget") .price_is(new BigDecimal("29.99")) .verify(); }
Default Assertions
Fields annotated with @AssertDefault are automatically asserted when verify() is called on a created() builder, unless you explicitly assert them:
@Assertable(ignoredFields = {"version"}) public class Order { private Long id; private String status; @AssertDefault("false") private Boolean archived; @AssertDefault("empty") private Optional<String> notes; private Long version; // getters... } @Test void createOrder_defaultFieldsAutoAsserted() { Order order = orderService.create(customerId); // archived=false and notes=empty are auto-asserted by @AssertDefault OrderAssert.created(order) .id_is_not_null() .status_is("PENDING") .verify(); // archived and notes defaults checked automatically } @Test void createOrder_overrideDefault() { Order order = orderService.createArchived(customerId); // Explicit assertion overrides the default OrderAssert.created(order) .id_is_not_null() .status_is("PENDING") .archived_is(true) // Override @AssertDefault("false") .notes_is("Auto-archived") .verify(); }
Supported @AssertDefault values:
| Value | Valid on | Behavior |
|---|---|---|
"true" / "false" |
Boolean, boolean, Optional<Boolean> |
Asserts boolean value |
"empty" |
Optional<?> |
Asserts Optional.empty() |
"null" |
Any reference type | Asserts null |
| Numeric string | Numeric types, Optional<NumericType> |
Asserts numeric value |
Invalid combinations (e.g. @AssertDefault("false") on a String field) produce compile-time errors.
Generated Methods Reference
For each field, Fabut generates intuitive assertion methods:
| Field Type | Methods |
|---|---|
T field |
field_is(T), field_is_null(), field_is_not_null(), field_is_ignored() |
Optional<T> field |
All above + field_is_empty(), field_is_not_empty(), field_is(InnerT) |
Static Factory Methods
| Method | Purpose |
|---|---|
XxxAssert.created(obj) |
Assert a newly created object (all fields must be covered) |
XxxAssert.updated(entity) |
Assert changed fields against snapshot |
XxxAssert.deleted(entity) |
Assert entity was deleted |
Note: Each factory method also has an overload that takes
Fabutas the first parameter (e.g.,created(fabut, obj)). Prefer the simpler form withoutFabut-- it resolves the instance automatically viaThreadLocal. Only use the explicitFabutparameter when the automatic resolution is not available (e.g., assertions outside the test class).
Configuration
public class BaseTest extends Fabut { public BaseTest() { // Classes tracked for database snapshot testing + usage tracking entityTypes.add(Order.class); entityTypes.add(Customer.class); entityTypes.add(Product.class); // Complex types for deep comparison + usage tracking complexTypes.add(OrderDto.class); // Types tracked for usage analysis only (no snapshot/assertion) trackedTypes.add(OrderFindTuple.class); trackedTypes.add(CustomerSearchTuple.class); // Types to skip during deep comparison ignoredTypes.add(Timestamp.class); ignoredTypes.add(Instant.class); } @Override protected List<?> findAll(Class<?> entityClass) { // Hook into your persistence layer return entityManager.createQuery("FROM " + entityClass.getSimpleName()).getResultList(); } @Override protected Object findById(Class<?> entityClass, Object id) { return entityManager.find(entityClass, id); } }
Usage Tracking
Fabut automatically tracks which fields of fetched objects are actually used during your tests. This helps detect suboptimal data fetching — for example, loading an entire DTO with 18 fields when only 2 are needed.
How It Works
When you call takeSnapshot(), Fabut activates usage tracking:
- ByteBuddy instruments all registered types (
entityTypes,complexTypes,trackedTypes) - Constructors are instrumented to register new objects automatically
- Getters are instrumented to record which fields are accessed
- At test end, a usage report is printed to stdout
No manual registration is needed. Any object of a registered type created after takeSnapshot() is automatically tracked.
Excluding Fields from Tracking
Exclude audit/framework fields that inflate the "unused" count:
public class BaseTest extends Fabut { public BaseTest() { complexTypes.add(OrderDto.class); ignoredFields.put(OrderDto.class, List.of("version", "createdAt", "updatedAt")); } }
Fields in ignoredFields are excluded from both usage percentage calculation and the report.
Disabling Usage Tracking
Set trackUsage = false to disable tracking entirely while keeping snapshot assertions:
public class BaseTest extends Fabut { public BaseTest() { trackUsage = false; // No instrumentation, no usage report entityTypes.add(Order.class); } }
Pausing Tracking During Assertions
Call pauseTracking() after your API call returns so that field access during assertions is not recorded as real usage:
@Test void fetchOrder_usesOnlyRequiredFields() { takeSnapshot(); Order order = orderService.findById(orderId); pauseTracking(); // Access below won't count as usage OrderAssert.created(order) .id_is_not_null() .status_is("PENDING") .verify(); }
Enforcing Usage Threshold
Set usageThreshold to make tests fail when field usage is too low:
public class BaseTest extends Fabut { public BaseTest() { complexTypes.add(OrderDto.class); trackedTypes.add(OrderTuple.class); usageThreshold = 50; // Fail if any class avg usage < 50% } }
When a violation occurs, the test fails with:
USAGE THRESHOLD VIOLATION: minimum 50% required
OrderDto: 25% avg usage (12 instances) — unused: cellId, indexInRow, isEdited, ...
Example Output
USAGE REPORT:
OrderDto: 12 instances fetched
Avg usage: 17%
Commonly unused: cellId, indexInRow, isEdited, valueBoolean, valueDecimal, ...
OrderFindTuple: 5 instances fetched
Accessed: all fields ✓
Order: 1 instance fetched
Avg usage: 0%
WARNING: 1 object fetched but never accessed:
Order[id=42]
Excluding Side-Effect Objects
When your service layer creates objects internally (e.g., DTOs built during postSave or createDto), these pollute the tracking report. Use UsageTracker.unregisterIfActive() in your repository to remove them immediately:
public OrderDto createDto(Order order) { OrderDto dto = new OrderDto(order); UsageTracker.unregisterIfActive(dto); // Not fetched by test, exclude from tracking return dto; }
Filtering Tracked Objects
Override shouldTrackObject() to exclude certain objects from tracking (e.g., uninitialized Hibernate proxies):
@Override protected boolean shouldTrackObject(Object obj) { if (obj instanceof HibernateProxy) { return Hibernate.isInitialized(obj); } return true; }
Type Registration
| Queue | Purpose | Snapshot | Assertion | Usage Tracked |
|---|---|---|---|---|
entityTypes |
JPA entities | Yes | Yes | Yes |
complexTypes |
DTOs, value objects | No | Yes | Yes |
trackedTypes |
Tuples, projections | No | No | Yes |
Maven Surefire Configuration
ByteBuddy requires dynamic agent loading. Add to your pom.xml:
<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-surefire-plugin</artifactId> <configuration> <argLine>-XX:+EnableDynamicAgentLoading</argLine> </configuration> </plugin>
Requirements
- Java 25+
- JUnit 6.0+
- ByteBuddy 1.17+ (included transitively)
License
Apache License 2.0