Skip to content

A powerful and intuitive testing framework that extends JUnit with streamlined assertion methods for Java objects. BCT eliminates verbose test code while providing comprehensive object introspection and comparison capabilities.

License

Notifications You must be signed in to change notification settings

jamesbognar/bean-centric-testing

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

11 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

Bean-Centric Testing Framework

A powerful and intuitive testing framework that extends JUnit with streamlined assertion methods for Java objects. BCT eliminates verbose test code while providing comprehensive object introspection and comparison capabilities.

πŸ”— Quick Links

πŸ“š Documentation

Note: All documentation is automatically updated with each commit to provide the latest project information.

πŸ“‹ Table of Contents

Overview

The Bean-Centric Testing Framework transforms complex multi-line JUnit assertions into simple, readable one-liners. Instead of manually extracting and comparing individual properties, BCT provides intelligent object introspection with flexible property access patterns.

Key Benefits

  • 🎯 Concise Assertions: Replace many lines of manual property extraction with single assertion calls
  • πŸ” Powerful Property Access: Nested objects, collections, arrays, and maps with unified syntax
  • ⚑ Flexible Comparison: Support for custom converters, formatters, and comparison logic
  • πŸ›‘οΈ Type Safety: Comprehensive error messages with clear property paths
  • πŸ”§ Extensible: Custom property extractors, stringifiers, and conversion logic
  • πŸ“¦ Minimal Dependencies: Depends only on JUnit 5 - no external libraries or heavyweight frameworks
  • πŸš€ Zero Configuration: Works out-of-the-box with sensible defaults and automatic discovery
  • πŸ§ͺ 100% Test Coverage: Thoroughly tested with comprehensive unit tests ensuring reliability and stability

Real-World Impact

BCT has been successfully deployed across the Apache Juneau project test suite with impressive results:

  • πŸ“Š Adoption Rate: 342 out of 658 test files (52%) now use BCT assertions
  • πŸ”’ Usage Statistics: Over 1,700 BCT assertion calls across the codebase
    • 859 assertBean() calls across 127 files
    • 663 assertList() calls across 69 files
    • 192 assertBeans() calls across 10 files
  • πŸ“ Code Reduction: Thousands of lines of verbose property extraction replaced with concise assertions
  • 🎯 Test Clarity: Complex object validation simplified to single-line assertions

JUnit vs BCT Comparison

Traditional JUnit:

// Testing a user object - verbose and repetitive
assertEquals("John", user.getName());
assertEquals(30, user.getAge());
assertTrue(user.isActive());
assertEquals("123 Main St", user.getAddress().getStreet());
assertEquals("Springfield", user.getAddress().getCity());

Bean-Centric Testing:

// Same test - concise and readable
assertBean(user, "name,age,active,address{street,city}", 
          "John,30,true,{123 Main St,Springfield}");

Quick Start

1. Import Static Methods

import static org.bct.api.BctAssertions.*;

2. Basic Object Testing

@Test
void testUser() {
    User user = new User("Alice", 25, true);
    
    // Test multiple properties at once
    assertBean(user, "name,age,active", "Alice,25,true");
    
    // Test individual properties
    assertBean(user, "name", "Alice");
    assertBean(user, "age", "25");
}

@Test
void testMultipleUsers() {
    List<User> users = List.of(
        new User("Alice", 25, true),
        new User("Bob", 30, false),
        new User("Carol", 35, true)
    );
    
    // Test same properties across multiple objects
    assertBeans(users, "name,age", 
        "Alice,25", 
        "Bob,30", 
        "Carol,35");
    
    // Test just names
    assertBeans(users, "name", "Alice", "Bob", "Carol");
}

3. Collection Testing

@Test
void testList() {
    List<String> colors = List.of("red", "green", "blue");
    
    // Test all elements
    assertList(colors, "red", "green", "blue");
    
    // Test as bean properties
    assertBean(colors, "0,1,2", "red,green,blue");
    assertBean(colors, "size", "3");
}

Core Assertion Methods

