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 ae70c9562e..57d1d9036c 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -85,9 +85,10 @@
-
-
-
+
+
+
+
diff --git a/TUnit.AspNetCore.Analyzers.Roslyn414/TUnit.AspNetCore.Analyzers.Roslyn414.csproj b/TUnit.AspNetCore.Analyzers.Roslyn414/TUnit.AspNetCore.Analyzers.Roslyn414.csproj
new file mode 100644
index 0000000000..a37eda453b
--- /dev/null
+++ b/TUnit.AspNetCore.Analyzers.Roslyn414/TUnit.AspNetCore.Analyzers.Roslyn414.csproj
@@ -0,0 +1,9 @@
+
+
+
+ 4.14
+
+
+
+
+
diff --git a/TUnit.AspNetCore.Analyzers.Roslyn44/TUnit.AspNetCore.Analyzers.Roslyn44.csproj b/TUnit.AspNetCore.Analyzers.Roslyn44/TUnit.AspNetCore.Analyzers.Roslyn44.csproj
new file mode 100644
index 0000000000..5df2a50bec
--- /dev/null
+++ b/TUnit.AspNetCore.Analyzers.Roslyn44/TUnit.AspNetCore.Analyzers.Roslyn44.csproj
@@ -0,0 +1,9 @@
+
+
+
+ 4.4
+
+
+
+
+
diff --git a/TUnit.AspNetCore.Analyzers.Roslyn47/TUnit.AspNetCore.Analyzers.Roslyn47.csproj b/TUnit.AspNetCore.Analyzers.Roslyn47/TUnit.AspNetCore.Analyzers.Roslyn47.csproj
new file mode 100644
index 0000000000..5694a2f514
--- /dev/null
+++ b/TUnit.AspNetCore.Analyzers.Roslyn47/TUnit.AspNetCore.Analyzers.Roslyn47.csproj
@@ -0,0 +1,9 @@
+
+
+
+ 4.7
+
+
+
+
+
diff --git a/TUnit.AspNetCore.Analyzers.Tests/TUnit.AspNetCore.Analyzers.Tests.csproj b/TUnit.AspNetCore.Analyzers.Tests/TUnit.AspNetCore.Analyzers.Tests.csproj
new file mode 100644
index 0000000000..e7bcc196d9
--- /dev/null
+++ b/TUnit.AspNetCore.Analyzers.Tests/TUnit.AspNetCore.Analyzers.Tests.csproj
@@ -0,0 +1,22 @@
+
+
+
+
+
+ net8.0;net9.0;net10.0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/TUnit.AspNetCore.Analyzers.Tests/Verifiers/CSharpAnalyzerVerifier.cs b/TUnit.AspNetCore.Analyzers.Tests/Verifiers/CSharpAnalyzerVerifier.cs
new file mode 100644
index 0000000000..1c085261f9
--- /dev/null
+++ b/TUnit.AspNetCore.Analyzers.Tests/Verifiers/CSharpAnalyzerVerifier.cs
@@ -0,0 +1,59 @@
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Testing;
+using Microsoft.CodeAnalysis.Diagnostics;
+using Microsoft.CodeAnalysis.Testing;
+
+namespace TUnit.AspNetCore.Analyzers.Tests.Verifiers;
+
+public static partial class CSharpAnalyzerVerifier
+ where TAnalyzer : DiagnosticAnalyzer, new()
+{
+ public class Test : CSharpAnalyzerTest
+ {
+ public Test()
+ {
+ ReferenceAssemblies.AddAssemblies(ReferenceAssemblies.Net.Net60.Assemblies);
+ SolutionTransforms.Add((solution, projectId) =>
+ {
+ var project = solution.GetProject(projectId);
+
+ if (project is null)
+ {
+ return solution;
+ }
+
+ var compilationOptions = project.CompilationOptions;
+
+ if (compilationOptions is null)
+ {
+ return solution;
+ }
+
+ if (compilationOptions is CSharpCompilationOptions cSharpCompilationOptions)
+ {
+ compilationOptions =
+ cSharpCompilationOptions.WithNullableContextOptions(NullableContextOptions.Enable);
+ }
+
+ if (project.ParseOptions is not CSharpParseOptions parseOptions)
+ {
+ return solution;
+ }
+
+ compilationOptions = compilationOptions
+ .WithSpecificDiagnosticOptions(compilationOptions.SpecificDiagnosticOptions
+ .SetItems(CSharpVerifierHelper.NullableWarnings)
+ // Suppress analyzer release tracking warnings - we're testing TUnit analyzers, not release tracking
+ .SetItem("RS2007", ReportDiagnostic.Suppress)
+ .SetItem("RS2008", ReportDiagnostic.Suppress));
+
+ solution = solution.WithProjectCompilationOptions(projectId, compilationOptions)
+ .WithProjectParseOptions(projectId, parseOptions
+ .WithLanguageVersion(LanguageVersion.Preview));
+
+ return solution;
+ });
+ }
+ }
+}
diff --git a/TUnit.AspNetCore.Analyzers.Tests/Verifiers/CSharpAnalyzerVerifier`1.cs b/TUnit.AspNetCore.Analyzers.Tests/Verifiers/CSharpAnalyzerVerifier`1.cs
new file mode 100644
index 0000000000..08543e0cd5
--- /dev/null
+++ b/TUnit.AspNetCore.Analyzers.Tests/Verifiers/CSharpAnalyzerVerifier`1.cs
@@ -0,0 +1,52 @@
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp.Testing;
+using Microsoft.CodeAnalysis.Diagnostics;
+using Microsoft.CodeAnalysis.Testing;
+
+namespace TUnit.AspNetCore.Analyzers.Tests.Verifiers;
+
+public static partial class CSharpAnalyzerVerifier
+ where TAnalyzer : DiagnosticAnalyzer, new()
+{
+ ///
+ public static DiagnosticResult Diagnostic()
+ => CSharpAnalyzerVerifier.Diagnostic();
+
+ ///
+ public static DiagnosticResult Diagnostic(string diagnosticId)
+ => CSharpAnalyzerVerifier.Diagnostic(diagnosticId);
+
+ ///
+ public static DiagnosticResult Diagnostic(DiagnosticDescriptor descriptor)
+ => CSharpAnalyzerVerifier.Diagnostic(descriptor);
+
+ ///
+ public static Task VerifyAnalyzerAsync([StringSyntax("c#")] string source, params DiagnosticResult[] expected)
+ {
+ return VerifyAnalyzerAsync(source, _ => { }, expected);
+ }
+
+ ///
+ public static async Task VerifyAnalyzerAsync([StringSyntax("c#")] string source, Action configureTest, params DiagnosticResult[] expected)
+ {
+ var test = new Test
+ {
+ TestCode = source,
+ ReferenceAssemblies = ReferenceAssemblies.Net.Net90,
+ TestState =
+ {
+ AdditionalReferences =
+ {
+ typeof(TUnit.Core.TUnitAttribute).Assembly.Location,
+ },
+ },
+ };
+
+ test.ExpectedDiagnostics.AddRange(expected);
+
+ configureTest(test);
+
+ await test.RunAsync(CancellationToken.None);
+ }
+}
diff --git a/TUnit.AspNetCore.Analyzers.Tests/Verifiers/CSharpVerifierHelper.cs b/TUnit.AspNetCore.Analyzers.Tests/Verifiers/CSharpVerifierHelper.cs
new file mode 100644
index 0000000000..e34302c084
--- /dev/null
+++ b/TUnit.AspNetCore.Analyzers.Tests/Verifiers/CSharpVerifierHelper.cs
@@ -0,0 +1,32 @@
+using System.Collections.Immutable;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+
+namespace TUnit.AspNetCore.Analyzers.Tests.Verifiers;
+
+internal static class CSharpVerifierHelper
+{
+ ///
+ /// By default, the compiler reports diagnostics for nullable reference types at
+ /// , and the analyzer test framework defaults to only validating
+ /// diagnostics at . This map contains all compiler diagnostic IDs
+ /// related to nullability mapped to , which is then used to enable all
+ /// of these warnings for default validation during analyzer and code fix tests.
+ ///
+ internal static ImmutableDictionary NullableWarnings { get; } = GetNullableWarningsFromCompiler();
+
+ private static ImmutableDictionary GetNullableWarningsFromCompiler()
+ {
+ string[] args = ["/warnaserror:nullable", "-p:LangVersion=preview"];
+ var commandLineArguments = CSharpCommandLineParser.Default.Parse(args, baseDirectory: Environment.CurrentDirectory, sdkDirectory: Environment.CurrentDirectory);
+ var nullableWarnings = commandLineArguments.CompilationOptions.SpecificDiagnosticOptions;
+
+ // Workaround for https://github.com/dotnet/roslyn/issues/41610
+ nullableWarnings = nullableWarnings
+ .SetItem("CS8632", ReportDiagnostic.Error)
+ .SetItem("CS8669", ReportDiagnostic.Error)
+ .SetItem("CS8652", ReportDiagnostic.Suppress);
+
+ return nullableWarnings;
+ }
+}
diff --git a/TUnit.AspNetCore.Analyzers.Tests/Verifiers/LineEndingNormalizingVerifier.cs b/TUnit.AspNetCore.Analyzers.Tests/Verifiers/LineEndingNormalizingVerifier.cs
new file mode 100644
index 0000000000..848dd250a3
--- /dev/null
+++ b/TUnit.AspNetCore.Analyzers.Tests/Verifiers/LineEndingNormalizingVerifier.cs
@@ -0,0 +1,101 @@
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.CodeAnalysis.Testing;
+
+namespace TUnit.AspNetCore.Analyzers.Tests.Verifiers;
+
+///
+/// A custom verifier that normalizes line endings to LF before comparison to support cross-platform testing.
+/// This prevents tests from failing due to differences between Windows (CRLF) and Unix (LF) line endings.
+/// By normalizing to LF (the universal standard), tests pass consistently on all platforms.
+///
+public class LineEndingNormalizingVerifier : IVerifier
+{
+ private readonly DefaultVerifier _defaultVerifier = new();
+
+ public void Empty(string collectionName, IEnumerable collection)
+ {
+ _defaultVerifier.Empty(collectionName, collection);
+ }
+
+ public void Equal(T expected, T actual, string? message = null)
+ {
+ // Normalize line endings for string comparisons
+ if (expected is string expectedString && actual is string actualString)
+ {
+ var normalizedExpected = NormalizeLineEndings(expectedString);
+ var normalizedActual = NormalizeLineEndings(actualString);
+ _defaultVerifier.Equal(normalizedExpected, normalizedActual, message);
+ }
+ else
+ {
+ _defaultVerifier.Equal(expected, actual, message);
+ }
+ }
+
+ public void True(bool assert, string? message = null)
+ {
+ _defaultVerifier.True(assert, message);
+ }
+
+ public void False(bool assert, string? message = null)
+ {
+ _defaultVerifier.False(assert, message);
+ }
+
+ [DoesNotReturn]
+ public void Fail(string? message = null)
+ {
+ _defaultVerifier.Fail(message);
+ }
+
+ public void LanguageIsSupported(string language)
+ {
+ _defaultVerifier.LanguageIsSupported(language);
+ }
+
+ public void NotEmpty(string collectionName, IEnumerable collection)
+ {
+ _defaultVerifier.NotEmpty(collectionName, collection);
+ }
+
+ public void SequenceEqual(IEnumerable expected, IEnumerable actual, IEqualityComparer? equalityComparer = null, string? message = null)
+ {
+ // Normalize line endings for string sequence comparisons
+ if (typeof(T) == typeof(string))
+ {
+ var normalizedExpected = expected.Cast().Select(NormalizeLineEndings).Cast();
+ var normalizedActual = actual.Cast().Select(NormalizeLineEndings).Cast();
+ _defaultVerifier.SequenceEqual(normalizedExpected, normalizedActual, equalityComparer, message);
+ }
+ else
+ {
+ _defaultVerifier.SequenceEqual(expected, actual, equalityComparer, message);
+ }
+ }
+
+ public IVerifier PushContext(string context)
+ {
+ // Create a new verifier that wraps the result of PushContext on the default verifier
+ return new LineEndingNormalizingVerifierWithContext(_defaultVerifier.PushContext(context));
+ }
+
+ private static string NormalizeLineEndings(string value)
+ {
+ // Normalize all line endings to LF (Unix) for cross-platform consistent comparison
+ // LF is the universal standard and prevents Windows/Linux test mismatches
+ return value.Replace("\r\n", "\n");
+ }
+
+ ///
+ /// Internal helper class to wrap a verifier with context
+ ///
+ private class LineEndingNormalizingVerifierWithContext : LineEndingNormalizingVerifier
+ {
+ private readonly IVerifier _wrappedVerifier;
+
+ public LineEndingNormalizingVerifierWithContext(IVerifier wrappedVerifier)
+ {
+ _wrappedVerifier = wrappedVerifier;
+ }
+ }
+}
diff --git a/TUnit.AspNetCore.Analyzers.Tests/WebApplicationFactoryAccessAnalyzerTests.cs b/TUnit.AspNetCore.Analyzers.Tests/WebApplicationFactoryAccessAnalyzerTests.cs
new file mode 100644
index 0000000000..e2e6407c68
--- /dev/null
+++ b/TUnit.AspNetCore.Analyzers.Tests/WebApplicationFactoryAccessAnalyzerTests.cs
@@ -0,0 +1,547 @@
+using Verifier = TUnit.AspNetCore.Analyzers.Tests.Verifiers.CSharpAnalyzerVerifier;
+
+namespace TUnit.AspNetCore.Analyzers.Tests;
+
+public class WebApplicationFactoryAccessAnalyzerTests
+{
+ private const string WebApplicationTestStub = """
+ namespace TUnit.AspNetCore
+ {
+ public abstract class WebApplicationTest
+ {
+ public int UniqueId { get; }
+ }
+
+ public abstract class WebApplicationTest : WebApplicationTest
+ where TFactory : class, new()
+ where TEntryPoint : class
+ {
+ public TFactory GlobalFactory { get; set; } = null!;
+ public object Factory { get; } = null!;
+ public System.IServiceProvider Services { get; } = null!;
+ public object? HttpCapture { get; }
+
+ protected virtual System.Threading.Tasks.Task SetupAsync() => System.Threading.Tasks.Task.CompletedTask;
+ }
+ }
+ """;
+
+ private const string WebApplicationFactoryStub = """
+ namespace Microsoft.AspNetCore.Mvc.Testing
+ {
+ public class WebApplicationFactory where TEntryPoint : class
+ {
+ public System.IServiceProvider Services { get; } = null!;
+ public object Server { get; } = null!;
+ public object CreateClient() => new object();
+ public object CreateDefaultClient() => new object();
+ }
+ }
+
+ namespace TUnit.AspNetCore
+ {
+ public class TestWebApplicationFactory : Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactory
+ where TEntryPoint : class
+ {
+ }
+ }
+ """;
+
+ [Test]
+ public async Task No_Error_When_Accessing_Factory_In_Test_Method()
+ {
+ await Verifier
+ .VerifyAnalyzerAsync(
+ $$"""
+ using TUnit.Core;
+ {{WebApplicationTestStub}}
+
+ public class MyFactory { }
+ public class Program { }
+
+ public class MyTests : TUnit.AspNetCore.WebApplicationTest
+ {
+ [Test]
+ public void MyTest()
+ {
+ var factory = Factory;
+ var services = Services;
+ }
+ }
+ """
+ );
+ }
+
+ [Test]
+ public async Task Error_When_Accessing_Factory_In_Constructor()
+ {
+ await Verifier
+ .VerifyAnalyzerAsync(
+ $$"""
+ using TUnit.Core;
+ {{WebApplicationTestStub}}
+
+ public class MyFactory { }
+ public class Program { }
+
+ public class MyTests : TUnit.AspNetCore.WebApplicationTest
+ {
+ public MyTests()
+ {
+ var factory = {|#0:Factory|};
+ }
+
+ [Test]
+ public void MyTest()
+ {
+ }
+ }
+ """,
+ Verifier.Diagnostic(Rules.FactoryAccessedTooEarly)
+ .WithLocation(0)
+ .WithArguments("Factory", "constructor")
+ );
+ }
+
+ [Test]
+ public async Task Error_When_Accessing_Services_In_Constructor()
+ {
+ await Verifier
+ .VerifyAnalyzerAsync(
+ $$"""
+ using TUnit.Core;
+ {{WebApplicationTestStub}}
+
+ public class MyFactory { }
+ public class Program { }
+
+ public class MyTests : TUnit.AspNetCore.WebApplicationTest
+ {
+ public MyTests()
+ {
+ var services = {|#0:Services|};
+ }
+
+ [Test]
+ public void MyTest()
+ {
+ }
+ }
+ """,
+ Verifier.Diagnostic(Rules.FactoryAccessedTooEarly)
+ .WithLocation(0)
+ .WithArguments("Services", "constructor")
+ );
+ }
+
+ [Test]
+ public async Task Error_When_Accessing_Factory_In_SetupAsync()
+ {
+ await Verifier
+ .VerifyAnalyzerAsync(
+ $$"""
+ using TUnit.Core;
+ using System.Threading.Tasks;
+ {{WebApplicationTestStub}}
+
+ public class MyFactory { }
+ public class Program { }
+
+ public class MyTests : TUnit.AspNetCore.WebApplicationTest
+ {
+ protected override Task SetupAsync()
+ {
+ var factory = {|#0:Factory|};
+ return Task.CompletedTask;
+ }
+
+ [Test]
+ public void MyTest()
+ {
+ }
+ }
+ """,
+ Verifier.Diagnostic(Rules.FactoryAccessedTooEarly)
+ .WithLocation(0)
+ .WithArguments("Factory", "SetupAsync")
+ );
+ }
+
+ [Test]
+ public async Task Error_When_Accessing_HttpCapture_In_SetupAsync()
+ {
+ await Verifier
+ .VerifyAnalyzerAsync(
+ $$"""
+ using TUnit.Core;
+ using System.Threading.Tasks;
+ {{WebApplicationTestStub}}
+
+ public class MyFactory { }
+ public class Program { }
+
+ public class MyTests : TUnit.AspNetCore.WebApplicationTest
+ {
+ protected override Task SetupAsync()
+ {
+ var capture = {|#0:HttpCapture|};
+ return Task.CompletedTask;
+ }
+
+ [Test]
+ public void MyTest()
+ {
+ }
+ }
+ """,
+ Verifier.Diagnostic(Rules.FactoryAccessedTooEarly)
+ .WithLocation(0)
+ .WithArguments("HttpCapture", "SetupAsync")
+ );
+ }
+
+ [Test]
+ public async Task Error_When_Accessing_GlobalFactory_In_Constructor()
+ {
+ // GlobalFactory is NOT available in constructor - it's injected via property injection after construction
+ await Verifier
+ .VerifyAnalyzerAsync(
+ $$"""
+ using TUnit.Core;
+ {{WebApplicationTestStub}}
+
+ public class MyFactory { }
+ public class Program { }
+
+ public class MyTests : TUnit.AspNetCore.WebApplicationTest
+ {
+ public MyTests()
+ {
+ var factory = {|#0:GlobalFactory|};
+ }
+
+ [Test]
+ public void MyTest()
+ {
+ }
+ }
+ """,
+ Verifier.Diagnostic(Rules.FactoryAccessedTooEarly)
+ .WithLocation(0)
+ .WithArguments("GlobalFactory", "constructor")
+ );
+ }
+
+ [Test]
+ public async Task No_Error_When_Accessing_GlobalFactory_In_SetupAsync()
+ {
+ // GlobalFactory IS available in SetupAsync - it's injected before SetupAsync runs
+ await Verifier
+ .VerifyAnalyzerAsync(
+ $$"""
+ using TUnit.Core;
+ using System.Threading.Tasks;
+ {{WebApplicationTestStub}}
+
+ public class MyFactory { }
+ public class Program { }
+
+ public class MyTests : TUnit.AspNetCore.WebApplicationTest
+ {
+ protected override Task SetupAsync()
+ {
+ var factory = GlobalFactory;
+ return Task.CompletedTask;
+ }
+
+ [Test]
+ public void MyTest()
+ {
+ }
+ }
+ """
+ );
+ }
+
+ [Test]
+ public async Task No_Error_When_Accessing_UniqueId_In_SetupAsync()
+ {
+ // UniqueId IS available in SetupAsync
+ await Verifier
+ .VerifyAnalyzerAsync(
+ $$"""
+ using TUnit.Core;
+ using System.Threading.Tasks;
+ {{WebApplicationTestStub}}
+
+ public class MyFactory { }
+ public class Program { }
+
+ public class MyTests : TUnit.AspNetCore.WebApplicationTest
+ {
+ protected override Task SetupAsync()
+ {
+ var id = UniqueId;
+ return Task.CompletedTask;
+ }
+
+ [Test]
+ public void MyTest()
+ {
+ }
+ }
+ """
+ );
+ }
+
+ [Test]
+ public async Task No_Error_For_Unrelated_Factory_Property()
+ {
+ // A property named Factory on an unrelated class should not trigger the analyzer
+ await Verifier
+ .VerifyAnalyzerAsync(
+ """
+ using TUnit.Core;
+
+ public class SomeClass
+ {
+ public object Factory { get; } = null!;
+ }
+
+ public class MyTests
+ {
+ private SomeClass _someClass = new();
+
+ public MyTests()
+ {
+ var factory = _someClass.Factory;
+ }
+
+ [Test]
+ public void MyTest()
+ {
+ }
+ }
+ """
+ );
+ }
+
+ [Test]
+ public async Task Error_When_Accessing_GlobalFactory_Services()
+ {
+ // GlobalFactory.Services should never be accessed - use Factory.Services instead
+ await Verifier
+ .VerifyAnalyzerAsync(
+ $$"""
+ using TUnit.Core;
+ {{WebApplicationFactoryStub}}
+ {{WebApplicationTestStub}}
+
+ public class MyFactory : TUnit.AspNetCore.TestWebApplicationFactory { }
+ public class Program { }
+
+ public class MyTests : TUnit.AspNetCore.WebApplicationTest
+ {
+ [Test]
+ public void MyTest()
+ {
+ var services = {|#0:GlobalFactory.Services|};
+ }
+ }
+ """,
+ Verifier.Diagnostic(Rules.GlobalFactoryMemberAccess)
+ .WithLocation(0)
+ .WithArguments("Services")
+ );
+ }
+
+ [Test]
+ public async Task Error_When_Accessing_GlobalFactory_Server()
+ {
+ // GlobalFactory.Server should never be accessed - use Factory.Server instead
+ await Verifier
+ .VerifyAnalyzerAsync(
+ $$"""
+ using TUnit.Core;
+ {{WebApplicationFactoryStub}}
+ {{WebApplicationTestStub}}
+
+ public class MyFactory : TUnit.AspNetCore.TestWebApplicationFactory { }
+ public class Program { }
+
+ public class MyTests : TUnit.AspNetCore.WebApplicationTest
+ {
+ [Test]
+ public void MyTest()
+ {
+ var server = {|#0:GlobalFactory.Server|};
+ }
+ }
+ """,
+ Verifier.Diagnostic(Rules.GlobalFactoryMemberAccess)
+ .WithLocation(0)
+ .WithArguments("Server")
+ );
+ }
+
+ [Test]
+ public async Task Error_When_Calling_GlobalFactory_CreateClient()
+ {
+ // GlobalFactory.CreateClient() should never be called - use Factory.CreateClient() instead
+ await Verifier
+ .VerifyAnalyzerAsync(
+ $$"""
+ using TUnit.Core;
+ {{WebApplicationFactoryStub}}
+ {{WebApplicationTestStub}}
+
+ public class MyFactory : TUnit.AspNetCore.TestWebApplicationFactory { }
+ public class Program { }
+
+ public class MyTests : TUnit.AspNetCore.WebApplicationTest
+ {
+ [Test]
+ public void MyTest()
+ {
+ var client = {|#0:GlobalFactory.CreateClient()|};
+ }
+ }
+ """,
+ Verifier.Diagnostic(Rules.GlobalFactoryMemberAccess)
+ .WithLocation(0)
+ .WithArguments("CreateClient")
+ );
+ }
+
+ [Test]
+ public async Task Error_When_Calling_GlobalFactory_CreateDefaultClient()
+ {
+ // GlobalFactory.CreateDefaultClient() should never be called - use Factory.CreateDefaultClient() instead
+ await Verifier
+ .VerifyAnalyzerAsync(
+ $$"""
+ using TUnit.Core;
+ {{WebApplicationFactoryStub}}
+ {{WebApplicationTestStub}}
+
+ public class MyFactory : TUnit.AspNetCore.TestWebApplicationFactory { }
+ public class Program { }
+
+ public class MyTests : TUnit.AspNetCore.WebApplicationTest
+ {
+ [Test]
+ public void MyTest()
+ {
+ var client = {|#0:GlobalFactory.CreateDefaultClient()|};
+ }
+ }
+ """,
+ Verifier.Diagnostic(Rules.GlobalFactoryMemberAccess)
+ .WithLocation(0)
+ .WithArguments("CreateDefaultClient")
+ );
+ }
+
+ [Test]
+ public async Task No_Error_When_Accessing_Factory_Services_In_Test()
+ {
+ // Factory.Services is the correct way to access services
+ await Verifier
+ .VerifyAnalyzerAsync(
+ $$"""
+ using TUnit.Core;
+ {{WebApplicationFactoryStub}}
+ {{WebApplicationTestStub}}
+
+ public class MyFactory : TUnit.AspNetCore.TestWebApplicationFactory { }
+ public class Program { }
+
+ public class MyTests : TUnit.AspNetCore.WebApplicationTest
+ {
+ [Test]
+ public void MyTest()
+ {
+ var services = Services;
+ }
+ }
+ """
+ );
+ }
+
+ [Test]
+ public async Task Error_When_Accessing_Factory_In_Constructor_With_Deep_Inheritance()
+ {
+ // Analyzer should detect WebApplicationTest even through multiple levels of inheritance
+ await Verifier
+ .VerifyAnalyzerAsync(
+ $$"""
+ using TUnit.Core;
+ {{WebApplicationTestStub}}
+
+ public class MyFactory { }
+ public class Program { }
+
+ public abstract class BaseTestClass : TUnit.AspNetCore.WebApplicationTest
+ {
+ }
+
+ public abstract class MiddleTestClass : BaseTestClass
+ {
+ }
+
+ public class MyTests : MiddleTestClass
+ {
+ public MyTests()
+ {
+ var factory = {|#0:Factory|};
+ }
+
+ [Test]
+ public void MyTest()
+ {
+ }
+ }
+ """,
+ Verifier.Diagnostic(Rules.FactoryAccessedTooEarly)
+ .WithLocation(0)
+ .WithArguments("Factory", "constructor")
+ );
+ }
+
+ [Test]
+ public async Task Error_When_Accessing_GlobalFactory_Services_With_Deep_Inheritance()
+ {
+ // Analyzer should detect GlobalFactory.Services access even through multiple levels of inheritance
+ await Verifier
+ .VerifyAnalyzerAsync(
+ $$"""
+ using TUnit.Core;
+ {{WebApplicationFactoryStub}}
+ {{WebApplicationTestStub}}
+
+ public class MyFactory : TUnit.AspNetCore.TestWebApplicationFactory { }
+ public class Program { }
+
+ public abstract class BaseTestClass : TUnit.AspNetCore.WebApplicationTest
+ {
+ }
+
+ public abstract class MiddleTestClass : BaseTestClass
+ {
+ }
+
+ public class MyTests : MiddleTestClass
+ {
+ [Test]
+ public void MyTest()
+ {
+ var services = {|#0:GlobalFactory.Services|};
+ }
+ }
+ """,
+ Verifier.Diagnostic(Rules.GlobalFactoryMemberAccess)
+ .WithLocation(0)
+ .WithArguments("Services")
+ );
+ }
+}
diff --git a/TUnit.AspNetCore.Analyzers/AnalyzerReleases.Shipped.md b/TUnit.AspNetCore.Analyzers/AnalyzerReleases.Shipped.md
new file mode 100644
index 0000000000..39071b5a25
--- /dev/null
+++ b/TUnit.AspNetCore.Analyzers/AnalyzerReleases.Shipped.md
@@ -0,0 +1,6 @@
+## Release 1.0
+
+### New Rules
+
+Rule ID | Category | Severity | Notes
+--------|----------|----------|-------
diff --git a/TUnit.AspNetCore.Analyzers/AnalyzerReleases.Unshipped.md b/TUnit.AspNetCore.Analyzers/AnalyzerReleases.Unshipped.md
new file mode 100644
index 0000000000..e69fa429b4
--- /dev/null
+++ b/TUnit.AspNetCore.Analyzers/AnalyzerReleases.Unshipped.md
@@ -0,0 +1,11 @@
+### New Rules
+
+Rule ID | Category | Severity | Notes
+--------|----------|----------|-------
+TUnit0062 | Usage | Error | Factory property accessed before initialization in WebApplicationTest
+TUnit0063 | Usage | Error | GlobalFactory member access breaks test isolation
+
+### Removed Rules
+
+Rule ID | Category | Severity | Notes
+--------|----------|----------|-------
diff --git a/TUnit.AspNetCore.Analyzers/ConcurrentDiagnosticAnalyzer.cs b/TUnit.AspNetCore.Analyzers/ConcurrentDiagnosticAnalyzer.cs
new file mode 100644
index 0000000000..0cef679d93
--- /dev/null
+++ b/TUnit.AspNetCore.Analyzers/ConcurrentDiagnosticAnalyzer.cs
@@ -0,0 +1,16 @@
+using Microsoft.CodeAnalysis.Diagnostics;
+
+namespace TUnit.AspNetCore.Analyzers;
+
+public abstract class ConcurrentDiagnosticAnalyzer : DiagnosticAnalyzer
+{
+ public sealed override void Initialize(AnalysisContext context)
+ {
+ context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
+ context.EnableConcurrentExecution();
+
+ InitializeInternal(context);
+ }
+
+ protected abstract void InitializeInternal(AnalysisContext context);
+}
diff --git a/TUnit.AspNetCore.Analyzers/Resources.Designer.cs b/TUnit.AspNetCore.Analyzers/Resources.Designer.cs
new file mode 100644
index 0000000000..de3968cd70
--- /dev/null
+++ b/TUnit.AspNetCore.Analyzers/Resources.Designer.cs
@@ -0,0 +1,112 @@
+//------------------------------------------------------------------------------
+//
+// This code was generated by a tool.
+//
+// Changes to this file may cause incorrect behavior and will be lost if
+// the code is regenerated.
+//
+//------------------------------------------------------------------------------
+
+namespace TUnit.AspNetCore.Analyzers {
+ using System;
+
+
+ ///
+ /// A strongly-typed resource class, for looking up localized strings, etc.
+ ///
+ [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")]
+ [global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
+ [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
+ internal class Resources {
+
+ private static global::System.Resources.ResourceManager resourceMan;
+
+ private static global::System.Globalization.CultureInfo resourceCulture;
+
+ [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")]
+ internal Resources() {
+ }
+
+ ///
+ /// Returns the cached ResourceManager instance used by this class.
+ ///
+ [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
+ internal static global::System.Resources.ResourceManager ResourceManager {
+ get {
+ if (object.ReferenceEquals(resourceMan, null)) {
+ global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("TUnit.AspNetCore.Analyzers.Resources", typeof(Resources).Assembly);
+ resourceMan = temp;
+ }
+ return resourceMan;
+ }
+ }
+
+ ///
+ /// Overrides the current thread's CurrentUICulture property for all
+ /// resource lookups using this strongly typed resource class.
+ ///
+ [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
+ internal static global::System.Globalization.CultureInfo Culture {
+ get {
+ return resourceCulture;
+ }
+ set {
+ resourceCulture = value;
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to The Factory, Services, and HttpCapture properties are not available in constructors or SetupAsync...
+ ///
+ internal static string TUnit0062Description {
+ get {
+ return ResourceManager.GetString("TUnit0062Description", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to '{0}' cannot be accessed in {1}. It is not initialized until after SetupAsync completes.
+ ///
+ internal static string TUnit0062MessageFormat {
+ get {
+ return ResourceManager.GetString("TUnit0062MessageFormat", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Factory property accessed before initialization.
+ ///
+ internal static string TUnit0062Title {
+ get {
+ return ResourceManager.GetString("TUnit0062Title", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Do not access Services, Server, or CreateClient on GlobalFactory directly...
+ ///
+ internal static string TUnit0063Description {
+ get {
+ return ResourceManager.GetString("TUnit0063Description", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Do not access '{0}' on GlobalFactory. Use 'Factory.{0}' instead to ensure test isolation.
+ ///
+ internal static string TUnit0063MessageFormat {
+ get {
+ return ResourceManager.GetString("TUnit0063MessageFormat", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to GlobalFactory member access breaks test isolation.
+ ///
+ internal static string TUnit0063Title {
+ get {
+ return ResourceManager.GetString("TUnit0063Title", resourceCulture);
+ }
+ }
+ }
+}
diff --git a/TUnit.AspNetCore.Analyzers/Resources.resx b/TUnit.AspNetCore.Analyzers/Resources.resx
new file mode 100644
index 0000000000..f749a357ac
--- /dev/null
+++ b/TUnit.AspNetCore.Analyzers/Resources.resx
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+
+
+
+ text/microsoft-resx
+
+
+ 1.3
+
+
+ System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ The Factory, Services, and HttpCapture properties are not available in constructors or SetupAsync. These properties are initialized in the [Before(HookType.Test)] hook which runs after SetupAsync. Access these properties only in test methods or [Before(HookType.Test)]/[After(HookType.Test)] hooks.
+
+
+ '{0}' cannot be accessed in {1}. It is not initialized until after SetupAsync completes.
+
+
+ Factory property accessed before initialization
+
+
+ Do not access Services, Server, or CreateClient on GlobalFactory directly. GlobalFactory is a shared template - use the Factory property instead, which provides an isolated instance for each test.
+
+
+ Do not access '{0}' on GlobalFactory. Use 'Factory.{0}' instead to ensure test isolation.
+
+
+ GlobalFactory member access breaks test isolation
+
+
diff --git a/TUnit.AspNetCore.Analyzers/Rules.cs b/TUnit.AspNetCore.Analyzers/Rules.cs
new file mode 100644
index 0000000000..aeccd1bc44
--- /dev/null
+++ b/TUnit.AspNetCore.Analyzers/Rules.cs
@@ -0,0 +1,30 @@
+using Microsoft.CodeAnalysis;
+
+namespace TUnit.AspNetCore.Analyzers;
+
+public static class Rules
+{
+ private const string UsageCategory = "Usage";
+
+ public static readonly DiagnosticDescriptor FactoryAccessedTooEarly =
+ CreateDescriptor("TUnit0062", UsageCategory, DiagnosticSeverity.Error);
+
+ public static readonly DiagnosticDescriptor GlobalFactoryMemberAccess =
+ CreateDescriptor("TUnit0063", UsageCategory, DiagnosticSeverity.Error);
+
+ private static DiagnosticDescriptor CreateDescriptor(string diagnosticId, string category, DiagnosticSeverity severity)
+ {
+ return new DiagnosticDescriptor(
+ id: diagnosticId,
+ title: new LocalizableResourceString(diagnosticId + "Title",
+ Resources.ResourceManager, typeof(Resources)),
+ messageFormat: new LocalizableResourceString(diagnosticId + "MessageFormat", Resources.ResourceManager,
+ typeof(Resources)),
+ category: category,
+ defaultSeverity: severity,
+ isEnabledByDefault: true,
+ description: new LocalizableResourceString(diagnosticId + "Description", Resources.ResourceManager,
+ typeof(Resources))
+ );
+ }
+}
diff --git a/TUnit.AspNetCore.Analyzers/TUnit.AspNetCore.Analyzers.csproj b/TUnit.AspNetCore.Analyzers/TUnit.AspNetCore.Analyzers.csproj
new file mode 100644
index 0000000000..ba541a0f23
--- /dev/null
+++ b/TUnit.AspNetCore.Analyzers/TUnit.AspNetCore.Analyzers.csproj
@@ -0,0 +1,47 @@
+
+
+
+
+
+ netstandard2.0
+ enable
+ latest
+ true
+ true
+ false
+ true
+ TUnit.AspNetCore.Analyzers
+ TUnit.AspNetCore.Analyzers
+ RS2003
+ false
+ false
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+ ResXFileCodeGenerator
+ Resources.Designer.cs
+
+
+
+
+ True
+ True
+ Resources.resx
+
+
+
+
+
+
+
+
diff --git a/TUnit.AspNetCore.Analyzers/WebApplicationFactoryAccessAnalyzer.cs b/TUnit.AspNetCore.Analyzers/WebApplicationFactoryAccessAnalyzer.cs
new file mode 100644
index 0000000000..ab7ce672c4
--- /dev/null
+++ b/TUnit.AspNetCore.Analyzers/WebApplicationFactoryAccessAnalyzer.cs
@@ -0,0 +1,203 @@
+using System.Collections.Immutable;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+using Microsoft.CodeAnalysis.Diagnostics;
+using Microsoft.CodeAnalysis.Operations;
+
+namespace TUnit.AspNetCore.Analyzers;
+
+[DiagnosticAnalyzer(LanguageNames.CSharp)]
+public class WebApplicationFactoryAccessAnalyzer : ConcurrentDiagnosticAnalyzer
+{
+ // Properties not available in constructors OR SetupAsync (initialized in Before hook)
+ private static readonly ImmutableHashSet RestrictedInConstructorAndSetup = ImmutableHashSet.Create(
+ "Factory",
+ "Services",
+ "HttpCapture"
+ );
+
+ // Properties not available in constructors only (available after property injection, before SetupAsync)
+ private static readonly ImmutableHashSet RestrictedInConstructorOnly = ImmutableHashSet.Create(
+ "GlobalFactory"
+ );
+
+ // Members that should never be accessed on GlobalFactory (breaks test isolation)
+ private static readonly ImmutableHashSet RestrictedGlobalFactoryMembers = ImmutableHashSet.Create(
+ "Services",
+ "Server",
+ "CreateClient",
+ "CreateDefaultClient"
+ );
+
+ public override ImmutableArray SupportedDiagnostics { get; } =
+ ImmutableArray.Create(Rules.FactoryAccessedTooEarly, Rules.GlobalFactoryMemberAccess);
+
+ protected override void InitializeInternal(AnalysisContext context)
+ {
+ context.RegisterOperationAction(AnalyzePropertyReference, OperationKind.PropertyReference);
+ context.RegisterOperationAction(AnalyzeInvocation, OperationKind.Invocation);
+ }
+
+ private void AnalyzePropertyReference(OperationAnalysisContext context)
+ {
+ if (context.Operation is not IPropertyReferenceOperation propertyReference)
+ {
+ return;
+ }
+
+ var propertyName = propertyReference.Property.Name;
+
+ // Check for GlobalFactory.Services or GlobalFactory.Server access
+ if (RestrictedGlobalFactoryMembers.Contains(propertyName) &&
+ IsGlobalFactoryAccess(propertyReference.Instance))
+ {
+ context.ReportDiagnostic(Diagnostic.Create(
+ Rules.GlobalFactoryMemberAccess,
+ context.Operation.Syntax.GetLocation(),
+ propertyName));
+ return;
+ }
+
+ var isRestrictedInBoth = RestrictedInConstructorAndSetup.Contains(propertyName);
+ var isRestrictedInConstructorOnly = RestrictedInConstructorOnly.Contains(propertyName);
+
+ if (!isRestrictedInBoth && !isRestrictedInConstructorOnly)
+ {
+ return;
+ }
+
+ // Check if this property belongs to WebApplicationTest or a derived type
+ var containingType = propertyReference.Property.ContainingType;
+ if (!IsWebApplicationTestType(containingType))
+ {
+ return;
+ }
+
+ // Check if we're in a constructor or SetupAsync method
+ var containingMethod = GetContainingMethod(context.Operation);
+ if (containingMethod == null)
+ {
+ return;
+ }
+
+ string? contextName = null;
+
+ if (containingMethod.MethodKind == MethodKind.Constructor)
+ {
+ // All restricted properties are invalid in constructor
+ contextName = "constructor";
+ }
+ else if (containingMethod.Name == "SetupAsync" && containingMethod.IsOverride)
+ {
+ // Only Factory/Services/HttpCapture are invalid in SetupAsync
+ // GlobalFactory IS available in SetupAsync
+ if (isRestrictedInBoth)
+ {
+ contextName = "SetupAsync";
+ }
+ }
+
+ if (contextName != null)
+ {
+ context.ReportDiagnostic(Diagnostic.Create(
+ Rules.FactoryAccessedTooEarly,
+ context.Operation.Syntax.GetLocation(),
+ propertyName,
+ contextName));
+ }
+ }
+
+ private void AnalyzeInvocation(OperationAnalysisContext context)
+ {
+ if (context.Operation is not IInvocationOperation invocation)
+ {
+ return;
+ }
+
+ var methodName = invocation.TargetMethod.Name;
+
+ // Check for GlobalFactory.CreateClient() access
+ if (RestrictedGlobalFactoryMembers.Contains(methodName) &&
+ IsGlobalFactoryAccess(invocation.Instance))
+ {
+ context.ReportDiagnostic(Diagnostic.Create(
+ Rules.GlobalFactoryMemberAccess,
+ context.Operation.Syntax.GetLocation(),
+ methodName));
+ }
+ }
+
+ private static bool IsGlobalFactoryAccess(IOperation? instance)
+ {
+ if (instance is not IPropertyReferenceOperation propertyRef)
+ {
+ return false;
+ }
+
+ // Check if accessing GlobalFactory property on a WebApplicationTest type
+ if (propertyRef.Property.Name != "GlobalFactory")
+ {
+ return false;
+ }
+
+ return IsWebApplicationTestType(propertyRef.Property.ContainingType);
+ }
+
+ private static bool IsWebApplicationTestType(INamedTypeSymbol? type)
+ {
+ while (type != null)
+ {
+ var typeName = type.Name;
+ var namespaceName = type.ContainingNamespace?.ToDisplayString();
+
+ // Check for WebApplicationTest or WebApplicationTest
+ if (typeName == "WebApplicationTest" && namespaceName == "TUnit.AspNetCore")
+ {
+ return true;
+ }
+
+ // Also check the generic version
+ if (type.OriginalDefinition?.Name == "WebApplicationTest" &&
+ type.OriginalDefinition.ContainingNamespace?.ToDisplayString() == "TUnit.AspNetCore")
+ {
+ return true;
+ }
+
+ type = type.BaseType;
+ }
+
+ return false;
+ }
+
+ private static IMethodSymbol? GetContainingMethod(IOperation operation)
+ {
+ var current = operation;
+ while (current != null)
+ {
+ if (current is IMethodBodyOperation or IBlockOperation)
+ {
+ // Get the semantic model to find the containing method
+ var syntax = current.Syntax;
+ while (syntax != null)
+ {
+ if (syntax is MethodDeclarationSyntax or ConstructorDeclarationSyntax)
+ {
+ var semanticModel = operation.SemanticModel;
+ if (semanticModel != null)
+ {
+ var symbol = semanticModel.GetDeclaredSymbol(syntax);
+ if (symbol is IMethodSymbol methodSymbol)
+ {
+ return methodSymbol;
+ }
+ }
+ }
+ syntax = syntax.Parent;
+ }
+ }
+ current = current.Parent;
+ }
+
+ return null;
+ }
+}
diff --git a/TUnit.AspNetCore/TUnit.AspNetCore.csproj b/TUnit.AspNetCore/TUnit.AspNetCore.csproj
index d2f9702d99..03e41e5ad3 100644
--- a/TUnit.AspNetCore/TUnit.AspNetCore.csproj
+++ b/TUnit.AspNetCore/TUnit.AspNetCore.csproj
@@ -27,9 +27,26 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/TUnit.AspNetCore/TestWebApplicationFactory.cs b/TUnit.AspNetCore/TestWebApplicationFactory.cs
index 39ce8380f0..f489c846ea 100644
--- a/TUnit.AspNetCore/TestWebApplicationFactory.cs
+++ b/TUnit.AspNetCore/TestWebApplicationFactory.cs
@@ -27,8 +27,13 @@ public WebApplicationFactory GetIsolatedFactory(
configureWebHostBuilder?.Invoke(builder);
// Then apply standard configuration
- builder.ConfigureTestServices(configureServices)
- .ConfigureAppConfiguration(configureConfiguration);
+ builder
+ .ConfigureAppConfiguration(configureConfiguration)
+ .ConfigureTestServices(services =>
+ {
+ configureServices(services);
+ services.AddSingleton(testContext);
+ });
if (options.EnableHttpExchangeCapture)
{
diff --git a/TUnit.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/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/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/EngineCancellationToken.cs b/TUnit.Core/EngineCancellationToken.cs
index 395cc7c5dd..ea3250db16 100644
--- a/TUnit.Core/EngineCancellationToken.cs
+++ b/TUnit.Core/EngineCancellationToken.cs
@@ -14,8 +14,7 @@ public class EngineCancellationToken : IDisposable
/// Gets the cancellation token.
///
public CancellationToken Token { get; private set; }
-
- private CancellationTokenSource? _forcefulExitCts;
+
private volatile bool _forcefulExitStarted;
///
@@ -27,6 +26,8 @@ internal void Initialise(CancellationToken cancellationToken)
CancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
Token = CancellationTokenSource.Token;
+ Token.Register(_ => Cancel(), this);
+
// Console.CancelKeyPress is not supported on browser platforms
#if NET5_0_OR_GREATER
if (!OperatingSystem.IsBrowser())
@@ -38,8 +39,16 @@ internal void Initialise(CancellationToken cancellationToken)
}
#endif
}
-
+
private void OnCancelKeyPress(object? sender, ConsoleCancelEventArgs e)
+ {
+ Cancel();
+
+ // Prevent the default behavior (immediate termination)
+ e.Cancel = true;
+ }
+
+ private void Cancel()
{
// Cancel the test execution
if (!CancellationTokenSource.IsCancellationRequested)
@@ -51,14 +60,9 @@ private void OnCancelKeyPress(object? sender, ConsoleCancelEventArgs e)
if (!_forcefulExitStarted)
{
_forcefulExitStarted = true;
-
- // Cancel any previous forceful exit timer
- _forcefulExitCts?.Cancel();
- _forcefulExitCts?.Dispose();
- _forcefulExitCts = new CancellationTokenSource();
-
+
// Start a new forceful exit timer
- _ = Task.Delay(TimeSpan.FromSeconds(10), _forcefulExitCts.Token).ContinueWith(t =>
+ _ = Task.Delay(TimeSpan.FromSeconds(30), CancellationToken.None).ContinueWith(t =>
{
if (!t.IsCanceled)
{
@@ -67,9 +71,6 @@ private void OnCancelKeyPress(object? sender, ConsoleCancelEventArgs e)
}
}, TaskScheduler.Default);
}
-
- // Prevent the default behavior (immediate termination)
- e.Cancel = true;
}
private void OnProcessExit(object? sender, EventArgs e)
@@ -102,8 +103,6 @@ public void Dispose()
#if NET5_0_OR_GREATER
}
#endif
- _forcefulExitCts?.Cancel();
- _forcefulExitCts?.Dispose();
CancellationTokenSource.Dispose();
}
}
diff --git a/TUnit.Core/Helpers/ArgumentFormatter.cs b/TUnit.Core/Helpers/ArgumentFormatter.cs
index 15c11f3333..15e7c3cf60 100644
--- a/TUnit.Core/Helpers/ArgumentFormatter.cs
+++ b/TUnit.Core/Helpers/ArgumentFormatter.cs
@@ -90,27 +90,7 @@ private static string FormatDefault(object? o)
if (o is string str)
{
// Replace dots with middle dot (·) to prevent VS Test Explorer from interpreting them as namespace separators
- // Only do this if the string contains dots, to avoid unnecessary allocations
- if (!str.Contains('.'))
- {
- return str;
- }
-
-#if NET8_0_OR_GREATER
- // Use Span for better performance - avoid string.Replace allocation
- Span buffer = str.Length <= 256
- ? stackalloc char[str.Length]
- : new char[str.Length];
-
- for (int i = 0; i < str.Length; i++)
- {
- buffer[i] = str[i] == '.' ? '·' : str[i];
- }
-
- return new string(buffer);
-#else
return str.Replace(".", "·");
-#endif
}
if (toString == type.FullName || toString == type.AssemblyQualifiedName)
diff --git a/TUnit.Core/Logging/DefaultLogger.cs b/TUnit.Core/Logging/DefaultLogger.cs
index 76fc7c18e1..9479873ec2 100644
--- a/TUnit.Core/Logging/DefaultLogger.cs
+++ b/TUnit.Core/Logging/DefaultLogger.cs
@@ -7,6 +7,11 @@ public class DefaultLogger(Context context) : TUnitLogger
{
private readonly ConcurrentDictionary> _values = new();
+ ///
+ /// Gets the context associated with this logger.
+ ///
+ protected Context Context => context;
+
public void PushProperties(IDictionary> dictionary)
{
foreach (var keyValuePair in dictionary)
@@ -52,16 +57,7 @@ public override async ValueTask LogAsync(LogLevel logLevel, TState state
var message = GenerateMessage(formatter(state, exception), exception, logLevel);
- if (logLevel >= LogLevel.Error)
- {
- await context.ErrorOutputWriter.WriteLineAsync(message);
- await GlobalContext.Current.OriginalConsoleError.WriteLineAsync(message);
- }
- else
- {
- await context.OutputWriter.WriteLineAsync(message);
- await GlobalContext.Current.OriginalConsoleOut.WriteLineAsync(message);
- }
+ await WriteToOutputAsync(message, logLevel >= LogLevel.Error);
}
public override void Log(LogLevel logLevel, TState state, Exception? exception, Func formatter)
@@ -73,19 +69,18 @@ public override void Log(LogLevel logLevel, TState state, Exception? exc
var message = GenerateMessage(formatter(state, exception), exception, logLevel);
- if (logLevel >= LogLevel.Error)
- {
- context.ErrorOutputWriter.WriteLine(message);
- GlobalContext.Current.OriginalConsoleError.WriteLine(message);
- }
- else
- {
- context.OutputWriter.WriteLine(message);
- GlobalContext.Current.OriginalConsoleOut.WriteLine(message);
- }
+ WriteToOutput(message, logLevel >= LogLevel.Error);
}
- private string GenerateMessage(string message, Exception? exception, LogLevel logLevel)
+ ///
+ /// Generates the formatted message to be logged.
+ /// Override this method to customize the message format.
+ ///
+ /// The message to log.
+ /// The exception associated with this log entry, if any.
+ /// The log level.
+ /// The formatted message.
+ protected virtual string GenerateMessage(string message, Exception? exception, LogLevel logLevel)
{
var stringBuilder = new StringBuilder();
@@ -120,4 +115,45 @@ private string GenerateMessage(string message, Exception? exception, LogLevel lo
return builtString;
}
+
+ ///
+ /// Writes the message to the output.
+ /// Override this method to customize how messages are written.
+ ///
+ /// The formatted message to write.
+ /// True if this is an error-level message.
+ protected virtual void WriteToOutput(string message, bool isError)
+ {
+ if (isError)
+ {
+ context.ErrorOutputWriter.WriteLine(message);
+ GlobalContext.Current.OriginalConsoleError.WriteLine(message);
+ }
+ else
+ {
+ context.OutputWriter.WriteLine(message);
+ GlobalContext.Current.OriginalConsoleOut.WriteLine(message);
+ }
+ }
+
+ ///
+ /// Asynchronously writes the message to the output.
+ /// Override this method to customize how messages are written.
+ ///
+ /// The formatted message to write.
+ /// True if this is an error-level message.
+ /// A task representing the async operation.
+ protected virtual async ValueTask WriteToOutputAsync(string message, bool isError)
+ {
+ if (isError)
+ {
+ await context.ErrorOutputWriter.WriteLineAsync(message);
+ await GlobalContext.Current.OriginalConsoleError.WriteLineAsync(message);
+ }
+ else
+ {
+ await context.OutputWriter.WriteLineAsync(message);
+ await GlobalContext.Current.OriginalConsoleOut.WriteLineAsync(message);
+ }
+ }
}
diff --git a/TUnit.Core/Models/TestModels/ParameterMetadata.cs b/TUnit.Core/Models/TestModels/ParameterMetadata.cs
index 6816a4e497..f3b24c53e4 100644
--- a/TUnit.Core/Models/TestModels/ParameterMetadata.cs
+++ b/TUnit.Core/Models/TestModels/ParameterMetadata.cs
@@ -56,9 +56,10 @@ public record ParameterMetadata([DynamicallyAccessedMembers(DynamicallyAccessedM
/// Cached data source attributes to avoid reflection call.
/// Set by source generator for AOT compatibility.
/// When null, falls back to using ReflectionInfo.GetCustomAttributes().
+ /// Includes attributes implementing IDataSourceAttribute or IDataSourceMemberAttribute.
///
[EditorBrowsable(EditorBrowsableState.Never)]
- public IDataSourceAttribute[]? CachedDataSourceAttributes { get; internal init; }
+ public Attribute[]? CachedDataSourceAttributes { get; internal init; }
///
/// Position of this parameter in the method/constructor signature.
diff --git a/TUnit.Engine.Tests/MatrixTests.cs b/TUnit.Engine.Tests/MatrixTests.cs
index 0daaafa2ec..d50a5bda37 100644
--- a/TUnit.Engine.Tests/MatrixTests.cs
+++ b/TUnit.Engine.Tests/MatrixTests.cs
@@ -8,7 +8,7 @@ public class MatrixTests(TestMode testMode) : InvokableTestBase(testMode)
[Test]
public async Task Test()
{
- var expectedCount = IsNetFramework ? 133 : 271;
+ var expectedCount = IsNetFramework ? 137 : 275;
await RunTestsWithFilter(
"/*/*/MatrixTests/*",
diff --git a/TUnit.Example.Asp.Net.TestProject/TUnit.Example.Asp.Net.TestProject.csproj b/TUnit.Example.Asp.Net.TestProject/TUnit.Example.Asp.Net.TestProject.csproj
index 7b3f975755..e22918675d 100644
--- a/TUnit.Example.Asp.Net.TestProject/TUnit.Example.Asp.Net.TestProject.csproj
+++ b/TUnit.Example.Asp.Net.TestProject/TUnit.Example.Asp.Net.TestProject.csproj
@@ -1,17 +1,19 @@
-
+
net10.0
+
+
-
+
@@ -19,7 +21,7 @@
-
+
diff --git a/TUnit.Example.Asp.Net.TestProject/WebApplicationFactory.cs b/TUnit.Example.Asp.Net.TestProject/WebApplicationFactory.cs
index 35bb1e4a85..63ae7cf69c 100644
--- a/TUnit.Example.Asp.Net.TestProject/WebApplicationFactory.cs
+++ b/TUnit.Example.Asp.Net.TestProject/WebApplicationFactory.cs
@@ -1,8 +1,6 @@
-using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using TUnit.AspNetCore;
-using TUnit.Core.Interfaces;
namespace TUnit.Example.Asp.Net.TestProject;
@@ -14,7 +12,7 @@ namespace TUnit.Example.Asp.Net.TestProject;
/// Instead, test classes inject containers and provide configuration overrides
/// via OverrideConfigurationAsync. This allows the factory to be created with new().
///
-public class WebApplicationFactory : TestWebApplicationFactory, IAsyncInitializer
+public class WebApplicationFactory : TestWebApplicationFactory
{
private int _configuredWebHostCalled;
@@ -38,13 +36,6 @@ public class WebApplicationFactory : TestWebApplicationFactory, IAsyncI
[ClassDataSource(Shared = SharedType.PerTestSession)]
public InMemoryKafka Kafka { get; init; } = null!;
-
- public Task InitializeAsync()
- {
- _ = Server;
- return Task.CompletedTask;
- }
-
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
Interlocked.Increment(ref _configuredWebHostCalled);
diff --git a/TUnit.Pipeline/Modules/RunAspNetCoreAnalyzersTestsModule.cs b/TUnit.Pipeline/Modules/RunAspNetCoreAnalyzersTestsModule.cs
new file mode 100644
index 0000000000..921b8b8a51
--- /dev/null
+++ b/TUnit.Pipeline/Modules/RunAspNetCoreAnalyzersTestsModule.cs
@@ -0,0 +1,34 @@
+using ModularPipelines.Attributes;
+using ModularPipelines.Context;
+using ModularPipelines.DotNet.Extensions;
+using ModularPipelines.DotNet.Options;
+using ModularPipelines.Enums;
+using ModularPipelines.Extensions;
+using ModularPipelines.Git.Extensions;
+using ModularPipelines.Models;
+using ModularPipelines.Modules;
+
+namespace TUnit.Pipeline.Modules;
+
+[NotInParallel("DotNetTests")]
+public class RunAspNetCoreAnalyzersTestsModule : Module
+{
+ protected override async Task ExecuteAsync(IPipelineContext context, CancellationToken cancellationToken)
+ {
+ var project = context.Git().RootDirectory.FindFile(x => x.Name == "TUnit.AspNetCore.Analyzers.Tests.csproj").AssertExists();
+
+ return await context.DotNet().Test(new DotNetTestOptions
+ {
+ WorkingDirectory = project.Folder!,
+ NoBuild = true,
+ Configuration = Configuration.Release,
+ Framework = "net9.0",
+ Arguments = ["--", "--hangdump", "--hangdump-filename", "hangdump.aspnetcore-analyzers-tests.dmp", "--hangdump-timeout", "5m"],
+ EnvironmentVariables = new Dictionary
+ {
+ ["DISABLE_GITHUB_REPORTER"] = "true",
+ },
+ CommandLogging = CommandLogging.Input | CommandLogging.Error | CommandLogging.Duration | CommandLogging.ExitCode
+ }, cancellationToken);
+ }
+}
diff --git a/TUnit.Pipeline/Modules/TestAspNetCoreNugetPackageModule.cs b/TUnit.Pipeline/Modules/TestAspNetCoreNugetPackageModule.cs
new file mode 100644
index 0000000000..ff6acab658
--- /dev/null
+++ b/TUnit.Pipeline/Modules/TestAspNetCoreNugetPackageModule.cs
@@ -0,0 +1,51 @@
+using ModularPipelines.Attributes;
+using ModularPipelines.Context;
+using ModularPipelines.DotNet.Options;
+using ModularPipelines.Extensions;
+using ModularPipelines.Git.Extensions;
+using ModularPipelines.Models;
+using Polly.Retry;
+using TUnit.Pipeline.Modules.Abstract;
+
+namespace TUnit.Pipeline.Modules;
+
+[DependsOn]
+[DependsOn]
+public class TestAspNetCoreNugetPackageModule : TestBaseModule
+{
+ protected override AsyncRetryPolicy?> RetryPolicy
+ => CreateRetryPolicy(3);
+
+ // ASP.NET Core only supports .NET Core frameworks, not .NET Framework
+ protected override IEnumerable TestableFrameworks
+ {
+ get
+ {
+ yield return "net10.0";
+ yield return "net9.0";
+ yield return "net8.0";
+ }
+ }
+
+ protected override async Task GetTestOptions(IPipelineContext context, string framework,
+ CancellationToken cancellationToken)
+ {
+ var version = await GetModule();
+
+ var project = context.Git()
+ .RootDirectory
+ .AssertExists()
+ .FindFile(x => x.Name == "TUnit.AspNetCore.NugetTester.csproj")
+ .AssertExists();
+
+ return new DotNetRunOptions
+ {
+ WorkingDirectory = project.Folder!,
+ Framework = framework,
+ Properties =
+ [
+ new KeyValue("TUnitVersion", version.Value!.SemVer!)
+ ]
+ };
+ }
+}
diff --git a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet10_0.verified.txt b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet10_0.verified.txt
index 41743ad69d..193caf076a 100644
--- a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet10_0.verified.txt
+++ b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet10_0.verified.txt
@@ -596,6 +596,7 @@ namespace .Conditions
protected override .<.> CheckAsync(. metadata) { }
protected override string GetExpectation() { }
public . Using(. comparer) { }
+ public . Using( equalityPredicate) { }
}
public class DictionaryDoesNotContainKeyAssertion : .
where TDictionary : .
@@ -840,6 +841,7 @@ namespace .Conditions
protected override .<.> CheckAsync(. metadata) { }
protected override string GetExpectation() { }
public . Using(. comparer) { }
+ public . Using( equalityPredicate) { }
}
[.("IsEquivalentTo")]
public class IsEquivalentToAssertion : .
@@ -852,6 +854,7 @@ namespace .Conditions
protected override .<.> CheckAsync(. metadata) { }
protected override string GetExpectation() { }
public . Using(. comparer) { }
+ public . Using( equalityPredicate) { }
}
public class IsNotAssignableToAssertion : .
{
@@ -953,6 +956,7 @@ namespace .Conditions
protected override .<.> CheckAsync(. metadata) { }
protected override string GetExpectation() { }
public . Using(. comparer) { }
+ public . Using( equalityPredicate) { }
}
public class NotNullAssertion : .
{
diff --git a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet8_0.verified.txt b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet8_0.verified.txt
index 90595a8661..cdf708e41b 100644
--- a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet8_0.verified.txt
+++ b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet8_0.verified.txt
@@ -591,6 +591,7 @@ namespace .Conditions
protected override .<.> CheckAsync(. metadata) { }
protected override string GetExpectation() { }
public . Using(. comparer) { }
+ public . Using( equalityPredicate) { }
}
public class DictionaryDoesNotContainKeyAssertion : .
where TDictionary : .
@@ -835,6 +836,7 @@ namespace .Conditions
protected override .<.> CheckAsync(. metadata) { }
protected override string GetExpectation() { }
public . Using(. comparer) { }
+ public . Using( equalityPredicate) { }
}
[.("IsEquivalentTo")]
public class IsEquivalentToAssertion : .
@@ -847,6 +849,7 @@ namespace .Conditions
protected override .<.> CheckAsync(. metadata) { }
protected override string GetExpectation() { }
public . Using(. comparer) { }
+ public . Using( equalityPredicate) { }
}
public class IsNotAssignableToAssertion : .
{
@@ -948,6 +951,7 @@ namespace .Conditions
protected override .<.> CheckAsync(. metadata) { }
protected override string GetExpectation() { }
public . Using(. comparer) { }
+ public . Using( equalityPredicate) { }
}
public class NotNullAssertion : .
{
diff --git a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet9_0.verified.txt b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet9_0.verified.txt
index 32e1ddc218..de701714b4 100644
--- a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet9_0.verified.txt
+++ b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet9_0.verified.txt
@@ -596,6 +596,7 @@ namespace .Conditions
protected override .<.> CheckAsync(. metadata) { }
protected override string GetExpectation() { }
public . Using(. comparer) { }
+ public . Using( equalityPredicate) { }
}
public class DictionaryDoesNotContainKeyAssertion : .
where TDictionary : .
@@ -840,6 +841,7 @@ namespace .Conditions
protected override .<.> CheckAsync(. metadata) { }
protected override string GetExpectation() { }
public . Using(. comparer) { }
+ public . Using( equalityPredicate) { }
}
[.("IsEquivalentTo")]
public class IsEquivalentToAssertion : .
@@ -852,6 +854,7 @@ namespace .Conditions
protected override .<.> CheckAsync(. metadata) { }
protected override string GetExpectation() { }
public . Using(. comparer) { }
+ public . Using( equalityPredicate) { }
}
public class IsNotAssignableToAssertion : .
{
@@ -953,6 +956,7 @@ namespace .Conditions
protected override .<.> CheckAsync(. metadata) { }
protected override string GetExpectation() { }
public . Using(. comparer) { }
+ public . Using( equalityPredicate) { }
}
public class NotNullAssertion : .
{
diff --git a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.Net4_7.verified.txt b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.Net4_7.verified.txt
index 0ceb47da88..702517902b 100644
--- a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.Net4_7.verified.txt
+++ b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.Net4_7.verified.txt
@@ -575,6 +575,7 @@ namespace .Conditions
protected override .<.> CheckAsync(. metadata) { }
protected override string GetExpectation() { }
public . Using(. comparer) { }
+ public . Using( equalityPredicate) { }
}
public class DictionaryDoesNotContainKeyAssertion : .
where TDictionary : .
@@ -810,6 +811,7 @@ namespace .Conditions
protected override .<.> CheckAsync(. metadata) { }
protected override string GetExpectation() { }
public . Using(. comparer) { }
+ public . Using( equalityPredicate) { }
}
[.("IsEquivalentTo")]
public class IsEquivalentToAssertion : .
@@ -820,6 +822,7 @@ namespace .Conditions
protected override .<.> CheckAsync(. metadata) { }
protected override string GetExpectation() { }
public . Using(. comparer) { }
+ public . Using( equalityPredicate) { }
}
public class IsNotAssignableToAssertion : .
{
@@ -919,6 +922,7 @@ namespace .Conditions
protected override .<.> CheckAsync(. metadata) { }
protected override string GetExpectation() { }
public . Using(. comparer) { }
+ public . Using( equalityPredicate) { }
}
public class NotNullAssertion : .
{
diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt
index 316cd35c04..23efc08b5c 100644
--- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt
+++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt
@@ -864,6 +864,7 @@ namespace
bool SkipIfEmpty { get; set; }
.<<.