diff --git a/.gitignore b/.gitignore
index 0e3c8cfe18..149ec04978 100644
--- a/.gitignore
+++ b/.gitignore
@@ -419,8 +419,8 @@ TUnit.TestProject/TestSession*.txt
.mcp.json
requirements
-TESTPROJECT_AOT
-TESTPROJECT_SINGLEFILE
+TESTPROJECT_AOT*
+TESTPROJECT_SINGLEFILE*
nul
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 912f589d68..57d1d9036c 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -51,18 +51,20 @@
+
-
+
-
+
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive
@@ -83,11 +85,12 @@
-
-
-
+
+
+
+
-
+
diff --git a/TUnit.Analyzers.Tests/ClassDataSourceConstructorAnalyzerTests.cs b/TUnit.Analyzers.Tests/ClassDataSourceConstructorAnalyzerTests.cs
new file mode 100644
index 0000000000..9b536bb7e1
--- /dev/null
+++ b/TUnit.Analyzers.Tests/ClassDataSourceConstructorAnalyzerTests.cs
@@ -0,0 +1,410 @@
+using Verifier = TUnit.Analyzers.Tests.Verifiers.CSharpAnalyzerVerifier;
+
+namespace TUnit.Analyzers.Tests;
+
+public class ClassDataSourceConstructorAnalyzerTests
+{
+ [Test]
+ public async Task No_Error_When_Type_Has_Parameterless_Constructor()
+ {
+ await Verifier
+ .VerifyAnalyzerAsync(
+ """
+ using TUnit.Core;
+
+ public class MyClass
+ {
+ [ClassDataSource]
+ public required MyData Data { get; init; }
+
+ [Test]
+ public void MyTest()
+ {
+ }
+ }
+
+ public class MyData
+ {
+ public MyData() { }
+ }
+ """
+ );
+ }
+
+ [Test]
+ public async Task No_Error_When_Type_Has_Internal_Parameterless_Constructor()
+ {
+ await Verifier
+ .VerifyAnalyzerAsync(
+ """
+ using TUnit.Core;
+
+ public class MyClass
+ {
+ [ClassDataSource]
+ public required MyData Data { get; init; }
+
+ [Test]
+ public void MyTest()
+ {
+ }
+ }
+
+ public class MyData
+ {
+ internal MyData() { }
+ }
+ """
+ );
+ }
+
+ [Test]
+ public async Task No_Error_When_Type_Is_Struct()
+ {
+ await Verifier
+ .VerifyAnalyzerAsync(
+ """
+ using TUnit.Core;
+
+ public class MyClass
+ {
+ [ClassDataSource]
+ public required MyData Data { get; init; }
+
+ [Test]
+ public void MyTest()
+ {
+ }
+ }
+
+ public struct MyData
+ {
+ public int Value { get; set; }
+ }
+ """
+ );
+ }
+
+ [Test]
+ public async Task No_Error_When_Type_Is_Record_With_Parameterless_Constructor()
+ {
+ await Verifier
+ .VerifyAnalyzerAsync(
+ """
+ using TUnit.Core;
+
+ public class MyClass
+ {
+ [ClassDataSource]
+ public required MyData Data { get; init; }
+
+ [Test]
+ public void MyTest()
+ {
+ }
+ }
+
+ public record MyData();
+ """
+ );
+ }
+
+ [Test]
+ public async Task No_Error_When_Type_Has_Implicit_Parameterless_Constructor()
+ {
+ await Verifier
+ .VerifyAnalyzerAsync(
+ """
+ using TUnit.Core;
+
+ public class MyClass
+ {
+ [ClassDataSource]
+ public required MyData Data { get; init; }
+
+ [Test]
+ public void MyTest()
+ {
+ }
+ }
+
+ public class MyData
+ {
+ public string Value { get; set; } = "";
+ }
+ """
+ );
+ }
+
+ [Test]
+ public async Task Error_When_Type_Has_Only_Parameterized_Constructor()
+ {
+ var expected = Verifier.Diagnostic(Rules.NoAccessibleConstructor)
+ .WithLocation(0)
+ .WithArguments("MyData");
+
+ await Verifier
+ .VerifyAnalyzerAsync(
+ """
+ using TUnit.Core;
+
+ public class MyClass
+ {
+ [{|#0:ClassDataSource|}]
+ public required MyData Data { get; init; }
+
+ [Test]
+ public void MyTest()
+ {
+ }
+ }
+
+ public class MyData
+ {
+ public MyData(string value) { }
+ }
+ """,
+ expected
+ );
+ }
+
+ [Test]
+ public async Task Error_When_Type_Has_Private_Parameterless_Constructor()
+ {
+ var expected = Verifier.Diagnostic(Rules.NoAccessibleConstructor)
+ .WithLocation(0)
+ .WithArguments("MyData");
+
+ await Verifier
+ .VerifyAnalyzerAsync(
+ """
+ using TUnit.Core;
+
+ public class MyClass
+ {
+ [{|#0:ClassDataSource|}]
+ public required MyData Data { get; init; }
+
+ [Test]
+ public void MyTest()
+ {
+ }
+ }
+
+ public class MyData
+ {
+ private MyData() { }
+ public MyData(string value) { }
+ }
+ """,
+ expected
+ );
+ }
+
+ [Test]
+ public async Task Error_When_Record_Has_Required_Parameters()
+ {
+ var expected = Verifier.Diagnostic(Rules.NoAccessibleConstructor)
+ .WithLocation(0)
+ .WithArguments("MyData");
+
+ await Verifier
+ .VerifyAnalyzerAsync(
+ """
+ using TUnit.Core;
+
+ public class MyClass
+ {
+ [{|#0:ClassDataSource|}]
+ public required MyData Data { get; init; }
+
+ [Test]
+ public void MyTest()
+ {
+ }
+ }
+
+ public record MyData(string Value);
+ """,
+ expected
+ );
+ }
+
+ [Test]
+ public async Task Error_When_Used_On_Method_Parameter()
+ {
+ var expected = Verifier.Diagnostic(Rules.NoAccessibleConstructor)
+ .WithLocation(0)
+ .WithArguments("MyData");
+
+ await Verifier
+ .VerifyAnalyzerAsync(
+ """
+ using TUnit.Core;
+
+ public class MyClass
+ {
+ [{|#0:ClassDataSource|}]
+ [Test]
+ public void MyTest(MyData data)
+ {
+ }
+ }
+
+ public class MyData
+ {
+ public MyData(string value) { }
+ }
+ """,
+ expected
+ );
+ }
+
+ [Test]
+ public async Task Error_When_Used_On_Class()
+ {
+ var expected = Verifier.Diagnostic(Rules.NoAccessibleConstructor)
+ .WithLocation(0)
+ .WithArguments("MyData");
+
+ await Verifier
+ .VerifyAnalyzerAsync(
+ """
+ using TUnit.Core;
+
+ [{|#0:ClassDataSource|}]
+ public class MyClass
+ {
+ public MyClass(MyData data) { }
+
+ [Test]
+ public void MyTest()
+ {
+ }
+ }
+
+ public class MyData
+ {
+ public MyData(string value) { }
+ }
+ """,
+ expected
+ );
+ }
+
+ [Test]
+ public async Task No_Error_When_Type_Is_Abstract()
+ {
+ // Abstract types can't be instantiated anyway, so we don't report an error
+ // The user likely intends to use a derived type
+ await Verifier
+ .VerifyAnalyzerAsync(
+ """
+ using TUnit.Core;
+
+ public class MyClass
+ {
+ [ClassDataSource]
+ public required MyData Data { get; init; }
+
+ [Test]
+ public void MyTest()
+ {
+ }
+ }
+
+ public abstract class MyData
+ {
+ protected MyData(string value) { }
+ }
+ """
+ );
+ }
+
+ [Test]
+ public async Task No_Error_When_Protected_Internal_Constructor()
+ {
+ await Verifier
+ .VerifyAnalyzerAsync(
+ """
+ using TUnit.Core;
+
+ public class MyClass
+ {
+ [ClassDataSource]
+ public required MyData Data { get; init; }
+
+ [Test]
+ public void MyTest()
+ {
+ }
+ }
+
+ public class MyData
+ {
+ protected internal MyData() { }
+ }
+ """
+ );
+ }
+
+ [Test]
+ public async Task Error_When_Protected_Constructor_Only()
+ {
+ var expected = Verifier.Diagnostic(Rules.NoAccessibleConstructor)
+ .WithLocation(0)
+ .WithArguments("MyData");
+
+ await Verifier
+ .VerifyAnalyzerAsync(
+ """
+ using TUnit.Core;
+
+ public class MyClass
+ {
+ [{|#0:ClassDataSource|}]
+ public required MyData Data { get; init; }
+
+ [Test]
+ public void MyTest()
+ {
+ }
+ }
+
+ public class MyData
+ {
+ protected MyData() { }
+ }
+ """,
+ expected
+ );
+ }
+
+ [Test]
+ public async Task No_Error_When_Type_Has_Both_Parameterless_And_Parameterized_Constructors()
+ {
+ await Verifier
+ .VerifyAnalyzerAsync(
+ """
+ using TUnit.Core;
+
+ public class MyClass
+ {
+ [ClassDataSource]
+ public required MyData Data { get; init; }
+
+ [Test]
+ public void MyTest()
+ {
+ }
+ }
+
+ public class MyData
+ {
+ public MyData() { }
+ public MyData(string value) { }
+ }
+ """
+ );
+ }
+}
diff --git a/TUnit.Analyzers/AnalyzerReleases.Unshipped.md b/TUnit.Analyzers/AnalyzerReleases.Unshipped.md
index c52332fd49..31a6ee8fe2 100644
--- a/TUnit.Analyzers/AnalyzerReleases.Unshipped.md
+++ b/TUnit.Analyzers/AnalyzerReleases.Unshipped.md
@@ -1,4 +1,11 @@
### New Rules
Rule ID | Category | Severity | Notes
---------|----------|----------|-------
\ No newline at end of file
+--------|----------|----------|-------
+TUnit0061 | Usage | Error | ClassDataSource type requires parameterless constructor
+
+### Removed Rules
+
+Rule ID | Category | Severity | Notes
+--------|----------|----------|-------
+TUnit0043 | Usage | Error | Changed to Info severity (now a suggestion instead of error)
\ No newline at end of file
diff --git a/TUnit.Analyzers/ClassDataSourceConstructorAnalyzer.cs b/TUnit.Analyzers/ClassDataSourceConstructorAnalyzer.cs
new file mode 100644
index 0000000000..07c03b20bd
--- /dev/null
+++ b/TUnit.Analyzers/ClassDataSourceConstructorAnalyzer.cs
@@ -0,0 +1,148 @@
+using System.Collections.Immutable;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.Diagnostics;
+using TUnit.Analyzers.Extensions;
+using TUnit.Analyzers.Helpers;
+
+namespace TUnit.Analyzers;
+
+[DiagnosticAnalyzer(LanguageNames.CSharp)]
+public class ClassDataSourceConstructorAnalyzer : ConcurrentDiagnosticAnalyzer
+{
+ public override ImmutableArray SupportedDiagnostics { get; } =
+ ImmutableArray.Create(Rules.NoAccessibleConstructor);
+
+ protected override void InitializeInternal(AnalysisContext context)
+ {
+ context.RegisterSymbolAction(AnalyzeProperty, SymbolKind.Property);
+ context.RegisterSymbolAction(AnalyzeMethod, SymbolKind.Method);
+ context.RegisterSymbolAction(AnalyzeClass, SymbolKind.NamedType);
+ }
+
+ private void AnalyzeProperty(SymbolAnalysisContext context)
+ {
+ if (context.Symbol is not IPropertySymbol propertySymbol)
+ {
+ return;
+ }
+
+ foreach (var attribute in propertySymbol.GetAttributes())
+ {
+ CheckClassDataSourceAttribute(context, attribute);
+ }
+ }
+
+ private void AnalyzeMethod(SymbolAnalysisContext context)
+ {
+ if (context.Symbol is not IMethodSymbol methodSymbol)
+ {
+ return;
+ }
+
+ // Check method-level attributes
+ foreach (var attribute in methodSymbol.GetAttributes())
+ {
+ CheckClassDataSourceAttribute(context, attribute);
+ }
+
+ // Check parameter-level attributes
+ foreach (var parameter in methodSymbol.Parameters)
+ {
+ foreach (var attribute in parameter.GetAttributes())
+ {
+ CheckClassDataSourceAttribute(context, attribute);
+ }
+ }
+ }
+
+ private void AnalyzeClass(SymbolAnalysisContext context)
+ {
+ if (context.Symbol is not INamedTypeSymbol namedTypeSymbol)
+ {
+ return;
+ }
+
+ foreach (var attribute in namedTypeSymbol.GetAttributes())
+ {
+ CheckClassDataSourceAttribute(context, attribute);
+ }
+ }
+
+ private void CheckClassDataSourceAttribute(SymbolAnalysisContext context, AttributeData attribute)
+ {
+ if (attribute.AttributeClass is null)
+ {
+ return;
+ }
+
+ // Check if this is ClassDataSourceAttribute
+ var attributeClassName = attribute.AttributeClass.Name;
+ var attributeFullName = attribute.AttributeClass.ToDisplayString();
+
+ if (!attributeClassName.StartsWith("ClassDataSourceAttribute") ||
+ !attributeFullName.StartsWith("TUnit.Core.ClassDataSourceAttribute<"))
+ {
+ return;
+ }
+
+ // Get the type argument T from ClassDataSource
+ if (attribute.AttributeClass is not INamedTypeSymbol { IsGenericType: true, TypeArguments.Length: > 0 } genericAttribute)
+ {
+ return;
+ }
+
+ var dataSourceType = genericAttribute.TypeArguments[0];
+
+ // Skip if the type is abstract - it can't be instantiated directly anyway
+ if (dataSourceType is INamedTypeSymbol { IsAbstract: true })
+ {
+ return;
+ }
+
+ // Skip type parameters - they can't be validated at compile time
+ if (dataSourceType is ITypeParameterSymbol)
+ {
+ return;
+ }
+
+ if (dataSourceType is not INamedTypeSymbol namedType)
+ {
+ return;
+ }
+
+ // Check if there's an accessible parameterless constructor
+ if (!HasAccessibleParameterlessConstructor(namedType, context.Compilation))
+ {
+ context.ReportDiagnostic(
+ Diagnostic.Create(
+ Rules.NoAccessibleConstructor,
+ attribute.GetLocation() ?? context.Symbol.Locations.FirstOrDefault(),
+ namedType.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat)));
+ }
+ }
+
+ private static bool HasAccessibleParameterlessConstructor(INamedTypeSymbol type, Compilation compilation)
+ {
+ // For structs, there's always an implicit parameterless constructor
+ if (type.IsValueType)
+ {
+ return true;
+ }
+
+ // If there are no explicit constructors, the compiler generates a public parameterless constructor
+ var hasAnyExplicitConstructor = type.InstanceConstructors
+ .Any(c => !c.IsImplicitlyDeclared);
+
+ if (!hasAnyExplicitConstructor)
+ {
+ return true;
+ }
+
+ // Check for an explicit accessible parameterless constructor
+ return type.InstanceConstructors
+ .Where(c => c.Parameters.Length == 0)
+ .Any(c => c.DeclaredAccessibility is Accessibility.Public
+ or Accessibility.Internal
+ or Accessibility.ProtectedOrInternal);
+ }
+}
diff --git a/TUnit.Analyzers/Resources.resx b/TUnit.Analyzers/Resources.resx
index 4482dd6a1e..ba705c6075 100644
--- a/TUnit.Analyzers/Resources.resx
+++ b/TUnit.Analyzers/Resources.resx
@@ -462,6 +462,15 @@
Data source may produce no tests
+
+ ClassDataSource<T> requires that type T has an accessible parameterless constructor. Add a public or internal parameterless constructor to the type, or use IAsyncInitializer for initialization logic.
+
+
+ Type '{0}' does not have an accessible parameterless constructor required by ClassDataSource<T>
+
+
+ ClassDataSource type requires parameterless constructor
+
When parameters have data source attributes, the method or class must be marked with [CombinedDataSources] to combine the parameter data sources.
diff --git a/TUnit.Analyzers/Rules.cs b/TUnit.Analyzers/Rules.cs
index c95fa52291..ae19286889 100644
--- a/TUnit.Analyzers/Rules.cs
+++ b/TUnit.Analyzers/Rules.cs
@@ -97,7 +97,7 @@ public static class Rules
CreateDescriptor("TUnit0042", UsageCategory, DiagnosticSeverity.Warning);
public static readonly DiagnosticDescriptor PropertyRequiredNotSet =
- CreateDescriptor("TUnit0043", UsageCategory, DiagnosticSeverity.Error);
+ CreateDescriptor("TUnit0043", UsageCategory, DiagnosticSeverity.Info);
public static readonly DiagnosticDescriptor MustHavePropertySetter =
CreateDescriptor("TUnit0044", UsageCategory, DiagnosticSeverity.Error);
@@ -162,6 +162,9 @@ public static class Rules
public static readonly DiagnosticDescriptor PotentialEmptyDataSource =
CreateDescriptor("TUnit0060", UsageCategory, DiagnosticSeverity.Info);
+ public static readonly DiagnosticDescriptor NoAccessibleConstructor =
+ CreateDescriptor("TUnit0061", UsageCategory, DiagnosticSeverity.Error);
+
public static readonly DiagnosticDescriptor GenericTypeNotAotCompatible =
CreateDescriptor("TUnit0300", UsageCategory, DiagnosticSeverity.Warning);
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/Extensions/LoggingExtensions.cs b/TUnit.AspNetCore/Extensions/LoggingExtensions.cs
new file mode 100644
index 0000000000..d0ecfac1cf
--- /dev/null
+++ b/TUnit.AspNetCore/Extensions/LoggingExtensions.cs
@@ -0,0 +1,28 @@
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using TUnit.AspNetCore.Logging;
+using TUnit.Core;
+
+namespace TUnit.AspNetCore.Extensions;
+
+///
+/// Extension methods for to simplify service replacement in tests.
+///
+public static class LoggingExtensions
+{
+ ///
+ /// Adds the TUnit logger provider to the logging builder with a specific context provider.
+ ///
+ /// The logging builder.
+ /// The test context.
+ /// The minimum log level to capture. Defaults to Information.
+ /// The logging builder for chaining.
+ public static ILoggingBuilder AddTUnit(
+ this ILoggingBuilder builder,
+ TestContext context,
+ LogLevel minLogLevel = LogLevel.Information)
+ {
+ builder.AddProvider(new TUnitLoggerProvider(context, minLogLevel));
+ return builder;
+ }
+}
diff --git a/TUnit.AspNetCore/Extensions/ServiceCollectionExtensions.cs b/TUnit.AspNetCore/Extensions/ServiceCollectionExtensions.cs
new file mode 100644
index 0000000000..b163fbbc9e
--- /dev/null
+++ b/TUnit.AspNetCore/Extensions/ServiceCollectionExtensions.cs
@@ -0,0 +1,120 @@
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+using Microsoft.Extensions.Logging;
+using TUnit.Core;
+
+namespace TUnit.AspNetCore.Extensions;
+
+///
+/// Extension methods for to simplify service replacement in tests.
+///
+public static class ServiceCollectionExtensions
+{
+ ///
+ /// Replaces all registrations of with the specified instance.
+ ///
+ /// The service type to replace.
+ /// The service collection.
+ /// The instance to use for the service.
+ /// The service collection for chaining.
+ ///
+ ///
+ /// services.ReplaceService<IEmailService>(new FakeEmailService());
+ ///
+ ///
+ public static IServiceCollection ReplaceService(
+ this IServiceCollection services,
+ TService instance)
+ where TService : class
+ {
+ services.RemoveAll();
+ services.AddSingleton(instance);
+ return services;
+ }
+
+ ///
+ /// Replaces all registrations of with the specified factory.
+ ///
+ /// The service type to replace.
+ /// The service collection.
+ /// The factory to create the service instance.
+ /// The lifetime of the service. Defaults to .
+ /// The service collection for chaining.
+ ///
+ ///
+ /// services.ReplaceService<IEmailService>(
+ /// sp => new FakeEmailService(sp.GetRequiredService<ILogger>()));
+ ///
+ ///
+ public static IServiceCollection ReplaceService(
+ this IServiceCollection services,
+ Func factory,
+ ServiceLifetime lifetime = ServiceLifetime.Singleton)
+ where TService : class
+ {
+ services.RemoveAll();
+
+ var descriptor = new ServiceDescriptor(typeof(TService), factory, lifetime);
+ services.Add(descriptor);
+
+ return services;
+ }
+
+ ///
+ /// Replaces all registrations of with .
+ ///
+ /// The service type to replace.
+ /// The implementation type to use.
+ /// The service collection.
+ /// The lifetime of the service. Defaults to .
+ /// The service collection for chaining.
+ ///
+ ///
+ /// services.ReplaceService<IEmailService, FakeEmailService>();
+ ///
+ ///
+ public static IServiceCollection ReplaceService(
+ this IServiceCollection services,
+ ServiceLifetime lifetime = ServiceLifetime.Singleton)
+ where TService : class
+ where TImplementation : class, TService
+ {
+ services.RemoveAll();
+
+ var descriptor = new ServiceDescriptor(typeof(TService), typeof(TImplementation), lifetime);
+ services.Add(descriptor);
+
+ return services;
+ }
+
+ ///
+ /// Removes all registrations of .
+ ///
+ /// The service type to remove.
+ /// The service collection.
+ /// The service collection for chaining.
+ public static IServiceCollection RemoveService(this IServiceCollection services)
+ where TService : class
+ {
+ services.RemoveAll();
+ return services;
+ }
+
+ ///
+ /// Adds TUnit logging to the service collection with a specific test context.
+ /// Use this overload when you need to capture logs for a specific test context.
+ ///
+ /// The service collection.
+ /// A function that returns the test context.
+ /// The minimum log level to capture. Defaults to Information.
+ /// The service collection for chaining.
+ public static IServiceCollection AddTUnitLogging(
+ this IServiceCollection services,
+ TestContext context,
+ LogLevel minLogLevel = LogLevel.Information)
+ {
+ services.AddLogging(builder => LoggingExtensions.AddTUnit(builder, context, minLogLevel));
+ return services;
+ }
+}
diff --git a/TUnit.AspNetCore/Interception/CapturedHttpExchange.cs b/TUnit.AspNetCore/Interception/CapturedHttpExchange.cs
new file mode 100644
index 0000000000..b83817c4b2
--- /dev/null
+++ b/TUnit.AspNetCore/Interception/CapturedHttpExchange.cs
@@ -0,0 +1,116 @@
+using System.Net;
+
+namespace TUnit.AspNetCore.Interception;
+
+///
+/// Represents a captured HTTP request/response exchange.
+///
+public sealed class CapturedHttpExchange
+{
+ ///
+ /// Gets the unique identifier for this exchange.
+ ///
+ public Guid Id { get; } = Guid.NewGuid();
+
+ ///
+ /// Gets the timestamp when the request was received.
+ ///
+ public DateTimeOffset Timestamp { get; init; }
+
+ ///
+ /// Gets the duration of the request processing.
+ ///
+ public TimeSpan Duration { get; init; }
+
+ ///
+ /// Gets the captured request details.
+ ///
+ public required CapturedRequest Request { get; init; }
+
+ ///
+ /// Gets the captured response details.
+ ///
+ public required CapturedResponse Response { get; init; }
+}
+
+///
+/// Represents a captured HTTP request.
+///
+public sealed class CapturedRequest
+{
+ ///
+ /// Gets the HTTP method (GET, POST, etc.).
+ ///
+ public required string Method { get; init; }
+
+ ///
+ /// Gets the request path.
+ ///
+ public required string Path { get; init; }
+
+ ///
+ /// Gets the query string (without the leading '?').
+ ///
+ public string? QueryString { get; init; }
+
+ ///
+ /// Gets the full URL (path + query string).
+ ///
+ public string Url => string.IsNullOrEmpty(QueryString) ? Path : $"{Path}?{QueryString}";
+
+ ///
+ /// Gets the request headers.
+ ///
+ public IReadOnlyDictionary Headers { get; init; } = new Dictionary();
+
+ ///
+ /// Gets the Content-Type header value, if present.
+ ///
+ public string? ContentType { get; init; }
+
+ ///
+ /// Gets the request body as a string, if captured.
+ ///
+ public string? Body { get; init; }
+
+ ///
+ /// Gets the request body length in bytes.
+ ///
+ public long? ContentLength { get; init; }
+}
+
+///
+/// Represents a captured HTTP response.
+///
+public sealed class CapturedResponse
+{
+ ///
+ /// Gets the HTTP status code.
+ ///
+ public required HttpStatusCode StatusCode { get; init; }
+
+ ///
+ /// Gets the status code as an integer.
+ ///
+ public int StatusCodeValue => (int)StatusCode;
+
+ ///
+ /// Gets the response headers.
+ ///
+ public IReadOnlyDictionary Headers { get; init; } = new Dictionary();
+
+ ///
+ /// Gets the Content-Type header value, if present.
+ ///
+ public string? ContentType { get; init; }
+
+ ///
+ /// Gets the response body as a string, if captured.
+ ///
+ public string? Body { get; init; }
+
+ ///
+ /// Gets the response body length in bytes.
+ ///
+ public long? ContentLength { get; init; }
+}
diff --git a/TUnit.AspNetCore/Interception/HttpExchangeCapture.cs b/TUnit.AspNetCore/Interception/HttpExchangeCapture.cs
new file mode 100644
index 0000000000..01e977bcaf
--- /dev/null
+++ b/TUnit.AspNetCore/Interception/HttpExchangeCapture.cs
@@ -0,0 +1,123 @@
+using System.Collections.Concurrent;
+using System.Net;
+
+namespace TUnit.AspNetCore.Interception;
+
+///
+/// Stores captured HTTP exchanges for test assertions.
+/// Register as a singleton in the test service collection.
+///
+public sealed class HttpExchangeCapture
+{
+ private readonly ConcurrentQueue _exchanges = new();
+
+ ///
+ /// Gets or sets whether to capture request bodies. Default is true.
+ /// Disable for large payloads or binary content.
+ ///
+ public bool CaptureRequestBody { get; set; } = true;
+
+ ///
+ /// Gets or sets whether to capture response bodies. Default is true.
+ /// Disable for large payloads or binary content.
+ ///
+ public bool CaptureResponseBody { get; set; } = true;
+
+ ///
+ /// Gets or sets the maximum body size to capture in bytes. Default is 1MB.
+ /// Bodies larger than this will be truncated.
+ ///
+ public int MaxBodySize { get; set; } = 1024 * 1024;
+
+ ///
+ /// Gets all captured exchanges in order.
+ ///
+ public IReadOnlyList Exchanges => [.. _exchanges];
+
+ ///
+ /// Gets the number of captured exchanges.
+ ///
+ public int Count => _exchanges.Count;
+
+ ///
+ /// Gets the most recent exchange, or null if none captured.
+ ///
+ public CapturedHttpExchange? Last => _exchanges.LastOrDefault();
+
+ ///
+ /// Gets the first exchange, or null if none captured.
+ ///
+ public CapturedHttpExchange? First => _exchanges.FirstOrDefault();
+
+ ///
+ /// Adds a captured exchange to the store.
+ ///
+ internal void Add(CapturedHttpExchange exchange)
+ {
+ _exchanges.Enqueue(exchange);
+ }
+
+ ///
+ /// Clears all captured exchanges.
+ ///
+ public void Clear()
+ {
+ while (_exchanges.TryDequeue(out _)) { }
+ }
+
+ ///
+ /// Gets exchanges matching the specified HTTP method.
+ ///
+ public IEnumerable ForMethod(string method) =>
+ _exchanges.Where(e => e.Request.Method.Equals(method, StringComparison.OrdinalIgnoreCase));
+
+ ///
+ /// Gets exchanges matching the specified path (exact match).
+ ///
+ public IEnumerable ForPath(string path) =>
+ _exchanges.Where(e => e.Request.Path.Equals(path, StringComparison.OrdinalIgnoreCase));
+
+ ///
+ /// Gets exchanges where the path starts with the specified prefix.
+ ///
+ public IEnumerable ForPathStartingWith(string prefix) =>
+ _exchanges.Where(e => e.Request.Path.StartsWith(prefix, StringComparison.OrdinalIgnoreCase));
+
+ ///
+ /// Gets exchanges matching the specified status code.
+ ///
+ public IEnumerable ForStatusCode(HttpStatusCode statusCode) =>
+ _exchanges.Where(e => e.Response.StatusCode == statusCode);
+
+ ///
+ /// Gets exchanges matching the specified status code.
+ ///
+ public IEnumerable ForStatusCode(int statusCode) =>
+ _exchanges.Where(e => e.Response.StatusCodeValue == statusCode);
+
+ ///
+ /// Gets exchanges matching the specified method and path.
+ ///
+ public IEnumerable For(string method, string path) =>
+ _exchanges.Where(e =>
+ e.Request.Method.Equals(method, StringComparison.OrdinalIgnoreCase) &&
+ e.Request.Path.Equals(path, StringComparison.OrdinalIgnoreCase));
+
+ ///
+ /// Gets exchanges matching the predicate.
+ ///
+ public IEnumerable Where(Func predicate) =>
+ _exchanges.Where(predicate);
+
+ ///
+ /// Returns true if any exchange matches the predicate.
+ ///
+ public bool Any(Func predicate) =>
+ _exchanges.Any(predicate);
+
+ ///
+ /// Returns true if any exchange was captured for the given method and path.
+ ///
+ public bool Any(string method, string path) =>
+ For(method, path).Any();
+}
diff --git a/TUnit.AspNetCore/Interception/HttpExchangeCaptureExtensions.cs b/TUnit.AspNetCore/Interception/HttpExchangeCaptureExtensions.cs
new file mode 100644
index 0000000000..3b07da7029
--- /dev/null
+++ b/TUnit.AspNetCore/Interception/HttpExchangeCaptureExtensions.cs
@@ -0,0 +1,76 @@
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace TUnit.AspNetCore.Interception;
+
+///
+/// Extension methods for adding HTTP exchange capture to tests.
+///
+public static class HttpExchangeCaptureExtensions
+{
+ ///
+ /// Adds HTTP exchange capture to the service collection.
+ /// This registers both the capture store and a startup filter that adds the middleware.
+ ///
+ /// The service collection.
+ /// Optional configuration for capture settings.
+ /// The service collection for chaining.
+ ///
+ ///
+ /// protected override void ConfigureTestServices(IServiceCollection services)
+ /// {
+ /// services.AddHttpExchangeCapture();
+ /// }
+ ///
+ /// [Test]
+ /// public async Task Test()
+ /// {
+ /// var client = Factory.CreateClient();
+ /// await client.GetAsync("/api/todos");
+ ///
+ /// var capture = Services.GetRequiredService<HttpExchangeCapture>();
+ /// await Assert.That(capture.Last!.Response.StatusCode).IsEqualTo(HttpStatusCode.OK);
+ /// }
+ ///
+ ///
+ public static IServiceCollection AddHttpExchangeCapture(
+ this IServiceCollection services,
+ Action? configure = null)
+ {
+ var capture = new HttpExchangeCapture();
+ configure?.Invoke(capture);
+
+ services.AddSingleton(capture);
+ services.AddSingleton(new HttpExchangeCaptureStartupFilter());
+
+ return services;
+ }
+
+ ///
+ /// Adds the HTTP exchange capture middleware to the pipeline.
+ /// Prefer using which handles this automatically.
+ ///
+ /// The application builder.
+ /// The application builder for chaining.
+ public static IApplicationBuilder UseHttpExchangeCapture(this IApplicationBuilder app)
+ {
+ return app.UseMiddleware();
+ }
+}
+
+///
+/// Startup filter that adds the HTTP exchange capture middleware early in the pipeline.
+///
+internal sealed class HttpExchangeCaptureStartupFilter : IStartupFilter
+{
+ public Action Configure(Action next)
+ {
+ return app =>
+ {
+ // Add capture middleware first so it captures everything
+ app.UseMiddleware();
+ next(app);
+ };
+ }
+}
diff --git a/TUnit.AspNetCore/Interception/HttpExchangeCaptureMiddleware.cs b/TUnit.AspNetCore/Interception/HttpExchangeCaptureMiddleware.cs
new file mode 100644
index 0000000000..e0a9c1e268
--- /dev/null
+++ b/TUnit.AspNetCore/Interception/HttpExchangeCaptureMiddleware.cs
@@ -0,0 +1,156 @@
+using System.Buffers;
+using System.Diagnostics;
+using System.Net;
+using System.Text;
+using Microsoft.AspNetCore.Http;
+
+namespace TUnit.AspNetCore.Interception;
+
+///
+/// Middleware that captures HTTP request/response exchanges for test assertions.
+///
+public sealed class HttpExchangeCaptureMiddleware
+{
+ private readonly RequestDelegate _next;
+ private readonly HttpExchangeCapture _capture;
+
+ public HttpExchangeCaptureMiddleware(RequestDelegate next, HttpExchangeCapture capture)
+ {
+ _next = next;
+ _capture = capture;
+ }
+
+ public async Task InvokeAsync(HttpContext context)
+ {
+ var timestamp = DateTimeOffset.UtcNow;
+ var stopwatch = Stopwatch.StartNew();
+
+ // Capture request
+ var capturedRequest = await CaptureRequestAsync(context.Request);
+
+ // Buffer response body if capturing
+ var originalBodyStream = context.Response.Body;
+ using var responseBodyStream = new MemoryStream();
+
+ if (_capture.CaptureResponseBody)
+ {
+ context.Response.Body = responseBodyStream;
+ }
+
+ try
+ {
+ await _next(context);
+ }
+ finally
+ {
+ // Ensure response body stream is restored even on exception
+ if (_capture.CaptureResponseBody)
+ {
+ context.Response.Body = originalBodyStream;
+ }
+ }
+
+ stopwatch.Stop();
+
+ // Capture response
+ string? responseBody = null;
+ if (_capture.CaptureResponseBody)
+ {
+ responseBodyStream.Position = 0;
+ responseBody = await ReadBodyAsync(responseBodyStream, _capture.MaxBodySize);
+
+ // Copy back to original stream
+ responseBodyStream.Position = 0;
+ await responseBodyStream.CopyToAsync(originalBodyStream);
+ }
+
+ var capturedResponse = CaptureResponse(context.Response, responseBody);
+
+ var exchange = new CapturedHttpExchange
+ {
+ Timestamp = timestamp,
+ Duration = stopwatch.Elapsed,
+ Request = capturedRequest,
+ Response = capturedResponse
+ };
+
+ _capture.Add(exchange);
+ }
+
+ private async Task CaptureRequestAsync(HttpRequest request)
+ {
+ string? body = null;
+
+ if (_capture.CaptureRequestBody && request.ContentLength > 0)
+ {
+ request.EnableBuffering();
+ body = await ReadBodyAsync(request.Body, _capture.MaxBodySize);
+ request.Body.Position = 0;
+ }
+
+ return new CapturedRequest
+ {
+ Method = request.Method,
+ Path = request.Path.Value ?? "/",
+ QueryString = request.QueryString.HasValue ? request.QueryString.Value?.TrimStart('?') : null,
+ Headers = CaptureHeaders(request.Headers),
+ ContentType = request.ContentType,
+ ContentLength = request.ContentLength,
+ Body = body
+ };
+ }
+
+ private static CapturedResponse CaptureResponse(HttpResponse response, string? body)
+ {
+ return new CapturedResponse
+ {
+ StatusCode = (HttpStatusCode)response.StatusCode,
+ Headers = CaptureHeaders(response.Headers),
+ ContentType = response.ContentType,
+ ContentLength = response.ContentLength,
+ Body = body
+ };
+ }
+
+ private static Dictionary CaptureHeaders(IHeaderDictionary headers)
+ {
+ var result = new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ foreach (var header in headers)
+ {
+ result[header.Key] = header.Value.ToArray();
+ }
+
+ return result;
+ }
+
+ private static async Task ReadBodyAsync(Stream stream, int maxSize)
+ {
+ var bufferSize = Math.Min(maxSize, 81920); // 80KB chunks
+ var buffer = ArrayPool.Shared.Rent(bufferSize);
+ try
+ {
+ var builder = new StringBuilder();
+ int totalRead = 0;
+
+ int bytesRead;
+ while ((bytesRead = await stream.ReadAsync(buffer.AsMemory(0, Math.Min(bufferSize, maxSize - totalRead)))) > 0)
+ {
+ builder.Append(Encoding.UTF8.GetString(buffer, 0, bytesRead));
+ totalRead += bytesRead;
+
+ if (totalRead >= maxSize)
+ {
+ builder.Append("... [truncated]");
+ break;
+ }
+ }
+
+ return builder.ToString();
+ }
+ finally
+ {
+ ArrayPool.Shared.Return(buffer);
+ }
+ }
+}
diff --git a/TUnit.AspNetCore/Logging/TUnitAspNetLogger.cs b/TUnit.AspNetCore/Logging/TUnitAspNetLogger.cs
new file mode 100644
index 0000000000..10d6b05394
--- /dev/null
+++ b/TUnit.AspNetCore/Logging/TUnitAspNetLogger.cs
@@ -0,0 +1,54 @@
+using Microsoft.Extensions.Logging;
+using TUnit.Core;
+
+namespace TUnit.AspNetCore.Logging;
+
+///
+/// A logger that writes log messages to TUnit's test output.
+/// Messages are associated with the current test context for proper output capture.
+///
+public sealed class TUnitAspNetLogger : ILogger
+{
+ private readonly string _categoryName;
+ private readonly TestContext _context;
+ private readonly LogLevel _minLogLevel;
+
+ internal TUnitAspNetLogger(string categoryName, TestContext context, LogLevel minLogLevel)
+ {
+ _categoryName = categoryName;
+ _context = context;
+ _minLogLevel = minLogLevel;
+ }
+
+ public IDisposable? BeginScope(TState state) where TState : notnull
+ {
+ return TUnitLoggerScope.Push(state);
+ }
+
+ public bool IsEnabled(LogLevel logLevel) => logLevel >= _minLogLevel;
+
+ public void Log(
+ LogLevel logLevel,
+ EventId eventId,
+ TState state,
+ Exception? exception,
+ Func formatter)
+ {
+ if (!IsEnabled(logLevel))
+ {
+ return;
+ }
+
+ // Set the current test context for proper output association
+ TestContext.Current = _context;
+
+ var message = formatter(state, exception);
+
+ if (exception is not null)
+ {
+ message = $"{message}{Environment.NewLine}{exception}";
+ }
+
+ Console.WriteLine($"[{logLevel}] {_categoryName}: {message}");
+ }
+}
diff --git a/TUnit.AspNetCore/Logging/TUnitLoggerProvider.cs b/TUnit.AspNetCore/Logging/TUnitLoggerProvider.cs
new file mode 100644
index 0000000000..5535a86819
--- /dev/null
+++ b/TUnit.AspNetCore/Logging/TUnitLoggerProvider.cs
@@ -0,0 +1,48 @@
+using System.Collections.Concurrent;
+using Microsoft.Extensions.Logging;
+using TUnit.Core;
+
+namespace TUnit.AspNetCore.Logging;
+
+///
+/// A logger provider that creates instances.
+/// Logs are written to the current test's output.
+///
+public sealed class TUnitLoggerProvider : ILoggerProvider
+{
+ private readonly ConcurrentDictionary _loggers = new();
+ private readonly TestContext _testContext;
+ private readonly LogLevel _minLogLevel;
+ private bool _disposed;
+
+ ///
+ /// Creates a new TUnitLoggerProvider that uses the provided context provider
+ /// to get the current test context.
+ ///
+ /// A function that returns the current test context, or null if not in a test.
+ /// The minimum log level to capture. Defaults to Information.
+ public TUnitLoggerProvider(TestContext testContext, LogLevel minLogLevel = LogLevel.Information)
+ {
+ _testContext = testContext;
+ _minLogLevel = minLogLevel;
+ }
+
+ public ILogger CreateLogger(string categoryName)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+
+ return _loggers.GetOrAdd(categoryName,
+ name => new TUnitAspNetLogger(name, _testContext, _minLogLevel));
+ }
+
+ public void Dispose()
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ _disposed = true;
+ _loggers.Clear();
+ }
+}
diff --git a/TUnit.AspNetCore/Logging/TUnitLoggerScope.cs b/TUnit.AspNetCore/Logging/TUnitLoggerScope.cs
new file mode 100644
index 0000000000..fd8406e524
--- /dev/null
+++ b/TUnit.AspNetCore/Logging/TUnitLoggerScope.cs
@@ -0,0 +1,54 @@
+namespace TUnit.AspNetCore.Logging;
+
+///
+/// Manages logging scope state using AsyncLocal for proper async flow.
+///
+internal sealed class TUnitLoggerScope : IDisposable
+{
+ private static readonly AsyncLocal CurrentScope = new();
+
+ private readonly object _state;
+ private readonly TUnitLoggerScope? _parent;
+ private bool _disposed;
+
+ private TUnitLoggerScope(object state, TUnitLoggerScope? parent)
+ {
+ _state = state;
+ _parent = parent;
+ }
+
+ public static TUnitLoggerScope? Current => CurrentScope.Value;
+
+ public static IDisposable Push(object state)
+ {
+ var scope = new TUnitLoggerScope(state, CurrentScope.Value);
+ CurrentScope.Value = scope;
+ return scope;
+ }
+
+ public void Dispose()
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ _disposed = true;
+ CurrentScope.Value = _parent;
+ }
+
+ public override string ToString()
+ {
+ var current = this;
+ var scopes = new List();
+
+ while (current != null)
+ {
+ scopes.Add(current._state.ToString() ?? string.Empty);
+ current = current._parent;
+ }
+
+ scopes.Reverse();
+ return string.Join(" => ", scopes);
+ }
+}
\ No newline at end of file
diff --git a/TUnit.AspNetCore/TUnit.AspNetCore.csproj b/TUnit.AspNetCore/TUnit.AspNetCore.csproj
new file mode 100644
index 0000000000..03e41e5ad3
--- /dev/null
+++ b/TUnit.AspNetCore/TUnit.AspNetCore.csproj
@@ -0,0 +1,52 @@
+
+
+
+
+
+
+ net8.0;net9.0;net10.0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/TUnit.AspNetCore/TestWebApplicationFactory.cs b/TUnit.AspNetCore/TestWebApplicationFactory.cs
new file mode 100644
index 0000000000..f489c846ea
--- /dev/null
+++ b/TUnit.AspNetCore/TestWebApplicationFactory.cs
@@ -0,0 +1,44 @@
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Mvc.Testing;
+using Microsoft.AspNetCore.TestHost;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using TUnit.AspNetCore.Extensions;
+using TUnit.AspNetCore.Interception;
+using TUnit.Core;
+
+namespace TUnit.AspNetCore;
+
+///
+/// Internal factory wrapper that allows configuration via a delegate.
+///
+public abstract class TestWebApplicationFactory : WebApplicationFactory where TEntryPoint : class
+{
+ public WebApplicationFactory GetIsolatedFactory(
+ TestContext testContext,
+ WebApplicationTestOptions options,
+ Action configureServices,
+ Action configureConfiguration,
+ Action? configureWebHostBuilder = null)
+ {
+ return WithWebHostBuilder(builder =>
+ {
+ // Apply user's escape hatch configuration first
+ configureWebHostBuilder?.Invoke(builder);
+
+ // Then apply standard configuration
+ builder
+ .ConfigureAppConfiguration(configureConfiguration)
+ .ConfigureTestServices(services =>
+ {
+ configureServices(services);
+ services.AddSingleton(testContext);
+ });
+
+ if (options.EnableHttpExchangeCapture)
+ {
+ builder.ConfigureTestServices(services => services.AddHttpExchangeCapture());
+ }
+ });
+ }
+}
diff --git a/TUnit.AspNetCore/WebApplicationTest.cs b/TUnit.AspNetCore/WebApplicationTest.cs
new file mode 100644
index 0000000000..1de04a34d5
--- /dev/null
+++ b/TUnit.AspNetCore/WebApplicationTest.cs
@@ -0,0 +1,251 @@
+using System.ComponentModel;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Mvc.Testing;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using TUnit.AspNetCore.Interception;
+using TUnit.Core;
+
+namespace TUnit.AspNetCore;
+
+public abstract class WebApplicationTest
+{
+ internal static int _idCounter;
+
+ ///
+ /// Gets a unique identifier for this test instance.
+ /// Useful for creating isolated resources (tables, topics, keys) per test.
+ ///
+ public int UniqueId { get; }
+
+ internal WebApplicationTest()
+ {
+ UniqueId = Interlocked.Increment(ref _idCounter);
+ }
+
+ ///
+ /// Creates an isolated name by combining a base name with the test's unique identifier.
+ /// Use for database tables, Redis keys, Kafka topics, etc.
+ ///
+ /// The base name for the resource.
+ /// A unique name in the format "Test_{UniqueId}_{baseName}".
+ ///
+ ///
+ /// // In a test with UniqueId = 42:
+ /// var tableName = GetIsolatedName("todos"); // Returns "Test_42_todos"
+ /// var topicName = GetIsolatedName("orders"); // Returns "Test_42_orders"
+ ///
+ ///
+ protected string GetIsolatedName(string baseName) => $"Test_{UniqueId}_{baseName}";
+
+ ///
+ /// Creates an isolated prefix using the test's unique identifier.
+ /// Use for key prefixes in Redis, Kafka topic prefixes, etc.
+ ///
+ /// The separator character. Defaults to "_".
+ /// A unique prefix in the format "test{separator}{UniqueId}{separator}".
+ ///
+ ///
+ /// // In a test with UniqueId = 42:
+ /// var prefix = GetIsolatedPrefix(); // Returns "test_42_"
+ /// var dotPrefix = GetIsolatedPrefix("."); // Returns "test.42."
+ ///
+ ///
+ protected string GetIsolatedPrefix(string separator = "_") => $"test{separator}{UniqueId}{separator}";
+}
+
+///
+/// Base class for ASP.NET Core integration tests with TUnit.
+/// Provides per-test isolated web application factories via the delegating factory pattern.
+///
+/// The factory type derived from TestWebApplicationFactory.
+/// The entry point class (typically 'Program') of the application under test.
+///
+///
+/// This class creates a global once per test class type,
+/// then creates a per-test delegating factory using
+/// for each test. This enables parallel test execution with complete isolation.
+///
+///
+/// The factory is created in a BeforeTest hook, ensuring all other injected properties
+/// (such as database containers) are available before the factory is configured.
+///
+///
+public abstract class WebApplicationTest : WebApplicationTest
+ where TFactory : TestWebApplicationFactory, new()
+ where TEntryPoint : class
+{
+ [ClassDataSource(Shared = [SharedType.PerTestSession])]
+ public TFactory GlobalFactory { get; set; } = null!;
+
+ private WebApplicationFactory? _factory;
+
+ private readonly WebApplicationTestOptions _options = new();
+
+ ///
+ /// Gets the per-test delegating factory. This factory is isolated to the current test.
+ ///
+ /// Thrown if accessed before test setup.
+ public WebApplicationFactory Factory => _factory ?? throw new InvalidOperationException(
+ "Factory is not initialized. Ensure the test has started and the BeforeTest hook has run. " +
+ "Do not access Factory during test discovery or in data source methods.");
+
+ ///
+ /// Gets the service provider from the per-test factory.
+ /// Use this to resolve services for verification or setup.
+ ///
+ public IServiceProvider Services => Factory.Services;
+
+ ///
+ /// Initializes the isolated web application factory before each test.
+ /// This hook runs after all property injection is complete, ensuring
+ /// that dependencies like database containers are available.
+ ///
+ [Before(HookType.Test)]
+ [EditorBrowsable(EditorBrowsableState.Never)]
+ public async Task InitializeFactoryAsync(TestContext testContext)
+ {
+ ConfigureTestOptions(_options);
+
+ // Run async setup first - use this for database/container initialization
+ await SetupAsync();
+
+ // Then create factory with sync configuration (required by ASP.NET Core hosting)
+ _factory = GlobalFactory.GetIsolatedFactory(
+ testContext,
+ _options,
+ ConfigureTestServices,
+ (_, config) => ConfigureTestConfiguration(config),
+ ConfigureWebHostBuilder);
+
+ // Eagerly start the test server to catch configuration errors early
+ _ = _factory.Server;
+ }
+
+ [After(HookType.Test)]
+ [EditorBrowsable(EditorBrowsableState.Never)]
+ public async Task DisposeFactoryAsync()
+ {
+ if (_factory != null)
+ {
+ await _factory.DisposeAsync();
+ }
+ }
+
+ ///
+ /// Override to perform async setup before the factory is created.
+ /// Use this for operations that require async (database table creation,
+ /// container health checks, external service initialization, etc.).
+ ///
+ ///
+ /// This method runs BEFORE and
+ /// . Store any results in
+ /// instance fields for use in the configuration methods.
+ ///
+ ///
+ ///
+ /// protected override async Task SetupAsync()
+ /// {
+ /// TableName = GetIsolatedName("todos");
+ /// await CreateTableAsync(TableName);
+ /// }
+ ///
+ /// protected override void ConfigureTestConfiguration(IConfigurationBuilder config)
+ /// {
+ /// config.AddInMemoryCollection(new Dictionary<string, string?>
+ /// {
+ /// { "Database:TableName", TableName }
+ /// });
+ /// }
+ ///
+ ///
+ protected virtual Task SetupAsync()
+ {
+ return Task.CompletedTask;
+ }
+
+ protected virtual void ConfigureTestOptions(WebApplicationTestOptions options)
+ {
+ }
+
+ ///
+ /// Override to configure additional services for the test.
+ /// Called synchronously during factory creation (ASP.NET Core requirement).
+ /// For async setup, use instead.
+ ///
+ /// The service collection to configure.
+ ///
+ ///
+ /// protected override void ConfigureTestServices(IServiceCollection services)
+ /// {
+ /// services.ReplaceService<IEmailService>(new FakeEmailService());
+ /// }
+ ///
+ ///
+ protected virtual void ConfigureTestServices(IServiceCollection services)
+ {
+ }
+
+ ///
+ /// Override to configure the application for the test.
+ /// Called synchronously during factory creation (ASP.NET Core requirement).
+ /// For async setup, use instead.
+ ///
+ /// The configuration builder to configure.
+ ///
+ ///
+ /// protected override void ConfigureTestConfiguration(IConfigurationBuilder config)
+ /// {
+ /// config.AddInMemoryCollection(new Dictionary<string, string?>
+ /// {
+ /// { "Database:TableName", GetIsolatedName("todos") }
+ /// });
+ /// }
+ ///
+ ///
+ protected virtual void ConfigureTestConfiguration(IConfigurationBuilder config)
+ {
+ }
+
+ ///
+ /// Override to configure the web host builder directly.
+ /// This is an escape hatch for advanced scenarios not covered by other configuration methods.
+ /// Called first, before and .
+ ///
+ /// The web host builder to configure.
+ ///
+ ///
+ /// protected override void ConfigureWebHostBuilder(IWebHostBuilder builder)
+ /// {
+ /// builder.UseEnvironment("Staging");
+ /// builder.UseSetting("MyFeature:Enabled", "true");
+ /// builder.ConfigureKestrel(options => options.AddServerHeader = false);
+ /// }
+ ///
+ ///
+ protected virtual void ConfigureWebHostBuilder(IWebHostBuilder builder)
+ {
+ }
+
+ ///
+ /// Gets the HTTP exchange capture store, if enabled via .
+ /// Returns null if HTTP exchange capture is not enabled.
+ ///
+ ///
+ ///
+ /// protected override WebApplicationTestOptions Options => new() { EnableHttpExchangeCapture = true };
+ ///
+ /// [Test]
+ /// public async Task CapturesRequests()
+ /// {
+ /// var client = Factory.CreateClient();
+ /// await client.GetAsync("/api/todos");
+ ///
+ /// await Assert.That(HttpCapture).IsNotNull();
+ /// await Assert.That(HttpCapture!.Last!.Response.StatusCode).IsEqualTo(HttpStatusCode.OK);
+ /// }
+ ///
+ ///
+ public HttpExchangeCapture? HttpCapture =>
+ _options.EnableHttpExchangeCapture ? (field ??= new()) : null;
+}
diff --git a/TUnit.AspNetCore/WebApplicationTestOptions.cs b/TUnit.AspNetCore/WebApplicationTestOptions.cs
new file mode 100644
index 0000000000..bfeff23e64
--- /dev/null
+++ b/TUnit.AspNetCore/WebApplicationTestOptions.cs
@@ -0,0 +1,11 @@
+namespace TUnit.AspNetCore;
+
+public record WebApplicationTestOptions
+{
+ ///
+ /// Gets or sets a value indicating whether HTTP exchange capture is enabled for the test.
+ /// When enabled, all HTTP requests and responses are recorded and can be inspected via .
+ /// Default is false.
+ ///
+ public bool EnableHttpExchangeCapture { get; set; } = false;
+}
diff --git a/TUnit.Assertions.Tests/AssertConditions/BecauseTests.cs b/TUnit.Assertions.Tests/AssertConditions/BecauseTests.cs
index 0c4c88137f..a75232e91c 100644
--- a/TUnit.Assertions.Tests/AssertConditions/BecauseTests.cs
+++ b/TUnit.Assertions.Tests/AssertConditions/BecauseTests.cs
@@ -144,4 +144,26 @@ await Assert.That(variable).IsFalse().Because(because1)
var exception = await Assert.ThrowsAsync(action);
await Assert.That(exception.Message).Contains(because1).And.Contains(because2);
}
+
+ [Test]
+ public async Task Because_Message_Appears_Inline_With_Expectation()
+ {
+ var expectedMessage = """
+ Expected to be false, because this is the reason
+ but found True
+
+ at Assert.That(variable).IsFalse().Because("this is the reason")
+ """;
+
+ var variable = true;
+
+ var action = async () =>
+ {
+ await Assert.That(variable).IsFalse().Because("this is the reason");
+ };
+
+ var exception = await Assert.ThrowsAsync(action);
+ await Assert.That(exception.Message.NormalizeLineEndings())
+ .IsEqualTo(expectedMessage.NormalizeLineEndings());
+ }
}
diff --git a/TUnit.Assertions.Tests/Bugs/Tests1600.cs b/TUnit.Assertions.Tests/Bugs/Tests1600.cs
index e85824749b..0a8b4850ec 100644
--- a/TUnit.Assertions.Tests/Bugs/Tests1600.cs
+++ b/TUnit.Assertions.Tests/Bugs/Tests1600.cs
@@ -22,6 +22,39 @@ public async Task Custom_Comparer()
await Assert.That(array1).IsEquivalentTo(array2).Using(new MyModelComparer());
}
+ [Test]
+ public async Task Custom_Predicate()
+ {
+ MyModel[] array1 = [new(), new(), new()];
+ MyModel[] array2 = [new(), new(), new()];
+
+ // Using a lambda predicate instead of implementing IEqualityComparer
+ await Assert.That(array1).IsEquivalentTo(array2).Using((x, y) => true);
+ }
+
+ [Test]
+ public async Task Custom_Predicate_With_Property_Comparison()
+ {
+ var users1 = new[] { new User("Alice", 30), new User("Bob", 25) };
+ var users2 = new[] { new User("Bob", 25), new User("Alice", 30) };
+
+ // Elements have different order but are equivalent by name and age
+ await Assert.That(users1)
+ .IsEquivalentTo(users2)
+ .Using((u1, u2) => u1?.Name == u2?.Name && u1?.Age == u2?.Age);
+ }
+
+ [Test]
+ public async Task Custom_Predicate_Not_Equivalent()
+ {
+ var users1 = new[] { new User("Alice", 30), new User("Bob", 25) };
+ var users2 = new[] { new User("Charlie", 35), new User("Diana", 28) };
+
+ await Assert.That(users1)
+ .IsNotEquivalentTo(users2)
+ .Using((u1, u2) => u1?.Name == u2?.Name && u1?.Age == u2?.Age);
+ }
+
public class MyModel
{
public string Id { get; } = Guid.NewGuid().ToString();
@@ -39,4 +72,6 @@ public int GetHashCode(MyModel obj)
return 1;
}
}
+
+ public record User(string Name, int Age);
}
diff --git a/TUnit.Assertions.Tests/CollectionAssertionTests.cs b/TUnit.Assertions.Tests/CollectionAssertionTests.cs
index ffb9a9b92d..e79b897477 100644
--- a/TUnit.Assertions.Tests/CollectionAssertionTests.cs
+++ b/TUnit.Assertions.Tests/CollectionAssertionTests.cs
@@ -270,4 +270,40 @@ await Assert.That(names)
.And.Contains("Bob")
.And.DoesNotContain("Dave");
}
+
+ [Test]
+ public async Task All_Predicate_Failure_Message_Contains_Index_And_Value()
+ {
+ var items = new[] { 2, 4, -5, 8 };
+
+ await Assert.That(async () =>
+ await Assert.That(items).All(x => x > 0)
+ ).Throws()
+ .WithMessageContaining("index 2")
+ .And.WithMessageContaining("[-5]");
+ }
+
+ [Test]
+ public async Task All_Predicate_Failure_Message_Contains_String_Value()
+ {
+ var names = new[] { "Alice", "Bob", "" };
+
+ await Assert.That(async () =>
+ await Assert.That(names).All(x => !string.IsNullOrEmpty(x))
+ ).Throws()
+ .WithMessageContaining("index 2")
+ .And.WithMessageContaining("[]");
+ }
+
+ [Test]
+ public async Task All_Predicate_Failure_Message_Contains_First_Failing_Item()
+ {
+ var items = new[] { 1, 2, 3, -1, -2, -3 };
+
+ await Assert.That(async () =>
+ await Assert.That(items).All(x => x > 0)
+ ).Throws()
+ .WithMessageContaining("index 3")
+ .And.WithMessageContaining("[-1]");
+ }
}
diff --git a/TUnit.Assertions/Conditions/CollectionAssertions.cs b/TUnit.Assertions/Conditions/CollectionAssertions.cs
index bf9973f7c4..206630a348 100644
--- a/TUnit.Assertions/Conditions/CollectionAssertions.cs
+++ b/TUnit.Assertions/Conditions/CollectionAssertions.cs
@@ -423,7 +423,7 @@ protected override Task CheckAsync(EvaluationMetadata Using(IEquality
return new DictionaryContainsKeyAssertion(Context, _expectedKey, comparer);
}
+ public DictionaryContainsKeyAssertion Using(Func equalityPredicate)
+ {
+ return new DictionaryContainsKeyAssertion(
+ Context, _expectedKey, new FuncEqualityComparer(equalityPredicate));
+ }
+
protected override Task CheckAsync(EvaluationMetadata metadata)
{
var value = metadata.Value;
diff --git a/TUnit.Assertions/Conditions/Helpers/FuncEqualityComparer.cs b/TUnit.Assertions/Conditions/Helpers/FuncEqualityComparer.cs
new file mode 100644
index 0000000000..aeb1307a7b
--- /dev/null
+++ b/TUnit.Assertions/Conditions/Helpers/FuncEqualityComparer.cs
@@ -0,0 +1,25 @@
+namespace TUnit.Assertions.Conditions.Helpers;
+
+///
+/// An IEqualityComparer implementation that uses a custom Func for equality comparison.
+/// This allows users to pass lambda predicates to assertion methods like Using().
+///
+/// The type of objects to compare.
+internal sealed class FuncEqualityComparer : IEqualityComparer
+{
+ private readonly Func _equals;
+
+ public FuncEqualityComparer(Func equals)
+ {
+ _equals = equals ?? throw new ArgumentNullException(nameof(equals));
+ }
+
+ public bool Equals(T? x, T? y) => _equals(x, y);
+
+ // Return a constant hash code to force linear search in collection equivalency.
+ // This is intentional because:
+ // 1. We cannot derive a meaningful hash function from an equality predicate
+ // 2. CollectionEquivalencyChecker already uses O(n²) linear search for custom comparers
+ // 3. This matches the expected behavior for all custom IEqualityComparer implementations
+ public int GetHashCode(T obj) => 0;
+}
diff --git a/TUnit.Assertions/Conditions/IsEquivalentToAssertion.cs b/TUnit.Assertions/Conditions/IsEquivalentToAssertion.cs
index 7592c54f0e..18f2fa8ae7 100644
--- a/TUnit.Assertions/Conditions/IsEquivalentToAssertion.cs
+++ b/TUnit.Assertions/Conditions/IsEquivalentToAssertion.cs
@@ -47,6 +47,12 @@ public IsEquivalentToAssertion Using(IEqualityComparer Using(Func equalityPredicate)
+ {
+ SetComparer(new FuncEqualityComparer(equalityPredicate));
+ return this;
+ }
+
protected override Task CheckAsync(EvaluationMetadata metadata)
{
var value = metadata.Value;
diff --git a/TUnit.Assertions/Conditions/NotEquivalentToAssertion.cs b/TUnit.Assertions/Conditions/NotEquivalentToAssertion.cs
index e32548337f..41a6d3d880 100644
--- a/TUnit.Assertions/Conditions/NotEquivalentToAssertion.cs
+++ b/TUnit.Assertions/Conditions/NotEquivalentToAssertion.cs
@@ -46,6 +46,12 @@ public NotEquivalentToAssertion Using(IEqualityComparer Using(Func equalityPredicate)
+ {
+ SetComparer(new FuncEqualityComparer(equalityPredicate));
+ return this;
+ }
+
protected override Task CheckAsync(EvaluationMetadata metadata)
{
var value = metadata.Value;
diff --git a/TUnit.Assertions/Conditions/PredicateAssertions.cs b/TUnit.Assertions/Conditions/PredicateAssertions.cs
index 744a257181..4d43ee668d 100644
--- a/TUnit.Assertions/Conditions/PredicateAssertions.cs
+++ b/TUnit.Assertions/Conditions/PredicateAssertions.cs
@@ -1,5 +1,6 @@
using System.Text;
using TUnit.Assertions.Attributes;
+using TUnit.Assertions.Conditions.Helpers;
using TUnit.Assertions.Core;
namespace TUnit.Assertions.Conditions;
@@ -66,6 +67,12 @@ public IsEquatableOrEqualToAssertion Using(IEqualityComparer com
return this;
}
+ public IsEquatableOrEqualToAssertion Using(Func equalityPredicate)
+ {
+ SetComparer(new FuncEqualityComparer(equalityPredicate));
+ return this;
+ }
+
protected override Task CheckAsync(EvaluationMetadata metadata)
{
var value = metadata.Value;
diff --git a/TUnit.Assertions/Core/Assertion.cs b/TUnit.Assertions/Core/Assertion.cs
index cda35ff933..1b9e06afc7 100644
--- a/TUnit.Assertions/Core/Assertion.cs
+++ b/TUnit.Assertions/Core/Assertion.cs
@@ -212,22 +212,24 @@ public OrContinuation Or
///
protected Exception CreateException(AssertionResult result)
{
- var message = $"""
- Expected {GetExpectation()}
- but {result.Message}
-
- at {Context.ExpressionBuilder}
- """;
+ var expectation = GetExpectation();
if (_becauseMessage != null)
{
- // Check if message already starts with "because" to avoid duplication
+ // Append because message inline with the expectation
var becausePrefix = _becauseMessage.StartsWith("because ", StringComparison.OrdinalIgnoreCase)
? _becauseMessage
: $"because {_becauseMessage}";
- message += $"\n\n{becausePrefix}";
+ expectation = $"{expectation}, {becausePrefix}";
}
+ var message = $"""
+ Expected {expectation}
+ but {result.Message}
+
+ at {Context.ExpressionBuilder}
+ """;
+
return new AssertionException(message);
}
diff --git a/TUnit.Core.SourceGenerator.Tests/MatrixTests.Test.verified.txt b/TUnit.Core.SourceGenerator.Tests/MatrixTests.Test.verified.txt
index ea365df2ae..9b3fa0a86d 100644
--- a/TUnit.Core.SourceGenerator.Tests/MatrixTests.Test.verified.txt
+++ b/TUnit.Core.SourceGenerator.Tests/MatrixTests.Test.verified.txt
@@ -1365,3 +1365,117 @@ internal static class TUnit_TestProject_MatrixTests_Exclusion__int_int_ModuleIni
global::TUnit.Core.SourceRegistrar.Register(typeof(global::TUnit.TestProject.MatrixTests), new TUnit_TestProject_MatrixTests_Exclusion__int_int_TestSource());
}
}
+
+
+// ===== FILE SEPARATOR =====
+
+//
+#pragma warning disable
+
+#nullable enable
+namespace TUnit.Generated;
+internal sealed class TUnit_TestProject_MatrixTests_MatrixMethod_WithEnumParameter_UsesOnlyMethodValues__bool_CountToTenEnum_TestSource : global::TUnit.Core.Interfaces.SourceGenerator.ITestSource
+{
+ public async global::System.Collections.Generic.IAsyncEnumerable GetTestsAsync(string testSessionId, [global::System.Runtime.CompilerServices.EnumeratorCancellation] global::System.Threading.CancellationToken cancellationToken = default)
+ {
+ var metadata = new global::TUnit.Core.TestMetadata
+ {
+ TestName = "MatrixMethod_WithEnumParameter_UsesOnlyMethodValues",
+ TestClassType = typeof(global::TUnit.TestProject.MatrixTests),
+ TestMethodName = "MatrixMethod_WithEnumParameter_UsesOnlyMethodValues",
+ Dependencies = global::System.Array.Empty(),
+ AttributeFactory = static () =>
+ [
+ new global::TUnit.Core.TestAttribute(),
+ new global::TUnit.TestProject.Attributes.EngineTest(global::TUnit.TestProject.Attributes.ExpectedResult.Pass)
+ ],
+ DataSources = new global::TUnit.Core.IDataSourceAttribute[]
+ {
+ new global::TUnit.Core.MatrixDataSourceAttribute(),
+ },
+ ClassDataSources = global::System.Array.Empty(),
+ PropertyDataSources = global::System.Array.Empty(),
+ PropertyInjections = global::System.Array.Empty(),
+ InheritanceDepth = 0,
+ FilePath = @"",
+ LineNumber = 197,
+ MethodMetadata = new global::TUnit.Core.MethodMetadata
+ {
+ Type = typeof(global::TUnit.TestProject.MatrixTests),
+ TypeInfo = new global::TUnit.Core.ConcreteType(typeof(global::TUnit.TestProject.MatrixTests)),
+ Name = "MatrixMethod_WithEnumParameter_UsesOnlyMethodValues",
+ GenericTypeCount = 0,
+ ReturnType = typeof(global::System.Threading.Tasks.Task),
+ ReturnTypeInfo = new global::TUnit.Core.ConcreteType(typeof(global::System.Threading.Tasks.Task)),
+ Parameters = new global::TUnit.Core.ParameterMetadata[]
+ {
+ new global::TUnit.Core.ParameterMetadata(typeof(bool))
+ {
+ Name = "flag",
+ TypeInfo = new global::TUnit.Core.ConcreteType(typeof(bool)),
+ IsNullable = false,
+ ReflectionInfo = typeof(global::TUnit.TestProject.MatrixTests).GetMethod("MatrixMethod_WithEnumParameter_UsesOnlyMethodValues", global::System.Reflection.BindingFlags.Public | global::System.Reflection.BindingFlags.NonPublic | global::System.Reflection.BindingFlags.Instance, null, new global::System.Type[] { typeof(bool), typeof(global::TUnit.TestProject.MatrixTests.CountToTenEnum) }, null)!.GetParameters()[0]
+ },
+ new global::TUnit.Core.ParameterMetadata(typeof(global::TUnit.TestProject.MatrixTests.CountToTenEnum))
+ {
+ Name = "enum",
+ TypeInfo = new global::TUnit.Core.ConcreteType(typeof(global::TUnit.TestProject.MatrixTests.CountToTenEnum)),
+ IsNullable = false,
+ ReflectionInfo = typeof(global::TUnit.TestProject.MatrixTests).GetMethod("MatrixMethod_WithEnumParameter_UsesOnlyMethodValues", global::System.Reflection.BindingFlags.Public | global::System.Reflection.BindingFlags.NonPublic | global::System.Reflection.BindingFlags.Instance, null, new global::System.Type[] { typeof(bool), typeof(global::TUnit.TestProject.MatrixTests.CountToTenEnum) }, null)!.GetParameters()[1]
+ }
+ },
+ Class = global::TUnit.Core.ClassMetadata.GetOrAdd("TestsBase`1:global::TUnit.TestProject.MatrixTests", static () =>
+ {
+ var classMetadata = new global::TUnit.Core.ClassMetadata
+ {
+ Type = typeof(global::TUnit.TestProject.MatrixTests),
+ TypeInfo = new global::TUnit.Core.ConcreteType(typeof(global::TUnit.TestProject.MatrixTests)),
+ Name = "MatrixTests",
+ Namespace = "TUnit.TestProject",
+ Assembly = global::TUnit.Core.AssemblyMetadata.GetOrAdd("TestsBase`1", static () => new global::TUnit.Core.AssemblyMetadata { Name = "TestsBase`1" }),
+ Parameters = global::System.Array.Empty(),
+ Properties = global::System.Array.Empty(),
+ Parent = null
+ };
+ foreach (var prop in classMetadata.Properties)
+ {
+ prop.ClassMetadata = classMetadata;
+ prop.ContainingTypeMetadata = classMetadata;
+ }
+ return classMetadata;
+ })
+ },
+ InstanceFactory = (typeArgs, args) => new global::TUnit.TestProject.MatrixTests(),
+ InvokeTypedTest = static (instance, args, cancellationToken) =>
+ {
+ try
+ {
+ switch (args.Length)
+ {
+ case 2:
+ {
+ return new global::System.Threading.Tasks.ValueTask(instance.MatrixMethod_WithEnumParameter_UsesOnlyMethodValues(TUnit.Core.Helpers.CastHelper.Cast(args[0]), TUnit.Core.Helpers.CastHelper.Cast(args[1])));
+ }
+ default:
+ throw new global::System.ArgumentException($"Expected exactly 2 arguments, but got {args.Length}");
+ }
+ }
+ catch (global::System.Exception ex)
+ {
+ return new global::System.Threading.Tasks.ValueTask(global::System.Threading.Tasks.Task.FromException(ex));
+ }
+ },
+ };
+ metadata.UseRuntimeDataGeneration(testSessionId);
+ yield return metadata;
+ yield break;
+ }
+}
+internal static class TUnit_TestProject_MatrixTests_MatrixMethod_WithEnumParameter_UsesOnlyMethodValues__bool_CountToTenEnum_ModuleInitializer
+{
+ [global::System.Runtime.CompilerServices.ModuleInitializer]
+ public static void Initialize()
+ {
+ global::TUnit.Core.SourceRegistrar.Register(typeof(global::TUnit.TestProject.MatrixTests), new TUnit_TestProject_MatrixTests_MatrixMethod_WithEnumParameter_UsesOnlyMethodValues__bool_CountToTenEnum_TestSource());
+ }
+}
diff --git a/TUnit.Core.SourceGenerator/CodeGenerationHelpers.cs b/TUnit.Core.SourceGenerator/CodeGenerationHelpers.cs
index 31ecc4b5e8..7555f29138 100644
--- a/TUnit.Core.SourceGenerator/CodeGenerationHelpers.cs
+++ b/TUnit.Core.SourceGenerator/CodeGenerationHelpers.cs
@@ -49,14 +49,16 @@ public static string GenerateParameterMetadataArray(IMethodSymbol method)
}
// Generate cached data source attributes for AOT compatibility
+ // Include both IDataSourceAttribute and IDataSourceMemberAttribute implementations
var dataSourceAttributes = param.GetAttributes()
.Where(attr => attr.AttributeClass != null &&
- attr.AttributeClass.AllInterfaces.Any(i => i.Name == "IDataSourceAttribute"))
+ attr.AttributeClass.AllInterfaces.Any(i =>
+ i.Name == "IDataSourceAttribute" || i.Name == "IDataSourceMemberAttribute"))
.ToArray();
if (dataSourceAttributes.Length > 0)
{
- writer.AppendLine($"CachedDataSourceAttributes = new global::TUnit.Core.IDataSourceAttribute[]");
+ writer.AppendLine($"CachedDataSourceAttributes = new global::System.Attribute[]");
writer.AppendLine("{");
writer.SetIndentLevel(3);
foreach (var attr in dataSourceAttributes)
diff --git a/TUnit.Core.SourceGenerator/CodeGenerators/StaticPropertyInitializationGenerator.cs b/TUnit.Core.SourceGenerator/CodeGenerators/StaticPropertyInitializationGenerator.cs
index 7f4cf2e9d6..157915e409 100644
--- a/TUnit.Core.SourceGenerator/CodeGenerators/StaticPropertyInitializationGenerator.cs
+++ b/TUnit.Core.SourceGenerator/CodeGenerators/StaticPropertyInitializationGenerator.cs
@@ -49,9 +49,16 @@ public void Initialize(IncrementalGeneratorInitializationContext context)
return null;
}
+ // Skip open generic types - we can't generate code for types with unbound type parameters
+ // The initialization will happen in the consuming assembly that provides concrete type arguments
+ if (typeSymbol.IsGenericType && typeSymbol.TypeArguments.Any(t => t.TypeKind == TypeKind.TypeParameter))
+ {
+ return null;
+ }
+
// Check if this type has any static properties with data source attributes
var hasStaticPropertiesWithDataSources = GetStaticPropertyDataSources(typeSymbol).Any();
-
+
return hasStaticPropertiesWithDataSources ? typeSymbol : null;
}
diff --git a/TUnit.Core.SourceGenerator/Extensions/TypeExtensions.cs b/TUnit.Core.SourceGenerator/Extensions/TypeExtensions.cs
index 726185ffa0..d3c10a781c 100644
--- a/TUnit.Core.SourceGenerator/Extensions/TypeExtensions.cs
+++ b/TUnit.Core.SourceGenerator/Extensions/TypeExtensions.cs
@@ -237,10 +237,14 @@ public static string GloballyQualified(this ISymbol typeSymbol)
if (typeSymbol is INamedTypeSymbol { IsGenericType: true } namedTypeSymbol)
{
// Check if this is an unbound generic type or has type parameter arguments
+ // Use multiple detection methods for robustness across Roslyn versions
var hasTypeParameters = namedTypeSymbol.TypeArguments.Any(t => t.TypeKind == TypeKind.TypeParameter);
+ var hasTypeParameterSymbols = namedTypeSymbol.TypeArguments.OfType().Any();
var isUnboundGeneric = namedTypeSymbol.IsUnboundGenericType;
-
- if (hasTypeParameters || isUnboundGeneric)
+ // Also detect generic type definitions by checking if type equals its OriginalDefinition
+ var isGenericTypeDefinition = SymbolEqualityComparer.Default.Equals(namedTypeSymbol, namedTypeSymbol.OriginalDefinition);
+
+ if (hasTypeParameters || hasTypeParameterSymbols || isUnboundGeneric || isGenericTypeDefinition)
{
// Special case for System.Nullable<> - Roslyn displays it as "T?" even for open generic
if (namedTypeSymbol.SpecialType == SpecialType.System_Nullable_T ||
diff --git a/TUnit.Core.SourceGenerator/Generators/PropertyInjectionSourceGenerator.cs b/TUnit.Core.SourceGenerator/Generators/PropertyInjectionSourceGenerator.cs
index 29e833e178..36a3c2f3d6 100644
--- a/TUnit.Core.SourceGenerator/Generators/PropertyInjectionSourceGenerator.cs
+++ b/TUnit.Core.SourceGenerator/Generators/PropertyInjectionSourceGenerator.cs
@@ -60,6 +60,29 @@ public void Initialize(IncrementalGeneratorInitializationContext context)
}
GenerateIndividualPropertyInjectionSource(context, classData);
});
+
+ // Third pipeline: Generate InitializerPropertyRegistry metadata for IAsyncInitializer types
+ // that have properties returning other IAsyncInitializer types.
+ // This enables AOT-compatible nested initializer discovery.
+ var asyncInitializerTypes = context.SyntaxProvider
+ .CreateSyntaxProvider(
+ predicate: (node, _) => node is TypeDeclarationSyntax,
+ transform: (ctx, _) => GetAsyncInitializerWithInitializerProperties(ctx))
+ .Where(x => x != null)
+ .Select((x, _) => x!)
+ .Collect()
+ .SelectMany((types, _) => types.DistinctBy(t => t.TypeSymbol, SymbolEqualityComparer.Default))
+ .Combine(enabledProvider);
+
+ context.RegisterSourceOutput(asyncInitializerTypes, (ctx, data) =>
+ {
+ var (typeInfo, isEnabled) = data;
+ if (!isEnabled)
+ {
+ return;
+ }
+ GenerateInitializerPropertySource(ctx, typeInfo);
+ });
}
private static bool IsClassWithDataSourceProperties(SyntaxNode node)
@@ -582,11 +605,26 @@ private static string FormatTypedConstant(TypedConstant constant)
TypedConstantKind.Primitive => constant.Value?.ToString() ?? "null",
TypedConstantKind.Enum => FormatEnumConstant(constant),
TypedConstantKind.Type => FormatTypeConstant(constant),
- TypedConstantKind.Array => $"new object[] {{ {string.Join(", ", constant.Values.Select(FormatTypedConstant))} }}",
+ TypedConstantKind.Array => FormatArrayConstant(constant),
_ => constant.Value?.ToString() ?? "null"
};
}
+ private static string FormatArrayConstant(TypedConstant constant)
+ {
+ // Get the element type from the array type (e.g., SharedType[] -> SharedType)
+ if (constant.Type is IArrayTypeSymbol arrayType)
+ {
+ var elementTypeName = GetNonNullableTypeString(arrayType.ElementType);
+ var elements = string.Join(", ", constant.Values.Select(FormatTypedConstant));
+ return $"new {elementTypeName}[] {{ {elements} }}";
+ }
+
+ // Fallback to object[] if type information is not available
+ var fallbackElements = string.Join(", ", constant.Values.Select(FormatTypedConstant));
+ return $"new object[] {{ {fallbackElements} }}";
+ }
+
private static string FormatEnumConstant(TypedConstant constant)
{
if (constant is { Type: not null, Value: not null })
@@ -635,6 +673,130 @@ private static string GetNonNullableTypeString(ITypeSymbol typeSymbol)
// Alias for consistency
private static string GetNonNullableTypeName(ITypeSymbol typeSymbol) => GetNonNullableTypeString(typeSymbol);
+
+ #region IAsyncInitializer Property Discovery (for nested initializer discovery)
+
+ ///
+ /// Finds types that implement IAsyncInitializer and have properties that return other IAsyncInitializer types.
+ /// Used for AOT-compatible nested initializer discovery during object graph traversal.
+ ///
+ private static AsyncInitializerTypeInfo? GetAsyncInitializerWithInitializerProperties(GeneratorSyntaxContext context)
+ {
+ var typeDecl = (TypeDeclarationSyntax)context.Node;
+ var semanticModel = context.SemanticModel;
+
+ if (semanticModel.GetDeclaredSymbol(typeDecl) is not INamedTypeSymbol typeSymbol)
+ {
+ return null;
+ }
+
+ // Skip non-public/internal types
+ if (!IsPubliclyAccessible(typeSymbol))
+ {
+ return null;
+ }
+
+ // Skip open generic types
+ if (typeSymbol.IsUnboundGenericType || typeSymbol.TypeParameters.Length > 0)
+ {
+ return null;
+ }
+
+ var asyncInitializerInterface = semanticModel.Compilation.GetTypeByMetadataName("TUnit.Core.Interfaces.IAsyncInitializer");
+ if (asyncInitializerInterface == null)
+ {
+ return null;
+ }
+
+ // Check if this type implements IAsyncInitializer
+ if (!typeSymbol.AllInterfaces.Contains(asyncInitializerInterface, SymbolEqualityComparer.Default))
+ {
+ return null;
+ }
+
+ // Find properties that return IAsyncInitializer types
+ var initializerProperties = new List();
+
+ var allProperties = typeSymbol.GetMembers()
+ .OfType()
+ .Where(p => p.GetMethod != null && !p.IsStatic && !p.IsIndexer)
+ .ToList();
+
+ foreach (var property in allProperties)
+ {
+ // Check if the property type implements IAsyncInitializer
+ if (property.Type is INamedTypeSymbol propertyType)
+ {
+ if (propertyType.AllInterfaces.Contains(asyncInitializerInterface, SymbolEqualityComparer.Default) ||
+ SymbolEqualityComparer.Default.Equals(propertyType, asyncInitializerInterface))
+ {
+ initializerProperties.Add(new InitializerPropertyMetadata
+ {
+ Property = property
+ });
+ }
+ }
+ }
+
+ if (initializerProperties.Count == 0)
+ {
+ return null;
+ }
+
+ return new AsyncInitializerTypeInfo
+ {
+ TypeSymbol = typeSymbol,
+ Properties = initializerProperties.ToImmutableArray()
+ };
+ }
+
+ ///
+ /// Generates source code that registers IAsyncInitializer property metadata with InitializerPropertyRegistry.
+ ///
+ private static void GenerateInitializerPropertySource(SourceProductionContext context, AsyncInitializerTypeInfo typeInfo)
+ {
+ var typeSymbol = typeInfo.TypeSymbol;
+ var safeName = GetSafeClassName(typeSymbol);
+ var fileName = $"{safeName}_InitializerProperties.g.cs";
+
+ var sourceBuilder = new StringBuilder();
+
+ sourceBuilder.AppendLine("using System;");
+ sourceBuilder.AppendLine("using TUnit.Core.Discovery;");
+ sourceBuilder.AppendLine();
+ sourceBuilder.AppendLine("namespace TUnit.Generated;");
+ sourceBuilder.AppendLine();
+
+ // Generate module initializer
+ sourceBuilder.AppendLine($"internal static class {safeName}_InitializerPropertiesInitializer");
+ sourceBuilder.AppendLine("{");
+ sourceBuilder.AppendLine(" [global::System.Runtime.CompilerServices.ModuleInitializer]");
+ sourceBuilder.AppendLine(" public static void Initialize()");
+ sourceBuilder.AppendLine(" {");
+ sourceBuilder.AppendLine($" InitializerPropertyRegistry.Register(typeof({typeSymbol.GloballyQualified()}), new InitializerPropertyInfo[]");
+ sourceBuilder.AppendLine(" {");
+
+ foreach (var propInfo in typeInfo.Properties)
+ {
+ var property = propInfo.Property;
+ var propertyTypeName = property.Type.GloballyQualified();
+
+ sourceBuilder.AppendLine(" new InitializerPropertyInfo");
+ sourceBuilder.AppendLine(" {");
+ sourceBuilder.AppendLine($" PropertyName = \"{property.Name}\",");
+ sourceBuilder.AppendLine($" PropertyType = typeof({propertyTypeName}),");
+ sourceBuilder.AppendLine($" GetValue = static obj => (({typeSymbol.GloballyQualified()})obj).{property.Name}");
+ sourceBuilder.AppendLine(" },");
+ }
+
+ sourceBuilder.AppendLine(" });");
+ sourceBuilder.AppendLine(" }");
+ sourceBuilder.AppendLine("}");
+
+ context.AddSource(fileName, sourceBuilder.ToString());
+ }
+
+ #endregion
}
internal sealed class ClassWithDataSourceProperties
@@ -665,3 +827,21 @@ public int GetHashCode(ClassWithDataSourceProperties obj)
return SymbolEqualityComparer.Default.GetHashCode(obj.ClassSymbol);
}
}
+
+///
+/// Model for types that implement IAsyncInitializer and have properties returning IAsyncInitializer.
+/// Used for generating AOT-compatible nested initializer discovery metadata.
+///
+internal sealed class AsyncInitializerTypeInfo
+{
+ public required INamedTypeSymbol TypeSymbol { get; init; }
+ public required ImmutableArray Properties { get; init; }
+}
+
+///
+/// Metadata about a property that returns an IAsyncInitializer type.
+///
+internal sealed class InitializerPropertyMetadata
+{
+ public required IPropertySymbol Property { get; init; }
+}
diff --git a/TUnit.Core.SourceGenerator/Utilities/MetadataGenerationHelper.cs b/TUnit.Core.SourceGenerator/Utilities/MetadataGenerationHelper.cs
index 786d91f382..91cde113c6 100644
--- a/TUnit.Core.SourceGenerator/Utilities/MetadataGenerationHelper.cs
+++ b/TUnit.Core.SourceGenerator/Utilities/MetadataGenerationHelper.cs
@@ -426,12 +426,18 @@ private static string GetPropertyAccessor(INamedTypeSymbol namedTypeSymbol, IPro
// For generic types with unresolved type parameters, we can't cast to the open generic type
// We need to use dynamic or reflection
var hasUnresolvedTypeParameters = namedTypeSymbol.IsGenericType &&
- namedTypeSymbol.TypeArguments.Any(t => t.TypeKind == TypeKind.TypeParameter);
+ (namedTypeSymbol.TypeArguments.Any(t => t.TypeKind == TypeKind.TypeParameter) ||
+ namedTypeSymbol.TypeArguments.OfType().Any() ||
+ SymbolEqualityComparer.Default.Equals(namedTypeSymbol, namedTypeSymbol.OriginalDefinition));
- if (hasUnresolvedTypeParameters && !property.IsStatic)
+ if (hasUnresolvedTypeParameters)
{
- // Use dynamic to avoid invalid cast to open generic type
- return $"o => ((dynamic)o).{property.Name}";
+ return property.IsStatic
+ // Can't access static members on an unbound generic type like WebApplicationTest<,>
+ // Use reflection to get the value at runtime
+ ? $"_ => typeof({namedTypeSymbol.GloballyQualified()}).GetProperty(\"{property.Name}\")?.GetValue(null)"
+ // Use dynamic to avoid invalid cast to open generic type
+ : $"o => ((dynamic)o).{property.Name}";
}
var safeTypeName = namedTypeSymbol.GloballyQualified();
diff --git a/TUnit.Core/Attributes/TestData/ClassDataSourceAttribute.cs b/TUnit.Core/Attributes/TestData/ClassDataSourceAttribute.cs
index d75e405f7d..9dbfc8ff2c 100644
--- a/TUnit.Core/Attributes/TestData/ClassDataSourceAttribute.cs
+++ b/TUnit.Core/Attributes/TestData/ClassDataSourceAttribute.cs
@@ -4,31 +4,15 @@
namespace TUnit.Core;
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method | AttributeTargets.Property | AttributeTargets.Parameter, AllowMultiple = true)]
-public sealed class ClassDataSourceAttribute<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors | DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.NonPublicProperties)] T>
- : DataSourceGeneratorAttribute
+public sealed class ClassDataSourceAttribute : UntypedDataSourceGeneratorAttribute
{
- public SharedType Shared { get; set; } = SharedType.None;
- public string Key { get; set; } = string.Empty;
- public Type ClassType => typeof(T);
+ private Type[] _types;
- protected override IEnumerable> GenerateDataSources(DataGeneratorMetadata dataGeneratorMetadata)
+ public ClassDataSourceAttribute()
{
- var testClassType = TestClassTypeHelper.GetTestClassType(dataGeneratorMetadata);
- yield return () => ClassDataSources.Get(dataGeneratorMetadata.TestSessionId)
- .Get(Shared, testClassType, Key, dataGeneratorMetadata);
+ _types = [];
}
-
- public IEnumerable GetSharedTypes() => [Shared];
-
- public IEnumerable GetKeys() => string.IsNullOrEmpty(Key) ? [] : [Key];
-}
-
-[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method | AttributeTargets.Property | AttributeTargets.Parameter, AllowMultiple = true)]
-public sealed class ClassDataSourceAttribute : UntypedDataSourceGeneratorAttribute
-{
- private readonly Type[] _types;
-
[UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code", Justification = "Non-params constructor calls params one with proper annotations.")]
public ClassDataSourceAttribute(
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors | DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.NonPublicProperties)]
@@ -99,6 +83,25 @@ public ClassDataSourceAttribute(params Type[] types)
{
yield return () =>
{
+ if (_types.Length == 0)
+ {
+ _types = dataGeneratorMetadata.MembersToGenerate.Select(x =>
+ {
+ if (x is ParameterMetadata parameterMetadata)
+ {
+ return parameterMetadata.Type;
+ }
+
+ if (x is PropertyMetadata propertyMetadata)
+ {
+ return propertyMetadata.Type;
+ }
+
+ throw new ArgumentOutOfRangeException(nameof(dataGeneratorMetadata),
+ "Member to generate must be either a parameter or a property.");
+ }).ToArray();
+ }
+
var items = new object?[_types.Length];
for (var i = 0; i < _types.Length; i++)
@@ -117,3 +120,24 @@ public ClassDataSourceAttribute(params Type[] types)
public IEnumerable GetKeys() => Keys;
}
+
+[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method | AttributeTargets.Property | AttributeTargets.Parameter, AllowMultiple = true)]
+public sealed class ClassDataSourceAttribute<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors | DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.NonPublicProperties)] T>
+ : DataSourceGeneratorAttribute
+{
+ public SharedType Shared { get; set; } = SharedType.None;
+ public string Key { get; set; } = string.Empty;
+ public Type ClassType => typeof(T);
+
+ protected override IEnumerable> GenerateDataSources(DataGeneratorMetadata dataGeneratorMetadata)
+ {
+ var testClassType = TestClassTypeHelper.GetTestClassType(dataGeneratorMetadata);
+ yield return () => ClassDataSources.Get(dataGeneratorMetadata.TestSessionId)
+ .Get(Shared, testClassType, Key, dataGeneratorMetadata);
+ }
+
+
+ public IEnumerable GetSharedTypes() => [Shared];
+
+ public IEnumerable GetKeys() => string.IsNullOrEmpty(Key) ? [] : [Key];
+}
diff --git a/TUnit.Core/Attributes/TestData/CombinedDataSourcesAttribute.cs b/TUnit.Core/Attributes/TestData/CombinedDataSourcesAttribute.cs
index 4d8ab15ef5..53a1a697ee 100644
--- a/TUnit.Core/Attributes/TestData/CombinedDataSourcesAttribute.cs
+++ b/TUnit.Core/Attributes/TestData/CombinedDataSourcesAttribute.cs
@@ -126,7 +126,9 @@ public sealed class CombinedDataSourcesAttribute : AsyncUntypedDataSourceGenerat
if (parameterMetadata.CachedDataSourceAttributes != null)
{
// Source-generated mode: use cached attributes (no reflection!)
- dataSourceAttributes = parameterMetadata.CachedDataSourceAttributes;
+ dataSourceAttributes = parameterMetadata.CachedDataSourceAttributes
+ .OfType()
+ .ToArray();
}
else
{
diff --git a/TUnit.Core/Attributes/TestData/IDataSourceMemberAttribute.cs b/TUnit.Core/Attributes/TestData/IDataSourceMemberAttribute.cs
new file mode 100644
index 0000000000..dcb9c3db5c
--- /dev/null
+++ b/TUnit.Core/Attributes/TestData/IDataSourceMemberAttribute.cs
@@ -0,0 +1,9 @@
+namespace TUnit.Core;
+
+///
+/// Marker interface for attributes that provide data values for individual parameters
+/// within a data source context (e.g., matrix testing).
+/// Attributes implementing this interface will be cached by the source generator
+/// for AOT-compatible runtime access.
+///
+public interface IDataSourceMemberAttribute;
diff --git a/TUnit.Core/Attributes/TestData/InstanceMethodDataSourceSourceAttribute.cs b/TUnit.Core/Attributes/TestData/InstanceMethodDataSourceSourceAttribute.cs
index 9413df05a1..1e5b4ea2eb 100644
--- a/TUnit.Core/Attributes/TestData/InstanceMethodDataSourceSourceAttribute.cs
+++ b/TUnit.Core/Attributes/TestData/InstanceMethodDataSourceSourceAttribute.cs
@@ -7,7 +7,7 @@ namespace TUnit.Core;
/// This implements IAccessesInstanceData which tells the engine to create a properly-initialized
/// instance before evaluating the data source.
///
-[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)]
+[AttributeUsage(AttributeTargets.Method | AttributeTargets.Property, AllowMultiple = true)]
public class InstanceMethodDataSourceAttribute : MethodDataSourceAttribute, IAccessesInstanceData
{
public InstanceMethodDataSourceAttribute(string methodNameProvidingDataSource)
diff --git a/TUnit.Core/Attributes/TestData/MatrixSourceAttribute.cs b/TUnit.Core/Attributes/TestData/MatrixSourceAttribute.cs
index 7417dcb421..d1a7ccac7c 100644
--- a/TUnit.Core/Attributes/TestData/MatrixSourceAttribute.cs
+++ b/TUnit.Core/Attributes/TestData/MatrixSourceAttribute.cs
@@ -42,7 +42,7 @@ namespace TUnit.Core;
///
/// The values to be used for this parameter in the test matrix.
[AttributeUsage(AttributeTargets.Parameter)]
-public class MatrixAttribute(params object?[]? objects) : TUnitAttribute
+public class MatrixAttribute(params object?[]? objects) : TUnitAttribute, IDataSourceMemberAttribute
{
protected MatrixAttribute() : this(null)
{
diff --git a/TUnit.Core/Attributes/TestData/MethodDataSourceAttribute.cs b/TUnit.Core/Attributes/TestData/MethodDataSourceAttribute.cs
index c6bd260fe0..1597914248 100644
--- a/TUnit.Core/Attributes/TestData/MethodDataSourceAttribute.cs
+++ b/TUnit.Core/Attributes/TestData/MethodDataSourceAttribute.cs
@@ -89,6 +89,17 @@ public MethodDataSourceAttribute(
{
targetType = dataGeneratorMetadata.TestClassInstance.GetType();
}
+
+ // If the target type is abstract or interface, we can't create an instance of it.
+ // Fall back to the test class type which should be concrete.
+ if (targetType != null && (targetType.IsAbstract || targetType.IsInterface))
+ {
+ var testClassType = TestClassTypeHelper.GetTestClassType(dataGeneratorMetadata);
+ if (testClassType != null && !testClassType.IsAbstract && !testClassType.IsInterface)
+ {
+ targetType = testClassType;
+ }
+ }
if (targetType == null)
{
@@ -108,7 +119,13 @@ public MethodDataSourceAttribute(
object? instance = null;
if (!methodInfo.IsStatic)
{
- instance = dataGeneratorMetadata.TestClassInstance ?? Activator.CreateInstance(targetType);
+ // Skip PlaceholderInstance as it's a sentinel value, not a real instance
+ var testClassInstance = dataGeneratorMetadata.TestClassInstance;
+ if (testClassInstance is PlaceholderInstance)
+ {
+ testClassInstance = null;
+ }
+ instance = testClassInstance ?? Activator.CreateInstance(targetType);
}
methodResult = methodInfo.Invoke(instance, Arguments);
@@ -125,7 +142,13 @@ public MethodDataSourceAttribute(
object? instance = null;
if (propertyInfo.GetMethod?.IsStatic != true)
{
- instance = dataGeneratorMetadata.TestClassInstance ?? Activator.CreateInstance(targetType);
+ // Skip PlaceholderInstance as it's a sentinel value, not a real instance
+ var testClassInstance = dataGeneratorMetadata.TestClassInstance;
+ if (testClassInstance is PlaceholderInstance)
+ {
+ testClassInstance = null;
+ }
+ instance = testClassInstance ?? Activator.CreateInstance(targetType);
}
methodResult = propertyInfo.GetValue(instance);
@@ -136,7 +159,13 @@ public MethodDataSourceAttribute(
object? instance = null;
if (!fieldInfo.IsStatic)
{
- instance = dataGeneratorMetadata.TestClassInstance ?? Activator.CreateInstance(targetType);
+ // Skip PlaceholderInstance as it's a sentinel value, not a real instance
+ var testClassInstance = dataGeneratorMetadata.TestClassInstance;
+ if (testClassInstance is PlaceholderInstance)
+ {
+ testClassInstance = null;
+ }
+ instance = testClassInstance ?? Activator.CreateInstance(targetType);
}
methodResult = fieldInfo.GetValue(instance);
diff --git a/TUnit.Core/Discovery/InitializerPropertyRegistry.cs b/TUnit.Core/Discovery/InitializerPropertyRegistry.cs
new file mode 100644
index 0000000000..fe7269a3db
--- /dev/null
+++ b/TUnit.Core/Discovery/InitializerPropertyRegistry.cs
@@ -0,0 +1,60 @@
+using System.Collections.Concurrent;
+using System.Diagnostics.CodeAnalysis;
+using System.Reflection;
+using TUnit.Core.Interfaces;
+
+namespace TUnit.Core.Discovery;
+
+///
+/// Registry for IAsyncInitializer property metadata generated at compile time.
+/// Used for AOT-compatible nested initializer discovery.
+///
+public static class InitializerPropertyRegistry
+{
+ private static readonly ConcurrentDictionary Registry = new();
+
+ ///
+ /// Registers property metadata for a type. Called by generated code.
+ ///
+ public static void Register(Type type, InitializerPropertyInfo[] properties)
+ {
+ Registry[type] = properties;
+ }
+
+ ///
+ /// Gets property metadata for a type, or null if not registered.
+ ///
+ public static InitializerPropertyInfo[]? GetProperties(Type type)
+ {
+ return Registry.TryGetValue(type, out var properties) ? properties : null;
+ }
+
+ ///
+ /// Checks if a type has registered property metadata.
+ ///
+ public static bool HasRegistration(Type type)
+ {
+ return Registry.ContainsKey(type);
+ }
+}
+
+///
+/// Metadata about a property that returns an IAsyncInitializer.
+///
+public sealed class InitializerPropertyInfo
+{
+ ///
+ /// The name of the property.
+ ///
+ public required string PropertyName { get; init; }
+
+ ///
+ /// The property type.
+ ///
+ public required Type PropertyType { get; init; }
+
+ ///
+ /// Delegate to get the property value from an instance.
+ ///
+ public required Func