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
165 changes: 165 additions & 0 deletions TUnit.Mocks.Analyzers.Tests/ArgIsNullNonNullableAnalyzerTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
using TUnit.Mocks.Analyzers.Tests.Verifiers;

using Verifier = TUnit.Mocks.Analyzers.Tests.Verifiers.CSharpAnalyzerVerifier<TUnit.Mocks.Analyzers.ArgIsNullNonNullableAnalyzer>;

namespace TUnit.Mocks.Analyzers.Tests;

public class ArgIsNullNonNullableAnalyzerTests
{
private const string ArgStub = """
namespace TUnit.Mocks.Arguments
{
public static class Arg
{
public static Arg<T> IsNull<T>() => default!;
public static Arg<T> IsNotNull<T>() => default!;
}

public struct Arg<T> { }
}
""";

[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<int>()|};
}
}
""",
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<bool>()|};
}
}
""",
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<int?>();
}
}
"""
);
}

[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<int?>();
}
}
"""
);
}

[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<string>();
}
}
"""
);
}

[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<string>();
}
}
"""
);
}

[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<string?>();
}
}
"""
);
}

[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<string?>();
}
}
"""
);
}
}
72 changes: 72 additions & 0 deletions TUnit.Mocks.Analyzers/ArgIsNullNonNullableAnalyzer.cs
Original file line number Diff line number Diff line change
@@ -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<DiagnosticDescriptor> 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<T> 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 } } } };
}
}
20 changes: 17 additions & 3 deletions TUnit.Mocks.Analyzers/Rules.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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(
Expand All @@ -28,7 +30,8 @@ public static class Rules
messageFormat: "Mock.OfDelegate<T>() 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<T>() is designed specifically for mocking delegate types. Use Mock.Of<T>() for interfaces and classes."
);

public static readonly DiagnosticDescriptor TM004_RequiresCSharp14 = new(
Expand All @@ -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 <LangVersion>14</LangVersion> or <LangVersion>preview</LangVersion> 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<T>() will always return false and Arg.IsNotNull<T>() will always return true. Use the nullable form (e.g. int?) to match nullable value type parameters."
);
}
40 changes: 40 additions & 0 deletions TUnit.Mocks.Tests/ArgumentMatcherTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@

namespace TUnit.Mocks.Tests;

public interface INullableValueConsumer
{
string Process(int? value);
}

/// <summary>
/// US3 Integration Tests: Argument matchers for mock setup expressions.
/// </summary>
Expand Down Expand Up @@ -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<INullableValueConsumer>();
mock.Process(IsNull<int?>()).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<INullableValueConsumer>();
mock.Process(IsNotNull<int?>()).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()
{
Expand Down
8 changes: 4 additions & 4 deletions TUnit.Mocks/Arguments/Arg.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,11 @@ public static class Arg
/// <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));

/// <summary>Matches only when the argument is null.</summary>
public static Arg<T> IsNull<T>() where T : class => new(new NullMatcher<T>());
/// <summary>Matches only when the argument is null. Supports reference types and nullable value types.</summary>
public static Arg<T> IsNull<T>() => new(new NullMatcher<T>());

/// <summary>Matches only when the argument is not null.</summary>
public static Arg<T> IsNotNull<T>() where T : class => new(new NotNullMatcher<T>());
/// <summary>Matches only when the argument is not null. Supports reference types and nullable value types.</summary>
public static Arg<T> IsNotNull<T>() => new(new NotNullMatcher<T>());

/// <summary>Matches a string against a regular expression pattern.</summary>
public static Arg<string> Matches(string pattern) => new(new RegexMatcher(pattern));
Expand Down
Loading