Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 4 additions & 0 deletions docs/docs/writing-tests/mocking/argument-matchers.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,10 @@ mock.GetUser(Is<int>(id => id > 0)).Returns(new User("Valid"));
mock.GetUser(Is<int>(id => id <= 0)).Throws<ArgumentException>();
```

For a non-nullable reference type, the predicate receives `T`, not `T?`, so `Is<User>(u => u.Name == "Alice")` needs no null guard. Use `Is<User?>(u => ...)` when the predicate should handle null itself, including when matching a parameter declared as `User?`.

Nullability annotations do not distinguish `Is<User>` from `Is<User?>` 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<User?>(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<T>()` wrapper needed:
Expand Down
9 changes: 8 additions & 1 deletion src/TUnit.Mocks/Arguments/Arg.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,14 @@ public static class Arg
public static Arg<T> Is<T>(T value) => new(new ExactMatcher<T>(value));

/// <summary>Matches when the predicate returns true for the actual argument.</summary>
public static Arg<T> Is<T>(Func<T?, bool> predicate) => new(new PredicateMatcher<T>(predicate));
/// <remarks>
/// For a non-nullable reference type, the predicate receives <typeparamref name="T"/>, so it can
/// dereference its argument without a null guard. Use <c>Is&lt;T?&gt;(...)</c> 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.
/// </remarks>
public static Arg<T> Is<T>(Func<T, bool> predicate) => new(new PredicateMatcher<T>(predicate));

/// <summary>Matches only when the argument is null. Supports reference types and nullable value types.</summary>
public static Arg<T> IsNull<T>() => new(NullMatcher<T>.Instance);
Expand Down
2 changes: 1 addition & 1 deletion src/TUnit.Mocks/Arguments/ArgOfT.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ public Arg(IArgumentMatcher matcher)

/// <summary>Implicitly converts a predicate to an <see cref="Arg{T}"/> using predicate matching.</summary>
/// <param name="predicate">The predicate that determines whether an argument matches.</param>
public static implicit operator Arg<T>(Func<T, bool> predicate) => new(new PredicateMatcher<T>(predicate!));
public static implicit operator Arg<T>(Func<T, bool> predicate) => new(new PredicateMatcher<T>(predicate));

public static implicit operator Arg<T>(AnyArg _) => new(AnyMatcher<T>.Instance);

Expand Down
6 changes: 3 additions & 3 deletions src/TUnit.Mocks/Matchers/PredicateMatcher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,14 @@ namespace TUnit.Mocks.Matchers;
/// </summary>
internal sealed class PredicateMatcher<T> : IArgumentMatcher<T>
{
private readonly Func<T?, bool> _predicate;
private readonly Func<T, bool> _predicate;

public PredicateMatcher(Func<T?, bool> predicate)
public PredicateMatcher(Func<T, bool> 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)
{
Expand Down
92 changes: 92 additions & 0 deletions tests/TUnit.Mocks.Tests/PredicateNullabilityTests.cs
Original file line number Diff line number Diff line change
@@ -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);
}

/// <summary>
/// Regression tests for #7304: <c>Is&lt;T&gt;(predicate)</c> passes <c>T</c> (not <c>T?</c>) for a
/// non-nullable reference type, so predicates can dereference their argument without a null guard.
/// </summary>
public class PredicateNullabilityTests
{
[Test]
public async Task Predicate_For_NonNullable_Reference_Type_Dereferences_Without_Null_Guard()
{
var mock = IPredicateUserRepo.Mock();
mock.Save(Is<PredicateUser>(u => u.Name == "Alice")).Returns("alice");
Comment thread
thomhurst marked this conversation as resolved.
Resolved

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<PredicateUser>(u => u.Name == "Alice")).Returns("alice");

IPredicateUserRepo repo = mock.Object;

await Assert.That(() => repo.Save(null!)).Throws<NullReferenceException>();
}

[Test]
public async Task Null_Argument_Does_Not_Match_Explicit_Null_Guard()
{
var mock = INullablePredicateUserRepo.Mock();
mock.Save(Is<PredicateUser?>(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<PredicateUser?>(u => throw failure)).Returns("never");

INullablePredicateUserRepo repo = mock.Object;

await Assert.That(() => repo.Save(null)).Throws<InvalidOperationException>();
}

[Test]
public async Task Nullable_Predicate_Can_Match_Null()
{
var mock = INullablePredicateUserRepo.Mock();
mock.Save(Is<PredicateUser?>(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<PredicateUser>(u => throw new NullReferenceException("from predicate"))).Returns("never");

IPredicateUserRepo repo = mock.Object;

await Assert.That(() => repo.Save(new PredicateUser("Alice"))).Throws<NullReferenceException>();
}
}
Loading