ADR-0012: Testing Patterns for Async Node Implementations¶
Status¶
Accepted
Date¶
2025-02-27
Context¶
Testing async code in Rust presents several challenges, particularly when it comes to:
- Lifetime issues with closures that capture variables
- Type inference complexities with async closures
- Implementation constraints when using trait objects
- Testing behavior that relies on future resolution
We encountered specific issues when testing our lifecycle node implementation:
- Lifetime errors when using closures directly in tests
- Difficulty creating reusable test patterns
- Readability and maintainability of test code
Decision¶
We will adopt a set of testing patterns specifically for our async node implementations:
1. Use Concrete Implementations for Testing¶
Instead of using closures and the helper functions like lifecycle_node()
, we'll create concrete test implementations of the traits:
// For testing LifecycleNode
struct TestLifecycleNode {
id: NodeId,
}
#[async_trait]
impl LifecycleNode<TestContext, DefaultAction> for TestLifecycleNode {
type PrepOutput = i32;
type ExecOutput = i32;
fn id(&self) -> NodeId { self.id.clone() }
async fn prep(&self, ctx: &mut TestContext) -> Result<i32, FloxideError> {
// Test-specific implementation...
Ok(42)
}
// ... other method implementations
}
2. Test Adapters Directly¶
Test adapter implementations directly rather than through helper functions:
#[tokio::test]
async fn test_lifecycle_node() {
let lifecycle_node = TestLifecycleNode { id: "test-node".to_string() };
let node = LifecycleNodeAdapter::new(lifecycle_node);
// Test node behavior...
}
3. Create Factory Functions for Complex Setup¶
For tests requiring complex setup, use factory functions that return fully initialized test objects:
fn create_test_workflow() -> Workflow<TestContext, DefaultAction> {
let start_node = TestNode { id: "start".to_string() };
let mut workflow = Workflow::new(start_node);
// Add more nodes, configure workflow
workflow
}
4. Use Type Aliases for Complex Types¶
When working with complex generic types, define type aliases to improve readability:
type TestWorkflow = Workflow<TestContext, DefaultAction, i32>;
type TestBatchNode = BatchNode<TestBatchContext, Item, DefaultAction>;
Consequences¶
Advantages¶
- No Lifetime Issues: By using concrete implementations, we avoid the lifetime issues common with closures
- Clear Test Intent: Tests are more explicit about what they're testing
- Better Test Organization: Test objects can be reused across multiple tests
- Easier Debugging: When tests fail, it's clearer where the failure occurs
- Isolated Test Logic: Each test component has a clear responsibility
Disadvantages¶
- More Boilerplate: Requires more code to set up tests
- Lower Test-to-Code Ratio: Tests may be significantly longer than the code they test
- Learning Curve: New team members need to understand the testing patterns
Alternatives Considered¶
Using Box<dyn Fn...>
for Lifecycle Closures¶
We considered changing the lifecycle_node
function to accept boxed closures:
pub fn lifecycle_node<Context, Action, PrepOut, ExecOut>(
id: Option<String>,
prep_fn: Box<dyn Fn(&mut Context) -> BoxFuture<'_, Result<PrepOut, FloxideError>> + Send + Sync>,
// ...
)
This would allow for easier use in tests, but would make the API more cumbersome for normal use.
Testing Helper Functions Directly¶
We considered writing tests specifically for helper functions like lifecycle_node
, which would allow simpler test cases. However, this wouldn't test the integration with the Node trait.
Using Helper Macros for Tests¶
We explored creating test macros that would handle the boilerplate, but this would hide important details and make debugging more difficult.
Implementation Notes¶
- We will update existing tests to follow these patterns
- We will document these patterns in the project's testing guidelines
- Test modules will be organized to match the structure of the code they test
- Helper modules may be created for shared test components
- Test coverage should focus on behavior, not implementation details