Skip to content

Commit 6d85d11

Browse files
committed
Address review comments: rename, tests, perf fixes, cache unification
- Rename SetCustomAttribute to AddCustomAttribute to reflect additive semantics - Add GetTestPropertiesAsTraits unit tests (method, class, multi-level, empty) - Remove fragile HaveCount assertions from integration tests (filter-based instead) - Unify attribute cache: ReflectHelper delegates to ReflectionOperations cache - Replace LINQ allocations in GetTestCategories with direct iteration - Fix MockableReflectionOperations.GetSingleAttributeOrDefault error message - Make ReflectHelper methods static where they no longer access instance data
1 parent 16a1b05 commit 6d85d11

9 files changed

Lines changed: 169 additions & 102 deletions

File tree

src/Adapter/MSTestAdapter.PlatformServices/Discovery/AssemblyEnumerator.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -222,7 +222,7 @@ private static bool TryUnfoldITestDataSources(UnitTestElement test, DiscoveryTes
222222

223223
// PERF: Access the cached attribute array directly to avoid allocating two iterator state machines
224224
// for GetAttributes<Attribute>().OfType<ITestDataSource>() on every data-driven test during discovery.
225-
Attribute[] allAttributes = ReflectHelper.Instance.GetCustomAttributesCached(testMethodInfo.MethodInfo);
225+
Attribute[] allAttributes = ReflectHelper.GetCustomAttributesCached(testMethodInfo.MethodInfo);
226226

227227
// We need to use a temporary list to avoid adding tests to the main list if we fail to expand any data source.
228228
List<UnitTestElement> tempListOfTests = [];

src/Adapter/MSTestAdapter.PlatformServices/Discovery/TypeEnumerator.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -136,15 +136,15 @@ internal UnitTestElement GetTestFromMethod(MethodInfo method, bool classDisables
136136
// information (like test categories, traits, deployment items) which is not optimal.
137137
var testElement = new UnitTestElement(testMethod)
138138
{
139-
TestCategory = _reflectHelper.GetTestCategories(method, _type),
139+
TestCategory = ReflectHelper.GetTestCategories(method, _type),
140140
DoNotParallelize = classDisablesParallelization || _reflectHelper.IsAttributeDefined<DoNotParallelizeAttribute>(method),
141141
#if !WINDOWS_UWP && !WIN_UI
142142
DeploymentItems = PlatformServiceProvider.Instance.TestDeployment.GetDeploymentItems(method, _type, warnings),
143143
#endif
144-
Traits = [.. _reflectHelper.GetTestPropertiesAsTraits(method)],
144+
Traits = [.. ReflectHelper.GetTestPropertiesAsTraits(method)],
145145
};
146146

147-
Attribute[] attributes = _reflectHelper.GetCustomAttributesCached(method);
147+
Attribute[] attributes = ReflectHelper.GetCustomAttributesCached(method);
148148
TestMethodAttribute? testMethodAttribute = null;
149149
List<string>? workItemIds = null;
150150

src/Adapter/MSTestAdapter.PlatformServices/Helpers/ReflectHelper.cs

Lines changed: 15 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
using System.Security;
55

6+
using Microsoft.VisualStudio.TestPlatform.MSTestAdapter.PlatformServices;
67
using Microsoft.VisualStudio.TestPlatform.ObjectModel;
78
using Microsoft.VisualStudio.TestTools.UnitTesting;
89

@@ -15,11 +16,9 @@ internal class ReflectHelper : MarshalByRefObject
1516
private static readonly Lazy<ReflectHelper> InstanceValue = new(() => new());
1617
#pragma warning restore RS0030 // Do not use banned APIs
1718

18-
// PERF: This was moved from Dictionary<MemberInfo, Dictionary<string, object>> to Concurrent<ICustomAttributeProvider, Attribute[]>
19-
// storing an array allows us to store multiple attributes of the same type if we find them. It also has lower memory footprint, and is faster
20-
// when we are going through the whole collection. Giving us overall better perf.
21-
private readonly ConcurrentDictionary<ICustomAttributeProvider, Attribute[]> _attributeCache = [];
22-
19+
// PERF: Attribute caching is now centralized in ReflectionOperations._attributeCache.
20+
// ReflectHelper delegates to PlatformServiceProvider.Instance.ReflectionOperations.GetCustomAttributesCached
21+
// so that discovery and execution paths share a single cache, avoiding double memory usage.
2322
public static ReflectHelper Instance => InstanceValue.Value;
2423

2524
/// <summary>
@@ -141,7 +140,7 @@ internal static bool MatchReturnType(MethodInfo method, Type returnType)
141140
/// <param name="categoryAttributeProvider">The member to inspect.</param>
142141
/// <param name="owningType">The reflected type that owns <paramref name="categoryAttributeProvider"/>.</param>
143142
/// <returns>Categories defined.</returns>
144-
internal string[] GetTestCategories(MemberInfo categoryAttributeProvider, Type owningType)
143+
internal static string[] GetTestCategories(MemberInfo categoryAttributeProvider, Type owningType)
145144
{
146145
Attribute[] methodAttributes = GetCustomAttributesCached(categoryAttributeProvider);
147146
Attribute[] typeAttributes = GetCustomAttributesCached(owningType);
@@ -228,7 +227,7 @@ internal static bool IsDoNotParallelizeSet(Assembly assembly)
228227
/// </summary>
229228
/// <param name="testPropertyProvider">The member to inspect.</param>
230229
/// <returns>List of traits.</returns>
231-
internal Trait[] GetTestPropertiesAsTraits(MethodInfo testPropertyProvider)
230+
internal static Trait[] GetTestPropertiesAsTraits(MethodInfo testPropertyProvider)
232231
{
233232
Attribute[] attributesFromMethod = GetCustomAttributesCached(testPropertyProvider);
234233
Attribute[] attributesFromClass = testPropertyProvider.ReflectedType is { } testClass ? GetCustomAttributesCached(testClass) : [];
@@ -307,7 +306,7 @@ internal Trait[] GetTestPropertiesAsTraits(MethodInfo testPropertyProvider)
307306
/// <param name="attributeProvider">The member to inspect.</param>
308307
/// <param name="action">The action to perform.</param>
309308
/// <param name="state">The state to pass to action.</param>
310-
internal void PerformActionOnAttribute<TAttributeType, TState>(ICustomAttributeProvider attributeProvider, Action<TAttributeType, TState?> action, TState? state)
309+
internal static void PerformActionOnAttribute<TAttributeType, TState>(ICustomAttributeProvider attributeProvider, Action<TAttributeType, TState?> action, TState? state)
311310
where TAttributeType : Attribute
312311
{
313312
Attribute[] attributes = GetCustomAttributesCached(attributeProvider);
@@ -324,69 +323,20 @@ internal void PerformActionOnAttribute<TAttributeType, TState>(ICustomAttributeP
324323

325324
/// <summary>
326325
/// Gets and caches the attributes for the given type, or method.
326+
/// Delegates to <see cref="PlatformServiceProvider.Instance"/> so that
327+
/// discovery and execution share a single attribute cache.
327328
/// </summary>
328329
/// <param name="attributeProvider">The member to inspect.</param>
329330
/// <returns>attributes defined.</returns>
330-
internal Attribute[] GetCustomAttributesCached(ICustomAttributeProvider attributeProvider)
331-
{
332-
// If the information is cached, then use it otherwise populate the cache using
333-
// the reflection APIs.
334-
return _attributeCache.GetOrAdd(attributeProvider, GetAttributes);
335-
336-
// We are avoiding func allocation here.
337-
static Attribute[] GetAttributes(ICustomAttributeProvider attributeProvider)
338-
{
339-
// Populate the cache
340-
try
341-
{
342-
object[]? attributes = NotCachedReflectionAccessor.GetCustomAttributesNotCached(attributeProvider);
343-
return attributes is null ? [] : attributes as Attribute[] ?? [.. attributes.Cast<Attribute>()];
344-
}
345-
catch (Exception ex)
346-
{
347-
// Get the exception description
348-
string description;
349-
try
350-
{
351-
// Can throw if the Message or StackTrace properties throw exceptions
352-
description = ex.ToString();
353-
}
354-
catch (Exception ex2)
355-
{
356-
description = string.Format(CultureInfo.CurrentCulture, Resource.ExceptionOccuredWhileGettingTheExceptionDescription, ex.GetType().FullName, ex2.GetType().FullName); // ex.GetType().FullName +
357-
}
331+
internal static Attribute[] GetCustomAttributesCached(ICustomAttributeProvider attributeProvider)
332+
=> PlatformServiceProvider.Instance.ReflectionOperations.GetCustomAttributesCached(attributeProvider);
358333

359-
if (PlatformServiceProvider.Instance.AdapterTraceLogger.IsWarningEnabled)
360-
{
361-
PlatformServiceProvider.Instance.AdapterTraceLogger.Warning(Resource.FailedToGetCustomAttribute, attributeProvider.GetType().FullName!, description);
362-
}
363-
364-
return [];
365-
}
366-
}
367-
}
368-
369-
/// <summary>
370-
/// Reflection helper that is accessing Reflection directly, and won't cache the results.
371-
/// </summary>
372-
internal static class NotCachedReflectionAccessor
334+
internal static /* for tests */ void ClearCache()
373335
{
374-
/// <summary>
375-
/// Get custom attributes on a member without cache. Be CAREFUL where you use this, repeatedly accessing reflection without caching the results degrades the performance.
376-
/// </summary>
377-
/// <param name="attributeProvider">Member for which attributes needs to be retrieved.</param>
378-
/// <returns>All attributes of give type on member.</returns>
379-
public static object[]? GetCustomAttributesNotCached(ICustomAttributeProvider attributeProvider)
336+
// Delegate to the shared cache in ReflectionOperations.
337+
if (PlatformServiceProvider.Instance?.ReflectionOperations is ReflectionOperations reflectionOperations)
380338
{
381-
object[] attributesArray = attributeProvider is MemberInfo memberInfo
382-
? PlatformServiceProvider.Instance.ReflectionOperations.GetCustomAttributes(memberInfo)
383-
: PlatformServiceProvider.Instance.ReflectionOperations.GetCustomAttributes((Assembly)attributeProvider, typeof(Attribute));
384-
385-
return attributesArray; // TODO: Investigate if we rely on NRE
339+
reflectionOperations.ClearCache();
386340
}
387341
}
388-
389-
internal /* for tests */ void ClearCache()
390-
// Tests manipulate the platform reflection provider, and we end up caching different attributes than the class / method actually has.
391-
=> _attributeCache.Clear();
392342
}

src/Adapter/MSTestAdapter.PlatformServices/Helpers/ReflectionHelper.cs

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,39 @@ internal static class ReflectionHelper
2121
/// <returns>Categories defined.</returns>
2222
public static string[] GetTestCategories(this IReflectionOperations reflectionOperations, MemberInfo categoryAttributeProvider, Type owningType)
2323
{
24-
IEnumerable<TestCategoryBaseAttribute> methodCategories = reflectionOperations.GetAttributes<TestCategoryBaseAttribute>(categoryAttributeProvider);
25-
IEnumerable<TestCategoryBaseAttribute> typeCategories = reflectionOperations.GetAttributes<TestCategoryBaseAttribute>(owningType);
26-
IEnumerable<TestCategoryBaseAttribute> assemblyCategories = reflectionOperations.GetAttributes<TestCategoryBaseAttribute>(owningType.Assembly);
24+
// Iterate the cached attribute arrays directly to avoid LINQ iterator/state-machine
25+
// allocations. This follows the same allocation-free pattern used by GetTestPropertiesAsTraits.
26+
Attribute[] methodAttributes = reflectionOperations.GetCustomAttributesCached(categoryAttributeProvider);
27+
Attribute[] typeAttributes = reflectionOperations.GetCustomAttributesCached(owningType);
28+
Attribute[] assemblyAttributes = reflectionOperations.GetCustomAttributesCached(owningType.Assembly);
2729

28-
return [.. methodCategories.Concat(typeCategories).Concat(assemblyCategories).SelectMany(c => c.TestCategories)];
30+
List<string>? categories = null;
31+
32+
foreach (Attribute attribute in methodAttributes)
33+
{
34+
if (attribute is TestCategoryBaseAttribute categoryAttr)
35+
{
36+
(categories ??= []).AddRange(categoryAttr.TestCategories);
37+
}
38+
}
39+
40+
foreach (Attribute attribute in typeAttributes)
41+
{
42+
if (attribute is TestCategoryBaseAttribute categoryAttr)
43+
{
44+
(categories ??= []).AddRange(categoryAttr.TestCategories);
45+
}
46+
}
47+
48+
foreach (Attribute attribute in assemblyAttributes)
49+
{
50+
if (attribute is TestCategoryBaseAttribute categoryAttr)
51+
{
52+
(categories ??= []).AddRange(categoryAttr.TestCategories);
53+
}
54+
}
55+
56+
return categories is null ? [] : [.. categories];
2957
}
3058

3159
/// <summary>

test/IntegrationTests/PlatformServices.Desktop.IntegrationTests/ReflectionUtilityTests.cs

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Copyright (c) Microsoft Corporation. All rights reserved.
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
22
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
33

44
using AwesomeAssertions;
@@ -43,8 +43,9 @@ public void GetCustomAttributesShouldReturnAllAttributes()
4343
object[]? attributes = _reflectionOperations.GetCustomAttributes(methodInfo);
4444

4545
attributes.Should().NotBeNull();
46-
attributes.Should().HaveCount(2);
4746

47+
// Filter to known test attributes to avoid fragility if the test asset ever
48+
// enables nullable annotations (which would add compiler-generated attributes).
4849
string[] expectedAttributes = ["Owner : base", "TestCategory : base"];
4950
GetAttributeValuePairs(attributes!).Should().Equal(expectedAttributes);
5051
}
@@ -56,9 +57,10 @@ public void GetCustomAttributesShouldReturnAllAttributesWithBaseInheritance()
5657
object[]? attributes = _reflectionOperations.GetCustomAttributes(methodInfo);
5758

5859
attributes.Should().NotBeNull();
59-
attributes.Should().HaveCount(3);
6060

6161
// Notice that the Owner on the base method does not show up since it can only be defined once.
62+
// Filter to known test attributes to avoid fragility if the test asset ever
63+
// enables nullable annotations (which would add compiler-generated attributes).
6264
string[] expectedAttributes = ["Owner : derived", "TestCategory : derived", "TestCategory : base"];
6365
GetAttributeValuePairs(attributes!).Should().Equal(expectedAttributes);
6466
}
@@ -70,8 +72,8 @@ public void GetCustomAttributesOnTypeShouldReturnAllAttributes()
7072
object[]? attributes = _reflectionOperations.GetCustomAttributes(type);
7173

7274
attributes.Should().NotBeNull();
73-
attributes.Should().HaveCount(1);
7475

76+
// Filter to known test attributes to avoid fragility with compiler-generated attributes.
7577
string[] expectedAttributes = ["TestCategory : ba"];
7678
GetAttributeValuePairs(attributes!).Should().Equal(expectedAttributes);
7779
}
@@ -83,8 +85,8 @@ public void GetCustomAttributesOnTypeShouldReturnAllAttributesWithBaseInheritanc
8385
object[]? attributes = _reflectionOperations.GetCustomAttributes(type);
8486

8587
attributes.Should().NotBeNull();
86-
attributes.Should().HaveCount(2);
8788

89+
// Filter to known test attributes to avoid fragility with compiler-generated attributes.
8890
string[] expectedAttributes = ["TestCategory : a", "TestCategory : ba"];
8991
GetAttributeValuePairs(attributes!).Should().Equal(expectedAttributes);
9092
}
@@ -123,8 +125,8 @@ public void GetCustomAttributesShouldReturnAllAttributesIncludingUserDefinedAttr
123125
object[]? attributes = _reflectionOperations.GetCustomAttributes(methodInfo);
124126

125127
attributes.Should().NotBeNull();
126-
attributes.Should().HaveCount(3);
127128

129+
// Filter to known test attributes to avoid fragility with compiler-generated attributes.
128130
string[] expectedAttributes = ["Duration : superfast", "Owner : base", "TestCategory : base"];
129131
GetAttributeValuePairs(attributes!).Should().Equal(expectedAttributes);
130132
}

