diff --git a/CHANGELOG.md b/CHANGELOG.md index 213992a9e..0fb32c319 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ The format is loosely based on [Keep a Changelog](http://keepachangelog.com/en/1 * New method overloads for `It.Is`, `It.IsIn`, and `It.IsNotIn` that compare values using a custom `IEqualityComparer` (@weitzhandler, #1064) * New properties `ReturnValue` and `Exception` on `IInvocation` to query recorded invocations return values or exceptions (@MaStr11, #921, #1077) +* Support for "nested" type matchers, i.e. type matchers that appear as part of a composite type (such as `It.IsAnyType[]` or `Func`). Argument match expressions like `It.IsAny>()` should now work as expected, whereas they previously didn't. In this particular example, you should no longer need a workaround like `(Func)It.IsAny()` as originally suggested in #918. (@stakx, #1092) #### Changed diff --git a/src/Moq/ExpressionExtensions.cs b/src/Moq/ExpressionExtensions.cs index 105187efa..256f74cd4 100644 --- a/src/Moq/ExpressionExtensions.cs +++ b/src/Moq/ExpressionExtensions.cs @@ -198,9 +198,13 @@ void Split(Expression e, out Expression r /* remainder */, out InvocationShape p { foreach (var typeArgument in methodCallExpression.Method.GetGenericArguments()) { - if (typeArgument.IsTypeMatcher(out var typeMatcherType)) + if (typeArgument.IsOrContainsTypeMatcher()) { - Guard.ImplementsTypeMatcherProtocol(typeMatcherType); + // This is a (somewhat roundabout) way of ensuring that the type matchers used + // will be usable. They will not be usable if they don't implement the type + // matcher protocol correctly; and `SubstituteTypeMatchers` tests for that, so + // we'll reuse its recursive logic instead of having to reimplement our own. + _ = typeArgument.SubstituteTypeMatchers(typeArgument); } } } diff --git a/src/Moq/Extensions.cs b/src/Moq/Extensions.cs index 81d70294f..7d745fc50 100644 --- a/src/Moq/Extensions.cs +++ b/src/Moq/Extensions.cs @@ -252,6 +252,26 @@ public static bool IsTypeMatcher(this Type type, out Type typeMatcherType) } } + public static bool IsOrContainsTypeMatcher(this Type type) + { + if (type.IsTypeMatcher()) + { + return true; + } + else if (type.HasElementType) + { + return type.GetElementType().IsOrContainsTypeMatcher(); + } + else if (type.IsGenericType) + { + return type.GetGenericArguments().Any(IsOrContainsTypeMatcher); + } + else + { + return false; + } + } + public static bool ImplementsTypeMatcherProtocol(this Type type) { return typeof(ITypeMatcher).IsAssignableFrom(type) && type.CanCreateInstance(); @@ -290,26 +310,23 @@ public static IEnumerable GetMethods(this Type type, string name) for (int i = 0; i < count; ++i) { - if (considerTypeMatchers && types[i].IsTypeMatcher(out var typeMatcherType)) - { - Debug.Assert(typeMatcherType.ImplementsTypeMatcherProtocol()); + var t = types[i]; - var typeMatcher = (ITypeMatcher)Activator.CreateInstance(typeMatcherType); - if (typeMatcher.Matches(otherTypes[i]) == false) - { - return false; - } + if (considerTypeMatchers && t.IsOrContainsTypeMatcher()) + { + t = t.SubstituteTypeMatchers(otherTypes[i]); } - else if (exact) + + if (exact) { - if (types[i] != otherTypes[i]) + if (t.Equals(otherTypes[i]) == false) { return false; } } else { - if (types[i].IsAssignableFrom(otherTypes[i]) == false) + if (t.IsAssignableFrom(otherTypes[i]) == false) { return false; } @@ -369,6 +386,96 @@ private static MethodInfo GetInvokeMethodFromUntypedDelegateCallback(Delegate ca } } + /// + /// Visits all constituent parts of , replacing all type matchers + /// that match the type argument at the corresponding position in . + /// + /// The type to be matched. May be, or contain, type matchers. + /// The type argument to match against . + public static Type SubstituteTypeMatchers(this Type type, Type other) + { + // If a type matcher `T` successfully matches its corresponding type `O` from `other`, `T` in `type` + // gets replaced by `O`. If all type matchers successfully matched, and they have been replaced by + // their arguments in `other`, callers can then perform a final `IsAssignableFrom` check to match + // everything else (fixed types). Being able to defer to `IsAssignableFrom` saves us from having to + // re-implement all of its type equivalence rules (polymorphism, co-/contravariance, etc.). + // + // We still need to do some checks ourselves, however: In order to traverse both `type` and `other` + // in lockstep, we need to ensure that they have the same basic structure. + + if (type.IsTypeMatcher(out var typeMatcherType)) + { + var typeMatcher = (ITypeMatcher)Activator.CreateInstance(typeMatcherType); + + if (typeMatcher.Matches(other)) + { + return other; + } + } + else if (type.HasElementType && other.HasElementType) + { + var te = type.GetElementType(); + var oe = other.GetElementType(); + + if (type.IsArray && other.IsArray) + { + var tr = type.GetArrayRank(); + var or = other.GetArrayRank(); + + if (tr == or) + { + var se = te.SubstituteTypeMatchers(oe); + if (se.Equals(te)) + { + return type; + } + else + { + return tr == 1 ? se.MakeArrayType() : se.MakeArrayType(tr); + } + } + } + else if (type.IsByRef && other.IsByRef) + { + var se = te.SubstituteTypeMatchers(oe); + return se == te ? type : se.MakeByRefType(); + } + else if (type.IsPointer && other.IsPointer) + { + var se = te.SubstituteTypeMatchers(oe); + return se == te ? type : se.MakePointerType(); + } + } + else if (type.IsGenericType && other.IsGenericType) + { + var td = type.GetGenericTypeDefinition(); + var od = other.GetGenericTypeDefinition(); + + if (td.Equals(od)) + { + var ta = type.GetGenericArguments(); + var oa = other.GetGenericArguments(); + var changed = false; + + Debug.Assert(oa.Length == ta.Length); + + for (int i = 0; i < ta.Length; ++i) + { + var sa = ta[i].SubstituteTypeMatchers(oa[i]); + if (sa.Equals(ta[i]) == false) + { + changed = true; + ta[i] = sa; + } + } + + return changed ? td.MakeGenericType(ta) : type; + } + } + + return type; + } + public static Setup TryFind(this IEnumerable setups, InvocationShape expectation) { return setups.FirstOrDefault(setup => setup.Expectation.Equals(expectation)); diff --git a/src/Moq/It.cs b/src/Moq/It.cs index b23c80822..d09a16e58 100644 --- a/src/Moq/It.cs +++ b/src/Moq/It.cs @@ -51,7 +51,7 @@ public static class Ref /// public static TValue IsAny() { - if (typeof(TValue).IsTypeMatcher()) + if (typeof(TValue).IsOrContainsTypeMatcher()) { return Match.Create( (argument, parameterType) => argument == null || parameterType.IsAssignableFrom(argument.GetType()), @@ -99,7 +99,7 @@ bool ITypeMatcher.Matches(Type type) /// Type of the value. public static TValue IsNotNull() { - if (typeof(TValue).IsTypeMatcher()) + if (typeof(TValue).IsOrContainsTypeMatcher()) { return Match.Create( (argument, parameterType) => argument != null && parameterType.IsAssignableFrom(argument.GetType()), @@ -140,7 +140,7 @@ public static TValue IsNotNull() [SuppressMessage("Microsoft.Design", "CA1006:DoNotNestGenericTypesInMemberSignatures")] public static TValue Is(Expression> match) { - if (typeof(TValue).IsTypeMatcher()) + if (typeof(TValue).IsOrContainsTypeMatcher()) { throw new ArgumentException(Resources.UseItIsOtherOverload, nameof(match)); } diff --git a/tests/Moq.Tests/CustomTypeMatchersFixture.cs b/tests/Moq.Tests/CustomTypeMatchersFixture.cs index 08d4cd0af..f1770a0c3 100644 --- a/tests/Moq.Tests/CustomTypeMatchersFixture.cs +++ b/tests/Moq.Tests/CustomTypeMatchersFixture.cs @@ -53,6 +53,17 @@ public void Cannot_use_type_matcher_with_parameterized_constructor_directly_in_S Assert.Contains("Picky does not have a default (public parameterless) constructor", ex.Message); } + [Fact] + public void Cannot_use_nested_type_matcher_with_parameterized_constructor_directly_in_Setup() + { + var mock = new Mock(); + + Action setup = () => mock.Setup(x => x.Method()); + + var ex = Assert.Throws(setup); + Assert.Contains("Picky does not have a default (public parameterless) constructor", ex.Message); + } + [Fact] public void Cannot_use_type_matcher_with_parameterized_constructor_directly_in_Verify() { @@ -64,6 +75,17 @@ public void Cannot_use_type_matcher_with_parameterized_constructor_directly_in_V Assert.Contains("Picky does not have a default (public parameterless) constructor", ex.Message); } + [Fact] + public void Cannot_use_nested_type_matcher_with_parameterized_constructor_directly_in_Verify() + { + var mock = new Mock(); + + Action verify = () => mock.Verify(x => x.Method(), Times.Never); + + var ex = Assert.Throws(verify); + Assert.Contains("Picky does not have a default (public parameterless) constructor", ex.Message); + } + [Fact] public void Can_use_type_matcher_derived_from_one_having_a_parameterized_constructor() { diff --git a/tests/Moq.Tests/Moq.Tests.csproj b/tests/Moq.Tests/Moq.Tests.csproj index 24ddaa3a6..72c3f3f48 100644 --- a/tests/Moq.Tests/Moq.Tests.csproj +++ b/tests/Moq.Tests/Moq.Tests.csproj @@ -23,6 +23,7 @@ + diff --git a/tests/Moq.Tests/NestedTypeMatchersFixture.cs b/tests/Moq.Tests/NestedTypeMatchersFixture.cs new file mode 100644 index 000000000..457ac6b1d --- /dev/null +++ b/tests/Moq.Tests/NestedTypeMatchersFixture.cs @@ -0,0 +1,79 @@ +// Copyright (c) 2007, Clarius Consulting, Manas Technology Solutions, InSTEDD, and Contributors. +// All rights reserved. Licensed under the BSD 3-Clause License; see License.txt. + +using System; +using System.Collections.Generic; + +using Xunit; + +namespace Moq.Tests +{ + public class NestedTypeMatchersFixture + { + #region SubstituteTypeMatchers helper extension method + + [Theory] + [InlineData(typeof(It.IsSubtype), typeof(AttributeTargets), typeof(AttributeTargets))] + [InlineData(typeof(It.IsSubtype[]), typeof(AttributeTargets[]), typeof(AttributeTargets[]))] + [InlineData(typeof(IEnumerable>), typeof(IEnumerable), typeof(IEnumerable))] + [InlineData(typeof(IEnumerable[]>), typeof(IEnumerable), typeof(IEnumerable))] + [InlineData(typeof(IEnumerable>[]), typeof(IEnumerable[]), typeof(IEnumerable[]))] + public void SubstituteTypeMatchers_substitutes_matches(Type type, Type other, Type expected) + { + Assert.Equal(expected, actual: type.SubstituteTypeMatchers(other)); + } + + [Theory] + [InlineData(typeof(It.IsSubtype), typeof(object), typeof(It.IsSubtype))] + [InlineData(typeof(It.IsSubtype[]), typeof(object[]), typeof(It.IsSubtype[]))] + [InlineData(typeof(IEnumerable>), typeof(IEnumerable), typeof(IEnumerable>))] + [InlineData(typeof(IEnumerable[]>), typeof(IEnumerable), typeof(IEnumerable[]>))] + public void SubstituteTypeMatchers_does_not_substitute_mismatches(Type type, Type other, Type expected) + { + Assert.Equal(expected, actual: type.SubstituteTypeMatchers(other)); + } + + [Theory] + [InlineData(typeof(It.IsAnyType[]), typeof(IEnumerable), typeof(It.IsAnyType[]))] + [InlineData(typeof(IEnumerable), typeof(object[]), typeof(IEnumerable))] + public void SubstituteTypeMatchers_does_not_substitute_mismatched_composite_kind(Type type, Type other, Type expected) + { + Assert.Equal(expected, actual: type.SubstituteTypeMatchers(other)); + } + + [Theory] + [InlineData(typeof(It.IsAnyType), typeof(IEnumerable), typeof(IEnumerable))] + [InlineData(typeof(It.IsAnyType), typeof(object[]), typeof(object[]))] + public void SubstituteTypeMatchers_gives_precedence_to_type_matchers_over_composite_kind(Type type, Type other, Type expected) + { + Assert.Equal(expected, actual: type.SubstituteTypeMatchers(other)); + } + + [Theory] + [InlineData(typeof(Tuple, object, It.IsAnyType, It.IsSubtype>), typeof(Tuple), typeof(Tuple>))] + public void SubstituteTypeMatchers_may_substitute_only_some_parts(Type type, Type other, Type expected) + { + Assert.Equal(expected, actual: type.SubstituteTypeMatchers(other)); + } + + #endregion + + #region Test cases + + [Fact] + public void It_IsAnyType_used_as_generic_type_argument_of_method() + { + var mock = new Mock(); + mock.Setup(m => m.Method(It.IsAny>())).Verifiable(); + mock.Object.Method(new string[] { "a", "b" }); + mock.Verify(); + } + + public interface IX + { + void Method(IEnumerable args); + } + + #endregion + } +} diff --git a/tests/Moq.Tests/Regressions/IssueReportsFixture.cs b/tests/Moq.Tests/Regressions/IssueReportsFixture.cs index 337376404..3937fffa1 100644 --- a/tests/Moq.Tests/Regressions/IssueReportsFixture.cs +++ b/tests/Moq.Tests/Regressions/IssueReportsFixture.cs @@ -17,6 +17,8 @@ using Castle.DynamicProxy; +using Microsoft.Extensions.Logging; + using Moq; using Moq.Properties; using Moq.Protected; @@ -3152,6 +3154,57 @@ private void SetupIntMethod(Action callback) #endregion + #region 918 + + public class Issue918 + { + [Fact] // just to remind ourselves why It.IsAnyType (see next test) may be needed in a real-world scenario + public void object_with_Microsoft_Extensions_Logging_Abstractions_ILogger() + { + var loggerMock = new Mock(); + loggerMock.Setup(m => m.Log( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>())).Verifiable(); + + var logger = loggerMock.Object; + logger.Log( + logLevel: LogLevel.Information, + eventId: new EventId(123), + state: AttributeTargets.All, + exception: null, + formatter: (state, ex) => ""); + + Assert.Throws(() => loggerMock.Verify()); + } + + [Fact] + public void It_IsAnyType_with_Microsoft_Extensions_Logging_Abstractions_ILogger() + { + var loggerMock = new Mock(); + loggerMock.Setup(m => m.Log( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>())).Verifiable(); + + var logger = loggerMock.Object; + logger.Log( + logLevel: LogLevel.Information, + eventId: new EventId(123), + state: AttributeTargets.All, + exception: null, + formatter: (state, ex) => ""); + + loggerMock.Verify(); + } + } + + #endregion + #region 932 public class Issue932