Tests properties of a single object using a flexible property syntax with powerful nested access capabilities.

This method provides comprehensive property access for any Java object, supporting nested objects, collections, arrays, maps, and custom field access patterns. It uses intelligent property resolution with automatic type handling and supports complex object graphs.

Basic Property Testing:

// Simple properties
assertBean(user, "name,email", "John,[email protected]");

// Boolean properties  
assertBean(user, "active,verified", "true,false");

// Null handling
assertBean(user, "middleName", "null");

Nested Object Testing:

// Single-level nesting
assertBean(user, "address{street,city}", "{123 Main St,Springfield}");

// Multi-level nesting
assertBean(user, "profile{settings{notifications{email}}}", "{{{true}}}");

// Mixed simple and nested
assertBean(order, "id,customer{name,email},total", "12345,{John Doe,[email protected]},99.95");

Collection and Array Testing:

// Index-based access
assertBean(order, "items{0{name},1{name}}", "{{Laptop},{Phone}}");

// Collection iteration (#{} syntax)
assertBean(order, "items{#{name}}", "[{Laptop},{Phone},{Tablet}]");

// Collection properties
assertBean(order, "items{length,#{price}}", "{3,[{999.99},{699.99},{299.99}]}");

// Array access
assertBean(data, "values{0,1,2}", "{100,200,300}");

Map Testing:

// Direct key access
assertBean(config, "database{host,port}", "{localhost,5432}");

// Map size testing
assertBean(user, "settings{size}", "{5}");

// Null key handling
assertBean(user, "mapWithNullKey{<null>}", "{nullKeyValue}");

Field and Method Testing:

// Public fields (no getters required)
assertBean(myBean, "f1,f2,f3", "val1,val2,val3");

// Field properties with chaining
assertBean(myBean, "f1{length},f2{class{simpleName}}", "{5},{{String}}");

// Boolean method variations
assertBean(user, "enabled,isActive,hasPermission", "true,false,true");

Value Syntax Rules:

  • Simple values: "value" for direct property values
  • Nested values: "{value}" for single-level nested properties
  • Deep nested: "{{value}}", "{{{value}}}" for multiple nesting levels
  • Collections: "[item1,item2]" for collection values
  • Collection iteration: "#{property}" iterates over ALL elements
  • Universal size: "length" and "size" work on arrays, collections, maps
  • Boolean values: "true", "false"
  • Null values: "null"

Property Access Priority:

  1. Collection/Array access: Numeric indices (e.g., "0", "1")
  2. Universal size properties: "length" and "size"
  3. Map key access: Direct key lookup (including "<null>")
  4. Boolean methods: is{Property}() methods
  5. Getter methods: get{Property}() methods
  6. Public fields: Direct field access

Tests multiple objects in a collection, comparing the same properties across all objects using the same property access logic as assertBean.

This method validates that each bean in a collection has the specified property values. It's perfect for testing collections of similar objects, validation results, or parsed data structures. Each expected value string corresponds to one bean in the collection.

Basic Collection Testing:

List<User> users = Arrays.asList(
    new User("Alice", 25),
    new User("Bob", 30),
    new User("Charlie", 35)
);

// Test same properties across all users
assertBeans(users, "name,age", 
    "Alice,25", 
    "Bob,30", 
    "Charlie,35"
);

Complex Nested Properties:

// Test nested properties across multiple beans
List<Order> orders = getOrderList();
assertBeans(orders, "id,customer{name,email}", 
    "1,{John,[email protected]}",
    "2,{Jane,[email protected]}"
);

// Test collection properties within beans
List<ShoppingCart> carts = getCartList();
assertBeans(carts, "items{0{name}},total", 
    "{{Laptop}},999.99",
    "{{Phone}},599.99"
);

Validation Testing:

// Test validation results
List<ValidationError> errors = validator.validate(form);
assertBeans(errors, "field,message,code", 
    "email,Invalid email format,E001",
    "age,Must be 18 or older,E002"
);

Collection Iteration Testing:

