diff --git a/TUnit.Mocks.Analyzers.Tests/ArgIsNullNonNullableAnalyzerTests.cs b/TUnit.Mocks.Analyzers.Tests/ArgIsNullNonNullableAnalyzerTests.cs new file mode 100644 index 0000000000..965a2a2a12 --- /dev/null +++ b/TUnit.Mocks.Analyzers.Tests/ArgIsNullNonNullableAnalyzerTests.cs @@ -0,0 +1,165 @@ +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(); + } + } + """ + ); + } + + [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/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..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,17 @@ 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 } ); + + 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.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 5c33418cd2..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. /// @@ -230,6 +235,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/Arguments/Arg.cs b/TUnit.Mocks/Arguments/Arg.cs index ce3b4f750e..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));