diff --git a/.editorconfig b/.editorconfig index 77ae61aed88..c5845809e5e 100644 --- a/.editorconfig +++ b/.editorconfig @@ -148,3 +148,7 @@ indent_style = unset insert_final_newline = false tab_width = unset trim_trailing_whitespace = false + +# Keep the predicate nullability regression a compile-time failure (#7304). +[tests/TUnit.Mocks.Tests/PredicateNullabilityTests.cs] +dotnet_diagnostic.CS8602.severity = error diff --git a/docs/docs/writing-tests/mocking/argument-matchers.md b/docs/docs/writing-tests/mocking/argument-matchers.md index c73b5d09aa0..4cd55ad162f 100644 --- a/docs/docs/writing-tests/mocking/argument-matchers.md +++ b/docs/docs/writing-tests/mocking/argument-matchers.md @@ -111,6 +111,10 @@ mock.GetUser(Is(id => id > 0)).Returns(new User("Valid")); mock.GetUser(Is(id => id <= 0)).Throws(); ``` +For a non-nullable reference type, the predicate receives `T`, not `T?`, so `Is(u => u.Name == "Alice")` needs no null guard. Use `Is(u => ...)` when the predicate should handle null itself, including when matching a parameter declared as `User?`. + +Nullability annotations do not distinguish `Is` from `Is` at runtime: both pass null to the predicate. A predicate that explicitly accepts null can match it in either form. All predicate exceptions propagate, including `NullReferenceException` when a null argument is dereferenced. Use an explicit null guard, such as `Is(u => u is not null && u.Name == "Alice")`, when null should be a non-match. + ### Inline Lambda Predicates You can write lambda predicates directly in mock setup and verification calls — no `Is()` wrapper needed: diff --git a/src/TUnit.Mocks/Arguments/Arg.cs b/src/TUnit.Mocks/Arguments/Arg.cs index f1a643385b3..00d8b6507e4 100644 --- a/src/TUnit.Mocks/Arguments/Arg.cs +++ b/src/TUnit.Mocks/Arguments/Arg.cs @@ -26,7 +26,14 @@ public static class Arg public static Arg Is(T value) => new(new ExactMatcher(value)); /// Matches when the predicate returns true for the actual argument. - public static Arg Is(Func predicate) => new(new PredicateMatcher(predicate)); + /// + /// For a non-nullable reference type, the predicate receives , so it can + /// dereference its argument without a null guard. Use Is<T?>(...) when the predicate + /// should handle a null argument itself. Both forms pass null to the predicate at runtime. + /// Predicate exceptions propagate, including when the argument is null. Use an explicit null + /// guard if null should be a non-match. + /// + public static Arg Is(Func predicate) => new(new PredicateMatcher(predicate)); /// Matches only when the argument is null. Supports reference types and nullable value types. public static Arg IsNull() => new(NullMatcher.Instance); diff --git a/src/TUnit.Mocks/Arguments/ArgOfT.cs b/src/TUnit.Mocks/Arguments/ArgOfT.cs index 5866257e5bb..a9358898b5d 100644 --- a/src/TUnit.Mocks/Arguments/ArgOfT.cs +++ b/src/TUnit.Mocks/Arguments/ArgOfT.cs @@ -41,7 +41,7 @@ public Arg(IArgumentMatcher matcher) /// Implicitly converts a predicate to an using predicate matching. /// The predicate that determines whether an argument matches. - public static implicit operator Arg(Func predicate) => new(new PredicateMatcher(predicate!)); + public static implicit operator Arg(Func predicate) => new(new PredicateMatcher(predicate)); public static implicit operator Arg(AnyArg _) => new(AnyMatcher.Instance); diff --git a/src/TUnit.Mocks/Matchers/PredicateMatcher.cs b/src/TUnit.Mocks/Matchers/PredicateMatcher.cs index d5cd82c14e9..f004aaee1c3 100644 --- a/src/TUnit.Mocks/Matchers/PredicateMatcher.cs +++ b/src/TUnit.Mocks/Matchers/PredicateMatcher.cs @@ -7,14 +7,14 @@ namespace TUnit.Mocks.Matchers; /// internal sealed class PredicateMatcher : IArgumentMatcher { - private readonly Func _predicate; + private readonly Func _predicate; - public PredicateMatcher(Func predicate) + public PredicateMatcher(Func predicate) { _predicate = predicate ?? throw new ArgumentNullException(nameof(predicate)); } - public bool Matches(T? value) => _predicate(value); + public bool Matches(T? value) => _predicate(value!); public bool Matches(object? value) { diff --git a/tests/TUnit.Mocks.Tests/PredicateNullabilityTests.cs b/tests/TUnit.Mocks.Tests/PredicateNullabilityTests.cs new file mode 100644 index 00000000000..b39d74ef1c4 --- /dev/null +++ b/tests/TUnit.Mocks.Tests/PredicateNullabilityTests.cs @@ -0,0 +1,92 @@ +using TUnit.Mocks; +using TUnit.Mocks.Arguments; + +namespace TUnit.Mocks.Tests; + +public record PredicateUser(string Name); + +public interface IPredicateUserRepo +{ + string Save(PredicateUser user); +} + +public interface INullablePredicateUserRepo +{ + string Save(PredicateUser? user); +} + +/// +/// Regression tests for #7304: Is<T>(predicate) passes T (not T?) for a +/// non-nullable reference type, so predicates can dereference their argument without a null guard. +/// +public class PredicateNullabilityTests +{ + [Test] + public async Task Predicate_For_NonNullable_Reference_Type_Dereferences_Without_Null_Guard() + { + var mock = IPredicateUserRepo.Mock(); + mock.Save(Is(u => u.Name == "Alice")).Returns("alice"); + + IPredicateUserRepo repo = mock.Object; + + await Assert.That(repo.Save(new PredicateUser("Alice"))).IsEqualTo("alice"); + await Assert.That(repo.Save(new PredicateUser("Bob"))).IsNotEqualTo("alice"); + } + + [Test] + public async Task Null_Argument_Dereference_Propagates_Exception() + { + var mock = IPredicateUserRepo.Mock(); + mock.Save(Is(u => u.Name == "Alice")).Returns("alice"); + + IPredicateUserRepo repo = mock.Object; + + await Assert.That(() => repo.Save(null!)).Throws(); + } + + [Test] + public async Task Null_Argument_Does_Not_Match_Explicit_Null_Guard() + { + var mock = INullablePredicateUserRepo.Mock(); + mock.Save(Is(u => u is not null && u.Name == "Alice")).Returns("alice"); + + INullablePredicateUserRepo repo = mock.Object; + + await Assert.That(repo.Save(null)).IsEqualTo(string.Empty); + } + + [Test] + public async Task Null_Argument_Preserves_Unrelated_Predicate_Exception() + { + var mock = INullablePredicateUserRepo.Mock(); + var failure = new InvalidOperationException("from predicate"); + mock.Save(Is(u => throw failure)).Returns("never"); + + INullablePredicateUserRepo repo = mock.Object; + + await Assert.That(() => repo.Save(null)).Throws(); + } + + [Test] + public async Task Nullable_Predicate_Can_Match_Null() + { + var mock = INullablePredicateUserRepo.Mock(); + mock.Save(Is(u => u is null)).Returns("null user"); + + INullablePredicateUserRepo repo = mock.Object; + + await Assert.That(repo.Save(null)).IsEqualTo("null user"); + await Assert.That(repo.Save(new PredicateUser("Alice"))).IsNotEqualTo("null user"); + } + + [Test] + public async Task Predicate_Exception_For_NonNull_Argument_Still_Propagates() + { + var mock = IPredicateUserRepo.Mock(); + mock.Save(Is(u => throw new NullReferenceException("from predicate"))).Returns("never"); + + IPredicateUserRepo repo = mock.Object; + + await Assert.That(() => repo.Save(new PredicateUser("Alice"))).Throws(); + } +}