Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Lower reflection overhead #2839

Merged
merged 23 commits into from
May 30, 2024
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
1 change: 1 addition & 0 deletions src/Adapter/MSTest.TestAdapter/BannedSymbols.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
M:Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.Helpers.ReflectHelper.#ctor; This is allowed only for tests.
34 changes: 24 additions & 10 deletions src/Adapter/MSTest.TestAdapter/Discovery/AssemblyEnumerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -188,14 +188,15 @@ internal static string GetLoadExceptionDetails(ReflectionTypeLoadException ex)
/// <param name="assemblyFileName">The reflected assembly name.</param>
/// <param name="discoverInternals">True to discover test classes which are declared internal in
/// addition to test classes which are declared public.</param>
/// <param name="discoveryOption"><see cref="TestDataSourceDiscoveryOption"/> to use when generating tests.</param>
/// <param name="testIdGenerationStrategy"><see cref="TestIdGenerationStrategy"/> to use when generating TestId.</param>
/// <returns>a TypeEnumerator instance.</returns>
internal virtual TypeEnumerator GetTypeEnumerator(Type type, string assemblyFileName, bool discoverInternals, TestIdGenerationStrategy testIdGenerationStrategy)
internal virtual TypeEnumerator GetTypeEnumerator(Type type, string assemblyFileName, bool discoverInternals, TestDataSourceDiscoveryOption discoveryOption, TestIdGenerationStrategy testIdGenerationStrategy)
{
var typeValidator = new TypeValidator(ReflectHelper, discoverInternals);
var testMethodValidator = new TestMethodValidator(ReflectHelper, discoverInternals);

return new TypeEnumerator(type, assemblyFileName, ReflectHelper, typeValidator, testMethodValidator, testIdGenerationStrategy);
return new TypeEnumerator(type, assemblyFileName, ReflectHelper, typeValidator, testMethodValidator, discoveryOption, testIdGenerationStrategy);
}

