diff --git a/eng/TestLibrary.props b/eng/TestLibrary.props index d939173fa4d..7acc01fdf0a 100644 --- a/eng/TestLibrary.props +++ b/eng/TestLibrary.props @@ -1,6 +1,23 @@ + + + + <_TUnitGeneratedNamespaceCompile Include="@(Compile)" Condition="'%(Filename)%(Extension)' == 'TUnit.Core.GeneratedNamespace.cs'" /> + <_TUnitOtherCompile Include="@(Compile)" Exclude="@(_TUnitGeneratedNamespaceCompile)" Condition="'@(_TUnitGeneratedNamespaceCompile)' != ''" /> + + + + + Library diff --git a/src/TUnit.Core/TUnit.Core.GeneratedNamespace.cs b/src/TUnit.Core/TUnit.Core.GeneratedNamespace.cs new file mode 100644 index 00000000000..2ac19f46719 --- /dev/null +++ b/src/TUnit.Core/TUnit.Core.GeneratedNamespace.cs @@ -0,0 +1,13 @@ +// +// Added to test projects by TUnit.Core.props so it compiles ahead of the project's own files. +// +// The C# compiler writes namespaces to the assembly in reverse order of their first declaration. +// Source-generated trees are always added after the project's files, so without this declaration +// the TUnit.Generated types (large static constructors registering every test) are written at the +// very start of the assembly. Antivirus scanners such as Microsoft Defender analyse code at the +// start of an assembly far more expensively, which added seconds to every build and first test run +// of large test projects on Windows. Declaring the namespace first moves the generated types to the +// end of the assembly instead. +namespace TUnit.Generated +{ +} diff --git a/src/TUnit.Core/TUnit.Core.csproj b/src/TUnit.Core/TUnit.Core.csproj index 0ffb72e7670..9f759056ad6 100644 --- a/src/TUnit.Core/TUnit.Core.csproj +++ b/src/TUnit.Core/TUnit.Core.csproj @@ -35,6 +35,18 @@ true $(BuildPath) + + + + + true + $(BuildTransitivePath) + + + + true + $(BuildPath) + + + + + + diff --git a/tools/tunit-nuget-tester/TUnit.NugetTester/TUnit.NugetTester/GeneratedTypeOrderTests.cs b/tools/tunit-nuget-tester/TUnit.NugetTester/TUnit.NugetTester/GeneratedTypeOrderTests.cs new file mode 100644 index 00000000000..b4beefe47f5 --- /dev/null +++ b/tools/tunit-nuget-tester/TUnit.NugetTester/TUnit.NugetTester/GeneratedTypeOrderTests.cs @@ -0,0 +1,32 @@ +using System.Diagnostics.CodeAnalysis; +using System.Reflection; + +namespace TUnit.NugetTester; + +/// +/// TUnit.Core.props adds TUnit.Core.GeneratedNamespace.cs ahead of the project's own files so the compiler +/// emits the source-generated TUnit.Generated types after the user's types. Emitting them first made +/// Microsoft Defender scan large test assemblies for seconds on every build (see PR #6908). +/// This project consumes the packed packages, so it guards the real NuGet import path. +/// +public class GeneratedTypeOrderTests +{ + [Test] + [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Only inspects this assembly's own type definitions.")] + public async Task Generated_Types_Are_Emitted_After_User_Types() + { + var types = typeof(GeneratedTypeOrderTests).Assembly.GetTypes(); + + var generatedTypes = types.Where(t => t.Namespace == "TUnit.Generated").ToArray(); + var userTypes = types.Where(t => !t.IsNested && t.Namespace == typeof(GeneratedTypeOrderTests).Namespace).ToArray(); + + await Assert.That(generatedTypes).IsNotEmpty(); + await Assert.That(userTypes).IsNotEmpty(); + + // TypeDef metadata tokens follow the order the compiler emitted the types in. + var firstGenerated = generatedTypes.Min(t => t.MetadataToken); + var lastUser = userTypes.Max(t => t.MetadataToken); + + await Assert.That(firstGenerated).IsGreaterThan(lastUser); + } +}