// Test collection iteration within beans (#{...} syntax)
List<Department> departments = getDepartmentList();
assertBeans(departments, "name,employees{#{name}}", 
    "Engineering,[{Alice},{Bob},{Charlie}]",
    "Marketing,[{David},{Eve}]"
);

Parser Result Testing:

// Test parsed object collections
var parsed = JsonParser.DEFAULT.parse(jsonArray, MyBean[].class);
assertBeans(Arrays.asList(parsed), "prop1,prop2", 
    "val1,val2", 
    "val3,val4"
);

Tests collection elements directly with support for multiple comparison modes: string conversion, functional validation with predicates, and direct object equality.

This method supports any object that can be converted to a List, including arrays, collections, iterables, streams, and more. It provides three distinct comparison modes based on the type of expected values provided.

String Conversion Testing (Default Mode):

// String collections - compares string representations
assertList(List.of("a", "b", "c"), "a", "b", "c");

// Number collections - converts to string for comparison
assertList(List.of(1, 2, 3), "1", "2", "3");

// Object collections - uses toString() or converter
assertList(productNames, "Laptop", "Phone", "Tablet");

// Arrays and other collection types
assertList(myArray, "element1", "element2", "element3");

Predicate Testing (Functional Validation):

// Use Predicate<T> for functional testing
Predicate<Integer> greaterThanOne = x -> x > 1;
assertList(List.of(2, 3, 4), greaterThanOne, greaterThanOne, greaterThanOne);

// Mix predicates with other comparison types
Predicate<String> startsWithA = s -> s.startsWith("a");
assertList(List.of("apple", "banana"), startsWithA, "banana");

// Complex predicate validation
Predicate<User> isActive = user -> user.isActive();
assertList(userList, isActive, isActive);

Object Equality Testing (Direct Comparison):

// Non-String, non-Predicate objects use Objects.equals() comparison
assertList(List.of(1, 2, 3), 1, 2, 3); // Integer objects

// Custom objects with proper equals() implementation
assertList(List.of(myBean1, myBean2), myBean1, myBean2);

// Mixed object types
assertList(mixedList, stringObj, integerObj, customObj);

Supported Input Types:

  • Collections: List, Set, Queue, Deque, etc.
  • Arrays: Both primitive and object arrays
  • Iterables: Any object implementing Iterable
  • Iterators: Iterator instances
  • Streams: Stream objects
  • Enumerations: Legacy Enumeration objects
  • Optional: Single-value Optional objects
  • Maps: Converted to list of entries

Tests objects with custom property access logic using a BiFunction, designed for objects that don't follow standard JavaBean patterns or require specialized property extraction.

This method creates an intermediate LinkedHashMap to collect all property values before using the same logic as assertBean for comparison. This ensures consistent ordering and supports the full nested property syntax. The BiFunction receives the object and property name, returning the property value.

Custom Property Access:

// Custom property access for non-standard objects
assertMapped(myObject, (obj, prop) -> obj.getProperty(prop), 
           "prop1,prop2", "value1,value2");

// Map-based property access
Map<String, Object> data = new HashMap<>();
data.put("name", "Alice");
data.put("score", 95);
assertMapped(data, (map, key) -> map.get(key), 
           "name,score", "Alice,95");

Exception Handling:

// Exceptions become simple class names in output
assertMapped(dataSource, (ds, prop) -> {
    try {
        return ds.getConnection().getMetaData().getDatabaseProductName();
    } catch (SQLException e) {
        throw new RuntimeException(e);
    }
}, "databaseName", "PostgreSQL");

// Safe property access with exception conversion
assertMapped(webService, (service, endpoint) -> {
    try {
        return service.call(endpoint);
    } catch (Exception e) {
        return e.getClass().getSimpleName(); // Returns "TimeoutException"
    }
}, "userEndpoint", "TimeoutException");

Complex Property Logic:

