Predicate Assertions

Table of contents

  1. Overview
    1. Why Use Predicate Assertions?
  2. Result Predicate Assertions
    1. Single-Value Result
      1. ShouldBeSuccessWhere
      2. With Custom Message
      3. Complex Predicates
      4. ShouldBeFailureWhere
      5. With Type Checking
    2. Multi-Arity Result
      1. Tuple Deconstruction
      2. Failure with Multi-Arity
    3. Source-Generated Support
  3. Option Predicate Assertions
    1. ShouldBeSomeWhere
      1. With Custom Message
      2. Complex Predicates
    2. ShouldBeNoneWhere
      1. Use Cases
      2. With Custom Message
  4. Either Predicate Assertions
    1. ShouldBeLeftWhere
      1. With Custom Message
      2. Complex Predicates
    2. ShouldBeRightWhere
      1. With Custom Message
      2. Complex Predicates
  5. Usage Examples
    1. Testing Computed Values
    2. Testing Validation Logic
    3. Testing Multi-Value Results
    4. Testing Optional Values
    5. Testing Either Branches
    6. Testing Business Rules
  6. Combining with Fluent Assertions
  7. Design Notes
    1. Method Signatures
    2. Failure Messages
    3. Source Generation
  8. Best Practices
  9. Common Patterns
    1. Range Validation
    2. Property Checks
    3. Type Validation
    4. Collection Checks
  10. See Also

Overview

Predicate assertions provide property-based testing for Result, Option, and Either types using lambda predicates. They offer a concise way to express invariant checks without manual value extraction, with clear failure messages and support for custom error descriptions.

Why Use Predicate Assertions?

  • Concise: Express conditions directly without extracting values
  • Clear intent: Predicates make test expectations explicit
  • Automatic short-circuiting: Fail fast with meaningful messages
  • Custom messages: Optional because parameter for context
  • Type-safe: Leverage the type system for correct assertions

Result Predicate Assertions

Single-Value Result

ShouldBeSuccessWhere

Test that a Result is successful and the value satisfies a predicate:

Result.Success(42).ShouldBeSuccessWhere(v => v > 40);

With Custom Message

Result.Success(5).ShouldBeSuccessWhere(
    v => v == 5, 
    because: "Expected computed value to be 5"
);

Complex Predicates

Result.Success("hello")
    .ShouldBeSuccessWhere(s => s.Length > 0 && s.StartsWith("h"));

Result.Success(100)
    .ShouldBeSuccessWhere(v => v % 10 == 0);

ShouldBeFailureWhere

Test that a Result is a failure and the exception satisfies a predicate:

Result.Failure<int>(new Exception("boom"))
    .ShouldBeFailureWhere(ex => ex.Message == "boom");

With Type Checking

Result.Failure<int>(new InvalidOperationException())
    .ShouldBeFailureWhere(ex => ex is InvalidOperationException);

Result.Failure<string>(new ArgumentException("Invalid arg"))
    .ShouldBeFailureWhere(ex => 
        ex is ArgumentException && ex.Message.Contains("Invalid")
    );

Multi-Arity Result

For multi-arity results, the predicate receives a tuple:

// Result<int, int, int>
Result.Success(1, 2, 3)
    .ShouldBeSuccessWhere(t => t.Item1 + t.Item2 + t.Item3 == 6);

// Result<string, int>
Result.Success("hello", 5)
    .ShouldBeSuccessWhere(t => t.Item1.Length == t.Item2);

// Result with 4 values
Result.Success(1, 2, 3, 4)
    .ShouldBeSuccessWhere(t => 
        t.Item1 < t.Item2 && 
        t.Item3 < t.Item4
    );

Tuple Deconstruction

Result.Success(10, 20)
    .ShouldBeSuccessWhere(t => {
        var (a, b) = t;
        return a + b == 30;
    });

Result.Success("user", 42, true)
    .ShouldBeSuccessWhere(t => {
        var (name, age, active) = t;
        return name.Length > 0 && age > 0 && active;
    });

Failure with Multi-Arity

Result.Failure<int, int>(new InvalidOperationException())
    .ShouldBeFailureWhere(ex => ex is InvalidOperationException);

Source-Generated Support

Predicate methods are source-generated for arities 1-8:

// All of these are generated
Result.Success(1).ShouldBeSuccessWhere(v => v > 0);
Result.Success(1, 2).ShouldBeSuccessWhere(t => t.Item1 + t.Item2 > 0);
Result.Success(1, 2, 3).ShouldBeSuccessWhere(t => t.Item1 + t.Item2 + t.Item3 > 0);
// ... up to Result.Success(v1, v2, v3, v4, v5, v6, v7, v8)

