From a10c97f3eb346d0613d977c51037f7ea778d2ee7 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Mon, 28 Sep 2026 16:01:59 +0100 Subject: [PATCH 1/3] perf: emit source-generated test types after user code to avoid slow Defender scans The C# compiler writes namespaces in reverse order of first declaration, and generator output is always compiled last, so TUnit.Generated (large static constructors registering every test) landed at the start of the assembly. Microsoft Defender analyses early methods far more expensively: a 10k-test assembly took ~5s to scan versus ~0.2s for MSTest/xUnit of similar size. Every build (CreateAppHost opens the dll) and the first test run paid it. TUnit.Core.props now adds a file declaring the empty TUnit.Generated namespace ahead of the SDK default Compile glob, which moves the generated types to the end of the assembly. --- src/TUnit.Core/TUnit.Core.GeneratedNamespace.cs | 13 +++++++++++++ src/TUnit.Core/TUnit.Core.csproj | 12 ++++++++++++ src/TUnit.Core/TUnit.Core.props | 9 +++++++++ 3 files changed, 34 insertions(+) create mode 100644 src/TUnit.Core/TUnit.Core.GeneratedNamespace.cs 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) + + + + + + From 562eb4dd1a1a2beaef255105481f7196af158fff Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Mon, 28 Sep 2026 17:17:44 +0100 Subject: [PATCH 2/3] Add packaged-consumer check that generated types are emitted after user types Adds GeneratedTypeOrderTests to TUnit.NugetTester, which the pipeline runs against the packed packages, so the real NuGet props import path is covered. Also documents in TUnit.Core.props why the Compile item's conditions work from a .props: MSBuild evaluates all properties before items. --- src/TUnit.Core/TUnit.Core.props | 5 +++ .../GeneratedTypeOrderTests.cs | 32 +++++++++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 tools/tunit-nuget-tester/TUnit.NugetTester/TUnit.NugetTester/GeneratedTypeOrderTests.cs diff --git a/src/TUnit.Core/TUnit.Core.props b/src/TUnit.Core/TUnit.Core.props index b23be4dceb7..bdb67a4204f 100644 --- a/src/TUnit.Core/TUnit.Core.props +++ b/src/TUnit.Core/TUnit.Core.props @@ -37,6 +37,11 @@ Declares the TUnit.Generated namespace before the project's own files (items added here precede the SDK's default Compile glob) so the compiler writes the source-generated types at the end of the assembly rather than the start, where Microsoft Defender scans them far more slowly. See TUnit.Core.GeneratedNamespace.cs. + + This must stay in the .props: items added from a .targets land after the default glob. The condition is safe + here even though Language is set later by the C# targets and EnableTUnitSourceGeneration may be set in the + project body, because MSBuild evaluates every property before any item, so item conditions see final values. + Guarded by TUnit.NugetTester's GeneratedTypeOrderTests. --> 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); + } +} From 2bd49faa47905d49bdf7016e24ea9ca14f248de0 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Mon, 28 Sep 2026 17:46:38 +0100 Subject: [PATCH 3/3] Put the TUnit.Generated namespace file first for in-repo test projects Repo projects import TUnit.Core.props from their project body, after the SDK's default Compile glob, so the namespace file landed last and the generated types were still emitted first. A target in eng/TestLibrary.props now moves it to the front of the Compile items just before CoreCompile. Evaluation is unchanged, and the shipped package is unaffected. --- eng/TestLibrary.props | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) 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