diff --git a/AspNetCore.Analyzer.props b/AspNetCore.Analyzer.props
new file mode 100644
index 0000000000..5036184620
--- /dev/null
+++ b/AspNetCore.Analyzer.props
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Directory.Build.props b/Directory.Build.props
index 3a10242347..1691a1d4eb 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -33,6 +33,13 @@
false
true
+
+ netstandard2.0
+ TUnit.AspNetCore.Analyzers
+ TUnit.AspNetCore.Analyzers
+ false
+ true
+
$([System.DateTime]::Now.ToString("yyyy"))
diff --git a/Directory.Packages.props b/Directory.Packages.props
index ecc225a1ff..67ded5f410 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -86,6 +86,7 @@
+
diff --git a/TUnit.AspNetCore.Analyzers.Roslyn414/TUnit.AspNetCore.Analyzers.Roslyn414.csproj b/TUnit.AspNetCore.Analyzers.Roslyn414/TUnit.AspNetCore.Analyzers.Roslyn414.csproj
new file mode 100644
index 0000000000..a37eda453b
--- /dev/null
+++ b/TUnit.AspNetCore.Analyzers.Roslyn414/TUnit.AspNetCore.Analyzers.Roslyn414.csproj
@@ -0,0 +1,9 @@
+
+
+
+ 4.14
+
+
+
+
+
diff --git a/TUnit.AspNetCore.Analyzers.Roslyn44/TUnit.AspNetCore.Analyzers.Roslyn44.csproj b/TUnit.AspNetCore.Analyzers.Roslyn44/TUnit.AspNetCore.Analyzers.Roslyn44.csproj
new file mode 100644
index 0000000000..5df2a50bec
--- /dev/null
+++ b/TUnit.AspNetCore.Analyzers.Roslyn44/TUnit.AspNetCore.Analyzers.Roslyn44.csproj
@@ -0,0 +1,9 @@
+
+
+
+ 4.4
+
+
+
+
+
diff --git a/TUnit.AspNetCore.Analyzers.Roslyn47/TUnit.AspNetCore.Analyzers.Roslyn47.csproj b/TUnit.AspNetCore.Analyzers.Roslyn47/TUnit.AspNetCore.Analyzers.Roslyn47.csproj
new file mode 100644
index 0000000000..5694a2f514
--- /dev/null
+++ b/TUnit.AspNetCore.Analyzers.Roslyn47/TUnit.AspNetCore.Analyzers.Roslyn47.csproj
@@ -0,0 +1,9 @@
+
+
+
+ 4.7
+
+
+
+
+
diff --git a/TUnit.AspNetCore.Analyzers.Tests/TUnit.AspNetCore.Analyzers.Tests.csproj b/TUnit.AspNetCore.Analyzers.Tests/TUnit.AspNetCore.Analyzers.Tests.csproj
new file mode 100644
index 0000000000..e7bcc196d9
--- /dev/null
+++ b/TUnit.AspNetCore.Analyzers.Tests/TUnit.AspNetCore.Analyzers.Tests.csproj
@@ -0,0 +1,22 @@
+
+
+
+
+
+ net8.0;net9.0;net10.0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/TUnit.AspNetCore.Analyzers.Tests/Verifiers/CSharpAnalyzerVerifier.cs b/TUnit.AspNetCore.Analyzers.Tests/Verifiers/CSharpAnalyzerVerifier.cs
new file mode 100644
index 0000000000..1c085261f9
--- /dev/null
+++ b/TUnit.AspNetCore.Analyzers.Tests/Verifiers/CSharpAnalyzerVerifier.cs
@@ -0,0 +1,59 @@
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Testing;
+using Microsoft.CodeAnalysis.Diagnostics;
+using Microsoft.CodeAnalysis.Testing;
+
+namespace TUnit.AspNetCore.Analyzers.Tests.Verifiers;
+
+public static partial class CSharpAnalyzerVerifier
+ where TAnalyzer : DiagnosticAnalyzer, new()
+{
+ public class Test : CSharpAnalyzerTest
+ {
+ public Test()
+ {
+ ReferenceAssemblies.AddAssemblies(ReferenceAssemblies.Net.Net60.Assemblies);
+ SolutionTransforms.Add((solution, projectId) =>
+ {
+ var project = solution.GetProject(projectId);
+
+ if (project is null)
+ {
+ return solution;
+ }
+
+ var compilationOptions = project.CompilationOptions;
+
+ if (compilationOptions is null)
+ {
+ return solution;
+ }
+
+ if (compilationOptions is CSharpCompilationOptions cSharpCompilationOptions)
+ {
+ compilationOptions =
+ cSharpCompilationOptions.WithNullableContextOptions(NullableContextOptions.Enable);
+ }
+
+ if (project.ParseOptions is not CSharpParseOptions parseOptions)
+ {
+ return solution;
+ }
+
+ compilationOptions = compilationOptions
+ .WithSpecificDiagnosticOptions(compilationOptions.SpecificDiagnosticOptions
+ .SetItems(CSharpVerifierHelper.NullableWarnings)
+ // Suppress analyzer release tracking warnings - we're testing TUnit analyzers, not release tracking
+ .SetItem("RS2007", ReportDiagnostic.Suppress)
+ .SetItem("RS2008", ReportDiagnostic.Suppress));
+
+ solution = solution.WithProjectCompilationOptions(projectId, compilationOptions)
+ .WithProjectParseOptions(projectId, parseOptions
+ .WithLanguageVersion(LanguageVersion.Preview));
+
+ return solution;
+ });
+ }
+ }
+}
diff --git a/TUnit.AspNetCore.Analyzers.Tests/Verifiers/CSharpAnalyzerVerifier`1.cs b/TUnit.AspNetCore.Analyzers.Tests/Verifiers/CSharpAnalyzerVerifier`1.cs
new file mode 100644
index 0000000000..08543e0cd5
--- /dev/null
+++ b/TUnit.AspNetCore.Analyzers.Tests/Verifiers/CSharpAnalyzerVerifier`1.cs
@@ -0,0 +1,52 @@
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp.Testing;
+using Microsoft.CodeAnalysis.Diagnostics;
+using Microsoft.CodeAnalysis.Testing;
+
+namespace TUnit.AspNetCore.Analyzers.Tests.Verifiers;
+
+public static partial class CSharpAnalyzerVerifier
+ where TAnalyzer : DiagnosticAnalyzer, new()
+{
+ ///
+ public static DiagnosticResult Diagnostic()
+ => CSharpAnalyzerVerifier.Diagnostic();
+
+ ///
+ public static DiagnosticResult Diagnostic(string diagnosticId)
+ => CSharpAnalyzerVerifier.Diagnostic(diagnosticId);
+
+ ///
+ public static DiagnosticResult Diagnostic(DiagnosticDescriptor descriptor)
+ => CSharpAnalyzerVerifier.Diagnostic(descriptor);
+
+ ///
+ public static Task VerifyAnalyzerAsync([StringSyntax("c#")] string source, params DiagnosticResult[] expected)
+ {
+ return VerifyAnalyzerAsync(source, _ => { }, expected);
+ }
+
+ ///
+ public static async Task VerifyAnalyzerAsync([StringSyntax("c#")] string source, Action configureTest, params DiagnosticResult[] expected)
+ {
+ var test = new Test
+ {
+ TestCode = source,
+ ReferenceAssemblies = ReferenceAssemblies.Net.Net90,
+ TestState =
+ {
+ AdditionalReferences =
+ {
+ typeof(TUnit.Core.TUnitAttribute).Assembly.Location,
+ },
+ },
+ };
+
+ test.ExpectedDiagnostics.AddRange(expected);
+
+ configureTest(test);
+
+ await test.RunAsync(CancellationToken.None);
+ }
+}
diff --git a/TUnit.AspNetCore.Analyzers.Tests/Verifiers/CSharpVerifierHelper.cs b/TUnit.AspNetCore.Analyzers.Tests/Verifiers/CSharpVerifierHelper.cs
new file mode 100644
index 0000000000..e34302c084
--- /dev/null
+++ b/TUnit.AspNetCore.Analyzers.Tests/Verifiers/CSharpVerifierHelper.cs
@@ -0,0 +1,32 @@
+using System.Collections.Immutable;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+
+namespace TUnit.AspNetCore.Analyzers.Tests.Verifiers;
+
+internal static class CSharpVerifierHelper
+{
+ ///
+ /// By default, the compiler reports diagnostics for nullable reference types at
+ /// , and the analyzer test framework defaults to only validating
+ /// diagnostics at . This map contains all compiler diagnostic IDs
+ /// related to nullability mapped to , which is then used to enable all
+ /// of these warnings for default validation during analyzer and code fix tests.
+ ///
+ internal static ImmutableDictionary NullableWarnings { get; } = GetNullableWarningsFromCompiler();
+
+ private static ImmutableDictionary GetNullableWarningsFromCompiler()
+ {
+ string[] args = ["/warnaserror:nullable", "-p:LangVersion=preview"];
+ var commandLineArguments = CSharpCommandLineParser.Default.Parse(args, baseDirectory: Environment.CurrentDirectory, sdkDirectory: Environment.CurrentDirectory);
+ var nullableWarnings = commandLineArguments.CompilationOptions.SpecificDiagnosticOptions;
+
+ // Workaround for https://github.com/dotnet/roslyn/issues/41610
+ nullableWarnings = nullableWarnings
+ .SetItem("CS8632", ReportDiagnostic.Error)
+ .SetItem("CS8669", ReportDiagnostic.Error)
+ .SetItem("CS8652", ReportDiagnostic.Suppress);
+
+ return nullableWarnings;
+ }
+}
diff --git a/TUnit.AspNetCore.Analyzers.Tests/Verifiers/LineEndingNormalizingVerifier.cs b/TUnit.AspNetCore.Analyzers.Tests/Verifiers/LineEndingNormalizingVerifier.cs
new file mode 100644
index 0000000000..848dd250a3
--- /dev/null
+++ b/TUnit.AspNetCore.Analyzers.Tests/Verifiers/LineEndingNormalizingVerifier.cs
@@ -0,0 +1,101 @@
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.CodeAnalysis.Testing;
+
+namespace TUnit.AspNetCore.Analyzers.Tests.Verifiers;
+
+///
+/// A custom verifier that normalizes line endings to LF before comparison to support cross-platform testing.
+/// This prevents tests from failing due to differences between Windows (CRLF) and Unix (LF) line endings.
+/// By normalizing to LF (the universal standard), tests pass consistently on all platforms.
+///
+public class LineEndingNormalizingVerifier : IVerifier
+{
+ private readonly DefaultVerifier _defaultVerifier = new();
+
+ public void Empty(string collectionName, IEnumerable collection)
+ {
+ _defaultVerifier.Empty(collectionName, collection);
+ }
+
+ public void Equal(T expected, T actual, string? message = null)
+ {
+ // Normalize line endings for string comparisons
+ if (expected is string expectedString && actual is string actualString)
+ {
+ var normalizedExpected = NormalizeLineEndings(expectedString);
+ var normalizedActual = NormalizeLineEndings(actualString);
+ _defaultVerifier.Equal(normalizedExpected, normalizedActual, message);
+ }
+ else
+ {
+ _defaultVerifier.Equal(expected, actual, message);
+ }
+ }
+
+ public void True(bool assert, string? message = null)
+ {
+ _defaultVerifier.True(assert, message);
+ }
+
+ public void False(bool assert, string? message = null)
+ {
+ _defaultVerifier.False(assert, message);
+ }
+
+ [DoesNotReturn]
+ public void Fail(string? message = null)
+ {
+ _defaultVerifier.Fail(message);
+ }
+
+ public void LanguageIsSupported(string language)
+ {
+ _defaultVerifier.LanguageIsSupported(language);
+ }
+
+ public void NotEmpty(string collectionName, IEnumerable collection)
+ {
+ _defaultVerifier.NotEmpty(collectionName, collection);
+ }
+
+ public void SequenceEqual(IEnumerable expected, IEnumerable actual, IEqualityComparer? equalityComparer = null, string? message = null)
+ {
+ // Normalize line endings for string sequence comparisons
+ if (typeof(T) == typeof(string))
+ {
+ var normalizedExpected = expected.Cast().Select(NormalizeLineEndings).Cast();
+ var normalizedActual = actual.Cast().Select(NormalizeLineEndings).Cast();
+ _defaultVerifier.SequenceEqual(normalizedExpected, normalizedActual, equalityComparer, message);
+ }
+ else
+ {
+ _defaultVerifier.SequenceEqual(expected, actual, equalityComparer, message);
+ }
+ }
+
+ public IVerifier PushContext(string context)
+ {
+ // Create a new verifier that wraps the result of PushContext on the default verifier
+ return new LineEndingNormalizingVerifierWithContext(_defaultVerifier.PushContext(context));
+ }
+
+ private static string NormalizeLineEndings(string value)
+ {
+ // Normalize all line endings to LF (Unix) for cross-platform consistent comparison
+ // LF is the universal standard and prevents Windows/Linux test mismatches
+ return value.Replace("\r\n", "\n");
+ }
+
+ ///
+ /// Internal helper class to wrap a verifier with context
+ ///
+ private class LineEndingNormalizingVerifierWithContext : LineEndingNormalizingVerifier
+ {
+ private readonly IVerifier _wrappedVerifier;
+
+ public LineEndingNormalizingVerifierWithContext(IVerifier wrappedVerifier)
+ {
+ _wrappedVerifier = wrappedVerifier;
+ }
+ }
+}
diff --git a/TUnit.AspNetCore.Analyzers.Tests/WebApplicationFactoryAccessAnalyzerTests.cs b/TUnit.AspNetCore.Analyzers.Tests/WebApplicationFactoryAccessAnalyzerTests.cs
new file mode 100644
index 0000000000..e2e6407c68
--- /dev/null
+++ b/TUnit.AspNetCore.Analyzers.Tests/WebApplicationFactoryAccessAnalyzerTests.cs
@@ -0,0 +1,547 @@
+using Verifier = TUnit.AspNetCore.Analyzers.Tests.Verifiers.CSharpAnalyzerVerifier;
+
+namespace TUnit.AspNetCore.Analyzers.Tests;
+
+public class WebApplicationFactoryAccessAnalyzerTests
+{
+ private const string WebApplicationTestStub = """
+ namespace TUnit.AspNetCore
+ {
+ public abstract class WebApplicationTest
+ {
+ public int UniqueId { get; }
+ }
+
+ public abstract class WebApplicationTest : WebApplicationTest
+ where TFactory : class, new()
+ where TEntryPoint : class
+ {
+ public TFactory GlobalFactory { get; set; } = null!;
+ public object Factory { get; } = null!;
+ public System.IServiceProvider Services { get; } = null!;
+ public object? HttpCapture { get; }
+
+ protected virtual System.Threading.Tasks.Task SetupAsync() => System.Threading.Tasks.Task.CompletedTask;
+ }
+ }
+ """;
+
+ private const string WebApplicationFactoryStub = """
+ namespace Microsoft.AspNetCore.Mvc.Testing
+ {
+ public class WebApplicationFactory where TEntryPoint : class
+ {
+ public System.IServiceProvider Services { get; } = null!;
+ public object Server { get; } = null!;
+ public object CreateClient() => new object();
+ public object CreateDefaultClient() => new object();
+ }
+ }
+
+ namespace TUnit.AspNetCore
+ {
+ public class TestWebApplicationFactory : Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactory
+ where TEntryPoint : class
+ {
+ }
+ }
+ """;
+
+ [Test]
+ public async Task No_Error_When_Accessing_Factory_In_Test_Method()
+ {
+ await Verifier
+ .VerifyAnalyzerAsync(
+ $$"""
+ using TUnit.Core;
+ {{WebApplicationTestStub}}
+
+ public class MyFactory { }
+ public class Program { }
+
+ public class MyTests : TUnit.AspNetCore.WebApplicationTest
+ {
+ [Test]
+ public void MyTest()
+ {
+ var factory = Factory;
+ var services = Services;
+ }
+ }
+ """
+ );
+ }
+
+ [Test]
+ public async Task Error_When_Accessing_Factory_In_Constructor()
+ {
+ await Verifier
+ .VerifyAnalyzerAsync(
+ $$"""
+ using TUnit.Core;
+ {{WebApplicationTestStub}}
+
+ public class MyFactory { }
+ public class Program { }
+
+ public class MyTests : TUnit.AspNetCore.WebApplicationTest
+ {
+ public MyTests()
+ {
+ var factory = {|#0:Factory|};
+ }
+
+ [Test]
+ public void MyTest()
+ {
+ }
+ }
+ """,
+ Verifier.Diagnostic(Rules.FactoryAccessedTooEarly)
+ .WithLocation(0)
+ .WithArguments("Factory", "constructor")
+ );
+ }
+
+ [Test]
+ public async Task Error_When_Accessing_Services_In_Constructor()
+ {
+ await Verifier
+ .VerifyAnalyzerAsync(
+ $$"""
+ using TUnit.Core;
+ {{WebApplicationTestStub}}
+
+ public class MyFactory { }
+ public class Program { }
+
+ public class MyTests : TUnit.AspNetCore.WebApplicationTest
+ {
+ public MyTests()
+ {
+ var services = {|#0:Services|};
+ }
+
+ [Test]
+ public void MyTest()
+ {
+ }
+ }
+ """,
+ Verifier.Diagnostic(Rules.FactoryAccessedTooEarly)
+ .WithLocation(0)
+ .WithArguments("Services", "constructor")
+ );
+ }
+
+ [Test]
+ public async Task Error_When_Accessing_Factory_In_SetupAsync()
+ {
+ await Verifier
+ .VerifyAnalyzerAsync(
+ $$"""
+ using TUnit.Core;
+ using System.Threading.Tasks;
+ {{WebApplicationTestStub}}
+
+ public class MyFactory { }
+ public class Program { }
+
+ public class MyTests : TUnit.AspNetCore.WebApplicationTest
+ {
+ protected override Task SetupAsync()
+ {
+ var factory = {|#0:Factory|};
+ return Task.CompletedTask;
+ }
+
+ [Test]
+ public void MyTest()
+ {
+ }
+ }
+ """,
+ Verifier.Diagnostic(Rules.FactoryAccessedTooEarly)
+ .WithLocation(0)
+ .WithArguments("Factory", "SetupAsync")
+ );
+ }
+
+ [Test]
+ public async Task Error_When_Accessing_HttpCapture_In_SetupAsync()
+ {
+ await Verifier
+ .VerifyAnalyzerAsync(
+ $$"""
+ using TUnit.Core;
+ using System.Threading.Tasks;
+ {{WebApplicationTestStub}}
+
+ public class MyFactory { }
+ public class Program { }
+
+ public class MyTests : TUnit.AspNetCore.WebApplicationTest
+ {
+ protected override Task SetupAsync()
+ {
+ var capture = {|#0:HttpCapture|};
+ return Task.CompletedTask;
+ }
+
+ [Test]
+ public void MyTest()
+ {
+ }
+ }
+ """,
+ Verifier.Diagnostic(Rules.FactoryAccessedTooEarly)
+ .WithLocation(0)
+ .WithArguments("HttpCapture", "SetupAsync")
+ );
+ }
+
+ [Test]
+ public async Task Error_When_Accessing_GlobalFactory_In_Constructor()
+ {
+ // GlobalFactory is NOT available in constructor - it's injected via property injection after construction
+ await Verifier
+ .VerifyAnalyzerAsync(
+ $$"""
+ using TUnit.Core;
+ {{WebApplicationTestStub}}
+
+ public class MyFactory { }
+ public class Program { }
+
+ public class MyTests : TUnit.AspNetCore.WebApplicationTest
+ {
+ public MyTests()
+ {
+ var factory = {|#0:GlobalFactory|};
+ }
+
+ [Test]
+ public void MyTest()
+ {
+ }
+ }
+ """,
+ Verifier.Diagnostic(Rules.FactoryAccessedTooEarly)
+ .WithLocation(0)
+ .WithArguments("GlobalFactory", "constructor")
+ );
+ }
+
+ [Test]
+ public async Task No_Error_When_Accessing_GlobalFactory_In_SetupAsync()
+ {
+ // GlobalFactory IS available in SetupAsync - it's injected before SetupAsync runs
+ await Verifier
+ .VerifyAnalyzerAsync(
+ $$"""
+ using TUnit.Core;
+ using System.Threading.Tasks;
+ {{WebApplicationTestStub}}
+
+ public class MyFactory { }
+ public class Program { }
+
+ public class MyTests : TUnit.AspNetCore.WebApplicationTest
+ {
+ protected override Task SetupAsync()
+ {
+ var factory = GlobalFactory;
+ return Task.CompletedTask;
+ }
+
+ [Test]
+ public void MyTest()
+ {
+ }
+ }
+ """
+ );
+ }
+
+ [Test]
+ public async Task No_Error_When_Accessing_UniqueId_In_SetupAsync()
+ {
+ // UniqueId IS available in SetupAsync
+ await Verifier
+ .VerifyAnalyzerAsync(
+ $$"""
+ using TUnit.Core;
+ using System.Threading.Tasks;
+ {{WebApplicationTestStub}}
+
+ public class MyFactory { }
+ public class Program { }
+
+ public class MyTests : TUnit.AspNetCore.WebApplicationTest
+ {
+ protected override Task SetupAsync()
+ {
+ var id = UniqueId;
+ return Task.CompletedTask;
+ }
+
+ [Test]
+ public void MyTest()
+ {
+ }
+ }
+ """
+ );
+ }
+
+ [Test]
+ public async Task No_Error_For_Unrelated_Factory_Property()
+ {
+ // A property named Factory on an unrelated class should not trigger the analyzer
+ await Verifier
+ .VerifyAnalyzerAsync(
+ """
+ using TUnit.Core;
+
+ public class SomeClass
+ {
+ public object Factory { get; } = null!;
+ }
+
+ public class MyTests
+ {
+ private SomeClass _someClass = new();
+
+ public MyTests()
+ {
+ var factory = _someClass.Factory;
+ }
+
+ [Test]
+ public void MyTest()
+ {
+ }
+ }
+ """
+ );
+ }
+
+ [Test]
+ public async Task Error_When_Accessing_GlobalFactory_Services()
+ {
+ // GlobalFactory.Services should never be accessed - use Factory.Services instead
+ await Verifier
+ .VerifyAnalyzerAsync(
+ $$"""
+ using TUnit.Core;
+ {{WebApplicationFactoryStub}}
+ {{WebApplicationTestStub}}
+
+ public class MyFactory : TUnit.AspNetCore.TestWebApplicationFactory { }
+ public class Program { }
+
+ public class MyTests : TUnit.AspNetCore.WebApplicationTest
+ {
+ [Test]
+ public void MyTest()
+ {
+ var services = {|#0:GlobalFactory.Services|};
+ }
+ }
+ """,
+ Verifier.Diagnostic(Rules.GlobalFactoryMemberAccess)
+ .WithLocation(0)
+ .WithArguments("Services")
+ );
+ }
+
+ [Test]
+ public async Task Error_When_Accessing_GlobalFactory_Server()
+ {
+ // GlobalFactory.Server should never be accessed - use Factory.Server instead
+ await Verifier
+ .VerifyAnalyzerAsync(
+ $$"""
+ using TUnit.Core;
+ {{WebApplicationFactoryStub}}
+ {{WebApplicationTestStub}}
+
+ public class MyFactory : TUnit.AspNetCore.TestWebApplicationFactory { }
+ public class Program { }
+
+ public class MyTests : TUnit.AspNetCore.WebApplicationTest
+ {
+ [Test]
+ public void MyTest()
+ {
+ var server = {|#0:GlobalFactory.Server|};
+ }
+ }
+ """,
+ Verifier.Diagnostic(Rules.GlobalFactoryMemberAccess)
+ .WithLocation(0)
+ .WithArguments("Server")
+ );
+ }
+
+ [Test]
+ public async Task Error_When_Calling_GlobalFactory_CreateClient()
+ {
+ // GlobalFactory.CreateClient() should never be called - use Factory.CreateClient() instead
+ await Verifier
+ .VerifyAnalyzerAsync(
+ $$"""
+ using TUnit.Core;
+ {{WebApplicationFactoryStub}}
+ {{WebApplicationTestStub}}
+
+ public class MyFactory : TUnit.AspNetCore.TestWebApplicationFactory { }
+ public class Program { }
+
+ public class MyTests : TUnit.AspNetCore.WebApplicationTest
+ {
+ [Test]
+ public void MyTest()
+ {
+ var client = {|#0:GlobalFactory.CreateClient()|};
+ }
+ }
+ """,
+ Verifier.Diagnostic(Rules.GlobalFactoryMemberAccess)
+ .WithLocation(0)
+ .WithArguments("CreateClient")
+ );
+ }
+
+ [Test]
+ public async Task Error_When_Calling_GlobalFactory_CreateDefaultClient()
+ {
+ // GlobalFactory.CreateDefaultClient() should never be called - use Factory.CreateDefaultClient() instead
+ await Verifier
+ .VerifyAnalyzerAsync(
+ $$"""
+ using TUnit.Core;
+ {{WebApplicationFactoryStub}}
+ {{WebApplicationTestStub}}
+
+ public class MyFactory : TUnit.AspNetCore.TestWebApplicationFactory { }
+ public class Program { }
+
+ public class MyTests : TUnit.AspNetCore.WebApplicationTest
+ {
+ [Test]
+ public void MyTest()
+ {
+ var client = {|#0:GlobalFactory.CreateDefaultClient()|};
+ }
+ }
+ """,
+ Verifier.Diagnostic(Rules.GlobalFactoryMemberAccess)
+ .WithLocation(0)
+ .WithArguments("CreateDefaultClient")
+ );
+ }
+
+ [Test]
+ public async Task No_Error_When_Accessing_Factory_Services_In_Test()
+ {
+ // Factory.Services is the correct way to access services
+ await Verifier
+ .VerifyAnalyzerAsync(
+ $$"""
+ using TUnit.Core;
+ {{WebApplicationFactoryStub}}
+ {{WebApplicationTestStub}}
+
+ public class MyFactory : TUnit.AspNetCore.TestWebApplicationFactory { }
+ public class Program { }
+
+ public class MyTests : TUnit.AspNetCore.WebApplicationTest
+ {
+ [Test]
+ public void MyTest()
+ {
+ var services = Services;
+ }
+ }
+ """
+ );
+ }
+
+ [Test]
+ public async Task Error_When_Accessing_Factory_In_Constructor_With_Deep_Inheritance()
+ {
+ // Analyzer should detect WebApplicationTest even through multiple levels of inheritance
+ await Verifier
+ .VerifyAnalyzerAsync(
+ $$"""
+ using TUnit.Core;
+ {{WebApplicationTestStub}}
+
+ public class MyFactory { }
+ public class Program { }
+
+ public abstract class BaseTestClass : TUnit.AspNetCore.WebApplicationTest
+ {
+ }
+
+ public abstract class MiddleTestClass : BaseTestClass
+ {
+ }
+
+ public class MyTests : MiddleTestClass
+ {
+ public MyTests()
+ {
+ var factory = {|#0:Factory|};
+ }
+
+ [Test]
+ public void MyTest()
+ {
+ }
+ }
+ """,
+ Verifier.Diagnostic(Rules.FactoryAccessedTooEarly)
+ .WithLocation(0)
+ .WithArguments("Factory", "constructor")
+ );
+ }
+
+ [Test]
+ public async Task Error_When_Accessing_GlobalFactory_Services_With_Deep_Inheritance()
+ {
+ // Analyzer should detect GlobalFactory.Services access even through multiple levels of inheritance
+ await Verifier
+ .VerifyAnalyzerAsync(
+ $$"""
+ using TUnit.Core;
+ {{WebApplicationFactoryStub}}
+ {{WebApplicationTestStub}}
+
+ public class MyFactory : TUnit.AspNetCore.TestWebApplicationFactory { }
+ public class Program { }
+
+ public abstract class BaseTestClass : TUnit.AspNetCore.WebApplicationTest
+ {
+ }
+
+ public abstract class MiddleTestClass : BaseTestClass
+ {
+ }
+
+ public class MyTests : MiddleTestClass
+ {
+ [Test]
+ public void MyTest()
+ {
+ var services = {|#0:GlobalFactory.Services|};
+ }
+ }
+ """,
+ Verifier.Diagnostic(Rules.GlobalFactoryMemberAccess)
+ .WithLocation(0)
+ .WithArguments("Services")
+ );
+ }
+}
diff --git a/TUnit.AspNetCore.Analyzers/AnalyzerReleases.Shipped.md b/TUnit.AspNetCore.Analyzers/AnalyzerReleases.Shipped.md
new file mode 100644
index 0000000000..39071b5a25
--- /dev/null
+++ b/TUnit.AspNetCore.Analyzers/AnalyzerReleases.Shipped.md
@@ -0,0 +1,6 @@
+## Release 1.0
+
+### New Rules
+
+Rule ID | Category | Severity | Notes
+--------|----------|----------|-------
diff --git a/TUnit.AspNetCore.Analyzers/AnalyzerReleases.Unshipped.md b/TUnit.AspNetCore.Analyzers/AnalyzerReleases.Unshipped.md
new file mode 100644
index 0000000000..e69fa429b4
--- /dev/null
+++ b/TUnit.AspNetCore.Analyzers/AnalyzerReleases.Unshipped.md
@@ -0,0 +1,11 @@
+### New Rules
+
+Rule ID | Category | Severity | Notes
+--------|----------|----------|-------
+TUnit0062 | Usage | Error | Factory property accessed before initialization in WebApplicationTest
+TUnit0063 | Usage | Error | GlobalFactory member access breaks test isolation
+
+### Removed Rules
+
+Rule ID | Category | Severity | Notes
+--------|----------|----------|-------
diff --git a/TUnit.AspNetCore.Analyzers/ConcurrentDiagnosticAnalyzer.cs b/TUnit.AspNetCore.Analyzers/ConcurrentDiagnosticAnalyzer.cs
new file mode 100644
index 0000000000..0cef679d93
--- /dev/null
+++ b/TUnit.AspNetCore.Analyzers/ConcurrentDiagnosticAnalyzer.cs
@@ -0,0 +1,16 @@
+using Microsoft.CodeAnalysis.Diagnostics;
+
+namespace TUnit.AspNetCore.Analyzers;
+
+public abstract class ConcurrentDiagnosticAnalyzer : DiagnosticAnalyzer
+{
+ public sealed override void Initialize(AnalysisContext context)
+ {
+ context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
+ context.EnableConcurrentExecution();
+
+ InitializeInternal(context);
+ }
+
+ protected abstract void InitializeInternal(AnalysisContext context);
+}
diff --git a/TUnit.AspNetCore.Analyzers/Resources.Designer.cs b/TUnit.AspNetCore.Analyzers/Resources.Designer.cs
new file mode 100644
index 0000000000..de3968cd70
--- /dev/null
+++ b/TUnit.AspNetCore.Analyzers/Resources.Designer.cs
@@ -0,0 +1,112 @@
+//------------------------------------------------------------------------------
+//
+// This code was generated by a tool.
+//
+// Changes to this file may cause incorrect behavior and will be lost if
+// the code is regenerated.
+//
+//------------------------------------------------------------------------------
+
+namespace TUnit.AspNetCore.Analyzers {
+ using System;
+
+
+ ///
+ /// A strongly-typed resource class, for looking up localized strings, etc.
+ ///
+ [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")]
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
+ [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
+ internal class Resources {
+
+ private static global::System.Resources.ResourceManager resourceMan;
+
+ private static global::System.Globalization.CultureInfo resourceCulture;
+
+ [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")]
+ internal Resources() {
+ }
+
+ ///
+ /// Returns the cached ResourceManager instance used by this class.
+ ///
+ [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
+ internal static global::System.Resources.ResourceManager ResourceManager {
+ get {
+ if (object.ReferenceEquals(resourceMan, null)) {
+ global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("TUnit.AspNetCore.Analyzers.Resources", typeof(Resources).Assembly);
+ resourceMan = temp;
+ }
+ return resourceMan;
+ }
+ }
+
+ ///
+ /// Overrides the current thread's CurrentUICulture property for all
+ /// resource lookups using this strongly typed resource class.
+ ///
+ [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
+ internal static global::System.Globalization.CultureInfo Culture {
+ get {
+ return resourceCulture;
+ }
+ set {
+ resourceCulture = value;
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to The Factory, Services, and HttpCapture properties are not available in constructors or SetupAsync...
+ ///
+ internal static string TUnit0062Description {
+ get {
+ return ResourceManager.GetString("TUnit0062Description", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to '{0}' cannot be accessed in {1}. It is not initialized until after SetupAsync completes.
+ ///
+ internal static string TUnit0062MessageFormat {
+ get {
+ return ResourceManager.GetString("TUnit0062MessageFormat", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Factory property accessed before initialization.
+ ///
+ internal static string TUnit0062Title {
+ get {
+ return ResourceManager.GetString("TUnit0062Title", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Do not access Services, Server, or CreateClient on GlobalFactory directly...
+ ///
+ internal static string TUnit0063Description {
+ get {
+ return ResourceManager.GetString("TUnit0063Description", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Do not access '{0}' on GlobalFactory. Use 'Factory.{0}' instead to ensure test isolation.
+ ///
+ internal static string TUnit0063MessageFormat {
+ get {
+ return ResourceManager.GetString("TUnit0063MessageFormat", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to GlobalFactory member access breaks test isolation.
+ ///
+ internal static string TUnit0063Title {
+ get {
+ return ResourceManager.GetString("TUnit0063Title", resourceCulture);
+ }
+ }
+ }
+}
diff --git a/TUnit.AspNetCore.Analyzers/Resources.resx b/TUnit.AspNetCore.Analyzers/Resources.resx
new file mode 100644
index 0000000000..f749a357ac
--- /dev/null
+++ b/TUnit.AspNetCore.Analyzers/Resources.resx
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+
+
+
+ text/microsoft-resx
+
+
+ 1.3
+
+
+ System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ The Factory, Services, and HttpCapture properties are not available in constructors or SetupAsync. These properties are initialized in the [Before(HookType.Test)] hook which runs after SetupAsync. Access these properties only in test methods or [Before(HookType.Test)]/[After(HookType.Test)] hooks.
+
+
+ '{0}' cannot be accessed in {1}. It is not initialized until after SetupAsync completes.
+
+
+ Factory property accessed before initialization
+
+
+ Do not access Services, Server, or CreateClient on GlobalFactory directly. GlobalFactory is a shared template - use the Factory property instead, which provides an isolated instance for each test.
+
+
+ Do not access '{0}' on GlobalFactory. Use 'Factory.{0}' instead to ensure test isolation.
+
+
+ GlobalFactory member access breaks test isolation
+
+
diff --git a/TUnit.AspNetCore.Analyzers/Rules.cs b/TUnit.AspNetCore.Analyzers/Rules.cs
new file mode 100644
index 0000000000..aeccd1bc44
--- /dev/null
+++ b/TUnit.AspNetCore.Analyzers/Rules.cs
@@ -0,0 +1,30 @@
+using Microsoft.CodeAnalysis;
+
+namespace TUnit.AspNetCore.Analyzers;
+
+public static class Rules
+{
+ private const string UsageCategory = "Usage";
+
+ public static readonly DiagnosticDescriptor FactoryAccessedTooEarly =
+ CreateDescriptor("TUnit0062", UsageCategory, DiagnosticSeverity.Error);
+
+ public static readonly DiagnosticDescriptor GlobalFactoryMemberAccess =
+ CreateDescriptor("TUnit0063", UsageCategory, DiagnosticSeverity.Error);
+
+ private static DiagnosticDescriptor CreateDescriptor(string diagnosticId, string category, DiagnosticSeverity severity)
+ {
+ return new DiagnosticDescriptor(
+ id: diagnosticId,
+ title: new LocalizableResourceString(diagnosticId + "Title",
+ Resources.ResourceManager, typeof(Resources)),
+ messageFormat: new LocalizableResourceString(diagnosticId + "MessageFormat", Resources.ResourceManager,
+ typeof(Resources)),
+ category: category,
+ defaultSeverity: severity,
+ isEnabledByDefault: true,
+ description: new LocalizableResourceString(diagnosticId + "Description", Resources.ResourceManager,
+ typeof(Resources))
+ );
+ }
+}
diff --git a/TUnit.AspNetCore.Analyzers/TUnit.AspNetCore.Analyzers.csproj b/TUnit.AspNetCore.Analyzers/TUnit.AspNetCore.Analyzers.csproj
new file mode 100644
index 0000000000..ba541a0f23
--- /dev/null
+++ b/TUnit.AspNetCore.Analyzers/TUnit.AspNetCore.Analyzers.csproj
@@ -0,0 +1,47 @@
+
+
+
+
+
+ netstandard2.0
+ enable
+ latest
+ true
+ true
+ false
+ true
+ TUnit.AspNetCore.Analyzers
+ TUnit.AspNetCore.Analyzers
+ RS2003
+ false
+ false
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+ ResXFileCodeGenerator
+ Resources.Designer.cs
+
+
+
+
+ True
+ True
+ Resources.resx
+
+
+
+
+
+
+
+
diff --git a/TUnit.AspNetCore.Analyzers/WebApplicationFactoryAccessAnalyzer.cs b/TUnit.AspNetCore.Analyzers/WebApplicationFactoryAccessAnalyzer.cs
new file mode 100644
index 0000000000..ab7ce672c4
--- /dev/null
+++ b/TUnit.AspNetCore.Analyzers/WebApplicationFactoryAccessAnalyzer.cs
@@ -0,0 +1,203 @@
+using System.Collections.Immutable;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+using Microsoft.CodeAnalysis.Diagnostics;
+using Microsoft.CodeAnalysis.Operations;
+
+namespace TUnit.AspNetCore.Analyzers;
+
+[DiagnosticAnalyzer(LanguageNames.CSharp)]
+public class WebApplicationFactoryAccessAnalyzer : ConcurrentDiagnosticAnalyzer
+{
+ // Properties not available in constructors OR SetupAsync (initialized in Before hook)
+ private static readonly ImmutableHashSet RestrictedInConstructorAndSetup = ImmutableHashSet.Create(
+ "Factory",
+ "Services",
+ "HttpCapture"
+ );
+
+ // Properties not available in constructors only (available after property injection, before SetupAsync)
+ private static readonly ImmutableHashSet RestrictedInConstructorOnly = ImmutableHashSet.Create(
+ "GlobalFactory"
+ );
+
+ // Members that should never be accessed on GlobalFactory (breaks test isolation)
+ private static readonly ImmutableHashSet RestrictedGlobalFactoryMembers = ImmutableHashSet.Create(
+ "Services",
+ "Server",
+ "CreateClient",
+ "CreateDefaultClient"
+ );
+
+ public override ImmutableArray SupportedDiagnostics { get; } =
+ ImmutableArray.Create(Rules.FactoryAccessedTooEarly, Rules.GlobalFactoryMemberAccess);
+
+ protected override void InitializeInternal(AnalysisContext context)
+ {
+ context.RegisterOperationAction(AnalyzePropertyReference, OperationKind.PropertyReference);
+ context.RegisterOperationAction(AnalyzeInvocation, OperationKind.Invocation);
+ }
+
+ private void AnalyzePropertyReference(OperationAnalysisContext context)
+ {
+ if (context.Operation is not IPropertyReferenceOperation propertyReference)
+ {
+ return;
+ }
+
+ var propertyName = propertyReference.Property.Name;
+
+ // Check for GlobalFactory.Services or GlobalFactory.Server access
+ if (RestrictedGlobalFactoryMembers.Contains(propertyName) &&
+ IsGlobalFactoryAccess(propertyReference.Instance))
+ {
+ context.ReportDiagnostic(Diagnostic.Create(
+ Rules.GlobalFactoryMemberAccess,
+ context.Operation.Syntax.GetLocation(),
+ propertyName));
+ return;
+ }
+
+ var isRestrictedInBoth = RestrictedInConstructorAndSetup.Contains(propertyName);
+ var isRestrictedInConstructorOnly = RestrictedInConstructorOnly.Contains(propertyName);
+
+ if (!isRestrictedInBoth && !isRestrictedInConstructorOnly)
+ {
+ return;
+ }
+
+ // Check if this property belongs to WebApplicationTest or a derived type
+ var containingType = propertyReference.Property.ContainingType;
+ if (!IsWebApplicationTestType(containingType))
+ {
+ return;
+ }
+
+ // Check if we're in a constructor or SetupAsync method
+ var containingMethod = GetContainingMethod(context.Operation);
+ if (containingMethod == null)
+ {
+ return;
+ }
+
+ string? contextName = null;
+
+ if (containingMethod.MethodKind == MethodKind.Constructor)
+ {
+ // All restricted properties are invalid in constructor
+ contextName = "constructor";
+ }
+ else if (containingMethod.Name == "SetupAsync" && containingMethod.IsOverride)
+ {
+ // Only Factory/Services/HttpCapture are invalid in SetupAsync
+ // GlobalFactory IS available in SetupAsync
+ if (isRestrictedInBoth)
+ {
+ contextName = "SetupAsync";
+ }
+ }
+
+ if (contextName != null)
+ {
+ context.ReportDiagnostic(Diagnostic.Create(
+ Rules.FactoryAccessedTooEarly,
+ context.Operation.Syntax.GetLocation(),
+ propertyName,
+ contextName));
+ }
+ }
+
+ private void AnalyzeInvocation(OperationAnalysisContext context)
+ {
+ if (context.Operation is not IInvocationOperation invocation)
+ {
+ return;
+ }
+
+ var methodName = invocation.TargetMethod.Name;
+
+ // Check for GlobalFactory.CreateClient() access
+ if (RestrictedGlobalFactoryMembers.Contains(methodName) &&
+ IsGlobalFactoryAccess(invocation.Instance))
+ {
+ context.ReportDiagnostic(Diagnostic.Create(
+ Rules.GlobalFactoryMemberAccess,
+ context.Operation.Syntax.GetLocation(),
+ methodName));
+ }
+ }
+
+ private static bool IsGlobalFactoryAccess(IOperation? instance)
+ {
+ if (instance is not IPropertyReferenceOperation propertyRef)
+ {
+ return false;
+ }
+
+ // Check if accessing GlobalFactory property on a WebApplicationTest type
+ if (propertyRef.Property.Name != "GlobalFactory")
+ {
+ return false;
+ }
+
+ return IsWebApplicationTestType(propertyRef.Property.ContainingType);
+ }
+
+ private static bool IsWebApplicationTestType(INamedTypeSymbol? type)
+ {
+ while (type != null)
+ {
+ var typeName = type.Name;
+ var namespaceName = type.ContainingNamespace?.ToDisplayString();
+
+ // Check for WebApplicationTest or WebApplicationTest
+ if (typeName == "WebApplicationTest" && namespaceName == "TUnit.AspNetCore")
+ {
+ return true;
+ }
+
+ // Also check the generic version
+ if (type.OriginalDefinition?.Name == "WebApplicationTest" &&
+ type.OriginalDefinition.ContainingNamespace?.ToDisplayString() == "TUnit.AspNetCore")
+ {
+ return true;
+ }
+
+ type = type.BaseType;
+ }
+
+ return false;
+ }
+
+ private static IMethodSymbol? GetContainingMethod(IOperation operation)
+ {
+ var current = operation;
+ while (current != null)
+ {
+ if (current is IMethodBodyOperation or IBlockOperation)
+ {
+ // Get the semantic model to find the containing method
+ var syntax = current.Syntax;
+ while (syntax != null)
+ {
+ if (syntax is MethodDeclarationSyntax or ConstructorDeclarationSyntax)
+ {
+ var semanticModel = operation.SemanticModel;
+ if (semanticModel != null)
+ {
+ var symbol = semanticModel.GetDeclaredSymbol(syntax);
+ if (symbol is IMethodSymbol methodSymbol)
+ {
+ return methodSymbol;
+ }
+ }
+ }
+ syntax = syntax.Parent;
+ }
+ }
+ current = current.Parent;
+ }
+
+ return null;
+ }
+}
diff --git a/TUnit.AspNetCore/TUnit.AspNetCore.csproj b/TUnit.AspNetCore/TUnit.AspNetCore.csproj
index d2f9702d99..03e41e5ad3 100644
--- a/TUnit.AspNetCore/TUnit.AspNetCore.csproj
+++ b/TUnit.AspNetCore/TUnit.AspNetCore.csproj
@@ -27,9 +27,26 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/TUnit.AspNetCore/TestWebApplicationFactory.cs b/TUnit.AspNetCore/TestWebApplicationFactory.cs
index 39ce8380f0..f489c846ea 100644
--- a/TUnit.AspNetCore/TestWebApplicationFactory.cs
+++ b/TUnit.AspNetCore/TestWebApplicationFactory.cs
@@ -27,8 +27,13 @@ public WebApplicationFactory GetIsolatedFactory(
configureWebHostBuilder?.Invoke(builder);
// Then apply standard configuration
- builder.ConfigureTestServices(configureServices)
- .ConfigureAppConfiguration(configureConfiguration);
+ builder
+ .ConfigureAppConfiguration(configureConfiguration)
+ .ConfigureTestServices(services =>
+ {
+ configureServices(services);
+ services.AddSingleton(testContext);
+ });
if (options.EnableHttpExchangeCapture)
{
diff --git a/TUnit.Example.Asp.Net.TestProject/TUnit.Example.Asp.Net.TestProject.csproj b/TUnit.Example.Asp.Net.TestProject/TUnit.Example.Asp.Net.TestProject.csproj
index 7b3f975755..e22918675d 100644
--- a/TUnit.Example.Asp.Net.TestProject/TUnit.Example.Asp.Net.TestProject.csproj
+++ b/TUnit.Example.Asp.Net.TestProject/TUnit.Example.Asp.Net.TestProject.csproj
@@ -1,17 +1,19 @@
-
+
net10.0
+
+
-
+
@@ -19,7 +21,7 @@
-
+
diff --git a/TUnit.Pipeline/Modules/RunAspNetCoreAnalyzersTestsModule.cs b/TUnit.Pipeline/Modules/RunAspNetCoreAnalyzersTestsModule.cs
new file mode 100644
index 0000000000..921b8b8a51
--- /dev/null
+++ b/TUnit.Pipeline/Modules/RunAspNetCoreAnalyzersTestsModule.cs
@@ -0,0 +1,34 @@
+using ModularPipelines.Attributes;
+using ModularPipelines.Context;
+using ModularPipelines.DotNet.Extensions;
+using ModularPipelines.DotNet.Options;
+using ModularPipelines.Enums;
+using ModularPipelines.Extensions;
+using ModularPipelines.Git.Extensions;
+using ModularPipelines.Models;
+using ModularPipelines.Modules;
+
+namespace TUnit.Pipeline.Modules;
+
+[NotInParallel("DotNetTests")]
+public class RunAspNetCoreAnalyzersTestsModule : Module
+{
+ protected override async Task ExecuteAsync(IPipelineContext context, CancellationToken cancellationToken)
+ {
+ var project = context.Git().RootDirectory.FindFile(x => x.Name == "TUnit.AspNetCore.Analyzers.Tests.csproj").AssertExists();
+
+ return await context.DotNet().Test(new DotNetTestOptions
+ {
+ WorkingDirectory = project.Folder!,
+ NoBuild = true,
+ Configuration = Configuration.Release,
+ Framework = "net9.0",
+ Arguments = ["--", "--hangdump", "--hangdump-filename", "hangdump.aspnetcore-analyzers-tests.dmp", "--hangdump-timeout", "5m"],
+ EnvironmentVariables = new Dictionary
+ {
+ ["DISABLE_GITHUB_REPORTER"] = "true",
+ },
+ CommandLogging = CommandLogging.Input | CommandLogging.Error | CommandLogging.Duration | CommandLogging.ExitCode
+ }, cancellationToken);
+ }
+}
diff --git a/TUnit.Pipeline/Modules/TestAspNetCoreNugetPackageModule.cs b/TUnit.Pipeline/Modules/TestAspNetCoreNugetPackageModule.cs
new file mode 100644
index 0000000000..ff6acab658
--- /dev/null
+++ b/TUnit.Pipeline/Modules/TestAspNetCoreNugetPackageModule.cs
@@ -0,0 +1,51 @@
+using ModularPipelines.Attributes;
+using ModularPipelines.Context;
+using ModularPipelines.DotNet.Options;
+using ModularPipelines.Extensions;
+using ModularPipelines.Git.Extensions;
+using ModularPipelines.Models;
+using Polly.Retry;
+using TUnit.Pipeline.Modules.Abstract;
+
+namespace TUnit.Pipeline.Modules;
+
+[DependsOn]
+[DependsOn]
+public class TestAspNetCoreNugetPackageModule : TestBaseModule
+{
+ protected override AsyncRetryPolicy?> RetryPolicy
+ => CreateRetryPolicy(3);
+
+ // ASP.NET Core only supports .NET Core frameworks, not .NET Framework
+ protected override IEnumerable TestableFrameworks
+ {
+ get
+ {
+ yield return "net10.0";
+ yield return "net9.0";
+ yield return "net8.0";
+ }
+ }
+
+ protected override async Task GetTestOptions(IPipelineContext context, string framework,
+ CancellationToken cancellationToken)
+ {
+ var version = await GetModule();
+
+ var project = context.Git()
+ .RootDirectory
+ .AssertExists()
+ .FindFile(x => x.Name == "TUnit.AspNetCore.NugetTester.csproj")
+ .AssertExists();
+
+ return new DotNetRunOptions
+ {
+ WorkingDirectory = project.Folder!,
+ Framework = framework,
+ Properties =
+ [
+ new KeyValue("TUnitVersion", version.Value!.SemVer!)
+ ]
+ };
+ }
+}
diff --git a/TUnit.sln b/TUnit.sln
index 12b327bdf5..2bb8c46d11 100644
--- a/TUnit.sln
+++ b/TUnit.sln
@@ -141,6 +141,16 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TUnit.Profile", "TUnit.Prof
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TUnit.AspNetCore", "TUnit.AspNetCore\TUnit.AspNetCore.csproj", "{A42DAF9A-6E16-4A73-9343-3FE7C8EBDF75}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TUnit.AspNetCore.Analyzers.Roslyn44", "TUnit.AspNetCore.Analyzers.Roslyn44\TUnit.AspNetCore.Analyzers.Roslyn44.csproj", "{9C8243FD-CF7C-4A0A-B2D3-8C6724E616F1}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TUnit.AspNetCore.Analyzers.Roslyn47", "TUnit.AspNetCore.Analyzers.Roslyn47\TUnit.AspNetCore.Analyzers.Roslyn47.csproj", "{6B731D8E-F5E8-4709-8FB7-0332F1FC2CB4}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TUnit.AspNetCore.Analyzers.Roslyn414", "TUnit.AspNetCore.Analyzers.Roslyn414\TUnit.AspNetCore.Analyzers.Roslyn414.csproj", "{D5C70ADD-B960-4E6C-836C-6041938D04BE}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TUnit.AspNetCore.Analyzers.Tests", "TUnit.AspNetCore.Analyzers.Tests\TUnit.AspNetCore.Analyzers.Tests.csproj", "{9B33972F-F5B9-4EC2-AE5C-4D48604DEB04}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TUnit.AspNetCore.Analyzers", "TUnit.AspNetCore.Analyzers\TUnit.AspNetCore.Analyzers.csproj", "{6134813B-F928-443F-A629-F6726A1112F9}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -799,6 +809,66 @@ Global
{A42DAF9A-6E16-4A73-9343-3FE7C8EBDF75}.Release|x64.Build.0 = Release|Any CPU
{A42DAF9A-6E16-4A73-9343-3FE7C8EBDF75}.Release|x86.ActiveCfg = Release|Any CPU
{A42DAF9A-6E16-4A73-9343-3FE7C8EBDF75}.Release|x86.Build.0 = Release|Any CPU
+ {9C8243FD-CF7C-4A0A-B2D3-8C6724E616F1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {9C8243FD-CF7C-4A0A-B2D3-8C6724E616F1}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {9C8243FD-CF7C-4A0A-B2D3-8C6724E616F1}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {9C8243FD-CF7C-4A0A-B2D3-8C6724E616F1}.Debug|x64.Build.0 = Debug|Any CPU
+ {9C8243FD-CF7C-4A0A-B2D3-8C6724E616F1}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {9C8243FD-CF7C-4A0A-B2D3-8C6724E616F1}.Debug|x86.Build.0 = Debug|Any CPU
+ {9C8243FD-CF7C-4A0A-B2D3-8C6724E616F1}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {9C8243FD-CF7C-4A0A-B2D3-8C6724E616F1}.Release|Any CPU.Build.0 = Release|Any CPU
+ {9C8243FD-CF7C-4A0A-B2D3-8C6724E616F1}.Release|x64.ActiveCfg = Release|Any CPU
+ {9C8243FD-CF7C-4A0A-B2D3-8C6724E616F1}.Release|x64.Build.0 = Release|Any CPU
+ {9C8243FD-CF7C-4A0A-B2D3-8C6724E616F1}.Release|x86.ActiveCfg = Release|Any CPU
+ {9C8243FD-CF7C-4A0A-B2D3-8C6724E616F1}.Release|x86.Build.0 = Release|Any CPU
+ {6B731D8E-F5E8-4709-8FB7-0332F1FC2CB4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {6B731D8E-F5E8-4709-8FB7-0332F1FC2CB4}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {6B731D8E-F5E8-4709-8FB7-0332F1FC2CB4}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {6B731D8E-F5E8-4709-8FB7-0332F1FC2CB4}.Debug|x64.Build.0 = Debug|Any CPU
+ {6B731D8E-F5E8-4709-8FB7-0332F1FC2CB4}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {6B731D8E-F5E8-4709-8FB7-0332F1FC2CB4}.Debug|x86.Build.0 = Debug|Any CPU
+ {6B731D8E-F5E8-4709-8FB7-0332F1FC2CB4}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {6B731D8E-F5E8-4709-8FB7-0332F1FC2CB4}.Release|Any CPU.Build.0 = Release|Any CPU
+ {6B731D8E-F5E8-4709-8FB7-0332F1FC2CB4}.Release|x64.ActiveCfg = Release|Any CPU
+ {6B731D8E-F5E8-4709-8FB7-0332F1FC2CB4}.Release|x64.Build.0 = Release|Any CPU
+ {6B731D8E-F5E8-4709-8FB7-0332F1FC2CB4}.Release|x86.ActiveCfg = Release|Any CPU
+ {6B731D8E-F5E8-4709-8FB7-0332F1FC2CB4}.Release|x86.Build.0 = Release|Any CPU
+ {D5C70ADD-B960-4E6C-836C-6041938D04BE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {D5C70ADD-B960-4E6C-836C-6041938D04BE}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {D5C70ADD-B960-4E6C-836C-6041938D04BE}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {D5C70ADD-B960-4E6C-836C-6041938D04BE}.Debug|x64.Build.0 = Debug|Any CPU
+ {D5C70ADD-B960-4E6C-836C-6041938D04BE}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {D5C70ADD-B960-4E6C-836C-6041938D04BE}.Debug|x86.Build.0 = Debug|Any CPU
+ {D5C70ADD-B960-4E6C-836C-6041938D04BE}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {D5C70ADD-B960-4E6C-836C-6041938D04BE}.Release|Any CPU.Build.0 = Release|Any CPU
+ {D5C70ADD-B960-4E6C-836C-6041938D04BE}.Release|x64.ActiveCfg = Release|Any CPU
+ {D5C70ADD-B960-4E6C-836C-6041938D04BE}.Release|x64.Build.0 = Release|Any CPU
+ {D5C70ADD-B960-4E6C-836C-6041938D04BE}.Release|x86.ActiveCfg = Release|Any CPU
+ {D5C70ADD-B960-4E6C-836C-6041938D04BE}.Release|x86.Build.0 = Release|Any CPU
+ {9B33972F-F5B9-4EC2-AE5C-4D48604DEB04}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {9B33972F-F5B9-4EC2-AE5C-4D48604DEB04}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {9B33972F-F5B9-4EC2-AE5C-4D48604DEB04}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {9B33972F-F5B9-4EC2-AE5C-4D48604DEB04}.Debug|x64.Build.0 = Debug|Any CPU
+ {9B33972F-F5B9-4EC2-AE5C-4D48604DEB04}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {9B33972F-F5B9-4EC2-AE5C-4D48604DEB04}.Debug|x86.Build.0 = Debug|Any CPU
+ {9B33972F-F5B9-4EC2-AE5C-4D48604DEB04}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {9B33972F-F5B9-4EC2-AE5C-4D48604DEB04}.Release|Any CPU.Build.0 = Release|Any CPU
+ {9B33972F-F5B9-4EC2-AE5C-4D48604DEB04}.Release|x64.ActiveCfg = Release|Any CPU
+ {9B33972F-F5B9-4EC2-AE5C-4D48604DEB04}.Release|x64.Build.0 = Release|Any CPU
+ {9B33972F-F5B9-4EC2-AE5C-4D48604DEB04}.Release|x86.ActiveCfg = Release|Any CPU
+ {9B33972F-F5B9-4EC2-AE5C-4D48604DEB04}.Release|x86.Build.0 = Release|Any CPU
+ {6134813B-F928-443F-A629-F6726A1112F9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {6134813B-F928-443F-A629-F6726A1112F9}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {6134813B-F928-443F-A629-F6726A1112F9}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {6134813B-F928-443F-A629-F6726A1112F9}.Debug|x64.Build.0 = Debug|Any CPU
+ {6134813B-F928-443F-A629-F6726A1112F9}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {6134813B-F928-443F-A629-F6726A1112F9}.Debug|x86.Build.0 = Debug|Any CPU
+ {6134813B-F928-443F-A629-F6726A1112F9}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {6134813B-F928-443F-A629-F6726A1112F9}.Release|Any CPU.Build.0 = Release|Any CPU
+ {6134813B-F928-443F-A629-F6726A1112F9}.Release|x64.ActiveCfg = Release|Any CPU
+ {6134813B-F928-443F-A629-F6726A1112F9}.Release|x64.Build.0 = Release|Any CPU
+ {6134813B-F928-443F-A629-F6726A1112F9}.Release|x86.ActiveCfg = Release|Any CPU
+ {6134813B-F928-443F-A629-F6726A1112F9}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -860,6 +930,11 @@ Global
{A7B8C9D0-1234-4567-8901-23456789ABCD} = {62AD1EAF-43C4-4AC0-B9FA-CD59739B3850}
{FEB5F3F3-A7DD-4DE0-A9C7-B9AD489E9C52} = {62AD1EAF-43C4-4AC0-B9FA-CD59739B3850}
{A42DAF9A-6E16-4A73-9343-3FE7C8EBDF75} = {1B56B580-4D59-4E83-9F80-467D58DADAC1}
+ {9C8243FD-CF7C-4A0A-B2D3-8C6724E616F1} = {503DA9FA-045D-4910-8AF6-905E6048B1F1}
+ {6B731D8E-F5E8-4709-8FB7-0332F1FC2CB4} = {503DA9FA-045D-4910-8AF6-905E6048B1F1}
+ {D5C70ADD-B960-4E6C-836C-6041938D04BE} = {503DA9FA-045D-4910-8AF6-905E6048B1F1}
+ {9B33972F-F5B9-4EC2-AE5C-4D48604DEB04} = {62AD1EAF-43C4-4AC0-B9FA-CD59739B3850}
+ {6134813B-F928-443F-A629-F6726A1112F9} = {503DA9FA-045D-4910-8AF6-905E6048B1F1}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {109D285A-36B3-4503-BCDF-8E26FB0E2C5B}
diff --git a/tools/tunit-nuget-tester/TUnit.AspNetCore.NugetTester/TUnit.AspNetCore.NugetTester.WebApp/Program.cs b/tools/tunit-nuget-tester/TUnit.AspNetCore.NugetTester/TUnit.AspNetCore.NugetTester.WebApp/Program.cs
new file mode 100644
index 0000000000..17ce53228e
--- /dev/null
+++ b/tools/tunit-nuget-tester/TUnit.AspNetCore.NugetTester/TUnit.AspNetCore.NugetTester.WebApp/Program.cs
@@ -0,0 +1,63 @@
+var builder = WebApplication.CreateBuilder(args);
+
+// Register services that can be replaced in tests
+builder.Services.AddSingleton();
+builder.Services.AddSingleton();
+
+var app = builder.Build();
+
+// Basic ping endpoint
+app.MapGet("/ping", () => "pong");
+
+// Greeting endpoint using injected service
+app.MapGet("/greet/{name}", (string name, IGreetingService greetingService) =>
+ greetingService.GetGreeting(name));
+
+// Time endpoint using injected service
+app.MapGet("/time", (ITimeService timeService) =>
+ new { CurrentTime = timeService.GetCurrentTime() });
+
+// Configuration endpoint for testing config overrides
+app.MapGet("/config/message", (IConfiguration config) =>
+ config["TestMessage"] ?? "default message");
+
+// Echo endpoint for testing request/response capture
+app.MapPost("/echo", async (HttpRequest request) =>
+{
+ using var reader = new StreamReader(request.Body);
+ var body = await reader.ReadToEndAsync();
+ return Results.Ok(new { Echo = body, Received = DateTime.UtcNow });
+});
+
+// Status endpoint that returns headers for testing
+app.MapGet("/status", (HttpContext context) =>
+{
+ context.Response.Headers.Append("X-Custom-Header", "test-value");
+ return Results.Ok(new { Status = "healthy" });
+});
+
+app.Run();
+
+// Service interfaces and implementations
+public interface IGreetingService
+{
+ string GetGreeting(string name);
+}
+
+public class DefaultGreetingService : IGreetingService
+{
+ public string GetGreeting(string name) => $"Hello, {name}!";
+}
+
+public interface ITimeService
+{
+ DateTime GetCurrentTime();
+}
+
+public class SystemTimeService : ITimeService
+{
+ public DateTime GetCurrentTime() => DateTime.UtcNow;
+}
+
+// Required for WebApplicationFactory
+public partial class Program;
diff --git a/tools/tunit-nuget-tester/TUnit.AspNetCore.NugetTester/TUnit.AspNetCore.NugetTester.WebApp/TUnit.AspNetCore.NugetTester.WebApp.csproj b/tools/tunit-nuget-tester/TUnit.AspNetCore.NugetTester/TUnit.AspNetCore.NugetTester.WebApp/TUnit.AspNetCore.NugetTester.WebApp.csproj
new file mode 100644
index 0000000000..ff70c90a2d
--- /dev/null
+++ b/tools/tunit-nuget-tester/TUnit.AspNetCore.NugetTester/TUnit.AspNetCore.NugetTester.WebApp/TUnit.AspNetCore.NugetTester.WebApp.csproj
@@ -0,0 +1,10 @@
+
+
+
+ net8.0;net9.0;net10.0
+ enable
+ enable
+ false
+
+
+
diff --git a/tools/tunit-nuget-tester/TUnit.AspNetCore.NugetTester/TUnit.AspNetCore.NugetTester.sln b/tools/tunit-nuget-tester/TUnit.AspNetCore.NugetTester/TUnit.AspNetCore.NugetTester.sln
new file mode 100644
index 0000000000..2e8965641f
--- /dev/null
+++ b/tools/tunit-nuget-tester/TUnit.AspNetCore.NugetTester/TUnit.AspNetCore.NugetTester.sln
@@ -0,0 +1,48 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.0.31903.59
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TUnit.AspNetCore.NugetTester.WebApp", "TUnit.AspNetCore.NugetTester.WebApp\TUnit.AspNetCore.NugetTester.WebApp.csproj", "{72905889-262E-4555-A47B-F3C2E8768BA0}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TUnit.AspNetCore.NugetTester", "TUnit.AspNetCore.NugetTester\TUnit.AspNetCore.NugetTester.csproj", "{D9FA8977-E954-436E-81C8-38DCE64B1A7D}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Debug|x64 = Debug|x64
+ Debug|x86 = Debug|x86
+ Release|Any CPU = Release|Any CPU
+ Release|x64 = Release|x64
+ Release|x86 = Release|x86
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {72905889-262E-4555-A47B-F3C2E8768BA0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {72905889-262E-4555-A47B-F3C2E8768BA0}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {72905889-262E-4555-A47B-F3C2E8768BA0}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {72905889-262E-4555-A47B-F3C2E8768BA0}.Debug|x64.Build.0 = Debug|Any CPU
+ {72905889-262E-4555-A47B-F3C2E8768BA0}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {72905889-262E-4555-A47B-F3C2E8768BA0}.Debug|x86.Build.0 = Debug|Any CPU
+ {72905889-262E-4555-A47B-F3C2E8768BA0}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {72905889-262E-4555-A47B-F3C2E8768BA0}.Release|Any CPU.Build.0 = Release|Any CPU
+ {72905889-262E-4555-A47B-F3C2E8768BA0}.Release|x64.ActiveCfg = Release|Any CPU
+ {72905889-262E-4555-A47B-F3C2E8768BA0}.Release|x64.Build.0 = Release|Any CPU
+ {72905889-262E-4555-A47B-F3C2E8768BA0}.Release|x86.ActiveCfg = Release|Any CPU
+ {72905889-262E-4555-A47B-F3C2E8768BA0}.Release|x86.Build.0 = Release|Any CPU
+ {D9FA8977-E954-436E-81C8-38DCE64B1A7D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {D9FA8977-E954-436E-81C8-38DCE64B1A7D}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {D9FA8977-E954-436E-81C8-38DCE64B1A7D}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {D9FA8977-E954-436E-81C8-38DCE64B1A7D}.Debug|x64.Build.0 = Debug|Any CPU
+ {D9FA8977-E954-436E-81C8-38DCE64B1A7D}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {D9FA8977-E954-436E-81C8-38DCE64B1A7D}.Debug|x86.Build.0 = Debug|Any CPU
+ {D9FA8977-E954-436E-81C8-38DCE64B1A7D}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {D9FA8977-E954-436E-81C8-38DCE64B1A7D}.Release|Any CPU.Build.0 = Release|Any CPU
+ {D9FA8977-E954-436E-81C8-38DCE64B1A7D}.Release|x64.ActiveCfg = Release|Any CPU
+ {D9FA8977-E954-436E-81C8-38DCE64B1A7D}.Release|x64.Build.0 = Release|Any CPU
+ {D9FA8977-E954-436E-81C8-38DCE64B1A7D}.Release|x86.ActiveCfg = Release|Any CPU
+ {D9FA8977-E954-436E-81C8-38DCE64B1A7D}.Release|x86.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+EndGlobal
diff --git a/tools/tunit-nuget-tester/TUnit.AspNetCore.NugetTester/TUnit.AspNetCore.NugetTester/GlobalUsings.cs b/tools/tunit-nuget-tester/TUnit.AspNetCore.NugetTester/TUnit.AspNetCore.NugetTester/GlobalUsings.cs
new file mode 100644
index 0000000000..4ee3a4af7b
--- /dev/null
+++ b/tools/tunit-nuget-tester/TUnit.AspNetCore.NugetTester/TUnit.AspNetCore.NugetTester/GlobalUsings.cs
@@ -0,0 +1,6 @@
+global using System.Collections.Concurrent;
+global using Microsoft.Extensions.DependencyInjection;
+global using Microsoft.Extensions.Logging;
+global using Microsoft.Extensions.Configuration;
+global using TUnit.Core;
+global using TUnit.AspNetCore.Extensions;
diff --git a/tools/tunit-nuget-tester/TUnit.AspNetCore.NugetTester/TUnit.AspNetCore.NugetTester/HooksAndLifecycleTests.cs b/tools/tunit-nuget-tester/TUnit.AspNetCore.NugetTester/TUnit.AspNetCore.NugetTester/HooksAndLifecycleTests.cs
new file mode 100644
index 0000000000..69d24dd51a
--- /dev/null
+++ b/tools/tunit-nuget-tester/TUnit.AspNetCore.NugetTester/TUnit.AspNetCore.NugetTester/HooksAndLifecycleTests.cs
@@ -0,0 +1,131 @@
+namespace TUnit.AspNetCore.NugetTester;
+
+///
+/// Tests verifying that TUnit hooks and WebApplicationTest lifecycle work correctly together.
+/// These tests prove that source generation and hooks function properly when TUnit.AspNetCore
+/// is consumed as a NuGet package.
+///
+public class HooksAndLifecycleTests : TestsBase
+{
+ private bool _beforeTestHookRan;
+ private bool _setupAsyncRan;
+ private string? _isolatedName;
+
+ protected override async Task SetupAsync()
+ {
+ _setupAsyncRan = true;
+ _isolatedName = GetIsolatedName("test-resource");
+ await Task.CompletedTask;
+ }
+
+ [Before(HookType.Test)]
+ public void BeforeTestHook()
+ {
+ _beforeTestHookRan = true;
+ Console.WriteLine($"[BeforeTest] Starting test: {TestContext.Current?.Metadata?.DisplayName}");
+ }
+
+ [After(HookType.Test)]
+ public void AfterTestHook()
+ {
+ // This hook runs after each test, proving After hooks work with WebApplicationTest
+ Console.WriteLine($"[AfterTest] Finished test: {TestContext.Current?.Metadata?.DisplayName}");
+ }
+
+ [Test]
+ public async Task Factory_IsNotNull_WhenTestRuns()
+ {
+ // Verifies that the Factory is properly initialized before test execution
+ await Assert.That(Factory).IsNotNull();
+ }
+
+ [Test]
+ public async Task Factory_CreateClient_Works()
+ {
+ // Verifies that we can create an HttpClient from the factory
+ var client = Factory.CreateClient();
+ await Assert.That(client).IsNotNull();
+ }
+
+ [Test]
+ public async Task Services_AreAccessible_FromFactory()
+ {
+ // Verifies that Services property exposes the service provider
+ await Assert.That(Services).IsNotNull();
+
+ var greetingService = Services.GetService();
+ await Assert.That(greetingService).IsNotNull();
+ }
+
+ [Test]
+ public async Task GlobalFactory_IsShared_AcrossTests()
+ {
+ // Verifies that GlobalFactory is accessible
+ await Assert.That(GlobalFactory).IsNotNull();
+ }
+
+ [Test]
+ public async Task SetupAsync_RanBeforeTest()
+ {
+ // Verifies that SetupAsync was called before the test
+ await Assert.That(_setupAsyncRan).IsTrue();
+ }
+
+ [Test]
+ public async Task BeforeTestHook_RanBeforeTest()
+ {
+ // Verifies that the [Before(Test)] hook ran before this test
+ await Assert.That(_beforeTestHookRan).IsTrue();
+ }
+
+ [Test]
+ public async Task GetIsolatedName_ReturnsUniqueValue()
+ {
+ // Verifies that GetIsolatedName produces a unique isolation name
+ await Assert.That(_isolatedName).IsNotNull();
+ await Assert.That(_isolatedName).Contains("test-resource");
+ await Assert.That(_isolatedName).Contains("Test_");
+ }
+
+ [Test]
+ public async Task UniqueId_IsPositive()
+ {
+ // Verifies that UniqueId is assigned and positive
+ await Assert.That(UniqueId).IsPositive();
+ }
+
+ [Test]
+ public async Task GetIsolatedPrefix_ReturnsFormattedPrefix()
+ {
+ // Verifies that GetIsolatedPrefix produces a formatted prefix
+ var prefix = GetIsolatedPrefix("_");
+ await Assert.That(prefix).StartsWith("test_");
+ await Assert.That(prefix).Contains("_");
+ }
+
+ [Test]
+ public async Task Ping_Endpoint_ReturnsExpectedResponse()
+ {
+ // Basic integration test verifying the factory and web app work together
+ var client = Factory.CreateClient();
+ var response = await client.GetAsync("/ping");
+
+ await Assert.That(response.IsSuccessStatusCode).IsTrue();
+ var content = await response.Content.ReadAsStringAsync();
+ await Assert.That(content).IsEqualTo("pong");
+ }
+
+ [Test]
+ [Arguments("Alice")]
+ [Arguments("Bob")]
+ [Arguments("Charlie")]
+ public async Task DataDrivenTest_WithArguments_Works(string name)
+ {
+ // Verifies that data-driven tests work with WebApplicationTest
+ var client = Factory.CreateClient();
+ var response = await client.GetAsync($"/greet/{name}");
+
+ var content = await response.Content.ReadAsStringAsync();
+ await Assert.That(content).Contains(name);
+ }
+}
diff --git a/tools/tunit-nuget-tester/TUnit.AspNetCore.NugetTester/TUnit.AspNetCore.NugetTester/HttpExchangeCaptureTests.cs b/tools/tunit-nuget-tester/TUnit.AspNetCore.NugetTester/TUnit.AspNetCore.NugetTester/HttpExchangeCaptureTests.cs
new file mode 100644
index 0000000000..67ac41ffdc
--- /dev/null
+++ b/tools/tunit-nuget-tester/TUnit.AspNetCore.NugetTester/TUnit.AspNetCore.NugetTester/HttpExchangeCaptureTests.cs
@@ -0,0 +1,184 @@
+using System.Net;
+using System.Text;
+using TUnit.AspNetCore;
+using TUnit.AspNetCore.Interception;
+
+namespace TUnit.AspNetCore.NugetTester;
+
+///
+/// Tests verifying that HTTP exchange capture works correctly when TUnit.AspNetCore
+/// is consumed as a NuGet package.
+///
+public class HttpExchangeCaptureTests : TestsBase
+{
+ protected override void ConfigureTestOptions(WebApplicationTestOptions options)
+ {
+ // Enable HTTP exchange capture for these tests
+ options.EnableHttpExchangeCapture = true;
+ }
+
+ ///
+ /// Gets the HttpExchangeCapture from DI services.
+ /// Note: Use this instead of the HttpCapture property until bug is fixed.
+ ///
+ private HttpExchangeCapture Capture => Services.GetRequiredService();
+
+ [Test]
+ public async Task HttpCapture_IsAvailableInServices_WhenEnabled()
+ {
+ // Verifies that HttpExchangeCapture is registered when enabled in options
+ var capture = Services.GetService();
+ await Assert.That(capture).IsNotNull();
+ }
+
+ [Test]
+ public async Task HttpCapture_CapturesGetRequest()
+ {
+ // Verifies that GET requests are captured
+ var client = Factory.CreateClient();
+ await client.GetAsync("/ping");
+
+ await Assert.That(Capture.Count).IsEqualTo(1);
+ await Assert.That(Capture.Last!.Request.Method).IsEqualTo("GET");
+ await Assert.That(Capture.Last.Request.Path).IsEqualTo("/ping");
+ }
+
+ [Test]
+ public async Task HttpCapture_CapturesResponseStatusCode()
+ {
+ // Verifies that response status codes are captured
+ var client = Factory.CreateClient();
+ await client.GetAsync("/ping");
+
+ await Assert.That(Capture.Last!.Response.StatusCode).IsEqualTo(HttpStatusCode.OK);
+ await Assert.That(Capture.Last.Response.StatusCodeValue).IsEqualTo(200);
+ }
+
+ [Test]
+ public async Task HttpCapture_CapturesResponseBody()
+ {
+ // Verifies that response body is captured
+ var client = Factory.CreateClient();
+ await client.GetAsync("/ping");
+
+ await Assert.That(Capture.Last!.Response.Body).IsEqualTo("pong");
+ }
+
+ [Test]
+ public async Task HttpCapture_CapturesPostRequestBody()
+ {
+ // Verifies that POST request body is captured
+ var client = Factory.CreateClient();
+ var content = new StringContent("test payload", Encoding.UTF8, "text/plain");
+ await client.PostAsync("/echo", content);
+
+ await Assert.That(Capture.Last!.Request.Method).IsEqualTo("POST");
+ await Assert.That(Capture.Last.Request.Body).Contains("test payload");
+ }
+
+ [Test]
+ public async Task HttpCapture_CapturesResponseHeaders()
+ {
+ // Verifies that response headers are captured
+ var client = Factory.CreateClient();
+ await client.GetAsync("/status");
+
+ await Assert.That(Capture.Last!.Response.Headers)
+ .ContainsKey("X-Custom-Header");
+ }
+
+ [Test]
+ public async Task HttpCapture_CapturesMultipleExchanges()
+ {
+ // Verifies that multiple exchanges are captured in order
+ var client = Factory.CreateClient();
+ await client.GetAsync("/ping");
+ await client.GetAsync("/status");
+ await client.GetAsync("/greet/World");
+
+ await Assert.That(Capture.Count).IsEqualTo(3);
+ await Assert.That(Capture.First!.Request.Path).IsEqualTo("/ping");
+ await Assert.That(Capture.Last!.Request.Path).IsEqualTo("/greet/World");
+ }
+
+ [Test]
+ public async Task HttpCapture_ForMethod_FiltersCorrectly()
+ {
+ // Verifies that filtering by HTTP method works
+ var client = Factory.CreateClient();
+ await client.GetAsync("/ping");
+ await client.PostAsync("/echo", new StringContent("test"));
+
+ var getExchanges = Capture.ForMethod("GET");
+ var postExchanges = Capture.ForMethod("POST");
+
+ await Assert.That(getExchanges.Count()).IsEqualTo(1);
+ await Assert.That(postExchanges.Count()).IsEqualTo(1);
+ }
+
+ [Test]
+ public async Task HttpCapture_ForPath_FiltersCorrectly()
+ {
+ // Verifies that filtering by path works
+ var client = Factory.CreateClient();
+ await client.GetAsync("/ping");
+ await client.GetAsync("/status");
+
+ var pingExchanges = Capture.ForPath("/ping");
+
+ await Assert.That(pingExchanges.Count()).IsEqualTo(1);
+ await Assert.That(pingExchanges.First()!.Request.Path).IsEqualTo("/ping");
+ }
+
+ [Test]
+ public async Task HttpCapture_ForPathStartingWith_FiltersCorrectly()
+ {
+ // Verifies that filtering by path prefix works
+ var client = Factory.CreateClient();
+ await client.GetAsync("/greet/Alice");
+ await client.GetAsync("/greet/Bob");
+ await client.GetAsync("/ping");
+
+ var greetExchanges = Capture.ForPathStartingWith("/greet");
+
+ await Assert.That(greetExchanges.Count()).IsEqualTo(2);
+ }
+
+ [Test]
+ public async Task HttpCapture_Clear_RemovesAllExchanges()
+ {
+ // Verifies that Clear() works correctly
+ var client = Factory.CreateClient();
+ await client.GetAsync("/ping");
+ await client.GetAsync("/status");
+
+ await Assert.That(Capture.Count).IsEqualTo(2);
+
+ Capture.Clear();
+
+ await Assert.That(Capture.Count).IsEqualTo(0);
+ }
+
+ [Test]
+ public async Task HttpCapture_TracksDuration()
+ {
+ // Verifies that request duration is tracked
+ var client = Factory.CreateClient();
+ await client.GetAsync("/ping");
+
+ await Assert.That(Capture.Last!.Duration).IsGreaterThanOrEqualTo(TimeSpan.Zero);
+ }
+
+ [Test]
+ public async Task HttpCapture_TracksTimestamp()
+ {
+ // Verifies that timestamp is tracked
+ var before = DateTime.UtcNow;
+ var client = Factory.CreateClient();
+ await client.GetAsync("/ping");
+ var after = DateTime.UtcNow;
+
+ await Assert.That(Capture.Last!.Timestamp).IsGreaterThanOrEqualTo(before);
+ await Assert.That(Capture.Last.Timestamp).IsLessThanOrEqualTo(after);
+ }
+}
diff --git a/tools/tunit-nuget-tester/TUnit.AspNetCore.NugetTester/TUnit.AspNetCore.NugetTester/ServiceOverrideTests.cs b/tools/tunit-nuget-tester/TUnit.AspNetCore.NugetTester/TUnit.AspNetCore.NugetTester/ServiceOverrideTests.cs
new file mode 100644
index 0000000000..560aaac12c
--- /dev/null
+++ b/tools/tunit-nuget-tester/TUnit.AspNetCore.NugetTester/TUnit.AspNetCore.NugetTester/ServiceOverrideTests.cs
@@ -0,0 +1,195 @@
+using Microsoft.Extensions.Configuration;
+using TUnit.AspNetCore;
+
+namespace TUnit.AspNetCore.NugetTester;
+
+///
+/// Tests verifying that service and configuration overrides work correctly when
+/// TUnit.AspNetCore is consumed as a NuGet package.
+///
+public class ServiceOverrideTests : TestsBase
+{
+ private const string TestConfigMessage = "Custom test message from configuration";
+
+ protected override void ConfigureTestServices(IServiceCollection services)
+ {
+ // Replace the greeting service with a test double
+ services.ReplaceService(new TestGreetingService());
+
+ // Replace the time service with a fixed time for deterministic testing
+ services.ReplaceService(new FixedTimeService(new DateTime(2024, 1, 15, 12, 0, 0, DateTimeKind.Utc)));
+ }
+
+ protected override void ConfigureTestConfiguration(IConfigurationBuilder config)
+ {
+ // Add test-specific configuration
+ config.AddInMemoryCollection(new Dictionary
+ {
+ { "TestMessage", TestConfigMessage }
+ });
+ }
+
+ [Test]
+ public async Task ServiceReplacement_UsesTestDouble()
+ {
+ // Verifies that the replaced IGreetingService is used
+ var client = Factory.CreateClient();
+ var response = await client.GetAsync("/greet/World");
+ var content = await response.Content.ReadAsStringAsync();
+
+ // The test double should return a different format
+ await Assert.That(content).IsEqualTo("Test greeting for: World");
+ }
+
+ [Test]
+ public async Task ServiceReplacement_FixedTimeService_ReturnsDeterministicTime()
+ {
+ // Verifies that the replaced ITimeService returns the fixed time
+ var timeService = Services.GetRequiredService();
+ var time = timeService.GetCurrentTime();
+
+ await Assert.That(time).IsEqualTo(new DateTime(2024, 1, 15, 12, 0, 0, DateTimeKind.Utc));
+ }
+
+ [Test]
+ public async Task ConfigurationOverride_AppliesTestConfiguration()
+ {
+ // Verifies that configuration overrides are applied
+ var client = Factory.CreateClient();
+ var response = await client.GetAsync("/config/message");
+ var content = await response.Content.ReadAsStringAsync();
+
+ await Assert.That(content).IsEqualTo(TestConfigMessage);
+ }
+
+ [Test]
+ public async Task ConfigurationOverride_AccessibleViaServices()
+ {
+ // Verifies that configuration is accessible via IConfiguration
+ var config = Services.GetRequiredService();
+ var message = config["TestMessage"];
+
+ await Assert.That(message).IsEqualTo(TestConfigMessage);
+ }
+
+ ///
+ /// Test double for IGreetingService that returns a different format.
+ ///
+ private class TestGreetingService : IGreetingService
+ {
+ public string GetGreeting(string name) => $"Test greeting for: {name}";
+ }
+
+ ///
+ /// Test double for ITimeService that returns a fixed time.
+ ///
+ private class FixedTimeService : ITimeService
+ {
+ private readonly DateTime _fixedTime;
+
+ public FixedTimeService(DateTime fixedTime)
+ {
+ _fixedTime = fixedTime;
+ }
+
+ public DateTime GetCurrentTime() => _fixedTime;
+ }
+}
+
+///
+/// Tests verifying that SetupAsync can perform async operations before factory creation,
+/// and those results can be used in synchronous configuration methods.
+///
+public class AsyncSetupTests : TestsBase
+{
+ private string? _asyncResult;
+
+ protected override async Task SetupAsync()
+ {
+ // Simulate async setup work (e.g., container health check, database creation)
+ await Task.Delay(10);
+ _asyncResult = $"async-result-{UniqueId}";
+ }
+
+ protected override void ConfigureTestConfiguration(IConfigurationBuilder config)
+ {
+ // Use the result from SetupAsync in the synchronous configuration
+ config.AddInMemoryCollection(new Dictionary
+ {
+ { "AsyncResult", _asyncResult }
+ });
+ }
+
+ [Test]
+ public async Task SetupAsync_CompletesBeforeConfigureTestConfiguration()
+ {
+ // Verifies that SetupAsync runs before ConfigureTestConfiguration
+ await Assert.That(_asyncResult).IsNotNull();
+ await Assert.That(_asyncResult).StartsWith("async-result-");
+ }
+
+ [Test]
+ public async Task AsyncResultFromSetup_AvailableInConfiguration()
+ {
+ // Verifies that async setup results are available in configuration
+ var config = Services.GetRequiredService();
+ var asyncResult = config["AsyncResult"];
+
+ await Assert.That(asyncResult).IsNotNull();
+ await Assert.That(asyncResult).IsEqualTo(_asyncResult);
+ }
+}
+
+///
+/// Tests verifying that test isolation works correctly - each test gets its own
+/// factory and services don't leak between tests.
+///
+public class IsolationTests : TestsBase
+{
+ private static readonly ConcurrentDictionary SeenUniqueIds = new();
+
+ [Test]
+ [Repeat(3)]
+ public async Task UniqueId_IsDifferentForEachTestInstance()
+ {
+ // Record the unique ID for this test instance
+ var wasAdded = SeenUniqueIds.TryAdd(UniqueId.ToString(), 1);
+
+ // Each test should have a unique ID not seen before
+ await Assert.That(wasAdded).IsTrue();
+ }
+
+ [Test]
+ public async Task GetIsolatedName_IncludesUniqueId()
+ {
+ // Verifies that isolated names include the unique ID
+ var name1 = GetIsolatedName("resource");
+ var name2 = GetIsolatedName("resource");
+
+ // Same test instance should get the same isolated name for same base
+ await Assert.That(name1).IsEqualTo(name2);
+ await Assert.That(name1).Contains(UniqueId.ToString());
+ }
+}
+
+///
+/// Tests verifying that logging integration works correctly.
+///
+public class LoggingIntegrationTests : TestsBase
+{
+ [Test]
+ public async Task TestContext_IsAccessible_DuringTest()
+ {
+ // Verifies that TestContext is accessible during test execution
+ await Assert.That(TestContext.Current).IsNotNull();
+ await Assert.That(TestContext.Current!.Metadata?.DisplayName).IsNotNull();
+ }
+
+ [Test]
+ public async Task LoggerFactory_IsAvailable_InServices()
+ {
+ // Verifies that logging services are available
+ var loggerFactory = Services.GetService();
+ await Assert.That(loggerFactory).IsNotNull();
+ }
+}
diff --git a/tools/tunit-nuget-tester/TUnit.AspNetCore.NugetTester/TUnit.AspNetCore.NugetTester/TUnit.AspNetCore.NugetTester.csproj b/tools/tunit-nuget-tester/TUnit.AspNetCore.NugetTester/TUnit.AspNetCore.NugetTester/TUnit.AspNetCore.NugetTester.csproj
new file mode 100644
index 0000000000..0752ac3005
--- /dev/null
+++ b/tools/tunit-nuget-tester/TUnit.AspNetCore.NugetTester/TUnit.AspNetCore.NugetTester/TUnit.AspNetCore.NugetTester.csproj
@@ -0,0 +1,26 @@
+
+
+
+ net8.0;net9.0;net10.0
+ latest
+ true
+ enable
+ exe
+ enable
+ true
+ $(NoWarn);NU1504
+
+
+
+
+
+ $(TUnitVersion)
+
+
+
+
+
+
+
+
+
diff --git a/tools/tunit-nuget-tester/TUnit.AspNetCore.NugetTester/TUnit.AspNetCore.NugetTester/TestWebAppFactory.cs b/tools/tunit-nuget-tester/TUnit.AspNetCore.NugetTester/TUnit.AspNetCore.NugetTester/TestWebAppFactory.cs
new file mode 100644
index 0000000000..38ca78b8c2
--- /dev/null
+++ b/tools/tunit-nuget-tester/TUnit.AspNetCore.NugetTester/TUnit.AspNetCore.NugetTester/TestWebAppFactory.cs
@@ -0,0 +1,30 @@
+using TUnit.AspNetCore;
+using TUnit.Core.Interfaces;
+
+namespace TUnit.AspNetCore.NugetTester;
+
+///
+/// Factory for integration tests. Extends TestWebApplicationFactory to support
+/// per-test isolation via the WebApplicationTest pattern.
+///
+public class TestWebAppFactory : TestWebApplicationFactory, IAsyncInitializer
+{
+ private int _configuredWebHostCalled;
+
+ ///
+ /// Tracks how many times ConfiguredWebHost was called, useful for verifying factory behavior.
+ ///
+ public int ConfiguredWebHostCalled => _configuredWebHostCalled;
+
+ public Task InitializeAsync()
+ {
+ // Eagerly start the server to catch configuration errors early
+ _ = Server;
+ return Task.CompletedTask;
+ }
+
+ protected override void ConfigureWebHost(Microsoft.AspNetCore.Hosting.IWebHostBuilder builder)
+ {
+ Interlocked.Increment(ref _configuredWebHostCalled);
+ }
+}
diff --git a/tools/tunit-nuget-tester/TUnit.AspNetCore.NugetTester/TUnit.AspNetCore.NugetTester/TestsBase.cs b/tools/tunit-nuget-tester/TUnit.AspNetCore.NugetTester/TUnit.AspNetCore.NugetTester/TestsBase.cs
new file mode 100644
index 0000000000..4252c2f609
--- /dev/null
+++ b/tools/tunit-nuget-tester/TUnit.AspNetCore.NugetTester/TUnit.AspNetCore.NugetTester/TestsBase.cs
@@ -0,0 +1,10 @@
+using TUnit.AspNetCore;
+
+namespace TUnit.AspNetCore.NugetTester;
+
+///
+/// Base class for ASP.NET Core integration tests using the WebApplicationTest pattern.
+///
+public abstract class TestsBase : WebApplicationTest
+{
+}