This document outlines the incremental migration plan for introducing the IExpectation architecture to SharpAssert. The migration is designed to be non-breaking, with old and new systems coexisting during the transition.
// Flexible interface for any expectation
public interface IExpectation
{
// Returns null on success, error message on failure
string? Evaluate(ExpectationContext context);
}
// Context provided during evaluation
public class ExpectationContext
{
public string Expression { get; init; }
public string FilePath { get; init; }
public int LineNumber { get; init; }
}
// Base class with operator overloading for composition
public abstract class Expectation : IExpectation
{
public abstract string? Evaluate(ExpectationContext context);
// Compose expectations with operators
public static Expectation operator &(Expectation left, Expectation right)
=> new LogicalAndExpectation(left, right);
public static Expectation operator |(Expectation left, Expectation right)
=> new LogicalOrExpectation(left, right);
public static Expectation operator !(Expectation operand)
=> new LogicalNotExpectation(operand);
}- Single evaluation enforced by type system -
Evaluate()returns both result and message - Composition via operators - Use
&,|,!to compose expectations - Incremental migration - Old and new systems coexist
- No breaking changes - All existing tests continue to pass
- Reuse existing formatters - IComparisonFormatter infrastructure preserved
Goal: Establish the IExpectation architecture without breaking existing functionality.
- Create
IExpectationinterface inSharpAssert.Runtime/IExpectation.cs - Create
ExpectationContextclass inSharpAssert.Runtime/ExpectationContext.cs - Create
Expectationabstract base class inSharpAssert.Runtime/Expectation.cs- Implement
&,|,!operators - Add XML documentation
- Implement
- Create
LogicalAndExpectationinSharpAssert.Runtime/LogicalAndExpectation.cs- Implement short-circuit evaluation in
Evaluate() - Format failure messages showing which operand failed
- Implement short-circuit evaluation in
- Create
LogicalOrExpectationinSharpAssert.Runtime/LogicalOrExpectation.cs- Evaluate both operands on failure
- Show why both were false
- Create
LogicalNotExpectationinSharpAssert.Runtime/LogicalNotExpectation.cs- Negate the result
- Format appropriate failure message
- Add
Assert(IExpectation)overload toSharp.cs- Takes
IExpectationparameter - Uses
CallerArgumentExpressionfor expression text - Creates
ExpectationContext - Calls
expectation.Evaluate(context) - Throws
SharpAssertionExceptionif error returned
- Takes
- Existing
Assert(Expression<Func<bool>>)remains unchanged
- Create
ExpectationFoundationFixture.cs- Test direct IExpectation usage
- Test operator composition (&, |, !)
- Test logical short-circuit behavior
- Test error message formatting
- Commit: "Add IExpectation foundation - no breaking changes"
- Verify all existing tests still pass
- Run full test suite
Exit Criteria:
- ✅ IExpectation interface defined
- ✅ Operator overloading works
- ✅ New Assert overload exists and works
- ✅ All existing tests pass (zero regressions)
Goal: Convert all Assert(Expression) calls to use IExpectation immediately, wrapping existing behavior in DefaultExpectation.
- Create
DefaultExpectationinSharpAssert.Runtime/DefaultExpectation.cs- Constructor takes
Expression<Func<bool>>and expression text Evaluate()delegates to EXISTINGExpressionAnalyzer(no new logic!)- Returns existing error formatting on failure
- This preserves ALL current functionality exactly as-is
public override string? Evaluate(ExpectationContext context) { // Delegate to existing analyzer - zero changes to behavior var analyzer = new ExpressionAnalyzer(); var result = analyzer.Analyze(expression, context); return result.IsSuccess ? null : result.ErrorMessage; }
- Constructor takes
- Create
ExpressionToExpectationclass inSharpAssert.Runtime/ExpressionToExpectation.cs- Static method:
IExpectation FromExpression(Expression<Func<bool>> expr, string exprText) - Initially returns
DefaultExpectationfor ALL cases:
public static IExpectation FromExpression(Expression<Func<bool>> expr, string exprText) { // Phase 1: Everything goes to DefaultExpectation // Later phases will add pattern matching here return new DefaultExpectation(expr, exprText); }
- Static method:
- Modify
SharpInternal.Assert(Expression<Func<bool>>):public static void Assert( Expression<Func<bool>> expression, string? expressionText = null, [CallerFilePath] string? file = null, [CallerLineNumber] int line = 0) { // Convert expression to expectation var expectation = ExpressionToExpectation.FromExpression(expression, expressionText); // Delegate to IExpectation path var context = new ExpectationContext { Expression = expressionText ?? "", FilePath = file ?? "", LineNumber = line }; var error = expectation.Evaluate(context); if (error != null) throw new SharpAssertionException(error); }
- Remove old direct call to ExpressionAnalyzer
- Run ENTIRE test suite
- All 17 test fixtures must pass
- Error messages must be IDENTICAL to before
- No behavior changes whatsoever
- Commit: "Convert Assert(Expression) to use IExpectation via DefaultExpectation wrapper"
- All tests pass
- Behavior unchanged
- Foundation ready for incremental specialization
Exit Criteria:
- ✅ All Assert(Expression) uses IExpectation
- ✅ DefaultExpectation wraps existing behavior
- ✅ All 17 test fixtures pass
- ✅ Error messages identical to before
- ✅ Zero breaking changes
Goal: Replace DefaultExpectation with BinaryComparisonExpectation for binary operators.
- Create
BinaryComparisonExpectationinSharpAssert.Runtime/BinaryComparisonExpectation.cs- Constructor takes
BinaryExpressionand expression text Evaluate()compiles left and right operands once- Performs comparison based on
NodeType - On failure, delegates to
ComparisonFormatterService.GetFormatter() - Reuses existing formatter infrastructure (StringComparisonFormatter, ObjectComparisonFormatter, etc.)
- Constructor takes
- Update
ExpressionToExpectation.FromExpression():public static IExpectation FromExpression(Expression<Func<bool>> expr, string exprText) { var body = expr.Body; return body switch { // NEW: Route binary comparisons to specialized expectation BinaryExpression binary when IsBinaryComparison(binary) => new BinaryComparisonExpectation(binary, exprText), // Fallback to existing behavior (wrapped) _ => new DefaultExpectation(expr, exprText) }; } static bool IsBinaryComparison(BinaryExpression binary) => binary.NodeType is ExpressionType.Equal or ExpressionType.NotEqual or ExpressionType.LessThan or ExpressionType.LessThanOrEqual or ExpressionType.GreaterThan or ExpressionType.GreaterThanOrEqual;
- Run
BinaryComparisonFixture- should pass - Compare error messages - must be identical
- String comparisons use
StringComparisonFormatter - Object comparisons use
ObjectComparisonFormatter - Collection comparisons use
CollectionComparisonFormatter - Nullable comparisons use
NullableComparisonFormatter
- All 17 test fixtures must still pass
- Only binary comparisons use new path
- Everything else still uses DefaultExpectation (unchanged)
- Commit: "Replace DefaultExpectation with BinaryComparisonExpectation for binary operators"
- All tests pass
- Error messages identical
- First specialization complete
Exit Criteria:
- ✅ Binary comparisons use specialized expectation
- ✅ Existing formatters reused correctly
- ✅ Error messages identical to DefaultExpectation behavior
- ✅ All test fixtures still pass
- ✅ DefaultExpectation handles everything except binary comparisons
Goal: Verify that BinaryComparisonExpectation correctly delegates to specialized formatters.
Note: These don't need separate expectations - they're handled by BinaryComparisonExpectation delegating to the existing formatter infrastructure.
- Run
StringComparisonFixture- should pass - Verify inline diff:
h[-e][+a]llo - Verify multiline diff formatting
- Verify null vs empty string handling
- Verify long string truncation
- Confirm delegates to
StringComparisonFormatter
- Run
ObjectComparisonFixture- should pass - Verify nested object comparison
- Verify property difference reporting
- Verify custom Equals() respect
- Verify null object handling
- Verify limit of 20 differences
- Confirm delegates to
ObjectComparisonFormatter
- Run
CollectionComparisonFixture- should pass - Verify first mismatch reporting
- Verify missing/extra element detection
- Verify empty collection handling
- Verify DateTime formatting with InvariantCulture
- Confirm delegates to
CollectionComparisonFormatter
- Run
NullableTypeFixture- should pass - Verify int?, bool?, DateTime? handling
- Verify nullable vs non-nullable comparisons
- Verify nullable vs nullable comparisons
- Verify HasValue state display
- Confirm delegates to
NullableComparisonFormatter
- Commit: "Verify formatter delegation in BinaryComparisonExpectation"
- All specialized formatter fixtures pass
- No code changes needed (already working)
Exit Criteria:
- ✅ String/Object/Collection/Nullable fixtures all pass
- ✅ Formatter delegation works correctly
- ✅ Error messages identical to previous behavior
Goal: Replace DefaultExpectation with composition expectations for logical operators.
- Update
ExpressionToExpectation.FromExpression():public static IExpectation FromExpression(Expression<Func<bool>> expr, string exprText) { var body = expr.Body; return body switch { // Binary comparisons (Phase 2) BinaryExpression binary when IsBinaryComparison(binary) => new BinaryComparisonExpectation(binary, exprText), // NEW: Logical operators BinaryExpression binary when binary.NodeType == ExpressionType.AndAlso => new LogicalAndExpectation( FromExpression(Expression.Lambda<Func<bool>>(binary.Left), null), FromExpression(Expression.Lambda<Func<bool>>(binary.Right), null)), BinaryExpression binary when binary.NodeType == ExpressionType.OrElse => new LogicalOrExpectation( FromExpression(Expression.Lambda<Func<bool>>(binary.Left), null), FromExpression(Expression.Lambda<Func<bool>>(binary.Right), null)), UnaryExpression unary when unary.NodeType == ExpressionType.Not => new LogicalNotExpectation( FromExpression(Expression.Lambda<Func<bool>>(unary.Operand), null)), // Fallback _ => new DefaultExpectation(expr, exprText) }; }
- Run
LogicalOperatorFixture- should pass - Verify && short-circuit behavior
- Verify || both operand evaluation
- Verify ! negation
- Verify error messages match previous behavior
- Verify composition: expressions can be binary comparisons OR logical operators (recursive)
- Commit: "Replace DefaultExpectation with composition expectations for logical operators"
-
LogicalOperatorFixturepasses - All other fixtures still pass
Exit Criteria:
- ✅ Logical operators compose expectations recursively
- ✅ Short-circuit semantics preserved during evaluation
- ✅ LogicalOperatorFixture passes
- ✅ DefaultExpectation now handles everything except binary comparisons and logical operators
Goal: Replace DefaultExpectation with LINQ operation expectations (Contains, Any, All).
- Create
LinqContainsExpectationinSharpAssert.Runtime/LinqContainsExpectation.cs- Extract collection and item from MethodCallExpression
- Format failure with collection preview
- Reuse formatting logic from
LinqOperationFormatter
- Create
LinqAnyExpectation- Handle empty collection case
- Show predicate expression
- Format failure message
- Create
LinqAllExpectation- Compile and evaluate predicate to find failures
- Show only failing items
- Limit to 10 items
- Add pattern matching for LINQ methods in ExpressionToExpectation
- Add before the fallback case
- Run
LinqOperationsFixture- should pass - Verify Contains formatting
- Verify Any predicate display
- Verify All failing items
- Verify collection truncation at 10 items
- Commit: "Replace DefaultExpectation with LINQ operation expectations"
-
LinqOperationsFixturepasses - All other fixtures still pass
Exit Criteria:
- ✅ LINQ operations use specialized expectations
- ✅ Error messages identical to previous behavior
- ✅ LinqOperationsFixture passes
Goal: Replace DefaultExpectation with SequenceEqual expectation.
- Create
SequenceEqualExpectationinSharpAssert.Runtime/SequenceEqualExpectation.cs- Extract both sequences from MethodCallExpression
- Materialize to lists
- Use DiffPlex for unified diff
- Show length mismatch if applicable
- Detect custom comparer usage
- Context lines: 3 before each diff
- Max diff lines: 50
- Max sequence preview: 20 items
- Add pattern matching for SequenceEqual in ExpressionToExpectation
- Run
SequenceEqualFixture- should pass - Verify unified diff display
- Verify length mismatch reporting
- Verify custom comparer indication
- Verify diff truncation at 50 lines
- Commit: "Replace DefaultExpectation with SequenceEqual expectation"
-
SequenceEqualFixturepasses - All other fixtures still pass
Exit Criteria:
- ✅ SequenceEqual uses specialized expectation
- ✅ Error messages identical to previous behavior
- ✅ SequenceEqualFixture passes
Goal: Handle async binary comparisons (rewriter already handles this).
Note: Async support is primarily handled by the rewriter generating appropriate code. The expectations just need to handle the result.
- Run
AsyncBinaryFixture- should pass - Run
AsyncAssertionFixture- should pass - Async is handled by rewriter, not expression tree
- Already working through existing async analyzers
- Add note that async is rewriter-level, not expectation-level
- Existing
AsyncExpressionAnalyzerwrapped by DefaultExpectation - May need future refactoring but works for now
- Commit: "Verify async support works through DefaultExpectation"
- Async fixtures pass
- No changes needed (already working)
Exit Criteria:
- ✅ Async fixtures pass
- ✅ Async handled by rewriter + DefaultExpectation
- ✅ Future work documented if needed
Goal: Handle dynamic type comparisons (similar to async).
Note: Dynamic support is handled by the existing DynamicExpressionAnalyzer wrapped by DefaultExpectation.
- Run
DynamicAssertionFixture- should pass - Dynamic handled by existing analyzer wrapped by DefaultExpectation
- Already working through existing dynamic analyzers
- Commit: "Verify dynamic support works through DefaultExpectation"
- Dynamic fixture passes
- No changes needed (already working)
Exit Criteria:
- ✅ DynamicAssertionFixture passes
- ✅ Dynamic handled by existing analyzer + DefaultExpectation
- ✅ Future work documented if needed
Goal: Verify all 17 test fixtures pass with the new architecture.
- Run ALL 17 test fixtures
- Verify all pass
- Compare error messages against baseline (should be identical)
- Check performance (should be similar or better)
- Run test coverage for new expectation classes
- Ensure all branches covered
- Add missing tests if needed
- Check which assertions still use DefaultExpectation
- Should only be edge cases and features not yet migrated
- Document what remains
- Commit: "Verify complete test suite passes with IExpectation architecture"
- All tests pass
- Coverage maintained or improved
Exit Criteria:
- ✅ All 17 test fixtures pass
- ✅ Error messages match previous implementation
- ✅ No performance regressions
- ✅ Test coverage maintained
Goal: Migrate Throws/ThrowsAsync to return IExpectation (breaking change, but worth it).
- Create
ThrowsExpectation<T>inSharpAssert.Runtime/ThrowsExpectation.cs- Inherits from
Expectation(for operator overloading) - Evaluate action in constructor (eager evaluation)
- Capture exception or null
- Implement
Evaluate()to format based on captured state - Expose
Exceptionproperty for further assertions - Add implicit conversion to exception type:
public static implicit operator T(ThrowsExpectation<T> exp)
- Inherits from
- Create
ThrowsAsyncExpectation<T>similarly for async
- Modify
Sharp.Throws<T>(Action)to returnThrowsExpectation<T> - Modify
Sharp.ThrowsAsync<T>(Func<Task>)to returnThrowsAsyncExpectation<T> - This is a BREAKING CHANGE but enables:
Assert(Throws<T>(...))for direct assertionArgumentException ex = Throws<ArgumentException>(...)for implicit conversionAssert(Throws<T>(...) & otherExpectation)for composition
- Update tests using Throws to work with new return type
- Test implicit conversion:
ArgumentException ex = Throws<ArgumentException>(...) - Test direct assertion:
Assert(Throws<ArgumentException>(...)) - Test composition:
Assert(Throws<ArgumentException>(...) & !Throws<InvalidOperationException>(...)) - Verify exception property access works
- Document breaking change
- Add migration guide for users
- Commit: "Refactor Throws to return IExpectation (BREAKING CHANGE)"
- All exception-related tests pass
- Breaking change documented
Exit Criteria:
- ✅ Throws returns Expectation subclass
- ✅ Exception property accessible
- ✅ Implicit conversion works
- ✅ Composition with operators works
- ✅ All tests pass
- ✅ Breaking change documented
Goal: Demonstrate extension method pattern with EquivalentTo (first of many).
- Create
ExpectationExtensionsclass inSharpAssert.Runtime/ExpectationExtensions.cs - Add
EquivalentTo<T>(this T left, T right)extension method - Returns
ObjectEquivalenceExpectation<T> - Uses CompareNetObjects for comparison
- Create
ObjectEquivalenceExpectation<T>inSharpAssert.Runtime/ObjectEquivalenceExpectation.cs- Inherits from
Expectation(for operator overloading) - Constructor takes left and right values
Evaluate()uses CompareLogic for deep comparison- Returns null on success, diff string on failure
- Inherits from
- Create
EquivalentToFixture.cs - Test basic object equivalence
- Test composition:
Assert(x.EquivalentTo(y) & z.EquivalentTo(w)) - Test negation:
Assert(!x.EquivalentTo(y)) - Test complex composition:
Assert(x.EquivalentTo(y) | !z.EquivalentTo(w))
- Add example to README.md
- Show how to create custom expectations
- Document operator composition
- Commit: "Add EquivalentTo extension method as first FluentAssertions-style expectation"
- New tests pass
- Pattern documented for future extensions
Exit Criteria:
- ✅ EquivalentTo works and composes naturally
- ✅ Pattern documented for community contributions
- ✅ Foundation for future FluentAssertions-style expectations
Goal: Consider removing DefaultExpectation wrapper if most cases are specialized (OPTIONAL).
Note: This phase is optional. DefaultExpectation provides a safety net and may still be useful for edge cases.
- Check what assertions still use DefaultExpectation
- If usage is minimal, consider removing wrapper entirely
- If usage is significant, keep DefaultExpectation as fallback
- Keep as fallback for unhandled cases
- Provides safety net
- Easy to add new specializations later
- No changes needed
- Remove DefaultExpectation class
- Remove ExpressionAnalyzer wrapper
- Ensure ALL cases handled by specialized expectations
- More work but cleaner architecture
- Update README.md with IExpectation architecture
- Document extension method pattern
- Document operator composition (&, |, !)
- Show examples of custom expectations
- Add IExpectation design notes
- Add patterns for creating expectations
- Document formatter reuse strategy
- Note that async/dynamic still use old analyzers (for now)
- Commit: "Update documentation for IExpectation architecture"
- Documentation complete
- Examples clear
Exit Criteria:
- ✅ Decision made on DefaultExpectation
- ✅ Documentation updated
- ✅ Examples provided
- ✅ Contributing guide updated
- ✅ All 17 test fixtures pass
- ✅ Error messages identical to original implementation
- ✅ No performance regressions
- ✅ Cross-platform compatibility maintained (InvariantCulture, etc.)
- ✅ Clean separation of concerns (expectations vs formatters)
- ✅ Easy to add new expectations
- ✅ Operator composition works naturally
- ✅ Single evaluation enforced by type system
- ✅ Test coverage maintained or improved
- ✅ All existing tests pass without modification (except Throws return type)
- ✅ New tests for expectation-specific features
- ✅ Architecture documented
- ✅ Examples provided
- ✅ Contributing guide updated
If issues arise during migration:
-
Any phase (Phases 0-12):
- Revert to previous commit
- Fix issues in isolated branch
- Re-apply when fixed
- Each phase is independently testable
-
No feature flags = simpler rollback:
- Just use git revert
- No configuration management
- No dual code paths to maintain
-
Progressive specialization = safety:
- DefaultExpectation always works as fallback
- New expectations only added when verified
- Can pause migration at any phase
Conservative estimate: 1.5-2 weeks for core migration
- Phase 0: 1 day (foundation: interface, base class, operators, composition)
- Phase 1: 0.5 days (DefaultExpectation wrapper - trivial)
- Phase 2: 1-2 days (binary comparisons - first real specialization)
- Phase 3: 0.5 days (verify formatters work - no code changes)
- Phase 4: 1 day (logical operators composition)
- Phase 5: 1 day (LINQ operations)
- Phase 6: 0.5 days (SequenceEqual)
- Phases 7-8: 0.5 days (verify async/dynamic - no changes)
- Phase 9: 0.5 days (full test suite verification)
- Phase 10: 1-2 days (Throws refactor - breaking change)
- Phase 11: 1 day (EquivalentTo extension example)
- Phase 12: 0.5 days (optional cleanup/docs)
Aggressive estimate: 1 week with focused effort
Key insight: Most phases are verification, not implementation. The wrapper approach means less new code to write.
- Each phase should be a separate commit
- Run full test suite after each phase
- Keep commits small and focused
- Document any deviations from plan
- Update this document as you discover issues
Once migration is complete, these features can be added:
-
FluentAssertions-style expectations:
InAscendingOrder()HasUniqueItems()Matches(pattern)for string wildcardsCloseTo(expected, tolerance)for numeric proximity- Custom object comparison with configuration
-
Enhanced Throws:
Throws<T>().WithMessage(pattern)Throws<T>().WithInnerException<T2>()- Message pattern matching
-
Performance optimizations:
- Cache compiled expressions
- Optimize formatter selection
-
Additional features:
- Custom expectations from users
- Plugin system for third-party expectations
- Better integration with test frameworks
expectation-classes-research.md- Detailed analysis of 17 expectation classes neededfluent.md- FluentAssertions features referencelearnings.md- Implementation learnings and gotchas- Test fixtures in
src/SharpAssert.Tests/- Current behavior to preserve