diff --git a/src/Analyzers/MSTest.Analyzers/AvoidAssertAreEqualOnCollectionsAnalyzer.cs b/src/Analyzers/MSTest.Analyzers/AvoidAssertAreEqualOnCollectionsAnalyzer.cs index fddde8e247..abf8dc94d8 100644 --- a/src/Analyzers/MSTest.Analyzers/AvoidAssertAreEqualOnCollectionsAnalyzer.cs +++ b/src/Analyzers/MSTest.Analyzers/AvoidAssertAreEqualOnCollectionsAnalyzer.cs @@ -53,11 +53,16 @@ public override void Initialize(AnalysisContext context) return; } - context.RegisterOperationAction(context => AnalyzeInvocation(context, assertSymbol, genericEnumerableSymbol), OperationKind.Invocation); + // May be null on very old target frameworks; when it is, only the IEquatable<self> check becomes a + // no-op — the object.Equals override detection still runs. + INamedTypeSymbol? equatableSymbol = compilation.GetOrCreateTypeByMetadataName(WellKnownTypeNames.SystemIEquatable1); + INamedTypeSymbol? equalityComparerSymbol = compilation.GetOrCreateTypeByMetadataName(WellKnownTypeNames.SystemCollectionsGenericEqualityComparer1); + + context.RegisterOperationAction(context => AnalyzeInvocation(context, assertSymbol, genericEnumerableSymbol, equatableSymbol, equalityComparerSymbol), OperationKind.Invocation); }); } - private static void AnalyzeInvocation(OperationAnalysisContext context, INamedTypeSymbol assertSymbol, INamedTypeSymbol genericEnumerableSymbol) + private static void AnalyzeInvocation(OperationAnalysisContext context, INamedTypeSymbol assertSymbol, INamedTypeSymbol genericEnumerableSymbol, INamedTypeSymbol? equatableSymbol, INamedTypeSymbol? equalityComparerSymbol) { var invocation = (IInvocationOperation)context.Operation; IMethodSymbol targetMethod = invocation.TargetMethod; @@ -89,6 +94,23 @@ targetMethod.Name is not ("AreEqual" or "AreNotEqual") || // patterns where the caller widened to a non-collection type (e.g. `Assert.AreEqual(arr1, arr2)` // or `Assert.AreEqual((object)arr1, (object)arr2)`) which would otherwise silently use reference equality. ITypeSymbol comparedType = targetMethod.TypeArguments[0]; + + // Opt out when the compared type declares its own equality (implements IEquatable<itself> or overrides + // object.Equals). The author has then deliberately chosen a custom, non-reference equality that Assert.AreEqual + // honors via EqualityComparer<T>.Default, so suggesting a sequence/structural comparison would second-guess + // a deliberate decision (see issue #9971). Two things are important here: + // * The check must be based on the *selected generic type argument* T, not the argument's runtime collection + // type. Widening to a base type (e.g. Assert.AreEqual<object>(collection, collection)) discards the + // collection's IEquatable<self> and falls back to reference equality — exactly the footgun the rule targets. + // * It must not apply when the caller supplies a *custom* comparer, because then EqualityComparer<T>.Default + // (and therefore the type's own equality) is not used at all. A `null` comparer or an explicit + // `EqualityComparer<T>.Default` argument is equivalent to the parameterless overload (Assert falls back to + // the default comparer — see Assert.AreEqual.cs), so those keep the opt-out. + if (!HasCustomComparerArgument(invocation, comparedType, equalityComparerSymbol) && DeclaresOwnEquality(comparedType, equatableSymbol)) + { + return; + } + ITypeSymbol? reportedType = ShouldReport(comparedType, genericEnumerableSymbol) ? comparedType : GetCollectionArgumentType(invocation, firstParameterName, genericEnumerableSymbol) @@ -104,6 +126,45 @@ targetMethod.Name is not ("AreEqual" or "AreNotEqual") || context.ReportDiagnostic(invocation.CreateDiagnostic(Rule, methodName, comparedTypeDisplay)); } + // Returns true only when a `comparer` argument is supplied that is not provably the default comparer for the + // selected type argument T. `null` and `EqualityComparer.Default` both dispatch to EqualityComparer.Default at + // runtime, so they must keep the equality opt-out; any other comparer bypasses the type's own equality and should + // re-enable the diagnostic. + private static bool HasCustomComparerArgument(IInvocationOperation invocation, ITypeSymbol comparedType, INamedTypeSymbol? equalityComparerSymbol) + { + foreach (IArgumentOperation argument in invocation.Arguments) + { + if (argument.Parameter?.Name != "comparer") + { + continue; + } + + // Only peel built-in conversions (identity, implicit reference, boxing). A user-defined conversion actually + // executes and can turn a null source into a custom comparer, so it must be treated as opaque — otherwise a + // null underneath a user-defined operator would be misclassified as the default comparer. + IOperation value = argument.Value.WalkDownBuiltInConversion(); + + // Any constant-null comparer (null literal, `default`, `default(IEqualityComparer)`, a const null field, …) + // falls back to EqualityComparer.Default in Assert, and a reference to `EqualityComparer.Default` is that + // same default comparer — neither bypasses the type's own equality. + // + // The reference must be `EqualityComparer.Default` for the *selected* T: `IEqualityComparer` is + // contravariant, so `Assert.AreEqual(d1, d2, EqualityComparer.Default)` compiles, but that base + // comparer dispatches to Base's equality — not Derived's — so it is a custom comparer for T = Derived. + bool isDefaultComparer = + value.ConstantValue is { HasValue: true, Value: null } + || (value is IPropertyReferenceOperation { Property: { Name: "Default", IsStatic: true } property } + && equalityComparerSymbol is not null + && SymbolEqualityComparer.Default.Equals(property.ContainingType.OriginalDefinition, equalityComparerSymbol) + && property.ContainingType.TypeArguments.Length == 1 + && SymbolEqualityComparer.Default.Equals(property.ContainingType.TypeArguments[0], comparedType)); + + return !isDefaultComparer; + } + + return false; + } + private static ITypeSymbol? GetCollectionArgumentType(IInvocationOperation invocation, string parameterName, INamedTypeSymbol genericEnumerableSymbol) { IArgumentOperation? argument = invocation.Arguments.FirstOrDefault(arg => arg.Parameter?.Name == parameterName); @@ -122,6 +183,115 @@ private static bool ShouldReport(ITypeSymbol comparedType, INamedTypeSymbol gene => comparedType.SpecialType != SpecialType.System_String && ImplementsGenericEnumerable(comparedType, genericEnumerableSymbol); + private static bool DeclaresOwnEquality(ITypeSymbol type, INamedTypeSymbol? equatableSymbol) + => type switch + { + INamedTypeSymbol namedType => ImplementsSelfEquatable(namedType, namedType, equatableSymbol) || OverridesObjectEquals(namedType), + + // The equality target is the type parameter T itself (the type whose EqualityComparer.Default is used). + ITypeParameterSymbol typeParameter => TypeParameterDeclaresOwnEquality(typeParameter, typeParameter, equatableSymbol), + + _ => false, + }; + + // EqualityComparer<T>.Default honors a `where T : IEquatable` constraint, and a class constraint that overrides + // object.Equals is inherited by every T. A bare `where T : IEnumerable<...>` constraint has neither, so it stays the + // reference-equality footgun we still want to flag. + // + // `equalityTarget` stays the original type parameter T while we traverse constraints, including transitive + // type-parameter constraints (`where T : U where U : ...`). The IEquatable check must be against T: for + // `where T : ISelf` with `ISelf : IEquatable`, a concrete T need not implement IEquatable<T>, so it would + // still use reference equality. An object.Equals override on any reachable class constraint is inherited by T and so + // is honored regardless of the equality target. + private static bool TypeParameterDeclaresOwnEquality(ITypeParameterSymbol typeParameter, ITypeSymbol equalityTarget, INamedTypeSymbol? equatableSymbol) + { + foreach (ITypeSymbol constraintType in typeParameter.ConstraintTypes) + { + switch (constraintType) + { + case INamedTypeSymbol namedConstraint when ImplementsSelfEquatable(namedConstraint, equalityTarget, equatableSymbol) || OverridesObjectEquals(namedConstraint): + return true; + + case ITypeParameterSymbol nestedTypeParameter when TypeParameterDeclaresOwnEquality(nestedTypeParameter, equalityTarget, equatableSymbol): + return true; + } + } + + return false; + } + + // Returns true when `candidate` is or implements IEquatable<equalityType>, i.e. the equality contract that + // EqualityComparer<equalityType>.Default would dispatch to. `equalityType` is the type whose default comparer + // is used (the compared type or the type parameter); `candidate` is the type (or constraint) we inspect. + private static bool ImplementsSelfEquatable(INamedTypeSymbol candidate, ITypeSymbol equalityType, INamedTypeSymbol? equatableSymbol) + { + if (equatableSymbol is null) + { + return false; + } + + // The candidate can be the IEquatable<T> interface itself (e.g. a `where T : IEquatable` constraint) + // or a type that lists it among its implemented interfaces (e.g. `class C : IEquatable`). + if (IsEquatableOf(candidate, equalityType, equatableSymbol)) + { + return true; + } + + foreach (INamedTypeSymbol implemented in candidate.AllInterfaces) + { + if (IsEquatableOf(implemented, equalityType, equatableSymbol)) + { + return true; + } + } + + return false; + } + + private static bool IsEquatableOf(INamedTypeSymbol type, ITypeSymbol equalityType, INamedTypeSymbol equatableSymbol) + => SymbolEqualityComparer.Default.Equals(type.OriginalDefinition, equatableSymbol) + && type.TypeArguments.Length == 1 + && SymbolEqualityComparer.Default.Equals(type.TypeArguments[0], equalityType); + + private static bool OverridesObjectEquals(INamedTypeSymbol type) + { + // Stop before object AND ValueType: System.ValueType overrides object.Equals, so walking into it would treat + // every struct that implements IEnumerable as declaring its own equality even when it has no Equals of its + // own (its backing array/list field would then be compared by reference). An explicit struct override is found + // on the concrete type before we reach ValueType. + for (INamedTypeSymbol? current = type; + current is not null && current.SpecialType is not (SpecialType.System_Object or SpecialType.System_ValueType or SpecialType.System_Enum); + current = current.BaseType) + { + foreach (ISymbol member in current.GetMembers(nameof(Equals))) + { + if (member is IMethodSymbol { IsOverride: true, Parameters.Length: 1, ReturnType.SpecialType: SpecialType.System_Boolean } method && + method.Parameters[0].Type.SpecialType == SpecialType.System_Object && + OverrideRootIsObjectEquals(method)) + { + return true; + } + } + } + + return false; + } + + // `IsOverride` only proves the method overrides *some* virtual slot. A base class can declare + // `new virtual bool Equals(object)`, and a derived override of that member is not an override of + // object.Equals — EqualityComparer<T>.Default still dispatches the unchanged object.Equals slot + // (reference equality). Follow the override chain to its root and require it to be object.Equals. + private static bool OverrideRootIsObjectEquals(IMethodSymbol method) + { + IMethodSymbol root = method; + while (root.OverriddenMethod is { } overridden) + { + root = overridden; + } + + return root.ContainingType?.SpecialType == SpecialType.System_Object; + } + private static bool HasNullLiteralArgument(IInvocationOperation invocation, string parameterName) { IArgumentOperation? argument = invocation.Arguments.FirstOrDefault(arg => arg.Parameter?.Name == parameterName); diff --git a/src/Analyzers/MSTest.Analyzers/Helpers/WellKnownTypeNames.cs b/src/Analyzers/MSTest.Analyzers/Helpers/WellKnownTypeNames.cs index da55bf4ecf..7d6a3ca8b0 100644 --- a/src/Analyzers/MSTest.Analyzers/Helpers/WellKnownTypeNames.cs +++ b/src/Analyzers/MSTest.Analyzers/Helpers/WellKnownTypeNames.cs @@ -47,12 +47,14 @@ internal static class WellKnownTypeNames public const string MicrosoftVisualStudioTestToolsUnitTestingWorkItemAttribute = "Microsoft.VisualStudio.TestTools.UnitTesting.WorkItemAttribute"; public const string System = "System"; + public const string SystemCollectionsGenericEqualityComparer1 = "System.Collections.Generic.EqualityComparer`1"; public const string SystemCollectionsGenericIEnumerable1 = "System.Collections.Generic.IEnumerable`1"; public const string SystemCollectionsIDictionary = "System.Collections.IDictionary"; public const string SystemDescriptionAttribute = "System.ComponentModel.DescriptionAttribute"; public const string SystemFunc1 = "System.Func`1"; public const string SystemIAsyncDisposable = "System.IAsyncDisposable"; public const string SystemIDisposable = "System.IDisposable"; + public const string SystemIEquatable1 = "System.IEquatable`1"; public const string SystemLinqEnumerable = "System.Linq.Enumerable"; public const string SystemLinqExpressionsExpression1 = "System.Linq.Expressions.Expression`1"; public const string SystemOperatingSystem = "System.OperatingSystem"; diff --git a/test/UnitTests/MSTest.Analyzers.UnitTests/AvoidAssertAreEqualOnCollectionsAnalyzerTests.cs b/test/UnitTests/MSTest.Analyzers.UnitTests/AvoidAssertAreEqualOnCollectionsAnalyzerTests.cs index ca2397781a..e66c872397 100644 --- a/test/UnitTests/MSTest.Analyzers.UnitTests/AvoidAssertAreEqualOnCollectionsAnalyzerTests.cs +++ b/test/UnitTests/MSTest.Analyzers.UnitTests/AvoidAssertAreEqualOnCollectionsAnalyzerTests.cs @@ -826,6 +826,744 @@ public sealed class Wrapper await VerifyCS.VerifyAnalyzerAsync(code); } + [TestMethod] + public async Task WhenUsingAssertAreEqualOnCollectionImplementingIEquatable_DoNotReportDiagnostic() + { + // The type is a collection but declares its own equality via IEquatable, so Assert.AreEqual + // honors that intentional equality. Suggesting a sequence comparison would second-guess the author (issue #9971). + string code = """ + #nullable enable + using System; + using System.Collections; + using System.Collections.Generic; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + [TestClass] + public class MyTestClass + { + [TestMethod] + public void MyTestMethod() + { + MyCollection c1 = new(); + MyCollection c2 = new(); + Assert.AreEqual(c1, c2); + } + + private sealed class MyCollection : IEnumerable, IEquatable + { + public bool Equals(MyCollection? other) => true; + + public IEnumerator GetEnumerator() => new List().GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } + } + """; + + await VerifyCS.VerifyAnalyzerAsync(code); + } + + [TestMethod] + public async Task WhenUsingAssertAreNotEqualOnCollectionImplementingIEquatable_DoNotReportDiagnostic() + { + string code = """ + #nullable enable + using System; + using System.Collections; + using System.Collections.Generic; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + [TestClass] + public class MyTestClass + { + [TestMethod] + public void MyTestMethod() + { + MyCollection c1 = new(); + MyCollection c2 = new(); + Assert.AreNotEqual(c1, c2); + } + + private sealed class MyCollection : IEnumerable, IEquatable + { + public bool Equals(MyCollection? other) => true; + + public IEnumerator GetEnumerator() => new List().GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } + } + """; + + await VerifyCS.VerifyAnalyzerAsync(code); + } + + [TestMethod] + public async Task WhenUsingAssertAreEqualOnCollectionOverridingObjectEquals_DoNotReportDiagnostic() + { + // Overriding object.Equals is the same intentional-equality signal as IEquatable. + string code = """ + #nullable enable + using System.Collections; + using System.Collections.Generic; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + [TestClass] + public class MyTestClass + { + [TestMethod] + public void MyTestMethod() + { + MyCollection c1 = new(); + MyCollection c2 = new(); + Assert.AreEqual(c1, c2); + } + + private sealed class MyCollection : IEnumerable + { + public override bool Equals(object? obj) => obj is MyCollection; + + public override int GetHashCode() => 0; + + public IEnumerator GetEnumerator() => new List().GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } + } + """; + + await VerifyCS.VerifyAnalyzerAsync(code); + } + + [TestMethod] + public async Task WhenUsingAssertAreEqualOnCollectionImplementingIEquatableOfOtherType_ReportDiagnostic() + { + // IEquatable is not used by EqualityComparer.Default, so the type still + // falls back to reference equality — the footgun the rule targets. + string code = """ + #nullable enable + using System; + using System.Collections; + using System.Collections.Generic; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + [TestClass] + public class MyTestClass + { + [TestMethod] + public void MyTestMethod() + { + MyCollection c1 = new(); + MyCollection c2 = new(); + {|#0:Assert.AreEqual(c1, c2)|}; + } + + private sealed class MyCollection : IEnumerable, IEquatable + { + public bool Equals(string? other) => false; + + public IEnumerator GetEnumerator() => new List().GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } + } + """; + + await VerifyCS.VerifyAnalyzerAsync(code, ExpectedDiagnostic("Assert.AreEqual", "MyCollection")); + } + + [TestMethod] + public async Task WhenUsingAssertAreEqualOnCollectionImplementingIEquatableButWidenedToObject_ReportDiagnostic() + { + // The collection declares its own equality via IEquatable, but the call is widened to object. + // EqualityComparer.Default ignores IEquatable and uses reference equality, so this + // is still the footgun the rule targets. The opt-out must key off the selected generic type argument. + string code = """ + #nullable enable + using System; + using System.Collections; + using System.Collections.Generic; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + [TestClass] + public class MyTestClass + { + [TestMethod] + public void MyTestMethod() + { + MyCollection c1 = new(); + MyCollection c2 = new(); + {|#0:Assert.AreEqual(c1, c2)|}; + } + + private sealed class MyCollection : IEnumerable, IEquatable + { + public bool Equals(MyCollection? other) => true; + + public IEnumerator GetEnumerator() => new List().GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } + } + """; + + await VerifyCS.VerifyAnalyzerAsync(code, ExpectedDiagnostic("Assert.AreEqual", "MyCollection")); + } + + [TestMethod] + public async Task WhenUsingAssertAreEqualOnCollectionImplementingIEquatableWithCustomComparer_ReportDiagnostic() + { + // A genuinely custom comparer bypasses the type's own IEquatable, so the opt-out must not apply and + // MSTEST0065 should still fire. + string code = """ + #nullable enable + using System; + using System.Collections; + using System.Collections.Generic; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + [TestClass] + public class MyTestClass + { + [TestMethod] + public void MyTestMethod() + { + MyCollection c1 = new(); + MyCollection c2 = new(); + {|#0:Assert.AreEqual(c1, c2, new CustomComparer())|}; + } + + private sealed class CustomComparer : IEqualityComparer + { + public bool Equals(MyCollection? x, MyCollection? y) => true; + + public int GetHashCode(MyCollection obj) => 0; + } + + private sealed class MyCollection : IEnumerable, IEquatable + { + public bool Equals(MyCollection? other) => true; + + public IEnumerator GetEnumerator() => new List().GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } + } + """; + + await VerifyCS.VerifyAnalyzerAsync(code, ExpectedDiagnostic("Assert.AreEqual", "MyCollection")); + } + + [TestMethod] + public async Task WhenUsingAssertAreEqualOnCollectionImplementingIEquatableWithExplicitDefaultComparer_DoNotReportDiagnostic() + { + // `EqualityComparer.Default` dispatches to the type's own IEquatable, so it is equivalent + // to the parameterless overload and keeps the opt-out. + string code = """ + #nullable enable + using System; + using System.Collections; + using System.Collections.Generic; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + [TestClass] + public class MyTestClass + { + [TestMethod] + public void MyTestMethod() + { + MyCollection c1 = new(); + MyCollection c2 = new(); + Assert.AreEqual(c1, c2, EqualityComparer.Default); + } + + private sealed class MyCollection : IEnumerable, IEquatable + { + public bool Equals(MyCollection? other) => true; + + public IEnumerator GetEnumerator() => new List().GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } + } + """; + + await VerifyCS.VerifyAnalyzerAsync(code); + } + + [TestMethod] + public async Task WhenUsingAssertAreEqualOnCollectionWithBaseTypeDefaultComparer_ReportDiagnostic() + { + // IEqualityComparer is contravariant, so EqualityComparer.Default compiles as the comparer for a + // Derived argument. But it dispatches to Base's equality, not Derived's IEquatable, so it is a custom + // comparer for T = Derived and MSTEST0065 must still fire. + string code = """ + #nullable enable + using System; + using System.Collections; + using System.Collections.Generic; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + [TestClass] + public class MyTestClass + { + [TestMethod] + public void MyTestMethod() + { + Derived c1 = new(); + Derived c2 = new(); + {|#0:Assert.AreEqual(c1, c2, EqualityComparer.Default)|}; + } + + private class Base + { + } + + private sealed class Derived : Base, IEnumerable, IEquatable + { + public bool Equals(Derived? other) => true; + + public IEnumerator GetEnumerator() => new List().GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } + } + """; + + await VerifyCS.VerifyAnalyzerAsync(code, ExpectedDiagnostic("Assert.AreEqual", "Derived")); + } + + [TestMethod] + public async Task WhenUsingAssertAreEqualOnCollectionImplementingIEquatableWithNullComparer_DoNotReportDiagnostic() + { + // A null comparer is replaced with EqualityComparer.Default by Assert, so it is equivalent to the + // parameterless overload and keeps the opt-out. + string code = """ + #nullable enable + using System; + using System.Collections; + using System.Collections.Generic; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + [TestClass] + public class MyTestClass + { + [TestMethod] + public void MyTestMethod() + { + MyCollection c1 = new(); + MyCollection c2 = new(); + Assert.AreEqual(c1, c2, (IEqualityComparer)null!); + } + + private sealed class MyCollection : IEnumerable, IEquatable + { + public bool Equals(MyCollection? other) => true; + + public IEnumerator GetEnumerator() => new List().GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } + } + """; + + await VerifyCS.VerifyAnalyzerAsync(code); + } + + [TestMethod] + public async Task WhenUsingAssertAreEqualOnCollectionImplementingIEquatableWithDefaultComparerExpression_DoNotReportDiagnostic() + { + // `default(IEqualityComparer)` constant-folds to null, which Assert replaces with + // EqualityComparer.Default, so it keeps the opt-out just like a null literal. + string code = """ + #nullable enable + using System; + using System.Collections; + using System.Collections.Generic; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + [TestClass] + public class MyTestClass + { + [TestMethod] + public void MyTestMethod() + { + MyCollection c1 = new(); + MyCollection c2 = new(); + Assert.AreEqual(c1, c2, default(IEqualityComparer)!); + } + + private sealed class MyCollection : IEnumerable, IEquatable + { + public bool Equals(MyCollection? other) => true; + + public IEnumerator GetEnumerator() => new List().GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } + } + """; + + await VerifyCS.VerifyAnalyzerAsync(code); + } + + [TestMethod] + public async Task WhenUsingAssertAreEqualOnTypeParameterConstrainedToIEquatable_DoNotReportDiagnostic() + { + // `where T : IEnumerable, IEquatable` guarantees EqualityComparer.Default honors IEquatable, + // so the comparison uses the constrained equality, not reference equality. + string code = """ + using System; + using System.Collections.Generic; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + [TestClass] + public class MyTestClass + { + [TestMethod] + public void MyTestMethod(T a, T b) where T : IEnumerable, IEquatable + { + Assert.AreEqual(a, b); + } + } + """; + + await VerifyCS.VerifyAnalyzerAsync(code); + } + + [TestMethod] + public async Task WhenUsingAssertAreEqualOnCollectionOverridingNewVirtualEquals_ReportDiagnostic() + { + // The base declares `new virtual bool Equals(object)` (a different slot from object.Equals), and the + // collection overrides that. EqualityComparer.Default still dispatches the unchanged + // object.Equals slot (reference equality), so this must NOT be treated as declaring its own equality. + string code = """ + #nullable enable + using System.Collections; + using System.Collections.Generic; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + [TestClass] + public class MyTestClass + { + [TestMethod] + public void MyTestMethod() + { + MyCollection c1 = new(); + MyCollection c2 = new(); + {|#0:Assert.AreEqual(c1, c2)|}; + } + + private class Base + { + public new virtual bool Equals(object? obj) => true; + } + + private sealed class MyCollection : Base, IEnumerable + { + public override bool Equals(object? obj) => true; + + public IEnumerator GetEnumerator() => new List().GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } + } + """; + + await VerifyCS.VerifyAnalyzerAsync(code, ExpectedDiagnostic("Assert.AreEqual", "MyCollection")); + } + + [TestMethod] + public async Task WhenUsingAssertAreEqualOnStructCollectionWithoutOwnEquality_ReportDiagnostic() + { + // A struct implementing IEnumerable inherits ValueType.Equals but declares no equality of its own, + // so EqualityComparer.Default falls back to ValueType's field-wise (reflection-based) comparison rather + // than any intentional equality. The walk must stop before System.ValueType so this is still reported. + string code = """ + #nullable enable + using System.Collections; + using System.Collections.Generic; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + [TestClass] + public class MyTestClass + { + [TestMethod] + public void MyTestMethod() + { + MyCollection c1 = default; + MyCollection c2 = default; + {|#0:Assert.AreEqual(c1, c2)|}; + } + + private struct MyCollection : IEnumerable + { + public IEnumerator GetEnumerator() => new List().GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } + } + """; + + await VerifyCS.VerifyAnalyzerAsync(code, ExpectedDiagnostic("Assert.AreEqual", "MyCollection")); + } + + [TestMethod] + public async Task WhenUsingAssertAreEqualOnStructCollectionOverridingObjectEquals_DoNotReportDiagnostic() + { + // A struct that explicitly overrides object.Equals is found on the concrete type before ValueType, so its + // intentional equality is honored and MSTEST0065 is suppressed. + string code = """ + #nullable enable + using System.Collections; + using System.Collections.Generic; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + [TestClass] + public class MyTestClass + { + [TestMethod] + public void MyTestMethod() + { + MyCollection c1 = default; + MyCollection c2 = default; + Assert.AreEqual(c1, c2); + } + + private struct MyCollection : IEnumerable + { + public override bool Equals(object? obj) => true; + + public override int GetHashCode() => 0; + + public IEnumerator GetEnumerator() => new List().GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } + } + """; + + await VerifyCS.VerifyAnalyzerAsync(code); + } + + [TestMethod] + public async Task WhenUsingAssertAreEqualOnTypeParameterConstrainedToSelfEquatingType_ReportDiagnostic() + { + // `where T : ISelf` with `ISelf : IEquatable` does NOT guarantee T implements IEquatable: + // a concrete T is only required to be assignable to ISelf. EqualityComparer.Default therefore may still + // use reference equality, so the diagnostic must be preserved (the constraint's own IEquatable is not T's). + string code = """ + using System; + using System.Collections.Generic; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + [TestClass] + public class MyTestClass + { + [TestMethod] + public void MyTestMethod(T a, T b) where T : ISelf + { + {|#0:Assert.AreEqual(a, b)|}; + } + + public interface ISelf : IEnumerable, IEquatable + { + } + } + """; + + await VerifyCS.VerifyAnalyzerAsync(code, ExpectedDiagnostic("Assert.AreEqual", "T")); + } + + [TestMethod] + public async Task WhenUsingAssertAreEqualOnTypeParameterConstrainedToClassOverridingObjectEquals_DoNotReportDiagnostic() + { + // A class constraint that overrides object.Equals is inherited by every T, so EqualityComparer.Default + // uses that intentional equality. + string code = """ + #nullable enable + using System.Collections; + using System.Collections.Generic; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + [TestClass] + public class MyTestClass + { + [TestMethod] + public void MyTestMethod(T a, T b) where T : BaseCollection + { + Assert.AreEqual(a, b); + } + + public class BaseCollection : IEnumerable + { + public override bool Equals(object? obj) => true; + + public override int GetHashCode() => 0; + + public IEnumerator GetEnumerator() => new List().GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } + } + """; + + await VerifyCS.VerifyAnalyzerAsync(code); + } + + [TestMethod] + public async Task WhenUsingAssertAreEqualOnTypeParameterWithTransitiveClassConstraintOverridingObjectEquals_DoNotReportDiagnostic() + { + // Transitive type-parameter constraint: `where T : U where U : BaseCollection`. Every concrete T derives from + // BaseCollection and inherits its object.Equals override, so EqualityComparer.Default uses that equality. + // The constraint traversal must follow the nested type parameter U to reach BaseCollection. + string code = """ + #nullable enable + using System.Collections; + using System.Collections.Generic; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + [TestClass] + public class MyTestClass + { + [TestMethod] + public void MyTestMethod(T a, T b) where T : U where U : BaseCollection + { + Assert.AreEqual(a, b); + } + + public class BaseCollection : IEnumerable + { + public override bool Equals(object? obj) => true; + + public override int GetHashCode() => 0; + + public IEnumerator GetEnumerator() => new List().GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } + } + """; + + await VerifyCS.VerifyAnalyzerAsync(code); + } + + [TestMethod] + public async Task WhenUsingAssertAreEqualOnCollectionImplementingIEquatableOfBaseType_ReportDiagnostic() + { + // IEquatable is invariant: EqualityComparer.Default requires Derived to implement + // IEquatable exactly. A collection implementing only IEquatable (and not overriding + // object.Equals) still falls back to reference equality, so this must be reported. + string code = """ + #nullable enable + using System; + using System.Collections; + using System.Collections.Generic; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + [TestClass] + public class MyTestClass + { + [TestMethod] + public void MyTestMethod() + { + Derived c1 = new(); + Derived c2 = new(); + {|#0:Assert.AreEqual(c1, c2)|}; + } + + private class Base + { + } + + private sealed class Derived : Base, IEnumerable, IEquatable + { + public bool Equals(Base? other) => true; + + public IEnumerator GetEnumerator() => new List().GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } + } + """; + + await VerifyCS.VerifyAnalyzerAsync(code, ExpectedDiagnostic("Assert.AreEqual", "Derived")); + } + + [TestMethod] + public async Task WhenUsingAssertAreEqualOnCollectionWithExplicitInterfaceIEquatable_DoNotReportDiagnostic() + { + // Explicit interface implementation of IEquatable is still the equality EqualityComparer.Default + // dispatches to (via AllInterfaces), so the opt-out applies. + string code = """ + #nullable enable + using System; + using System.Collections; + using System.Collections.Generic; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + [TestClass] + public class MyTestClass + { + [TestMethod] + public void MyTestMethod() + { + MyCollection c1 = new(); + MyCollection c2 = new(); + Assert.AreEqual(c1, c2); + } + + private sealed class MyCollection : IEnumerable, IEquatable + { + bool IEquatable.Equals(MyCollection? other) => true; + + public IEnumerator GetEnumerator() => new List().GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } + } + """; + + await VerifyCS.VerifyAnalyzerAsync(code); + } + + [TestMethod] + public async Task WhenUsingAssertAreNotEqualOnCollectionImplementingIEquatableButWidenedToObject_ReportDiagnostic() + { + // AreNotEqual mirror of the widened-to-object case: EqualityComparer.Default ignores + // IEquatable, so this is still the reference-equality footgun and must be reported. + string code = """ + #nullable enable + using System; + using System.Collections; + using System.Collections.Generic; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + [TestClass] + public class MyTestClass + { + [TestMethod] + public void MyTestMethod() + { + MyCollection c1 = new(); + MyCollection c2 = new(); + {|#0:Assert.AreNotEqual(c1, c2)|}; + } + + private sealed class MyCollection : IEnumerable, IEquatable + { + public bool Equals(MyCollection? other) => true; + + public IEnumerator GetEnumerator() => new List().GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } + } + """; + + await VerifyCS.VerifyAnalyzerAsync(code, ExpectedDiagnostic("Assert.AreNotEqual", "MyCollection")); + } + private static DiagnosticResult ExpectedDiagnostic(string methodName, string typeName) => VerifyCS.Diagnostic().WithLocation(0).WithArguments(methodName, typeName);