diff --git a/MSTest.slnf b/MSTest.slnf index 3c3f4c624a..f8858c6031 100644 --- a/MSTest.slnf +++ b/MSTest.slnf @@ -11,6 +11,7 @@ "src\\Analyzers\\MSTest.Analyzers.CodeFixes\\MSTest.Analyzers.CodeFixes.csproj", "src\\Analyzers\\MSTest.Analyzers.Package\\MSTest.Analyzers.Package.csproj", "src\\Analyzers\\MSTest.Analyzers\\MSTest.Analyzers.csproj", + "src\\Analyzers\\MSTest.AotReflection.SourceGeneration\\MSTest.AotReflection.SourceGeneration.csproj", "src\\Analyzers\\MSTest.GlobalConfigsGenerator\\MSTest.GlobalConfigsGenerator.csproj", "src\\Analyzers\\MSTest.SourceGeneration\\MSTest.SourceGeneration.csproj", "src\\Package\\MSTest.Sdk\\MSTest.Sdk.csproj", diff --git a/TestFx.slnx b/TestFx.slnx index 991e9dc8db..4f9fc147da 100644 --- a/TestFx.slnx +++ b/TestFx.slnx @@ -69,6 +69,7 @@ + @@ -133,6 +134,7 @@ + diff --git a/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/TestClassModelBuilder.cs b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/TestClassModelBuilder.cs index 9188cf64c0..48d0f3209a 100644 --- a/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/TestClassModelBuilder.cs +++ b/src/Analyzers/MSTest.AotReflection.SourceGeneration/Generators/TestClassModelBuilder.cs @@ -19,8 +19,7 @@ internal static class TestClassModelBuilder { private static readonly SymbolDisplayFormat FullyQualifiedFormat = SymbolDisplayFormat.FullyQualifiedFormat.WithMiscellaneousOptions( - SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier - | SymbolDisplayMiscellaneousOptions.UseSpecialTypes); + SymbolDisplayMiscellaneousOptions.UseSpecialTypes); public static TestClassModel Build(INamedTypeSymbol typeSymbol) { diff --git a/src/Analyzers/MSTest.AotReflection.SourceGeneration/MSTest.AotReflection.SourceGeneration.csproj b/src/Analyzers/MSTest.AotReflection.SourceGeneration/MSTest.AotReflection.SourceGeneration.csproj index 1369428997..dc9bbade05 100644 --- a/src/Analyzers/MSTest.AotReflection.SourceGeneration/MSTest.AotReflection.SourceGeneration.csproj +++ b/src/Analyzers/MSTest.AotReflection.SourceGeneration/MSTest.AotReflection.SourceGeneration.csproj @@ -29,4 +29,8 @@ + + + + diff --git a/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests.csproj b/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests.csproj new file mode 100644 index 0000000000..b91def2e3a --- /dev/null +++ b/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests.csproj @@ -0,0 +1,24 @@ + + + + net8.0 + MSTest.AotReflection.SourceGeneration.UnitTests + true + true + Exe + + + + + + + + + + + + + + + diff --git a/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs b/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs new file mode 100644 index 0000000000..9363460a93 --- /dev/null +++ b/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/MSTestReflectionMetadataGeneratorTests.cs @@ -0,0 +1,508 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using AwesomeAssertions; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; + +using MSTest.AotReflection.SourceGeneration.Generators; + +namespace MSTest.AotReflection.SourceGeneration.UnitTests; + +/// +/// Behavior tests for . +/// These pin the current PoC output so the upcoming follow-up PRs (#1837) can extend it safely. +/// +[TestClass] +public sealed class MSTestReflectionMetadataGeneratorTests +{ + /// + /// Minimal MSTest attribute stubs so the generator can locate [TestClass] / + /// [TestMethod] in test fixtures without dragging the real TestFramework + /// assemblies into the Roslyn compilation. + /// + private const string MinimalMSTestStub = """ + namespace Microsoft.VisualStudio.TestTools.UnitTesting + { + [System.AttributeUsage(System.AttributeTargets.Class)] + public class TestClassAttribute : System.Attribute { } + + [System.AttributeUsage(System.AttributeTargets.Method)] + public class TestMethodAttribute : System.Attribute + { + public TestMethodAttribute() { } + public TestMethodAttribute(string displayName) { DisplayName = displayName; } + public string? DisplayName { get; set; } + } + + [System.AttributeUsage(System.AttributeTargets.Class | System.AttributeTargets.Method, AllowMultiple = true)] + public class TestCategoryAttribute : System.Attribute + { + public TestCategoryAttribute(string category) { Category = category; } + public string Category { get; } + } + + [System.AttributeUsage(System.AttributeTargets.Property)] + public class TestContextAttribute : System.Attribute { } + } + """; + + [TestMethod] + public void Generator_EmitsSupportTypes_OnAnyCompilation() + { + const string userCode = """ + // Intentionally empty — no [TestClass] in the consumer. + """; + + GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode); + + result.Diagnostics.Should().BeEmpty(); + // Support types are emitted via RegisterPostInitializationOutput → always present. + string support = result.GeneratedSources + .Single(s => s.HintName == "MSTestReflectionMetadata.SupportTypes.g.cs") + .SourceText.ToString(); + + support.Should().Contain("namespace MSTest.SourceGenerated"); + support.Should().Contain("internal sealed class TestClassReflectionInfo"); + support.Should().Contain("internal sealed class TestMethodReflectionInfo"); + support.Should().Contain("internal sealed class TestPropertyReflectionInfo"); + support.Should().Contain("internal sealed class TestConstructorReflectionInfo"); + } + + [TestMethod] + public void Generator_EmitsRegistry_WithDiscoveredTestClass() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + [TestClass] + public class MyTests + { + [TestMethod] + public void Test1() { } + } + } + """; + + GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode); + + result.Diagnostics.Should().BeEmpty(); + string registry = GetRegistry(result); + + registry.Should().Contain("internal static class MSTestReflectionMetadata"); + registry.Should().Contain("public const string AssemblyName = \"TestSample\";"); + registry.Should().Contain("Type = typeof(global::Sample.MyTests)"); + registry.Should().Contain("Name = \"Test1\""); + registry.Should().Contain("Invoke = static (instance, args) => { ((global::Sample.MyTests)instance!).Test1(); return null; },"); + } + + [TestMethod] + public void Generator_EmitsEmptyRegistry_WhenNoTestClasses() + { + const string userCode = """ + namespace Sample + { + // No [TestClass] anywhere. + public class NotATest { public void Foo() { } } + } + """; + + GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode); + + result.Diagnostics.Should().BeEmpty(); + string registry = GetRegistry(result); + registry.Should().Contain("public static IReadOnlyList TestClasses { get; } = new TestClassReflectionInfo[]"); + // No concrete TestClassReflectionInfo instance is emitted (note the open paren). + registry.Should().NotContain("new TestClassReflectionInfo("); + } + + [TestMethod] + public void Generator_SkipsStaticTestClass() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + [TestClass] + public static class StaticTests + { + [TestMethod] + public static void Test1() { } + } + } + """; + + GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode); + + result.Diagnostics.Should().BeEmpty(); + string registry = GetRegistry(result); + // Static classes are excluded by the predicate in the generator (cannot be instantiated). + registry.Should().NotContain("StaticTests"); + } + + [TestMethod] + public void Generator_SkipsAbstractTestClass() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + [TestClass] + public abstract class AbstractTests + { + [TestMethod] + public void Test1() { } + } + + [TestClass] + public class ConcreteTests + { + [TestMethod] + public void Test2() { } + } + } + """; + + GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode); + + result.Diagnostics.Should().BeEmpty(); + string registry = GetRegistry(result); + // Abstract classes are filtered in BuildModel — they cannot be instantiated. + registry.Should().NotContain("AbstractTests"); + registry.Should().Contain("typeof(global::Sample.ConcreteTests)"); + } + + [TestMethod] + public void Generator_SkipsGenericTestClass() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + [TestClass] + public class GenericTests + { + [TestMethod] + public void Test1() { } + } + } + """; + + GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode); + + result.Diagnostics.Should().BeEmpty(); + string registry = GetRegistry(result); + // Open-generic test classes are out of scope for this PoC. + registry.Should().NotContain("GenericTests"); + } + + [TestMethod] + public void Generator_EmitsConstructorInvoker() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + [TestClass] + public class CtorTests + { + public CtorTests() { } + + [TestMethod] + public void Test1() { } + } + } + """; + + GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode); + + result.Diagnostics.Should().BeEmpty(); + string registry = GetRegistry(result); + registry.Should().Contain("Constructors = new TestConstructorReflectionInfo[]"); + registry.Should().Contain("Invoke = static args => new global::Sample.CtorTests(),"); + } + + [TestMethod] + public void Generator_EmitsParameterTypes_ForMethodWithParameters() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + [TestClass] + public class ParamTests + { + [TestMethod] + public void Test1(int x, string y) { } + } + } + """; + + GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode); + + result.Diagnostics.Should().BeEmpty(); + string registry = GetRegistry(result); + registry.Should().Contain("ParameterTypes = new Type[] { typeof(int), typeof(string) }"); + registry.Should().Contain("ParameterNames = new string[] { \"x\", \"y\" }"); + } + + [TestMethod] + public void Generator_FlagsAsyncReturnTypes() + { + const string userCode = """ + using System.Threading.Tasks; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + [TestClass] + public class AsyncTests + { + [TestMethod] + public Task Test1() => Task.CompletedTask; + + [TestMethod] + public ValueTask Test2() => default; + } + } + """; + + GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode); + + result.Diagnostics.Should().BeEmpty(); + string registry = GetRegistry(result); + registry.Should().Contain("Name = \"Test1\""); + registry.Should().Contain("ReturnsTask = true"); + registry.Should().Contain("Name = \"Test2\""); + registry.Should().Contain("ReturnsValueTask = true"); + } + + [TestMethod] + public void Generator_CapturesClassLevelAttributes() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + [TestClass] + [TestCategory("Smoke")] + public class TaggedTests + { + [TestMethod] + public void Test1() { } + } + } + """; + + GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode); + + result.Diagnostics.Should().BeEmpty(); + string registry = GetRegistry(result); + registry.Should().Contain("global::Microsoft.VisualStudio.TestTools.UnitTesting.TestClassAttribute"); + registry.Should().Contain("global::Microsoft.VisualStudio.TestTools.UnitTesting.TestCategoryAttribute"); + registry.Should().Contain("\"Smoke\""); + } + + [TestMethod] + public void Generator_EmitsPropertyGetterAndSetter() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + public class TestContext { } + + [TestClass] + public class PropTests + { + [TestContext] + public TestContext? Context { get; set; } + + [TestMethod] + public void Test1() { } + } + } + """; + + GeneratorRunResult result = RunGenerator(MinimalMSTestStub, userCode); + + result.Diagnostics.Should().BeEmpty(); + string registry = GetRegistry(result); + registry.Should().Contain("Name = \"Context\""); + registry.Should().Contain("HasPublicSetter = true"); + registry.Should().Contain("Get = static instance => instance is null ? null : (object?)((global::Sample.PropTests)instance).Context,"); + registry.Should().Contain("Set = static (instance, value) => ((global::Sample.PropTests)instance!).Context = (global::Sample.TestContext)value!,"); + } + + [TestMethod] + public void Generator_EmittedSource_CompilesCleanly() + { + const string userCode = """ + using System.Threading.Tasks; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + public class TestContext { } + + [TestClass] + [TestCategory("Smoke")] + public class FullShape + { + [TestContext] + public TestContext? Context { get; set; } + + public FullShape() { } + + [TestMethod("alias")] + public void Sync(int x) { } + + [TestMethod] + public Task Asynchronous() => Task.CompletedTask; + } + } + """; + + Compilation outputCompilation = RunGeneratorAndGetCompilation(MinimalMSTestStub, userCode); + + IEnumerable diagnostics = outputCompilation + .GetDiagnostics() + .Where(d => d.Severity == DiagnosticSeverity.Error); + + diagnostics.Should().BeEmpty( + "the generated source MUST compile cleanly when consumed in the same compilation as the user code"); + } + + [TestMethod] + public void Generator_StripsNullableAnnotation_FromTypeofExpressions() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + public class TestContext { } + + [TestClass] + public class NullableShapes + { + [TestContext] + public TestContext? Context { get; set; } + + [TestMethod] + public void TakesNullableRef(string? value) { } + + [TestMethod] + public void TakesNullableValueType(int? n) { } + } + } + """; + + Compilation outputCompilation = RunGeneratorAndGetCompilation(MinimalMSTestStub, userCode); + string registry = outputCompilation + .SyntaxTrees + .Single(t => t.FilePath.EndsWith("MSTestReflectionMetadata.Registry.g.cs", System.StringComparison.Ordinal)) + .ToString(); + + // typeof(...) MUST NOT carry nullable reference type annotation (CS8639). + registry.Should().NotContain("typeof(global::Sample.TestContext?)"); + registry.Should().NotContain("typeof(string?)"); + // Reference types in typeof drop the annotation entirely. + registry.Should().Contain("typeof(global::Sample.TestContext)"); + registry.Should().Contain("typeof(string)"); + // Nullable value types are still distinct from their underlying type and must be preserved as Nullable. + registry.Should().Contain("typeof(int?)"); + + // The whole compilation must be free of CS errors. + IEnumerable errors = outputCompilation + .GetDiagnostics() + .Where(d => d.Severity == DiagnosticSeverity.Error); + errors.Should().BeEmpty("typeof(T?) on a reference type is invalid C# (CS8639)"); + } + + [TestMethod] + public void Generator_IsIncremental_SupportTypesAreCached_WhenInputUnchanged() + { + const string userCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + + namespace Sample + { + [TestClass] + public class IncTests + { + [TestMethod] + public void Test1() { } + } + } + """; + + CSharpCompilation compilation = CreateCompilation(MinimalMSTestStub, userCode); + GeneratorDriver driver = CSharpGeneratorDriver + .Create(new MSTestReflectionMetadataGenerator()) + .WithUpdatedParseOptions((CSharpParseOptions)compilation.SyntaxTrees.First().Options); + + // Track step output cache reasons. + driver = driver.RunGenerators(compilation); + driver = driver.RunGenerators(compilation); + + GeneratorDriverRunResult result = driver.GetRunResult(); + result.Diagnostics.Should().BeEmpty(); + result.Results.Should().ContainSingle(); + // Two passes against the same compilation must produce identical sources. + result.Results[0].GeneratedSources.Should().HaveCount(2); + } + + private static string GetRegistry(GeneratorRunResult result) + => result.GeneratedSources + .Single(s => s.HintName == "MSTestReflectionMetadata.Registry.g.cs") + .SourceText.ToString() + .Replace("\r\n", "\n"); + + private static GeneratorRunResult RunGenerator(params string[] sources) + { + CSharpCompilation compilation = CreateCompilation(sources); + GeneratorDriver driver = CSharpGeneratorDriver.Create(new MSTestReflectionMetadataGenerator()); + driver = driver.RunGeneratorsAndUpdateCompilation(compilation, out _, out _); + return driver.GetRunResult().Results[0]; + } + + private static Compilation RunGeneratorAndGetCompilation(params string[] sources) + { + CSharpCompilation compilation = CreateCompilation(sources); + GeneratorDriver driver = CSharpGeneratorDriver.Create(new MSTestReflectionMetadataGenerator()); + driver.RunGeneratorsAndUpdateCompilation(compilation, out Compilation outputCompilation, out _); + return outputCompilation; + } + + private static CSharpCompilation CreateCompilation(params string[] sources) + { + IEnumerable trees = sources.Select(s => CSharpSyntaxTree.ParseText(s)); + MetadataReference[] references = new[] + { + MetadataReference.CreateFromFile(typeof(object).Assembly.Location), + MetadataReference.CreateFromFile(typeof(System.Runtime.CompilerServices.ModuleInitializerAttribute).Assembly.Location), + MetadataReference.CreateFromFile(typeof(System.Reflection.Assembly).Assembly.Location), + MetadataReference.CreateFromFile(typeof(System.Collections.Generic.Dictionary<,>).Assembly.Location), + MetadataReference.CreateFromFile(typeof(System.Reflection.MethodInfo).Assembly.Location), + MetadataReference.CreateFromFile(typeof(System.Linq.Enumerable).Assembly.Location), + MetadataReference.CreateFromFile(typeof(System.Threading.Tasks.Task).Assembly.Location), + }; + + return CSharpCompilation.Create( + "TestSample", + trees, + references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + } +} diff --git a/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/Program.cs b/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/Program.cs new file mode 100644 index 0000000000..9fbdc73473 --- /dev/null +++ b/test/UnitTests/MSTest.AotReflection.SourceGeneration.UnitTests/Program.cs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.Testing.Extensions; + +ITestApplicationBuilder builder = await TestApplication.CreateBuilderAsync(args); +builder.AddMSTest(() => [Assembly.GetEntryAssembly()!]); + +#if ENABLE_CODECOVERAGE +builder.AddCodeCoverageProvider(); +#endif +builder.AddHangDumpProvider(); +builder.AddCrashDumpProvider(ignoreIfNotSupported: true); +builder.AddTrxReportProvider(); +builder.AddAppInsightsTelemetryProvider(); +builder.AddAzureDevOpsProvider(); + +using ITestApplication app = await builder.BuildAsync(); +return await app.RunAsync();