From a6ae1cfb6a296c91fc3aed2a9b8da0d037e31ba4 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Tue, 14 Oct 2025 09:46:01 +0100 Subject: [PATCH 1/5] feat: add support for cross-type equality assertions with IEquatable for structs --- TUnit.Assertions.Tests/EquatableTests.cs | 268 +++++++++++++ .../Conditions/EquatableAssertion.cs | 92 +++++ .../Extensions/AssertionExtensions.cs | 372 ++---------------- 3 files changed, 391 insertions(+), 341 deletions(-) create mode 100644 TUnit.Assertions.Tests/EquatableTests.cs create mode 100644 TUnit.Assertions/Conditions/EquatableAssertion.cs diff --git a/TUnit.Assertions.Tests/EquatableTests.cs b/TUnit.Assertions.Tests/EquatableTests.cs new file mode 100644 index 0000000000..fc47129ad3 --- /dev/null +++ b/TUnit.Assertions.Tests/EquatableTests.cs @@ -0,0 +1,268 @@ +#if NET6_0_OR_GREATER +namespace TUnit.Assertions.Tests; + +/// +/// Tests for IEquatable<T> assertions where the actual and expected types differ. +/// These tests verify that types implementing IEquatable with cross-type equality work correctly. +/// Note: Only available on .NET 6+ due to overload resolution limitations in older frameworks. +/// +public class EquatableTests +{ + // Example struct from GitHub issue #2972 + public struct Wrapper : IEquatable, IEquatable + { + public long Value { get; set; } + + public bool Equals(Wrapper other) => Value == other.Value; + + public bool Equals(long other) => Value == other; + + public override bool Equals(object? obj) + { + return obj switch + { + Wrapper wrapper => Equals(wrapper), + long l => Equals(l), + _ => false + }; + } + + public override int GetHashCode() => Value.GetHashCode(); + } + + // Struct with multiple IEquatable implementations + public struct MultiEquatable : IEquatable, IEquatable, IEquatable + { + public int IntValue { get; set; } + public string StringValue { get; set; } + + public bool Equals(MultiEquatable other) => + IntValue == other.IntValue && StringValue == other.StringValue; + + public bool Equals(int other) => IntValue == other; + + public bool Equals(string? other) => StringValue == other; + + public override bool Equals(object? obj) + { + return obj switch + { + MultiEquatable m => Equals(m), + int i => Equals(i), + string s => Equals(s), + _ => false + }; + } + + public override int GetHashCode() => HashCode.Combine(IntValue, StringValue); + } + + // Simple struct with IEquatable + public struct IntWrapper : IEquatable + { + public int Value { get; set; } + + public bool Equals(int other) => Value == other; + + public override bool Equals(object? obj) => obj is int i && Equals(i); + + public override int GetHashCode() => Value.GetHashCode(); + } + + [Test] + public async Task Wrapper_IsEqualTo_Long_Success() + { + // Arrange + Wrapper wrapper = new() { Value = 42 }; + + // Act & Assert + await Assert.That(wrapper).IsEqualTo(42L); + } + + [Test] + public async Task Wrapper_IsEqualTo_Long_Failure() + { + // Arrange + Wrapper wrapper = new() { Value = 42 }; + + // Act & Assert + await Assert.ThrowsAsync(async () => + { + await Assert.That(wrapper).IsEqualTo(99L); + }); + } + + [Test] + public async Task Wrapper_IsEqualTo_Wrapper_Success() + { + // Arrange + Wrapper wrapper1 = new() { Value = 42 }; + Wrapper wrapper2 = new() { Value = 42 }; + + // Act & Assert + await Assert.That(wrapper1).IsEqualTo(wrapper2); + } + + [Test] + public async Task NullableWrapper_IsEqualTo_Long_Success() + { + // Arrange + Wrapper? wrapper = new Wrapper { Value = 42 }; + + // Act & Assert + await Assert.That(wrapper).IsEqualTo(42L); + } + + [Test] + public async Task NullableWrapper_IsEqualTo_Long_NullFailure() + { + // Arrange + Wrapper? wrapper = null; + + // Act & Assert + await Assert.ThrowsAsync(async () => + { + await Assert.That(wrapper).IsEqualTo(42L); + }); + } + + [Test] + public async Task MultiEquatable_IsEqualTo_Int_Success() + { + // Arrange + MultiEquatable value = new() { IntValue = 100, StringValue = "test" }; + + // Act & Assert + await Assert.That(value).IsEqualTo(100); + } + + [Test] + public async Task MultiEquatable_IsEqualTo_String_Success() + { + // Arrange + MultiEquatable value = new() { IntValue = 100, StringValue = "test" }; + + // Act & Assert + await Assert.That(value).IsEqualTo("test"); + } + + [Test] + public async Task IntWrapper_IsEqualTo_Int_Success() + { + // Arrange + IntWrapper wrapper = new() { Value = 123 }; + + // Act & Assert + await Assert.That(wrapper).IsEqualTo(123); + } + + [Test] + public async Task IntWrapper_IsEqualTo_Int_Failure() + { + // Arrange + IntWrapper wrapper = new() { Value = 123 }; + + // Act & Assert + await Assert.ThrowsAsync(async () => + { + await Assert.That(wrapper).IsEqualTo(456); + }); + } + + [Test] + public async Task EquatableAssertion_WithAnd_Success() + { + // Arrange + Wrapper wrapper = new() { Value = 42 }; + + // Act & Assert + await Assert.That(wrapper).IsEqualTo(42L).And.IsEqualTo(new Wrapper { Value = 42 }); + } + + [Test] + public async Task EquatableAssertion_WithOr_Success() + { + // Arrange + Wrapper wrapper = new() { Value = 42 }; + + // Act & Assert - should pass because first condition is true + await Assert.That(wrapper).IsEqualTo(42L).Or.IsEqualTo(new Wrapper { Value = 99 }); + } + + [Test] + public async Task EquatableAssertion_WithAnd_Failure() + { + // Arrange + Wrapper wrapper = new() { Value = 42 }; + + // Act & Assert + await Assert.ThrowsAsync(async () => + { + await Assert.That(wrapper).IsEqualTo(42L).And.IsEqualTo(new Wrapper { Value = 99 }); + }); + } + + [Test] + public async Task EquatableAssertion_ChainedWithAndContinuation() + { + // Arrange + Wrapper wrapper = new() { Value = 42 }; + + // Act & Assert - test that And continuation works + await Assert.That(wrapper).IsEqualTo(42L).And.IsEqualTo(42L); + } + + [Test] + public async Task EquatableAssertion_ChainedWithOrContinuation() + { + // Arrange + Wrapper wrapper = new() { Value = 42 }; + + // Act & Assert - test that Or continuation works + await Assert.That(wrapper).IsEqualTo(99L).Or.IsEqualTo(42L); + } + + [Test] + public async Task NullableIntWrapper_IsEqualTo_Int_Success() + { + // Arrange + IntWrapper? wrapper = new IntWrapper { Value = 789 }; + + // Act & Assert + await Assert.That(wrapper).IsEqualTo(789); + } + + [Test] + public async Task NullableIntWrapper_IsEqualTo_Int_NullFailure() + { + // Arrange + IntWrapper? wrapper = null; + + // Act & Assert + await Assert.ThrowsAsync(async () => + { + await Assert.That(wrapper).IsEqualTo(789); + }); + } + + // Edge case: Ensure the constraint-based overload doesn't interfere with standard equality + [Test] + public async Task StandardEquality_StillWorks_ForSameTypes() + { + // Arrange + int value = 42; + + // Act & Assert - should use standard IsEqualTo, not the IEquatable overload + await Assert.That(value).IsEqualTo(42); + } + + // Test to verify the example from GitHub issue #2972 works exactly as requested + [Test] + public async Task GitHubIssue2972_Example() + { + // This is the exact example from the GitHub issue + Wrapper value = new() { Value = 1 }; + await Assert.That(value).IsEqualTo(1L); + } +} +#endif diff --git a/TUnit.Assertions/Conditions/EquatableAssertion.cs b/TUnit.Assertions/Conditions/EquatableAssertion.cs new file mode 100644 index 0000000000..ae1e8a65d8 --- /dev/null +++ b/TUnit.Assertions/Conditions/EquatableAssertion.cs @@ -0,0 +1,92 @@ +using TUnit.Assertions.Core; + +namespace TUnit.Assertions.Conditions; + +/// +/// Asserts that a value implementing IEquatable<TExpected> is equal to an expected value of a different type. +/// This allows comparing types that implement IEquatable with cross-type equality. +/// Example: A Wrapper struct implementing IEquatable<long> can be compared directly to a long value. +/// +public class EquatableAssertion : Assertion + where TActual : IEquatable +{ + private readonly TExpected _expected; + + public EquatableAssertion( + AssertionContext context, + TExpected expected) + : base(context) + { + _expected = expected; + } + + protected override Task CheckAsync(EvaluationMetadata metadata) + { + var value = metadata.Value; + var exception = metadata.Exception; + + if (exception != null) + { + return Task.FromResult(AssertionResult.Failed($"threw {exception.GetType().FullName}")); + } + + if (value == null) + { + return Task.FromResult(AssertionResult.Failed("value was null")); + } + + // Use IEquatable.Equals for comparison + if (value.Equals(_expected)) + { + return Task.FromResult(AssertionResult.Passed); + } + + return Task.FromResult(AssertionResult.Failed($"found {value}")); + } + + protected override string GetExpectation() => $"to be equal to {_expected}"; +} + +/// +/// Asserts that a nullable value type implementing IEquatable<TExpected> is equal to an expected value. +/// Handles nullable structs that implement IEquatable. +/// +public class NullableEquatableAssertion : Assertion + where TActual : struct, IEquatable +{ + private readonly TExpected _expected; + + public NullableEquatableAssertion( + AssertionContext context, + TExpected expected) + : base(context) + { + _expected = expected; + } + + protected override Task CheckAsync(EvaluationMetadata metadata) + { + var value = metadata.Value; + var exception = metadata.Exception; + + if (exception != null) + { + return Task.FromResult(AssertionResult.Failed($"threw {exception.GetType().FullName}")); + } + + if (!value.HasValue) + { + return Task.FromResult(AssertionResult.Failed("value was null")); + } + + // Use IEquatable.Equals for comparison + if (value.Value.Equals(_expected)) + { + return Task.FromResult(AssertionResult.Passed); + } + + return Task.FromResult(AssertionResult.Failed($"found {value.Value}")); + } + + protected override string GetExpectation() => $"to be equal to {_expected}"; +} diff --git a/TUnit.Assertions/Extensions/AssertionExtensions.cs b/TUnit.Assertions/Extensions/AssertionExtensions.cs index 2789d9f53a..c03954cf95 100644 --- a/TUnit.Assertions/Extensions/AssertionExtensions.cs +++ b/TUnit.Assertions/Extensions/AssertionExtensions.cs @@ -157,6 +157,37 @@ public static DateTimeOffsetEqualsAssertion IsEqualTo( return new DateTimeOffsetEqualsAssertion(source.Context, expected); } + /// + /// Asserts that a struct implementing IEquatable<TExpected> is equal to the expected value. + /// This enables direct equality comparisons for structs with cross-type IEquatable implementations. + /// Example: A Wrapper struct implementing IEquatable<long> can be compared directly to a long value. + /// Note: In cases of ambiguity with same-type comparisons, explicitly specify the type parameter. + /// + public static EquatableAssertion IsEqualTo( + this IAssertionSource source, + TExpected expected, + [CallerArgumentExpression(nameof(expected))] string? expression = null) + where TActual : struct, IEquatable + { + source.Context.ExpressionBuilder.Append($".IsEqualTo({expression})"); + return new EquatableAssertion(source.Context, expected); + } + + /// + /// Asserts that a nullable struct implementing IEquatable<TExpected> is equal to the expected value. + /// Handles nullable structs with cross-type IEquatable implementations. + /// Note: In cases of ambiguity with same-type comparisons, explicitly specify the type parameter. + /// + public static NullableEquatableAssertion IsEqualTo( + this IAssertionSource source, + TExpected expected, + [CallerArgumentExpression(nameof(expected))] string? expression = null) + where TActual : struct, IEquatable + { + source.Context.ExpressionBuilder.Append($".IsEqualTo({expression})"); + return new NullableEquatableAssertion(source.Context, expected); + } + // ============ COMPARISONS ============ // IsGreaterThan, IsGreaterThanOrEqualTo, IsLessThan, IsLessThanOrEqualTo, and IsBetween // are now generated by AssertionExtensionGenerator @@ -225,133 +256,6 @@ public static LessThanAssertion IsNegative( // ============ BOOLEAN ============ // IsTrue and IsFalse are now generated by AssertionExtensionGenerator for IAssertionSource - // Only And/Or continuation overloads remain here since generator doesn't create those - - /// - /// Asserts that the boolean value is true (And continuation overload). - /// - public static Chaining.AndAssertion IsTrue( - this AndContinuation source) - { - source.Context.ExpressionBuilder.Append(".IsTrue()"); - var newAssertion = new Bool_IsTrue_Assertion(source.Context); - return new Chaining.AndAssertion(source.PreviousAssertion, newAssertion); - } - - /// - /// Asserts that the boolean value is true (Or continuation overload). - /// - public static Chaining.OrAssertion IsTrue( - this OrContinuation source) - { - source.Context.ExpressionBuilder.Append(".IsTrue()"); - var newAssertion = new Bool_IsTrue_Assertion(source.Context); - return new Chaining.OrAssertion(source.PreviousAssertion, newAssertion); - } - - /// - /// Asserts that the boolean value is false (And continuation overload). - /// - public static Chaining.AndAssertion IsFalse( - this AndContinuation source) - { - source.Context.ExpressionBuilder.Append(".IsFalse()"); - var newAssertion = new Bool_IsFalse_Assertion(source.Context); - return new Chaining.AndAssertion(source.PreviousAssertion, newAssertion); - } - - /// - /// Asserts that the boolean value is false (Or continuation overload). - /// - public static Chaining.OrAssertion IsFalse( - this OrContinuation source) - { - source.Context.ExpressionBuilder.Append(".IsFalse()"); - var newAssertion = new Bool_IsFalse_Assertion(source.Context); - return new Chaining.OrAssertion(source.PreviousAssertion, newAssertion); - } - - // ============ AND/OR CONTINUATION OVERLOADS FOR COMMON METHODS ============ - - /// - /// Asserts that the value is equal to the expected value (And continuation overload). - /// - public static Chaining.AndAssertion IsEqualTo( - this AndContinuation source, - TValue expected, - [CallerArgumentExpression(nameof(expected))] string? expression = null) - { - source.Context.ExpressionBuilder.Append($".IsEqualTo({expression})"); - var newAssertion = new EqualsAssertion(source.Context, expected); - return new Chaining.AndAssertion(source.PreviousAssertion, newAssertion); - } - - /// - /// Asserts that the value is equal to the expected value (Or continuation overload). - /// - public static Chaining.OrAssertion IsEqualTo( - this OrContinuation source, - TValue expected, - [CallerArgumentExpression(nameof(expected))] string? expression = null) - { - source.Context.ExpressionBuilder.Append($".IsEqualTo({expression})"); - var newAssertion = new EqualsAssertion(source.Context, expected); - return new Chaining.OrAssertion(source.PreviousAssertion, newAssertion); - } - - /// - /// Asserts that the string is equal to the expected value using the specified comparison (And continuation overload). - /// - public static Chaining.AndAssertion IsEqualTo( - this AndContinuation source, - string expected, - StringComparison comparison, - [CallerArgumentExpression(nameof(expected))] string? expression = null) - { - source.Context.ExpressionBuilder.Append($".IsEqualTo({expression}, StringComparison.{comparison})"); - var newAssertion = new StringEqualsAssertion(source.Context, expected).WithComparison(comparison); - return new Chaining.AndAssertion(source.PreviousAssertion, newAssertion); - } - - /// - /// Asserts that the string is equal to the expected value using the specified comparison (Or continuation overload). - /// - public static Chaining.OrAssertion IsEqualTo( - this OrContinuation source, - string expected, - StringComparison comparison, - [CallerArgumentExpression(nameof(expected))] string? expression = null) - { - source.Context.ExpressionBuilder.Append($".IsEqualTo({expression}, StringComparison.{comparison})"); - var newAssertion = new StringEqualsAssertion(source.Context, expected).WithComparison(comparison); - return new Chaining.OrAssertion(source.PreviousAssertion, newAssertion); - } - - /// - /// Asserts that the value is not equal to the expected value (And continuation overload). - /// - public static Chaining.AndAssertion IsNotEqualTo( - this AndContinuation source, - TValue expected, - [CallerArgumentExpression(nameof(expected))] string? expression = null) - { - source.Context.ExpressionBuilder.Append($".IsNotEqualTo({expression})"); - var newAssertion = new NotEqualsAssertion(source.Context, expected); - return new Chaining.AndAssertion(source.PreviousAssertion, newAssertion); - } - - /// - /// Asserts that the value is not equal to the expected value (Or continuation overload). - /// - public static Chaining.OrAssertion IsNotEqualTo( - this OrContinuation source, - TValue expected, - [CallerArgumentExpression(nameof(expected))] string? expression = null) - { - source.Context.ExpressionBuilder.Append($".IsNotEqualTo({expression})"); - var newAssertion = new NotEqualsAssertion(source.Context, expected); - return new Chaining.OrAssertion(source.PreviousAssertion, newAssertion); - } // ============ TYPE CHECKS ============ // IsTypeOf(Type), IsAssignableTo, IsNotAssignableTo are now generated by AssertionExtensionGenerator @@ -392,33 +296,6 @@ public static IsTypeOfRuntimeAssertion IsOfType( return new IsTypeOfRuntimeAssertion(source.Context, expectedType); } - /// - /// Asserts that the value is of the specified type (runtime Type parameter, for AndContinuation). - /// Example: await Assert.That(obj).IsEqualTo("foo").And.IsOfType(typeof(string)); - /// - public static Chaining.AndAssertion IsOfType( - this AndContinuation source, - Type expectedType) - { - source.Context.ExpressionBuilder.Append($".IsOfType(typeof({expectedType.Name}))"); - var newAssertion = new IsTypeOfRuntimeAssertion(source.Context, expectedType); - return new Chaining.AndAssertion(source.PreviousAssertion, newAssertion); - } - - /// - /// Asserts that the value is of the specified type (runtime Type parameter, for OrContinuation). - /// Example: await Assert.That(obj).IsEqualTo("foo").Or.IsOfType(typeof(string)); - /// - public static Chaining.OrAssertion IsOfType( - this OrContinuation source, - Type expectedType) - { - source.Context.ExpressionBuilder.Append($".IsOfType(typeof({expectedType.Name}))"); - var newAssertion = new IsTypeOfRuntimeAssertion(source.Context, expectedType); - return new Chaining.OrAssertion(source.PreviousAssertion, newAssertion); - } - - /// /// Asserts on a member of an object using a lambda selector. /// Returns an assertion on the member value for further chaining. @@ -462,17 +339,6 @@ public static StringLengthAssertion HasLength( return new StringLengthAssertion(source.Context, expectedLength); } - /// - /// Returns a wrapper for string length assertions (And continuation overload). - /// Example: await Assert.That(str).IsNotNull().And.HasLength().EqualTo(5); - /// - public static LengthWrapper HasLength( - this AndContinuation source) - { - source.Context.ExpressionBuilder.Append(".HasLength()"); - return new LengthWrapper(source.Context); - } - // ============ DICTIONARY ASSERTIONS ============ /// @@ -639,35 +505,6 @@ public static CollectionContainsPredicateAssertion, TItem> Co return new CollectionContainsPredicateAssertion, TItem>(source.Context, predicate); } - /// - /// Asserts that the collection contains the expected item (And continuation overload). - /// Returns AndAssertion to enable And chaining and prevent mixing with Or. - /// - public static Chaining.AndAssertion Contains( - this AndContinuation source, - TItem expected, - [CallerArgumentExpression(nameof(expected))] string? expression = null) - where TCollection : IEnumerable - { - source.Context.ExpressionBuilder.Append($".Contains({expression})"); - var newAssertion = new CollectionContainsAssertion(source.Context, expected); - return new Chaining.AndAssertion(source.PreviousAssertion, newAssertion); - } - - /// - /// Asserts that the collection contains the expected item (Or continuation overload). - /// Returns OrAssertion to enable Or chaining and prevent mixing with And. - /// - public static Chaining.OrAssertion Contains( - this OrContinuation source, - TItem expected, - [CallerArgumentExpression(nameof(expected))] string? expression = null) - where TCollection : IEnumerable - { - source.Context.ExpressionBuilder.Append($".Contains({expression})"); - var newAssertion = new CollectionContainsAssertion(source.Context, expected); - return new Chaining.OrAssertion(source.PreviousAssertion, newAssertion); - } /// /// Asserts that the collection does NOT contain the expected item. @@ -1220,55 +1057,6 @@ public static ExceptionMessageAssertion HasMessageContaining( return new ExceptionMessageAssertion(mappedContext, expectedSubstring, comparison); } - /// - /// Asserts that an exception's Message property contains the expected substring (And continuation overload). - /// - public static Chaining.AndAssertion HasMessageContaining( - this AndContinuation source, - string expectedSubstring) - { - source.Context.ExpressionBuilder.Append($".HasMessageContaining(\"{expectedSubstring}\")"); - var newAssertion = new HasMessageContainingAssertion(source.Context, expectedSubstring); - return new Chaining.AndAssertion(source.PreviousAssertion, newAssertion); - } - - /// - /// Asserts that an exception's Message property contains the expected substring (Or continuation overload). - /// - public static Chaining.OrAssertion HasMessageContaining( - this OrContinuation source, - string expectedSubstring) - { - source.Context.ExpressionBuilder.Append($".HasMessageContaining(\"{expectedSubstring}\")"); - var newAssertion = new HasMessageContainingAssertion(source.Context, expectedSubstring); - return new Chaining.OrAssertion(source.PreviousAssertion, newAssertion); - } - - /// - /// Asserts that an exception's Message property contains the expected substring using the specified comparison (And continuation overload). - /// - public static Chaining.AndAssertion HasMessageContaining( - this AndContinuation source, - string expectedSubstring, - StringComparison comparison) - { - source.Context.ExpressionBuilder.Append($".HasMessageContaining(\"{expectedSubstring}\", StringComparison.{comparison})"); - var newAssertion = new HasMessageContainingAssertion(source.Context, expectedSubstring, comparison); - return new Chaining.AndAssertion(source.PreviousAssertion, newAssertion); - } - - /// - /// Asserts that an exception's Message property contains the expected substring using the specified comparison (Or continuation overload). - /// - public static Chaining.OrAssertion HasMessageContaining( - this OrContinuation source, - string expectedSubstring, - StringComparison comparison) - { - source.Context.ExpressionBuilder.Append($".HasMessageContaining(\"{expectedSubstring}\", StringComparison.{comparison})"); - var newAssertion = new HasMessageContainingAssertion(source.Context, expectedSubstring, comparison); - return new Chaining.OrAssertion(source.PreviousAssertion, newAssertion); - } /// /// Asserts that the exception message contains the specified substring. @@ -1355,55 +1143,6 @@ public static HasMessageEqualToAssertion HasMessageEqualTo( return new HasMessageEqualToAssertion(source.Context, expectedMessage, comparison); } - /// - /// Asserts that an exception's Message property exactly equals the expected string (And continuation overload). - /// - public static Chaining.AndAssertion HasMessageEqualTo( - this AndContinuation source, - string expectedMessage) - { - source.Context.ExpressionBuilder.Append($".HasMessageEqualTo(\"{expectedMessage}\")"); - var newAssertion = new HasMessageEqualToAssertion(source.Context, expectedMessage); - return new Chaining.AndAssertion(source.PreviousAssertion, newAssertion); - } - - /// - /// Asserts that an exception's Message property exactly equals the expected string (Or continuation overload). - /// - public static Chaining.OrAssertion HasMessageEqualTo( - this OrContinuation source, - string expectedMessage) - { - source.Context.ExpressionBuilder.Append($".HasMessageEqualTo(\"{expectedMessage}\")"); - var newAssertion = new HasMessageEqualToAssertion(source.Context, expectedMessage); - return new Chaining.OrAssertion(source.PreviousAssertion, newAssertion); - } - - /// - /// Asserts that an exception's Message property exactly equals the expected string using the specified string comparison (And continuation overload). - /// - public static Chaining.AndAssertion HasMessageEqualTo( - this AndContinuation source, - string expectedMessage, - StringComparison comparison) - { - source.Context.ExpressionBuilder.Append($".HasMessageEqualTo(\"{expectedMessage}\", StringComparison.{comparison})"); - var newAssertion = new HasMessageEqualToAssertion(source.Context, expectedMessage, comparison); - return new Chaining.AndAssertion(source.PreviousAssertion, newAssertion); - } - - /// - /// Asserts that an exception's Message property exactly equals the expected string using the specified string comparison (Or continuation overload). - /// - public static Chaining.OrAssertion HasMessageEqualTo( - this OrContinuation source, - string expectedMessage, - StringComparison comparison) - { - source.Context.ExpressionBuilder.Append($".HasMessageEqualTo(\"{expectedMessage}\", StringComparison.{comparison})"); - var newAssertion = new HasMessageEqualToAssertion(source.Context, expectedMessage, comparison); - return new Chaining.OrAssertion(source.PreviousAssertion, newAssertion); - } /// /// Asserts that an exception's Message property starts with the expected string. @@ -1430,55 +1169,6 @@ public static HasMessageStartingWithAssertion HasMessageStartingWith(source.Context, expectedPrefix, comparison); } - /// - /// Asserts that an exception's Message property starts with the expected string (And continuation overload). - /// - public static Chaining.AndAssertion HasMessageStartingWith( - this AndContinuation source, - string expectedPrefix) - { - source.Context.ExpressionBuilder.Append($".HasMessageStartingWith(\"{expectedPrefix}\")"); - var newAssertion = new HasMessageStartingWithAssertion(source.Context, expectedPrefix); - return new Chaining.AndAssertion(source.PreviousAssertion, newAssertion); - } - - /// - /// Asserts that an exception's Message property starts with the expected string (Or continuation overload). - /// - public static Chaining.OrAssertion HasMessageStartingWith( - this OrContinuation source, - string expectedPrefix) - { - source.Context.ExpressionBuilder.Append($".HasMessageStartingWith(\"{expectedPrefix}\")"); - var newAssertion = new HasMessageStartingWithAssertion(source.Context, expectedPrefix); - return new Chaining.OrAssertion(source.PreviousAssertion, newAssertion); - } - - /// - /// Asserts that an exception's Message property starts with the expected string using the specified string comparison (And continuation overload). - /// - public static Chaining.AndAssertion HasMessageStartingWith( - this AndContinuation source, - string expectedPrefix, - StringComparison comparison) - { - source.Context.ExpressionBuilder.Append($".HasMessageStartingWith(\"{expectedPrefix}\", StringComparison.{comparison})"); - var newAssertion = new HasMessageStartingWithAssertion(source.Context, expectedPrefix, comparison); - return new Chaining.AndAssertion(source.PreviousAssertion, newAssertion); - } - - /// - /// Asserts that an exception's Message property starts with the expected string using the specified string comparison (Or continuation overload). - /// - public static Chaining.OrAssertion HasMessageStartingWith( - this OrContinuation source, - string expectedPrefix, - StringComparison comparison) - { - source.Context.ExpressionBuilder.Append($".HasMessageStartingWith(\"{expectedPrefix}\", StringComparison.{comparison})"); - var newAssertion = new HasMessageStartingWithAssertion(source.Context, expectedPrefix, comparison); - return new Chaining.OrAssertion(source.PreviousAssertion, newAssertion); - } /// /// Asserts that the DateTime is after or equal to the expected DateTime. From e70d591aefef44e5159cb7ae9f4c30ddb68c2d7c Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Tue, 14 Oct 2025 09:48:41 +0100 Subject: [PATCH 2/5] Add EquatableAssertion and NullableEquatableAssertion classes to assertion library - Introduced EquatableAssertion for asserting equality between two types. - Added NullableEquatableAssertion to handle nullable types in assertions. - Updated various extension methods to remove redundant overloads and improve clarity. - Ensured consistency across .NET 4.7, .NET 8.0, and .NET 9.0 verified files. --- ...Has_No_API_Changes.DotNet10_0.verified.txt | 47 +++++++------------ ..._Has_No_API_Changes.DotNet8_0.verified.txt | 47 +++++++------------ ..._Has_No_API_Changes.DotNet9_0.verified.txt | 47 +++++++------------ ...ary_Has_No_API_Changes.Net4_7.verified.txt | 47 +++++++------------ 4 files changed, 72 insertions(+), 116 deletions(-) diff --git a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet10_0.verified.txt b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet10_0.verified.txt index 1d47957be5..9199048d75 100644 --- a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet10_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet10_0.verified.txt @@ -680,6 +680,13 @@ namespace .Conditions public . IgnoringType() { } public . Within(object tolerance) { } } + public class EquatableAssertion : . + where TActual : + { + public EquatableAssertion(. context, TExpected expected) { } + protected override .<.> CheckAsync(. metadata) { } + protected override string GetExpectation() { } + } public static class ExceptionAssertionExtensions { [.(ExpectationMessage="to have a help link")] @@ -994,6 +1001,13 @@ namespace .Conditions protected override .<.> CheckAsync(. metadata) { } protected override string GetExpectation() { } } + public class NullableEquatableAssertion : . + where TActual : struct, + { + public NullableEquatableAssertion(. context, TExpected expected) { } + protected override .<.> CheckAsync(. metadata) { } + protected override string GetExpectation() { } + } [.<.Process>("EnableRaisingEvents", CustomName="DoesNotHaveEventRaisingEnabled", ExpectationMessage="have event raising enabled", NegateLogic=true)] [.<.Process>("EnableRaisingEvents", ExpectationMessage="have event raising enabled")] [.<.Process>("HasExited", CustomName="HasNotExited", ExpectationMessage="have exited", NegateLogic=true)] @@ -1541,14 +1555,10 @@ namespace .Extensions public static . CompletesWithin(this . source, timeout, [.("timeout")] string? expression = null) { } public static .<., TItem> Contains(this .<.> source, predicate, [.("predicate")] string? expression = null) { } public static .<., TItem> Contains(this .<.> source, TItem expected, [.("expected")] string? expression = null) { } - public static . Contains(this . source, TItem expected, [.("expected")] string? expression = null) - where TCollection : . { } public static . Contains(this . source, predicate, [.("predicate")] string? expression = null) where TCollection : . { } public static . Contains(this . source, TItem expected, [.("expected")] string? expression = null) where TCollection : . { } - public static . Contains(this . source, TItem expected, [.("expected")] string? expression = null) - where TCollection : . { } public static . ContainsKey(this .<.> source, TKey key, [.("key")] string? expression = null) { } public static . ContainsKey(this .<.> source, TKey key, . comparer, [.("key")] string? expression = null) { } public static . ContainsKey(this . source, TKey key, [.("key")] string? expression = null) @@ -1583,26 +1593,13 @@ namespace .Extensions public static ..HasFlagAssertion HasFlag(this . source, TEnum expectedFlag, [.("expectedFlag")] string? expression = null) where TEnum : struct, { } public static ..LengthWrapper HasLength(this . source) { } - public static ..LengthWrapper HasLength(this . source) { } public static . HasLength(this . source, int expectedLength, [.("expectedLength")] string? expression = null) { } public static . HasMember(this . source, .<> memberSelector) { } - public static . HasMessageContaining(this . source, string expectedSubstring) { } public static . HasMessageContaining(this . source, string expectedSubstring) { } - public static . HasMessageContaining(this . source, string expectedSubstring) { } - public static . HasMessageContaining(this . source, string expectedSubstring, comparison) { } public static . HasMessageContaining(this . source, string expectedSubstring, comparison) { } - public static . HasMessageContaining(this . source, string expectedSubstring, comparison) { } - public static . HasMessageEqualTo(this . source, string expectedMessage) { } public static . HasMessageEqualTo(this . source, string expectedMessage) { } - public static . HasMessageEqualTo(this . source, string expectedMessage) { } - public static . HasMessageEqualTo(this . source, string expectedMessage, comparison) { } - public static . HasMessageEqualTo(this . source, string expectedMessage, comparison) { } public static . HasMessageEqualTo(this . source, string expectedMessage, comparison) { } public static . HasMessageStartingWith(this . source, string expectedPrefix) { } - public static . HasMessageStartingWith(this . source, string expectedPrefix) { } - public static . HasMessageStartingWith(this . source, string expectedPrefix) { } - public static . HasMessageStartingWith(this . source, string expectedPrefix, comparison) { } - public static . HasMessageStartingWith(this . source, string expectedPrefix, comparison) { } public static . HasMessageStartingWith(this . source, string expectedPrefix, comparison) { } public static ..HasSameNameAsAssertion HasSameNameAs(this . source, otherEnumValue, [.("otherEnumValue")] string? expression = null) where TEnum : struct, { } @@ -1624,12 +1621,12 @@ namespace .Extensions public static . IsEqualTo(this . source, double expected, [.("expected")] string? expression = null) { } public static . IsEqualTo(this . source, long expected, [.("expected")] string? expression = null) { } public static . IsEqualTo(this . source, string expected, [.("expected")] string? expression = null) { } - public static . IsEqualTo(this . source, string expected, comparison, [.("expected")] string? expression = null) { } public static . IsEqualTo(this . source, string expected, comparison, [.("expected")] string? expression = null) { } - public static . IsEqualTo(this . source, string expected, comparison, [.("expected")] string? expression = null) { } - public static . IsEqualTo(this . source, TValue expected, [.("expected")] string? expression = null) { } - public static . IsEqualTo(this . source, TValue expected, [.("expected")] string? expression = null) { } public static . IsEqualTo(this . source, TValue expected, [.("expected")] string? expression = null) { } + public static . IsEqualTo(this . source, TExpected expected, [.("expected")] string? expression = null) + where TActual : struct, { } + public static . IsEqualTo(this . source, TExpected expected, [.("expected")] string? expression = null) + where TActual : struct, { } public static . IsEquivalentTo(this . source, object? expected, [.("expected")] string? expression = null) { } public static . IsEquivalentTo(this . source, . expected, [.("expected")] string? expression = null) where TCollection : . { } @@ -1637,8 +1634,6 @@ namespace .Extensions where TCollection : . { } public static . IsEquivalentTo(this . source, . expected, . ordering, [.("expected")] string? expression = null) where TCollection : . { } - public static . IsFalse(this . source) { } - public static . IsFalse(this . source) { } public static . IsIn(this . source, params TValue[] collection) { } public static .<., TItem> IsInDescendingOrder(this .<.> source) where TItem : { } @@ -1659,8 +1654,6 @@ namespace .Extensions where TEnum : struct, { } public static . IsNotEmpty(this . source) where TValue : .IEnumerable { } - public static . IsNotEqualTo(this . source, TValue expected, [.("expected")] string? expression = null) { } - public static . IsNotEqualTo(this . source, TValue expected, [.("expected")] string? expression = null) { } public static . IsNotEquivalentTo(this . source, object? expected, [.("expected")] string? expression = null) { } public static . IsNotEquivalentTo(this . source, . expected, [.("expected")] string? expression = null) where TCollection : . { } @@ -1670,16 +1663,12 @@ namespace .Extensions where TCollection : . { } public static . IsNotIn(this . source, params TValue[] collection) { } public static ..IsNotParsableIntoAssertion IsNotParsableInto<[.(..None | ..PublicMethods | ..Interfaces)] T>(this . source) { } - public static . IsOfType(this . source, expectedType) { } - public static . IsOfType(this . source, expectedType) { } public static . IsOfType(this . source, expectedType, [.("expectedType")] string? expression = null) { } public static ..IsParsableIntoAssertion IsParsableInto<[.(..None | ..PublicMethods | ..Interfaces)] T>(this . source) { } public static . IsPositive(this . source) where TValue : { } public static . IsPositive(this . source) where TValue : struct, { } - public static . IsTrue(this . source) { } - public static . IsTrue(this . source) { } public static . IsTypeOf(this . source) { } public static . IsTypeOf(this . source) { } public static . Satisfies(this . source, predicate, [.("predicate")] string? expression = null) { } diff --git a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet8_0.verified.txt b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet8_0.verified.txt index f14350f52a..e564e8cbad 100644 --- a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet8_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet8_0.verified.txt @@ -680,6 +680,13 @@ namespace .Conditions public . IgnoringType() { } public . Within(object tolerance) { } } + public class EquatableAssertion : . + where TActual : + { + public EquatableAssertion(. context, TExpected expected) { } + protected override .<.> CheckAsync(. metadata) { } + protected override string GetExpectation() { } + } public static class ExceptionAssertionExtensions { [.(ExpectationMessage="to have a help link")] @@ -994,6 +1001,13 @@ namespace .Conditions protected override .<.> CheckAsync(. metadata) { } protected override string GetExpectation() { } } + public class NullableEquatableAssertion : . + where TActual : struct, + { + public NullableEquatableAssertion(. context, TExpected expected) { } + protected override .<.> CheckAsync(. metadata) { } + protected override string GetExpectation() { } + } [.<.Process>("EnableRaisingEvents", CustomName="DoesNotHaveEventRaisingEnabled", ExpectationMessage="have event raising enabled", NegateLogic=true)] [.<.Process>("EnableRaisingEvents", ExpectationMessage="have event raising enabled")] [.<.Process>("HasExited", CustomName="HasNotExited", ExpectationMessage="have exited", NegateLogic=true)] @@ -1541,14 +1555,10 @@ namespace .Extensions public static . CompletesWithin(this . source, timeout, [.("timeout")] string? expression = null) { } public static .<., TItem> Contains(this .<.> source, predicate, [.("predicate")] string? expression = null) { } public static .<., TItem> Contains(this .<.> source, TItem expected, [.("expected")] string? expression = null) { } - public static . Contains(this . source, TItem expected, [.("expected")] string? expression = null) - where TCollection : . { } public static . Contains(this . source, predicate, [.("predicate")] string? expression = null) where TCollection : . { } public static . Contains(this . source, TItem expected, [.("expected")] string? expression = null) where TCollection : . { } - public static . Contains(this . source, TItem expected, [.("expected")] string? expression = null) - where TCollection : . { } public static . ContainsKey(this .<.> source, TKey key, [.("key")] string? expression = null) { } public static . ContainsKey(this .<.> source, TKey key, . comparer, [.("key")] string? expression = null) { } public static . ContainsKey(this . source, TKey key, [.("key")] string? expression = null) @@ -1583,26 +1593,13 @@ namespace .Extensions public static ..HasFlagAssertion HasFlag(this . source, TEnum expectedFlag, [.("expectedFlag")] string? expression = null) where TEnum : struct, { } public static ..LengthWrapper HasLength(this . source) { } - public static ..LengthWrapper HasLength(this . source) { } public static . HasLength(this . source, int expectedLength, [.("expectedLength")] string? expression = null) { } public static . HasMember(this . source, .<> memberSelector) { } - public static . HasMessageContaining(this . source, string expectedSubstring) { } public static . HasMessageContaining(this . source, string expectedSubstring) { } - public static . HasMessageContaining(this . source, string expectedSubstring) { } - public static . HasMessageContaining(this . source, string expectedSubstring, comparison) { } public static . HasMessageContaining(this . source, string expectedSubstring, comparison) { } - public static . HasMessageContaining(this . source, string expectedSubstring, comparison) { } - public static . HasMessageEqualTo(this . source, string expectedMessage) { } public static . HasMessageEqualTo(this . source, string expectedMessage) { } - public static . HasMessageEqualTo(this . source, string expectedMessage) { } - public static . HasMessageEqualTo(this . source, string expectedMessage, comparison) { } - public static . HasMessageEqualTo(this . source, string expectedMessage, comparison) { } public static . HasMessageEqualTo(this . source, string expectedMessage, comparison) { } public static . HasMessageStartingWith(this . source, string expectedPrefix) { } - public static . HasMessageStartingWith(this . source, string expectedPrefix) { } - public static . HasMessageStartingWith(this . source, string expectedPrefix) { } - public static . HasMessageStartingWith(this . source, string expectedPrefix, comparison) { } - public static . HasMessageStartingWith(this . source, string expectedPrefix, comparison) { } public static . HasMessageStartingWith(this . source, string expectedPrefix, comparison) { } public static ..HasSameNameAsAssertion HasSameNameAs(this . source, otherEnumValue, [.("otherEnumValue")] string? expression = null) where TEnum : struct, { } @@ -1624,12 +1621,12 @@ namespace .Extensions public static . IsEqualTo(this . source, double expected, [.("expected")] string? expression = null) { } public static . IsEqualTo(this . source, long expected, [.("expected")] string? expression = null) { } public static . IsEqualTo(this . source, string expected, [.("expected")] string? expression = null) { } - public static . IsEqualTo(this . source, string expected, comparison, [.("expected")] string? expression = null) { } public static . IsEqualTo(this . source, string expected, comparison, [.("expected")] string? expression = null) { } - public static . IsEqualTo(this . source, string expected, comparison, [.("expected")] string? expression = null) { } - public static . IsEqualTo(this . source, TValue expected, [.("expected")] string? expression = null) { } - public static . IsEqualTo(this . source, TValue expected, [.("expected")] string? expression = null) { } public static . IsEqualTo(this . source, TValue expected, [.("expected")] string? expression = null) { } + public static . IsEqualTo(this . source, TExpected expected, [.("expected")] string? expression = null) + where TActual : struct, { } + public static . IsEqualTo(this . source, TExpected expected, [.("expected")] string? expression = null) + where TActual : struct, { } public static . IsEquivalentTo(this . source, object? expected, [.("expected")] string? expression = null) { } public static . IsEquivalentTo(this . source, . expected, [.("expected")] string? expression = null) where TCollection : . { } @@ -1637,8 +1634,6 @@ namespace .Extensions where TCollection : . { } public static . IsEquivalentTo(this . source, . expected, . ordering, [.("expected")] string? expression = null) where TCollection : . { } - public static . IsFalse(this . source) { } - public static . IsFalse(this . source) { } public static . IsIn(this . source, params TValue[] collection) { } public static .<., TItem> IsInDescendingOrder(this .<.> source) where TItem : { } @@ -1659,8 +1654,6 @@ namespace .Extensions where TEnum : struct, { } public static . IsNotEmpty(this . source) where TValue : .IEnumerable { } - public static . IsNotEqualTo(this . source, TValue expected, [.("expected")] string? expression = null) { } - public static . IsNotEqualTo(this . source, TValue expected, [.("expected")] string? expression = null) { } public static . IsNotEquivalentTo(this . source, object? expected, [.("expected")] string? expression = null) { } public static . IsNotEquivalentTo(this . source, . expected, [.("expected")] string? expression = null) where TCollection : . { } @@ -1670,16 +1663,12 @@ namespace .Extensions where TCollection : . { } public static . IsNotIn(this . source, params TValue[] collection) { } public static ..IsNotParsableIntoAssertion IsNotParsableInto<[.(..None | ..PublicMethods | ..Interfaces)] T>(this . source) { } - public static . IsOfType(this . source, expectedType) { } - public static . IsOfType(this . source, expectedType) { } public static . IsOfType(this . source, expectedType, [.("expectedType")] string? expression = null) { } public static ..IsParsableIntoAssertion IsParsableInto<[.(..None | ..PublicMethods | ..Interfaces)] T>(this . source) { } public static . IsPositive(this . source) where TValue : { } public static . IsPositive(this . source) where TValue : struct, { } - public static . IsTrue(this . source) { } - public static . IsTrue(this . source) { } public static . IsTypeOf(this . source) { } public static . IsTypeOf(this . source) { } public static . Satisfies(this . source, predicate, [.("predicate")] string? expression = null) { } diff --git a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet9_0.verified.txt b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet9_0.verified.txt index 8d7aa05390..fd054db586 100644 --- a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet9_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet9_0.verified.txt @@ -680,6 +680,13 @@ namespace .Conditions public . IgnoringType() { } public . Within(object tolerance) { } } + public class EquatableAssertion : . + where TActual : + { + public EquatableAssertion(. context, TExpected expected) { } + protected override .<.> CheckAsync(. metadata) { } + protected override string GetExpectation() { } + } public static class ExceptionAssertionExtensions { [.(ExpectationMessage="to have a help link")] @@ -994,6 +1001,13 @@ namespace .Conditions protected override .<.> CheckAsync(. metadata) { } protected override string GetExpectation() { } } + public class NullableEquatableAssertion : . + where TActual : struct, + { + public NullableEquatableAssertion(. context, TExpected expected) { } + protected override .<.> CheckAsync(. metadata) { } + protected override string GetExpectation() { } + } [.<.Process>("EnableRaisingEvents", CustomName="DoesNotHaveEventRaisingEnabled", ExpectationMessage="have event raising enabled", NegateLogic=true)] [.<.Process>("EnableRaisingEvents", ExpectationMessage="have event raising enabled")] [.<.Process>("HasExited", CustomName="HasNotExited", ExpectationMessage="have exited", NegateLogic=true)] @@ -1541,14 +1555,10 @@ namespace .Extensions public static . CompletesWithin(this . source, timeout, [.("timeout")] string? expression = null) { } public static .<., TItem> Contains(this .<.> source, predicate, [.("predicate")] string? expression = null) { } public static .<., TItem> Contains(this .<.> source, TItem expected, [.("expected")] string? expression = null) { } - public static . Contains(this . source, TItem expected, [.("expected")] string? expression = null) - where TCollection : . { } public static . Contains(this . source, predicate, [.("predicate")] string? expression = null) where TCollection : . { } public static . Contains(this . source, TItem expected, [.("expected")] string? expression = null) where TCollection : . { } - public static . Contains(this . source, TItem expected, [.("expected")] string? expression = null) - where TCollection : . { } public static . ContainsKey(this .<.> source, TKey key, [.("key")] string? expression = null) { } public static . ContainsKey(this .<.> source, TKey key, . comparer, [.("key")] string? expression = null) { } public static . ContainsKey(this . source, TKey key, [.("key")] string? expression = null) @@ -1583,26 +1593,13 @@ namespace .Extensions public static ..HasFlagAssertion HasFlag(this . source, TEnum expectedFlag, [.("expectedFlag")] string? expression = null) where TEnum : struct, { } public static ..LengthWrapper HasLength(this . source) { } - public static ..LengthWrapper HasLength(this . source) { } public static . HasLength(this . source, int expectedLength, [.("expectedLength")] string? expression = null) { } public static . HasMember(this . source, .<> memberSelector) { } - public static . HasMessageContaining(this . source, string expectedSubstring) { } public static . HasMessageContaining(this . source, string expectedSubstring) { } - public static . HasMessageContaining(this . source, string expectedSubstring) { } - public static . HasMessageContaining(this . source, string expectedSubstring, comparison) { } public static . HasMessageContaining(this . source, string expectedSubstring, comparison) { } - public static . HasMessageContaining(this . source, string expectedSubstring, comparison) { } - public static . HasMessageEqualTo(this . source, string expectedMessage) { } public static . HasMessageEqualTo(this . source, string expectedMessage) { } - public static . HasMessageEqualTo(this . source, string expectedMessage) { } - public static . HasMessageEqualTo(this . source, string expectedMessage, comparison) { } - public static . HasMessageEqualTo(this . source, string expectedMessage, comparison) { } public static . HasMessageEqualTo(this . source, string expectedMessage, comparison) { } public static . HasMessageStartingWith(this . source, string expectedPrefix) { } - public static . HasMessageStartingWith(this . source, string expectedPrefix) { } - public static . HasMessageStartingWith(this . source, string expectedPrefix) { } - public static . HasMessageStartingWith(this . source, string expectedPrefix, comparison) { } - public static . HasMessageStartingWith(this . source, string expectedPrefix, comparison) { } public static . HasMessageStartingWith(this . source, string expectedPrefix, comparison) { } public static ..HasSameNameAsAssertion HasSameNameAs(this . source, otherEnumValue, [.("otherEnumValue")] string? expression = null) where TEnum : struct, { } @@ -1624,12 +1621,12 @@ namespace .Extensions public static . IsEqualTo(this . source, double expected, [.("expected")] string? expression = null) { } public static . IsEqualTo(this . source, long expected, [.("expected")] string? expression = null) { } public static . IsEqualTo(this . source, string expected, [.("expected")] string? expression = null) { } - public static . IsEqualTo(this . source, string expected, comparison, [.("expected")] string? expression = null) { } public static . IsEqualTo(this . source, string expected, comparison, [.("expected")] string? expression = null) { } - public static . IsEqualTo(this . source, string expected, comparison, [.("expected")] string? expression = null) { } - public static . IsEqualTo(this . source, TValue expected, [.("expected")] string? expression = null) { } - public static . IsEqualTo(this . source, TValue expected, [.("expected")] string? expression = null) { } public static . IsEqualTo(this . source, TValue expected, [.("expected")] string? expression = null) { } + public static . IsEqualTo(this . source, TExpected expected, [.("expected")] string? expression = null) + where TActual : struct, { } + public static . IsEqualTo(this . source, TExpected expected, [.("expected")] string? expression = null) + where TActual : struct, { } public static . IsEquivalentTo(this . source, object? expected, [.("expected")] string? expression = null) { } public static . IsEquivalentTo(this . source, . expected, [.("expected")] string? expression = null) where TCollection : . { } @@ -1637,8 +1634,6 @@ namespace .Extensions where TCollection : . { } public static . IsEquivalentTo(this . source, . expected, . ordering, [.("expected")] string? expression = null) where TCollection : . { } - public static . IsFalse(this . source) { } - public static . IsFalse(this . source) { } public static . IsIn(this . source, params TValue[] collection) { } public static .<., TItem> IsInDescendingOrder(this .<.> source) where TItem : { } @@ -1659,8 +1654,6 @@ namespace .Extensions where TEnum : struct, { } public static . IsNotEmpty(this . source) where TValue : .IEnumerable { } - public static . IsNotEqualTo(this . source, TValue expected, [.("expected")] string? expression = null) { } - public static . IsNotEqualTo(this . source, TValue expected, [.("expected")] string? expression = null) { } public static . IsNotEquivalentTo(this . source, object? expected, [.("expected")] string? expression = null) { } public static . IsNotEquivalentTo(this . source, . expected, [.("expected")] string? expression = null) where TCollection : . { } @@ -1670,16 +1663,12 @@ namespace .Extensions where TCollection : . { } public static . IsNotIn(this . source, params TValue[] collection) { } public static ..IsNotParsableIntoAssertion IsNotParsableInto<[.(..None | ..PublicMethods | ..Interfaces)] T>(this . source) { } - public static . IsOfType(this . source, expectedType) { } - public static . IsOfType(this . source, expectedType) { } public static . IsOfType(this . source, expectedType, [.("expectedType")] string? expression = null) { } public static ..IsParsableIntoAssertion IsParsableInto<[.(..None | ..PublicMethods | ..Interfaces)] T>(this . source) { } public static . IsPositive(this . source) where TValue : { } public static . IsPositive(this . source) where TValue : struct, { } - public static . IsTrue(this . source) { } - public static . IsTrue(this . source) { } public static . IsTypeOf(this . source) { } public static . IsTypeOf(this . source) { } public static . Satisfies(this . source, predicate, [.("predicate")] string? expression = null) { } diff --git a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.Net4_7.verified.txt b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.Net4_7.verified.txt index 9023e92f6f..ab9f38bfaa 100644 --- a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.Net4_7.verified.txt +++ b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.Net4_7.verified.txt @@ -647,6 +647,13 @@ namespace .Conditions public . IgnoringType() { } public . Within(object tolerance) { } } + public class EquatableAssertion : . + where TActual : + { + public EquatableAssertion(. context, TExpected expected) { } + protected override .<.> CheckAsync(. metadata) { } + protected override string GetExpectation() { } + } public static class ExceptionAssertionExtensions { [.(ExpectationMessage="to have a help link")] @@ -952,6 +959,13 @@ namespace .Conditions protected override .<.> CheckAsync(. metadata) { } protected override string GetExpectation() { } } + public class NullableEquatableAssertion : . + where TActual : struct, + { + public NullableEquatableAssertion(. context, TExpected expected) { } + protected override .<.> CheckAsync(. metadata) { } + protected override string GetExpectation() { } + } [.<.Process>("EnableRaisingEvents", CustomName="DoesNotHaveEventRaisingEnabled", ExpectationMessage="have event raising enabled", NegateLogic=true)] [.<.Process>("EnableRaisingEvents", ExpectationMessage="have event raising enabled")] [.<.Process>("HasExited", CustomName="HasNotExited", ExpectationMessage="have exited", NegateLogic=true)] @@ -1446,14 +1460,10 @@ namespace .Extensions public static . CompletesWithin(this . source, timeout, [.("timeout")] string? expression = null) { } public static .<., TItem> Contains(this .<.> source, predicate, [.("predicate")] string? expression = null) { } public static .<., TItem> Contains(this .<.> source, TItem expected, [.("expected")] string? expression = null) { } - public static . Contains(this . source, TItem expected, [.("expected")] string? expression = null) - where TCollection : . { } public static . Contains(this . source, predicate, [.("predicate")] string? expression = null) where TCollection : . { } public static . Contains(this . source, TItem expected, [.("expected")] string? expression = null) where TCollection : . { } - public static . Contains(this . source, TItem expected, [.("expected")] string? expression = null) - where TCollection : . { } public static . ContainsKey(this .<.> source, TKey key, [.("key")] string? expression = null) { } public static . ContainsKey(this .<.> source, TKey key, . comparer, [.("key")] string? expression = null) { } public static . ContainsKey(this . source, TKey key, [.("key")] string? expression = null) @@ -1488,26 +1498,13 @@ namespace .Extensions public static ..HasFlagAssertion HasFlag(this . source, TEnum expectedFlag, [.("expectedFlag")] string? expression = null) where TEnum : struct, { } public static ..LengthWrapper HasLength(this . source) { } - public static ..LengthWrapper HasLength(this . source) { } public static . HasLength(this . source, int expectedLength, [.("expectedLength")] string? expression = null) { } public static . HasMember(this . source, .<> memberSelector) { } - public static . HasMessageContaining(this . source, string expectedSubstring) { } public static . HasMessageContaining(this . source, string expectedSubstring) { } - public static . HasMessageContaining(this . source, string expectedSubstring) { } - public static . HasMessageContaining(this . source, string expectedSubstring, comparison) { } public static . HasMessageContaining(this . source, string expectedSubstring, comparison) { } - public static . HasMessageContaining(this . source, string expectedSubstring, comparison) { } - public static . HasMessageEqualTo(this . source, string expectedMessage) { } public static . HasMessageEqualTo(this . source, string expectedMessage) { } - public static . HasMessageEqualTo(this . source, string expectedMessage) { } - public static . HasMessageEqualTo(this . source, string expectedMessage, comparison) { } - public static . HasMessageEqualTo(this . source, string expectedMessage, comparison) { } public static . HasMessageEqualTo(this . source, string expectedMessage, comparison) { } public static . HasMessageStartingWith(this . source, string expectedPrefix) { } - public static . HasMessageStartingWith(this . source, string expectedPrefix) { } - public static . HasMessageStartingWith(this . source, string expectedPrefix) { } - public static . HasMessageStartingWith(this . source, string expectedPrefix, comparison) { } - public static . HasMessageStartingWith(this . source, string expectedPrefix, comparison) { } public static . HasMessageStartingWith(this . source, string expectedPrefix, comparison) { } public static ..HasSameNameAsAssertion HasSameNameAs(this . source, otherEnumValue, [.("otherEnumValue")] string? expression = null) where TEnum : struct, { } @@ -1527,12 +1524,12 @@ namespace .Extensions public static . IsEqualTo(this . source, double expected, [.("expected")] string? expression = null) { } public static . IsEqualTo(this . source, long expected, [.("expected")] string? expression = null) { } public static . IsEqualTo(this . source, string expected, [.("expected")] string? expression = null) { } - public static . IsEqualTo(this . source, string expected, comparison, [.("expected")] string? expression = null) { } public static . IsEqualTo(this . source, string expected, comparison, [.("expected")] string? expression = null) { } - public static . IsEqualTo(this . source, string expected, comparison, [.("expected")] string? expression = null) { } - public static . IsEqualTo(this . source, TValue expected, [.("expected")] string? expression = null) { } - public static . IsEqualTo(this . source, TValue expected, [.("expected")] string? expression = null) { } public static . IsEqualTo(this . source, TValue expected, [.("expected")] string? expression = null) { } + public static . IsEqualTo(this . source, TExpected expected, [.("expected")] string? expression = null) + where TActual : struct, { } + public static . IsEqualTo(this . source, TExpected expected, [.("expected")] string? expression = null) + where TActual : struct, { } public static . IsEquivalentTo(this . source, object? expected, [.("expected")] string? expression = null) { } public static . IsEquivalentTo(this . source, . expected, [.("expected")] string? expression = null) where TCollection : . { } @@ -1540,8 +1537,6 @@ namespace .Extensions where TCollection : . { } public static . IsEquivalentTo(this . source, . expected, . ordering, [.("expected")] string? expression = null) where TCollection : . { } - public static . IsFalse(this . source) { } - public static . IsFalse(this . source) { } public static . IsIn(this . source, params TValue[] collection) { } public static .<., TItem> IsInDescendingOrder(this .<.> source) where TItem : { } @@ -1562,8 +1557,6 @@ namespace .Extensions where TEnum : struct, { } public static . IsNotEmpty(this . source) where TValue : .IEnumerable { } - public static . IsNotEqualTo(this . source, TValue expected, [.("expected")] string? expression = null) { } - public static . IsNotEqualTo(this . source, TValue expected, [.("expected")] string? expression = null) { } public static . IsNotEquivalentTo(this . source, object? expected, [.("expected")] string? expression = null) { } public static . IsNotEquivalentTo(this . source, . expected, [.("expected")] string? expression = null) where TCollection : . { } @@ -1573,16 +1566,12 @@ namespace .Extensions where TCollection : . { } public static . IsNotIn(this . source, params TValue[] collection) { } public static ..IsNotParsableIntoAssertion IsNotParsableInto(this . source) { } - public static . IsOfType(this . source, expectedType) { } - public static . IsOfType(this . source, expectedType) { } public static . IsOfType(this . source, expectedType, [.("expectedType")] string? expression = null) { } public static ..IsParsableIntoAssertion IsParsableInto(this . source) { } public static . IsPositive(this . source) where TValue : { } public static . IsPositive(this . source) where TValue : struct, { } - public static . IsTrue(this . source) { } - public static . IsTrue(this . source) { } public static . IsTypeOf(this . source) { } public static . IsTypeOf(this . source) { } public static . Satisfies(this . source, predicate, [.("predicate")] string? expression = null) { } From f22336cd66f0c124f4f0431e69909c8ac31043ef Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Tue, 14 Oct 2025 10:57:30 +0100 Subject: [PATCH 3/5] feat: add overload resolution priorities to IsEqualTo assertions for improved type comparison --- .../Extensions/AssertionExtensions.cs | 50 ++++++++++++++++++- ...Has_No_API_Changes.DotNet10_0.verified.txt | 14 ++++++ ..._Has_No_API_Changes.DotNet8_0.verified.txt | 2 + ..._Has_No_API_Changes.DotNet9_0.verified.txt | 14 ++++++ ...ary_Has_No_API_Changes.Net4_7.verified.txt | 2 + 5 files changed, 80 insertions(+), 2 deletions(-) diff --git a/TUnit.Assertions/Extensions/AssertionExtensions.cs b/TUnit.Assertions/Extensions/AssertionExtensions.cs index c03954cf95..b23f8817aa 100644 --- a/TUnit.Assertions/Extensions/AssertionExtensions.cs +++ b/TUnit.Assertions/Extensions/AssertionExtensions.cs @@ -25,7 +25,9 @@ public static class AssertionExtensions /// /// Asserts that the value is equal to the expected value. /// Generic method that works for all types. + /// Priority 0: Fallback for types without specialized overloads. /// + [OverloadResolutionPriority(0)] public static EqualsAssertion IsEqualTo( this IAssertionSource source, TValue expected, @@ -53,7 +55,9 @@ public static EqualsAssertion EqualTo( /// /// Asserts that the DateTime is equal to the expected value. /// Returns DateTimeEqualsAssertion which has .Within() method! + /// Priority 2: Highest priority for specialized type. /// + [OverloadResolutionPriority(2)] public static DateTimeEqualsAssertion IsEqualTo( this IAssertionSource source, DateTime expected, @@ -66,7 +70,9 @@ public static DateTimeEqualsAssertion IsEqualTo( /// /// Asserts that the string is equal to the expected value. /// Returns StringEqualsAssertion which has .IgnoringCase() and .WithComparison() methods! + /// Priority 2: Highest priority for specialized type. /// + [OverloadResolutionPriority(2)] public static StringEqualsAssertion IsEqualTo( this IAssertionSource source, string expected, @@ -94,7 +100,9 @@ public static StringEqualsAssertion IsEqualTo( /// /// Asserts that the DateOnly is equal to the expected value. /// Returns DateOnlyEqualsAssertion which has .WithinDays() method! + /// Priority 2: Highest priority for specialized type. /// + [OverloadResolutionPriority(2)] public static DateOnlyEqualsAssertion IsEqualTo( this IAssertionSource source, DateOnly expected, @@ -107,7 +115,9 @@ public static DateOnlyEqualsAssertion IsEqualTo( /// /// Asserts that the TimeOnly is equal to the expected value. /// Returns TimeOnlyEqualsAssertion which has .Within() method! + /// Priority 2: Highest priority for specialized type. /// + [OverloadResolutionPriority(2)] public static TimeOnlyEqualsAssertion IsEqualTo( this IAssertionSource source, TimeOnly expected, @@ -121,7 +131,9 @@ public static TimeOnlyEqualsAssertion IsEqualTo( /// /// Asserts that the double is equal to the expected value. /// Returns DoubleEqualsAssertion which has .Within() method! + /// Priority 2: Highest priority for specialized type. /// + [OverloadResolutionPriority(2)] public static DoubleEqualsAssertion IsEqualTo( this IAssertionSource source, double expected, @@ -134,7 +146,9 @@ public static DoubleEqualsAssertion IsEqualTo( /// /// Asserts that the long is equal to the expected value. /// Returns LongEqualsAssertion which has .Within() method! + /// Priority 2: Highest priority for specialized type. /// + [OverloadResolutionPriority(2)] public static LongEqualsAssertion IsEqualTo( this IAssertionSource source, long expected, @@ -147,7 +161,9 @@ public static LongEqualsAssertion IsEqualTo( /// /// Asserts that the DateTimeOffset is equal to the expected value. /// Returns DateTimeOffsetEqualsAssertion which has .Within() method! + /// Priority 2: Highest priority for specialized type. /// + [OverloadResolutionPriority(2)] public static DateTimeOffsetEqualsAssertion IsEqualTo( this IAssertionSource source, DateTimeOffset expected, @@ -157,12 +173,41 @@ public static DateTimeOffsetEqualsAssertion IsEqualTo( return new DateTimeOffsetEqualsAssertion(source.Context, expected); } + /// + /// Asserts that the int is equal to the expected value. + /// Priority 2: Takes precedence over IEquatable overload to provide .Within() support. + /// + [OverloadResolutionPriority(2)] + public static EqualsAssertion IsEqualTo( + this IAssertionSource source, + int expected, + [CallerArgumentExpression(nameof(expected))] string? expression = null) + { + source.Context.ExpressionBuilder.Append($".IsEqualTo({expression})"); + return new EqualsAssertion(source.Context, expected); + } + + /// + /// Asserts that the TimeSpan is equal to the expected value. + /// Priority 2: Takes precedence over IEquatable overload to provide .Within() support. + /// + [OverloadResolutionPriority(2)] + public static EqualsAssertion IsEqualTo( + this IAssertionSource source, + TimeSpan expected, + [CallerArgumentExpression(nameof(expected))] string? expression = null) + { + source.Context.ExpressionBuilder.Append($".IsEqualTo({expression})"); + return new EqualsAssertion(source.Context, expected); + } + /// /// Asserts that a struct implementing IEquatable<TExpected> is equal to the expected value. /// This enables direct equality comparisons for structs with cross-type IEquatable implementations. /// Example: A Wrapper struct implementing IEquatable<long> can be compared directly to a long value. - /// Note: In cases of ambiguity with same-type comparisons, explicitly specify the type parameter. + /// Priority 1: Higher priority than generic fallback, uses type-specific IEquatable.Equals. /// + [OverloadResolutionPriority(1)] public static EquatableAssertion IsEqualTo( this IAssertionSource source, TExpected expected, @@ -176,8 +221,9 @@ public static EquatableAssertion IsEqualTo /// Asserts that a nullable struct implementing IEquatable<TExpected> is equal to the expected value. /// Handles nullable structs with cross-type IEquatable implementations. - /// Note: In cases of ambiguity with same-type comparisons, explicitly specify the type parameter. + /// Priority 1: Higher priority than generic fallback, uses type-specific IEquatable.Equals. /// + [OverloadResolutionPriority(1)] public static NullableEquatableAssertion IsEqualTo( this IAssertionSource source, TExpected expected, diff --git a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet10_0.verified.txt b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet10_0.verified.txt index 9199048d75..2a84742b6c 100644 --- a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet10_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet10_0.verified.txt @@ -1614,17 +1614,31 @@ namespace .Extensions where TEnum : struct, { } public static . IsEmpty(this . source) where TValue : .IEnumerable { } + [.(2)] public static . IsEqualTo(this .<> source, expected, [.("expected")] string? expression = null) { } + [.(2)] public static . IsEqualTo(this .<> source, expected, [.("expected")] string? expression = null) { } + [.(2)] public static . IsEqualTo(this .<> source, expected, [.("expected")] string? expression = null) { } + [.(2)] public static . IsEqualTo(this .<> source, expected, [.("expected")] string? expression = null) { } + [.(2)] + public static .<> IsEqualTo(this .<> source, expected, [.("expected")] string? expression = null) { } + [.(2)] public static . IsEqualTo(this . source, double expected, [.("expected")] string? expression = null) { } + [.(2)] + public static . IsEqualTo(this . source, int expected, [.("expected")] string? expression = null) { } + [.(2)] public static . IsEqualTo(this . source, long expected, [.("expected")] string? expression = null) { } + [.(2)] public static . IsEqualTo(this . source, string expected, [.("expected")] string? expression = null) { } public static . IsEqualTo(this . source, string expected, comparison, [.("expected")] string? expression = null) { } + [.(0)] public static . IsEqualTo(this . source, TValue expected, [.("expected")] string? expression = null) { } + [.(1)] public static . IsEqualTo(this . source, TExpected expected, [.("expected")] string? expression = null) where TActual : struct, { } + [.(1)] public static . IsEqualTo(this . source, TExpected expected, [.("expected")] string? expression = null) where TActual : struct, { } public static . IsEquivalentTo(this . source, object? expected, [.("expected")] string? expression = null) { } diff --git a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet8_0.verified.txt b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet8_0.verified.txt index e564e8cbad..a2a71e012a 100644 --- a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet8_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet8_0.verified.txt @@ -1618,7 +1618,9 @@ namespace .Extensions public static . IsEqualTo(this .<> source, expected, [.("expected")] string? expression = null) { } public static . IsEqualTo(this .<> source, expected, [.("expected")] string? expression = null) { } public static . IsEqualTo(this .<> source, expected, [.("expected")] string? expression = null) { } + public static .<> IsEqualTo(this .<> source, expected, [.("expected")] string? expression = null) { } public static . IsEqualTo(this . source, double expected, [.("expected")] string? expression = null) { } + public static . IsEqualTo(this . source, int expected, [.("expected")] string? expression = null) { } public static . IsEqualTo(this . source, long expected, [.("expected")] string? expression = null) { } public static . IsEqualTo(this . source, string expected, [.("expected")] string? expression = null) { } public static . IsEqualTo(this . source, string expected, comparison, [.("expected")] string? expression = null) { } diff --git a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet9_0.verified.txt b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet9_0.verified.txt index fd054db586..d90c6dfea5 100644 --- a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet9_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet9_0.verified.txt @@ -1614,17 +1614,31 @@ namespace .Extensions where TEnum : struct, { } public static . IsEmpty(this . source) where TValue : .IEnumerable { } + [.(2)] public static . IsEqualTo(this .<> source, expected, [.("expected")] string? expression = null) { } + [.(2)] public static . IsEqualTo(this .<> source, expected, [.("expected")] string? expression = null) { } + [.(2)] public static . IsEqualTo(this .<> source, expected, [.("expected")] string? expression = null) { } + [.(2)] public static . IsEqualTo(this .<> source, expected, [.("expected")] string? expression = null) { } + [.(2)] + public static .<> IsEqualTo(this .<> source, expected, [.("expected")] string? expression = null) { } + [.(2)] public static . IsEqualTo(this . source, double expected, [.("expected")] string? expression = null) { } + [.(2)] + public static . IsEqualTo(this . source, int expected, [.("expected")] string? expression = null) { } + [.(2)] public static . IsEqualTo(this . source, long expected, [.("expected")] string? expression = null) { } + [.(2)] public static . IsEqualTo(this . source, string expected, [.("expected")] string? expression = null) { } public static . IsEqualTo(this . source, string expected, comparison, [.("expected")] string? expression = null) { } + [.(0)] public static . IsEqualTo(this . source, TValue expected, [.("expected")] string? expression = null) { } + [.(1)] public static . IsEqualTo(this . source, TExpected expected, [.("expected")] string? expression = null) where TActual : struct, { } + [.(1)] public static . IsEqualTo(this . source, TExpected expected, [.("expected")] string? expression = null) where TActual : struct, { } public static . IsEquivalentTo(this . source, object? expected, [.("expected")] string? expression = null) { } diff --git a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.Net4_7.verified.txt b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.Net4_7.verified.txt index ab9f38bfaa..8a6fc35145 100644 --- a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.Net4_7.verified.txt +++ b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.Net4_7.verified.txt @@ -1521,7 +1521,9 @@ namespace .Extensions where TValue : .IEnumerable { } public static . IsEqualTo(this .<> source, expected, [.("expected")] string? expression = null) { } public static . IsEqualTo(this .<> source, expected, [.("expected")] string? expression = null) { } + public static .<> IsEqualTo(this .<> source, expected, [.("expected")] string? expression = null) { } public static . IsEqualTo(this . source, double expected, [.("expected")] string? expression = null) { } + public static . IsEqualTo(this . source, int expected, [.("expected")] string? expression = null) { } public static . IsEqualTo(this . source, long expected, [.("expected")] string? expression = null) { } public static . IsEqualTo(this . source, string expected, [.("expected")] string? expression = null) { } public static . IsEqualTo(this . source, string expected, comparison, [.("expected")] string? expression = null) { } From eac8a33f7d223d2f5ca0d0117991a0293e3af76f Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Tue, 14 Oct 2025 13:33:45 +0100 Subject: [PATCH 4/5] feat: Enhance assertion chaining and exception message assertions - Implemented support for chaining assertions using And/Or logic in Assertion class. - Added methods to map exceptions in AssertionContext and EvaluationContext. - Introduced new assertion classes for checking exception messages: - ExceptionMessageContainsAssertion - ExceptionMessageEqualsAssertion - ExceptionMessageNotContainsAssertion - ExceptionMessageMatchesPatternAssertion - ExceptionMessageMatchesAssertion - ExceptionParameterNameAssertion - Updated existing assertion extensions to utilize new exception mapping methods. - Created CombinerType enum to specify assertion combination logic. --- TUnit.Assertions/Chaining/AndAssertion.cs | 12 +- TUnit.Assertions/Chaining/AndContinuation.cs | 3 + TUnit.Assertions/Chaining/OrAssertion.cs | 12 +- TUnit.Assertions/Chaining/OrContinuation.cs | 3 + .../Conditions/ExceptionMessageAssertion.cs | 50 -- .../Conditions/ExceptionPropertyAssertions.cs | 311 +++++++++++++ .../Conditions/ThrowsAssertion.cs | 426 +++++++++--------- TUnit.Assertions/Core/Assertion.cs | 32 ++ TUnit.Assertions/Core/AssertionContext.cs | 41 ++ TUnit.Assertions/Core/CombinerType.cs | 17 + TUnit.Assertions/Core/EvaluationContext.cs | 14 + .../Extensions/AssertionExtensions.cs | 205 +++++++-- .../Sources/AsyncFuncAssertion.cs | 4 +- TUnit.Assertions/Sources/FuncAssertion.cs | 8 +- TUnit.Assertions/Sources/TaskAssertion.cs | 4 +- 15 files changed, 803 insertions(+), 339 deletions(-) delete mode 100644 TUnit.Assertions/Conditions/ExceptionMessageAssertion.cs create mode 100644 TUnit.Assertions/Conditions/ExceptionPropertyAssertions.cs create mode 100644 TUnit.Assertions/Core/CombinerType.cs diff --git a/TUnit.Assertions/Chaining/AndAssertion.cs b/TUnit.Assertions/Chaining/AndAssertion.cs index 92e2efe970..b9186e64ee 100644 --- a/TUnit.Assertions/Chaining/AndAssertion.cs +++ b/TUnit.Assertions/Chaining/AndAssertion.cs @@ -30,12 +30,12 @@ public AndAssertion( { var currentScope = AssertionScope.GetCurrentAssertionScope(); - // Try first assertion + // Try first assertion - use ExecuteCoreAsync to avoid recursion if (currentScope != null) { // Inside Assert.Multiple - track exception count var exceptionCountBefore = currentScope.ExceptionCount; - await _first.AssertAsync(); + await _first.ExecuteCoreAsync(); if (currentScope.ExceptionCount > exceptionCountBefore) { @@ -65,14 +65,14 @@ public AndAssertion( else { // Not in Assert.Multiple - first must pass - await _first.AssertAsync(); // Will throw if fails + await _first.ExecuteCoreAsync(); // Will throw if fails } - // First passed, try second assertion + // First passed, try second assertion - use ExecuteCoreAsync to avoid recursion if (currentScope != null) { var exceptionCountBefore = currentScope.ExceptionCount; - await _second.AssertAsync(); + await _second.ExecuteCoreAsync(); if (currentScope.ExceptionCount > exceptionCountBefore) { @@ -107,7 +107,7 @@ public AndAssertion( // Not in Assert.Multiple try { - var result = await _second.AssertAsync(); + var result = await _second.ExecuteCoreAsync(); return result; } catch (AssertionException ex) diff --git a/TUnit.Assertions/Chaining/AndContinuation.cs b/TUnit.Assertions/Chaining/AndContinuation.cs index de1597dd81..2f17681195 100644 --- a/TUnit.Assertions/Chaining/AndContinuation.cs +++ b/TUnit.Assertions/Chaining/AndContinuation.cs @@ -25,5 +25,8 @@ internal AndContinuation(AssertionContext context, Assertion pre Context = context ?? throw new ArgumentNullException(nameof(context)); PreviousAssertion = previousAssertion ?? throw new ArgumentNullException(nameof(previousAssertion)); Context.ExpressionBuilder.Append(".And"); + + // Set pending link state for next assertion to consume + Context.SetPendingLink(previousAssertion, CombinerType.And); } } diff --git a/TUnit.Assertions/Chaining/OrAssertion.cs b/TUnit.Assertions/Chaining/OrAssertion.cs index 296c322ea1..a462ca989e 100644 --- a/TUnit.Assertions/Chaining/OrAssertion.cs +++ b/TUnit.Assertions/Chaining/OrAssertion.cs @@ -31,12 +31,12 @@ public OrAssertion( var currentScope = AssertionScope.GetCurrentAssertionScope(); Exception? firstException = null; - // Try first assertion + // Try first assertion - use ExecuteCoreAsync to avoid recursion if (currentScope != null) { // Inside Assert.Multiple - track exception count var exceptionCountBefore = currentScope.ExceptionCount; - await _first.AssertAsync(); + await _first.ExecuteCoreAsync(); if (currentScope.ExceptionCount > exceptionCountBefore) { @@ -54,7 +54,7 @@ public OrAssertion( // Not in Assert.Multiple - use exception handling try { - var result = await _first.AssertAsync(); + var result = await _first.ExecuteCoreAsync(); // First passed - return success return result; } @@ -64,11 +64,11 @@ public OrAssertion( } } - // First failed, try second assertion + // First failed, try second assertion - use ExecuteCoreAsync to avoid recursion if (currentScope != null) { var exceptionCountBefore = currentScope.ExceptionCount; - await _second.AssertAsync(); + await _second.ExecuteCoreAsync(); if (currentScope.ExceptionCount > exceptionCountBefore) { @@ -108,7 +108,7 @@ public OrAssertion( // Not in Assert.Multiple try { - var result = await _second.AssertAsync(); + var result = await _second.ExecuteCoreAsync(); // Second passed - return success (first failed but Or means at least one passes) return result; } diff --git a/TUnit.Assertions/Chaining/OrContinuation.cs b/TUnit.Assertions/Chaining/OrContinuation.cs index ce3d2ca658..674b631975 100644 --- a/TUnit.Assertions/Chaining/OrContinuation.cs +++ b/TUnit.Assertions/Chaining/OrContinuation.cs @@ -25,5 +25,8 @@ internal OrContinuation(AssertionContext context, Assertion prev Context = context ?? throw new ArgumentNullException(nameof(context)); PreviousAssertion = previousAssertion ?? throw new ArgumentNullException(nameof(previousAssertion)); Context.ExpressionBuilder.Append(".Or"); + + // Set pending link state for next assertion to consume + Context.SetPendingLink(previousAssertion, CombinerType.Or); } } diff --git a/TUnit.Assertions/Conditions/ExceptionMessageAssertion.cs b/TUnit.Assertions/Conditions/ExceptionMessageAssertion.cs deleted file mode 100644 index 466e7df48e..0000000000 --- a/TUnit.Assertions/Conditions/ExceptionMessageAssertion.cs +++ /dev/null @@ -1,50 +0,0 @@ -using System.Text; -using TUnit.Assertions.Core; - -namespace TUnit.Assertions.Conditions; - -/// -/// Asserts that an exception message contains a specific substring. -/// Works on AndContinuation after Throws assertions. -/// -public class ExceptionMessageAssertion : Assertion -{ - private readonly string _expectedSubstring; - private readonly StringComparison _comparison; - - public ExceptionMessageAssertion( - AssertionContext context, - string expectedSubstring, - StringComparison comparison = StringComparison.Ordinal) - : base(context) - { - _expectedSubstring = expectedSubstring; - _comparison = comparison; - } - - protected override Task CheckAsync(EvaluationMetadata metadata) - { - var value = metadata.Value; - var exception = metadata.Exception; - - if (exception == null) - { - return Task.FromResult(AssertionResult.Failed("no exception was thrown")); - } - - if (exception.Message == null) - { - return Task.FromResult(AssertionResult.Failed("exception message was null")); - } - - if (exception.Message.Contains(_expectedSubstring, _comparison)) - { - return Task.FromResult(AssertionResult.Passed); - } - - return Task.FromResult(AssertionResult.Failed($"exception message was \"{exception.Message}\"")); - } - - protected override string GetExpectation() => - $"exception message to contain \"{_expectedSubstring}\""; -} diff --git a/TUnit.Assertions/Conditions/ExceptionPropertyAssertions.cs b/TUnit.Assertions/Conditions/ExceptionPropertyAssertions.cs new file mode 100644 index 0000000000..7eaa9b61a2 --- /dev/null +++ b/TUnit.Assertions/Conditions/ExceptionPropertyAssertions.cs @@ -0,0 +1,311 @@ +using System.Runtime.CompilerServices; +using System.Text.RegularExpressions; +using TUnit.Assertions.Core; + +namespace TUnit.Assertions.Conditions; + +/// +/// Asserts that an exception's Message property contains a specific substring. +/// +public class ExceptionMessageContainsAssertion : Assertion + where TException : Exception +{ + private readonly string _expectedSubstring; + private readonly StringComparison _comparison; + + public ExceptionMessageContainsAssertion( + AssertionContext context, + string expectedSubstring, + StringComparison comparison = StringComparison.Ordinal) + : base(context) + { + _expectedSubstring = expectedSubstring; + _comparison = comparison; + } + + protected override Task CheckAsync(EvaluationMetadata metadata) + { + var exception = metadata.Value; + var evaluationException = metadata.Exception; + + if (evaluationException != null) + { + return Task.FromResult(AssertionResult.Failed($"threw {evaluationException.GetType().FullName}")); + } + + if (exception == null) + { + return Task.FromResult(AssertionResult.Failed("no exception was thrown")); + } + + if (exception.Message == null) + { + return Task.FromResult(AssertionResult.Failed("exception message was null")); + } + + if (exception.Message.Contains(_expectedSubstring, _comparison)) + { + return Task.FromResult(AssertionResult.Passed); + } + + return Task.FromResult(AssertionResult.Failed($"exception message was \"{exception.Message}\"")); + } + + protected override string GetExpectation() => + $"exception message to contain \"{_expectedSubstring}\""; +} + +/// +/// Asserts that an exception's Message property exactly equals a specific string. +/// +public class ExceptionMessageEqualsAssertion : Assertion + where TException : Exception +{ + private readonly string _expectedMessage; + private readonly StringComparison _comparison; + + public ExceptionMessageEqualsAssertion( + AssertionContext context, + string expectedMessage, + StringComparison comparison = StringComparison.Ordinal) + : base(context) + { + _expectedMessage = expectedMessage; + _comparison = comparison; + } + + protected override Task CheckAsync(EvaluationMetadata metadata) + { + var exception = metadata.Value; + var evaluationException = metadata.Exception; + + if (evaluationException != null) + { + return Task.FromResult(AssertionResult.Failed($"threw {evaluationException.GetType().FullName}")); + } + + if (exception == null) + { + return Task.FromResult(AssertionResult.Failed("no exception was thrown")); + } + + if (string.Equals(exception.Message, _expectedMessage, _comparison)) + { + return Task.FromResult(AssertionResult.Passed); + } + + return Task.FromResult(AssertionResult.Failed($"exception message was \"{exception.Message}\"")); + } + + protected override string GetExpectation() => + $"exception message to equal \"{_expectedMessage}\""; +} + +/// +/// Asserts that an exception's Message property does NOT contain a specific substring. +/// +public class ExceptionMessageNotContainsAssertion : Assertion + where TException : Exception +{ + private readonly string _notExpectedSubstring; + private readonly StringComparison _comparison; + + public ExceptionMessageNotContainsAssertion( + AssertionContext context, + string notExpectedSubstring, + StringComparison comparison = StringComparison.Ordinal) + : base(context) + { + _notExpectedSubstring = notExpectedSubstring; + _comparison = comparison; + } + + protected override Task CheckAsync(EvaluationMetadata metadata) + { + var exception = metadata.Value; + var evaluationException = metadata.Exception; + + if (evaluationException != null) + { + return Task.FromResult(AssertionResult.Failed($"threw {evaluationException.GetType().FullName}")); + } + + if (exception == null) + { + return Task.FromResult(AssertionResult.Failed("no exception was thrown")); + } + + if (exception.Message != null && exception.Message.Contains(_notExpectedSubstring, _comparison)) + { + return Task.FromResult(AssertionResult.Failed( + $"exception message \"{exception.Message}\" should not contain \"{_notExpectedSubstring}\"")); + } + + return Task.FromResult(AssertionResult.Passed); + } + + protected override string GetExpectation() => + $"exception message to not contain \"{_notExpectedSubstring}\""; +} + +/// +/// Asserts that an exception's Message property matches a wildcard pattern. +/// * matches any number of characters, ? matches a single character. +/// +public class ExceptionMessageMatchesPatternAssertion : Assertion + where TException : Exception +{ + private readonly string _pattern; + + public ExceptionMessageMatchesPatternAssertion( + AssertionContext context, + string pattern) + : base(context) + { + _pattern = pattern; + } + + protected override Task CheckAsync(EvaluationMetadata metadata) + { + var exception = metadata.Value; + var evaluationException = metadata.Exception; + + if (evaluationException != null) + { + return Task.FromResult(AssertionResult.Failed($"threw {evaluationException.GetType().FullName}")); + } + + if (exception == null) + { + return Task.FromResult(AssertionResult.Failed("no exception was thrown")); + } + + if (exception.Message == null) + { + return Task.FromResult(AssertionResult.Failed("exception message was null")); + } + + if (MatchesPattern(exception.Message, _pattern)) + { + return Task.FromResult(AssertionResult.Passed); + } + + return Task.FromResult(AssertionResult.Failed( + $"exception message \"{exception.Message}\" does not match pattern \"{_pattern}\"")); + } + + protected override string GetExpectation() => + $"exception message to match pattern \"{_pattern}\""; + + private static bool MatchesPattern(string input, string pattern) + { + // Convert wildcard pattern to regex + // * matches any number of characters (including newlines) + // ? matches a single character + var regexPattern = "^" + Regex.Escape(pattern) + .Replace("\\*", ".*") + .Replace("\\?", ".") + "$"; + + // Use Singleline option so . matches newlines (needed for multiline error messages) + return Regex.IsMatch(input, regexPattern, RegexOptions.Singleline); + } +} + +/// +/// Asserts that an exception's Message property matches a StringMatcher pattern. +/// +public class ExceptionMessageMatchesAssertion : Assertion + where TException : Exception +{ + private readonly StringMatcher _matcher; + + public ExceptionMessageMatchesAssertion( + AssertionContext context, + StringMatcher matcher) + : base(context) + { + _matcher = matcher; + } + + protected override Task CheckAsync(EvaluationMetadata metadata) + { + var exception = metadata.Value; + var evaluationException = metadata.Exception; + + if (evaluationException != null) + { + return Task.FromResult(AssertionResult.Failed($"threw {evaluationException.GetType().FullName}")); + } + + if (exception == null) + { + return Task.FromResult(AssertionResult.Failed("no exception was thrown")); + } + + if (exception.Message == null) + { + return Task.FromResult(AssertionResult.Failed("exception message was null")); + } + + if (_matcher.IsMatch(exception.Message)) + { + return Task.FromResult(AssertionResult.Passed); + } + + return Task.FromResult(AssertionResult.Failed( + $"exception message \"{exception.Message}\" does not match {_matcher}")); + } + + protected override string GetExpectation() => + $"exception message to match {_matcher}"; +} + +/// +/// Asserts that an ArgumentException has a specific parameter name. +/// +public class ExceptionParameterNameAssertion : Assertion + where TException : Exception +{ + private readonly string _expectedParameterName; + + public ExceptionParameterNameAssertion( + AssertionContext context, + string expectedParameterName) + : base(context) + { + _expectedParameterName = expectedParameterName; + } + + protected override Task CheckAsync(EvaluationMetadata metadata) + { + var exception = metadata.Value; + var evaluationException = metadata.Exception; + + if (evaluationException != null) + { + return Task.FromResult(AssertionResult.Failed($"threw {evaluationException.GetType().FullName}")); + } + + if (exception == null) + { + return Task.FromResult(AssertionResult.Failed("no exception was thrown")); + } + + if (exception is ArgumentException argumentException) + { + if (argumentException.ParamName == _expectedParameterName) + { + return Task.FromResult(AssertionResult.Passed); + } + + return Task.FromResult(AssertionResult.Failed( + $"ArgumentException parameter name was \"{argumentException.ParamName}\"")); + } + + return Task.FromResult(AssertionResult.Failed( + $"WithParameterName can only be used with ArgumentException, but exception is {exception.GetType().Name}")); + } + + protected override string GetExpectation() => + $"ArgumentException to have parameter name \"{_expectedParameterName}\""; +} diff --git a/TUnit.Assertions/Conditions/ThrowsAssertion.cs b/TUnit.Assertions/Conditions/ThrowsAssertion.cs index 1f2c2b3f9b..17c39266f0 100644 --- a/TUnit.Assertions/Conditions/ThrowsAssertion.cs +++ b/TUnit.Assertions/Conditions/ThrowsAssertion.cs @@ -1,59 +1,23 @@ -using System.Runtime.CompilerServices; using TUnit.Assertions.Core; namespace TUnit.Assertions.Conditions; /// -/// Base class for exception assertions that provides common message validation functionality. +/// Base class for exception assertions that provides common type-checking functionality. /// Uses self-referencing generic pattern to maintain type-safe fluent API. /// public abstract class BaseThrowsAssertion : Assertion where TException : Exception where TSelf : BaseThrowsAssertion { - private string? _expectedMessageSubstring; - private string? _expectedExactMessage; - private string? _expectedParameterName; - private string? _notExpectedMessageSubstring; - private string? _expectedMessagePattern; - private StringMatcher? _expectedMessageMatcher; - private StringComparison _stringComparison = StringComparison.Ordinal; + private readonly bool _allowSubclasses; protected BaseThrowsAssertion( - AssertionContext context, + AssertionContext context, bool allowSubclasses) - : base(new AssertionContext(MapToException(context.Evaluation, allowSubclasses), context.ExpressionBuilder)) - { - } - - private static EvaluationContext MapToException(EvaluationContext context, bool allowSubclasses) + : base(context) { - return new EvaluationContext(async () => - { - var (value, exception) = await context.GetAsync(); - - // Move exception to value field so it can be returned by GetAwaiter - // This allows: var ex = await Assert.That(action).Throws(); - if (exception != null) - { - bool isMatch = allowSubclasses - ? exception is TException - : exception.GetType() == typeof(TException); - - if (isMatch) - { - return ((TException)exception, null); // Exception as value, cleared exception field - } - else - { - // Wrong type - keep in exception field for CheckAsync to report - return (default(TException), exception); - } - } - - // No exception was thrown - keep null in both fields - return (default(TException), null); - }); + _allowSubclasses = allowSubclasses; } /// @@ -66,197 +30,34 @@ private static EvaluationContext MapToException(EvaluationContext protected abstract bool IsExactTypeMatch { get; } - /// - /// Asserts that the exception message exactly equals the specified string. - /// - public TSelf WithMessage(string expectedMessage, [CallerArgumentExpression(nameof(expectedMessage))] string? expression = null) - { - _expectedExactMessage = expectedMessage; - Context.ExpressionBuilder.Append($".WithMessage({expression})"); - return (TSelf)this; - } - - /// - /// Asserts that the exception message contains the specified substring. - /// - public TSelf WithMessageContaining(string expectedSubstring, [CallerArgumentExpression(nameof(expectedSubstring))] string? expression = null) - { - _expectedMessageSubstring = expectedSubstring; - Context.ExpressionBuilder.Append($".WithMessageContaining({expression})"); - return (TSelf)this; - } - - /// - /// Asserts that the exception message contains the specified substring using the specified string comparison. - /// - public TSelf WithMessageContaining(string expectedSubstring, StringComparison comparison, [CallerArgumentExpression(nameof(expectedSubstring))] string? expression = null) - { - _expectedMessageSubstring = expectedSubstring; - _stringComparison = comparison; - Context.ExpressionBuilder.Append($".WithMessageContaining({expression}, StringComparison.{comparison})"); - return (TSelf)this; - } - - /// - /// Alias for WithMessageContaining - asserts that the exception message contains the specified substring. - /// - public TSelf HasMessageContaining(string expectedSubstring, [CallerArgumentExpression(nameof(expectedSubstring))] string? expression = null) - { - return WithMessageContaining(expectedSubstring, expression); - } - - /// - /// Alias for WithMessageContaining - asserts that the exception message contains the specified substring using the specified string comparison. - /// - public TSelf HasMessageContaining(string expectedSubstring, StringComparison comparison, [CallerArgumentExpression(nameof(expectedSubstring))] string? expression = null) - { - return WithMessageContaining(expectedSubstring, comparison, expression); - } - - /// - /// Asserts that the exception message does NOT contain the specified substring. - /// - public TSelf WithMessageNotContaining(string notExpectedSubstring, [CallerArgumentExpression(nameof(notExpectedSubstring))] string? expression = null) - { - _notExpectedMessageSubstring = notExpectedSubstring; - Context.ExpressionBuilder.Append($".WithMessageNotContaining({expression})"); - return (TSelf)this; - } - - /// - /// Asserts that the exception message does NOT contain the specified substring using the specified string comparison. - /// - public TSelf WithMessageNotContaining(string notExpectedSubstring, StringComparison comparison, [CallerArgumentExpression(nameof(notExpectedSubstring))] string? expression = null) - { - _notExpectedMessageSubstring = notExpectedSubstring; - _stringComparison = comparison; - Context.ExpressionBuilder.Append($".WithMessageNotContaining({expression}, StringComparison.{comparison})"); - return (TSelf)this; - } - - /// - /// Asserts that the exception message matches the specified pattern (using wildcards * and ?). - /// * matches any number of characters, ? matches a single character. - /// - public TSelf WithMessageMatching(string pattern, [CallerArgumentExpression(nameof(pattern))] string? expression = null) - { - _expectedMessagePattern = pattern; - Context.ExpressionBuilder.Append($".WithMessageMatching({expression})"); - return (TSelf)this; - } - - /// - /// Asserts that the exception message matches the specified StringMatcher pattern. - /// Supports regex, wildcards, and case-insensitive matching. - /// - public TSelf WithMessageMatching(StringMatcher matcher, [CallerArgumentExpression(nameof(matcher))] string? expression = null) - { - _expectedMessageMatcher = matcher; - Context.ExpressionBuilder.Append($".WithMessageMatching({expression})"); - return (TSelf)this; - } - - /// - /// Asserts that the ArgumentException has the specified parameter name. - /// Only valid when TException is ArgumentException or a subclass. - /// - public TSelf WithParameterName(string expectedParameterName, [CallerArgumentExpression(nameof(expectedParameterName))] string? expression = null) - { - _expectedParameterName = expectedParameterName; - Context.ExpressionBuilder.Append($".WithParameterName({expression})"); - return (TSelf)this; - } - protected sealed override Task CheckAsync(EvaluationMetadata metadata) { - var value = metadata.Value; - var exception = metadata.Exception; + var exception = metadata.Value; + var evaluationException = metadata.Exception; - // For Throws assertions, the exception is stored as the value after mapping - var actualException = exception ?? value as Exception; + // If there was an evaluation exception, something went wrong during evaluation + if (evaluationException != null) + { + return Task.FromResult(AssertionResult.Failed($"threw {evaluationException.GetType().FullName}")); + } - if (actualException == null) + // The exception should be in the value field after MapException + if (exception == null) { return Task.FromResult(AssertionResult.Failed("no exception was thrown")); } // Delegate type checking to derived class - if (!CheckExceptionType(actualException, out var typeErrorMessage)) + if (!CheckExceptionType(exception, out var typeErrorMessage)) { return Task.FromResult(AssertionResult.Failed(typeErrorMessage!)); } - // Validate message expectations - if (_expectedExactMessage != null && actualException.Message != _expectedExactMessage) - { - return Task.FromResult(AssertionResult.Failed( - $"exception message \"{actualException.Message}\" does not equal \"{_expectedExactMessage}\"")); - } - - if (_expectedMessageSubstring != null && !actualException.Message.Contains(_expectedMessageSubstring, _stringComparison)) - { - return Task.FromResult(AssertionResult.Failed( - $"exception message \"{actualException.Message}\" does not contain \"{_expectedMessageSubstring}\"")); - } - - if (_notExpectedMessageSubstring != null && actualException.Message.Contains(_notExpectedMessageSubstring, _stringComparison)) - { - return Task.FromResult(AssertionResult.Failed( - $"exception message \"{actualException.Message}\" should not contain \"{_notExpectedMessageSubstring}\"")); - } - - if (_expectedMessagePattern != null && !MatchesPattern(actualException.Message, _expectedMessagePattern)) - { - return Task.FromResult(AssertionResult.Failed( - $"exception message \"{actualException.Message}\" does not match pattern \"{_expectedMessagePattern}\"")); - } - - if (_expectedMessageMatcher != null && !_expectedMessageMatcher.IsMatch(actualException.Message)) - { - return Task.FromResult(AssertionResult.Failed( - $"exception message \"{actualException.Message}\" does not match {_expectedMessageMatcher}")); - } - - // Validate parameter name for ArgumentException - if (_expectedParameterName != null) - { - if (actualException is ArgumentException argumentException) - { - if (argumentException.ParamName != _expectedParameterName) - { - return Task.FromResult(AssertionResult.Failed( - $"ArgumentException parameter name \"{argumentException.ParamName}\" does not equal \"{_expectedParameterName}\"")); - } - } - else - { - return Task.FromResult(AssertionResult.Failed( - $"WithParameterName can only be used with ArgumentException, but exception is {actualException.GetType().Name}")); - } - } - return Task.FromResult(AssertionResult.Passed); } protected override string GetExpectation() => - _expectedExactMessage != null - ? $"to throw {(IsExactTypeMatch ? "exactly " : "")}{typeof(TException).Name} with message \"{_expectedExactMessage}\"" - : _expectedMessageSubstring != null - ? $"to throw {(IsExactTypeMatch ? "exactly " : "")}{typeof(TException).Name} with message containing \"{_expectedMessageSubstring}\"" - : $"to throw {(IsExactTypeMatch ? "exactly " : "")}{typeof(TException).Name}"; - - private static bool MatchesPattern(string input, string pattern) - { - // Convert wildcard pattern to regex - // * matches any number of characters (including newlines) - // ? matches a single character - var regexPattern = "^" + System.Text.RegularExpressions.Regex.Escape(pattern) - .Replace("\\*", ".*") - .Replace("\\?", ".") + "$"; - - // Use Singleline option so . matches newlines (needed for multiline error messages) - return System.Text.RegularExpressions.Regex.IsMatch(input, regexPattern, System.Text.RegularExpressions.RegexOptions.Singleline); - } + $"to throw {(IsExactTypeMatch ? "exactly " : "")}{typeof(TException).Name}"; } /// @@ -267,7 +68,7 @@ public class ThrowsAssertion : BaseThrowsAssertion context) + AssertionContext context) : base(context, allowSubclasses: true) { } @@ -295,17 +96,108 @@ public ThrowsAssertion WithInnerException() Context.ExpressionBuilder.Append(".WithInnerException()"); // Create a new evaluation context that evaluates to the inner exception - var innerExceptionContext = new EvaluationContext(async () => + var innerExceptionContext = new EvaluationContext(async () => { var (value, exception) = await Context.GetAsync(); - // Exception might be in value field (after mapping) or exception field - var actualException = value as Exception ?? exception; + // After MapException, the exception is in the value field + var actualException = value; + var inner = actualException?.InnerException; - return (null, actualException?.InnerException); + return (inner, null); // Inner exception as value }); - return new ThrowsAssertion(new AssertionContext(innerExceptionContext, Context.ExpressionBuilder)); + return new ThrowsAssertion(new AssertionContext(innerExceptionContext, Context.ExpressionBuilder)); + } + + /// + /// Instance method for backward compatibility - delegates to extension method. + /// Asserts that the exception message contains the specified substring. + /// + public ExceptionMessageContainsAssertion WithMessageContaining(string expectedSubstring) + { + Context.ExpressionBuilder.Append($".WithMessageContaining(\"{expectedSubstring}\")"); + return new ExceptionMessageContainsAssertion(Context, expectedSubstring); + } + + /// + /// Instance method for backward compatibility - delegates to extension method. + /// Asserts that the exception message contains the specified substring using the specified comparison. + /// + public ExceptionMessageContainsAssertion WithMessageContaining(string expectedSubstring, StringComparison comparison) + { + Context.ExpressionBuilder.Append($".WithMessageContaining(\"{expectedSubstring}\", StringComparison.{comparison})"); + return new ExceptionMessageContainsAssertion(Context, expectedSubstring, comparison); + } + + /// + /// Instance method for backward compatibility - delegates to extension method. + /// Asserts that the exception message does NOT contain the specified substring. + /// + public ExceptionMessageNotContainsAssertion WithMessageNotContaining(string notExpectedSubstring) + { + Context.ExpressionBuilder.Append($".WithMessageNotContaining(\"{notExpectedSubstring}\")"); + return new ExceptionMessageNotContainsAssertion(Context, notExpectedSubstring); + } + + /// + /// Instance method for backward compatibility - delegates to extension method. + /// Asserts that the exception message does NOT contain the specified substring using the specified comparison. + /// + public ExceptionMessageNotContainsAssertion WithMessageNotContaining(string notExpectedSubstring, StringComparison comparison) + { + Context.ExpressionBuilder.Append($".WithMessageNotContaining(\"{notExpectedSubstring}\", StringComparison.{comparison})"); + return new ExceptionMessageNotContainsAssertion(Context, notExpectedSubstring, comparison); + } + + /// + /// Instance method for backward compatibility - delegates to extension method. + /// Asserts that the exception message exactly equals the specified string. + /// + public ExceptionMessageEqualsAssertion WithMessage(string expectedMessage) + { + Context.ExpressionBuilder.Append($".WithMessage(\"{expectedMessage}\")"); + return new ExceptionMessageEqualsAssertion(Context, expectedMessage); + } + + /// + /// Instance method for backward compatibility - delegates to extension method. + /// Asserts that the exception message exactly equals the specified string using the specified comparison. + /// + public ExceptionMessageEqualsAssertion WithMessage(string expectedMessage, StringComparison comparison) + { + Context.ExpressionBuilder.Append($".WithMessage(\"{expectedMessage}\", StringComparison.{comparison})"); + return new ExceptionMessageEqualsAssertion(Context, expectedMessage, comparison); + } + + /// + /// Instance method for backward compatibility - delegates to extension method. + /// Asserts that the exception message matches a wildcard pattern. + /// + public ExceptionMessageMatchesPatternAssertion WithMessageMatching(string pattern) + { + Context.ExpressionBuilder.Append($".WithMessageMatching(\"{pattern}\")"); + return new ExceptionMessageMatchesPatternAssertion(Context, pattern); + } + + /// + /// Instance method for backward compatibility - delegates to extension method. + /// Asserts that the exception message matches a StringMatcher pattern. + /// + public ExceptionMessageMatchesAssertion WithMessageMatching(StringMatcher matcher) + { + Context.ExpressionBuilder.Append($".WithMessageMatching(matcher)"); + return new ExceptionMessageMatchesAssertion(Context, matcher); + } + + /// + /// Instance method for backward compatibility - delegates to extension method. + /// Asserts that an ArgumentException has the specified parameter name. + /// + public ExceptionParameterNameAssertion WithParameterName(string expectedParameterName) + { + Context.ExpressionBuilder.Append($".WithParameterName(\"{expectedParameterName}\")"); + return new ExceptionParameterNameAssertion(Context, expectedParameterName); } } @@ -316,7 +208,7 @@ public class ThrowsExactlyAssertion : BaseThrowsAssertion context) + AssertionContext context) : base(context, allowSubclasses: false) { } @@ -335,6 +227,96 @@ protected override bool CheckExceptionType(Exception actualException, out string errorMessage = null; return true; } + + /// + /// Instance method for backward compatibility - delegates to extension method. + /// Asserts that the exception message contains the specified substring. + /// + public ExceptionMessageContainsAssertion WithMessageContaining(string expectedSubstring) + { + Context.ExpressionBuilder.Append($".WithMessageContaining(\"{expectedSubstring}\")"); + return new ExceptionMessageContainsAssertion(Context, expectedSubstring); + } + + /// + /// Instance method for backward compatibility - delegates to extension method. + /// Asserts that the exception message contains the specified substring using the specified comparison. + /// + public ExceptionMessageContainsAssertion WithMessageContaining(string expectedSubstring, StringComparison comparison) + { + Context.ExpressionBuilder.Append($".WithMessageContaining(\"{expectedSubstring}\", StringComparison.{comparison})"); + return new ExceptionMessageContainsAssertion(Context, expectedSubstring, comparison); + } + + /// + /// Instance method for backward compatibility - delegates to extension method. + /// Asserts that the exception message does NOT contain the specified substring. + /// + public ExceptionMessageNotContainsAssertion WithMessageNotContaining(string notExpectedSubstring) + { + Context.ExpressionBuilder.Append($".WithMessageNotContaining(\"{notExpectedSubstring}\")"); + return new ExceptionMessageNotContainsAssertion(Context, notExpectedSubstring); + } + + /// + /// Instance method for backward compatibility - delegates to extension method. + /// Asserts that the exception message does NOT contain the specified substring using the specified comparison. + /// + public ExceptionMessageNotContainsAssertion WithMessageNotContaining(string notExpectedSubstring, StringComparison comparison) + { + Context.ExpressionBuilder.Append($".WithMessageNotContaining(\"{notExpectedSubstring}\", StringComparison.{comparison})"); + return new ExceptionMessageNotContainsAssertion(Context, notExpectedSubstring, comparison); + } + + /// + /// Instance method for backward compatibility - delegates to extension method. + /// Asserts that the exception message exactly equals the specified string. + /// + public ExceptionMessageEqualsAssertion WithMessage(string expectedMessage) + { + Context.ExpressionBuilder.Append($".WithMessage(\"{expectedMessage}\")"); + return new ExceptionMessageEqualsAssertion(Context, expectedMessage); + } + + /// + /// Instance method for backward compatibility - delegates to extension method. + /// Asserts that the exception message exactly equals the specified string using the specified comparison. + /// + public ExceptionMessageEqualsAssertion WithMessage(string expectedMessage, StringComparison comparison) + { + Context.ExpressionBuilder.Append($".WithMessage(\"{expectedMessage}\", StringComparison.{comparison})"); + return new ExceptionMessageEqualsAssertion(Context, expectedMessage, comparison); + } + + /// + /// Instance method for backward compatibility - delegates to extension method. + /// Asserts that the exception message matches a wildcard pattern. + /// + public ExceptionMessageMatchesPatternAssertion WithMessageMatching(string pattern) + { + Context.ExpressionBuilder.Append($".WithMessageMatching(\"{pattern}\")"); + return new ExceptionMessageMatchesPatternAssertion(Context, pattern); + } + + /// + /// Instance method for backward compatibility - delegates to extension method. + /// Asserts that the exception message matches a StringMatcher pattern. + /// + public ExceptionMessageMatchesAssertion WithMessageMatching(StringMatcher matcher) + { + Context.ExpressionBuilder.Append($".WithMessageMatching(matcher)"); + return new ExceptionMessageMatchesAssertion(Context, matcher); + } + + /// + /// Instance method for backward compatibility - delegates to extension method. + /// Asserts that an ArgumentException has the specified parameter name. + /// + public ExceptionParameterNameAssertion WithParameterName(string expectedParameterName) + { + Context.ExpressionBuilder.Append($".WithParameterName(\"{expectedParameterName}\")"); + return new ExceptionParameterNameAssertion(Context, expectedParameterName); + } } /// diff --git a/TUnit.Assertions/Core/Assertion.cs b/TUnit.Assertions/Core/Assertion.cs index 00fd6c4495..49a098ecfa 100644 --- a/TUnit.Assertions/Core/Assertion.cs +++ b/TUnit.Assertions/Core/Assertion.cs @@ -33,10 +33,25 @@ public abstract class Assertion /// private string? _becauseMessage; + /// + /// Wrapped execution for And/Or chaining. + /// When set, AssertAsync() delegates to this wrapper instead of executing directly. + /// + private Assertion? _wrappedExecution; protected Assertion(AssertionContext context) { Context = context ?? throw new ArgumentNullException(nameof(context)); + + // Auto-detect chaining from context state + var (previous, combiner) = context.ConsumePendingLink(); + if (previous != null) + { + // Create wrapper based on combiner type + _wrappedExecution = combiner == CombinerType.And + ? new Chaining.AndAssertion(previous, this) + : new Chaining.OrAssertion(previous, this); + } } /// @@ -84,9 +99,26 @@ public Assertion Because(string message) /// Main assertion execution flow. /// Evaluates the context (if not already evaluated), checks the condition, /// and throws if the assertion fails (or adds to AssertionScope if within Assert.Multiple). + /// If this assertion is part of an And/Or chain, delegates to the wrapper. /// public virtual async Task AssertAsync() { + // If part of an And/Or chain, delegate to the wrapper + if (_wrappedExecution != null) + { + return await _wrappedExecution.AssertAsync(); + } + + return await ExecuteCoreAsync(); + } + + /// + /// Executes the core assertion logic without delegation. + /// Used internally by AndAssertion/OrAssertion to avoid infinite recursion. + /// + internal async Task ExecuteCoreAsync() + { + // Normal single-assertion execution (never delegates to wrapper) var (value, exception) = await Context.GetAsync(); var (startTime, endTime) = Context.GetTiming(); diff --git a/TUnit.Assertions/Core/AssertionContext.cs b/TUnit.Assertions/Core/AssertionContext.cs index 3939398fcb..73c0e6b0a0 100644 --- a/TUnit.Assertions/Core/AssertionContext.cs +++ b/TUnit.Assertions/Core/AssertionContext.cs @@ -52,6 +52,14 @@ public AssertionContext Map(Func mapper) ); } + public AssertionContext MapException() where TException : Exception + { + return new AssertionContext( + Evaluation.MapException(), + ExpressionBuilder + ); + } + /// /// Gets the evaluated value and any exception that occurred. /// Evaluates once and caches the result for subsequent calls. @@ -69,4 +77,37 @@ public AssertionContext Map(Func mapper) { return Evaluation.GetTiming(); } + + /// + /// Pending assertion to link with when the next assertion is constructed. + /// Set by AndContinuation/OrContinuation, consumed by Assertion constructor. + /// + internal Assertion? PendingLinkPrevious { get; private set; } + + /// + /// The type of combiner (And/Or) for the pending link. + /// + internal CombinerType? PendingLinkType { get; private set; } + + /// + /// Sets the pending link state for the next assertion to consume. + /// Called by AndContinuation/OrContinuation constructors. + /// + internal void SetPendingLink(Assertion previous, CombinerType type) + { + PendingLinkPrevious = previous; + PendingLinkType = type; + } + + /// + /// Consumes and clears the pending link state. + /// Called by Assertion constructor to auto-detect chaining. + /// + internal (Assertion? previous, CombinerType? type) ConsumePendingLink() + { + var result = (PendingLinkPrevious, PendingLinkType); + PendingLinkPrevious = null; + PendingLinkType = null; + return result; + } } diff --git a/TUnit.Assertions/Core/CombinerType.cs b/TUnit.Assertions/Core/CombinerType.cs new file mode 100644 index 0000000000..27b4413c5c --- /dev/null +++ b/TUnit.Assertions/Core/CombinerType.cs @@ -0,0 +1,17 @@ +namespace TUnit.Assertions.Core; + +/// +/// Specifies how assertions should be combined in a chain. +/// +internal enum CombinerType +{ + /// + /// All assertions in the chain must pass (AND logic). + /// + And, + + /// + /// At least one assertion in the chain must pass (OR logic). + /// + Or +} diff --git a/TUnit.Assertions/Core/EvaluationContext.cs b/TUnit.Assertions/Core/EvaluationContext.cs index 6277a0e996..93beef0113 100644 --- a/TUnit.Assertions/Core/EvaluationContext.cs +++ b/TUnit.Assertions/Core/EvaluationContext.cs @@ -75,6 +75,20 @@ public EvaluationContext Map(Func mapper) }); } + public EvaluationContext MapException() where TException : Exception + { + return new EvaluationContext(async () => + { + var (value, exception) = await GetAsync(); + if (exception is TException tException) + { + return (tException, null); + } + + return (null, exception); + }); + } + /// /// Gets the timing information for this evaluation. /// Only meaningful after evaluation has occurred. diff --git a/TUnit.Assertions/Extensions/AssertionExtensions.cs b/TUnit.Assertions/Extensions/AssertionExtensions.cs index b23f8817aa..226f79d6a5 100644 --- a/TUnit.Assertions/Extensions/AssertionExtensions.cs +++ b/TUnit.Assertions/Extensions/AssertionExtensions.cs @@ -3,6 +3,7 @@ using System.Linq.Expressions; using System.Runtime.CompilerServices; using System.Text.RegularExpressions; +using TUnit.Assertions.Chaining; using TUnit.Assertions.Conditions; using TUnit.Assertions.Conditions.Wrappers; using TUnit.Assertions.Core; @@ -997,8 +998,8 @@ public static ThrowsAssertion Throws( where TException : Exception { source.Context.ExpressionBuilder.Append($".Throws<{typeof(TException).Name}>()"); - // Map the context to object? since we only care about the exception - var mappedContext = source.Context.Map(_ => null); + // Use MapException to move exception from exception field to value field + var mappedContext = source.Context.MapException(); return new ThrowsAssertion(mappedContext); } @@ -1012,7 +1013,7 @@ public static ThrowsAssertion ThrowsException( where TException : Exception { source.Context.ExpressionBuilder.Append($".ThrowsException<{typeof(TException).Name}>()"); - var mappedContext = source.Context.Map(_ => null); + var mappedContext = source.Context.MapException(); return new ThrowsAssertion(mappedContext); } @@ -1025,7 +1026,7 @@ public static ThrowsAssertion ThrowsException( this IDelegateAssertionSource source) { source.Context.ExpressionBuilder.Append(".ThrowsException()"); - var mappedContext = source.Context.Map(_ => null); + var mappedContext = source.Context.MapException(); return new ThrowsAssertion(mappedContext); } @@ -1039,8 +1040,7 @@ public static ThrowsAssertion ThrowsAsync( where TException : Exception { source.Context.ExpressionBuilder.Append($".ThrowsAsync<{typeof(TException).Name}>()"); - // Map the context to object? since we only care about the exception - var mappedContext = source.Context.Map(_ => null); + var mappedContext = source.Context.MapException(); return new ThrowsAssertion(mappedContext); } @@ -1054,8 +1054,7 @@ public static ThrowsExactlyAssertion ThrowsExactly()"); - // Map the context to object? since we only care about the exception - var mappedContext = source.Context.Map(_ => null); + var mappedContext = source.Context.MapException(); return new ThrowsExactlyAssertion(mappedContext); } @@ -1072,63 +1071,175 @@ public static ThrowsNothingAssertion ThrowsNothing( return new ThrowsNothingAssertion(source.Context); } + // ============ EXCEPTION MESSAGE/PROPERTY ASSERTIONS ============ + // These work on IAssertionSource where the exception is the value + /// /// Asserts that the exception message contains the specified substring. - /// Works on AndContinuation after Throws assertions. - /// Example: await Assert.That(() => ThrowingMethod()).Throws<Exception>().And.HasMessageContaining("error"); + /// Works after Throws assertions. + /// Example: await Assert.That(() => ThrowingMethod()).Throws<Exception>().WithMessageContaining("error"); /// - public static ExceptionMessageAssertion HasMessageContaining( - this IAssertionSource source, - string expectedSubstring) + public static ExceptionMessageContainsAssertion WithMessageContaining( + this IAssertionSource source, + string expectedSubstring, + [CallerArgumentExpression(nameof(expectedSubstring))] string? expression = null) + where TException : Exception { - source.Context.ExpressionBuilder.Append($".HasMessageContaining(\"{expectedSubstring}\")"); - // Map the context to object? for ExceptionMessageAssertion - var mappedContext = source.Context.Map(v => v); - return new ExceptionMessageAssertion(mappedContext, expectedSubstring); + source.Context.ExpressionBuilder.Append($".WithMessageContaining({expression})"); + return new ExceptionMessageContainsAssertion(source.Context, expectedSubstring); } /// /// Asserts that the exception message contains the specified substring using the specified comparison. - /// Works on AndContinuation after Throws assertions. - /// Example: await Assert.That(() => ThrowingMethod()).Throws<Exception>().And.HasMessageContaining("error", StringComparison.OrdinalIgnoreCase); + /// Works after Throws assertions. + /// Example: await Assert.That(() => ThrowingMethod()).Throws<Exception>().WithMessageContaining("error", StringComparison.OrdinalIgnoreCase); /// - public static ExceptionMessageAssertion HasMessageContaining( - this IAssertionSource source, + public static ExceptionMessageContainsAssertion WithMessageContaining( + this IAssertionSource source, string expectedSubstring, - StringComparison comparison) + StringComparison comparison, + [CallerArgumentExpression(nameof(expectedSubstring))] string? expression = null) + where TException : Exception { - source.Context.ExpressionBuilder.Append($".HasMessageContaining(\"{expectedSubstring}\", StringComparison.{comparison})"); - // Map the context to object? for ExceptionMessageAssertion - var mappedContext = source.Context.Map(v => v); - return new ExceptionMessageAssertion(mappedContext, expectedSubstring, comparison); + source.Context.ExpressionBuilder.Append($".WithMessageContaining({expression}, StringComparison.{comparison})"); + return new ExceptionMessageContainsAssertion(source.Context, expectedSubstring, comparison); } - /// - /// Asserts that the exception message contains the specified substring. - /// Alias for HasMessageContaining. - /// Example: await Assert.That(() => ThrowingMethod()).Throws<Exception>().And.WithMessageContaining("error"); + /// Alias for WithMessageContaining - asserts that the exception message contains the specified substring. + /// Works after Throws assertions. + /// Example: await Assert.That(() => ThrowingMethod()).Throws<Exception>().HasMessageContaining("error"); /// - public static ExceptionMessageAssertion WithMessageContaining( - this IAssertionSource source, - string expectedSubstring) + public static ExceptionMessageContainsAssertion HasMessageContaining( + this IAssertionSource source, + string expectedSubstring, + [CallerArgumentExpression(nameof(expectedSubstring))] string? expression = null) + where TException : Exception { - source.Context.ExpressionBuilder.Append($".WithMessageContaining(\"{expectedSubstring}\")"); - return new ExceptionMessageAssertion(source.Context, expectedSubstring); + return source.WithMessageContaining(expectedSubstring, expression); } /// - /// Asserts that the exception message contains the specified substring using the specified comparison. - /// Alias for HasMessageContaining. - /// Example: await Assert.That(() => ThrowingMethod()).Throws<Exception>().And.WithMessageContaining("error", StringComparison.OrdinalIgnoreCase); + /// Alias for WithMessageContaining - asserts that the exception message contains the specified substring using the specified comparison. + /// Works after Throws assertions. + /// Example: await Assert.That(() => ThrowingMethod()).Throws<Exception>().HasMessageContaining("error", StringComparison.OrdinalIgnoreCase); /// - public static ExceptionMessageAssertion WithMessageContaining( - this IAssertionSource source, + public static ExceptionMessageContainsAssertion HasMessageContaining( + this IAssertionSource source, string expectedSubstring, - StringComparison comparison) + StringComparison comparison, + [CallerArgumentExpression(nameof(expectedSubstring))] string? expression = null) + where TException : Exception + { + return source.WithMessageContaining(expectedSubstring, comparison, expression); + } + + /// + /// Asserts that the exception message exactly equals the specified string. + /// Works after Throws assertions. + /// Example: await Assert.That(() => ThrowingMethod()).Throws<Exception>().WithMessage("exact message"); + /// + public static ExceptionMessageEqualsAssertion WithMessage( + this IAssertionSource source, + string expectedMessage, + [CallerArgumentExpression(nameof(expectedMessage))] string? expression = null) + where TException : Exception + { + source.Context.ExpressionBuilder.Append($".WithMessage({expression})"); + return new ExceptionMessageEqualsAssertion(source.Context, expectedMessage); + } + + /// + /// Asserts that the exception message exactly equals the specified string using the specified comparison. + /// Works after Throws assertions. + /// Example: await Assert.That(() => ThrowingMethod()).Throws<Exception>().WithMessage("exact message", StringComparison.OrdinalIgnoreCase); + /// + public static ExceptionMessageEqualsAssertion WithMessage( + this IAssertionSource source, + string expectedMessage, + StringComparison comparison, + [CallerArgumentExpression(nameof(expectedMessage))] string? expression = null) + where TException : Exception + { + source.Context.ExpressionBuilder.Append($".WithMessage({expression}, StringComparison.{comparison})"); + return new ExceptionMessageEqualsAssertion(source.Context, expectedMessage, comparison); + } + + /// + /// Asserts that the exception message does NOT contain the specified substring. + /// Works after Throws assertions. + /// Example: await Assert.That(() => ThrowingMethod()).Throws<Exception>().WithMessageNotContaining("should not appear"); + /// + public static ExceptionMessageNotContainsAssertion WithMessageNotContaining( + this IAssertionSource source, + string notExpectedSubstring, + [CallerArgumentExpression(nameof(notExpectedSubstring))] string? expression = null) + where TException : Exception + { + source.Context.ExpressionBuilder.Append($".WithMessageNotContaining({expression})"); + return new ExceptionMessageNotContainsAssertion(source.Context, notExpectedSubstring); + } + + /// + /// Asserts that the exception message does NOT contain the specified substring using the specified comparison. + /// Works after Throws assertions. + /// Example: await Assert.That(() => ThrowingMethod()).Throws<Exception>().WithMessageNotContaining("should not appear", StringComparison.OrdinalIgnoreCase); + /// + public static ExceptionMessageNotContainsAssertion WithMessageNotContaining( + this IAssertionSource source, + string notExpectedSubstring, + StringComparison comparison, + [CallerArgumentExpression(nameof(notExpectedSubstring))] string? expression = null) + where TException : Exception + { + source.Context.ExpressionBuilder.Append($".WithMessageNotContaining({expression}, StringComparison.{comparison})"); + return new ExceptionMessageNotContainsAssertion(source.Context, notExpectedSubstring, comparison); + } + + /// + /// Asserts that the exception message matches a wildcard pattern. + /// * matches any number of characters, ? matches a single character. + /// Works after Throws assertions. + /// Example: await Assert.That(() => ThrowingMethod()).Throws<Exception>().WithMessageMatching("Error: *"); + /// + public static ExceptionMessageMatchesPatternAssertion WithMessageMatching( + this IAssertionSource source, + string pattern, + [CallerArgumentExpression(nameof(pattern))] string? expression = null) + where TException : Exception + { + source.Context.ExpressionBuilder.Append($".WithMessageMatching({expression})"); + return new ExceptionMessageMatchesPatternAssertion(source.Context, pattern); + } + + /// + /// Asserts that the exception message matches a StringMatcher pattern. + /// Works after Throws assertions. + /// Example: await Assert.That(() => ThrowingMethod()).Throws<Exception>().WithMessageMatching(StringMatcher.Regex("Error.*")); + /// + public static ExceptionMessageMatchesAssertion WithMessageMatching( + this IAssertionSource source, + StringMatcher matcher, + [CallerArgumentExpression(nameof(matcher))] string? expression = null) + where TException : Exception + { + source.Context.ExpressionBuilder.Append($".WithMessageMatching({expression})"); + return new ExceptionMessageMatchesAssertion(source.Context, matcher); + } + + /// + /// Asserts that an ArgumentException has the specified parameter name. + /// Works after Throws assertions. + /// Example: await Assert.That(() => ThrowingMethod()).Throws<ArgumentException>().WithParameterName("paramName"); + /// + public static ExceptionParameterNameAssertion WithParameterName( + this IAssertionSource source, + string expectedParameterName, + [CallerArgumentExpression(nameof(expectedParameterName))] string? expression = null) + where TException : Exception { - source.Context.ExpressionBuilder.Append($".WithMessageContaining(\"{expectedSubstring}\", StringComparison.{comparison})"); - return new ExceptionMessageAssertion(source.Context, expectedSubstring, comparison); + source.Context.ExpressionBuilder.Append($".WithParameterName({expression})"); + return new ExceptionParameterNameAssertion(source.Context, expectedParameterName); } // Specific overloads for delegate types where TValue is always object? @@ -1136,7 +1247,7 @@ public static ThrowsAssertion Throws(this DelegateAssert { var iface = (IAssertionSource)source; iface.Context.ExpressionBuilder.Append($".Throws<{typeof(TException).Name}>()"); - var mappedContext = iface.Context.Map(_ => null); + var mappedContext = iface.Context.MapException(); return new ThrowsAssertion(mappedContext); } @@ -1144,7 +1255,7 @@ public static ThrowsExactlyAssertion ThrowsExactly(this { var iface = (IAssertionSource)source; iface.Context.ExpressionBuilder.Append($".ThrowsExactly<{typeof(TException).Name}>()"); - var mappedContext = iface.Context.Map(_ => null); + var mappedContext = iface.Context.MapException(); return new ThrowsExactlyAssertion(mappedContext); } @@ -1152,7 +1263,7 @@ public static ThrowsAssertion Throws(this AsyncDelegateA { var iface = (IAssertionSource)source; iface.Context.ExpressionBuilder.Append($".Throws<{typeof(TException).Name}>()"); - var mappedContext = iface.Context.Map(_ => null); + var mappedContext = iface.Context.MapException(); return new ThrowsAssertion(mappedContext); } @@ -1160,7 +1271,7 @@ public static ThrowsExactlyAssertion ThrowsExactly(this { var iface = (IAssertionSource)source; iface.Context.ExpressionBuilder.Append($".ThrowsExactly<{typeof(TException).Name}>()"); - var mappedContext = iface.Context.Map(_ => null); + var mappedContext = iface.Context.MapException(); return new ThrowsExactlyAssertion(mappedContext); } diff --git a/TUnit.Assertions/Sources/AsyncFuncAssertion.cs b/TUnit.Assertions/Sources/AsyncFuncAssertion.cs index 0212b3d6dd..23b1353176 100644 --- a/TUnit.Assertions/Sources/AsyncFuncAssertion.cs +++ b/TUnit.Assertions/Sources/AsyncFuncAssertion.cs @@ -41,7 +41,7 @@ public AsyncFuncAssertion(Func> func, string? expression) public ThrowsAssertion Throws() where TException : Exception { Context.ExpressionBuilder.Append($".Throws<{typeof(TException).Name}>()"); - var mappedContext = Context.Map(_ => null); + var mappedContext = Context.MapException(); return new ThrowsAssertion(mappedContext); } @@ -53,7 +53,7 @@ public ThrowsAssertion Throws() where TException : Excep public ThrowsExactlyAssertion ThrowsExactly() where TException : Exception { Context.ExpressionBuilder.Append($".ThrowsExactly<{typeof(TException).Name}>()"); - var mappedContext = Context.Map(_ => null); + var mappedContext = Context.MapException(); return new ThrowsExactlyAssertion(mappedContext); } } diff --git a/TUnit.Assertions/Sources/FuncAssertion.cs b/TUnit.Assertions/Sources/FuncAssertion.cs index 317c5e3eb3..c62bbf9df4 100644 --- a/TUnit.Assertions/Sources/FuncAssertion.cs +++ b/TUnit.Assertions/Sources/FuncAssertion.cs @@ -42,8 +42,8 @@ public FuncAssertion(Func func, string? expression) public ThrowsAssertion Throws() where TException : Exception { Context.ExpressionBuilder.Append($".Throws<{typeof(TException).Name}>()"); - var mappedContext = Context.Map(_ => null); - return new ThrowsAssertion(mappedContext); + var mappedContext = Context.MapException(); + return new ThrowsAssertion(mappedContext!); } /// @@ -54,7 +54,7 @@ public ThrowsAssertion Throws() where TException : Excep public ThrowsExactlyAssertion ThrowsExactly() where TException : Exception { Context.ExpressionBuilder.Append($".ThrowsExactly<{typeof(TException).Name}>()"); - var mappedContext = Context.Map(_ => null); - return new ThrowsExactlyAssertion(mappedContext); + var mappedContext = Context.MapException(); + return new ThrowsExactlyAssertion(mappedContext!); } } diff --git a/TUnit.Assertions/Sources/TaskAssertion.cs b/TUnit.Assertions/Sources/TaskAssertion.cs index 57cab086e2..2249b5309f 100644 --- a/TUnit.Assertions/Sources/TaskAssertion.cs +++ b/TUnit.Assertions/Sources/TaskAssertion.cs @@ -60,7 +60,7 @@ public TaskAssertion(Task task, string? expression) public ThrowsAssertion Throws() where TException : Exception { Context.ExpressionBuilder.Append($".Throws<{typeof(TException).Name}>()"); - var mappedContext = Context.Map(_ => null); + var mappedContext = Context.MapException(); return new ThrowsAssertion(mappedContext); } @@ -72,7 +72,7 @@ public ThrowsAssertion Throws() where TException : Excep public ThrowsExactlyAssertion ThrowsExactly() where TException : Exception { Context.ExpressionBuilder.Append($".ThrowsExactly<{typeof(TException).Name}>()"); - var mappedContext = Context.Map(_ => null); + var mappedContext = Context.MapException(); return new ThrowsExactlyAssertion(mappedContext); } From 1b6dd66e2f02d3f052be4362476d617dd018707c Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Tue, 14 Oct 2025 14:07:44 +0100 Subject: [PATCH 5/5] feat: Add support for And/Or assertion chaining with evaluation logic and error messaging --- .../AssertionBuilders/AndAssertionTests.cs | 276 +++++++++++++ .../AssertionBuilders/OrAssertionTests.cs | 368 ++++++++++++++++++ .../Delegates/Throws.ExactlyTests.cs | 2 +- .../Delegates/Throws.OfTypeTests.cs | 4 +- .../Throws.WithInnerExceptionTests.cs | 6 +- .../Throws.WithMessageMatchingTests.cs | 10 +- .../Delegates/Throws.WithMessageTests.cs | 6 +- .../Throws.WithParameterNameTests.cs | 6 +- TUnit.Assertions/Core/Assertion.cs | 32 +- ...Has_No_API_Changes.DotNet10_0.verified.txt | 106 ++++- ..._Has_No_API_Changes.DotNet8_0.verified.txt | 106 ++++- ..._Has_No_API_Changes.DotNet9_0.verified.txt | 106 ++++- ...ary_Has_No_API_Changes.Net4_7.verified.txt | 106 ++++- 13 files changed, 1035 insertions(+), 99 deletions(-) diff --git a/TUnit.Assertions.Tests/AssertionBuilders/AndAssertionTests.cs b/TUnit.Assertions.Tests/AssertionBuilders/AndAssertionTests.cs index a6f015f530..8a45bdc931 100644 --- a/TUnit.Assertions.Tests/AssertionBuilders/AndAssertionTests.cs +++ b/TUnit.Assertions.Tests/AssertionBuilders/AndAssertionTests.cs @@ -31,4 +31,280 @@ public async Task Does_Not_Throw_For_Multiple_And() await Assert.That(action).ThrowsNothing(); } + + [Test] + public async Task Both_Assertions_Pass() + { + var value = 5; + + await Assert.That(value) + .IsGreaterThan(3) + .And + .IsLessThan(10); + } + + [Test] + public async Task Both_Assertions_Are_Evaluated_When_First_Passes() + { + var firstEvaluated = false; + var secondEvaluated = false; + + var value = 5; + + await Assert.That(value) + .Satisfies(_ => + { + firstEvaluated = true; + return _ > 3; + }) + .And + .Satisfies(_ => + { + secondEvaluated = true; + return _ < 10; + }); + + await Assert.That(firstEvaluated).IsTrue(); + await Assert.That(secondEvaluated).IsTrue(); + } + + [Test] + public async Task Second_Assertion_Not_Evaluated_When_First_Fails() + { + var firstEvaluated = false; + var secondEvaluated = false; + + var value = 5; + + var action = async () => await Assert.That(value) + .Satisfies(_ => + { + firstEvaluated = true; + return _ > 10; // This will fail + }) + .And + .Satisfies(_ => + { + secondEvaluated = true; + return _ < 20; + }); + + await Assert.That(action).Throws(); + await Assert.That(firstEvaluated).IsTrue(); + await Assert.That(secondEvaluated).IsFalse(); + } + + [Test] + public async Task Error_Message_Shows_Combined_Expectations_When_Second_Fails() + { + var value = 5; + + var action = async () => await Assert.That(value) + .IsGreaterThan(3) + .And + .IsLessThan(4); + + var exception = await Assert.That(action).Throws(); + + await Assert.That(exception.Message) + .Contains("to be greater than 3") + .And + .Contains("to be less than 4"); + } + + [Test] + public async Task Error_Message_Shows_First_Expectation_When_First_Fails() + { + var value = 5; + + var action = async () => await Assert.That(value) + .IsGreaterThan(10) + .And + .IsLessThan(20); + + var exception = await Assert.That(action).Throws(); + + await Assert.That(exception.Message) + .Contains("to be greater than 10"); + } + + [Test] + public async Task Three_Way_And_Chain_All_Pass() + { + var value = 5; + + await Assert.That(value) + .IsGreaterThan(3) + .And + .IsLessThan(10) + .And + .IsEqualTo(5); + } + + [Test] + public async Task Three_Way_And_Chain_Middle_Fails() + { + var firstEvaluated = false; + var secondEvaluated = false; + var thirdEvaluated = false; + + var value = 5; + + var action = async () => await Assert.That(value) + .Satisfies(_ => + { + firstEvaluated = true; + return _ > 3; // Passes + }) + .And + .Satisfies(_ => + { + secondEvaluated = true; + return _ > 10; // Fails + }) + .And + .Satisfies(_ => + { + thirdEvaluated = true; + return _ < 20; + }); + + await Assert.That(action).Throws(); + await Assert.That(firstEvaluated).IsTrue(); + await Assert.That(secondEvaluated).IsTrue(); + await Assert.That(thirdEvaluated).IsFalse(); + } + + [Test] + public async Task Three_Way_And_Chain_Last_Fails() + { + var firstEvaluated = false; + var secondEvaluated = false; + var thirdEvaluated = false; + + var value = 5; + + var action = async () => await Assert.That(value) + .Satisfies(_ => + { + firstEvaluated = true; + return _ > 3; // Passes + }) + .And + .Satisfies(_ => + { + secondEvaluated = true; + return _ < 10; // Passes + }) + .And + .Satisfies(_ => + { + thirdEvaluated = true; + return _ > 20; // Fails + }); + + await Assert.That(action).Throws(); + await Assert.That(firstEvaluated).IsTrue(); + await Assert.That(secondEvaluated).IsTrue(); + await Assert.That(thirdEvaluated).IsTrue(); + } + + [Test] + public async Task Within_Assert_Multiple_Both_Assertions_Evaluated() + { + var firstEvaluated = false; + var secondEvaluated = false; + + var action = async () => + { + using (Assert.Multiple()) + { + await Assert.That(5) + .Satisfies(_ => + { + firstEvaluated = true; + return _ > 10; // Fails + }) + .And + .Satisfies(_ => + { + secondEvaluated = true; + return _ < 20; + }); + } + }; + + await Assert.That(action).Throws(); + await Assert.That(firstEvaluated).IsTrue(); + await Assert.That(secondEvaluated).IsFalse(); + } + + [Test] + public async Task Within_Assert_Multiple_Combined_Error_Message_When_Second_Fails() + { + var action = async () => + { + using (Assert.Multiple()) + { + await Assert.That(5) + .IsGreaterThan(3) + .And + .IsLessThan(4); + } + }; + + var exception = await Assert.That(action).Throws(); + + await Assert.That(exception.Message) + .Contains("to be greater than 3") + .And + .Contains("to be less than 4"); + } + + [Test] + public async Task Evaluation_Order_Verified_With_Counter() + { + var evaluationOrder = new List(); + + var value = 5; + + await Assert.That(value) + .Satisfies(_ => + { + evaluationOrder.Add(1); + return _ > 3; + }) + .And + .Satisfies(_ => + { + evaluationOrder.Add(2); + return _ < 10; + }); + + await Assert.That(evaluationOrder).HasCount().EqualTo(2); + await Assert.That(evaluationOrder[0]).IsEqualTo(1); + await Assert.That(evaluationOrder[1]).IsEqualTo(2); + } + + [Test] + public async Task And_With_Collection_Assertions() + { + var collection = new[] { 1, 2, 3, 4, 5 }; + + await Assert.That(collection) + .Contains(3) + .And + .HasCount().EqualTo(5); + } + + [Test] + public async Task And_With_String_Assertions() + { + var text = "Hello World"; + + await Assert.That(text) + .Contains("Hello") + .And + .HasLength(11); + } } diff --git a/TUnit.Assertions.Tests/AssertionBuilders/OrAssertionTests.cs b/TUnit.Assertions.Tests/AssertionBuilders/OrAssertionTests.cs index 1f347bf420..430d870860 100644 --- a/TUnit.Assertions.Tests/AssertionBuilders/OrAssertionTests.cs +++ b/TUnit.Assertions.Tests/AssertionBuilders/OrAssertionTests.cs @@ -32,6 +32,374 @@ public async Task Does_Not_Throw_For_Multiple_Or() await Assert.That(action).ThrowsNothing(); } + [Test] + public async Task First_Assertion_Passes() + { + var value = 5; + + await Assert.That(value) + .IsGreaterThan(3) + .Or + .IsLessThan(1); + } + + [Test] + public async Task Second_Assertion_Passes_When_First_Fails() + { + var value = 5; + + await Assert.That(value) + .IsLessThan(3) + .Or + .IsGreaterThan(4); + } + + [Test] + public async Task Second_Assertion_Not_Evaluated_When_First_Passes() + { + var firstEvaluated = false; + var secondEvaluated = false; + + var value = 5; + + await Assert.That(value) + .Satisfies(_ => + { + firstEvaluated = true; + return _ > 3; // This will pass + }) + .Or + .Satisfies(_ => + { + secondEvaluated = true; + return _ < 1; + }); + + await Assert.That(firstEvaluated).IsTrue(); + await Assert.That(secondEvaluated).IsFalse(); + } + + [Test] + public async Task Second_Assertion_Is_Evaluated_When_First_Fails() + { + var firstEvaluated = false; + var secondEvaluated = false; + + var value = 5; + + await Assert.That(value) + .Satisfies(_ => + { + firstEvaluated = true; + return _ > 10; // This will fail + }) + .Or + .Satisfies(_ => + { + secondEvaluated = true; + return _ < 10; // This will pass + }); + + await Assert.That(firstEvaluated).IsTrue(); + await Assert.That(secondEvaluated).IsTrue(); + } + + [Test] + public async Task Error_Message_Shows_Combined_Expectations_When_Both_Fail() + { + var value = 5; + + var action = async () => await Assert.That(value) + .IsGreaterThan(10) + .Or + .IsLessThan(3); + + var exception = await Assert.That(action).Throws(); + + await Assert.That(exception.Message) + .Contains("to be greater than 10") + .And + .Contains("to be less than 3"); + } + + [Test] + public async Task Three_Way_Or_Chain_First_Passes() + { + var firstEvaluated = false; + var secondEvaluated = false; + var thirdEvaluated = false; + + var value = 5; + + await Assert.That(value) + .Satisfies(_ => + { + firstEvaluated = true; + return _ > 3; // Passes + }) + .Or + .Satisfies(_ => + { + secondEvaluated = true; + return _ < 1; + }) + .Or + .Satisfies(_ => + { + thirdEvaluated = true; + return _ == 100; + }); + + await Assert.That(firstEvaluated).IsTrue(); + await Assert.That(secondEvaluated).IsFalse(); + await Assert.That(thirdEvaluated).IsFalse(); + } + + [Test] + public async Task Three_Way_Or_Chain_Second_Passes() + { + var firstEvaluated = false; + var secondEvaluated = false; + var thirdEvaluated = false; + + var value = 5; + + await Assert.That(value) + .Satisfies(_ => + { + firstEvaluated = true; + return _ > 10; // Fails + }) + .Or + .Satisfies(_ => + { + secondEvaluated = true; + return _ < 10; // Passes + }) + .Or + .Satisfies(_ => + { + thirdEvaluated = true; + return _ == 100; + }); + + await Assert.That(firstEvaluated).IsTrue(); + await Assert.That(secondEvaluated).IsTrue(); + await Assert.That(thirdEvaluated).IsFalse(); + } + + [Test] + public async Task Three_Way_Or_Chain_Third_Passes() + { + var firstEvaluated = false; + var secondEvaluated = false; + var thirdEvaluated = false; + + var value = 5; + + await Assert.That(value) + .Satisfies(_ => + { + firstEvaluated = true; + return _ > 10; // Fails + }) + .Or + .Satisfies(_ => + { + secondEvaluated = true; + return _ < 1; // Fails + }) + .Or + .Satisfies(_ => + { + thirdEvaluated = true; + return _ == 5; // Passes + }); + + await Assert.That(firstEvaluated).IsTrue(); + await Assert.That(secondEvaluated).IsTrue(); + await Assert.That(thirdEvaluated).IsTrue(); + } + + [Test] + public async Task Three_Way_Or_Chain_All_Fail() + { + var firstEvaluated = false; + var secondEvaluated = false; + var thirdEvaluated = false; + + var value = 5; + + var action = async () => await Assert.That(value) + .Satisfies(_ => + { + firstEvaluated = true; + return _ > 10; // Fails + }) + .Or + .Satisfies(_ => + { + secondEvaluated = true; + return _ < 1; // Fails + }) + .Or + .Satisfies(_ => + { + thirdEvaluated = true; + return _ == 100; // Fails + }); + + await Assert.That(action).Throws(); + await Assert.That(firstEvaluated).IsTrue(); + await Assert.That(secondEvaluated).IsTrue(); + await Assert.That(thirdEvaluated).IsTrue(); + } + + [Test] + public async Task Within_Assert_Multiple_Second_Evaluated_When_First_Fails() + { + var firstEvaluated = false; + var secondEvaluated = false; + + using (Assert.Multiple()) + { + await Assert.That(5) + .Satisfies(_ => + { + firstEvaluated = true; + return _ > 10; // Fails + }) + .Or + .Satisfies(_ => + { + secondEvaluated = true; + return _ < 10; // Passes + }); + } + + await Assert.That(firstEvaluated).IsTrue(); + await Assert.That(secondEvaluated).IsTrue(); + } + + [Test] + public async Task Within_Assert_Multiple_Second_Not_Evaluated_When_First_Passes() + { + var firstEvaluated = false; + var secondEvaluated = false; + + using (Assert.Multiple()) + { + await Assert.That(5) + .Satisfies(_ => + { + firstEvaluated = true; + return _ > 3; // Passes + }) + .Or + .Satisfies(_ => + { + secondEvaluated = true; + return _ < 1; + }); + } + + await Assert.That(firstEvaluated).IsTrue(); + await Assert.That(secondEvaluated).IsFalse(); + } + + [Test] + public async Task Within_Assert_Multiple_Combined_Error_Message_When_Both_Fail() + { + var action = async () => + { + using (Assert.Multiple()) + { + await Assert.That(5) + .IsGreaterThan(10) + .Or + .IsLessThan(3); + } + }; + + var exception = await Assert.That(action).Throws(); + + await Assert.That(exception.Message) + .Contains("to be greater than 10") + .And + .Contains("to be less than 3"); + } + + [Test] + public async Task Evaluation_Order_Verified_With_Counter() + { + var evaluationOrder = new List(); + + var value = 5; + + await Assert.That(value) + .Satisfies(_ => + { + evaluationOrder.Add(1); + return _ > 10; // Fails + }) + .Or + .Satisfies(_ => + { + evaluationOrder.Add(2); + return _ < 10; // Passes + }); + + await Assert.That(evaluationOrder).HasCount().EqualTo(2); + await Assert.That(evaluationOrder[0]).IsEqualTo(1); + await Assert.That(evaluationOrder[1]).IsEqualTo(2); + } + + [Test] + public async Task Short_Circuits_Verified_With_Counter() + { + var evaluationOrder = new List(); + + var value = 5; + + await Assert.That(value) + .Satisfies(_ => + { + evaluationOrder.Add(1); + return _ > 3; // Passes + }) + .Or + .Satisfies(_ => + { + evaluationOrder.Add(2); + return _ < 10; + }); + + await Assert.That(evaluationOrder).HasCount().EqualTo(1); + await Assert.That(evaluationOrder[0]).IsEqualTo(1); + } + + [Test] + public async Task Or_With_Collection_Assertions() + { + var collection = new[] { 1, 2, 3 }; + + await Assert.That(collection) + .Contains(5) + .Or + .HasCount().EqualTo(3); + } + + [Test] + public async Task Or_With_String_Assertions() + { + var text = "Hello World"; + + await Assert.That(text) + .Contains("Goodbye") + .Or + .HasLength(11); + } + // [Test] // [Skip("Extension method resolution issues with Polyfill package")] // public async Task Short_Circuits_When_First_Assertion_Succeeds() diff --git a/TUnit.Assertions.Tests/Assertions/Delegates/Throws.ExactlyTests.cs b/TUnit.Assertions.Tests/Assertions/Delegates/Throws.ExactlyTests.cs index ec990162dc..82fbb1f190 100644 --- a/TUnit.Assertions.Tests/Assertions/Delegates/Throws.ExactlyTests.cs +++ b/TUnit.Assertions.Tests/Assertions/Delegates/Throws.ExactlyTests.cs @@ -10,7 +10,7 @@ public async Task Fails_For_Code_With_Other_Exceptions() { var expectedMessage = """ Expected to throw exactly CustomException - but wrong exception type: OtherException instead of exactly CustomException + but threw TUnit.Assertions.Tests.Assertions.Delegates.Throws+OtherException at Assert.That(action).ThrowsExactly() """; diff --git a/TUnit.Assertions.Tests/Assertions/Delegates/Throws.OfTypeTests.cs b/TUnit.Assertions.Tests/Assertions/Delegates/Throws.OfTypeTests.cs index d80855876b..a900ceaff2 100644 --- a/TUnit.Assertions.Tests/Assertions/Delegates/Throws.OfTypeTests.cs +++ b/TUnit.Assertions.Tests/Assertions/Delegates/Throws.OfTypeTests.cs @@ -10,7 +10,7 @@ public async Task Fails_For_Code_With_Other_Exceptions() { var expectedMessage = """ Expected to throw CustomException - but wrong exception type: OtherException instead of CustomException + but threw TUnit.Assertions.Tests.Assertions.Delegates.Throws+OtherException at Assert.That(action).Throws() """; @@ -29,7 +29,7 @@ public async Task Fails_For_Code_With_Supertype_Exceptions() { var expectedMessage = """ Expected to throw SubCustomException - but wrong exception type: CustomException instead of SubCustomException + but threw TUnit.Assertions.Tests.Assertions.Delegates.Throws+CustomException at Assert.That(action).Throws() """; diff --git a/TUnit.Assertions.Tests/Assertions/Delegates/Throws.WithInnerExceptionTests.cs b/TUnit.Assertions.Tests/Assertions/Delegates/Throws.WithInnerExceptionTests.cs index fcd6ed9c2c..88386844cd 100644 --- a/TUnit.Assertions.Tests/Assertions/Delegates/Throws.WithInnerExceptionTests.cs +++ b/TUnit.Assertions.Tests/Assertions/Delegates/Throws.WithInnerExceptionTests.cs @@ -11,10 +11,10 @@ public async Task Fails_For_Different_Messages_In_Inner_Exception() var outerMessage = "foo"; var expectedInnerMessage = "bar"; var expectedMessage = """ - Expected to throw Exception with message "bar" - but exception message "some different inner message" does not equal "bar" + Expected exception message to equal "bar" + but exception message was "some different inner message" - at Assert.That(action).ThrowsException().WithInnerException().WithMessage(expectedInnerMessage) + at Assert.That(action).ThrowsException().WithInnerException().WithMessage("bar") """; Exception exception = CreateCustomException(outerMessage, CreateCustomException("some different inner message")); diff --git a/TUnit.Assertions.Tests/Assertions/Delegates/Throws.WithMessageMatchingTests.cs b/TUnit.Assertions.Tests/Assertions/Delegates/Throws.WithMessageMatchingTests.cs index 935b83e29a..8401907110 100644 --- a/TUnit.Assertions.Tests/Assertions/Delegates/Throws.WithMessageMatchingTests.cs +++ b/TUnit.Assertions.Tests/Assertions/Delegates/Throws.WithMessageMatchingTests.cs @@ -38,10 +38,10 @@ public async Task Fails_For_Different_Messages() var message1 = "foo"; var message2 = "bar"; var expectedMessage = """ - Expected to throw exactly CustomException + Expected exception message to match pattern "bar" but exception message "foo" does not match pattern "bar" - at Assert.That(action).ThrowsExactly().WithMessageMatching(message2) + at Assert.That(action).ThrowsExactly().WithMessageMatching("bar") """; Exception exception = CreateCustomException(message1); Action action = () => throw exception; @@ -89,7 +89,7 @@ public async Task Succeeds_For_Matching_Message() public async Task Supports_Case_Insensitive_Wildcard_Pattern( string message, string pattern, bool expectMatch) { - var expectedExpression = "*Assert.That(action).ThrowsException().WithMessageMatching(StringMatcher.AsWildcard(pattern).Ignor*"; + var expectedExpression = "*Assert.That(action).ThrowsException().WithMessageMatching(*"; Exception exception = CreateCustomException(message); Action action = () => throw exception; @@ -118,7 +118,7 @@ public async Task Supports_Case_Insensitive_Wildcard_Pattern( public async Task Supports_Regex_Pattern( string message, string pattern, bool expectMatch) { - var expectedExpression = "*Assert.That(action).ThrowsException().WithMessageMatching(StringMatcher.AsRegex(pattern))*"; + var expectedExpression = "*Assert.That(action).ThrowsException().WithMessageMatching(*"; Exception exception = CreateCustomException(message); Action action = () => throw exception; @@ -142,7 +142,7 @@ public async Task Supports_Regex_Pattern( public async Task Supports_Case_Insensitive_Regex_Pattern( string message, string pattern, bool expectMatch) { - var expectedExpression = "*Assert.That(action).ThrowsException().WithMessageMatching(StringMatcher.AsRegex(pattern).Ignoring*"; + var expectedExpression = "*Assert.That(action).ThrowsException().WithMessageMatching(*"; Exception exception = CreateCustomException(message); Action action = () => throw exception; diff --git a/TUnit.Assertions.Tests/Assertions/Delegates/Throws.WithMessageTests.cs b/TUnit.Assertions.Tests/Assertions/Delegates/Throws.WithMessageTests.cs index 48701bf114..d444dc3372 100644 --- a/TUnit.Assertions.Tests/Assertions/Delegates/Throws.WithMessageTests.cs +++ b/TUnit.Assertions.Tests/Assertions/Delegates/Throws.WithMessageTests.cs @@ -11,10 +11,10 @@ public async Task Fails_For_Different_Messages() var message1 = "foo"; var message2 = "bar"; var expectedMessage = """ - Expected to throw exactly CustomException with message "bar" - but exception message "foo" does not equal "bar" + Expected exception message to equal "bar" + but exception message was "foo" - at Assert.That(action).ThrowsExactly().WithMessage(message2) + at Assert.That(action).ThrowsExactly().WithMessage("bar") """; Exception exception = CreateCustomException(message1); Action action = () => throw exception; diff --git a/TUnit.Assertions.Tests/Assertions/Delegates/Throws.WithParameterNameTests.cs b/TUnit.Assertions.Tests/Assertions/Delegates/Throws.WithParameterNameTests.cs index e535cffa2f..0256927de9 100644 --- a/TUnit.Assertions.Tests/Assertions/Delegates/Throws.WithParameterNameTests.cs +++ b/TUnit.Assertions.Tests/Assertions/Delegates/Throws.WithParameterNameTests.cs @@ -11,10 +11,10 @@ public async Task Fails_For_Different_Parameter_Name() var paramName1 = "foo"; var paramName2 = "bar"; var expectedMessage = """ - Expected to throw exactly ArgumentException - but ArgumentException parameter name "foo" does not equal "bar" + Expected ArgumentException to have parameter name "bar" + but ArgumentException parameter name was "foo" - at Assert.That(action).ThrowsExactly().WithParameterName(paramName2) + at Assert.That(action).ThrowsExactly().WithParameterName("bar") """; ArgumentException exception = new(string.Empty, paramName1); Action action = () => throw exception; diff --git a/TUnit.Assertions/Core/Assertion.cs b/TUnit.Assertions/Core/Assertion.cs index 49a098ecfa..93b515421d 100644 --- a/TUnit.Assertions/Core/Assertion.cs +++ b/TUnit.Assertions/Core/Assertion.cs @@ -118,6 +118,12 @@ public Assertion Because(string message) /// internal async Task ExecuteCoreAsync() { + // If this is an And/OrAssertion (composite), delegate to AssertAsync which has custom logic + if (this is Chaining.AndAssertion or Chaining.OrAssertion) + { + return await AssertAsync(); + } + // Normal single-assertion execution (never delegates to wrapper) var (value, exception) = await Context.GetAsync(); var (startTime, endTime) = Context.GetTiming(); @@ -155,13 +161,35 @@ public Assertion Because(string message) /// Creates an And continuation for chaining additional assertions. /// All assertions in an And chain must pass. /// - public AndContinuation And => new(Context, this); + public AndContinuation And + { + get + { + // Check if we're chaining And after Or (mixing combiners) + if (_wrappedExecution is Chaining.OrAssertion) + { + throw new Exceptions.MixedAndOrAssertionsException(); + } + return new(Context, _wrappedExecution ?? this); + } + } /// /// Creates an Or continuation for chaining alternative assertions. /// At least one assertion in an Or chain must pass. /// - public OrContinuation Or => new(Context, this); + public OrContinuation Or + { + get + { + // Check if we're chaining Or after And (mixing combiners) + if (_wrappedExecution is Chaining.AndAssertion) + { + throw new Exceptions.MixedAndOrAssertionsException(); + } + return new(Context, _wrappedExecution ?? this); + } + } /// /// Creates an AssertionException with a formatted error message. diff --git a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet10_0.verified.txt b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet10_0.verified.txt index 2a84742b6c..3318547dcf 100644 --- a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet10_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet10_0.verified.txt @@ -277,21 +277,11 @@ namespace .Conditions where TException : where TSelf : . { - protected BaseThrowsAssertion(. context, bool allowSubclasses) { } + protected BaseThrowsAssertion(. context, bool allowSubclasses) { } protected abstract bool IsExactTypeMatch { get; } protected override sealed .<.> CheckAsync(. metadata) { } protected abstract bool CheckExceptionType( actualException, out string? errorMessage); protected override string GetExpectation() { } - public TSelf HasMessageContaining(string expectedSubstring, [.("expectedSubstring")] string? expression = null) { } - public TSelf HasMessageContaining(string expectedSubstring, comparison, [.("expectedSubstring")] string? expression = null) { } - public TSelf WithMessage(string expectedMessage, [.("expectedMessage")] string? expression = null) { } - public TSelf WithMessageContaining(string expectedSubstring, [.("expectedSubstring")] string? expression = null) { } - public TSelf WithMessageContaining(string expectedSubstring, comparison, [.("expectedSubstring")] string? expression = null) { } - public TSelf WithMessageMatching(.StringMatcher matcher, [.("matcher")] string? expression = null) { } - public TSelf WithMessageMatching(string pattern, [.("pattern")] string? expression = null) { } - public TSelf WithMessageNotContaining(string notExpectedSubstring, [.("notExpectedSubstring")] string? expression = null) { } - public TSelf WithMessageNotContaining(string notExpectedSubstring, comparison, [.("notExpectedSubstring")] string? expression = null) { } - public TSelf WithParameterName(string expectedParameterName, [.("expectedParameterName")] string? expression = null) { } } [.("IsBetween")] public class BetweenAssertion : . @@ -714,10 +704,46 @@ namespace .Conditions [.(ExpectationMessage="to have a target site")] public static bool HasTargetSite(this value) { } } - public class ExceptionMessageAssertion : . + public class ExceptionMessageContainsAssertion : . + where TException : { - public ExceptionMessageAssertion(. context, string expectedSubstring, comparison = 4) { } - protected override .<.> CheckAsync(. metadata) { } + public ExceptionMessageContainsAssertion(. context, string expectedSubstring, comparison = 4) { } + protected override .<.> CheckAsync(. metadata) { } + protected override string GetExpectation() { } + } + public class ExceptionMessageEqualsAssertion : . + where TException : + { + public ExceptionMessageEqualsAssertion(. context, string expectedMessage, comparison = 4) { } + protected override .<.> CheckAsync(. metadata) { } + protected override string GetExpectation() { } + } + public class ExceptionMessageMatchesAssertion : . + where TException : + { + public ExceptionMessageMatchesAssertion(. context, .StringMatcher matcher) { } + protected override .<.> CheckAsync(. metadata) { } + protected override string GetExpectation() { } + } + public class ExceptionMessageMatchesPatternAssertion : . + where TException : + { + public ExceptionMessageMatchesPatternAssertion(. context, string pattern) { } + protected override .<.> CheckAsync(. metadata) { } + protected override string GetExpectation() { } + } + public class ExceptionMessageNotContainsAssertion : . + where TException : + { + public ExceptionMessageNotContainsAssertion(. context, string notExpectedSubstring, comparison = 4) { } + protected override .<.> CheckAsync(. metadata) { } + protected override string GetExpectation() { } + } + public class ExceptionParameterNameAssertion : . + where TException : + { + public ExceptionParameterNameAssertion(. context, string expectedParameterName) { } + protected override .<.> CheckAsync(. metadata) { } protected override string GetExpectation() { } } [.<.FileInfo>("Exists", CustomName="DoesNotExist", ExpectationMessage="exist", NegateLogic=true)] @@ -1189,17 +1215,35 @@ namespace .Conditions public class ThrowsAssertion : .> where TException : { - public ThrowsAssertion(. context) { } + public ThrowsAssertion(. context) { } protected override bool IsExactTypeMatch { get; } protected override bool CheckExceptionType( actualException, out string? errorMessage) { } public .<> WithInnerException() { } + public . WithMessage(string expectedMessage) { } + public . WithMessage(string expectedMessage, comparison) { } + public . WithMessageContaining(string expectedSubstring) { } + public . WithMessageContaining(string expectedSubstring, comparison) { } + public . WithMessageMatching(.StringMatcher matcher) { } + public . WithMessageMatching(string pattern) { } + public . WithMessageNotContaining(string notExpectedSubstring) { } + public . WithMessageNotContaining(string notExpectedSubstring, comparison) { } + public . WithParameterName(string expectedParameterName) { } } public class ThrowsExactlyAssertion : .> where TException : { - public ThrowsExactlyAssertion(. context) { } + public ThrowsExactlyAssertion(. context) { } protected override bool IsExactTypeMatch { get; } protected override bool CheckExceptionType( actualException, out string? errorMessage) { } + public . WithMessage(string expectedMessage) { } + public . WithMessage(string expectedMessage, comparison) { } + public . WithMessageContaining(string expectedSubstring) { } + public . WithMessageContaining(string expectedSubstring, comparison) { } + public . WithMessageMatching(.StringMatcher matcher) { } + public . WithMessageMatching(string pattern) { } + public . WithMessageNotContaining(string notExpectedSubstring) { } + public . WithMessageNotContaining(string notExpectedSubstring, comparison) { } + public . WithParameterName(string expectedParameterName) { } } public class ThrowsNothingAssertion : . { @@ -1376,6 +1420,8 @@ namespace .Core "End"})] public <, > GetTiming() { } public . Map( mapper) { } + public . MapException() + where TException : { } } public readonly struct AssertionResult { @@ -1418,6 +1464,8 @@ namespace .Core "End"})] public <, > GetTiming() { } public . Map( mapper) { } + public . MapException() + where TException : { } } public readonly struct EvaluationMetadata { @@ -1595,8 +1643,10 @@ namespace .Extensions public static ..LengthWrapper HasLength(this . source) { } public static . HasLength(this . source, int expectedLength, [.("expectedLength")] string? expression = null) { } public static . HasMember(this . source, .<> memberSelector) { } - public static . HasMessageContaining(this . source, string expectedSubstring) { } - public static . HasMessageContaining(this . source, string expectedSubstring, comparison) { } + public static . HasMessageContaining(this . source, string expectedSubstring, [.("expectedSubstring")] string? expression = null) + where TException : { } + public static . HasMessageContaining(this . source, string expectedSubstring, comparison, [.("expectedSubstring")] string? expression = null) + where TException : { } public static . HasMessageEqualTo(this . source, string expectedMessage) { } public static . HasMessageEqualTo(this . source, string expectedMessage, comparison) { } public static . HasMessageStartingWith(this . source, string expectedPrefix) { } @@ -1707,8 +1757,24 @@ namespace .Extensions where TException : { } public static . ThrowsNothing(this . source) { } public static ..WhenParsedIntoAssertion WhenParsedInto<[.(..None | ..PublicMethods | ..Interfaces)] T>(this . source) { } - public static . WithMessageContaining(this . source, string expectedSubstring) { } - public static . WithMessageContaining(this . source, string expectedSubstring, comparison) { } + public static . WithMessage(this . source, string expectedMessage, [.("expectedMessage")] string? expression = null) + where TException : { } + public static . WithMessage(this . source, string expectedMessage, comparison, [.("expectedMessage")] string? expression = null) + where TException : { } + public static . WithMessageContaining(this . source, string expectedSubstring, [.("expectedSubstring")] string? expression = null) + where TException : { } + public static . WithMessageContaining(this . source, string expectedSubstring, comparison, [.("expectedSubstring")] string? expression = null) + where TException : { } + public static . WithMessageMatching(this . source, .StringMatcher matcher, [.("matcher")] string? expression = null) + where TException : { } + public static . WithMessageMatching(this . source, string pattern, [.("pattern")] string? expression = null) + where TException : { } + public static . WithMessageNotContaining(this . source, string notExpectedSubstring, [.("notExpectedSubstring")] string? expression = null) + where TException : { } + public static . WithMessageNotContaining(this . source, string notExpectedSubstring, comparison, [.("notExpectedSubstring")] string? expression = null) + where TException : { } + public static . WithParameterName(this . source, string expectedParameterName, [.("expectedParameterName")] string? expression = null) + where TException : { } } public static class BetweenAssertionExtensions { diff --git a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet8_0.verified.txt b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet8_0.verified.txt index a2a71e012a..9e2b2f9822 100644 --- a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet8_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet8_0.verified.txt @@ -277,21 +277,11 @@ namespace .Conditions where TException : where TSelf : . { - protected BaseThrowsAssertion(. context, bool allowSubclasses) { } + protected BaseThrowsAssertion(. context, bool allowSubclasses) { } protected abstract bool IsExactTypeMatch { get; } protected override sealed .<.> CheckAsync(. metadata) { } protected abstract bool CheckExceptionType( actualException, out string? errorMessage); protected override string GetExpectation() { } - public TSelf HasMessageContaining(string expectedSubstring, [.("expectedSubstring")] string? expression = null) { } - public TSelf HasMessageContaining(string expectedSubstring, comparison, [.("expectedSubstring")] string? expression = null) { } - public TSelf WithMessage(string expectedMessage, [.("expectedMessage")] string? expression = null) { } - public TSelf WithMessageContaining(string expectedSubstring, [.("expectedSubstring")] string? expression = null) { } - public TSelf WithMessageContaining(string expectedSubstring, comparison, [.("expectedSubstring")] string? expression = null) { } - public TSelf WithMessageMatching(.StringMatcher matcher, [.("matcher")] string? expression = null) { } - public TSelf WithMessageMatching(string pattern, [.("pattern")] string? expression = null) { } - public TSelf WithMessageNotContaining(string notExpectedSubstring, [.("notExpectedSubstring")] string? expression = null) { } - public TSelf WithMessageNotContaining(string notExpectedSubstring, comparison, [.("notExpectedSubstring")] string? expression = null) { } - public TSelf WithParameterName(string expectedParameterName, [.("expectedParameterName")] string? expression = null) { } } [.("IsBetween")] public class BetweenAssertion : . @@ -714,10 +704,46 @@ namespace .Conditions [.(ExpectationMessage="to have a target site")] public static bool HasTargetSite(this value) { } } - public class ExceptionMessageAssertion : . + public class ExceptionMessageContainsAssertion : . + where TException : { - public ExceptionMessageAssertion(. context, string expectedSubstring, comparison = 4) { } - protected override .<.> CheckAsync(. metadata) { } + public ExceptionMessageContainsAssertion(. context, string expectedSubstring, comparison = 4) { } + protected override .<.> CheckAsync(. metadata) { } + protected override string GetExpectation() { } + } + public class ExceptionMessageEqualsAssertion : . + where TException : + { + public ExceptionMessageEqualsAssertion(. context, string expectedMessage, comparison = 4) { } + protected override .<.> CheckAsync(. metadata) { } + protected override string GetExpectation() { } + } + public class ExceptionMessageMatchesAssertion : . + where TException : + { + public ExceptionMessageMatchesAssertion(. context, .StringMatcher matcher) { } + protected override .<.> CheckAsync(. metadata) { } + protected override string GetExpectation() { } + } + public class ExceptionMessageMatchesPatternAssertion : . + where TException : + { + public ExceptionMessageMatchesPatternAssertion(. context, string pattern) { } + protected override .<.> CheckAsync(. metadata) { } + protected override string GetExpectation() { } + } + public class ExceptionMessageNotContainsAssertion : . + where TException : + { + public ExceptionMessageNotContainsAssertion(. context, string notExpectedSubstring, comparison = 4) { } + protected override .<.> CheckAsync(. metadata) { } + protected override string GetExpectation() { } + } + public class ExceptionParameterNameAssertion : . + where TException : + { + public ExceptionParameterNameAssertion(. context, string expectedParameterName) { } + protected override .<.> CheckAsync(. metadata) { } protected override string GetExpectation() { } } [.<.FileInfo>("Exists", CustomName="DoesNotExist", ExpectationMessage="exist", NegateLogic=true)] @@ -1189,17 +1215,35 @@ namespace .Conditions public class ThrowsAssertion : .> where TException : { - public ThrowsAssertion(. context) { } + public ThrowsAssertion(. context) { } protected override bool IsExactTypeMatch { get; } protected override bool CheckExceptionType( actualException, out string? errorMessage) { } public .<> WithInnerException() { } + public . WithMessage(string expectedMessage) { } + public . WithMessage(string expectedMessage, comparison) { } + public . WithMessageContaining(string expectedSubstring) { } + public . WithMessageContaining(string expectedSubstring, comparison) { } + public . WithMessageMatching(.StringMatcher matcher) { } + public . WithMessageMatching(string pattern) { } + public . WithMessageNotContaining(string notExpectedSubstring) { } + public . WithMessageNotContaining(string notExpectedSubstring, comparison) { } + public . WithParameterName(string expectedParameterName) { } } public class ThrowsExactlyAssertion : .> where TException : { - public ThrowsExactlyAssertion(. context) { } + public ThrowsExactlyAssertion(. context) { } protected override bool IsExactTypeMatch { get; } protected override bool CheckExceptionType( actualException, out string? errorMessage) { } + public . WithMessage(string expectedMessage) { } + public . WithMessage(string expectedMessage, comparison) { } + public . WithMessageContaining(string expectedSubstring) { } + public . WithMessageContaining(string expectedSubstring, comparison) { } + public . WithMessageMatching(.StringMatcher matcher) { } + public . WithMessageMatching(string pattern) { } + public . WithMessageNotContaining(string notExpectedSubstring) { } + public . WithMessageNotContaining(string notExpectedSubstring, comparison) { } + public . WithParameterName(string expectedParameterName) { } } public class ThrowsNothingAssertion : . { @@ -1376,6 +1420,8 @@ namespace .Core "End"})] public <, > GetTiming() { } public . Map( mapper) { } + public . MapException() + where TException : { } } public readonly struct AssertionResult { @@ -1418,6 +1464,8 @@ namespace .Core "End"})] public <, > GetTiming() { } public . Map( mapper) { } + public . MapException() + where TException : { } } public readonly struct EvaluationMetadata { @@ -1595,8 +1643,10 @@ namespace .Extensions public static ..LengthWrapper HasLength(this . source) { } public static . HasLength(this . source, int expectedLength, [.("expectedLength")] string? expression = null) { } public static . HasMember(this . source, .<> memberSelector) { } - public static . HasMessageContaining(this . source, string expectedSubstring) { } - public static . HasMessageContaining(this . source, string expectedSubstring, comparison) { } + public static . HasMessageContaining(this . source, string expectedSubstring, [.("expectedSubstring")] string? expression = null) + where TException : { } + public static . HasMessageContaining(this . source, string expectedSubstring, comparison, [.("expectedSubstring")] string? expression = null) + where TException : { } public static . HasMessageEqualTo(this . source, string expectedMessage) { } public static . HasMessageEqualTo(this . source, string expectedMessage, comparison) { } public static . HasMessageStartingWith(this . source, string expectedPrefix) { } @@ -1695,8 +1745,24 @@ namespace .Extensions where TException : { } public static . ThrowsNothing(this . source) { } public static ..WhenParsedIntoAssertion WhenParsedInto<[.(..None | ..PublicMethods | ..Interfaces)] T>(this . source) { } - public static . WithMessageContaining(this . source, string expectedSubstring) { } - public static . WithMessageContaining(this . source, string expectedSubstring, comparison) { } + public static . WithMessage(this . source, string expectedMessage, [.("expectedMessage")] string? expression = null) + where TException : { } + public static . WithMessage(this . source, string expectedMessage, comparison, [.("expectedMessage")] string? expression = null) + where TException : { } + public static . WithMessageContaining(this . source, string expectedSubstring, [.("expectedSubstring")] string? expression = null) + where TException : { } + public static . WithMessageContaining(this . source, string expectedSubstring, comparison, [.("expectedSubstring")] string? expression = null) + where TException : { } + public static . WithMessageMatching(this . source, .StringMatcher matcher, [.("matcher")] string? expression = null) + where TException : { } + public static . WithMessageMatching(this . source, string pattern, [.("pattern")] string? expression = null) + where TException : { } + public static . WithMessageNotContaining(this . source, string notExpectedSubstring, [.("notExpectedSubstring")] string? expression = null) + where TException : { } + public static . WithMessageNotContaining(this . source, string notExpectedSubstring, comparison, [.("notExpectedSubstring")] string? expression = null) + where TException : { } + public static . WithParameterName(this . source, string expectedParameterName, [.("expectedParameterName")] string? expression = null) + where TException : { } } public static class BetweenAssertionExtensions { diff --git a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet9_0.verified.txt b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet9_0.verified.txt index d90c6dfea5..d8f89a1fec 100644 --- a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet9_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet9_0.verified.txt @@ -277,21 +277,11 @@ namespace .Conditions where TException : where TSelf : . { - protected BaseThrowsAssertion(. context, bool allowSubclasses) { } + protected BaseThrowsAssertion(. context, bool allowSubclasses) { } protected abstract bool IsExactTypeMatch { get; } protected override sealed .<.> CheckAsync(. metadata) { } protected abstract bool CheckExceptionType( actualException, out string? errorMessage); protected override string GetExpectation() { } - public TSelf HasMessageContaining(string expectedSubstring, [.("expectedSubstring")] string? expression = null) { } - public TSelf HasMessageContaining(string expectedSubstring, comparison, [.("expectedSubstring")] string? expression = null) { } - public TSelf WithMessage(string expectedMessage, [.("expectedMessage")] string? expression = null) { } - public TSelf WithMessageContaining(string expectedSubstring, [.("expectedSubstring")] string? expression = null) { } - public TSelf WithMessageContaining(string expectedSubstring, comparison, [.("expectedSubstring")] string? expression = null) { } - public TSelf WithMessageMatching(.StringMatcher matcher, [.("matcher")] string? expression = null) { } - public TSelf WithMessageMatching(string pattern, [.("pattern")] string? expression = null) { } - public TSelf WithMessageNotContaining(string notExpectedSubstring, [.("notExpectedSubstring")] string? expression = null) { } - public TSelf WithMessageNotContaining(string notExpectedSubstring, comparison, [.("notExpectedSubstring")] string? expression = null) { } - public TSelf WithParameterName(string expectedParameterName, [.("expectedParameterName")] string? expression = null) { } } [.("IsBetween")] public class BetweenAssertion : . @@ -714,10 +704,46 @@ namespace .Conditions [.(ExpectationMessage="to have a target site")] public static bool HasTargetSite(this value) { } } - public class ExceptionMessageAssertion : . + public class ExceptionMessageContainsAssertion : . + where TException : { - public ExceptionMessageAssertion(. context, string expectedSubstring, comparison = 4) { } - protected override .<.> CheckAsync(. metadata) { } + public ExceptionMessageContainsAssertion(. context, string expectedSubstring, comparison = 4) { } + protected override .<.> CheckAsync(. metadata) { } + protected override string GetExpectation() { } + } + public class ExceptionMessageEqualsAssertion : . + where TException : + { + public ExceptionMessageEqualsAssertion(. context, string expectedMessage, comparison = 4) { } + protected override .<.> CheckAsync(. metadata) { } + protected override string GetExpectation() { } + } + public class ExceptionMessageMatchesAssertion : . + where TException : + { + public ExceptionMessageMatchesAssertion(. context, .StringMatcher matcher) { } + protected override .<.> CheckAsync(. metadata) { } + protected override string GetExpectation() { } + } + public class ExceptionMessageMatchesPatternAssertion : . + where TException : + { + public ExceptionMessageMatchesPatternAssertion(. context, string pattern) { } + protected override .<.> CheckAsync(. metadata) { } + protected override string GetExpectation() { } + } + public class ExceptionMessageNotContainsAssertion : . + where TException : + { + public ExceptionMessageNotContainsAssertion(. context, string notExpectedSubstring, comparison = 4) { } + protected override .<.> CheckAsync(. metadata) { } + protected override string GetExpectation() { } + } + public class ExceptionParameterNameAssertion : . + where TException : + { + public ExceptionParameterNameAssertion(. context, string expectedParameterName) { } + protected override .<.> CheckAsync(. metadata) { } protected override string GetExpectation() { } } [.<.FileInfo>("Exists", CustomName="DoesNotExist", ExpectationMessage="exist", NegateLogic=true)] @@ -1189,17 +1215,35 @@ namespace .Conditions public class ThrowsAssertion : .> where TException : { - public ThrowsAssertion(. context) { } + public ThrowsAssertion(. context) { } protected override bool IsExactTypeMatch { get; } protected override bool CheckExceptionType( actualException, out string? errorMessage) { } public .<> WithInnerException() { } + public . WithMessage(string expectedMessage) { } + public . WithMessage(string expectedMessage, comparison) { } + public . WithMessageContaining(string expectedSubstring) { } + public . WithMessageContaining(string expectedSubstring, comparison) { } + public . WithMessageMatching(.StringMatcher matcher) { } + public . WithMessageMatching(string pattern) { } + public . WithMessageNotContaining(string notExpectedSubstring) { } + public . WithMessageNotContaining(string notExpectedSubstring, comparison) { } + public . WithParameterName(string expectedParameterName) { } } public class ThrowsExactlyAssertion : .> where TException : { - public ThrowsExactlyAssertion(. context) { } + public ThrowsExactlyAssertion(. context) { } protected override bool IsExactTypeMatch { get; } protected override bool CheckExceptionType( actualException, out string? errorMessage) { } + public . WithMessage(string expectedMessage) { } + public . WithMessage(string expectedMessage, comparison) { } + public . WithMessageContaining(string expectedSubstring) { } + public . WithMessageContaining(string expectedSubstring, comparison) { } + public . WithMessageMatching(.StringMatcher matcher) { } + public . WithMessageMatching(string pattern) { } + public . WithMessageNotContaining(string notExpectedSubstring) { } + public . WithMessageNotContaining(string notExpectedSubstring, comparison) { } + public . WithParameterName(string expectedParameterName) { } } public class ThrowsNothingAssertion : . { @@ -1376,6 +1420,8 @@ namespace .Core "End"})] public <, > GetTiming() { } public . Map( mapper) { } + public . MapException() + where TException : { } } public readonly struct AssertionResult { @@ -1418,6 +1464,8 @@ namespace .Core "End"})] public <, > GetTiming() { } public . Map( mapper) { } + public . MapException() + where TException : { } } public readonly struct EvaluationMetadata { @@ -1595,8 +1643,10 @@ namespace .Extensions public static ..LengthWrapper HasLength(this . source) { } public static . HasLength(this . source, int expectedLength, [.("expectedLength")] string? expression = null) { } public static . HasMember(this . source, .<> memberSelector) { } - public static . HasMessageContaining(this . source, string expectedSubstring) { } - public static . HasMessageContaining(this . source, string expectedSubstring, comparison) { } + public static . HasMessageContaining(this . source, string expectedSubstring, [.("expectedSubstring")] string? expression = null) + where TException : { } + public static . HasMessageContaining(this . source, string expectedSubstring, comparison, [.("expectedSubstring")] string? expression = null) + where TException : { } public static . HasMessageEqualTo(this . source, string expectedMessage) { } public static . HasMessageEqualTo(this . source, string expectedMessage, comparison) { } public static . HasMessageStartingWith(this . source, string expectedPrefix) { } @@ -1707,8 +1757,24 @@ namespace .Extensions where TException : { } public static . ThrowsNothing(this . source) { } public static ..WhenParsedIntoAssertion WhenParsedInto<[.(..None | ..PublicMethods | ..Interfaces)] T>(this . source) { } - public static . WithMessageContaining(this . source, string expectedSubstring) { } - public static . WithMessageContaining(this . source, string expectedSubstring, comparison) { } + public static . WithMessage(this . source, string expectedMessage, [.("expectedMessage")] string? expression = null) + where TException : { } + public static . WithMessage(this . source, string expectedMessage, comparison, [.("expectedMessage")] string? expression = null) + where TException : { } + public static . WithMessageContaining(this . source, string expectedSubstring, [.("expectedSubstring")] string? expression = null) + where TException : { } + public static . WithMessageContaining(this . source, string expectedSubstring, comparison, [.("expectedSubstring")] string? expression = null) + where TException : { } + public static . WithMessageMatching(this . source, .StringMatcher matcher, [.("matcher")] string? expression = null) + where TException : { } + public static . WithMessageMatching(this . source, string pattern, [.("pattern")] string? expression = null) + where TException : { } + public static . WithMessageNotContaining(this . source, string notExpectedSubstring, [.("notExpectedSubstring")] string? expression = null) + where TException : { } + public static . WithMessageNotContaining(this . source, string notExpectedSubstring, comparison, [.("notExpectedSubstring")] string? expression = null) + where TException : { } + public static . WithParameterName(this . source, string expectedParameterName, [.("expectedParameterName")] string? expression = null) + where TException : { } } public static class BetweenAssertionExtensions { diff --git a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.Net4_7.verified.txt b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.Net4_7.verified.txt index 8a6fc35145..bafb3c62c5 100644 --- a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.Net4_7.verified.txt +++ b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.Net4_7.verified.txt @@ -275,21 +275,11 @@ namespace .Conditions where TException : where TSelf : . { - protected BaseThrowsAssertion(. context, bool allowSubclasses) { } + protected BaseThrowsAssertion(. context, bool allowSubclasses) { } protected abstract bool IsExactTypeMatch { get; } protected override sealed .<.> CheckAsync(. metadata) { } protected abstract bool CheckExceptionType( actualException, out string? errorMessage); protected override string GetExpectation() { } - public TSelf HasMessageContaining(string expectedSubstring, [.("expectedSubstring")] string? expression = null) { } - public TSelf HasMessageContaining(string expectedSubstring, comparison, [.("expectedSubstring")] string? expression = null) { } - public TSelf WithMessage(string expectedMessage, [.("expectedMessage")] string? expression = null) { } - public TSelf WithMessageContaining(string expectedSubstring, [.("expectedSubstring")] string? expression = null) { } - public TSelf WithMessageContaining(string expectedSubstring, comparison, [.("expectedSubstring")] string? expression = null) { } - public TSelf WithMessageMatching(.StringMatcher matcher, [.("matcher")] string? expression = null) { } - public TSelf WithMessageMatching(string pattern, [.("pattern")] string? expression = null) { } - public TSelf WithMessageNotContaining(string notExpectedSubstring, [.("notExpectedSubstring")] string? expression = null) { } - public TSelf WithMessageNotContaining(string notExpectedSubstring, comparison, [.("notExpectedSubstring")] string? expression = null) { } - public TSelf WithParameterName(string expectedParameterName, [.("expectedParameterName")] string? expression = null) { } } [.("IsBetween")] public class BetweenAssertion : . @@ -677,10 +667,46 @@ namespace .Conditions [.(ExpectationMessage="to have a target site")] public static bool HasTargetSite(this value) { } } - public class ExceptionMessageAssertion : . + public class ExceptionMessageContainsAssertion : . + where TException : { - public ExceptionMessageAssertion(. context, string expectedSubstring, comparison = 4) { } - protected override .<.> CheckAsync(. metadata) { } + public ExceptionMessageContainsAssertion(. context, string expectedSubstring, comparison = 4) { } + protected override .<.> CheckAsync(. metadata) { } + protected override string GetExpectation() { } + } + public class ExceptionMessageEqualsAssertion : . + where TException : + { + public ExceptionMessageEqualsAssertion(. context, string expectedMessage, comparison = 4) { } + protected override .<.> CheckAsync(. metadata) { } + protected override string GetExpectation() { } + } + public class ExceptionMessageMatchesAssertion : . + where TException : + { + public ExceptionMessageMatchesAssertion(. context, .StringMatcher matcher) { } + protected override .<.> CheckAsync(. metadata) { } + protected override string GetExpectation() { } + } + public class ExceptionMessageMatchesPatternAssertion : . + where TException : + { + public ExceptionMessageMatchesPatternAssertion(. context, string pattern) { } + protected override .<.> CheckAsync(. metadata) { } + protected override string GetExpectation() { } + } + public class ExceptionMessageNotContainsAssertion : . + where TException : + { + public ExceptionMessageNotContainsAssertion(. context, string notExpectedSubstring, comparison = 4) { } + protected override .<.> CheckAsync(. metadata) { } + protected override string GetExpectation() { } + } + public class ExceptionParameterNameAssertion : . + where TException : + { + public ExceptionParameterNameAssertion(. context, string expectedParameterName) { } + protected override .<.> CheckAsync(. metadata) { } protected override string GetExpectation() { } } [.<.FileInfo>("Exists", CustomName="DoesNotExist", ExpectationMessage="exist", NegateLogic=true)] @@ -1134,17 +1160,35 @@ namespace .Conditions public class ThrowsAssertion : .> where TException : { - public ThrowsAssertion(. context) { } + public ThrowsAssertion(. context) { } protected override bool IsExactTypeMatch { get; } protected override bool CheckExceptionType( actualException, out string? errorMessage) { } public .<> WithInnerException() { } + public . WithMessage(string expectedMessage) { } + public . WithMessage(string expectedMessage, comparison) { } + public . WithMessageContaining(string expectedSubstring) { } + public . WithMessageContaining(string expectedSubstring, comparison) { } + public . WithMessageMatching(.StringMatcher matcher) { } + public . WithMessageMatching(string pattern) { } + public . WithMessageNotContaining(string notExpectedSubstring) { } + public . WithMessageNotContaining(string notExpectedSubstring, comparison) { } + public . WithParameterName(string expectedParameterName) { } } public class ThrowsExactlyAssertion : .> where TException : { - public ThrowsExactlyAssertion(. context) { } + public ThrowsExactlyAssertion(. context) { } protected override bool IsExactTypeMatch { get; } protected override bool CheckExceptionType( actualException, out string? errorMessage) { } + public . WithMessage(string expectedMessage) { } + public . WithMessage(string expectedMessage, comparison) { } + public . WithMessageContaining(string expectedSubstring) { } + public . WithMessageContaining(string expectedSubstring, comparison) { } + public . WithMessageMatching(.StringMatcher matcher) { } + public . WithMessageMatching(string pattern) { } + public . WithMessageNotContaining(string notExpectedSubstring) { } + public . WithMessageNotContaining(string notExpectedSubstring, comparison) { } + public . WithParameterName(string expectedParameterName) { } } public class ThrowsNothingAssertion : . { @@ -1295,6 +1339,8 @@ namespace .Core "End"})] public <, > GetTiming() { } public . Map( mapper) { } + public . MapException() + where TException : { } } public readonly struct AssertionResult { @@ -1337,6 +1383,8 @@ namespace .Core "End"})] public <, > GetTiming() { } public . Map( mapper) { } + public . MapException() + where TException : { } } public readonly struct EvaluationMetadata { @@ -1500,8 +1548,10 @@ namespace .Extensions public static ..LengthWrapper HasLength(this . source) { } public static . HasLength(this . source, int expectedLength, [.("expectedLength")] string? expression = null) { } public static . HasMember(this . source, .<> memberSelector) { } - public static . HasMessageContaining(this . source, string expectedSubstring) { } - public static . HasMessageContaining(this . source, string expectedSubstring, comparison) { } + public static . HasMessageContaining(this . source, string expectedSubstring, [.("expectedSubstring")] string? expression = null) + where TException : { } + public static . HasMessageContaining(this . source, string expectedSubstring, comparison, [.("expectedSubstring")] string? expression = null) + where TException : { } public static . HasMessageEqualTo(this . source, string expectedMessage) { } public static . HasMessageEqualTo(this . source, string expectedMessage, comparison) { } public static . HasMessageStartingWith(this . source, string expectedPrefix) { } @@ -1598,8 +1648,24 @@ namespace .Extensions where TException : { } public static . ThrowsNothing(this . source) { } public static ..WhenParsedIntoAssertion WhenParsedInto(this . source) { } - public static . WithMessageContaining(this . source, string expectedSubstring) { } - public static . WithMessageContaining(this . source, string expectedSubstring, comparison) { } + public static . WithMessage(this . source, string expectedMessage, [.("expectedMessage")] string? expression = null) + where TException : { } + public static . WithMessage(this . source, string expectedMessage, comparison, [.("expectedMessage")] string? expression = null) + where TException : { } + public static . WithMessageContaining(this . source, string expectedSubstring, [.("expectedSubstring")] string? expression = null) + where TException : { } + public static . WithMessageContaining(this . source, string expectedSubstring, comparison, [.("expectedSubstring")] string? expression = null) + where TException : { } + public static . WithMessageMatching(this . source, .StringMatcher matcher, [.("matcher")] string? expression = null) + where TException : { } + public static . WithMessageMatching(this . source, string pattern, [.("pattern")] string? expression = null) + where TException : { } + public static . WithMessageNotContaining(this . source, string notExpectedSubstring, [.("notExpectedSubstring")] string? expression = null) + where TException : { } + public static . WithMessageNotContaining(this . source, string notExpectedSubstring, comparison, [.("notExpectedSubstring")] string? expression = null) + where TException : { } + public static . WithParameterName(this . source, string expectedParameterName, [.("expectedParameterName")] string? expression = null) + where TException : { } } public static class BetweenAssertionExtensions {