test/UnitTests/MSTestAdapter.PlatformServices.UnitTests/Execution/TestPropertyAttributeTests.cs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Copyright (c) Microsoft Corporation. All rights reserved.
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
22
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
33

44
using AwesomeAssertions;
@@ -22,7 +22,7 @@ public TestPropertyAttributeTests()
2222
testablePlatformServiceProvider.MockFileOperations.Setup(x => x.LoadAssembly(It.IsAny<string>())).Returns(GetType().Assembly);
2323
PlatformServiceProvider.Instance = testablePlatformServiceProvider;
2424

25-
ReflectHelper.Instance.ClearCache();
25+
ReflectHelper.ClearCache();
2626
}
2727

2828
protected override void Dispose(bool disposing)
@@ -39,7 +39,7 @@ protected override void Dispose(bool disposing)
3939

4040
public void GetTestMethodInfoShouldAddPropertiesFromContainingClassCorrectly()
4141
{
42-
TestPlatform.ObjectModel.Trait[] traits = [.. ReflectHelper.Instance.GetTestPropertiesAsTraits(typeof(DummyTestClassBase).GetMethod(nameof(DummyTestClassBase.VirtualTestMethodInBaseAndDerived))!)];
42+
TestPlatform.ObjectModel.Trait[] traits = [.. ReflectHelper.GetTestPropertiesAsTraits(typeof(DummyTestClassBase).GetMethod(nameof(DummyTestClassBase.VirtualTestMethodInBaseAndDerived))!)];
4343
traits.Length.Should().Be(3);
4444
traits[0].Name.Should().Be("TestMethodKeyFromBase");
4545
traits[0].Value.Should().Be("TestMethodValueFromBase");
@@ -51,7 +51,7 @@ public void GetTestMethodInfoShouldAddPropertiesFromContainingClassCorrectly()
5151

5252
public void GetTestMethodInfoShouldAddPropertiesFromContainingClassAndBaseClassesAndOverriddenMethodsCorrectly_OverriddenIsTestMethod()
5353
{
54-
TestPlatform.ObjectModel.Trait[] traits = [.. ReflectHelper.Instance.GetTestPropertiesAsTraits(typeof(DummyTestClassDerived).GetMethod(nameof(DummyTestClassDerived.VirtualTestMethodInBaseAndDerived))!)];
54+
TestPlatform.ObjectModel.Trait[] traits = [.. ReflectHelper.GetTestPropertiesAsTraits(typeof(DummyTestClassDerived).GetMethod(nameof(DummyTestClassDerived.VirtualTestMethodInBaseAndDerived))!)];
5555
traits.Length.Should().Be(6);
5656
traits[0].Name.Should().Be("DerivedMethod1Key");
5757
traits[0].Value.Should().Be("DerivedMethod1Value");
@@ -69,7 +69,7 @@ public void GetTestMethodInfoShouldAddPropertiesFromContainingClassAndBaseClasse
6969

7070
public void GetTestMethodInfoShouldAddPropertiesFromContainingClassAndBaseClassesAndOverriddenMethodsCorrectly_OverriddenIsNotTestMethod()
7171
{
72-
TestPlatform.ObjectModel.Trait[] traits = [.. ReflectHelper.Instance.GetTestPropertiesAsTraits(typeof(DummyTestClassDerived).GetMethod(nameof(DummyTestClassDerived.VirtualTestMethodInDerivedButNotTestMethodInBase))!)];
72+
TestPlatform.ObjectModel.Trait[] traits = [.. ReflectHelper.GetTestPropertiesAsTraits(typeof(DummyTestClassDerived).GetMethod(nameof(DummyTestClassDerived.VirtualTestMethodInDerivedButNotTestMethodInBase))!)];
7373
traits.Length.Should().Be(6);
7474
traits[0].Name.Should().Be("DerivedMethod2Key");
7575
traits[0].Value.Should().Be("DerivedMethod2Value");

test/UnitTests/MSTestAdapter.PlatformServices.UnitTests/Execution/TypeCacheTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ public TypeCacheTests()
3636
_testablePlatformServiceProvider = new TestablePlatformServiceProvider();
3737
PlatformServiceProvider.Instance = _testablePlatformServiceProvider;
3838

39-
ReflectHelper.Instance.ClearCache();
39+
ReflectHelper.ClearCache();
4040

4141
SetupMocks();
4242
}

0 commit comments

Comments
 (0)