Option Predicate Assertions

ShouldBeSomeWhere

Test that an Option has a value and it satisfies a predicate:

Option.Some(10).ShouldBeSomeWhere(v => v % 5 == 0);

With Custom Message

Option.Some(42).ShouldBeSomeWhere(
    v => v == 42,
    because: "Expected the answer to everything"
);

Complex Predicates

Option.Some("hello")
    .ShouldBeSomeWhere(s => s.Length > 3 && s.Contains("ll"));

Option.Some(DateTime.Now)
    .ShouldBeSomeWhere(dt => dt.Year == 2025);

Option.Some(new User { Age = 25 })
    .ShouldBeSomeWhere(u => u.Age >= 18);

ShouldBeNoneWhere

Test that an Option is None and an additional condition holds:

Option<int>.None().ShouldBeNoneWhere(() => true);

Use Cases

// Verify None with context check
var cache = new Cache();
Option<string> result = cache.Get("nonexistent");

result.ShouldBeNoneWhere(() => !cache.Contains("nonexistent"));

With Custom Message

Option<User>.None().ShouldBeNoneWhere(
    () => true,
    because: "User should not exist in test database"
);

Either Predicate Assertions

ShouldBeLeftWhere

Test that an Either is Left and the value satisfies a predicate:

Either<int, string>.FromLeft(2).ShouldBeLeftWhere(l => l < 5);

With Custom Message

Either<string, User>.FromLeft("Not found")
    .ShouldBeLeftWhere(
        msg => msg.Contains("Not"),
        because: "Expected user lookup to fail"
    );

Complex Predicates

Either<List<string>, User>.FromLeft(new List<string> { "a", "b" })
    .ShouldBeLeftWhere(list => list.Count == 2);

Either<Error, Result>.FromLeft(new Error { Code = 404 })
    .ShouldBeLeftWhere(err => err.Code >= 400 && err.Code < 500);

ShouldBeRightWhere

Test that an Either is Right and the value satisfies a predicate:

Either<int, string>.FromRight("abc").ShouldBeRightWhere(r => r.Length == 3);

With Custom Message

Either<Error, User>.FromRight(new User { Name = "John" })
    .ShouldBeRightWhere(
        u => u.Name == "John",
        because: "Expected successful user creation"
    );

Complex Predicates

Either<string, int>.FromRight(100)
    .ShouldBeRightWhere(n => n > 0 && n % 10 == 0);

Either<Error, SearchResult>.FromRight(new SearchResult { Count = 5 })
    .ShouldBeRightWhere(result => result.Count > 0 && result.Count < 10);

Usage Examples

Testing Computed Values

[Fact]
public void Calculate_WithValidInput_ReturnsExpectedResult()
{
    // Arrange
    var calculator = new Calculator();

    // Act
    var result = calculator.Divide(100, 5);

    // Assert - Predicate style
    result.ShouldBeSuccessWhere(v => v == 20);
}

[Fact]
public void Compute_ReturnsValueInRange()
{
    var result = service.Compute();
    result.ShouldBeSuccessWhere(
        v => v >= 1 && v <= 100,
        because: "Computed value should be in valid range"
    );
}

Testing Validation Logic

[Fact]
public void ValidateAge_WithValidAge_Passes()
{
    var result = validator.ValidateAge(25);
    result.ShouldBeSuccessWhere(age => age >= 18 && age <= 120);
}

[Fact]
public void ValidateEmail_WithInvalidEmail_Fails()
{
    var result = validator.ValidateEmail("invalid");
    result.ShouldBeFailureWhere(ex => 
        ex is ValidationException && 
        ex.Message.Contains("email")
    );
}

Testing Multi-Value Results

[Fact]
public void CreateUser_ReturnsValidUserAndToken()
{
    var result = service.CreateUser("John", "john@example.com");
    
    result.ShouldBeSuccessWhere(t => {
        var (user, token) = t;
        return user.Name == "John" && 
               user.Email == "john@example.com" &&
               !string.IsNullOrEmpty(token);
    });
}

[Fact]
public void GetCoordinates_ReturnsValidLatLong()
{
    var result = geoService.GetCoordinates("New York");
    
    result.ShouldBeSuccessWhere(t => {
        var (lat, lon) = t;
        return lat >= -90 && lat <= 90 &&
               lon >= -180 && lon <= 180;
    });
}