// Transform complex nested access patterns
assertMapped(configSystem, (config, prop) -> {
    switch(prop) {
        case "timeout": return config.getSettings().getTimeout();
        case "retries": return config.getSettings().getRetries();
        case "database": return config.getDatabase().getUrl();
        default: return config.getAttribute(prop);
    }
}, "timeout,retries,database", "30000,3,jdbc:postgresql://localhost:5432/mydb");

// Conditional property access
assertMapped(userService, (service, operation) -> {
    if ("count".equals(operation)) {
        return service.getUserCount();
    } else if ("active".equals(operation)) {
        return service.getActiveUsers().size();
    }
    return service.getProperty(operation);
}, "count,active", "150,45");

Legacy System Integration:

// Access legacy objects without standard getters
assertMapped(legacyBean, (bean, fieldName) -> {
    Field field = bean.getClass().getDeclaredField(fieldName);
    field.setAccessible(true);
    return field.get(bean);
}, "privateField1,privateField2", "value1,value2");

Additional Assertion Methods

BCT provides several additional assertion methods for common testing scenarios:

Tests that a string appears somewhere within the stringified object.

User user = new User("Alice Smith", 25);
List<String> items = Arrays.asList("apple", "banana", "cherry");

// Test substring presence
assertContains("Alice", user);
assertContains("Smith", user);
assertContains("banana", items);

Tests that all specified strings appear within the stringified object.

User user = new User("Alice Smith", 25);
user.setEmail("[email protected]");

// Test multiple substrings
assertContainsAll(user, "Alice", "Smith", "25");
assertContainsAll(user, "alice", "example.com");

Tests that collections, arrays, maps, or strings are empty.

List<String> emptyList = new ArrayList<>();
String[] emptyArray = {};
Map<String,String> emptyMap = new HashMap<>();
String emptyString = "";

// Test empty collections
assertEmpty(emptyList);
assertEmpty(emptyArray);
assertEmpty(emptyMap);
assertEmpty(emptyString);

Tests that collections, arrays, maps, or strings are not empty.

List<String> names = Arrays.asList("Alice");
String[] colors = {"red"};
Map<String,String> config = Map.of("key", "value");
String message = "Hello";

// Test non-empty collections
assertNotEmpty(names);
assertNotEmpty(colors);
assertNotEmpty(config);
assertNotEmpty(message);

Tests the size/length of collections, arrays, maps, or strings.

List<String> names = Arrays.asList("Alice", "Bob", "Carol");
String[] colors = {"red", "green"};
Map<String,Integer> scores = Map.of("Alice", 95, "Bob", 87);
String message = "Hello";

// Test collection sizes
assertSize(3, names);
assertSize(2, colors);
assertSize(2, scores);
assertSize(5, message);

Tests the string representation of an object using the configured converter.

User user = new User("Alice", 25);
List<Integer> numbers = Arrays.asList(1, 2, 3);
Date date = new Date(1609459200000L); // 2021-01-01

// Test string representations
assertString("User(name=Alice, age=25)", user);
assertString("[1, 2, 3]", numbers);
assertString("2021-01-01", date);

Tests that the stringified object matches a glob-style pattern (* and ? wildcards).

User user = new User("Alice Smith", 25);
String filename = "report.pdf";
String email = "[email protected]";

// Test pattern matching
assertMatchesGlob("*Alice*", user);
assertMatchesGlob("*.pdf", filename);
assertMatchesGlob("*@*.com", email);
assertMatchesGlob("User(name=Alice*, age=25)", user);

Nested Property Access

BCT provides powerful nested property access with intuitive syntax:

Object Nesting

// Access nested object properties
assertBean(order, "customer{address{city}}", "{{Springfield}}");

// Multiple nested levels
assertBean(user, "profile{settings{notifications{email}}}", "{{{true}}}");

// Multiple properties at each level
assertBean(order, "customer{name,email},shipping{method,cost}", 
          "{John,[email protected]},{Express,15.99}");

Collection and Array Access

// Index-based access
assertBean(order, "items{0{name},1{name}}", "{{Laptop},{Phone}}");