private List<UnitTestElement> DiscoverTestsInType(string assemblyFileName, string? runSettingsXml, Type type,
Expand All @@ -214,9 +215,8 @@ private List<UnitTestElement> DiscoverTestsInType(string assemblyFileName, strin
try
{
typeFullName = type.FullName;
TypeEnumerator testTypeEnumerator = GetTypeEnumerator(type, assemblyFileName, discoverInternals, testIdGenerationStrategy);
TypeEnumerator testTypeEnumerator = GetTypeEnumerator(type, assemblyFileName, discoverInternals, discoveryOption, testIdGenerationStrategy);
ICollection<UnitTestElement>? unitTestCases = testTypeEnumerator.Enumerate(out ICollection<string> warningsFromTypeEnumerator);

warningMessages.AddRange(warningsFromTypeEnumerator);

if (unitTestCases != null)
Expand Down Expand Up @@ -256,6 +256,18 @@ private bool DynamicDataAttached(IDictionary<string, object?> sourceLevelParamet
return false;
}

if (test.TestMethod.DataType == DynamicDataType.None)
{
return false;
}

// PERF: For perf we started setting DataType in TypeEnumerator, so when it is None we will not reach this line.
// But if we do run this code, we still reset it to None, because the code that determines if this is data drive test expects the value to be None
// and only sets it when needed.
//
// If you remove this line and acceptance tests still pass you are okay.
test.TestMethod.DataType = DynamicDataType.None;

// NOTE: From this place we don't have any path that would let the user write a message on the TestContext and we don't do
// anything with what would be printed anyway so we can simply use a simple StringWriter.
using var writer = new StringWriter();
Expand All @@ -268,11 +280,11 @@ private bool DynamicDataAttached(IDictionary<string, object?> sourceLevelParamet
private static bool TryProcessTestDataSourceTests(UnitTestElement test, TestMethodInfo testMethodInfo, List<UnitTestElement> tests)
{
MethodInfo methodInfo = testMethodInfo.MethodInfo;
IEnumerable<FrameworkITestDataSource>? testDataSources = ReflectHelper.GetAttributes<Attribute>(methodInfo, false)?.OfType<FrameworkITestDataSource>();
if (testDataSources == null || !testDataSources.Any())
{
return false;
}

// We don't have a special method to filter attributes that are not derived from Attribute, so we take all
// attributes and filter them. We don't have to care if there is one, because this method is only entered when
// there is at least one (we determine this in TypeEnumerator.GetTestFromMethod.
IEnumerable<FrameworkITestDataSource>? testDataSources = ReflectHelper.Instance.GetDerivedAttributes<Attribute>(methodInfo, inherit: false).OfType<FrameworkITestDataSource>();

try
{
Expand Down Expand Up @@ -304,9 +316,11 @@ private static bool ProcessTestDataSourceTests(UnitTestElement test, MethodInfo
throw new ArgumentException(string.Format(CultureInfo.CurrentCulture, FrameworkMessages.DynamicDataIEnumerableEmpty, "GetData", dataSource.GetType().Name));
}
}
catch (Exception ex) when (ex is ArgumentException && MSTestSettings.CurrentSettings.ConsiderEmptyDataSourceAsInconclusive)
catch (ArgumentException) when (MSTestSettings.CurrentSettings.ConsiderEmptyDataSourceAsInconclusive)
{
UnitTestElement discoveredTest = test.Clone();
// Make the test not data driven, because it had no data.
discoveredTest.TestMethod.DataType = DynamicDataType.None;
discoveredTest.DisplayName = dataSource.GetDisplayName(methodInfo, null) ?? discoveredTest.DisplayName;
tests.Add(discoveredTest);
continue;
Expand Down
12 changes: 9 additions & 3 deletions src/Adapter/MSTest.TestAdapter/Discovery/TestMethodValidator.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

using System.Globalization;
Expand Down Expand Up @@ -48,8 +48,14 @@ internal TestMethodValidator(ReflectHelper reflectHelper, bool discoverInternals
/// <returns> Return true if a method is a valid test method. </returns>
internal virtual bool IsValidTestMethod(MethodInfo testMethodInfo, Type type, ICollection<string> warnings)
{
if (!_reflectHelper.IsAttributeDefined<TestMethodAttribute>(testMethodInfo, false)
&& !_reflectHelper.HasAttributeDerivedFrom<TestMethodAttribute>(testMethodInfo, false))
// PERF: We are doing caching reflection here, meaning we will cache every method info in the
// assembly, this is because when we discover and run we will repeatedly inspect all the methods in the assembly, and this
// gives us a better performance.
// It would be possible to use non-caching reflection here if we knew that we are only doing discovery that won't be followed by run,
// but the difference is quite small, and we don't expect a huge amount of non-test methods in the assembly.
//
// Also skip all methods coming from object, because they cannot be tests.
if (testMethodInfo.DeclaringType == typeof(object) || !_reflectHelper.IsDerivedAttributeDefined<TestMethodAttribute>(testMethodInfo, inherit: false))
{
return false;
}
Expand Down
54 changes: 46 additions & 8 deletions src/Adapter/MSTest.TestAdapter/Discovery/TypeEnumerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ internal class TypeEnumerator
private readonly TypeValidator _typeValidator;
private readonly TestMethodValidator _testMethodValidator;
private readonly TestIdGenerationStrategy _testIdGenerationStrategy;
private readonly TestDataSourceDiscoveryOption _discoveryOption;
private readonly ReflectHelper _reflectHelper;

/// <summary>
Expand All @@ -33,14 +34,15 @@ internal class TypeEnumerator
/// <param name="typeValidator"> The validator for test classes. </param>
/// <param name="testMethodValidator"> The validator for test methods. </param>
/// <param name="testIdGenerationStrategy"><see cref="TestIdGenerationStrategy"/> to use when generating TestId.</param>
internal TypeEnumerator(Type type, string assemblyFilePath, ReflectHelper reflectHelper, TypeValidator typeValidator, TestMethodValidator testMethodValidator, TestIdGenerationStrategy testIdGenerationStrategy)
internal TypeEnumerator(Type type, string assemblyFilePath, ReflectHelper reflectHelper, TypeValidator typeValidator, TestMethodValidator testMethodValidator, TestDataSourceDiscoveryOption discoveryOption, TestIdGenerationStrategy testIdGenerationStrategy)
{
_type = type;
_assemblyFilePath = assemblyFilePath;
_reflectHelper = reflectHelper;
_typeValidator = typeValidator;
_testMethodValidator = testMethodValidator;
_testIdGenerationStrategy = testIdGenerationStrategy;
_discoveryOption = discoveryOption;
}

/// <summary>
Expand Down Expand Up @@ -73,6 +75,8 @@ internal Collection<UnitTestElement> GetTests(ICollection<string> warnings)
var tests = new Collection<UnitTestElement>();

// Test class is already valid. Verify methods.
// PERF: GetRuntimeMethods is used here to get all methods, including non-public, and static methods.
// if we rely on analyzers to identify all invalid methods on build, we can change this to fit the current settings.
foreach (MethodInfo method in _type.GetRuntimeMethods())
{
bool isMethodDeclaredInTestTypeAssembly = _reflectHelper.IsMethodDeclaredInSameAssemblyAsType(method, _type);
Expand Down Expand Up @@ -147,14 +151,30 @@ internal UnitTestElement GetTestFromMethod(MethodInfo method, bool isDeclaredInT
method.DeclaringType.Assembly);
}

// PERF: When discovery option is set to DuringDiscovery, we will expand data on tests to one test case
// per data item. This will happen in AssemblyEnumerator. But AssemblyEnumerator does not have direct access to
// the method info or method attributes, so it would create a TestMethodInfo to see if the test is data driven.
// Creating TestMethodInfo is expensive and should be done only for a test that we know is data driven.
//
// So to optimize this we check if we have some data source attribute. Because here we have access to all attributes
// and we store that info in DataType. AssemblyEnumerator will pick this up and will get the real test data in the expensive way
// or it will skip over getting the data cheaply, when DataType = DynamicDataType.None.
//
// This needs to be done only when DuringDiscovery is set, because otherwise we would populate the DataType, but we would not populate
// and execution would not try to re-populate the data, because DataType is already set to data driven, so it would just throw error about empty data.
if (_discoveryOption == TestDataSourceDiscoveryOption.DuringDiscovery)
{
testMethod.DataType = GetDynamicDataType(method);
}

var testElement = new UnitTestElement(testMethod)
{
// Get compiler generated type name for async test method (either void returning or task returning).
AsyncTypeName = method.GetAsyncTypeName(),
TestCategory = _reflectHelper.GetCategories(method, _type),
TestCategory = _reflectHelper.GetTestCategories(method, _type),
DoNotParallelize = _reflectHelper.IsDoNotParallelizeSet(method, _type),
Priority = _reflectHelper.GetPriority(method),
Ignored = _reflectHelper.IsAttributeDefined<IgnoreAttribute>(method, false),
Ignored = _reflectHelper.IsNonDerivedAttributeDefined<IgnoreAttribute>(method, inherit: false),
DeploymentItems = PlatformServiceProvider.Instance.TestDeployment.GetDeploymentItems(method, _type, warnings),
};

Expand All @@ -174,31 +194,49 @@ internal UnitTestElement GetTestFromMethod(MethodInfo method, bool isDeclaredInT

testElement.Traits = traits.ToArray();

if (_reflectHelper.GetCustomAttribute<CssIterationAttribute>(method) is CssIterationAttribute cssIteration)
nohwnd marked this conversation as resolved.
Show resolved Hide resolved
if (_reflectHelper.GetFirstDerivedAttributeOrDefault<CssIterationAttribute>(method, inherit: true) is CssIterationAttribute cssIteration)
{
testElement.CssIteration = cssIteration.CssIteration;
}

if (_reflectHelper.GetCustomAttribute<CssProjectStructureAttribute>(method) is CssProjectStructureAttribute cssProjectStructure)
if (_reflectHelper.GetFirstDerivedAttributeOrDefault<CssProjectStructureAttribute>(method, inherit: true) is CssProjectStructureAttribute cssProjectStructure)
{
testElement.CssProjectStructure = cssProjectStructure.CssProjectStructure;
}

if (_reflectHelper.GetCustomAttribute<DescriptionAttribute>(method) is DescriptionAttribute descriptionAttribute)
if (_reflectHelper.GetFirstDerivedAttributeOrDefault<DescriptionAttribute>(method, inherit: true) is DescriptionAttribute descriptionAttribute)
{
testElement.Description = descriptionAttribute.Description;
}

WorkItemAttribute[] workItemAttributes = _reflectHelper.GetCustomAttributes<WorkItemAttribute>(method);
WorkItemAttribute[] workItemAttributes = _reflectHelper.GetDerivedAttributes<WorkItemAttribute>(method, inherit: true).ToArray();
if (workItemAttributes.Length != 0)
{
testElement.WorkItemIds = workItemAttributes.Select(x => x.Id.ToString(CultureInfo.InvariantCulture)).ToArray();
}

// get DisplayName from TestMethodAttribute (or any inherited attribute)
TestMethodAttribute? testMethodAttribute = _reflectHelper.GetCustomAttribute<TestMethodAttribute>(method);
TestMethodAttribute? testMethodAttribute = _reflectHelper.GetFirstDerivedAttributeOrDefault<TestMethodAttribute>(method, inherit: true);
testElement.DisplayName = testMethodAttribute?.DisplayName ?? method.Name;

return testElement;
}

private DynamicDataType GetDynamicDataType(MethodInfo method)
{
foreach (Attribute attribute in _reflectHelper.GetDerivedAttributes<Attribute>(method, inherit: true))
{
if (AttributeComparer.IsDerived<ITestDataSource>(attribute))
{
return DynamicDataType.ITestDataSource;
}

if (AttributeComparer.IsDerived<DataSourceAttribute>(attribute))
{
return DynamicDataType.DataSourceAttribute;
}
}

return DynamicDataType.None;
}
}
9 changes: 6 additions & 3 deletions src/Adapter/MSTest.TestAdapter/Discovery/TypeValidator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,12 @@ internal virtual bool IsValidTestClass(Type type, ICollection<string> warnings)
{
TypeInfo typeInfo = type.GetTypeInfo();

if (!typeInfo.IsClass
|| (!_reflectHelper.IsAttributeDefined<TestClassAttribute>(typeInfo, false)
&& !_reflectHelper.HasAttributeDerivedFrom<TestClassAttribute>(typeInfo, false)))
// PERF: We are doing caching reflection here, meaning we will cache every class info in the
// assembly, this is because when we discover and run we will repeatedly inspect all the types in the assembly, and this
// gives us a better performance.
// It would be possible to use non-caching reflection here if we knew that we are only doing discovery that won't be followed by run,
// but the difference is quite small, and we don't expect a huge amount of non-test classes in the assembly.
if (!typeInfo.IsClass || !_reflectHelper.IsDerivedAttributeDefined<TestClassAttribute>(typeInfo, inherit: false))
{
return false;
}
Expand Down
5 changes: 2 additions & 3 deletions src/Adapter/MSTest.TestAdapter/Execution/TestMethodInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -83,12 +83,11 @@ internal TestMethodInfo(
/// </summary>
internal TestMethodOptions TestMethodOptions { get; }

public Attribute[]? GetAllAttributes(bool inherit) => ReflectHelper.GetCustomAttributes(TestMethod, inherit) as Attribute[];
public Attribute[]? GetAllAttributes(bool inherit) => ReflectHelper.Instance.GetDerivedAttributes<Attribute>(TestMethod, inherit).ToArray();

public TAttributeType[] GetAttributes<TAttributeType>(bool inherit)
where TAttributeType : Attribute
=> ReflectHelper.GetAttributes<TAttributeType>(TestMethod, inherit)
?? [];
=> ReflectHelper.Instance.GetDerivedAttributes<TAttributeType>(TestMethod, inherit).ToArray();

/// <summary>
/// Execute test method. Capture failures, handle async and return result.
Expand Down
Loading