From ba632178129dd2cdbffda7cd014b6a6ff0da4e44 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 24 May 2026 12:16:08 +0100 Subject: [PATCH 1/5] Preserve null assertion chaining --- .../AsyncEnumerableAssertionTests.cs | 15 ++ .../CollectionAssertionTests.cs | 21 ++ .../Conditions/AsyncEnumerableAssertions.cs | 26 +++ .../Conditions/CollectionNullAssertion.cs | 209 +++++++++++++++++- .../Sources/AsyncEnumerableAssertionBase.cs | 18 ++ .../Sources/CollectionAssertionBase.cs | 18 ++ .../Sources/DictionaryAssertionBase.cs | 18 ++ TUnit.Assertions/Sources/ListAssertionBase.cs | 18 ++ .../Sources/MutableDictionaryAssertionBase.cs | 18 ++ .../Sources/ReadOnlyListAssertionBase.cs | 18 ++ TUnit.Assertions/Sources/SetAssertionBase.cs | 18 ++ 11 files changed, 393 insertions(+), 4 deletions(-) diff --git a/TUnit.Assertions.Tests/AsyncEnumerableAssertionTests.cs b/TUnit.Assertions.Tests/AsyncEnumerableAssertionTests.cs index b65f2ed237..2541b0b334 100644 --- a/TUnit.Assertions.Tests/AsyncEnumerableAssertionTests.cs +++ b/TUnit.Assertions.Tests/AsyncEnumerableAssertionTests.cs @@ -149,6 +149,21 @@ await Assert.That(items) .Or.Contains(99); // First passes, so overall passes } + [Test] + public async Task Test_AsyncEnumerable_NullAssertions_Preserve_Chaining() + { + var items = AsyncRange(1, 5); + + await Assert.That(items) + .IsNotNull() + .And.Contains(3); + + IAsyncEnumerable? nullItems = null; + await Assert.That(nullItems!) + .IsNull() + .Or.Contains(3); + } + // Null handling [Test] public async Task Test_AsyncEnumerable_Null_Fails() diff --git a/TUnit.Assertions.Tests/CollectionAssertionTests.cs b/TUnit.Assertions.Tests/CollectionAssertionTests.cs index 0ba70fb5c0..aba1982a49 100644 --- a/TUnit.Assertions.Tests/CollectionAssertionTests.cs +++ b/TUnit.Assertions.Tests/CollectionAssertionTests.cs @@ -108,6 +108,27 @@ public async Task Count_WithInnerAssertion_AllMatch() await Assert.That(items).Count(item => item.IsGreaterThan(0)).IsEqualTo(5); } + [Test] + public async Task NullAssertions_Preserve_List_Chaining() + { + var items = new List { 1 }; + + await Assert.That(items).IsNotNull().And.ItemAt(0).IsEqualTo(1); + + List? nullItems = null; + await Assert.That(nullItems).IsNull().Or.ItemAt(0).IsEqualTo(1); + } + + [Test] + public async Task NullAssertions_Preserve_Dictionary_And_Set_Chaining() + { + IDictionary dictionary = new Dictionary { ["one"] = 1 }; + ISet set = new HashSet { 1 }; + + await Assert.That(dictionary).IsNotNull().And.ContainsKey("one"); + await Assert.That(set).IsNotNull().And.IsSubsetOf([1, 2]); + } + [Test] public async Task Count_WithInnerAssertion_Lambda_Collection() { diff --git a/TUnit.Assertions/Conditions/AsyncEnumerableAssertions.cs b/TUnit.Assertions/Conditions/AsyncEnumerableAssertions.cs index 4767edbd14..8e747ad38b 100644 --- a/TUnit.Assertions/Conditions/AsyncEnumerableAssertions.cs +++ b/TUnit.Assertions/Conditions/AsyncEnumerableAssertions.cs @@ -43,6 +43,32 @@ private static async Task> MaterializeAsync(IAsyncEnumerable protected abstract AssertionResult CheckMaterialized(List items); } +internal class AsyncEnumerableNullAssertion : AsyncEnumerableAssertionBase +{ + public AsyncEnumerableNullAssertion(AssertionContext> context) + : base(context) + { + } + + protected override Task CheckAsync(EvaluationMetadata> metadata) + => NullCheck.CheckIsNull(metadata); + + protected override string GetExpectation() => "to be null"; +} + +internal class AsyncEnumerableNotNullAssertion : AsyncEnumerableAssertionBase +{ + public AsyncEnumerableNotNullAssertion(AssertionContext> context) + : base(context) + { + } + + protected override Task CheckAsync(EvaluationMetadata> metadata) + => NullCheck.CheckIsNotNull(metadata); + + protected override string GetExpectation() => "to not be null"; +} + /// /// Asserts that the async enumerable is empty or not empty. /// diff --git a/TUnit.Assertions/Conditions/CollectionNullAssertion.cs b/TUnit.Assertions/Conditions/CollectionNullAssertion.cs index 94b09acee7..78b5f507ef 100644 --- a/TUnit.Assertions/Conditions/CollectionNullAssertion.cs +++ b/TUnit.Assertions/Conditions/CollectionNullAssertion.cs @@ -1,9 +1,28 @@ using System.Collections; +using TUnit.Assertions.Abstractions; using TUnit.Assertions.Core; using TUnit.Assertions.Sources; namespace TUnit.Assertions.Conditions; +/// +/// Asserts that a collection is null, preserving collection type information. +/// Extends CollectionAssertionBase to ensure .And and .Or return collection-specific continuations. +/// +internal class CollectionNullAssertion : CollectionAssertionBase + where TCollection : IEnumerable +{ + public CollectionNullAssertion(AssertionContext context) + : base(context) + { + } + + protected override Task CheckAsync(EvaluationMetadata metadata) + => NullCheck.CheckIsNull(metadata); + + protected override string GetExpectation() => "to be null"; +} + /// /// Asserts that a collection is not null, preserving collection type information. /// Extends CollectionAssertionBase to ensure .And and .Or return collection-specific continuations. @@ -17,16 +36,198 @@ public CollectionNotNullAssertion(AssertionContext context) } protected override Task CheckAsync(EvaluationMetadata metadata) + => NullCheck.CheckIsNotNull(metadata); + + protected override string GetExpectation() => "to not be null"; +} + +internal class ListNullAssertion : ListAssertionBase + where TList : IList +{ + public ListNullAssertion(AssertionContext context) + : base(context) { - var value = metadata.Value; + } - if (value != null) + protected override Task CheckAsync(EvaluationMetadata metadata) + => NullCheck.CheckIsNull(metadata); + + protected override string GetExpectation() => "to be null"; +} + +internal class ListNotNullAssertion : ListAssertionBase + where TList : IList +{ + public ListNotNullAssertion(AssertionContext context) + : base(context) + { + } + + protected override Task CheckAsync(EvaluationMetadata metadata) + => NullCheck.CheckIsNotNull(metadata); + + protected override string GetExpectation() => "to not be null"; +} + +internal class ReadOnlyListNullAssertion : ReadOnlyListAssertionBase + where TList : IReadOnlyList +{ + public ReadOnlyListNullAssertion(AssertionContext context) + : base(context) + { + } + + protected override Task CheckAsync(EvaluationMetadata metadata) + => NullCheck.CheckIsNull(metadata); + + protected override string GetExpectation() => "to be null"; +} + +internal class ReadOnlyListNotNullAssertion : ReadOnlyListAssertionBase + where TList : IReadOnlyList +{ + public ReadOnlyListNotNullAssertion(AssertionContext context) + : base(context) + { + } + + protected override Task CheckAsync(EvaluationMetadata metadata) + => NullCheck.CheckIsNotNull(metadata); + + protected override string GetExpectation() => "to not be null"; +} + +internal class DictionaryNullAssertion : DictionaryAssertionBase + where TDictionary : IReadOnlyDictionary + where TKey : notnull +{ + public DictionaryNullAssertion(AssertionContext context) + : base(context) + { + } + + protected override Task CheckAsync(EvaluationMetadata metadata) + => NullCheck.CheckIsNull(metadata); + + protected override string GetExpectation() => "to be null"; +} + +internal class DictionaryNotNullAssertion : DictionaryAssertionBase + where TDictionary : IReadOnlyDictionary + where TKey : notnull +{ + public DictionaryNotNullAssertion(AssertionContext context) + : base(context) + { + } + + protected override Task CheckAsync(EvaluationMetadata metadata) + => NullCheck.CheckIsNotNull(metadata); + + protected override string GetExpectation() => "to not be null"; +} + +internal class MutableDictionaryNullAssertion : MutableDictionaryAssertionBase + where TDictionary : IDictionary + where TKey : notnull +{ + public MutableDictionaryNullAssertion(AssertionContext context) + : base(context) + { + } + + protected override Task CheckAsync(EvaluationMetadata metadata) + => NullCheck.CheckIsNull(metadata); + + protected override string GetExpectation() => "to be null"; +} + +internal class MutableDictionaryNotNullAssertion : MutableDictionaryAssertionBase + where TDictionary : IDictionary + where TKey : notnull +{ + public MutableDictionaryNotNullAssertion(AssertionContext context) + : base(context) + { + } + + protected override Task CheckAsync(EvaluationMetadata metadata) + => NullCheck.CheckIsNotNull(metadata); + + protected override string GetExpectation() => "to not be null"; +} + +internal class SetNullAssertion : SetAssertionBase + where TSet : IEnumerable +{ + private readonly Func> _adapterFactory; + + public SetNullAssertion( + AssertionContext context, + Func> adapterFactory) + : base(context) + { + _adapterFactory = adapterFactory; + } + + protected override ISetAdapter CreateSetAdapter(TSet value) => _adapterFactory(value); + + protected override Task CheckAsync(EvaluationMetadata metadata) + => NullCheck.CheckIsNull(metadata); + + protected override string GetExpectation() => "to be null"; +} + +internal class SetNotNullAssertion : SetAssertionBase + where TSet : IEnumerable +{ + private readonly Func> _adapterFactory; + + public SetNotNullAssertion( + AssertionContext context, + Func> adapterFactory) + : base(context) + { + _adapterFactory = adapterFactory; + } + + protected override ISetAdapter CreateSetAdapter(TSet value) => _adapterFactory(value); + + protected override Task CheckAsync(EvaluationMetadata metadata) + => NullCheck.CheckIsNotNull(metadata); + + protected override string GetExpectation() => "to not be null"; +} + +internal static class NullCheck +{ + public static Task CheckIsNull(EvaluationMetadata metadata) + { + if (metadata.Exception != null) + { + return Task.FromResult(AssertionResult.Failed($"threw {metadata.Exception.GetType().Name}", metadata.Exception)); + } + + if (metadata.Value is null) { return AssertionResult._passedTask; } - return Task.FromResult(AssertionResult.Failed("value is null")); + return Task.FromResult(AssertionResult.Failed("value is not null")); } - protected override string GetExpectation() => "to not be null"; + public static Task CheckIsNotNull(EvaluationMetadata metadata) + { + if (metadata.Exception != null) + { + return Task.FromResult(AssertionResult.Failed($"threw {metadata.Exception.GetType().Name}", metadata.Exception)); + } + + if (metadata.Value is not null) + { + return AssertionResult._passedTask; + } + + return Task.FromResult(AssertionResult.Failed("value is null")); + } } diff --git a/TUnit.Assertions/Sources/AsyncEnumerableAssertionBase.cs b/TUnit.Assertions/Sources/AsyncEnumerableAssertionBase.cs index 7ae256b726..5481870ef4 100644 --- a/TUnit.Assertions/Sources/AsyncEnumerableAssertionBase.cs +++ b/TUnit.Assertions/Sources/AsyncEnumerableAssertionBase.cs @@ -39,6 +39,24 @@ private protected AsyncEnumerableAssertionBase( protected override string GetExpectation() => "async enumerable assertion"; + /// + /// Asserts that the async enumerable is null while preserving async-enumerable-specific chaining. + /// + public AsyncEnumerableAssertionBase IsNull() + { + Context.ExpressionBuilder.Append(".IsNull()"); + return new AsyncEnumerableNullAssertion(Context); + } + + /// + /// Asserts that the async enumerable is not null while preserving async-enumerable-specific chaining. + /// + public AsyncEnumerableAssertionBase IsNotNull() + { + Context.ExpressionBuilder.Append(".IsNotNull()"); + return new AsyncEnumerableNotNullAssertion(Context); + } + /// /// Asserts that the async enumerable is empty. /// Example: await Assert.That(asyncEnumerable).IsEmpty(); diff --git a/TUnit.Assertions/Sources/CollectionAssertionBase.cs b/TUnit.Assertions/Sources/CollectionAssertionBase.cs index c5d7c24454..40798487c3 100644 --- a/TUnit.Assertions/Sources/CollectionAssertionBase.cs +++ b/TUnit.Assertions/Sources/CollectionAssertionBase.cs @@ -44,6 +44,24 @@ private protected CollectionAssertionBase( protected override string GetExpectation() => "collection assertion"; + /// + /// Asserts that the collection is null while preserving collection-specific chaining. + /// + public CollectionAssertionBase IsNull() + { + Context.ExpressionBuilder.Append(".IsNull()"); + return new CollectionNullAssertion(Context); + } + + /// + /// Asserts that the collection is not null while preserving collection-specific chaining. + /// + public CollectionAssertionBase IsNotNull() + { + Context.ExpressionBuilder.Append(".IsNotNull()"); + return new CollectionNotNullAssertion(Context); + } + /// /// Asserts that the collection is of the specified type and returns an assertion on the casted value. /// This allows chaining additional assertions on the typed value. diff --git a/TUnit.Assertions/Sources/DictionaryAssertionBase.cs b/TUnit.Assertions/Sources/DictionaryAssertionBase.cs index 4be375439b..b483172ef5 100644 --- a/TUnit.Assertions/Sources/DictionaryAssertionBase.cs +++ b/TUnit.Assertions/Sources/DictionaryAssertionBase.cs @@ -40,6 +40,24 @@ private protected DictionaryAssertionBase( protected override string GetExpectation() => "dictionary assertion"; + /// + /// Asserts that the dictionary is null while preserving dictionary-specific chaining. + /// + public new DictionaryAssertionBase IsNull() + { + Context.ExpressionBuilder.Append(".IsNull()"); + return new DictionaryNullAssertion(Context); + } + + /// + /// Asserts that the dictionary is not null while preserving dictionary-specific chaining. + /// + public new DictionaryAssertionBase IsNotNull() + { + Context.ExpressionBuilder.Append(".IsNotNull()"); + return new DictionaryNotNullAssertion(Context); + } + /// /// Asserts that the dictionary contains the specified key. /// This instance method enables calling ContainsKey with proper type inference. diff --git a/TUnit.Assertions/Sources/ListAssertionBase.cs b/TUnit.Assertions/Sources/ListAssertionBase.cs index 12365631b9..aca9f29ebc 100644 --- a/TUnit.Assertions/Sources/ListAssertionBase.cs +++ b/TUnit.Assertions/Sources/ListAssertionBase.cs @@ -31,6 +31,24 @@ private protected ListAssertionBase( { } + /// + /// Asserts that the list is null while preserving list-specific chaining. + /// + public new ListAssertionBase IsNull() + { + Context.ExpressionBuilder.Append(".IsNull()"); + return new ListNullAssertion(Context); + } + + /// + /// Asserts that the list is not null while preserving list-specific chaining. + /// + public new ListAssertionBase IsNotNull() + { + Context.ExpressionBuilder.Append(".IsNotNull()"); + return new ListNotNullAssertion(Context); + } + /// /// Asserts that the item at the specified index equals the expected value. /// Example: await Assert.That(list).HasItemAt(0, "expected"); diff --git a/TUnit.Assertions/Sources/MutableDictionaryAssertionBase.cs b/TUnit.Assertions/Sources/MutableDictionaryAssertionBase.cs index 4b35035413..dccb9e2443 100644 --- a/TUnit.Assertions/Sources/MutableDictionaryAssertionBase.cs +++ b/TUnit.Assertions/Sources/MutableDictionaryAssertionBase.cs @@ -38,6 +38,24 @@ private protected MutableDictionaryAssertionBase( protected override string GetExpectation() => "dictionary assertion"; + /// + /// Asserts that the dictionary is null while preserving mutable-dictionary-specific chaining. + /// + public new MutableDictionaryAssertionBase IsNull() + { + Context.ExpressionBuilder.Append(".IsNull()"); + return new MutableDictionaryNullAssertion(Context); + } + + /// + /// Asserts that the dictionary is not null while preserving mutable-dictionary-specific chaining. + /// + public new MutableDictionaryAssertionBase IsNotNull() + { + Context.ExpressionBuilder.Append(".IsNotNull()"); + return new MutableDictionaryNotNullAssertion(Context); + } + /// /// Asserts that the dictionary contains the specified key. /// Example: await Assert.That(dictionary).ContainsKey("key1"); diff --git a/TUnit.Assertions/Sources/ReadOnlyListAssertionBase.cs b/TUnit.Assertions/Sources/ReadOnlyListAssertionBase.cs index 3d7c8db6ca..4a61a42128 100644 --- a/TUnit.Assertions/Sources/ReadOnlyListAssertionBase.cs +++ b/TUnit.Assertions/Sources/ReadOnlyListAssertionBase.cs @@ -33,6 +33,24 @@ private protected ReadOnlyListAssertionBase( context.SetPendingLink(previousAssertion, combinerType); } + /// + /// Asserts that the list is null while preserving read-only-list-specific chaining. + /// + public new ReadOnlyListAssertionBase IsNull() + { + Context.ExpressionBuilder.Append(".IsNull()"); + return new ReadOnlyListNullAssertion(Context); + } + + /// + /// Asserts that the list is not null while preserving read-only-list-specific chaining. + /// + public new ReadOnlyListAssertionBase IsNotNull() + { + Context.ExpressionBuilder.Append(".IsNotNull()"); + return new ReadOnlyListNotNullAssertion(Context); + } + /// /// Asserts that the list has an item at the specified index that equals the expected value. /// Example: await Assert.That(list).HasItemAt(0, "first"); diff --git a/TUnit.Assertions/Sources/SetAssertionBase.cs b/TUnit.Assertions/Sources/SetAssertionBase.cs index 62576cbff1..3b41b64cbd 100644 --- a/TUnit.Assertions/Sources/SetAssertionBase.cs +++ b/TUnit.Assertions/Sources/SetAssertionBase.cs @@ -43,6 +43,24 @@ private protected SetAssertionBase( protected override string GetExpectation() => "set assertion"; + /// + /// Asserts that the set is null while preserving set-specific chaining. + /// + public new SetAssertionBase IsNull() + { + Context.ExpressionBuilder.Append(".IsNull()"); + return new SetNullAssertion(Context, CreateSetAdapter); + } + + /// + /// Asserts that the set is not null while preserving set-specific chaining. + /// + public new SetAssertionBase IsNotNull() + { + Context.ExpressionBuilder.Append(".IsNotNull()"); + return new SetNotNullAssertion(Context, CreateSetAdapter); + } + // ======================================== // Set-specific methods // ======================================== From c08259e94e8709280e1cdc0d6d5de183e4014300 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Thu, 28 May 2026 18:30:03 +0100 Subject: [PATCH 2/5] chore: update PublicAPI snapshots for IsNull/IsNotNull overloads Accept the new IsNull()/IsNotNull() methods added to the seven specialized assertion bases (Collection, List, ReadOnlyList, Dictionary, MutableDictionary, Set, AsyncEnumerable) into the PublicAPI verified snapshots across net8.0, net9.0, net10.0, and net472. --- ...rary_Has_No_API_Changes.DotNet10_0.verified.txt | 14 ++++++++++++++ ...brary_Has_No_API_Changes.DotNet8_0.verified.txt | 14 ++++++++++++++ ...brary_Has_No_API_Changes.DotNet9_0.verified.txt | 14 ++++++++++++++ ..._Library_Has_No_API_Changes.Net4_7.verified.txt | 14 ++++++++++++++ 4 files changed, 56 insertions(+) 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 fc2d114091..137a52f8c2 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 @@ -6187,7 +6187,9 @@ namespace .Sources public .> IsNotAssignableFrom() { } public .> IsNotAssignableTo() { } public . IsNotEmpty() { } + public . IsNotNull() { } public .<., TExpected> IsNotTypeOf() { } + public . IsNull() { } public .<., TExpected> IsTypeOf() { } } public class AsyncEnumerableAssertion : . @@ -6258,7 +6260,9 @@ namespace .Sources public . IsNotAssignableFrom() { } public . IsNotAssignableTo() { } public . IsNotEmpty() { } + public . IsNotNull() { } public . IsNotTypeOf() { } + public . IsNull() { } public . IsOrderedBy( keySelector, [.("keySelector")] string? expression = null) { } public . IsOrderedBy( keySelector, .? comparer, [.("keySelector")] string? selectorExpression = null, [.("comparer")] string? comparerExpression = null) { } public . IsOrderedByDescending( keySelector, [.("keySelector")] string? expression = null) { } @@ -6303,6 +6307,8 @@ namespace .Sources public . DoesNotContainKey(TKey expectedKey, [.("expectedKey")] string? expression = null) { } public . DoesNotContainValue(TValue expectedValue, [.("expectedValue")] string? expression = null) { } protected override string GetExpectation() { } + public new . IsNotNull() { } + public new . IsNull() { } } public class DictionaryAssertion : .<., TKey, TValue>, .<., .> where TKey : notnull @@ -6347,6 +6353,8 @@ namespace .Sources public new . Or { get; } public . FirstItem() { } public . HasItemAt(int index, TItem expected, [.("index")] string? indexExpression = null, [.("expected")] string? expectedExpression = null) { } + public new . IsNotNull() { } + public new . IsNull() { } public . ItemAt(int index, [.("index")] string? expression = null) { } public . LastItem() { } } @@ -6418,6 +6426,8 @@ namespace .Sources public . DoesNotContainKey(TKey expectedKey, [.("expectedKey")] string? expression = null) { } public . DoesNotContainValue(TValue expectedValue, [.("expectedValue")] string? expression = null) { } protected override string GetExpectation() { } + public new . IsNotNull() { } + public new . IsNull() { } } public class MutableDictionaryAssertion : .<., TKey, TValue>, .<., .>, .<., .> where TKey : notnull @@ -6434,6 +6444,8 @@ namespace .Sources public new . Or { get; } public . FirstItem() { } public . HasItemAt(int index, TItem expected, .? comparer = null, [.("index")] string? indexExpression = null, [.("expected")] string? expectedExpression = null) { } + public new . IsNotNull() { } + public new . IsNull() { } public . ItemAt(int index, [.("index")] string? indexExpression = null) { } public . LastItem() { } } @@ -6467,6 +6479,8 @@ namespace .Sources protected abstract . CreateSetAdapter(TSet value); public . DoesNotOverlap(. other, [.("other")] string? expression = null) { } protected override string GetExpectation() { } + public new . IsNotNull() { } + public new . IsNull() { } public . IsProperSubsetOf(. other, [.("other")] string? expression = null) { } public . IsProperSupersetOf(. other, [.("other")] string? expression = null) { } public . IsSubsetOf(. other, [.("other")] 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 8783b78802..c0f574a30e 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 @@ -6110,7 +6110,9 @@ namespace .Sources public .> IsNotAssignableFrom() { } public .> IsNotAssignableTo() { } public . IsNotEmpty() { } + public . IsNotNull() { } public .<., TExpected> IsNotTypeOf() { } + public . IsNull() { } public .<., TExpected> IsTypeOf() { } } public class AsyncEnumerableAssertion : . @@ -6180,7 +6182,9 @@ namespace .Sources public . IsNotAssignableFrom() { } public . IsNotAssignableTo() { } public . IsNotEmpty() { } + public . IsNotNull() { } public . IsNotTypeOf() { } + public . IsNull() { } public . IsOrderedBy( keySelector, [.("keySelector")] string? expression = null) { } public . IsOrderedBy( keySelector, .? comparer, [.("keySelector")] string? selectorExpression = null, [.("comparer")] string? comparerExpression = null) { } public . IsOrderedByDescending( keySelector, [.("keySelector")] string? expression = null) { } @@ -6225,6 +6229,8 @@ namespace .Sources public . DoesNotContainKey(TKey expectedKey, [.("expectedKey")] string? expression = null) { } public . DoesNotContainValue(TValue expectedValue, [.("expectedValue")] string? expression = null) { } protected override string GetExpectation() { } + public new . IsNotNull() { } + public new . IsNull() { } } public class DictionaryAssertion : .<., TKey, TValue>, .<., .> where TKey : notnull @@ -6269,6 +6275,8 @@ namespace .Sources public new . Or { get; } public . FirstItem() { } public . HasItemAt(int index, TItem expected, [.("index")] string? indexExpression = null, [.("expected")] string? expectedExpression = null) { } + public new . IsNotNull() { } + public new . IsNull() { } public . ItemAt(int index, [.("index")] string? expression = null) { } public . LastItem() { } } @@ -6340,6 +6348,8 @@ namespace .Sources public . DoesNotContainKey(TKey expectedKey, [.("expectedKey")] string? expression = null) { } public . DoesNotContainValue(TValue expectedValue, [.("expectedValue")] string? expression = null) { } protected override string GetExpectation() { } + public new . IsNotNull() { } + public new . IsNull() { } } public class MutableDictionaryAssertion : .<., TKey, TValue>, .<., .>, .<., .> where TKey : notnull @@ -6356,6 +6366,8 @@ namespace .Sources public new . Or { get; } public . FirstItem() { } public . HasItemAt(int index, TItem expected, .? comparer = null, [.("index")] string? indexExpression = null, [.("expected")] string? expectedExpression = null) { } + public new . IsNotNull() { } + public new . IsNull() { } public . ItemAt(int index, [.("index")] string? indexExpression = null) { } public . LastItem() { } } @@ -6389,6 +6401,8 @@ namespace .Sources protected abstract . CreateSetAdapter(TSet value); public . DoesNotOverlap(. other, [.("other")] string? expression = null) { } protected override string GetExpectation() { } + public new . IsNotNull() { } + public new . IsNull() { } public . IsProperSubsetOf(. other, [.("other")] string? expression = null) { } public . IsProperSupersetOf(. other, [.("other")] string? expression = null) { } public . IsSubsetOf(. other, [.("other")] 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 ed5f733562..ed8723ca6c 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 @@ -6187,7 +6187,9 @@ namespace .Sources public .> IsNotAssignableFrom() { } public .> IsNotAssignableTo() { } public . IsNotEmpty() { } + public . IsNotNull() { } public .<., TExpected> IsNotTypeOf() { } + public . IsNull() { } public .<., TExpected> IsTypeOf() { } } public class AsyncEnumerableAssertion : . @@ -6258,7 +6260,9 @@ namespace .Sources public . IsNotAssignableFrom() { } public . IsNotAssignableTo() { } public . IsNotEmpty() { } + public . IsNotNull() { } public . IsNotTypeOf() { } + public . IsNull() { } public . IsOrderedBy( keySelector, [.("keySelector")] string? expression = null) { } public . IsOrderedBy( keySelector, .? comparer, [.("keySelector")] string? selectorExpression = null, [.("comparer")] string? comparerExpression = null) { } public . IsOrderedByDescending( keySelector, [.("keySelector")] string? expression = null) { } @@ -6303,6 +6307,8 @@ namespace .Sources public . DoesNotContainKey(TKey expectedKey, [.("expectedKey")] string? expression = null) { } public . DoesNotContainValue(TValue expectedValue, [.("expectedValue")] string? expression = null) { } protected override string GetExpectation() { } + public new . IsNotNull() { } + public new . IsNull() { } } public class DictionaryAssertion : .<., TKey, TValue>, .<., .> where TKey : notnull @@ -6347,6 +6353,8 @@ namespace .Sources public new . Or { get; } public . FirstItem() { } public . HasItemAt(int index, TItem expected, [.("index")] string? indexExpression = null, [.("expected")] string? expectedExpression = null) { } + public new . IsNotNull() { } + public new . IsNull() { } public . ItemAt(int index, [.("index")] string? expression = null) { } public . LastItem() { } } @@ -6418,6 +6426,8 @@ namespace .Sources public . DoesNotContainKey(TKey expectedKey, [.("expectedKey")] string? expression = null) { } public . DoesNotContainValue(TValue expectedValue, [.("expectedValue")] string? expression = null) { } protected override string GetExpectation() { } + public new . IsNotNull() { } + public new . IsNull() { } } public class MutableDictionaryAssertion : .<., TKey, TValue>, .<., .>, .<., .> where TKey : notnull @@ -6434,6 +6444,8 @@ namespace .Sources public new . Or { get; } public . FirstItem() { } public . HasItemAt(int index, TItem expected, .? comparer = null, [.("index")] string? indexExpression = null, [.("expected")] string? expectedExpression = null) { } + public new . IsNotNull() { } + public new . IsNull() { } public . ItemAt(int index, [.("index")] string? indexExpression = null) { } public . LastItem() { } } @@ -6467,6 +6479,8 @@ namespace .Sources protected abstract . CreateSetAdapter(TSet value); public . DoesNotOverlap(. other, [.("other")] string? expression = null) { } protected override string GetExpectation() { } + public new . IsNotNull() { } + public new . IsNull() { } public . IsProperSubsetOf(. other, [.("other")] string? expression = null) { } public . IsProperSupersetOf(. other, [.("other")] string? expression = null) { } public . IsSubsetOf(. other, [.("other")] 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 b29afe65f8..40e20a1d6d 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 @@ -5305,7 +5305,9 @@ namespace .Sources public .> IsNotAssignableFrom() { } public .> IsNotAssignableTo() { } public . IsNotEmpty() { } + public . IsNotNull() { } public .<., TExpected> IsNotTypeOf() { } + public . IsNull() { } public .<., TExpected> IsTypeOf() { } } public class AsyncEnumerableAssertion : . @@ -5375,7 +5377,9 @@ namespace .Sources public . IsNotAssignableFrom() { } public . IsNotAssignableTo() { } public . IsNotEmpty() { } + public . IsNotNull() { } public . IsNotTypeOf() { } + public . IsNull() { } public . IsOrderedBy( keySelector, [.("keySelector")] string? expression = null) { } public . IsOrderedBy( keySelector, .? comparer, [.("keySelector")] string? selectorExpression = null, [.("comparer")] string? comparerExpression = null) { } public . IsOrderedByDescending( keySelector, [.("keySelector")] string? expression = null) { } @@ -5419,6 +5423,8 @@ namespace .Sources public . DoesNotContainKey(TKey expectedKey, [.("expectedKey")] string? expression = null) { } public . DoesNotContainValue(TValue expectedValue, [.("expectedValue")] string? expression = null) { } protected override string GetExpectation() { } + public new . IsNotNull() { } + public new . IsNull() { } } public class DictionaryAssertion : .<., TKey, TValue> where TKey : notnull @@ -5461,6 +5467,8 @@ namespace .Sources public new . Or { get; } public . FirstItem() { } public . HasItemAt(int index, TItem expected, [.("index")] string? indexExpression = null, [.("expected")] string? expectedExpression = null) { } + public new . IsNotNull() { } + public new . IsNull() { } public . ItemAt(int index, [.("index")] string? expression = null) { } public . LastItem() { } } @@ -5486,6 +5494,8 @@ namespace .Sources public . DoesNotContainKey(TKey expectedKey, [.("expectedKey")] string? expression = null) { } public . DoesNotContainValue(TValue expectedValue, [.("expectedValue")] string? expression = null) { } protected override string GetExpectation() { } + public new . IsNotNull() { } + public new . IsNull() { } } public class MutableDictionaryAssertion : .<., TKey, TValue> where TKey : notnull @@ -5500,6 +5510,8 @@ namespace .Sources public new . Or { get; } public . FirstItem() { } public . HasItemAt(int index, TItem expected, .? comparer = null, [.("index")] string? indexExpression = null, [.("expected")] string? expectedExpression = null) { } + public new . IsNotNull() { } + public new . IsNull() { } public . ItemAt(int index, [.("index")] string? indexExpression = null) { } public . LastItem() { } } @@ -5521,6 +5533,8 @@ namespace .Sources protected abstract . CreateSetAdapter(TSet value); public . DoesNotOverlap(. other, [.("other")] string? expression = null) { } protected override string GetExpectation() { } + public new . IsNotNull() { } + public new . IsNull() { } public . IsProperSubsetOf(. other, [.("other")] string? expression = null) { } public . IsProperSupersetOf(. other, [.("other")] string? expression = null) { } public . IsSubsetOf(. other, [.("other")] string? expression = null) { } From 021d38d79f103cadc82ddd2dce559bd42acb6220 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Thu, 28 May 2026 19:24:33 +0100 Subject: [PATCH 3/5] @ refactor(assertions): collapse null-assertion pairs into bool expectNull Addresses PR review: collapse each internal Null/NotNull assertion pair into a single class parameterized by `bool expectNull`, matching the existing ItemAt/LastItem convention. 14 classes -> 8 (public CollectionNotNullAssertion kept for API compatibility). - Consolidate NullCheck into Check(metadata, expectNull) + Expectation(expectNull) - Document why SetNullAssertion still carries adapterFactory (abstract contract) - Add failure-path tests for IsNotNull/IsNull through And/Or chains @ --- .../CollectionAssertionTests.cs | 22 +++ .../Conditions/AsyncEnumerableAssertions.cs | 25 +-- .../Conditions/CollectionNullAssertion.cs | 180 ++++-------------- .../Sources/AsyncEnumerableAssertionBase.cs | 4 +- .../Sources/DictionaryAssertionBase.cs | 4 +- TUnit.Assertions/Sources/ListAssertionBase.cs | 4 +- .../Sources/MutableDictionaryAssertionBase.cs | 4 +- .../Sources/ReadOnlyListAssertionBase.cs | 4 +- TUnit.Assertions/Sources/SetAssertionBase.cs | 4 +- 9 files changed, 72 insertions(+), 179 deletions(-) diff --git a/TUnit.Assertions.Tests/CollectionAssertionTests.cs b/TUnit.Assertions.Tests/CollectionAssertionTests.cs index aba1982a49..24c21cfb10 100644 --- a/TUnit.Assertions.Tests/CollectionAssertionTests.cs +++ b/TUnit.Assertions.Tests/CollectionAssertionTests.cs @@ -129,6 +129,28 @@ public async Task NullAssertions_Preserve_Dictionary_And_Set_Chaining() await Assert.That(set).IsNotNull().And.IsSubsetOf([1, 2]); } + [Test] + public async Task NullAssertions_IsNotNull_Fails_When_Null_Through_Chain() + { + List? nullItems = null; + + await Assert.That(async () => + await Assert.That(nullItems).IsNotNull().And.Contains(1) + ).Throws() + .WithMessageContaining("to not be null"); + } + + [Test] + public async Task NullAssertions_IsNull_Fails_When_NotNull_Through_Chain() + { + var items = new List { 1 }; + + await Assert.That(async () => + await Assert.That(items).IsNull().Or.Contains(99) + ).Throws() + .WithMessageContaining("to be null"); + } + [Test] public async Task Count_WithInnerAssertion_Lambda_Collection() { diff --git a/TUnit.Assertions/Conditions/AsyncEnumerableAssertions.cs b/TUnit.Assertions/Conditions/AsyncEnumerableAssertions.cs index 8e747ad38b..423b66af0b 100644 --- a/TUnit.Assertions/Conditions/AsyncEnumerableAssertions.cs +++ b/TUnit.Assertions/Conditions/AsyncEnumerableAssertions.cs @@ -43,30 +43,13 @@ private static async Task> MaterializeAsync(IAsyncEnumerable protected abstract AssertionResult CheckMaterialized(List items); } -internal class AsyncEnumerableNullAssertion : AsyncEnumerableAssertionBase +internal class AsyncEnumerableNullAssertion(AssertionContext> context, bool expectNull) + : AsyncEnumerableAssertionBase(context) { - public AsyncEnumerableNullAssertion(AssertionContext> context) - : base(context) - { - } - - protected override Task CheckAsync(EvaluationMetadata> metadata) - => NullCheck.CheckIsNull(metadata); - - protected override string GetExpectation() => "to be null"; -} - -internal class AsyncEnumerableNotNullAssertion : AsyncEnumerableAssertionBase -{ - public AsyncEnumerableNotNullAssertion(AssertionContext> context) - : base(context) - { - } - protected override Task CheckAsync(EvaluationMetadata> metadata) - => NullCheck.CheckIsNotNull(metadata); + => NullCheck.Check(metadata, expectNull); - protected override string GetExpectation() => "to not be null"; + protected override string GetExpectation() => NullCheck.Expectation(expectNull); } /// diff --git a/TUnit.Assertions/Conditions/CollectionNullAssertion.cs b/TUnit.Assertions/Conditions/CollectionNullAssertion.cs index 78b5f507ef..9678cb6f3e 100644 --- a/TUnit.Assertions/Conditions/CollectionNullAssertion.cs +++ b/TUnit.Assertions/Conditions/CollectionNullAssertion.cs @@ -18,9 +18,9 @@ public CollectionNullAssertion(AssertionContext context) } protected override Task CheckAsync(EvaluationMetadata metadata) - => NullCheck.CheckIsNull(metadata); + => NullCheck.Check(metadata, expectNull: true); - protected override string GetExpectation() => "to be null"; + protected override string GetExpectation() => NullCheck.Expectation(expectNull: true); } /// @@ -36,198 +36,86 @@ public CollectionNotNullAssertion(AssertionContext context) } protected override Task CheckAsync(EvaluationMetadata metadata) - => NullCheck.CheckIsNotNull(metadata); + => NullCheck.Check(metadata, expectNull: false); - protected override string GetExpectation() => "to not be null"; + protected override string GetExpectation() => NullCheck.Expectation(expectNull: false); } -internal class ListNullAssertion : ListAssertionBase +internal class ListNullAssertion(AssertionContext context, bool expectNull) + : ListAssertionBase(context) where TList : IList { - public ListNullAssertion(AssertionContext context) - : base(context) - { - } - - protected override Task CheckAsync(EvaluationMetadata metadata) - => NullCheck.CheckIsNull(metadata); - - protected override string GetExpectation() => "to be null"; -} - -internal class ListNotNullAssertion : ListAssertionBase - where TList : IList -{ - public ListNotNullAssertion(AssertionContext context) - : base(context) - { - } - protected override Task CheckAsync(EvaluationMetadata metadata) - => NullCheck.CheckIsNotNull(metadata); + => NullCheck.Check(metadata, expectNull); - protected override string GetExpectation() => "to not be null"; + protected override string GetExpectation() => NullCheck.Expectation(expectNull); } -internal class ReadOnlyListNullAssertion : ReadOnlyListAssertionBase +internal class ReadOnlyListNullAssertion(AssertionContext context, bool expectNull) + : ReadOnlyListAssertionBase(context) where TList : IReadOnlyList { - public ReadOnlyListNullAssertion(AssertionContext context) - : base(context) - { - } - protected override Task CheckAsync(EvaluationMetadata metadata) - => NullCheck.CheckIsNull(metadata); + => NullCheck.Check(metadata, expectNull); - protected override string GetExpectation() => "to be null"; + protected override string GetExpectation() => NullCheck.Expectation(expectNull); } -internal class ReadOnlyListNotNullAssertion : ReadOnlyListAssertionBase - where TList : IReadOnlyList -{ - public ReadOnlyListNotNullAssertion(AssertionContext context) - : base(context) - { - } - - protected override Task CheckAsync(EvaluationMetadata metadata) - => NullCheck.CheckIsNotNull(metadata); - - protected override string GetExpectation() => "to not be null"; -} - -internal class DictionaryNullAssertion : DictionaryAssertionBase +internal class DictionaryNullAssertion(AssertionContext context, bool expectNull) + : DictionaryAssertionBase(context) where TDictionary : IReadOnlyDictionary where TKey : notnull { - public DictionaryNullAssertion(AssertionContext context) - : base(context) - { - } - - protected override Task CheckAsync(EvaluationMetadata metadata) - => NullCheck.CheckIsNull(metadata); - - protected override string GetExpectation() => "to be null"; -} - -internal class DictionaryNotNullAssertion : DictionaryAssertionBase - where TDictionary : IReadOnlyDictionary - where TKey : notnull -{ - public DictionaryNotNullAssertion(AssertionContext context) - : base(context) - { - } - - protected override Task CheckAsync(EvaluationMetadata metadata) - => NullCheck.CheckIsNotNull(metadata); - - protected override string GetExpectation() => "to not be null"; -} - -internal class MutableDictionaryNullAssertion : MutableDictionaryAssertionBase - where TDictionary : IDictionary - where TKey : notnull -{ - public MutableDictionaryNullAssertion(AssertionContext context) - : base(context) - { - } - protected override Task CheckAsync(EvaluationMetadata metadata) - => NullCheck.CheckIsNull(metadata); + => NullCheck.Check(metadata, expectNull); - protected override string GetExpectation() => "to be null"; + protected override string GetExpectation() => NullCheck.Expectation(expectNull); } -internal class MutableDictionaryNotNullAssertion : MutableDictionaryAssertionBase +internal class MutableDictionaryNullAssertion(AssertionContext context, bool expectNull) + : MutableDictionaryAssertionBase(context) where TDictionary : IDictionary where TKey : notnull { - public MutableDictionaryNotNullAssertion(AssertionContext context) - : base(context) - { - } - protected override Task CheckAsync(EvaluationMetadata metadata) - => NullCheck.CheckIsNotNull(metadata); + => NullCheck.Check(metadata, expectNull); - protected override string GetExpectation() => "to not be null"; + protected override string GetExpectation() => NullCheck.Expectation(expectNull); } -internal class SetNullAssertion : SetAssertionBase +internal class SetNullAssertion( + AssertionContext context, + Func> adapterFactory, + bool expectNull) + : SetAssertionBase(context) where TSet : IEnumerable { - private readonly Func> _adapterFactory; - - public SetNullAssertion( - AssertionContext context, - Func> adapterFactory) - : base(context) - { - _adapterFactory = adapterFactory; - } - - protected override ISetAdapter CreateSetAdapter(TSet value) => _adapterFactory(value); + // adapterFactory is never invoked on the null-check path (the set is never materialized), + // but SetAssertionBase requires CreateSetAdapter, so it must be supplied. + protected override ISetAdapter CreateSetAdapter(TSet value) => adapterFactory(value); protected override Task CheckAsync(EvaluationMetadata metadata) - => NullCheck.CheckIsNull(metadata); + => NullCheck.Check(metadata, expectNull); - protected override string GetExpectation() => "to be null"; -} - -internal class SetNotNullAssertion : SetAssertionBase - where TSet : IEnumerable -{ - private readonly Func> _adapterFactory; - - public SetNotNullAssertion( - AssertionContext context, - Func> adapterFactory) - : base(context) - { - _adapterFactory = adapterFactory; - } - - protected override ISetAdapter CreateSetAdapter(TSet value) => _adapterFactory(value); - - protected override Task CheckAsync(EvaluationMetadata metadata) - => NullCheck.CheckIsNotNull(metadata); - - protected override string GetExpectation() => "to not be null"; + protected override string GetExpectation() => NullCheck.Expectation(expectNull); } internal static class NullCheck { - public static Task CheckIsNull(EvaluationMetadata metadata) + public static Task Check(EvaluationMetadata metadata, bool expectNull) { if (metadata.Exception != null) { return Task.FromResult(AssertionResult.Failed($"threw {metadata.Exception.GetType().Name}", metadata.Exception)); } - if (metadata.Value is null) + if (expectNull ? metadata.Value is null : metadata.Value is not null) { return AssertionResult._passedTask; } - return Task.FromResult(AssertionResult.Failed("value is not null")); + return Task.FromResult(AssertionResult.Failed(expectNull ? "value is not null" : "value is null")); } - public static Task CheckIsNotNull(EvaluationMetadata metadata) - { - if (metadata.Exception != null) - { - return Task.FromResult(AssertionResult.Failed($"threw {metadata.Exception.GetType().Name}", metadata.Exception)); - } - - if (metadata.Value is not null) - { - return AssertionResult._passedTask; - } - - return Task.FromResult(AssertionResult.Failed("value is null")); - } + public static string Expectation(bool expectNull) => expectNull ? "to be null" : "to not be null"; } diff --git a/TUnit.Assertions/Sources/AsyncEnumerableAssertionBase.cs b/TUnit.Assertions/Sources/AsyncEnumerableAssertionBase.cs index 5481870ef4..91c8c9e13e 100644 --- a/TUnit.Assertions/Sources/AsyncEnumerableAssertionBase.cs +++ b/TUnit.Assertions/Sources/AsyncEnumerableAssertionBase.cs @@ -45,7 +45,7 @@ private protected AsyncEnumerableAssertionBase( public AsyncEnumerableAssertionBase IsNull() { Context.ExpressionBuilder.Append(".IsNull()"); - return new AsyncEnumerableNullAssertion(Context); + return new AsyncEnumerableNullAssertion(Context, expectNull: true); } /// @@ -54,7 +54,7 @@ public AsyncEnumerableAssertionBase IsNull() public AsyncEnumerableAssertionBase IsNotNull() { Context.ExpressionBuilder.Append(".IsNotNull()"); - return new AsyncEnumerableNotNullAssertion(Context); + return new AsyncEnumerableNullAssertion(Context, expectNull: false); } /// diff --git a/TUnit.Assertions/Sources/DictionaryAssertionBase.cs b/TUnit.Assertions/Sources/DictionaryAssertionBase.cs index b483172ef5..aea85145de 100644 --- a/TUnit.Assertions/Sources/DictionaryAssertionBase.cs +++ b/TUnit.Assertions/Sources/DictionaryAssertionBase.cs @@ -46,7 +46,7 @@ private protected DictionaryAssertionBase( public new DictionaryAssertionBase IsNull() { Context.ExpressionBuilder.Append(".IsNull()"); - return new DictionaryNullAssertion(Context); + return new DictionaryNullAssertion(Context, expectNull: true); } /// @@ -55,7 +55,7 @@ private protected DictionaryAssertionBase( public new DictionaryAssertionBase IsNotNull() { Context.ExpressionBuilder.Append(".IsNotNull()"); - return new DictionaryNotNullAssertion(Context); + return new DictionaryNullAssertion(Context, expectNull: false); } /// diff --git a/TUnit.Assertions/Sources/ListAssertionBase.cs b/TUnit.Assertions/Sources/ListAssertionBase.cs index aca9f29ebc..18218dc430 100644 --- a/TUnit.Assertions/Sources/ListAssertionBase.cs +++ b/TUnit.Assertions/Sources/ListAssertionBase.cs @@ -37,7 +37,7 @@ private protected ListAssertionBase( public new ListAssertionBase IsNull() { Context.ExpressionBuilder.Append(".IsNull()"); - return new ListNullAssertion(Context); + return new ListNullAssertion(Context, expectNull: true); } /// @@ -46,7 +46,7 @@ private protected ListAssertionBase( public new ListAssertionBase IsNotNull() { Context.ExpressionBuilder.Append(".IsNotNull()"); - return new ListNotNullAssertion(Context); + return new ListNullAssertion(Context, expectNull: false); } /// diff --git a/TUnit.Assertions/Sources/MutableDictionaryAssertionBase.cs b/TUnit.Assertions/Sources/MutableDictionaryAssertionBase.cs index dccb9e2443..c94d8a73ba 100644 --- a/TUnit.Assertions/Sources/MutableDictionaryAssertionBase.cs +++ b/TUnit.Assertions/Sources/MutableDictionaryAssertionBase.cs @@ -44,7 +44,7 @@ private protected MutableDictionaryAssertionBase( public new MutableDictionaryAssertionBase IsNull() { Context.ExpressionBuilder.Append(".IsNull()"); - return new MutableDictionaryNullAssertion(Context); + return new MutableDictionaryNullAssertion(Context, expectNull: true); } /// @@ -53,7 +53,7 @@ private protected MutableDictionaryAssertionBase( public new MutableDictionaryAssertionBase IsNotNull() { Context.ExpressionBuilder.Append(".IsNotNull()"); - return new MutableDictionaryNotNullAssertion(Context); + return new MutableDictionaryNullAssertion(Context, expectNull: false); } /// diff --git a/TUnit.Assertions/Sources/ReadOnlyListAssertionBase.cs b/TUnit.Assertions/Sources/ReadOnlyListAssertionBase.cs index 4a61a42128..d4cd240574 100644 --- a/TUnit.Assertions/Sources/ReadOnlyListAssertionBase.cs +++ b/TUnit.Assertions/Sources/ReadOnlyListAssertionBase.cs @@ -39,7 +39,7 @@ private protected ReadOnlyListAssertionBase( public new ReadOnlyListAssertionBase IsNull() { Context.ExpressionBuilder.Append(".IsNull()"); - return new ReadOnlyListNullAssertion(Context); + return new ReadOnlyListNullAssertion(Context, expectNull: true); } /// @@ -48,7 +48,7 @@ private protected ReadOnlyListAssertionBase( public new ReadOnlyListAssertionBase IsNotNull() { Context.ExpressionBuilder.Append(".IsNotNull()"); - return new ReadOnlyListNotNullAssertion(Context); + return new ReadOnlyListNullAssertion(Context, expectNull: false); } /// diff --git a/TUnit.Assertions/Sources/SetAssertionBase.cs b/TUnit.Assertions/Sources/SetAssertionBase.cs index 3b41b64cbd..a482818907 100644 --- a/TUnit.Assertions/Sources/SetAssertionBase.cs +++ b/TUnit.Assertions/Sources/SetAssertionBase.cs @@ -49,7 +49,7 @@ private protected SetAssertionBase( public new SetAssertionBase IsNull() { Context.ExpressionBuilder.Append(".IsNull()"); - return new SetNullAssertion(Context, CreateSetAdapter); + return new SetNullAssertion(Context, CreateSetAdapter, expectNull: true); } /// @@ -58,7 +58,7 @@ private protected SetAssertionBase( public new SetAssertionBase IsNotNull() { Context.ExpressionBuilder.Append(".IsNotNull()"); - return new SetNotNullAssertion(Context, CreateSetAdapter); + return new SetNullAssertion(Context, CreateSetAdapter, expectNull: false); } // ======================================== From 7dacd23d815a9ae206a4b28e9ebf258cc84c3b74 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Thu, 28 May 2026 19:51:43 +0100 Subject: [PATCH 4/5] @ refactor(assertions): address review on null-assertion chaining (#6008) - Remove dead IsNotNull extension method shadowed by the instance method; base CollectionAssertionBase.IsNull/IsNotNull now return the concrete CollectionNullAssertion/CollectionNotNullAssertion types, restoring the specific return type the extension provided. - Make CollectionNullAssertion public for symmetry with CollectionNotNullAssertion. - Document why null-check methods use `new` shadowing rather than virtual/override (covariant returns are unsafe on the netstandard2.0 target). - Update public API snapshots. @ --- .../Conditions/CollectionNullAssertion.cs | 2 +- .../Extensions/AssertionExtensions.cs | 16 ---------------- .../Sources/CollectionAssertionBase.cs | 9 +++++++-- ...ry_Has_No_API_Changes.DotNet10_0.verified.txt | 9 +++++++-- ...ary_Has_No_API_Changes.DotNet8_0.verified.txt | 9 +++++++-- ...ary_Has_No_API_Changes.DotNet9_0.verified.txt | 9 +++++++-- ...ibrary_Has_No_API_Changes.Net4_7.verified.txt | 9 +++++++-- 7 files changed, 36 insertions(+), 27 deletions(-) diff --git a/TUnit.Assertions/Conditions/CollectionNullAssertion.cs b/TUnit.Assertions/Conditions/CollectionNullAssertion.cs index 9678cb6f3e..2924d85918 100644 --- a/TUnit.Assertions/Conditions/CollectionNullAssertion.cs +++ b/TUnit.Assertions/Conditions/CollectionNullAssertion.cs @@ -9,7 +9,7 @@ namespace TUnit.Assertions.Conditions; /// Asserts that a collection is null, preserving collection type information. /// Extends CollectionAssertionBase to ensure .And and .Or return collection-specific continuations. /// -internal class CollectionNullAssertion : CollectionAssertionBase +public class CollectionNullAssertion : CollectionAssertionBase where TCollection : IEnumerable { public CollectionNullAssertion(AssertionContext context) diff --git a/TUnit.Assertions/Extensions/AssertionExtensions.cs b/TUnit.Assertions/Extensions/AssertionExtensions.cs index d80570af2c..212a334294 100644 --- a/TUnit.Assertions/Extensions/AssertionExtensions.cs +++ b/TUnit.Assertions/Extensions/AssertionExtensions.cs @@ -46,22 +46,6 @@ public static NotNullAssertion IsNotNull( return new NotNullAssertion(mappedContext); } - /// - /// Asserts that a collection is not null, preserving collection type information. - /// Returns a collection-aware assertion that maintains TItem type for proper chaining. - /// This overload enables: Assert.That(collection).IsNotNull().And.Contains(x => predicate). - /// - public static CollectionNotNullAssertion IsNotNull( - this CollectionAssertionBase source) - where TCollection : class, IEnumerable - { - var assertionSource = (IAssertionSource)source; - assertionSource.Context.ExpressionBuilder.Append(".IsNotNull()"); - // Map from TCollection? to TCollection (nullable to non-nullable) - var mappedContext = assertionSource.Context.Map((TCollection? v) => v!); - return new CollectionNotNullAssertion(mappedContext); - } - /// /// Alias for IsEqualTo - asserts that the value is equal to the expected value. /// Works with assertions, And, and Or continuations! diff --git a/TUnit.Assertions/Sources/CollectionAssertionBase.cs b/TUnit.Assertions/Sources/CollectionAssertionBase.cs index 40798487c3..7a8de7ae9a 100644 --- a/TUnit.Assertions/Sources/CollectionAssertionBase.cs +++ b/TUnit.Assertions/Sources/CollectionAssertionBase.cs @@ -44,10 +44,15 @@ private protected CollectionAssertionBase( protected override string GetExpectation() => "collection assertion"; + // Subclasses hide IsNull/IsNotNull with `public new` to narrow the return type to their own + // base (e.g. ListAssertionBase). This is the same idiom used by And/Or below; virtual+override + // with covariant returns is avoided because covariant returns are not honored at runtime on the + // netstandard2.0 target consumed by .NET Framework. + /// /// Asserts that the collection is null while preserving collection-specific chaining. /// - public CollectionAssertionBase IsNull() + public CollectionNullAssertion IsNull() { Context.ExpressionBuilder.Append(".IsNull()"); return new CollectionNullAssertion(Context); @@ -56,7 +61,7 @@ public CollectionAssertionBase IsNull() /// /// Asserts that the collection is not null while preserving collection-specific chaining. /// - public CollectionAssertionBase IsNotNull() + public CollectionNotNullAssertion IsNotNull() { Context.ExpressionBuilder.Append(".IsNotNull()"); return new CollectionNotNullAssertion(Context); 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 137a52f8c2..fa27de7d2a 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 @@ -922,6 +922,13 @@ namespace .Conditions protected override .<.> CheckAsync(. metadata) { } protected override string GetExpectation() { } } + public class CollectionNullAssertion : . + where TCollection : . + { + public CollectionNullAssertion(. context) { } + protected override .<.> CheckAsync(. metadata) { } + protected override string GetExpectation() { } + } public abstract class ComparerBasedAssertion : . { protected ComparerBasedAssertion(. context) { } @@ -2724,8 +2731,6 @@ namespace .Extensions where TValue : class { } public static . IsNotNull(this . source) where TValue : struct { } - public static . IsNotNull(this . source) - where TCollection : class, . { } public static ..IsNotParsableIntoAssertion IsNotParsableInto<[.(..None | ..PublicMethods | ..Interfaces)] T>(this . source) { } public static . IsOfType(this . source, expectedType, [.("expectedType")] string? expression = null) { } public static ..IsParsableIntoAssertion IsParsableInto<[.(..None | ..PublicMethods | ..Interfaces)] T>(this . source) { } 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 c0f574a30e..55d8de00de 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 @@ -905,6 +905,13 @@ namespace .Conditions protected override .<.> CheckAsync(. metadata) { } protected override string GetExpectation() { } } + public class CollectionNullAssertion : . + where TCollection : . + { + public CollectionNullAssertion(. context) { } + protected override .<.> CheckAsync(. metadata) { } + protected override string GetExpectation() { } + } public abstract class ComparerBasedAssertion : . { protected ComparerBasedAssertion(. context) { } @@ -2703,8 +2710,6 @@ namespace .Extensions where TValue : class { } public static . IsNotNull(this . source) where TValue : struct { } - public static . IsNotNull(this . source) - where TCollection : class, . { } public static ..IsNotParsableIntoAssertion IsNotParsableInto<[.(..None | ..PublicMethods | ..Interfaces)] T>(this . source) { } public static . IsOfType(this . source, expectedType, [.("expectedType")] string? expression = null) { } public static ..IsParsableIntoAssertion IsParsableInto<[.(..None | ..PublicMethods | ..Interfaces)] T>(this . source) { } 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 ed8723ca6c..1bdf38ff56 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 @@ -922,6 +922,13 @@ namespace .Conditions protected override .<.> CheckAsync(. metadata) { } protected override string GetExpectation() { } } + public class CollectionNullAssertion : . + where TCollection : . + { + public CollectionNullAssertion(. context) { } + protected override .<.> CheckAsync(. metadata) { } + protected override string GetExpectation() { } + } public abstract class ComparerBasedAssertion : . { protected ComparerBasedAssertion(. context) { } @@ -2724,8 +2731,6 @@ namespace .Extensions where TValue : class { } public static . IsNotNull(this . source) where TValue : struct { } - public static . IsNotNull(this . source) - where TCollection : class, . { } public static ..IsNotParsableIntoAssertion IsNotParsableInto<[.(..None | ..PublicMethods | ..Interfaces)] T>(this . source) { } public static . IsOfType(this . source, expectedType, [.("expectedType")] string? expression = null) { } public static ..IsParsableIntoAssertion IsParsableInto<[.(..None | ..PublicMethods | ..Interfaces)] T>(this . source) { } 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 40e20a1d6d..5e7ab6ae0c 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 @@ -762,6 +762,13 @@ namespace .Conditions protected override .<.> CheckAsync(. metadata) { } protected override string GetExpectation() { } } + public class CollectionNullAssertion : . + where TCollection : . + { + public CollectionNullAssertion(. context) { } + protected override .<.> CheckAsync(. metadata) { } + protected override string GetExpectation() { } + } public abstract class ComparerBasedAssertion : . { protected ComparerBasedAssertion(. context) { } @@ -2429,8 +2436,6 @@ namespace .Extensions where TValue : class { } public static . IsNotNull(this . source) where TValue : struct { } - public static . IsNotNull(this . source) - where TCollection : class, . { } public static ..IsNotParsableIntoAssertion IsNotParsableInto(this . source) { } public static . IsOfType(this . source, expectedType, [.("expectedType")] string? expression = null) { } public static ..IsParsableIntoAssertion IsParsableInto(this . source) { } From 086c190b99521701d40f5df37123bab823019c6d Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Thu, 28 May 2026 20:20:08 +0100 Subject: [PATCH 5/5] refactor(assertions): collapse collection null pair + align IsNotNull message Address review on PR #6008: - Merge public CollectionNullAssertion/CollectionNotNullAssertion into a single internal CollectionNullAssertion(context, bool expectNull), matching the consolidated pattern already used by the List/ ReadOnlyList/Dictionary/Set internal null assertions. IsNull()/IsNotNull() now return the CollectionAssertionBase type, halving the added public API. - Align NotNullAssertion's exception branch to "threw {Type}" (carrying the exception) to match NullCheck and the Is(Not)Default family, instead of the misleading "received null". - Update PublicAPI snapshots for all four TFMs. --- .../Conditions/CollectionNullAssertion.cs | 32 +++---------------- TUnit.Assertions/Conditions/NullAssertion.cs | 2 +- .../Sources/CollectionAssertionBase.cs | 8 ++--- ...Has_No_API_Changes.DotNet10_0.verified.txt | 14 -------- ..._Has_No_API_Changes.DotNet8_0.verified.txt | 14 -------- ..._Has_No_API_Changes.DotNet9_0.verified.txt | 14 -------- ...ary_Has_No_API_Changes.Net4_7.verified.txt | 14 -------- 7 files changed, 10 insertions(+), 88 deletions(-) diff --git a/TUnit.Assertions/Conditions/CollectionNullAssertion.cs b/TUnit.Assertions/Conditions/CollectionNullAssertion.cs index 2924d85918..38530ef317 100644 --- a/TUnit.Assertions/Conditions/CollectionNullAssertion.cs +++ b/TUnit.Assertions/Conditions/CollectionNullAssertion.cs @@ -6,39 +6,17 @@ namespace TUnit.Assertions.Conditions; /// -/// Asserts that a collection is null, preserving collection type information. +/// Asserts that a collection is null (or not null), preserving collection type information. /// Extends CollectionAssertionBase to ensure .And and .Or return collection-specific continuations. /// -public class CollectionNullAssertion : CollectionAssertionBase +internal class CollectionNullAssertion(AssertionContext context, bool expectNull) + : CollectionAssertionBase(context) where TCollection : IEnumerable { - public CollectionNullAssertion(AssertionContext context) - : base(context) - { - } - protected override Task CheckAsync(EvaluationMetadata metadata) - => NullCheck.Check(metadata, expectNull: true); - - protected override string GetExpectation() => NullCheck.Expectation(expectNull: true); -} - -/// -/// Asserts that a collection is not null, preserving collection type information. -/// Extends CollectionAssertionBase to ensure .And and .Or return collection-specific continuations. -/// -public class CollectionNotNullAssertion : CollectionAssertionBase - where TCollection : IEnumerable -{ - public CollectionNotNullAssertion(AssertionContext context) - : base(context) - { - } - - protected override Task CheckAsync(EvaluationMetadata metadata) - => NullCheck.Check(metadata, expectNull: false); + => NullCheck.Check(metadata, expectNull); - protected override string GetExpectation() => NullCheck.Expectation(expectNull: false); + protected override string GetExpectation() => NullCheck.Expectation(expectNull); } internal class ListNullAssertion(AssertionContext context, bool expectNull) diff --git a/TUnit.Assertions/Conditions/NullAssertion.cs b/TUnit.Assertions/Conditions/NullAssertion.cs index 2a6c15a742..251a11077e 100644 --- a/TUnit.Assertions/Conditions/NullAssertion.cs +++ b/TUnit.Assertions/Conditions/NullAssertion.cs @@ -20,7 +20,7 @@ protected override Task CheckAsync(EvaluationMetadata m { if (metadata.Exception != null) { - return Task.FromResult(AssertionResult.Failed("received null")); + return Task.FromResult(AssertionResult.Failed($"threw {metadata.Exception.GetType().Name}", metadata.Exception)); } var value = metadata.Value; diff --git a/TUnit.Assertions/Sources/CollectionAssertionBase.cs b/TUnit.Assertions/Sources/CollectionAssertionBase.cs index 7a8de7ae9a..4490a521af 100644 --- a/TUnit.Assertions/Sources/CollectionAssertionBase.cs +++ b/TUnit.Assertions/Sources/CollectionAssertionBase.cs @@ -52,19 +52,19 @@ private protected CollectionAssertionBase( /// /// Asserts that the collection is null while preserving collection-specific chaining. /// - public CollectionNullAssertion IsNull() + public CollectionAssertionBase IsNull() { Context.ExpressionBuilder.Append(".IsNull()"); - return new CollectionNullAssertion(Context); + return new CollectionNullAssertion(Context, expectNull: true); } /// /// Asserts that the collection is not null while preserving collection-specific chaining. /// - public CollectionNotNullAssertion IsNotNull() + public CollectionAssertionBase IsNotNull() { Context.ExpressionBuilder.Append(".IsNotNull()"); - return new CollectionNotNullAssertion(Context); + return new CollectionNullAssertion(Context, expectNull: false); } /// 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 fa27de7d2a..3fd6de6860 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 @@ -915,20 +915,6 @@ namespace .Conditions } public class CollectionMemberAssertionAdapter : . where TCollection : . { } - public class CollectionNotNullAssertion : . - where TCollection : . - { - public CollectionNotNullAssertion(. context) { } - protected override .<.> CheckAsync(. metadata) { } - protected override string GetExpectation() { } - } - public class CollectionNullAssertion : . - where TCollection : . - { - public CollectionNullAssertion(. context) { } - protected override .<.> CheckAsync(. metadata) { } - protected override string GetExpectation() { } - } public abstract class ComparerBasedAssertion : . { protected ComparerBasedAssertion(. context) { } 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 55d8de00de..abbcb81286 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 @@ -898,20 +898,6 @@ namespace .Conditions } public class CollectionMemberAssertionAdapter : . where TCollection : . { } - public class CollectionNotNullAssertion : . - where TCollection : . - { - public CollectionNotNullAssertion(. context) { } - protected override .<.> CheckAsync(. metadata) { } - protected override string GetExpectation() { } - } - public class CollectionNullAssertion : . - where TCollection : . - { - public CollectionNullAssertion(. context) { } - protected override .<.> CheckAsync(. metadata) { } - protected override string GetExpectation() { } - } public abstract class ComparerBasedAssertion : . { protected ComparerBasedAssertion(. context) { } 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 1bdf38ff56..adb56dfc3a 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 @@ -915,20 +915,6 @@ namespace .Conditions } public class CollectionMemberAssertionAdapter : . where TCollection : . { } - public class CollectionNotNullAssertion : . - where TCollection : . - { - public CollectionNotNullAssertion(. context) { } - protected override .<.> CheckAsync(. metadata) { } - protected override string GetExpectation() { } - } - public class CollectionNullAssertion : . - where TCollection : . - { - public CollectionNullAssertion(. context) { } - protected override .<.> CheckAsync(. metadata) { } - protected override string GetExpectation() { } - } public abstract class ComparerBasedAssertion : . { protected ComparerBasedAssertion(. context) { } 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 5e7ab6ae0c..3db6f0c9ae 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 @@ -755,20 +755,6 @@ namespace .Conditions } public class CollectionMemberAssertionAdapter : . where TCollection : . { } - public class CollectionNotNullAssertion : . - where TCollection : . - { - public CollectionNotNullAssertion(. context) { } - protected override .<.> CheckAsync(. metadata) { } - protected override string GetExpectation() { } - } - public class CollectionNullAssertion : . - where TCollection : . - { - public CollectionNullAssertion(. context) { } - protected override .<.> CheckAsync(. metadata) { } - protected override string GetExpectation() { } - } public abstract class ComparerBasedAssertion : . { protected ComparerBasedAssertion(. context) { }