// Iterate over all elements
assertBean(order, "items{#{name}}", "[{Laptop},{Phone},{Tablet}]");

// Collection properties
assertBean(order, "items{length,#{price}}", "{3,[{999.99},{699.99},{299.99}]}");

// Array access
assertBean(data, "values{0,1,2}", "{100,200,300}");

Map Access

// Direct key access
assertBean(config, "database{host,port}", "{localhost,5432}");

// Special characters in keys
assertBean(props, "app.version,app.name", "1.0.0,MyApp");

// Null key access
assertBean(mapWithNullKey, "<null>", "nullKeyValue");

Size and Length Properties

// Universal size access
assertBean(list, "size", "5");
assertBean(array, "length", "10");
assertBean(map, "size", "3");

// Combined with other properties
assertBean(user, "orders{size,#{total}}", "{3,[{99.99},{149.99},{79.99}]}");

Advanced Configuration

Custom Error Messages

// Static messages
assertBean(args().setMessage("User validation failed"), 
          user, "email", "[email protected]");

// Dynamic messages with placeholders
assertBean(args().setMessage("Test {0} failed on iteration {1}", testName, iteration),
          result, "status", "SUCCESS");

// Supplier-based messages for expensive computation
assertBean(args().setMessage(() -> "Test failed at " + Instant.now()),
          user, "lastLogin", expectedTime);

Custom Bean Converters

// Create converter with custom formatting
var converter = BasicBeanConverter.builder()
    .defaultSettings()
    .addStringifier(LocalDate.class, date -> 
        date.format(DateTimeFormatter.ISO_LOCAL_DATE))
    .addStringifier(Money.class, money -> 
        money.getAmount().toPlainString())
    .build();

// Use in assertions
assertBean(args().setBeanConverter(converter),
          order, "date,total", "2023-12-01,99.99");

Extending the Framework

Custom Stringifiers

Define how specific types should be converted to strings:

// Date formatting
Stringifier<LocalDateTime> dateStringifier = (conv, dt) -> 
    dt.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME);

// Complex object formatting
Stringifier<Order> orderStringifier = (conv, order) -> 
    "Order#" + order.getId() + "[" + order.getStatus() + "]";

// Registration
var converter = BasicBeanConverter.builder()
    .defaultSettings()
    .addStringifier(LocalDateTime.class, dateStringifier)
    .addStringifier(Order.class, orderStringifier)
    .build();

Custom Listifiers

Define how collection-like objects should be converted to lists:

// Custom collection handling
Listifier<ResultSet> resultSetListifier = (conv, rs) -> {
    var results = new ArrayList<>();
    while (rs.next()) {
        results.add(rs.getRowData());
    }
    return results;
};

// Stream processing
Listifier<Stream> streamListifier = (conv, stream) -> 
    stream.collect(toList());

Custom Property Extractors

Define custom property access logic:

// Database entity extractor
PropertyExtractor entityExtractor = new PropertyExtractor() {
    @Override
    public boolean canExtract(BeanConverter conv, Object obj, String prop) {
        return obj instanceof DatabaseEntity;
    }
    
    @Override
    public Object extract(BeanConverter conv, Object obj, String prop) {
        DatabaseEntity entity = (DatabaseEntity) obj;
        switch (prop) {
            case "id": return entity.getPrimaryKey();
            case "displayName": return entity.computeDisplayName();
            default: return entity.getAttribute(prop);
        }
    }
};

// Registration
var converter = BasicBeanConverter.builder()
    .defaultSettings()
    .addPropertyExtractor(entityExtractor)
    .build();

Custom Swappers

Define object pre-processing logic:

// Optional unwrapping (already built-in)
Swapper<Optional> optionalSwapper = (conv, opt) -> opt.orElse(null);

// Custom wrapper unwrapping
Swapper<LazyValue> lazySwapper = (conv, lazy) -> 
    lazy.isEvaluated() ? lazy.getValue() : "<unevaluated>";