Testing Optional Values

[Fact]
public void FindUser_WhenExists_ReturnsSomeWithValidData()
{
    var result = repository.FindUser("john");
    result.ShouldBeSomeWhere(u => 
        u.Username == "john" && 
        u.Email != null
    );
}

[Fact]
public void GetConfig_WhenMissing_ReturnsNone()
{
    var result = config.Get("nonexistent");
    result.ShouldBeNoneWhere(() => !config.Exists("nonexistent"));
}

Testing Either Branches

[Fact]
public void ProcessPayment_WithValidCard_ReturnsRightWithTransactionId()
{
    var result = processor.ProcessPayment(validCard);
    result.ShouldBeRightWhere(txId => 
        txId.Length == 32 && 
        txId.All(char.IsLetterOrDigit)
    );
}

[Fact]
public void ProcessPayment_WithInvalidCard_ReturnsLeftWithError()
{
    var result = processor.ProcessPayment(invalidCard);
    result.ShouldBeLeftWhere(err => 
        err.Code == "INVALID_CARD" && 
        err.Message != null
    );
}

Testing Business Rules

[Fact]
public void CalculateDiscount_AppliesCorrectPercentage()
{
    var result = service.CalculateDiscount(totalAmount: 1000, membershipLevel: "Gold");
    
    result.ShouldBeSuccessWhere(
        discount => discount >= 100 && discount <= 200,
        because: "Gold members should get 10-20% discount"
    );
}

[Fact]
public void ValidateOrder_ChecksAllInvariants()
{
    var result = validator.ValidateOrder(order);
    
    result.ShouldBeSuccessWhere(validOrder =>
        validOrder.Items.Count > 0 &&
        validOrder.Total > 0 &&
        validOrder.CustomerId > 0,
        because: "Valid orders must have items, positive total, and customer"
    );
}

Combining with Fluent Assertions

Predicate assertions can be combined with fluent assertions:

[Fact]
public void ComplexValidation_UsesMultipleStyles()
{
    var result = service.ProcessData(input);
    
    // Start with predicate
    result.ShouldBeSuccessWhere(v => v > 0);
    
    // Continue with fluent
    result
        .EnsureSuccess()
        .Where(v => v % 2 == 0)
        .Map(v => v * 2)
        .And(v => Assert.Equal(expected, v));
}

Design Notes

Method Signatures

// Result predicates
public static void ShouldBeSuccessWhere<T>(
    this Result<T> result, 
    Func<T, bool> predicate, 
    string? because = null);

public static void ShouldBeFailureWhere<T>(
    this Result<T> result, 
    Func<Exception, bool> predicate, 
    string? because = null);

// Option predicates
public static void ShouldBeSomeWhere<T>(
    this Option<T> option, 
    Func<T, bool> predicate, 
    string? because = null);

public static void ShouldBeNoneWhere<T>(
    this Option<T> option, 
    Func<bool> predicate, 
    string? because = null);

// Either predicates
public static void ShouldBeLeftWhere<TLeft, TRight>(
    this Either<TLeft, TRight> either, 
    Func<TLeft, bool> predicate, 
    string? because = null);

public static void ShouldBeRightWhere<TLeft, TRight>(
    this Either<TLeft, TRight> either, 
    Func<TRight, bool> predicate, 
    string? because = null);

Failure Messages

Predicate assertions emit clear failure messages:

// Example failure output:
// "Expected success result where predicate holds, but was failure."
// "Expected success result where predicate holds, but predicate returned false. Expected computed value to be 5"

Source Generation

Result predicate methods for arities 2-8 are source-generated to avoid manual duplication. The generator can be adjusted if you need support for more than 8 values.


Best Practices

  1. Use predicates for property validation - Express invariants directly
  2. Add custom messages - Use the because parameter for context
  3. Keep predicates simple - Complex logic can be hard to debug
  4. Combine styles - Use predicates with fluent assertions when needed
  5. Test edge cases - Verify boundary conditions with predicates
  6. Use meaningful names - Make test intent clear

Common Patterns

Range Validation

result.ShouldBeSuccessWhere(v => v >= min && v <= max);

Property Checks

result.ShouldBeSuccessWhere(user => 
    user.Name != null && 
    user.Email.Contains("@")
);

Type Validation

result.ShouldBeFailureWhere(ex => ex is ValidationException);

Collection Checks

result.ShouldBeSuccessWhere(list => 
    list.Count > 0 && 
    list.All(x => x > 0)
);

See Also