diff --git a/src/Analyzers/MethodSetupShouldSpecifyReturnValueAnalyzer.cs b/src/Analyzers/MethodSetupShouldSpecifyReturnValueAnalyzer.cs index 2ce0b488c..5fd57d2cc 100644 --- a/src/Analyzers/MethodSetupShouldSpecifyReturnValueAnalyzer.cs +++ b/src/Analyzers/MethodSetupShouldSpecifyReturnValueAnalyzer.cs @@ -188,6 +188,15 @@ private static bool HasReturnValueSpecification( return true; } + // When a non-Moq method (e.g., an extension method) appears in the chain, + // check if its return type implements a Moq fluent interface. The analyzer + // assumes such methods handle return value specification internally or pass + // the chain through for further configuration. + if (IsFluentChainWrapperMethod(symbolInfo, knownSymbols)) + { + return true; + } + current = memberAccess.Parent as InvocationExpressionSyntax; } @@ -229,4 +238,43 @@ private static bool HasReturnValueSymbol(SymbolInfo symbolInfo, MoqKnownSymbols .OfType() .Any(method => method.IsMoqReturnValueSpecificationMethod(knownSymbols)); } + + /// + /// Determines whether a method in the chain is a wrapper (e.g., an extension method) + /// whose return type implements a Moq fluent interface. Such methods are assumed to + /// handle return value specification internally, as indicated by their Moq fluent + /// return type. + /// + private static bool IsFluentChainWrapperMethod(SymbolInfo symbolInfo, MoqKnownSymbols knownSymbols) + { + if (symbolInfo.Symbol is IMethodSymbol resolved) + { + return IsWrapperMethod(resolved, knownSymbols); + } + + // When overload resolution fails, check all candidates. A false negative + // occurs if only the first candidate is inspected and it does not return a + // Moq fluent type while another candidate does. + return symbolInfo.CandidateSymbols + .OfType() + .Any(candidate => IsWrapperMethod(candidate, knownSymbols)); + } + + /// + /// Returns true when is a user-defined wrapper whose return + /// type implements a Moq fluent interface. Known Moq return-value, callback, and raises + /// methods are excluded because they are already handled by earlier checks in the + /// chain walk. + /// + private static bool IsWrapperMethod(IMethodSymbol method, MoqKnownSymbols knownSymbols) + { + if (method.IsMoqReturnValueSpecificationMethod(knownSymbols) + || method.IsMoqCallbackMethod(knownSymbols) + || method.IsMoqRaisesMethod(knownSymbols)) + { + return false; + } + + return method.ReturnType.ImplementsMoqFluentInterface(knownSymbols); + } } diff --git a/src/Common/ISymbolExtensions.Moq.cs b/src/Common/ISymbolExtensions.Moq.cs index b0428c29c..1805eb097 100644 --- a/src/Common/ISymbolExtensions.Moq.cs +++ b/src/Common/ISymbolExtensions.Moq.cs @@ -187,6 +187,22 @@ internal static bool IsMoqRaisesMethod(this ISymbol symbol, MoqKnownSymbols know IsConcreteSetupPhraseRaisesMethod(symbol, knownSymbols); } + /// + /// Determines whether a type symbol is or implements any Moq fluent interface from + /// the Moq.Language or Moq.Language.Flow namespaces. + /// + /// The type symbol to check. + /// Known Moq symbols resolved from the compilation. + /// + /// if the type is or implements a Moq fluent interface; + /// otherwise, . + /// + internal static bool ImplementsMoqFluentInterface(this ITypeSymbol typeSymbol, MoqKnownSymbols knownSymbols) + { + return IsMoqFluentType(typeSymbol, knownSymbols) + || HasMoqFluentInterfaceInHierarchy(typeSymbol, knownSymbols); + } + /// /// Checks if the symbol is a Raises method from ISetup / ISetupPhrase interfaces. /// @@ -208,11 +224,11 @@ private static bool IsConcreteSetupPhraseRaisesMethod(ISymbol symbol, MoqKnownSy } /// - /// Checks if the symbol is a Raises method from ICallback interfaces. + /// Checks if the symbol is a Raises method from ICallback, ISetupGetter, or ISetupSetter interfaces. /// /// The symbol to check. /// The known symbols for type checking. - /// True if the symbol is a callback Raises method; otherwise false. + /// True if the symbol is a Raises method from one of these interfaces; otherwise false. private static bool IsCallbackRaisesMethod(ISymbol symbol, MoqKnownSymbols knownSymbols) { return symbol.IsInstanceOf(knownSymbols.ICallbackRaises) || @@ -265,4 +281,75 @@ private static bool IsGenericResultProperty(IPropertySymbol propertySymbol, INam return genericType != null && SymbolEqualityComparer.Default.Equals(propertySymbol.ContainingType.OriginalDefinition, genericType); } + + /// + /// Checks if the type itself is a known Moq fluent interface. + /// + private static bool IsMoqFluentType(ITypeSymbol typeSymbol, MoqKnownSymbols knownSymbols) + { + if (typeSymbol is not INamedTypeSymbol namedType) + { + return false; + } + + INamedTypeSymbol original = namedType.IsGenericType ? namedType.ConstructedFrom : namedType; + + return IsMatchingFluentSymbol(original, knownSymbols); + } + + /// + /// Walks the AllInterfaces list to find any Moq fluent interface in the type hierarchy. + /// + private static bool HasMoqFluentInterfaceInHierarchy(ITypeSymbol typeSymbol, MoqKnownSymbols knownSymbols) + { + foreach (INamedTypeSymbol iface in typeSymbol.AllInterfaces) + { + INamedTypeSymbol original = iface.IsGenericType ? iface.ConstructedFrom : iface; + + if (IsMatchingFluentSymbol(original, knownSymbols)) + { + return true; + } + } + + return false; + } + + /// + /// Compares a type symbol against the set of known Moq fluent interface symbols + /// that indicate the fluent chain is active and a return value can be configured. + /// + /// + /// + /// This method intentionally excludes ICallback, ICallback{TMock}, and + /// ICallback{TMock, TResult} because these interfaces do not inherit from + /// IReturns{TMock, TResult} and do not indicate that a return value can be + /// configured. Types like ISetup{TMock, TResult} that inherit from + /// IReturns{TMock, TResult} are still caught via + /// when checking + /// . + /// + /// + /// Only IReturns{TMock, TResult} (arity 2) is checked. Moq.Language.IReturns + /// (arity 0) and Moq.Language.IReturns{T} (arity 1) resolve to null in Moq 4.18+ + /// and are not part of the fluent setup chain. + /// + /// + /// IThrows, ISetup{TResult}, ISetupGetter{TMock, TProperty}, and + /// ISetupSetter{TMock, TProperty} are included because a wrapper method may + /// return these types directly. IReturnsResult{TMock} is the return type of + /// .Returns() and .ReturnsAsync(). IThrowsResult is the return + /// type of .Throws(). + /// + /// + private static bool IsMatchingFluentSymbol(INamedTypeSymbol type, MoqKnownSymbols knownSymbols) + { + return SymbolEqualityComparer.Default.Equals(type, knownSymbols.IReturns2) + || SymbolEqualityComparer.Default.Equals(type, knownSymbols.IThrows) + || SymbolEqualityComparer.Default.Equals(type, knownSymbols.ISetup1) + || SymbolEqualityComparer.Default.Equals(type, knownSymbols.ISetupGetter) + || SymbolEqualityComparer.Default.Equals(type, knownSymbols.ISetupSetter) + || SymbolEqualityComparer.Default.Equals(type, knownSymbols.IReturnsResult1) + || SymbolEqualityComparer.Default.Equals(type, knownSymbols.IThrowsResult); + } } diff --git a/src/Common/WellKnown/MoqKnownSymbols.cs b/src/Common/WellKnown/MoqKnownSymbols.cs index 469872791..e99ec975c 100644 --- a/src/Common/WellKnown/MoqKnownSymbols.cs +++ b/src/Common/WellKnown/MoqKnownSymbols.cs @@ -246,7 +246,7 @@ internal MoqKnownSymbols(Compilation compilation) internal INamedTypeSymbol? MockRepository => TypeProvider.GetOrCreateTypeByMetadataName("Moq.MockRepository"); /// - /// Gets the methods for Moq.MockRepository.Of. + /// Gets the methods for Moq.MockRepository.Create. /// /// /// MockRepository is a subclass of MockFactory. @@ -422,6 +422,18 @@ internal MoqKnownSymbols(Compilation compilation) /// internal ImmutableArray IReturns2Raises => _iReturns2Raises.Value; + /// + /// Gets the interface Moq.Language.Flow.IReturnsResult{TMock}. + /// This is the actual return type of .Returns() and .ReturnsAsync(). + /// + internal INamedTypeSymbol? IReturnsResult1 => TypeProvider.GetOrCreateTypeByMetadataName("Moq.Language.Flow.IReturnsResult`1"); + + /// + /// Gets the interface Moq.Language.Flow.IThrowsResult. + /// This is the return type of .Throws(). + /// + internal INamedTypeSymbol? IThrowsResult => TypeProvider.GetOrCreateTypeByMetadataName("Moq.Language.Flow.IThrowsResult"); + /// /// Gets the interface Moq.Language.Flow.ISetup{T}. /// diff --git a/tests/Moq.Analyzers.Test/Common/MoqKnownSymbolsTests.cs b/tests/Moq.Analyzers.Test/Common/MoqKnownSymbolsTests.cs index 7cdf38110..2dcf794dc 100644 --- a/tests/Moq.Analyzers.Test/Common/MoqKnownSymbolsTests.cs +++ b/tests/Moq.Analyzers.Test/Common/MoqKnownSymbolsTests.cs @@ -221,6 +221,20 @@ public void NonVoidSetupPhrase2_WithoutMoqReference_ReturnsNull() Assert.Null(symbols.NonVoidSetupPhrase2); } + [Fact] + public void IReturnsResult1_WithoutMoqReference_ReturnsNull() + { + MoqKnownSymbols symbols = CreateSymbolsWithoutMoq(); + Assert.Null(symbols.IReturnsResult1); + } + + [Fact] + public void IThrowsResult_WithoutMoqReference_ReturnsNull() + { + MoqKnownSymbols symbols = CreateSymbolsWithoutMoq(); + Assert.Null(symbols.IThrowsResult); + } + [Fact] public void IRaiseable_WithoutMoqReference_ReturnsNull() { @@ -902,6 +916,24 @@ public async Task IRaise1_WithMoqReference_ReturnsNamedTypeSymbol() Assert.Equal(1, symbols.IRaise1!.Arity); } + [Fact] + public async Task IReturnsResult1_WithMoqReference_ReturnsNamedTypeSymbol() + { + MoqKnownSymbols symbols = await CreateSymbolsWithMoqAsync(); + Assert.NotNull(symbols.IReturnsResult1); + Assert.Equal("IReturnsResult", symbols.IReturnsResult1!.Name); + Assert.Equal(1, symbols.IReturnsResult1.Arity); + } + + [Fact] + public async Task IThrowsResult_WithMoqReference_ReturnsNamedTypeSymbol() + { + MoqKnownSymbols symbols = await CreateSymbolsWithMoqAsync(); + Assert.NotNull(symbols.IThrowsResult); + Assert.Equal("IThrowsResult", symbols.IThrowsResult!.Name); + Assert.Equal(0, symbols.IThrowsResult.Arity); + } + [Fact] public void Task_WithCoreReferences_ReturnsNamedTypeSymbol() { diff --git a/tests/Moq.Analyzers.Test/MethodSetupShouldSpecifyReturnValueAnalyzerTests.cs b/tests/Moq.Analyzers.Test/MethodSetupShouldSpecifyReturnValueAnalyzerTests.cs index bb729a59e..6afac3cb6 100644 --- a/tests/Moq.Analyzers.Test/MethodSetupShouldSpecifyReturnValueAnalyzerTests.cs +++ b/tests/Moq.Analyzers.Test/MethodSetupShouldSpecifyReturnValueAnalyzerTests.cs @@ -418,6 +418,101 @@ public static IEnumerable CustomReturnTypeMissingReturnValueTestData() return data.WithNamespaces().WithMoqReferenceAssemblyGroups(); } + // Regression test data for https://github.com/rjmurillo/moq.analyzers/issues/1067 + // Extension methods wrapping Returns/ReturnsAsync should suppress Moq1203. + public static IEnumerable Issue1067_WrappedSetupTestData() + { + IEnumerable data = + [ + + // Extension method returning IReturns that calls Returns internally + [""" + var moq = new Mock(MockBehavior.Strict); + moq.Setup(x => x.DoSomething("test")).ReturnsTrue(); + """], + + // Extension method returning IReturns that calls Returns internally + [""" + var moq = new Mock(); + moq.Setup(x => x.GetValue()).ReturnsFortyTwo(); + """], + + // Chained extension methods: Setup().ReturnsTrue() where ReturnsTrue returns IReturns + [""" + new Mock().Setup(x => x.DoSomething("test")).ReturnsTrue(); + """], + + // Extension method returning IReturns> that calls Returns internally + [""" + var moq = new Mock(); + moq.Setup(x => x.BarAsync()).ReturnsOneAsync(); + """], + + // Extension method returning IReturnsResult (actual return type of .Returns()) + [""" + var moq = new Mock(MockBehavior.Strict); + moq.Setup(x => x.DoSomething("test")).ReturnsTrueViaResult(); + """], + + // Extension method returning IThrowsResult (actual return type of .Throws()) + [""" + var moq = new Mock(); + moq.Setup(x => x.GetValue()).ThrowsInvalidOp(); + """], + + // Extension method on async setup that calls ReturnsAsync internally + [""" + var moq = new Mock(); + moq.Setup(x => x.BarAsync()).ReturnsOneAsyncViaReturnsAsync(); + """], + + // Extension method on ValueTask setup using Returns(new ValueTask(...)) + [""" + var moq = new Mock(); + moq.Setup(x => x.BazValueTaskAsync()).ReturnsOneValueTask(); + """], + + // Accepted false negative: no-op passthrough returning a fluent type without + // calling Returns. The analyzer cannot inspect the wrapper body, so it assumes + // methods returning fluent types handle configuration internally. Documented + // tradeoff: this suppresses Moq1203 even though no return value is configured. + [""" + var moq = new Mock(); + moq.Setup(x => x.GetValue()).NoOpPassthrough(); + """], + ]; + + return data.WithNamespaces().WithNewMoqReferenceAssemblyGroups(); + } + + // Extension methods that do NOT return a Moq fluent interface should still trigger Moq1203. + public static IEnumerable Issue1067_NonFluentExtensionTestData() + { + IEnumerable data = + [ + + // Extension method returning void (should still flag) + [""" + var moq = new Mock(); + {|Moq1203:moq.Setup(x => x.DoSomething("test"))|}.LogSetup(); + """], + + // Extension method returning unrelated type (should still flag) + [""" + var moq = new Mock(); + {|Moq1203:moq.Setup(x => x.GetValue())|}.ToDescription(); + """], + + // Extension method returning Task (not a Moq fluent type, should still flag) + [""" + var moq = new Mock(); + {|Moq1203:moq.Setup(x => x.GetValue())|}.AsTask(); + """], + ]; + + return data.WithNamespaces().WithNewMoqReferenceAssemblyGroups(); + } + [Theory] [MemberData(nameof(TestData))] public async Task ShouldAnalyzeMethodSetupReturnValue(string referenceAssemblyGroup, string @namespace, string mock) @@ -504,6 +599,20 @@ public async Task ShouldFlagSetupWithCustomReturnTypeMissingReturnValue(string r await VerifyCustomSourceMockAsync(referenceAssemblyGroup, @namespace, mock, recordDeclaration, interfaceMethod); } + [Theory] + [MemberData(nameof(Issue1067_WrappedSetupTestData))] + public async Task ShouldNotFlagSetupWrappedInFluentExtensionMethod(string referenceAssemblyGroup, string @namespace, string mock) + { + await VerifyWrappedSetupMockAsync(referenceAssemblyGroup, @namespace, mock); + } + + [Theory] + [MemberData(nameof(Issue1067_NonFluentExtensionTestData))] + public async Task ShouldFlagSetupWithNonFluentExtensionMethod(string referenceAssemblyGroup, string @namespace, string mock) + { + await VerifyWrappedSetupMockAsync(referenceAssemblyGroup, @namespace, mock); + } + private static string BuildSource(string @namespace, string mock) { return $$""" @@ -552,6 +661,109 @@ private void Test() """; } + private static string BuildWrappedSetupSource(string @namespace, string mock) + { + return $$""" + {{@namespace}} + using Moq.Language; + using Moq.Language.Flow; + + public interface IFoo + { + bool DoSomething(string value); + int GetValue(); + int Calculate(int a, int b); + Task BarAsync(); + ValueTask BazValueTaskAsync(); + void DoVoidMethod(); + void ProcessData(string data); + string Name { get; set; } + } + + public static class MoqExtensions + { + public static IReturns ReturnsTrue(this IReturns mock) + where TMock : class + { + mock.Returns(true); + return mock; + } + + public static IReturns ReturnsFortyTwo(this IReturns mock) + where TMock : class + { + mock.Returns(42); + return mock; + } + + public static IReturns> ReturnsOneAsync(this IReturns> mock) + where TMock : class + { + mock.Returns(Task.FromResult(1)); + return mock; + } + + public static IReturnsResult ReturnsTrueViaResult(this ISetup setup) + where TMock : class + { + return setup.Returns(true); + } + + public static IThrowsResult ThrowsInvalidOp(this ISetup setup) + where TMock : class + { + return setup.Throws(new InvalidOperationException()); + } + + public static void LogSetup(this ISetup setup) + where TMock : class + { + // Does not configure return value, returns void + } + + public static string ToDescription(this ISetup setup) + where TMock : class + { + return "description"; + } + + public static Task AsTask(this ISetup setup) + where TMock : class + { + return Task.CompletedTask; + } + + public static IReturns> ReturnsOneAsyncViaReturnsAsync(this IReturns> mock) + where TMock : class + { + mock.ReturnsAsync(1); + return mock; + } + + public static IReturns> ReturnsOneValueTask(this IReturns> mock) + where TMock : class + { + mock.Returns(new ValueTask(1)); + return mock; + } + + public static ISetup NoOpPassthrough(this ISetup setup) + where TMock : class + { + return setup; + } + } + + internal class UnitTest + { + private void Test() + { + {{mock}} + } + } + """; + } + private async Task VerifyMockAsync(string referenceAssemblyGroup, string @namespace, string mock) { string source = BuildSource(@namespace, mock); @@ -582,4 +794,14 @@ await Verifier.VerifyAnalyzerAsync( referenceAssemblyGroup, CompilerDiagnostics.None).ConfigureAwait(false); } + + private async Task VerifyWrappedSetupMockAsync(string referenceAssemblyGroup, string @namespace, string mock) + { + string source = BuildWrappedSetupSource(@namespace, mock); + output.WriteLine(source); + + await Verifier.VerifyAnalyzerAsync( + source, + referenceAssemblyGroup).ConfigureAwait(false); + } }