// Future handling
Swapper<CompletableFuture> futureSwapper = (conv, future) -> {
    try {
        return future.isDone() ? future.get() : "<pending>";
    } catch (Exception e) {
        return "<error: " + e.getMessage() + ">";
    }
};

Migration Examples

Before and After Comparisons

Complex Object Testing:

// Before: Traditional JUnit
@Test
void testOrderTraditional() {
    assertEquals(12345L, order.getId());
    assertEquals("John Doe", order.getCustomer().getName());
    assertEquals("[email protected]", order.getCustomer().getEmail());
    assertEquals("123 Main St", order.getShipping().getAddress().getStreet());
    assertEquals("Springfield", order.getShipping().getAddress().getCity());
    assertEquals(3, order.getItems().size());
    assertEquals("Laptop", order.getItems().get(0).getName());
    assertEquals(new BigDecimal("999.99"), order.getItems().get(0).getPrice());
    assertEquals(OrderStatus.PENDING, order.getStatus());
}

// After: Bean-Centric Testing
@Test  
void testOrderBCT() {
    assertBean(order, 
        "id,customer{name,email},shipping{address{street,city}},items{size,0{name,price}},status",
        "12345,{John Doe,[email protected]},{{123 Main St,Springfield}},{3,{Laptop,999.99}},PENDING");
}

Collection Testing:

// Before: Traditional JUnit
@Test
void testUsersTraditional() {
    assertEquals(3, users.size());
    assertEquals("Alice", users.get(0).getName());
    assertEquals(25, users.get(0).getAge());
    assertEquals("Bob", users.get(1).getName());
    assertEquals(30, users.get(1).getAge());
    assertEquals("Charlie", users.get(2).getName());
    assertEquals(35, users.get(2).getAge());
}

// After: Bean-Centric Testing
@Test
void testUsersBCT() {
    assertBeans(users, "name,age", 
        "Alice,25", 
        "Bob,30", 
        "Charlie,35");
}

Configuration Testing:

// Before: Traditional JUnit  
@Test
void testConfigTraditional() {
    assertEquals("localhost", config.getDatabase().getHost());
    assertEquals(5432, config.getDatabase().getPort());
    assertEquals("myapp", config.getDatabase().getSchema());
    assertEquals(30000, config.getTimeout());
    assertEquals(3, config.getRetries());
    assertTrue(config.isLoggingEnabled());
}

// After: Bean-Centric Testing
@Test
void testConfigBCT() {
    assertBean(config, 
        "database{host,port,schema},timeout,retries,loggingEnabled",
        "{localhost,5432,myapp},30000,3,true");
}

Javadoc Syntax Highlighting

This framework uses custom HTML tags in Javadocs for enhanced syntax highlighting:

  • <jk> tags: Java keywords (class, var, return, etc.)
  • <jv> tags: Local variables and field names
  • <jp> tags: Parameters in lambda expressions and methods
  • <jsm> tags: Static method calls
  • <jsf> tags: Static fields and constants
  • <js> tags: String literals
  • <jc> tags: Comments

The stylesheet for these tags provides professional syntax highlighting in generated documentation, making code examples more readable and visually appealing.

🎯 Key Takeaways

The Bean-Centric Testing Framework transforms verbose, error-prone test code into concise, readable assertions. By leveraging intelligent object introspection and flexible property access patterns, BCT enables developers to write more maintainable tests while improving test coverage and readability.

Benefits:

  • βœ… Reduced Code Volume: 70-80% less test code
  • βœ… Improved Readability: Clear, intention-revealing assertions
  • βœ… Better Maintainability: Changes to object structure require minimal test updates
  • βœ… Enhanced Error Messages: Precise failure reporting with property paths
  • βœ… Flexible Extension: Custom converters for domain-specific needs

Start with simple assertBean calls and gradually adopt more advanced features as your testing needs evolve. The framework grows with your project complexity while maintaining simplicity at its core.

About

A powerful and intuitive testing framework that extends JUnit with streamlined assertion methods for Java objects. BCT eliminates verbose test code while providing comprehensive object introspection and comparison capabilities.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages