From f63863547c42b8c17ec5810615f2f697d3829d57 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sat, 4 Apr 2026 12:16:32 +0100 Subject: [PATCH 1/5] Fix nullability warnings on Arg.IsNull and Arg.IsNotNull Relax generic constraint from `class` to `class?` so nullable reference types (e.g. `string?`) can be used without CS8620/CS8634 warnings. Fixes #5360 --- TUnit.Mocks/Arguments/Arg.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/TUnit.Mocks/Arguments/Arg.cs b/TUnit.Mocks/Arguments/Arg.cs index ce3b4f750e..541d139240 100644 --- a/TUnit.Mocks/Arguments/Arg.cs +++ b/TUnit.Mocks/Arguments/Arg.cs @@ -22,10 +22,10 @@ public static class Arg public static Arg Is(Func predicate) => new(new PredicateMatcher(predicate)); /// Matches only when the argument is null. - public static Arg IsNull() where T : class => new(new NullMatcher()); + public static Arg IsNull() where T : class? => new(new NullMatcher()); /// Matches only when the argument is not null. - public static Arg IsNotNull() where T : class => new(new NotNullMatcher()); + public static Arg IsNotNull() where T : class? => new(new NotNullMatcher()); /// Matches a string against a regular expression pattern. public static Arg Matches(string pattern) => new(new RegexMatcher(pattern)); From e78d1123e21e5e9517db5099ee98cfe2be052ab0 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sat, 4 Apr 2026 12:40:24 +0100 Subject: [PATCH 2/5] Support Arg.IsNull and Arg.IsNotNull for nullable value types Remove the `where T : class?` constraint so these matchers work with nullable value types such as `int?`. Users can now write `Arg.IsNull()` for methods with nullable value-type parameters. --- TUnit.Mocks.Tests/ArgumentMatcherTests.cs | 35 +++++++++++++++++++++++ TUnit.Mocks.Tests/BasicMockTests.cs | 5 ++++ TUnit.Mocks/Arguments/Arg.cs | 8 +++--- 3 files changed, 44 insertions(+), 4 deletions(-) diff --git a/TUnit.Mocks.Tests/ArgumentMatcherTests.cs b/TUnit.Mocks.Tests/ArgumentMatcherTests.cs index 5c33418cd2..2037e6715e 100644 --- a/TUnit.Mocks.Tests/ArgumentMatcherTests.cs +++ b/TUnit.Mocks.Tests/ArgumentMatcherTests.cs @@ -230,6 +230,41 @@ public async Task Arg_Capture_Does_Not_Capture_On_Partial_Match() await Assert.That(result).IsEqualTo(999); } + [Test] + public async Task Arg_IsNull_Matches_Null_Nullable_Value_Type() + { + // Arrange + var mock = Mock.Of(); + mock.Process(IsNull()).Returns("got null"); + + // Act + INullableValueConsumer consumer = mock.Object; + + // Assert — null matches + await Assert.That(consumer.Process(null)).IsEqualTo("got null"); + + // Assert — non-null does not match, returns default + await Assert.That(consumer.Process(42)).IsNotEqualTo("got null"); + } + + [Test] + public async Task Arg_IsNotNull_Matches_NonNull_Nullable_Value_Type() + { + // Arrange + var mock = Mock.Of(); + mock.Process(IsNotNull()).Returns("got value"); + + // Act + INullableValueConsumer consumer = mock.Object; + + // Assert — non-null matches + await Assert.That(consumer.Process(42)).IsEqualTo("got value"); + await Assert.That(consumer.Process(0)).IsEqualTo("got value"); + + // Assert — null does not match + await Assert.That(consumer.Process(null)).IsNotEqualTo("got value"); + } + [Test] public async Task Predicate_Matcher_With_String() { diff --git a/TUnit.Mocks.Tests/BasicMockTests.cs b/TUnit.Mocks.Tests/BasicMockTests.cs index e28facf8d0..47c1e6129d 100644 --- a/TUnit.Mocks.Tests/BasicMockTests.cs +++ b/TUnit.Mocks.Tests/BasicMockTests.cs @@ -18,6 +18,11 @@ public interface IGreeter string Greet(string name); } +public interface INullableValueConsumer +{ + string Process(int? value); +} + /// /// US1 Integration Tests: Create a mock and configure return values. /// diff --git a/TUnit.Mocks/Arguments/Arg.cs b/TUnit.Mocks/Arguments/Arg.cs index 541d139240..cbae62a446 100644 --- a/TUnit.Mocks/Arguments/Arg.cs +++ b/TUnit.Mocks/Arguments/Arg.cs @@ -21,11 +21,11 @@ public static class Arg /// Matches when the predicate returns true for the actual argument. public static Arg Is(Func predicate) => new(new PredicateMatcher(predicate)); - /// Matches only when the argument is null. - public static Arg IsNull() where T : class? => new(new NullMatcher()); + /// Matches only when the argument is null. Supports reference types and nullable value types. + public static Arg IsNull() => new(new NullMatcher()); - /// Matches only when the argument is not null. - public static Arg IsNotNull() where T : class? => new(new NotNullMatcher()); + /// Matches only when the argument is not null. Supports reference types and nullable value types. + public static Arg IsNotNull() => new(new NotNullMatcher()); /// Matches a string against a regular expression pattern. public static Arg Matches(string pattern) => new(new RegexMatcher(pattern)); From 7c2b8d2b62b99adf00c616527e7195567545e439 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sat, 4 Apr 2026 13:09:04 +0100 Subject: [PATCH 3/5] Add TM005 analyzer to flag Arg.IsNull/IsNotNull with non-nullable value types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Arg.IsNull() and Arg.IsNotNull() compile but are semantically vacuous — a non-nullable value type can never be null. TM005 warns at the call site and suggests using the nullable form (e.g. int?) instead. --- .../ArgIsNullNonNullableAnalyzerTests.cs | 129 ++++++++++++++++++ .../ArgIsNullNonNullableAnalyzer.cs | 72 ++++++++++ TUnit.Mocks.Analyzers/Rules.cs | 9 ++ 3 files changed, 210 insertions(+) create mode 100644 TUnit.Mocks.Analyzers.Tests/ArgIsNullNonNullableAnalyzerTests.cs create mode 100644 TUnit.Mocks.Analyzers/ArgIsNullNonNullableAnalyzer.cs diff --git a/TUnit.Mocks.Analyzers.Tests/ArgIsNullNonNullableAnalyzerTests.cs b/TUnit.Mocks.Analyzers.Tests/ArgIsNullNonNullableAnalyzerTests.cs new file mode 100644 index 0000000000..a528962939 --- /dev/null +++ b/TUnit.Mocks.Analyzers.Tests/ArgIsNullNonNullableAnalyzerTests.cs @@ -0,0 +1,129 @@ +using TUnit.Mocks.Analyzers.Tests.Verifiers; + +using Verifier = TUnit.Mocks.Analyzers.Tests.Verifiers.CSharpAnalyzerVerifier; + +namespace TUnit.Mocks.Analyzers.Tests; + +public class ArgIsNullNonNullableAnalyzerTests +{ + private const string ArgStub = """ + namespace TUnit.Mocks.Arguments + { + public static class Arg + { + public static Arg IsNull() => default!; + public static Arg IsNotNull() => default!; + } + + public struct Arg { } + } + """; + + [Test] + public async Task IsNull_With_Non_Nullable_Struct_Reports_TM005() + { + await Verifier.VerifyAnalyzerAsync( + ArgStub + """ + + public class TestClass + { + public void Test() + { + {|#0:TUnit.Mocks.Arguments.Arg.IsNull()|}; + } + } + """, + Verifier.Diagnostic(Rules.TM005_ArgIsNullNonNullableValueType) + .WithLocation(0) + .WithArguments("IsNull", "int") + ); + } + + [Test] + public async Task IsNotNull_With_Non_Nullable_Struct_Reports_TM005() + { + await Verifier.VerifyAnalyzerAsync( + ArgStub + """ + + public class TestClass + { + public void Test() + { + {|#0:TUnit.Mocks.Arguments.Arg.IsNotNull()|}; + } + } + """, + Verifier.Diagnostic(Rules.TM005_ArgIsNullNonNullableValueType) + .WithLocation(0) + .WithArguments("IsNotNull", "bool") + ); + } + + [Test] + public async Task IsNull_With_Nullable_Value_Type_Does_Not_Report() + { + await Verifier.VerifyAnalyzerAsync( + ArgStub + """ + + public class TestClass + { + public void Test() + { + TUnit.Mocks.Arguments.Arg.IsNull(); + } + } + """ + ); + } + + [Test] + public async Task IsNotNull_With_Nullable_Value_Type_Does_Not_Report() + { + await Verifier.VerifyAnalyzerAsync( + ArgStub + """ + + public class TestClass + { + public void Test() + { + TUnit.Mocks.Arguments.Arg.IsNotNull(); + } + } + """ + ); + } + + [Test] + public async Task IsNull_With_Reference_Type_Does_Not_Report() + { + await Verifier.VerifyAnalyzerAsync( + ArgStub + """ + + public class TestClass + { + public void Test() + { + TUnit.Mocks.Arguments.Arg.IsNull(); + } + } + """ + ); + } + + [Test] + public async Task IsNotNull_With_Reference_Type_Does_Not_Report() + { + await Verifier.VerifyAnalyzerAsync( + ArgStub + """ + + public class TestClass + { + public void Test() + { + TUnit.Mocks.Arguments.Arg.IsNotNull(); + } + } + """ + ); + } +} diff --git a/TUnit.Mocks.Analyzers/ArgIsNullNonNullableAnalyzer.cs b/TUnit.Mocks.Analyzers/ArgIsNullNonNullableAnalyzer.cs new file mode 100644 index 0000000000..49152d3fdf --- /dev/null +++ b/TUnit.Mocks.Analyzers/ArgIsNullNonNullableAnalyzer.cs @@ -0,0 +1,72 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace TUnit.Mocks.Analyzers; + +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public class ArgIsNullNonNullableAnalyzer : DiagnosticAnalyzer +{ + public override ImmutableArray SupportedDiagnostics { get; } = + ImmutableArray.Create(Rules.TM005_ArgIsNullNonNullableValueType); + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + + context.RegisterSyntaxNodeAction(AnalyzeInvocation, SyntaxKind.InvocationExpression); + } + + private static void AnalyzeInvocation(SyntaxNodeAnalysisContext context) + { + if (context.Node is not InvocationExpressionSyntax invocation) + { + return; + } + + var symbolInfo = context.SemanticModel.GetSymbolInfo(invocation, context.CancellationToken); + + if (symbolInfo.Symbol is not IMethodSymbol methodSymbol) + { + return; + } + + if (!IsArgNullMethod(methodSymbol)) + { + return; + } + + var typeArgument = methodSymbol.TypeArguments[0]; + + if (!typeArgument.IsValueType) + { + return; + } + + // Nullable is a value type but IS nullable — only flag non-nullable structs + if (typeArgument.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T) + { + return; + } + + context.ReportDiagnostic( + Diagnostic.Create( + Rules.TM005_ArgIsNullNonNullableValueType, + invocation.GetLocation(), + methodSymbol.Name, + typeArgument.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat) + ) + ); + } + + private static bool IsArgNullMethod(IMethodSymbol method) + { + return method.Name is "IsNull" or "IsNotNull" + && method.IsGenericMethod + && method.Parameters.Length == 0 + && method.ContainingType is { Name: "Arg", ContainingNamespace: { Name: "Arguments", ContainingNamespace: { Name: "Mocks", ContainingNamespace: { Name: "TUnit", ContainingNamespace.IsGlobalNamespace: true } } } }; + } +} diff --git a/TUnit.Mocks.Analyzers/Rules.cs b/TUnit.Mocks.Analyzers/Rules.cs index 55bd28606d..26b8bea3fa 100644 --- a/TUnit.Mocks.Analyzers/Rules.cs +++ b/TUnit.Mocks.Analyzers/Rules.cs @@ -31,6 +31,15 @@ public static class Rules isEnabledByDefault: true ); + public static readonly DiagnosticDescriptor TM005_ArgIsNullNonNullableValueType = new( + id: "TM005", + title: "Arg.IsNull/IsNotNull used with non-nullable value type", + messageFormat: "Arg.{0}<{1}>() will never match because '{1}' is a non-nullable value type. Use '{1}?' instead.", + category: "TUnit.Mocks", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); + public static readonly DiagnosticDescriptor TM004_RequiresCSharp14 = new( id: "TM004", title: "TUnit.Mocks requires C# 14 or later", From 6f8c5c9ea996d16d80c74a6f58e43f365a0390c5 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sat, 4 Apr 2026 13:45:02 +0100 Subject: [PATCH 4/5] Fix TM005 rule ordering and add string? regression tests Move TM005 after TM004 to restore numeric ordering in Rules.cs. Add explicit tests that Arg.IsNull/IsNotNull() does not trigger TM005, locking in the nullable reference type behaviour. --- .../ArgIsNullNonNullableAnalyzerTests.cs | 36 +++++++++++++++++++ TUnit.Mocks.Analyzers/Rules.cs | 18 +++++----- 2 files changed, 45 insertions(+), 9 deletions(-) diff --git a/TUnit.Mocks.Analyzers.Tests/ArgIsNullNonNullableAnalyzerTests.cs b/TUnit.Mocks.Analyzers.Tests/ArgIsNullNonNullableAnalyzerTests.cs index a528962939..965a2a2a12 100644 --- a/TUnit.Mocks.Analyzers.Tests/ArgIsNullNonNullableAnalyzerTests.cs +++ b/TUnit.Mocks.Analyzers.Tests/ArgIsNullNonNullableAnalyzerTests.cs @@ -126,4 +126,40 @@ public void Test() """ ); } + + [Test] + public async Task IsNull_With_Nullable_Reference_Type_Does_Not_Report() + { + await Verifier.VerifyAnalyzerAsync( + ArgStub + """ + + #nullable enable + public class TestClass + { + public void Test() + { + TUnit.Mocks.Arguments.Arg.IsNull(); + } + } + """ + ); + } + + [Test] + public async Task IsNotNull_With_Nullable_Reference_Type_Does_Not_Report() + { + await Verifier.VerifyAnalyzerAsync( + ArgStub + """ + + #nullable enable + public class TestClass + { + public void Test() + { + TUnit.Mocks.Arguments.Arg.IsNotNull(); + } + } + """ + ); + } } diff --git a/TUnit.Mocks.Analyzers/Rules.cs b/TUnit.Mocks.Analyzers/Rules.cs index 26b8bea3fa..b303e93644 100644 --- a/TUnit.Mocks.Analyzers/Rules.cs +++ b/TUnit.Mocks.Analyzers/Rules.cs @@ -31,15 +31,6 @@ public static class Rules isEnabledByDefault: true ); - public static readonly DiagnosticDescriptor TM005_ArgIsNullNonNullableValueType = new( - id: "TM005", - title: "Arg.IsNull/IsNotNull used with non-nullable value type", - messageFormat: "Arg.{0}<{1}>() will never match because '{1}' is a non-nullable value type. Use '{1}?' instead.", - category: "TUnit.Mocks", - defaultSeverity: DiagnosticSeverity.Warning, - isEnabledByDefault: true - ); - public static readonly DiagnosticDescriptor TM004_RequiresCSharp14 = new( id: "TM004", title: "TUnit.Mocks requires C# 14 or later", @@ -49,4 +40,13 @@ public static class Rules isEnabledByDefault: true, customTags: new[] { WellKnownDiagnosticTags.CompilationEnd } ); + + public static readonly DiagnosticDescriptor TM005_ArgIsNullNonNullableValueType = new( + id: "TM005", + title: "Arg.IsNull/IsNotNull used with non-nullable value type", + messageFormat: "Arg.{0}<{1}>() will never match because '{1}' is a non-nullable value type. Use '{1}?' instead.", + category: "TUnit.Mocks", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); } From c9d4a957d616bafe0e2457fe5afbdbed434fd144 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sat, 4 Apr 2026 13:50:01 +0100 Subject: [PATCH 5/5] Move INullableValueConsumer, promote TM005 to error, add rule descriptions - Move INullableValueConsumer to ArgumentMatcherTests.cs where it is used - Promote TM005 to DiagnosticSeverity.Error (non-nullable IsNull/IsNotNull is always wrong, not just suspicious) - Add description strings to all rules (TM001-TM005) for richer IDE hover text and dotnet_diagnostic suppression documentation --- TUnit.Mocks.Analyzers/Rules.cs | 15 ++++++++++----- TUnit.Mocks.Tests/ArgumentMatcherTests.cs | 5 +++++ TUnit.Mocks.Tests/BasicMockTests.cs | 5 ----- 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/TUnit.Mocks.Analyzers/Rules.cs b/TUnit.Mocks.Analyzers/Rules.cs index b303e93644..f89196a57b 100644 --- a/TUnit.Mocks.Analyzers/Rules.cs +++ b/TUnit.Mocks.Analyzers/Rules.cs @@ -10,7 +10,8 @@ public static class Rules messageFormat: "Cannot mock sealed type '{0}'. Consider extracting an interface.", category: "TUnit.Mocks", defaultSeverity: DiagnosticSeverity.Error, - isEnabledByDefault: true + isEnabledByDefault: true, + description: "TUnit.Mocks generates a subclass to intercept calls. Sealed types cannot be subclassed, so they cannot be mocked directly. Extract an interface or abstract class instead." ); public static readonly DiagnosticDescriptor TM002_CannotMockValueType = new( @@ -19,7 +20,8 @@ public static class Rules messageFormat: "Cannot mock value type '{0}'. Mocking requires reference types.", category: "TUnit.Mocks", defaultSeverity: DiagnosticSeverity.Error, - isEnabledByDefault: true + isEnabledByDefault: true, + description: "TUnit.Mocks generates a subclass to intercept calls. Value types (structs, enums) cannot be subclassed. Use an interface or class instead." ); public static readonly DiagnosticDescriptor TM003_OfDelegateRequiresDelegateType = new( @@ -28,7 +30,8 @@ public static class Rules messageFormat: "Mock.OfDelegate() requires T to be a delegate type, but '{0}' is not a delegate.", category: "TUnit.Mocks", defaultSeverity: DiagnosticSeverity.Error, - isEnabledByDefault: true + isEnabledByDefault: true, + description: "Mock.OfDelegate() is designed specifically for mocking delegate types. Use Mock.Of() for interfaces and classes." ); public static readonly DiagnosticDescriptor TM004_RequiresCSharp14 = new( @@ -38,6 +41,7 @@ public static class Rules category: "TUnit.Mocks", defaultSeverity: DiagnosticSeverity.Error, isEnabledByDefault: true, + description: "TUnit.Mocks uses C# 14 extension members in its generated code. Set 14 or preview in your project file.", customTags: new[] { WellKnownDiagnosticTags.CompilationEnd } ); @@ -46,7 +50,8 @@ public static class Rules title: "Arg.IsNull/IsNotNull used with non-nullable value type", messageFormat: "Arg.{0}<{1}>() will never match because '{1}' is a non-nullable value type. Use '{1}?' instead.", category: "TUnit.Mocks", - defaultSeverity: DiagnosticSeverity.Warning, - isEnabledByDefault: true + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "Non-nullable value types can never be null, so Arg.IsNull() will always return false and Arg.IsNotNull() will always return true. Use the nullable form (e.g. int?) to match nullable value type parameters." ); } diff --git a/TUnit.Mocks.Tests/ArgumentMatcherTests.cs b/TUnit.Mocks.Tests/ArgumentMatcherTests.cs index 2037e6715e..5979963bfa 100644 --- a/TUnit.Mocks.Tests/ArgumentMatcherTests.cs +++ b/TUnit.Mocks.Tests/ArgumentMatcherTests.cs @@ -3,6 +3,11 @@ namespace TUnit.Mocks.Tests; +public interface INullableValueConsumer +{ + string Process(int? value); +} + /// /// US3 Integration Tests: Argument matchers for mock setup expressions. /// diff --git a/TUnit.Mocks.Tests/BasicMockTests.cs b/TUnit.Mocks.Tests/BasicMockTests.cs index 47c1e6129d..e28facf8d0 100644 --- a/TUnit.Mocks.Tests/BasicMockTests.cs +++ b/TUnit.Mocks.Tests/BasicMockTests.cs @@ -18,11 +18,6 @@ public interface IGreeter string Greet(string name); } -public interface INullableValueConsumer -{ - string Process(int? value); -} - /// /// US1 Integration Tests: Create a mock and configure return values. ///