diff --git a/.github/scripts/process-benchmarks.js b/.github/scripts/process-benchmarks.js
index 8852efabbf..a558c9ac5b 100644
--- a/.github/scripts/process-benchmarks.js
+++ b/.github/scripts/process-benchmarks.js
@@ -6,6 +6,15 @@ const BUILD_DIR = 'benchmark-results/build';
const OUTPUT_DIR = 'docs/docs/benchmarks';
const STATIC_DIR = 'docs/static/benchmarks';
+const RUNTIME_DESCRIPTIONS = {
+ AsyncTests: 'Realistic async/await patterns with I/O simulation',
+ DataDrivenTests: 'Parameterized tests with multiple data sources',
+ MassiveParallelTests: 'Parallel execution stress tests',
+ MatrixTests: 'Combinatorial test generation and execution',
+ ScaleTests: 'Large test suites (150+ tests) measuring scalability',
+ SetupTeardownTests: 'Expensive test fixtures with setup/teardown overhead',
+};
+
console.log('🚀 Processing benchmark results...\n');
// Ensure output directories exist
@@ -371,13 +380,13 @@ These benchmarks were automatically generated on **${timestamp}** from the lates
Click on any benchmark to view detailed results:
${Object.keys(categories.runtime).map(testClass =>
- `- [${testClass}](${testClass}) - Detailed performance analysis`
+ `- [${testClass}](./${testClass}.md)${RUNTIME_DESCRIPTIONS[testClass] ? ` — ${RUNTIME_DESCRIPTIONS[testClass]}` : ''}`
).join('\n')}
${Object.keys(categories.build).length > 0 ? `
## 🔨 Build Benchmarks
-- [Build Performance](BuildTime) - Compilation time comparison
+- [Build Performance](./BuildTime.md) - Compilation time comparison
` : ''}
---
diff --git a/.github/scripts/process-mock-benchmarks.js b/.github/scripts/process-mock-benchmarks.js
index da088e1280..c4529d5f9d 100644
--- a/.github/scripts/process-mock-benchmarks.js
+++ b/.github/scripts/process-mock-benchmarks.js
@@ -384,7 +384,7 @@ ${libraryTableRows}
Click on any benchmark to view detailed results:
${Object.keys(categories).map(category =>
- `- [${category}](${category}) - ${categoryDescriptions[category] || category}`
+ `- [${category}](./${category}.md) - ${categoryDescriptions[category] || category}`
).join('\n')}
## 📈 What's Measured
diff --git a/Directory.Packages.props b/Directory.Packages.props
index a59f8dc490..277ab48bda 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -10,7 +10,7 @@
-
+
@@ -65,6 +65,8 @@
+
+
@@ -98,14 +100,14 @@
-
+
-
-
-
-
-
+
+
+
+
+
diff --git a/TUnit.AspNetCore.Analyzers.CodeFixers/TUnit.AspNetCore.Analyzers.CodeFixers.csproj b/TUnit.AspNetCore.Analyzers.CodeFixers/TUnit.AspNetCore.Analyzers.CodeFixers.csproj
new file mode 100644
index 0000000000..efe5419b1d
--- /dev/null
+++ b/TUnit.AspNetCore.Analyzers.CodeFixers/TUnit.AspNetCore.Analyzers.CodeFixers.csproj
@@ -0,0 +1,37 @@
+
+
+ netstandard2.0
+ enable
+ latest
+ true
+ true
+ false
+ true
+ TUnit.AspNetCore.Analyzers.CodeFixers
+ TUnit.AspNetCore.Analyzers.CodeFixers
+ RS2003
+ false
+ false
+
+
+
+ <_Parameter1>TUnit.AspNetCore.Analyzers.Tests
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/TUnit.AspNetCore.Analyzers.CodeFixers/UseTestWebApplicationFactoryCodeFixProvider.cs b/TUnit.AspNetCore.Analyzers.CodeFixers/UseTestWebApplicationFactoryCodeFixProvider.cs
new file mode 100644
index 0000000000..0ea98d36ce
--- /dev/null
+++ b/TUnit.AspNetCore.Analyzers.CodeFixers/UseTestWebApplicationFactoryCodeFixProvider.cs
@@ -0,0 +1,128 @@
+using System.Collections.Immutable;
+using System.Composition;
+using System.Linq;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CodeActions;
+using Microsoft.CodeAnalysis.CodeFixes;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+using Microsoft.CodeAnalysis.Formatting;
+using Microsoft.CodeAnalysis.Simplification;
+using TUnit.AspNetCore.Analyzers;
+
+namespace TUnit.AspNetCore.Analyzers.CodeFixers;
+
+[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(UseTestWebApplicationFactoryCodeFixProvider)), Shared]
+public class UseTestWebApplicationFactoryCodeFixProvider : CodeFixProvider
+{
+ private const string Title = "Inherit from TestWebApplicationFactory";
+ private const string TestWebApplicationFactoryName = "TestWebApplicationFactory";
+ private const string TestWebApplicationFactoryNamespace = "TUnit.AspNetCore";
+
+ public sealed override ImmutableArray FixableDiagnosticIds { get; } =
+ ImmutableArray.Create(Rules.DirectWebApplicationFactoryInheritance.Id);
+
+ public sealed override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer;
+
+ public sealed override async Task RegisterCodeFixesAsync(CodeFixContext context)
+ {
+ var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false);
+ if (root is null)
+ {
+ return;
+ }
+
+ foreach (var diagnostic in context.Diagnostics)
+ {
+ var baseTypeSyntax = root
+ .FindNode(diagnostic.Location.SourceSpan, getInnermostNodeForTie: true)
+ .FirstAncestorOrSelf();
+
+ if (baseTypeSyntax is null)
+ {
+ continue;
+ }
+
+ context.RegisterCodeFix(
+ CodeAction.Create(
+ title: Title,
+ createChangedDocument: c => ReplaceBaseTypeAsync(context.Document, baseTypeSyntax, c),
+ equivalenceKey: Title),
+ diagnostic);
+ }
+ }
+
+ private static async Task ReplaceBaseTypeAsync(
+ Document document,
+ BaseTypeSyntax baseTypeSyntax,
+ CancellationToken cancellationToken)
+ {
+ var genericName = baseTypeSyntax.Type switch
+ {
+ GenericNameSyntax g => g,
+ QualifiedNameSyntax { Right: GenericNameSyntax q } => q,
+ AliasQualifiedNameSyntax { Name: GenericNameSyntax a } => a,
+ _ => null,
+ };
+
+ if (genericName is null)
+ {
+ return document;
+ }
+
+ var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false);
+ if (root is not CompilationUnitSyntax compilationUnit)
+ {
+ return document;
+ }
+
+ var newTypeName = SyntaxFactory.GenericName(SyntaxFactory.Identifier(TestWebApplicationFactoryName))
+ .WithTypeArgumentList(genericName.TypeArgumentList);
+
+ var newBaseType = baseTypeSyntax.WithType(newTypeName)
+ .WithTriviaFrom(baseTypeSyntax)
+ .WithAdditionalAnnotations(Simplifier.Annotation, Formatter.Annotation);
+
+ var newCompilationUnit = compilationUnit.ReplaceNode(baseTypeSyntax, newBaseType);
+ newCompilationUnit = AddUsingIfMissing(newCompilationUnit, TestWebApplicationFactoryNamespace);
+
+ return document.WithSyntaxRoot(newCompilationUnit);
+ }
+
+ private static CompilationUnitSyntax AddUsingIfMissing(CompilationUnitSyntax compilationUnit, string namespaceName)
+ {
+ if (ContainsUsing(compilationUnit.Usings, namespaceName) ||
+ compilationUnit.Members.Any(m => ContainsUsingInNamespace(m, namespaceName)))
+ {
+ return compilationUnit;
+ }
+
+ var newUsing = SyntaxFactory.UsingDirective(SyntaxFactory.ParseName(namespaceName))
+ .WithTrailingTrivia(SyntaxFactory.ElasticCarriageReturnLineFeed);
+
+ return compilationUnit.AddUsings(newUsing);
+ }
+
+ private static bool ContainsUsingInNamespace(MemberDeclarationSyntax member, string namespaceName) => member switch
+ {
+ BaseNamespaceDeclarationSyntax ns =>
+ ContainsUsing(ns.Usings, namespaceName) ||
+ ns.Members.Any(m => ContainsUsingInNamespace(m, namespaceName)),
+ _ => false,
+ };
+
+ private static bool ContainsUsing(SyntaxList usings, string namespaceName)
+ {
+ foreach (var directive in usings)
+ {
+ if (directive.Alias is null &&
+ directive.StaticKeyword.IsKind(SyntaxKind.None) &&
+ directive.Name?.ToString() == namespaceName)
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/TUnit.AspNetCore.Analyzers.Tests/DirectWebApplicationFactoryInheritanceAnalyzerTests.cs b/TUnit.AspNetCore.Analyzers.Tests/DirectWebApplicationFactoryInheritanceAnalyzerTests.cs
new file mode 100644
index 0000000000..d378fe67ba
--- /dev/null
+++ b/TUnit.AspNetCore.Analyzers.Tests/DirectWebApplicationFactoryInheritanceAnalyzerTests.cs
@@ -0,0 +1,86 @@
+using Verifier = TUnit.AspNetCore.Analyzers.Tests.Verifiers.CSharpAnalyzerVerifier;
+
+namespace TUnit.AspNetCore.Analyzers.Tests;
+
+public class DirectWebApplicationFactoryInheritanceAnalyzerTests
+{
+ [Test]
+ public async Task Warning_When_Direct_WebApplicationFactory_Inheritance()
+ {
+ await Verifier.VerifyAnalyzerAsync(
+ $$"""
+ {{WebApplicationFactoryStubs.Source}}
+
+ public class MyFactory : {|#0:Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactory|}
+ {
+ }
+ """,
+ Verifier.Diagnostic(Rules.DirectWebApplicationFactoryInheritance)
+ .WithLocation(0)
+ .WithArguments("MyFactory"));
+ }
+
+ [Test]
+ public async Task No_Warning_When_Using_TestWebApplicationFactory()
+ {
+ await Verifier.VerifyAnalyzerAsync(
+ $$"""
+ {{WebApplicationFactoryStubs.Source}}
+
+ public class MyFactory : TUnit.AspNetCore.TestWebApplicationFactory
+ {
+ }
+ """);
+ }
+
+ [Test]
+ public async Task No_Warning_When_Transitively_Inherits_Via_TestWebApplicationFactory()
+ {
+ await Verifier.VerifyAnalyzerAsync(
+ $$"""
+ {{WebApplicationFactoryStubs.Source}}
+
+ public class BaseFactory : TUnit.AspNetCore.TestWebApplicationFactory
+ {
+ }
+
+ public class MyFactory : BaseFactory
+ {
+ }
+ """);
+ }
+
+ [Test]
+ public async Task Warning_Fires_Once_For_Partial_Class()
+ {
+ await Verifier.VerifyAnalyzerAsync(
+ $$"""
+ {{WebApplicationFactoryStubs.Source}}
+
+ public partial class MyFactory : {|#0:Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactory|}
+ {
+ }
+
+ public partial class MyFactory
+ {
+ }
+ """,
+ Verifier.Diagnostic(Rules.DirectWebApplicationFactoryInheritance)
+ .WithLocation(0)
+ .WithArguments("MyFactory"));
+ }
+
+ [Test]
+ public async Task Warning_On_Base_Type_Location()
+ {
+ await Verifier.VerifyAnalyzerAsync(
+ $$"""
+ {{WebApplicationFactoryStubs.Source}}
+
+ public class A : {|#0:Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactory|} { }
+ public class B : {|#1:Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactory|} { }
+ """,
+ Verifier.Diagnostic(Rules.DirectWebApplicationFactoryInheritance).WithLocation(0).WithArguments("A"),
+ Verifier.Diagnostic(Rules.DirectWebApplicationFactoryInheritance).WithLocation(1).WithArguments("B"));
+ }
+}
diff --git a/TUnit.AspNetCore.Analyzers.Tests/TUnit.AspNetCore.Analyzers.Tests.csproj b/TUnit.AspNetCore.Analyzers.Tests/TUnit.AspNetCore.Analyzers.Tests.csproj
index e7bcc196d9..f08a2d3984 100644
--- a/TUnit.AspNetCore.Analyzers.Tests/TUnit.AspNetCore.Analyzers.Tests.csproj
+++ b/TUnit.AspNetCore.Analyzers.Tests/TUnit.AspNetCore.Analyzers.Tests.csproj
@@ -8,12 +8,14 @@
+
+
diff --git a/TUnit.AspNetCore.Analyzers.Tests/UseTestWebApplicationFactoryCodeFixProviderTests.cs b/TUnit.AspNetCore.Analyzers.Tests/UseTestWebApplicationFactoryCodeFixProviderTests.cs
new file mode 100644
index 0000000000..354a282a94
--- /dev/null
+++ b/TUnit.AspNetCore.Analyzers.Tests/UseTestWebApplicationFactoryCodeFixProviderTests.cs
@@ -0,0 +1,135 @@
+using Verifier = TUnit.AspNetCore.Analyzers.Tests.Verifiers.CSharpCodeFixVerifier<
+ TUnit.AspNetCore.Analyzers.DirectWebApplicationFactoryInheritanceAnalyzer,
+ TUnit.AspNetCore.Analyzers.CodeFixers.UseTestWebApplicationFactoryCodeFixProvider>;
+
+namespace TUnit.AspNetCore.Analyzers.Tests;
+
+public class UseTestWebApplicationFactoryCodeFixProviderTests
+{
+ [Test]
+ public async Task Does_Not_Duplicate_Existing_Using()
+ {
+ var source = $$"""
+ using TUnit.AspNetCore;
+ {{WebApplicationFactoryStubs.Source}}
+
+ public class MyFactory : {|#0:Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactory|}
+ {
+ }
+ """;
+
+ var fixedSource = $$"""
+ using TUnit.AspNetCore;
+ {{WebApplicationFactoryStubs.Source}}
+
+ public class MyFactory : TestWebApplicationFactory
+ {
+ }
+ """;
+
+ await Verifier.VerifyCodeFixAsync(
+ source,
+ fixedSource,
+ Verifier.Diagnostic(Rules.DirectWebApplicationFactoryInheritance)
+ .WithLocation(0)
+ .WithArguments("MyFactory"));
+ }
+
+ [Test]
+ public async Task Does_Not_Duplicate_Using_When_Imported_Inside_Namespace()
+ {
+ var source = $$"""
+ {{WebApplicationFactoryStubs.Source}}
+
+ namespace App
+ {
+ using TUnit.AspNetCore;
+
+ public class MyFactory : {|#0:Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactory|}
+ {
+ }
+ }
+ """;
+
+ var fixedSource = $$"""
+ {{WebApplicationFactoryStubs.Source}}
+
+ namespace App
+ {
+ using TUnit.AspNetCore;
+
+ public class MyFactory : TestWebApplicationFactory
+ {
+ }
+ }
+ """;
+
+ await Verifier.VerifyCodeFixAsync(
+ source,
+ fixedSource,
+ Verifier.Diagnostic(Rules.DirectWebApplicationFactoryInheritance)
+ .WithLocation(0)
+ .WithArguments("MyFactory"));
+ }
+
+ [Test]
+ public async Task Does_Not_Duplicate_Using_In_File_Scoped_Namespace()
+ {
+ var source = """
+ namespace App;
+
+ using TUnit.AspNetCore;
+
+ public class MyFactory : {|#0:Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactory|}
+ {
+ }
+ """;
+
+ var fixedSource = """
+ namespace App;
+
+ using TUnit.AspNetCore;
+
+ public class MyFactory : TestWebApplicationFactory
+ {
+ }
+ """;
+
+ await Verifier.VerifyCodeFixAsync(
+ source,
+ fixedSource,
+ stubsSource: WebApplicationFactoryStubs.Source,
+ Verifier.Diagnostic(Rules.DirectWebApplicationFactoryInheritance)
+ .WithLocation(0)
+ .WithArguments("MyFactory"));
+ }
+
+ [Test]
+ public async Task Rewrites_Base_Type_To_TestWebApplicationFactory()
+ {
+ var source = $$"""
+ {{WebApplicationFactoryStubs.Source}}
+
+ public class MyFactory : {|#0:Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactory|}
+ {
+ }
+ """;
+
+ var fixedSource = $$"""
+ using TUnit.AspNetCore;
+
+ {{WebApplicationFactoryStubs.Source}}
+
+ public class MyFactory : TestWebApplicationFactory
+ {
+ }
+ """;
+
+ await Verifier.VerifyCodeFixAsync(
+ source,
+ fixedSource,
+ Verifier.Diagnostic(Rules.DirectWebApplicationFactoryInheritance)
+ .WithLocation(0)
+ .WithArguments("MyFactory"));
+ }
+}
diff --git a/TUnit.AspNetCore.Analyzers.Tests/Verifiers/CSharpCodeFixVerifier.cs b/TUnit.AspNetCore.Analyzers.Tests/Verifiers/CSharpCodeFixVerifier.cs
new file mode 100644
index 0000000000..b7b4d9aaa1
--- /dev/null
+++ b/TUnit.AspNetCore.Analyzers.Tests/Verifiers/CSharpCodeFixVerifier.cs
@@ -0,0 +1,64 @@
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CodeFixes;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Testing;
+using Microsoft.CodeAnalysis.Diagnostics;
+using Microsoft.CodeAnalysis.Testing;
+using Microsoft.CodeAnalysis.Text;
+
+namespace TUnit.AspNetCore.Analyzers.Tests.Verifiers;
+
+public static class CSharpCodeFixVerifier
+ where TAnalyzer : DiagnosticAnalyzer, new()
+ where TCodeFix : CodeFixProvider, new()
+{
+ public static DiagnosticResult Diagnostic(DiagnosticDescriptor descriptor)
+ => CSharpCodeFixVerifier.Diagnostic(descriptor);
+
+ public static Task VerifyCodeFixAsync(
+ [StringSyntax("c#")] string source,
+ [StringSyntax("c#")] string fixedSource,
+ params DiagnosticResult[] expected)
+ => VerifyCodeFixAsync(source, fixedSource, stubsSource: null, expected);
+
+ public static async Task VerifyCodeFixAsync(
+ [StringSyntax("c#")] string source,
+ [StringSyntax("c#")] string fixedSource,
+ [StringSyntax("c#")] string? stubsSource,
+ params DiagnosticResult[] expected)
+ {
+ var test = new CSharpCodeFixTest
+ {
+ TestCode = source,
+ FixedCode = fixedSource,
+ ReferenceAssemblies = ReferenceAssemblies.Net.Net90,
+ };
+
+ if (stubsSource is not null)
+ {
+ test.TestState.Sources.Add(stubsSource);
+ test.FixedState.Sources.Add(stubsSource);
+ }
+
+ test.TestState.AnalyzerConfigFiles.Add(("/.editorconfig", SourceText.From("""
+ is_global = true
+ end_of_line = lf
+ """)));
+
+ test.SolutionTransforms.Add((solution, projectId) =>
+ {
+ var project = solution.GetProject(projectId);
+ if (project?.ParseOptions is not CSharpParseOptions parseOptions)
+ {
+ return solution;
+ }
+
+ return solution.WithProjectParseOptions(projectId, parseOptions.WithLanguageVersion(LanguageVersion.Preview));
+ });
+
+ test.ExpectedDiagnostics.AddRange(expected);
+
+ await test.RunAsync(CancellationToken.None);
+ }
+}
diff --git a/TUnit.AspNetCore.Analyzers.Tests/WebApplicationFactoryStubs.cs b/TUnit.AspNetCore.Analyzers.Tests/WebApplicationFactoryStubs.cs
new file mode 100644
index 0000000000..1c56dead3e
--- /dev/null
+++ b/TUnit.AspNetCore.Analyzers.Tests/WebApplicationFactoryStubs.cs
@@ -0,0 +1,23 @@
+namespace TUnit.AspNetCore.Analyzers.Tests;
+
+internal static class WebApplicationFactoryStubs
+{
+ public const string Source = """
+ namespace Microsoft.AspNetCore.Mvc.Testing
+ {
+ public class WebApplicationFactory where TEntryPoint : class
+ {
+ }
+ }
+
+ namespace TUnit.AspNetCore
+ {
+ public class TestWebApplicationFactory : Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactory
+ where TEntryPoint : class
+ {
+ }
+ }
+
+ public class Program { }
+ """;
+}
diff --git a/TUnit.AspNetCore.Analyzers/AnalyzerReleases.Unshipped.md b/TUnit.AspNetCore.Analyzers/AnalyzerReleases.Unshipped.md
index e69fa429b4..1f2d7d476d 100644
--- a/TUnit.AspNetCore.Analyzers/AnalyzerReleases.Unshipped.md
+++ b/TUnit.AspNetCore.Analyzers/AnalyzerReleases.Unshipped.md
@@ -4,6 +4,7 @@ Rule ID | Category | Severity | Notes
--------|----------|----------|-------
TUnit0062 | Usage | Error | Factory property accessed before initialization in WebApplicationTest
TUnit0063 | Usage | Error | GlobalFactory member access breaks test isolation
+TUnit0064 | Usage | Warning | Inherit from TestWebApplicationFactory<T> instead of WebApplicationFactory<T>
### Removed Rules
diff --git a/TUnit.AspNetCore.Analyzers/DirectWebApplicationFactoryInheritanceAnalyzer.cs b/TUnit.AspNetCore.Analyzers/DirectWebApplicationFactoryInheritanceAnalyzer.cs
new file mode 100644
index 0000000000..bb3799615b
--- /dev/null
+++ b/TUnit.AspNetCore.Analyzers/DirectWebApplicationFactoryInheritanceAnalyzer.cs
@@ -0,0 +1,97 @@
+using System.Collections.Immutable;
+using System.Linq;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+using Microsoft.CodeAnalysis.Diagnostics;
+
+namespace TUnit.AspNetCore.Analyzers;
+
+[DiagnosticAnalyzer(LanguageNames.CSharp)]
+public class DirectWebApplicationFactoryInheritanceAnalyzer : ConcurrentDiagnosticAnalyzer
+{
+ private const string WebApplicationFactoryMetadataName = "Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactory`1";
+ private const string TestWebApplicationFactoryMetadataName = "TUnit.AspNetCore.TestWebApplicationFactory`1";
+
+ public override ImmutableArray SupportedDiagnostics { get; } =
+ ImmutableArray.Create(Rules.DirectWebApplicationFactoryInheritance);
+
+ protected override void InitializeInternal(AnalysisContext context)
+ {
+ context.RegisterCompilationStartAction(compilationContext =>
+ {
+ var webApplicationFactory = compilationContext.Compilation
+ .GetTypeByMetadataName(WebApplicationFactoryMetadataName);
+
+ if (webApplicationFactory is null)
+ {
+ return;
+ }
+
+ var testWebApplicationFactory = compilationContext.Compilation
+ .GetTypeByMetadataName(TestWebApplicationFactoryMetadataName);
+
+ if (testWebApplicationFactory is null)
+ {
+ return;
+ }
+
+ compilationContext.RegisterSymbolAction(
+ symbolContext => AnalyzeNamedType(symbolContext, webApplicationFactory, testWebApplicationFactory),
+ SymbolKind.NamedType);
+ });
+ }
+
+ private static void AnalyzeNamedType(
+ SymbolAnalysisContext context,
+ INamedTypeSymbol webApplicationFactory,
+ INamedTypeSymbol testWebApplicationFactory)
+ {
+ if (context.Symbol is not INamedTypeSymbol { TypeKind: TypeKind.Class, BaseType: { } baseType } type)
+ {
+ return;
+ }
+
+ if (!SymbolEqualityComparer.Default.Equals(baseType.OriginalDefinition, webApplicationFactory))
+ {
+ return;
+ }
+
+ if (SymbolEqualityComparer.Default.Equals(type.OriginalDefinition, testWebApplicationFactory))
+ {
+ return;
+ }
+
+ var location = GetBaseTypeLocation(type) ?? type.Locations.FirstOrDefault();
+ if (location is null)
+ {
+ return;
+ }
+
+ context.ReportDiagnostic(Diagnostic.Create(
+ Rules.DirectWebApplicationFactoryInheritance,
+ location,
+ type.Name));
+ }
+
+ private static Location? GetBaseTypeLocation(INamedTypeSymbol type)
+ {
+ foreach (var syntaxRef in type.DeclaringSyntaxReferences)
+ {
+ if (syntaxRef.GetSyntax() is not TypeDeclarationSyntax typeDeclaration)
+ {
+ continue;
+ }
+
+ var baseList = typeDeclaration.BaseList;
+ if (baseList is null || baseList.Types.Count == 0)
+ {
+ continue;
+ }
+
+ return baseList.Types[0].GetLocation();
+ }
+
+ return null;
+ }
+}
diff --git a/TUnit.AspNetCore.Analyzers/Resources.Designer.cs b/TUnit.AspNetCore.Analyzers/Resources.Designer.cs
index de3968cd70..b81237a624 100644
--- a/TUnit.AspNetCore.Analyzers/Resources.Designer.cs
+++ b/TUnit.AspNetCore.Analyzers/Resources.Designer.cs
@@ -108,5 +108,32 @@ internal static string TUnit0063Title {
return ResourceManager.GetString("TUnit0063Title", resourceCulture);
}
}
+
+ ///
+ /// Looks up a localized string similar to Classes inheriting directly from Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactory<T>...
+ ///
+ internal static string TUnit0064Description {
+ get {
+ return ResourceManager.GetString("TUnit0064Description", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to '{0}' inherits directly from WebApplicationFactory<T>...
+ ///
+ internal static string TUnit0064MessageFormat {
+ get {
+ return ResourceManager.GetString("TUnit0064MessageFormat", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Inherit from TestWebApplicationFactory<T> instead of WebApplicationFactory<T>.
+ ///
+ internal static string TUnit0064Title {
+ get {
+ return ResourceManager.GetString("TUnit0064Title", resourceCulture);
+ }
+ }
}
}
diff --git a/TUnit.AspNetCore.Analyzers/Resources.resx b/TUnit.AspNetCore.Analyzers/Resources.resx
index f749a357ac..f1f40b887a 100644
--- a/TUnit.AspNetCore.Analyzers/Resources.resx
+++ b/TUnit.AspNetCore.Analyzers/Resources.resx
@@ -36,4 +36,13 @@
GlobalFactory member access breaks test isolation
+
+ Classes inheriting directly from Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactory<T> silently lose distributed tracing propagation, per-test logging correlation, and TestContext.Current resolution inside request handlers. Inherit from TUnit.AspNetCore.TestWebApplicationFactory<T> (or wrap an existing factory with TracedWebApplicationFactory<T>) to restore these behaviours.
+
+
+ '{0}' inherits directly from WebApplicationFactory<T>. Inherit from TestWebApplicationFactory<T> to preserve tracing, log correlation, and TestContext.Current.
+
+
+ Inherit from TestWebApplicationFactory<T> instead of WebApplicationFactory<T>
+
diff --git a/TUnit.AspNetCore.Analyzers/Rules.cs b/TUnit.AspNetCore.Analyzers/Rules.cs
index aeccd1bc44..cc634ac05b 100644
--- a/TUnit.AspNetCore.Analyzers/Rules.cs
+++ b/TUnit.AspNetCore.Analyzers/Rules.cs
@@ -12,7 +12,18 @@ public static class Rules
public static readonly DiagnosticDescriptor GlobalFactoryMemberAccess =
CreateDescriptor("TUnit0063", UsageCategory, DiagnosticSeverity.Error);
- private static DiagnosticDescriptor CreateDescriptor(string diagnosticId, string category, DiagnosticSeverity severity)
+ public static readonly DiagnosticDescriptor DirectWebApplicationFactoryInheritance =
+ CreateDescriptor(
+ "TUnit0064",
+ UsageCategory,
+ DiagnosticSeverity.Warning,
+ helpLinkUri: "https://tunit.dev/docs/guides/distributed-tracing");
+
+ private static DiagnosticDescriptor CreateDescriptor(
+ string diagnosticId,
+ string category,
+ DiagnosticSeverity severity,
+ string? helpLinkUri = null)
{
return new DiagnosticDescriptor(
id: diagnosticId,
@@ -24,7 +35,8 @@ private static DiagnosticDescriptor CreateDescriptor(string diagnosticId, string
defaultSeverity: severity,
isEnabledByDefault: true,
description: new LocalizableResourceString(diagnosticId + "Description", Resources.ResourceManager,
- typeof(Resources))
+ typeof(Resources)),
+ helpLinkUri: helpLinkUri
);
}
}
diff --git a/TUnit.AspNetCore.Core/FlowSuppressingHostedService.cs b/TUnit.AspNetCore.Core/FlowSuppressingHostedService.cs
new file mode 100644
index 0000000000..38c2ea7101
--- /dev/null
+++ b/TUnit.AspNetCore.Core/FlowSuppressingHostedService.cs
@@ -0,0 +1,60 @@
+using Microsoft.Extensions.Hosting;
+
+namespace TUnit.AspNetCore;
+
+///
+/// Wraps an so its
+/// runs on a thread-pool worker with a clean .
+/// Background tasks spawned anywhere inside StartAsync — synchronously or
+/// after an await — inherit that clean context, so activities they later
+/// emit do not inherit the test's ambient
+/// as their parent.
+///
+///
+/// Implements so the Host's lifecycle hooks keep
+/// firing for inner services that implement it — the Host uses an is check
+/// against the registered instance, so without passthrough wrapping would silently
+/// drop those hooks.
+///
+internal sealed class FlowSuppressingHostedService(IHostedService inner) : IHostedLifecycleService
+{
+ public Task StartAsync(CancellationToken cancellationToken) =>
+ RunOnCleanContext(inner.StartAsync, cancellationToken);
+
+ public Task StopAsync(CancellationToken cancellationToken) =>
+ inner.StopAsync(cancellationToken);
+
+ public Task StartingAsync(CancellationToken cancellationToken) =>
+ inner is IHostedLifecycleService lifecycle
+ ? RunOnCleanContext(lifecycle.StartingAsync, cancellationToken)
+ : Task.CompletedTask;
+
+ public Task StartedAsync(CancellationToken cancellationToken) =>
+ inner is IHostedLifecycleService lifecycle
+ ? RunOnCleanContext(lifecycle.StartedAsync, cancellationToken)
+ : Task.CompletedTask;
+
+ // Stop lifecycle is intentionally not wrapped: stop methods typically signal
+ // cancellation and await shutdown rather than spawning new long-running background
+ // work, so context capture during Stop is not the span-leak vector that Start is.
+ public Task StoppingAsync(CancellationToken cancellationToken) =>
+ inner is IHostedLifecycleService lifecycle
+ ? lifecycle.StoppingAsync(cancellationToken)
+ : Task.CompletedTask;
+
+ public Task StoppedAsync(CancellationToken cancellationToken) =>
+ inner is IHostedLifecycleService lifecycle
+ ? lifecycle.StoppedAsync(cancellationToken)
+ : Task.CompletedTask;
+
+ // Dispatch onto a thread-pool worker with a clean captured ExecutionContext by
+ // combining SuppressFlow + Task.Run. Unlike wrapping `using (SuppressFlow()) return op(ct);`
+ // which only suppresses during the synchronous body, this keeps the inner operation
+ // running under a clean context through awaits — every `Task.Run` inside `op` also
+ // captures clean context.
+ private static Task RunOnCleanContext(Func op, CancellationToken ct)
+ {
+ using var _ = ExecutionContext.SuppressFlow();
+ return Task.Run(() => op(ct), ct);
+ }
+}
diff --git a/TUnit.AspNetCore.Core/Http/ActivityPropagationHandler.cs b/TUnit.AspNetCore.Core/Http/ActivityPropagationHandler.cs
index 56fc0029a4..6901488dd4 100644
--- a/TUnit.AspNetCore.Core/Http/ActivityPropagationHandler.cs
+++ b/TUnit.AspNetCore.Core/Http/ActivityPropagationHandler.cs
@@ -117,14 +117,14 @@ private static void InjectBaggage(Activity? activity, HttpRequestHeaders headers
// If a propagator already emitted W3C baggage (e.g. OTel SDK's BaggagePropagator),
// preserve it; otherwise emit our own so LegacyPropagator-based stacks still
// propagate test correlation baggage.
- if (activity is null || headers.Contains("baggage"))
+ if (activity is null || headers.Contains(TUnit.Core.TUnitActivitySource.BaggageHeader))
{
return;
}
if (TUnit.Core.TUnitActivitySource.TryBuildBaggageHeader(activity) is { } baggage)
{
- headers.TryAddWithoutValidation("baggage", baggage);
+ headers.TryAddWithoutValidation(TUnit.Core.TUnitActivitySource.BaggageHeader, baggage);
}
}
}
diff --git a/TUnit.AspNetCore.Core/Http/TUnitHttpClientFilter.cs b/TUnit.AspNetCore.Core/Http/TUnitHttpClientFilter.cs
new file mode 100644
index 0000000000..b98c6ee75f
--- /dev/null
+++ b/TUnit.AspNetCore.Core/Http/TUnitHttpClientFilter.cs
@@ -0,0 +1,43 @@
+using Microsoft.Extensions.Http;
+
+namespace TUnit.AspNetCore.Http;
+
+///
+/// Prepends and
+/// to every handler pipeline built in the SUT.
+/// Ensures outbound HTTP calls made via AddHttpClient<T>(), named, or typed clients
+/// carry the current test's traceparent, baggage, and X-TUnit-TestId headers.
+///
+/// Both handler types must remain stateless and thread-safe:
+/// caches the built pipeline and shares the same handler instances across every request on a given
+/// named client, including concurrent requests from parallel tests. Per-test correlation comes from
+/// and ,
+/// which are async-local — do not add instance fields capturing per-request state to either handler.
+///
+///
+internal sealed class TUnitHttpClientFilter : IHttpMessageHandlerBuilderFilter
+{
+ public Action Configure(Action next) =>
+ builder =>
+ {
+ next(builder);
+ // Insert at outermost positions so TUnit headers are emitted before any
+ // SUT-registered handler can run. Order must stay ActivityPropagationHandler
+ // first (writes traceparent/baggage) then TUnitTestIdHandler (writes X-TUnit-TestId).
+ builder.AdditionalHandlers.Insert(0, new ActivityPropagationHandler());
+ builder.AdditionalHandlers.Insert(1, new TUnitTestIdHandler());
+ };
+
+ ///
+ /// Returns the TUnit propagation handlers followed by the caller-supplied handlers,
+ /// in the order they should be passed to WebApplicationFactory.CreateDefaultClient.
+ ///
+ internal static DelegatingHandler[] PrependPropagationHandlers(DelegatingHandler[] handlers)
+ {
+ var all = new DelegatingHandler[handlers.Length + 2];
+ all[0] = new ActivityPropagationHandler();
+ all[1] = new TUnitTestIdHandler();
+ Array.Copy(handlers, 0, all, 2, handlers.Length);
+ return all;
+ }
+}
diff --git a/TUnit.AspNetCore.Core/Http/TUnitTestIdHandler.cs b/TUnit.AspNetCore.Core/Http/TUnitTestIdHandler.cs
index 7afd6ee513..d7f13b15b3 100644
--- a/TUnit.AspNetCore.Core/Http/TUnitTestIdHandler.cs
+++ b/TUnit.AspNetCore.Core/Http/TUnitTestIdHandler.cs
@@ -38,7 +38,7 @@ public TUnitTestIdHandler(HttpMessageHandler innerHandler) : base(innerHandler)
protected override Task SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
- if ((_testContext ?? TestContext.Current) is { } ctx)
+ if ((_testContext ?? TestContext.Current) is { } ctx && !request.Headers.Contains(HeaderName))
{
request.Headers.TryAddWithoutValidation(HeaderName, ctx.Id);
}
diff --git a/TUnit.AspNetCore.Core/PropagatorAlignmentStartupFilter.cs b/TUnit.AspNetCore.Core/PropagatorAlignmentStartupFilter.cs
new file mode 100644
index 0000000000..c442cf0650
--- /dev/null
+++ b/TUnit.AspNetCore.Core/PropagatorAlignmentStartupFilter.cs
@@ -0,0 +1,23 @@
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Hosting;
+using TUnit.Core;
+
+namespace TUnit.AspNetCore;
+
+///
+/// Runs after SUT startup code has
+/// executed. Needed because user Program.cs/Startup.cs can call
+/// Sdk.SetDefaultTextMapPropagator(...) (or otherwise reset
+/// ) during host
+/// build; is invoked when the pipeline is constructed,
+/// which is after all service registration and startup assignments, so alignment wins.
+///
+internal sealed class PropagatorAlignmentStartupFilter : IStartupFilter
+{
+ public Action Configure(Action next)
+ => app =>
+ {
+ PropagatorAlignment.AlignIfDefault();
+ next(app);
+ };
+}
diff --git a/TUnit.AspNetCore.Core/TUnit.AspNetCore.Core.csproj b/TUnit.AspNetCore.Core/TUnit.AspNetCore.Core.csproj
index 5ebc439f06..4daa3782ed 100644
--- a/TUnit.AspNetCore.Core/TUnit.AspNetCore.Core.csproj
+++ b/TUnit.AspNetCore.Core/TUnit.AspNetCore.Core.csproj
@@ -16,10 +16,17 @@
+
+
+
+
+
+
+
@@ -36,6 +43,8 @@
+
@@ -51,6 +60,10 @@
+
+
diff --git a/TUnit.AspNetCore.Core/TestWebApplicationFactory.cs b/TUnit.AspNetCore.Core/TestWebApplicationFactory.cs
index 86850f6e77..12af759107 100644
--- a/TUnit.AspNetCore.Core/TestWebApplicationFactory.cs
+++ b/TUnit.AspNetCore.Core/TestWebApplicationFactory.cs
@@ -3,11 +3,16 @@
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Http;
+using OpenTelemetry.Trace;
using TUnit.AspNetCore.Extensions;
+using TUnit.AspNetCore.Http;
using TUnit.AspNetCore.Interception;
using TUnit.AspNetCore.Logging;
using TUnit.Core;
+using TUnit.OpenTelemetry;
namespace TUnit.AspNetCore;
@@ -41,6 +46,17 @@ public WebApplicationFactory GetIsolatedFactory(
configureIsolatedServices(services);
services.AddSingleton(testContext);
services.AddTUnitLogging(testContext);
+
+ if (options.AutoPropagateHttpClientFactory)
+ {
+ services.TryAddEnumerable(
+ ServiceDescriptor.Singleton());
+ }
+
+ if (options.AutoConfigureOpenTelemetry)
+ {
+ AddTUnitOpenTelemetry(services);
+ }
});
if (options.EnableHttpExchangeCapture)
@@ -69,6 +85,9 @@ protected virtual void ConfigureStartupConfiguration(IConfigurationBuilder confi
/// Registers here
/// (rather than in ) so that minimal API hosts — where
/// returns null — also get correlated logging.
+ /// Also registers so the SUT's
+ /// ends up W3C-aligned
+ /// even when user startup code assigns a custom propagator of its own.
/// Subclasses overriding this method must call base.ConfigureWebHost(builder).
///
protected override void ConfigureWebHost(IWebHostBuilder builder)
@@ -77,10 +96,106 @@ protected override void ConfigureWebHost(IWebHostBuilder builder)
builder.ConfigureServices(services =>
{
+ services.AddSingleton();
services.AddCorrelatedTUnitLogging();
});
}
+ ///
+ /// Adds TUnit's default OpenTelemetry tracing configuration to :
+ /// the TUnit.AspNetCore.Http activity source, the
+ /// , and ASP.NET Core + HttpClient instrumentation.
+ /// Safe to call even if the SUT already registers these — OpenTelemetry de-duplicates them.
+ /// Also safe when combined with the TUnit.OpenTelemetry zero-config package: the
+ /// SUT and test-runner TracerProviders each carry their own processor, but the
+ /// processor's idempotent OnStart guard prevents duplicate tunit.test.id tags.
+ ///
+ private static void AddTUnitOpenTelemetry(IServiceCollection services)
+ {
+ services.AddOpenTelemetry().WithTracing(tracing => tracing
+ .AddSource(TUnitActivitySource.AspNetCoreHttpSourceName)
+ .AddProcessor(new TUnitTestCorrelationProcessor())
+ .AddAspNetCoreInstrumentation()
+ .AddHttpClientInstrumentation());
+ }
+
+ ///
+ /// Controls whether every registered
+ /// has its StartAsync dispatched onto a thread-pool worker with a clean
+ /// .
+ ///
+ /// When enabled (the default), background work spawned inside a hosted service's
+ /// StartAsync — synchronously or after an await — captures a clean
+ /// execution context. Activities emitted later on background threads become orphan
+ /// roots rather than inheriting the first test's .
+ /// Without this, spans from hosted-service work done during test B are attributed to
+ /// test A's TraceId.
+ ///
+ ///
+ /// Override and return false to preserve ambient context flow into hosted
+ /// services — only needed if the hosted service intentionally relies on
+ /// Activity.Current or other
+ /// values captured at factory-build time, or requires StartAsync to run on
+ /// the calling thread.
+ ///
+ ///
+ protected virtual bool SuppressHostedServiceExecutionContextFlow => true;
+
+ protected override IHost CreateHost(IHostBuilder builder)
+ {
+ if (SuppressHostedServiceExecutionContextFlow)
+ {
+ builder.ConfigureServices(DecorateHostedServicesWithFlowSuppression);
+ }
+
+ return base.CreateHost(builder);
+ }
+
+ private static void DecorateHostedServicesWithFlowSuppression(IServiceCollection services)
+ {
+ for (var i = 0; i < services.Count; i++)
+ {
+ var descriptor = services[i];
+
+ if (descriptor.ServiceType != typeof(IHostedService))
+ {
+ continue;
+ }
+
+ services[i] = WrapHostedServiceDescriptor(descriptor);
+ }
+ }
+
+ private static ServiceDescriptor WrapHostedServiceDescriptor(ServiceDescriptor descriptor)
+ {
+ if (descriptor.ImplementationInstance is IHostedService instance)
+ {
+ return new ServiceDescriptor(
+ typeof(IHostedService),
+ _ => new FlowSuppressingHostedService(instance),
+ descriptor.Lifetime);
+ }
+
+ if (descriptor.ImplementationFactory is { } factory)
+ {
+ return new ServiceDescriptor(
+ typeof(IHostedService),
+ sp => new FlowSuppressingHostedService((IHostedService)factory(sp)),
+ descriptor.Lifetime);
+ }
+
+ if (descriptor.ImplementationType is { } implType)
+ {
+ return new ServiceDescriptor(
+ typeof(IHostedService),
+ sp => new FlowSuppressingHostedService(
+ (IHostedService)ActivatorUtilities.CreateInstance(sp, implType)),
+ descriptor.Lifetime);
+ }
+
+ return descriptor;
+ }
+
///
/// Creates an with and
/// automatically prepended to the handler chain.
@@ -92,11 +207,7 @@ protected override void ConfigureWebHost(IWebHostBuilder builder)
///
public new HttpClient CreateDefaultClient(params DelegatingHandler[] handlers)
{
- var all = new DelegatingHandler[handlers.Length + 2];
- all[0] = new ActivityPropagationHandler();
- all[1] = new TUnitTestIdHandler();
- Array.Copy(handlers, 0, all, 2, handlers.Length);
- return base.CreateDefaultClient(all);
+ return base.CreateDefaultClient(TUnitHttpClientFilter.PrependPropagationHandlers(handlers));
}
///
diff --git a/TUnit.AspNetCore.Core/TracedWebApplicationFactory.cs b/TUnit.AspNetCore.Core/TracedWebApplicationFactory.cs
index 55658086b8..6d549a3b39 100644
--- a/TUnit.AspNetCore.Core/TracedWebApplicationFactory.cs
+++ b/TUnit.AspNetCore.Core/TracedWebApplicationFactory.cs
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
+using TUnit.AspNetCore.Http;
namespace TUnit.AspNetCore;
@@ -41,7 +42,7 @@ public TracedWebApplicationFactory(WebApplicationFactory inner)
/// Creates an with activity tracing and test context propagation.
///
public HttpClient CreateClient() =>
- _inner.CreateDefaultClient(new ActivityPropagationHandler(), new TUnitTestIdHandler());
+ _inner.CreateDefaultClient(TUnitHttpClientFilter.PrependPropagationHandlers([]));
///
/// Creates an with the specified delegating handlers, plus
@@ -49,11 +50,7 @@ public HttpClient CreateClient() =>
///
public HttpClient CreateDefaultClient(params DelegatingHandler[] handlers)
{
- var all = new DelegatingHandler[handlers.Length + 2];
- all[0] = new ActivityPropagationHandler();
- all[1] = new TUnitTestIdHandler();
- Array.Copy(handlers, 0, all, 2, handlers.Length);
- return _inner.CreateDefaultClient(all);
+ return _inner.CreateDefaultClient(TUnitHttpClientFilter.PrependPropagationHandlers(handlers));
}
///
diff --git a/TUnit.AspNetCore.Core/WebApplicationTestOptions.cs b/TUnit.AspNetCore.Core/WebApplicationTestOptions.cs
index bfeff23e64..2c924f79df 100644
--- a/TUnit.AspNetCore.Core/WebApplicationTestOptions.cs
+++ b/TUnit.AspNetCore.Core/WebApplicationTestOptions.cs
@@ -8,4 +8,36 @@ public record WebApplicationTestOptions
/// Default is false.
///
public bool EnableHttpExchangeCapture { get; set; } = false;
+
+ ///
+ /// Gets or sets a value indicating whether outbound HTTP calls made by the SUT through
+ /// (including AddHttpClient<T>(),
+ /// named clients, and typed clients) should automatically carry the test's
+ /// traceparent, baggage, and X-TUnit-TestId headers.
+ /// Default is true.
+ ///
+ /// Set to false when the SUT already instruments its outbound HTTP calls
+ /// (for example via the OpenTelemetry HttpClient instrumentation) and you do not want
+ /// TUnit to prepend its handlers to every factory pipeline.
+ ///
+ ///
+ public bool AutoPropagateHttpClientFactory { get; set; } = true;
+
+ ///
+ /// Gets or sets a value indicating whether the SUT's
+ /// should be automatically augmented with the TUnit HTTP activity source, the
+ /// TUnitTestCorrelationProcessor, and ASP.NET Core + HttpClient instrumentation.
+ /// Default is true.
+ ///
+ /// When enabled, test spans emitted inside the SUT are tagged with the ambient
+ /// tunit.test.id baggage so they remain queryable per-test in backends like
+ /// Seq or Jaeger, even when third-party libraries break the parent-chain.
+ ///
+ ///
+ /// Set to false to leave the SUT's OpenTelemetry configuration untouched —
+ /// useful if the SUT configures its own processors and you do not want TUnit's
+ /// defaults layered on top.
+ ///
+ ///
+ public bool AutoConfigureOpenTelemetry { get; set; } = true;
}
diff --git a/TUnit.AspNetCore.Tests.WebApp/Program.cs b/TUnit.AspNetCore.Tests.WebApp/Program.cs
index 03abeda9f9..3e2f4e2fa7 100644
--- a/TUnit.AspNetCore.Tests.WebApp/Program.cs
+++ b/TUnit.AspNetCore.Tests.WebApp/Program.cs
@@ -1,5 +1,8 @@
var builder = WebApplication.CreateBuilder(args);
+builder.Services.AddHttpClient("downstream")
+ .ConfigurePrimaryHttpMessageHandler(() => new HeaderEchoHandler());
+
var app = builder.Build();
var logger = app.Services.GetRequiredService().CreateLogger("Endpoints");
@@ -15,6 +18,29 @@
app.MapGet("/ping", () => "pong");
+// Outbound call through IHttpClientFactory. The downstream pipeline's primary
+// handler echoes request headers back in the response body so tests can assert
+// which headers the SUT-side HttpClient actually emitted.
+app.MapGet("/proxy", async (IHttpClientFactory factory) =>
+{
+ var client = factory.CreateClient("downstream");
+ var response = await client.GetAsync("http://downstream.test/");
+ var body = await response.Content.ReadAsStringAsync();
+ return Results.Content(body, "text/plain");
+});
+
app.Run();
public partial class Program;
+
+internal sealed class HeaderEchoHandler : HttpMessageHandler
+{
+ protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ var dump = string.Join("\n", request.Headers.SelectMany(h => h.Value.Select(v => $"{h.Key}: {v}")));
+ return Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.OK)
+ {
+ Content = new StringContent(dump)
+ });
+ }
+}
diff --git a/TUnit.AspNetCore.Tests/AutoConfigureOpenTelemetryTests.cs b/TUnit.AspNetCore.Tests/AutoConfigureOpenTelemetryTests.cs
new file mode 100644
index 0000000000..bac62309a3
--- /dev/null
+++ b/TUnit.AspNetCore.Tests/AutoConfigureOpenTelemetryTests.cs
@@ -0,0 +1,72 @@
+using System.Diagnostics;
+using Microsoft.Extensions.DependencyInjection;
+using OpenTelemetry.Trace;
+using TUnit.AspNetCore;
+using TUnit.Core;
+
+namespace TUnit.AspNetCore.Tests;
+
+///
+/// Coverage for thomhurst/TUnit#5594 —
+/// automatically augments the SUT's with TUnit's
+/// correlation processor + ASP.NET Core instrumentation.
+///
+///
+/// Serialized against sibling auto-wire tests because
+/// attaches a process-global per TracerProvider,
+/// so a parallel factory's correlation processor can tag activities created by another
+/// factory's SUT. Serializing keeps assertions observing only their own factory's wiring.
+///
+[NotInParallel(nameof(AutoConfigureOpenTelemetryTests))]
+public class AutoConfigureOpenTelemetryTests : WebApplicationTest
+{
+ private readonly List _exported = [];
+
+ protected override void ConfigureTestServices(IServiceCollection services)
+ {
+ services.AddOpenTelemetry().WithTracing(t => t.AddInMemoryExporter(_exported));
+ }
+
+ [Test]
+ public async Task AutoWires_TagsAspNetCoreSpans_WithTestId()
+ {
+ using var client = Factory.CreateClient();
+ var response = await client.GetAsync("/ping");
+ response.EnsureSuccessStatusCode();
+
+ var testId = TestContext.Current!.Id;
+ var taggedSpan = _exported.FirstOrDefault(a => (a.GetTagItem(TUnitActivitySource.TagTestId) as string) == testId);
+ await Assert.That(taggedSpan).IsNotNull();
+ }
+}
+
+[NotInParallel(nameof(AutoConfigureOpenTelemetryTests))]
+public class AutoConfigureOpenTelemetryOptOutTests : WebApplicationTest
+{
+ private readonly List _exported = [];
+
+ protected override void ConfigureTestOptions(WebApplicationTestOptions options)
+ {
+ options.AutoConfigureOpenTelemetry = false;
+ }
+
+ protected override void ConfigureTestServices(IServiceCollection services)
+ {
+ services.AddOpenTelemetry().WithTracing(t => t
+ .AddAspNetCoreInstrumentation()
+ .AddInMemoryExporter(_exported));
+ }
+
+ [Test]
+ public async Task OptOut_DoesNotTag_AspNetCoreSpans()
+ {
+ using var client = Factory.CreateClient();
+ var response = await client.GetAsync("/ping");
+ response.EnsureSuccessStatusCode();
+
+ foreach (var activity in _exported)
+ {
+ await Assert.That(activity.GetTagItem(TUnitActivitySource.TagTestId)).IsNull();
+ }
+ }
+}
diff --git a/TUnit.AspNetCore.Tests/HostedServiceFlowSuppressionTests.cs b/TUnit.AspNetCore.Tests/HostedServiceFlowSuppressionTests.cs
new file mode 100644
index 0000000000..a7d0c9332e
--- /dev/null
+++ b/TUnit.AspNetCore.Tests/HostedServiceFlowSuppressionTests.cs
@@ -0,0 +1,170 @@
+using System.Diagnostics;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using TUnit.AspNetCore;
+
+namespace TUnit.AspNetCore.Tests;
+
+///
+/// Tests for 's automatic
+/// wrapping of
+/// registrations. See issue #5589.
+///
+public class HostedServiceFlowSuppressionTests
+{
+ [Test]
+ public async Task StartAsync_SuppressesFlow_IntoSpawnedTasks()
+ {
+ using var outer = new Activity("outer-test").Start();
+
+ var probe = new FlowProbeHostedService();
+ await using var factory = new FlowSuppressTestFactory { Probe = probe };
+
+ _ = factory.Server;
+
+ await probe.SpawnedTaskCompleted.Task.WaitAsync(TimeSpan.FromSeconds(5));
+
+ await Assert.That(probe.ActivityInSpawnedTask).IsNull();
+ }
+
+ [Test]
+ public async Task StartAsync_SuppressesFlow_WhenSpawnIsAfterAwait()
+ {
+ using var outer = new Activity("outer-test").Start();
+
+ var probe = new DeepAsyncFlowProbeHostedService();
+ await using var factory = new DeepAsyncFlowSuppressTestFactory { Probe = probe };
+
+ _ = factory.Server;
+
+ await probe.SpawnedTaskCompleted.Task.WaitAsync(TimeSpan.FromSeconds(5));
+
+ await Assert.That(probe.ActivityInSpawnedTask).IsNull();
+ }
+
+ [Test]
+ public async Task OptOut_PreservesFlow_IntoSpawnedTasks()
+ {
+ using var outer = new Activity("outer-test").Start();
+
+ var probe = new FlowProbeHostedService();
+ await using var factory = new NoSuppressionFactory { Probe = probe };
+
+ _ = factory.Server;
+
+ await probe.SpawnedTaskCompleted.Task.WaitAsync(TimeSpan.FromSeconds(5));
+
+ await Assert.That(probe.ActivityInSpawnedTask).IsEqualTo(outer);
+ }
+
+ [Test]
+ public async Task RegisteredHostedServices_AreWrapped()
+ {
+ var probe = new FlowProbeHostedService();
+ await using var factory = new FlowSuppressTestFactory { Probe = probe };
+
+ _ = factory.Server;
+
+ var hostedServices = factory.Services.GetServices().ToList();
+
+ await Assert.That(hostedServices.Any(h => h is FlowSuppressingHostedService)).IsTrue();
+ }
+
+ [Test]
+ public async Task IsolatedFactory_HostedServices_AreWrapped()
+ {
+ await using var factory = new FlowSuppressTestFactory();
+
+ var isolated = factory.GetIsolatedFactory(
+ TestContext.Current!,
+ new WebApplicationTestOptions(),
+ services => services.AddSingleton(new FlowProbeHostedService()),
+ (_, _) => { });
+
+ _ = isolated.Server;
+
+ var hostedServices = isolated.Services.GetServices().ToList();
+
+ await Assert.That(hostedServices.Any(h => h is FlowSuppressingHostedService)).IsTrue();
+ }
+}
+
+internal class FlowSuppressTestFactory : TestWebApplicationFactory
+{
+ public FlowProbeHostedService? Probe { get; set; }
+
+ protected override void ConfigureWebHost(IWebHostBuilder builder)
+ {
+ base.ConfigureWebHost(builder);
+
+ builder.ConfigureServices(services =>
+ {
+ if (Probe is not null)
+ {
+ services.AddSingleton(Probe);
+ }
+ });
+ }
+}
+
+internal sealed class NoSuppressionFactory : FlowSuppressTestFactory
+{
+ protected override bool SuppressHostedServiceExecutionContextFlow => false;
+}
+
+internal sealed class FlowProbeHostedService : IHostedService
+{
+ public Activity? ActivityInSpawnedTask { get; private set; }
+ public TaskCompletionSource SpawnedTaskCompleted { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ public Task StartAsync(CancellationToken cancellationToken)
+ {
+ _ = Task.Run(() =>
+ {
+ ActivityInSpawnedTask = Activity.Current;
+ SpawnedTaskCompleted.TrySetResult();
+ });
+
+ return Task.CompletedTask;
+ }
+
+ public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
+}
+
+internal class DeepAsyncFlowSuppressTestFactory : TestWebApplicationFactory
+{
+ public DeepAsyncFlowProbeHostedService? Probe { get; set; }
+
+ protected override void ConfigureWebHost(IWebHostBuilder builder)
+ {
+ base.ConfigureWebHost(builder);
+
+ builder.ConfigureServices(services =>
+ {
+ if (Probe is not null)
+ {
+ services.AddSingleton(Probe);
+ }
+ });
+ }
+}
+
+internal sealed class DeepAsyncFlowProbeHostedService : IHostedService
+{
+ public Activity? ActivityInSpawnedTask { get; private set; }
+ public TaskCompletionSource SpawnedTaskCompleted { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ public async Task StartAsync(CancellationToken cancellationToken)
+ {
+ await Task.Yield();
+
+ _ = Task.Run(() =>
+ {
+ ActivityInSpawnedTask = Activity.Current;
+ SpawnedTaskCompleted.TrySetResult();
+ });
+ }
+
+ public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
+}
diff --git a/TUnit.AspNetCore.Tests/IHttpClientFactoryPropagationTests.cs b/TUnit.AspNetCore.Tests/IHttpClientFactoryPropagationTests.cs
new file mode 100644
index 0000000000..ff193e3bc3
--- /dev/null
+++ b/TUnit.AspNetCore.Tests/IHttpClientFactoryPropagationTests.cs
@@ -0,0 +1,56 @@
+using System.Diagnostics;
+using TUnit.AspNetCore;
+using TUnit.Core;
+
+namespace TUnit.AspNetCore.Tests;
+
+///
+/// Coverage for thomhurst/TUnit#5590 — the SUT's pipelines
+/// must automatically carry the test's trace context when
+/// is true.
+///
+public class IHttpClientFactoryPropagationTests : WebApplicationTest
+{
+ [Test]
+ public async Task SutHttpClientFactory_Propagates_TestContextHeaders()
+ {
+ using var activity = new Activity("outer-test-span").Start();
+
+ using var client = Factory.CreateClient();
+
+ var response = await client.GetAsync("/proxy");
+ response.EnsureSuccessStatusCode();
+
+ var echoed = await response.Content.ReadAsStringAsync();
+
+ await Assert.That(echoed).Contains("traceparent:");
+ await Assert.That(echoed).Contains(TUnitTestIdHandler.HeaderName + ": " + TestContext.Current!.Id);
+ await Assert.That(echoed).Contains("baggage:");
+ await Assert.That(echoed).Contains(activity.TraceId.ToString());
+ }
+}
+
+public class IHttpClientFactoryPropagationOptOutTests : WebApplicationTest
+{
+ protected override void ConfigureTestOptions(WebApplicationTestOptions options)
+ {
+ options.AutoPropagateHttpClientFactory = false;
+ }
+
+ [Test]
+ public async Task SutHttpClientFactory_DoesNotPropagate_WhenAutoPropagationDisabled()
+ {
+ using var activity = new Activity("outer-test-span").Start();
+
+ using var client = Factory.CreateClient();
+
+ var response = await client.GetAsync("/proxy");
+ response.EnsureSuccessStatusCode();
+
+ var echoed = await response.Content.ReadAsStringAsync();
+
+ await Assert.That(echoed).DoesNotContain(TUnitTestIdHandler.HeaderName);
+ await Assert.That(echoed).DoesNotContain("traceparent:");
+ await Assert.That(echoed).DoesNotContain("baggage:");
+ }
+}
diff --git a/TUnit.AspNetCore.Tests/TUnit.AspNetCore.Tests.csproj b/TUnit.AspNetCore.Tests/TUnit.AspNetCore.Tests.csproj
index ac3c24f9bc..8ae0077e6a 100644
--- a/TUnit.AspNetCore.Tests/TUnit.AspNetCore.Tests.csproj
+++ b/TUnit.AspNetCore.Tests/TUnit.AspNetCore.Tests.csproj
@@ -30,6 +30,10 @@
+
+
+
+
diff --git a/TUnit.Aspire.Tests/Helpers/OtlpTraceCaptureServer.cs b/TUnit.Aspire.Tests/Helpers/OtlpTraceCaptureServer.cs
index 718fef5a2e..ced2d85e96 100644
--- a/TUnit.Aspire.Tests/Helpers/OtlpTraceCaptureServer.cs
+++ b/TUnit.Aspire.Tests/Helpers/OtlpTraceCaptureServer.cs
@@ -1,5 +1,5 @@
using System.Net;
-using System.Net.Sockets;
+using TUnit.OpenTelemetry.Receiver;
namespace TUnit.Aspire.Tests.Helpers;
@@ -20,7 +20,7 @@ internal sealed class OtlpTraceCaptureServer : IAsyncDisposable
public OtlpTraceCaptureServer()
{
- (_listener, Port) = CreateListener();
+ (_listener, Port) = LoopbackHttpListenerFactory.Create();
}
public void Start()
@@ -167,42 +167,6 @@ private void EnqueueAndSignal(CapturedRequest captured)
}
}
- // HttpListener has no port-0 bind; probe TcpListener for a free port and retry to
- // mitigate (cannot fully eliminate) the TOCTOU race against another process binding
- // the same port before HttpListener.Start completes.
- private static (HttpListener Listener, int Port) CreateListener()
- {
- const int maxAttempts = 10;
- HttpListenerException? lastError = null;
-
- for (var attempt = 0; attempt < maxAttempts; attempt++)
- {
- int port;
- using (var tcpListener = new TcpListener(IPAddress.Loopback, 0))
- {
- tcpListener.Start();
- port = ((IPEndPoint)tcpListener.LocalEndpoint).Port;
- tcpListener.Stop();
- }
-
- var listener = new HttpListener();
- listener.Prefixes.Add($"http://127.0.0.1:{port}/");
- try
- {
- listener.Start();
- return (listener, port);
- }
- catch (HttpListenerException ex)
- {
- lastError = ex;
- ((IDisposable)listener).Dispose();
- }
- }
-
- throw new InvalidOperationException(
- $"Failed to bind a loopback HttpListener after {maxAttempts} attempts.", lastError);
- }
-
private sealed record PendingWait(string Path, TaskCompletionSource Completion);
}
diff --git a/TUnit.Aspire.Tests/OtlpLogParserTests.cs b/TUnit.Aspire.Tests/OtlpLogParserTests.cs
index 9ea8da2bb9..ae4b85f79c 100644
--- a/TUnit.Aspire.Tests/OtlpLogParserTests.cs
+++ b/TUnit.Aspire.Tests/OtlpLogParserTests.cs
@@ -1,5 +1,5 @@
-using TUnit.Aspire.Telemetry;
using TUnit.Aspire.Tests.Helpers;
+using TUnit.OpenTelemetry.Receiver;
using TUnit.Assertions;
using TUnit.Assertions.Extensions;
using TUnit.Core;
diff --git a/TUnit.Aspire.Tests/OtlpReceiverTests.cs b/TUnit.Aspire.Tests/OtlpReceiverTests.cs
index ce73ac8790..4194f76c60 100644
--- a/TUnit.Aspire.Tests/OtlpReceiverTests.cs
+++ b/TUnit.Aspire.Tests/OtlpReceiverTests.cs
@@ -1,7 +1,7 @@
using System.Diagnostics;
using System.Net;
-using TUnit.Aspire.Telemetry;
using TUnit.Aspire.Tests.Helpers;
+using TUnit.OpenTelemetry.Receiver;
using TUnit.Assertions;
using TUnit.Assertions.Extensions;
using TUnit.Core;
diff --git a/TUnit.Aspire.Tests/TestTraceExporterTests.cs b/TUnit.Aspire.Tests/TestTraceExporterTests.cs
index b5a7e4dd63..85e136a780 100644
--- a/TUnit.Aspire.Tests/TestTraceExporterTests.cs
+++ b/TUnit.Aspire.Tests/TestTraceExporterTests.cs
@@ -1,5 +1,7 @@
using System.Diagnostics;
using System.Text;
+using OpenTelemetry;
+using OpenTelemetry.Trace;
using TUnit.Aspire.Telemetry;
using TUnit.Aspire.Tests.Helpers;
using TUnit.Assertions;
@@ -30,38 +32,28 @@ await Assert.That(TestTraceExporter.TryParseDashboardEndpoint("http://127.0.0.1:
}
[Test]
- public async Task CreateTracerProvider_ExportsTracesForRegisteredSource()
+ public async Task AddToBuilder_ExportsTracesForRegisteredSource()
{
await using var server = new OtlpTraceCaptureServer();
server.Start();
- // Per-test ActivitySource name keeps spans isolated from any other test or production
- // listener, so this test stays parallel-safe even though OpenTelemetry exporters are
- // process-wide.
var sourceName = $"TUnit.Tests.{Guid.NewGuid():N}";
var endpoint = new Uri($"http://127.0.0.1:{server.Port}");
+ var builder = Sdk.CreateTracerProviderBuilder().AddSource(sourceName);
+ TestTraceExporter.AddToBuilder(builder, GetCurrentSessionContext(), endpoint);
+
using (var activitySource = new ActivitySource(sourceName))
- using (var provider = TestTraceExporter.CreateTracerProvider(
- endpoint, GetCurrentSessionContext(), sourceName))
+ using (var provider = builder.Build())
{
- using var testCase = activitySource.StartActivity("test case", ActivityKind.Internal);
- using var testBody = activitySource.StartActivity("test body", ActivityKind.Internal);
-
- await Assert.That(testCase).IsNotNull();
- await Assert.That(testBody).IsNotNull();
-
- testBody?.Stop();
+ using var testCase = activitySource.StartActivity("add-to-builder case", ActivityKind.Internal);
testCase?.Stop();
}
- // Disposing the provider flushes pending spans to the exporter.
var request = await server.WaitForRequestAsync("/v1/traces");
var body = Encoding.UTF8.GetString(request.Body);
- await Assert.That(body).Contains("test case");
- await Assert.That(body).Contains("test body");
- await Assert.That(body).Contains(typeof(TestTraceExporterTests).Assembly.GetName().Name!);
+ await Assert.That(body).Contains("add-to-builder case");
}
[Test]
diff --git a/TUnit.Aspire/AspireFixture.cs b/TUnit.Aspire/AspireFixture.cs
index 409aefc943..dcbf887b5d 100644
--- a/TUnit.Aspire/AspireFixture.cs
+++ b/TUnit.Aspire/AspireFixture.cs
@@ -8,6 +8,7 @@
using Microsoft.Extensions.Hosting;
using TUnit.Core;
using TUnit.Core.Interfaces;
+using TUnit.OpenTelemetry.Receiver;
namespace TUnit.Aspire;
@@ -27,7 +28,7 @@ public class AspireFixture : IAsyncInitializer, IAsyncDisposable
where TAppHost : class
{
private DistributedApplication? _app;
- private Telemetry.OtlpReceiver? _otlpReceiver;
+ private OtlpReceiver? _otlpReceiver;
private HttpMessageHandler? _httpHandler;
///
@@ -377,7 +378,7 @@ private void StartOtlpReceiver()
// Check if there's an existing upstream OTLP endpoint (e.g., Aspire dashboard)
// that we should forward to after processing.
var upstreamEndpoint = Environment.GetEnvironmentVariable(DashboardOtlpEndpointEnvVar);
- _otlpReceiver = new Telemetry.OtlpReceiver(upstreamEndpoint);
+ _otlpReceiver = new OtlpReceiver(upstreamEndpoint);
_otlpReceiver.Start();
}
diff --git a/TUnit.Aspire/AspireTelemetryHooks.cs b/TUnit.Aspire/AspireTelemetryHooks.cs
index e2595b5288..0802f13cba 100644
--- a/TUnit.Aspire/AspireTelemetryHooks.cs
+++ b/TUnit.Aspire/AspireTelemetryHooks.cs
@@ -1,23 +1,26 @@
using TUnit.Aspire.Telemetry;
using TUnit.Core;
+using TUnit.OpenTelemetry;
namespace TUnit.Aspire;
///
-/// Starts and stops the Aspire runner trace exporter for the current test session.
-/// This keeps per-test TUnit spans visible in external OTLP backends alongside SUT spans.
+/// Registers a Configure callback on so that the shared
+/// auto-wired TracerProvider exports spans to the Aspire dashboard's OTLP endpoint when
+/// DOTNET_DASHBOARD_OTLP_ENDPOINT_URL is present.
///
public static class AspireTelemetryHooks
{
- [Before(HookType.TestSession, Order = int.MaxValue)]
- public static void StartRunnerTraceExport(TestSessionContext context)
+ [Before(HookType.TestDiscovery, Order = AutoStart.AutoStartOrder - 1)]
+ public static void RegisterAspireExporter(TestSessionContext context)
{
- TestTraceExporter.TryStart(context);
- }
+ var endpoint = TestTraceExporter.TryGetDashboardEndpoint();
+ if (endpoint is null)
+ {
+ return;
+ }
- [After(HookType.TestSession, Order = int.MaxValue)]
- public static void StopRunnerTraceExport()
- {
- TestTraceExporter.Stop();
+ TUnitOpenTelemetry.Configure(builder =>
+ TestTraceExporter.AddToBuilder(builder, context, endpoint));
}
}
diff --git a/TUnit.Aspire/Http/TUnitBaggagePropagationHandler.cs b/TUnit.Aspire/Http/TUnitBaggagePropagationHandler.cs
index 7db4c59af2..1f6bc05290 100644
--- a/TUnit.Aspire/Http/TUnitBaggagePropagationHandler.cs
+++ b/TUnit.Aspire/Http/TUnitBaggagePropagationHandler.cs
@@ -38,12 +38,13 @@ protected override Task SendAsync(
}
});
- if (!request.Headers.Contains("baggage")
+ if (!request.Headers.Contains(TUnitActivitySource.BaggageHeader)
&& TUnitActivitySource.TryBuildBaggageHeader(activity) is { } baggage)
{
- // Older target frameworks still default to Correlation-Context for baggage.
- // Emit W3C baggage explicitly so backend correlation is stable everywhere.
- request.Headers.TryAddWithoutValidation("baggage", baggage);
+ // Belt-and-braces for users who opt out of TUnit's W3C propagator alignment
+ // via TUNIT_KEEP_LEGACY_PROPAGATOR=1: LegacyPropagator emits Correlation-Context
+ // only, so still emit W3C baggage explicitly for backend correlation.
+ request.Headers.TryAddWithoutValidation(TUnitActivitySource.BaggageHeader, baggage);
}
}
diff --git a/TUnit.Aspire/TUnit.Aspire.csproj b/TUnit.Aspire/TUnit.Aspire.csproj
index c3da2a4737..fe080d9d1d 100644
--- a/TUnit.Aspire/TUnit.Aspire.csproj
+++ b/TUnit.Aspire/TUnit.Aspire.csproj
@@ -17,11 +17,11 @@
+
-
diff --git a/TUnit.Aspire/Telemetry/TestTraceExporter.cs b/TUnit.Aspire/Telemetry/TestTraceExporter.cs
index 4c54696919..7d64697bec 100644
--- a/TUnit.Aspire/Telemetry/TestTraceExporter.cs
+++ b/TUnit.Aspire/Telemetry/TestTraceExporter.cs
@@ -1,4 +1,3 @@
-using System.Diagnostics;
using OpenTelemetry;
using OpenTelemetry.Exporter;
using OpenTelemetry.Resources;
@@ -8,86 +7,27 @@
namespace TUnit.Aspire.Telemetry;
///
-/// Exports TUnit's per-test spans to the same OTLP backend used by the Aspire dashboard.
-/// This keeps backend trace trees navigable without requiring users to configure a separate
-/// tracer provider in their test project.
+/// Helpers that wire the Aspire dashboard's OTLP endpoint into a
+/// . The provider itself is owned by
+/// TUnit.OpenTelemetry.AutoStart; this class only contributes configuration.
///
internal static class TestTraceExporter
{
private const string DashboardOtlpEndpointEnvVar = "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL";
- private const string TUnitSourceName = "TUnit";
private const string DefaultServiceName = "TUnit.Tests";
- private static readonly Lock SyncLock = new();
- private static TracerProvider? _tracerProvider;
+ internal static Uri? TryGetDashboardEndpoint()
+ => TryParseDashboardEndpoint(Environment.GetEnvironmentVariable(DashboardOtlpEndpointEnvVar));
- internal static bool IsStarted
+ internal static void AddToBuilder(TracerProviderBuilder builder, TestSessionContext context, Uri endpoint)
{
- get
- {
- lock (SyncLock)
- {
- return _tracerProvider is not null;
- }
- }
- }
-
- internal static void TryStart(TestSessionContext context)
- {
- var endpoint = TryParseDashboardEndpoint(
- Environment.GetEnvironmentVariable(DashboardOtlpEndpointEnvVar));
-
- if (endpoint is null)
- {
- return;
- }
-
- lock (SyncLock)
- {
- if (_tracerProvider is not null)
- {
- return;
- }
-
- _tracerProvider = CreateTracerProvider(endpoint, context, TUnitSourceName);
- }
- }
-
- ///
- /// Builds a standalone for the given endpoint and session.
- /// Caller owns disposal. Used by for the singleton case and by
- /// tests that need an isolated provider (parallel-safe — no static state touched).
- ///
- internal static TracerProvider CreateTracerProvider(
- Uri endpoint, TestSessionContext context, string sourceName)
- {
- return Sdk.CreateTracerProviderBuilder()
+ builder
.SetResourceBuilder(CreateResourceBuilder(context))
- .AddSource(sourceName)
.AddOtlpExporter(options =>
{
options.Endpoint = GetTracesEndpoint(endpoint);
options.Protocol = OtlpExportProtocol.HttpProtobuf;
- })
- .Build();
- }
-
- internal static void Stop()
- {
- TracerProvider? providerToDispose = null;
-
- lock (SyncLock)
- {
- if (_tracerProvider is null)
- {
- return;
- }
-
- providerToDispose = _tracerProvider;
- _tracerProvider = null;
- }
-
- providerToDispose.Dispose();
+ });
}
internal static Uri? TryParseDashboardEndpoint(string? value)
diff --git a/TUnit.Assertions/Conditions/Wrappers/CountWrapper.cs b/TUnit.Assertions/Conditions/Wrappers/CountWrapper.cs
new file mode 100644
index 0000000000..0bf6549781
--- /dev/null
+++ b/TUnit.Assertions/Conditions/Wrappers/CountWrapper.cs
@@ -0,0 +1,190 @@
+using System.Collections;
+using System.Linq;
+using System.Runtime.CompilerServices;
+using System.Text;
+using TUnit.Assertions.Conditions;
+using TUnit.Assertions.Core;
+using TUnit.Assertions.Extensions;
+
+namespace TUnit.Assertions.Conditions.Wrappers;
+
+///
+/// Wrapper for collection count assertions that provides .EqualTo() method.
+/// Example: await Assert.That(list).Count().EqualTo(5);
+///
+public class CountWrapper : IAssertionSource
+ where TCollection : IEnumerable
+{
+ private readonly AssertionContext _context;
+
+ public CountWrapper(AssertionContext context)
+ {
+ _context = context;
+ }
+
+ AssertionContext IAssertionSource.Context => _context;
+
+ private static int GetCount(TCollection? value) =>
+ value is null ? 0
+ : value is ICollection collection ? collection.Count
+ : value.Cast
diff --git a/TUnit.Core/TUnitActivitySource.cs b/TUnit.Core/TUnitActivitySource.cs
index 64d7652802..5f547cb713 100644
--- a/TUnit.Core/TUnitActivitySource.cs
+++ b/TUnit.Core/TUnitActivitySource.cs
@@ -12,6 +12,16 @@ public static class TUnitActivitySource
internal const string SourceName = "TUnit";
internal const string LifecycleSourceName = "TUnit.Lifecycle";
+ ///
+ /// Activity source emitted by TUnit's ASP.NET Core HTTP propagation handlers.
+ /// Registered automatically on the SUT's
+ /// listeners by TestWebApplicationFactory.
+ ///
+ public const string AspNetCoreHttpSourceName = "TUnit.AspNetCore.Http";
+
+ /// W3C baggage HTTP header name.
+ internal const string BaggageHeader = "baggage";
+
internal static readonly ActivitySource Source = new(SourceName, Version);
internal static readonly ActivitySource LifecycleSource = new(LifecycleSourceName, Version);
diff --git a/TUnit.Core/TestBuilderContext.cs b/TUnit.Core/TestBuilderContext.cs
index e6dbbdc43f..80809b8e0b 100644
--- a/TUnit.Core/TestBuilderContext.cs
+++ b/TUnit.Core/TestBuilderContext.cs
@@ -37,6 +37,10 @@ public static TestBuilderContext? Current
set => _stateBag = value;
}
+ ///
+ [Obsolete("Use StateBag property instead.")]
+ public ConcurrentDictionary ObjectBag => StateBag;
+
internal void CopyStateBagTo(TestBuilderContext target)
{
if (_stateBag is { IsEmpty: false } bag)
diff --git a/TUnit.Core/TestContext.Output.cs b/TUnit.Core/TestContext.Output.cs
index a09356e184..57e3a8bc01 100644
--- a/TUnit.Core/TestContext.Output.cs
+++ b/TUnit.Core/TestContext.Output.cs
@@ -14,9 +14,12 @@ internal record TimingEntry(string StepName, DateTimeOffset Start, DateTimeOffse
///
public partial class TestContext
{
- // Internal backing fields and properties
- // Timings are written sequentially by the framework during test execution, never by user code.
+ // Internal backing fields and properties.
+ // Engine writes are sequential per-test (lifecycle-ordered).
+ // User-facing writes via the obsolete ITestOutput.RecordTiming API may be concurrent,
+ // so all access through the obsolete bridge takes _timingsLock.
internal List Timings { get; } = [];
+ private readonly Lock _timingsLock = new();
// Artifacts use a lock because AttachArtifact is user-facing and can be called
// from parallel Task.WhenAll branches within a single test.
private readonly Lock _artifactsLock = new();
@@ -29,6 +32,24 @@ public partial class TestContext
TextWriter ITestOutput.ErrorOutput => ErrorOutputWriter;
IReadOnlyCollection ITestOutput.Artifacts => Artifacts;
+#pragma warning disable CS0618 // Obsolete Timing API — bridge to internal TimingEntry storage
+ IReadOnlyCollection ITestOutput.Timings
+ {
+ get
+ {
+ lock (_timingsLock)
+ {
+ return Timings.ConvertAll(t => new Timing(t.StepName, t.Start, t.End));
+ }
+ }
+ }
+
+ void ITestOutput.RecordTiming(Timing timing)
+ {
+ lock (_timingsLock) Timings.Add(new TimingEntry(timing.StepName, timing.Start, timing.End));
+ }
+#pragma warning restore CS0618
+
void ITestOutput.AttachArtifact(Artifact artifact)
{
lock (_artifactsLock) _artifacts.Add(artifact);
diff --git a/TUnit.Core/Timing.cs b/TUnit.Core/Timing.cs
new file mode 100644
index 0000000000..096c9f6a78
--- /dev/null
+++ b/TUnit.Core/Timing.cs
@@ -0,0 +1,7 @@
+namespace TUnit.Core;
+
+[Obsolete("Use OpenTelemetry activity spans instead. Hook timings are now automatically recorded as OTel child spans of the test activity.")]
+public record Timing(string StepName, DateTimeOffset Start, DateTimeOffset End)
+{
+ public TimeSpan Duration => End - Start;
+}
diff --git a/TUnit.Dev.slnx b/TUnit.Dev.slnx
index b8541146e4..333c1d5a6f 100644
--- a/TUnit.Dev.slnx
+++ b/TUnit.Dev.slnx
@@ -17,6 +17,7 @@
+
@@ -33,6 +34,7 @@
+
@@ -60,6 +62,7 @@
+
diff --git a/TUnit.Engine.Tests/HtmlReporterTests.cs b/TUnit.Engine.Tests/HtmlReporterTests.cs
index 030df930e8..c94041b47a 100644
--- a/TUnit.Engine.Tests/HtmlReporterTests.cs
+++ b/TUnit.Engine.Tests/HtmlReporterTests.cs
@@ -70,6 +70,48 @@ public async Task PublishArtifactAsync_Is_NoOp_When_MessageBus_Not_Injected()
}
+ [Test]
+ public void FilterAdditionalTraceIds_Removes_Primary_Trace_CaseInsensitive()
+ {
+ var primary = "abcdef0123456789abcdef0123456789";
+ var linked = "1111111111111111aaaaaaaaaaaaaaaa";
+ var all = new[] { primary.ToUpperInvariant(), linked };
+
+ var result = HtmlReporter.FilterAdditionalTraceIds(all, primary);
+
+ result.ShouldBe(new[] { linked });
+ }
+
+ [Test]
+ public void FilterAdditionalTraceIds_Returns_Input_When_Primary_Null()
+ {
+ var all = new[] { "aaaa", "bbbb" };
+
+ var result = HtmlReporter.FilterAdditionalTraceIds(all, primaryTraceId: null);
+
+ result.ShouldBeSameAs(all);
+ }
+
+ [Test]
+ public void FilterAdditionalTraceIds_Returns_Input_When_No_Match()
+ {
+ var all = new[] { "aaaa", "bbbb" };
+
+ var result = HtmlReporter.FilterAdditionalTraceIds(all, "cccc");
+
+ result.ShouldBeSameAs(all);
+ }
+
+ [Test]
+ public void FilterAdditionalTraceIds_Returns_Empty_When_Only_Primary()
+ {
+ var primary = "abcdef0123456789abcdef0123456789";
+
+ var result = HtmlReporter.FilterAdditionalTraceIds(new[] { primary }, primary);
+
+ result.ShouldBeEmpty();
+ }
+
[Test]
public async Task PublishArtifactAsync_Publishes_With_Correct_SessionUid()
{
diff --git a/TUnit.Engine/Configuration/EnvironmentConstants.cs b/TUnit.Engine/Configuration/EnvironmentConstants.cs
index ee293a94fa..db37e60c20 100644
--- a/TUnit.Engine/Configuration/EnvironmentConstants.cs
+++ b/TUnit.Engine/Configuration/EnvironmentConstants.cs
@@ -17,6 +17,7 @@ internal static class EnvironmentConstants
public const string DisableLogo = "TUNIT_DISABLE_LOGO";
public const string EnableIdeStreaming = "TUNIT_ENABLE_IDE_STREAMING";
public const string DiscoveryDiagnostics = "TUNIT_DISCOVERY_DIAGNOSTICS";
+ public const string MaxOtelExternalSpans = "TUNIT_OTEL_MAX_EXTERNAL_SPANS";
// TUnit-specific: JUnit output
public const string JUnitXmlOutputPath = "JUNIT_XML_OUTPUT_PATH";
diff --git a/TUnit.Engine/Reporters/Html/ActivityCollector.cs b/TUnit.Engine/Reporters/Html/ActivityCollector.cs
index 0eba6f28dc..83d3d1d5fe 100644
--- a/TUnit.Engine/Reporters/Html/ActivityCollector.cs
+++ b/TUnit.Engine/Reporters/Html/ActivityCollector.cs
@@ -2,28 +2,67 @@
using System.Collections.Concurrent;
using System.Diagnostics;
using TUnit.Core;
+using TUnit.Engine.Configuration;
namespace TUnit.Engine.Reporters.Html;
internal sealed class ActivityCollector : IDisposable
{
- // Cap external (non-TUnit) spans per test to keep the report manageable.
- // TUnit's own spans are always captured regardless of caps.
- // Soft cap — intentionally racy for performance; may be slightly exceeded under high concurrency.
- private const int MaxExternalSpansPerTest = 100;
- // Fallback cap applied per trace when the test case association cannot be determined
- // (e.g. broken Activity.Parent chains from async connection pooling).
- private const int MaxExternalSpansPerTrace = 100;
-
- private readonly ConcurrentDictionary> _spansByTrace = new();
- // Track external span count per test case (keyed by test case span ID)
- private readonly ConcurrentDictionary _externalSpanCountsByTest = new();
- // Fallback: per-trace cap for external spans whose parent chain is broken
- // (e.g. Npgsql async pooling where Activity.Parent is null but traceId is correct)
- private readonly ConcurrentDictionary _externalSpanCountsByTrace = new();
+ // Cap external (non-TUnit) spans to keep the report manageable. Applied per test,
+ // or per trace when the test-case association can't be determined (e.g. broken
+ // Activity.Parent chains from async connection pooling). TUnit's own spans are
+ // always captured regardless. Soft cap — intentionally racy for performance; may
+ // be slightly exceeded under high concurrency. Override via
+ // EnvironmentConstants.MaxOtelExternalSpans for users with busy SUTs.
+ private const int DefaultMaxExternalSpans = 100;
+ internal static readonly int MaxExternalSpans = ResolveExternalSpanCap();
+
+ private static int _capWarningEmitted;
+
+ private static int ResolveExternalSpanCap()
+ {
+ var raw = Environment.GetEnvironmentVariable(EnvironmentConstants.MaxOtelExternalSpans);
+ if (!string.IsNullOrEmpty(raw)
+ && int.TryParse(raw, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var parsed)
+ && parsed > 0)
+ {
+ return parsed;
+ }
+
+ return DefaultMaxExternalSpans;
+ }
+
+ private static void WarnCapHitOnce()
+ {
+ // CompareExchange avoids re-writing the flag on every overflow span once the
+ // cap has been breached — on busy SUTs this runs at every dropped span.
+ if (Interlocked.CompareExchange(ref _capWarningEmitted, 1, 0) == 0)
+ {
+ Console.Error.WriteLine(
+ $"[TUnit] External span cap of {MaxExternalSpans} reached; subsequent spans will be dropped. " +
+ $"Set {EnvironmentConstants.MaxOtelExternalSpans} to raise the limit.");
+ }
+ }
+
+ // Process-wide pointer to the currently-running collector, used by the OTLP receiver
+ // (in TUnit.OpenTelemetry) to feed external spans without an explicit wiring step.
+ // Only one HtmlReporter runs per session, so a static slot is sufficient.
+ private static ActivityCollector? _current;
+
+ public static ActivityCollector? Current => _current;
+
+ // All trace/span ID dictionaries use OrdinalIgnoreCase because external spans
+ // arrive hex-encoded as uppercase (Convert.ToHexString) while in-process Activity
+ // IDs serialize lowercase. Without case-insensitive keys the two would split into
+ // separate buckets for the same logical trace.
+ private readonly ConcurrentDictionary> _spansByTrace = new(StringComparer.OrdinalIgnoreCase);
+ private readonly ConcurrentDictionary _externalSpanCountsByTest = new(StringComparer.OrdinalIgnoreCase);
+ // Fallback per-trace cap for external spans whose parent chain is broken
+ // (e.g. Npgsql async pooling where Activity.Parent is null but traceId is correct).
+ private readonly ConcurrentDictionary _externalSpanCountsByTrace = new(StringComparer.OrdinalIgnoreCase);
// Known test case span IDs, populated at activity start time so they're available
// before child spans stop (children stop before parents in Activity ordering).
- private readonly ConcurrentDictionary _testCaseSpanIds = new();
+ private readonly ConcurrentDictionary _testCaseSpanIds = new(StringComparer.OrdinalIgnoreCase);
// Fast-path cache of trace IDs that should be collected. Subsumes TraceRegistry lookups
// so that subsequent activities on the same trace avoid cross-class dictionary checks.
private readonly ConcurrentDictionary _knownTraceIds = new(StringComparer.OrdinalIgnoreCase);
@@ -31,6 +70,10 @@ internal sealed class ActivityCollector : IDisposable
public void Start()
{
+ // First-started-wins: HtmlReporter creates one collector per session before any
+ // test runs, so this slot is claimed for the rest of the session. Later ad-hoc
+ // collectors (e.g. created from a test) don't race-steal the global pointer.
+ Interlocked.CompareExchange(ref _current, this, null);
// Listen to ALL sources so we can capture child spans from HttpClient, ASP.NET Core,
// EF Core, etc. The Sample callback uses smart filtering to avoid overhead: only spans
// belonging to known test traces are fully recorded; everything else gets PropagationData
@@ -111,10 +154,70 @@ private ActivitySamplingResult SampleActivityUsingParentId(ref ActivityCreationO
public void Stop()
{
+ Interlocked.CompareExchange(ref _current, null, this);
_listener?.Dispose();
_listener = null;
}
+ ///
+ /// Marks a trace ID as eligible for external span ingestion. Called by the OTLP
+ /// receiver when the test process has started or observed a trace that external
+ /// processes (e.g. a WebApplicationFactory SUT) may report spans against.
+ ///
+ internal void RegisterExternalTrace(string traceId)
+ {
+ _knownTraceIds.TryAdd(traceId, 0);
+ }
+
+ ///
+ /// Enqueues an externally-sourced span (typically from an OTLP receiver) into
+ /// the report. Dropped if the trace is not known, or if per-test/per-trace caps
+ /// for external spans have been exceeded.
+ ///
+ internal void IngestExternalSpan(SpanData span)
+ {
+ if (!_knownTraceIds.ContainsKey(span.TraceId))
+ {
+ return;
+ }
+
+ // Prefer per-test cap when the span's direct parent is a known test case span.
+ // Falls back to per-trace cap otherwise, mirroring OnActivityStopped's logic.
+ if (span.ParentSpanId is { } parentSpanId && _testCaseSpanIds.ContainsKey(parentSpanId))
+ {
+ if (_externalSpanCountsByTest.TryGetValue(parentSpanId, out var existing) && existing >= MaxExternalSpans)
+ {
+ WarnCapHitOnce();
+ return;
+ }
+
+ var count = _externalSpanCountsByTest.AddOrUpdate(parentSpanId, 1, static (_, c) => c + 1);
+ if (count > MaxExternalSpans)
+ {
+ WarnCapHitOnce();
+ return;
+ }
+ }
+ else
+ {
+ if (_externalSpanCountsByTrace.TryGetValue(span.TraceId, out var existing) && existing >= MaxExternalSpans)
+ {
+ WarnCapHitOnce();
+ return;
+ }
+
+ var count = _externalSpanCountsByTrace.AddOrUpdate(span.TraceId, 1, static (_, c) => c + 1);
+ if (count > MaxExternalSpans)
+ {
+ WarnCapHitOnce();
+ return;
+ }
+ }
+
+ var queue = _spansByTrace.GetOrAdd(span.TraceId, static _ => new ConcurrentQueue());
+ queue.Enqueue(span);
+ }
+
public SpanData[] GetAllSpans()
{
return _spansByTrace.Values.SelectMany(q => q).ToArray();
@@ -255,8 +358,9 @@ private void OnActivityStopped(Activity activity)
if (testSpanId is not null)
{
var count = _externalSpanCountsByTest.AddOrUpdate(testSpanId, 1, (_, c) => c + 1);
- if (count > MaxExternalSpansPerTest)
+ if (count > MaxExternalSpans)
{
+ WarnCapHitOnce();
return;
}
}
@@ -265,8 +369,9 @@ private void OnActivityStopped(Activity activity)
// Fallback cap by trace ID to prevent unbounded growth for spans
// with broken parent chains (e.g., Npgsql async connection pooling).
var count = _externalSpanCountsByTrace.AddOrUpdate(traceId, 1, (_, c) => c + 1);
- if (count > MaxExternalSpansPerTrace)
+ if (count > MaxExternalSpans)
{
+ WarnCapHitOnce();
return;
}
}
diff --git a/TUnit.Engine/Reporters/Html/HtmlReporter.cs b/TUnit.Engine/Reporters/Html/HtmlReporter.cs
index 6d9904c868..98bbc2eee4 100644
--- a/TUnit.Engine/Reporters/Html/HtmlReporter.cs
+++ b/TUnit.Engine/Reporters/Html/HtmlReporter.cs
@@ -268,7 +268,7 @@ private ReportData BuildReportData()
}
#if NET
- var additionalTraceIds = TraceRegistry.GetTraceIds(kvp.Key);
+ var additionalTraceIds = FilterAdditionalTraceIds(TraceRegistry.GetTraceIds(kvp.Key), traceId);
string[]? additionalTraceIdsForResult = additionalTraceIds.Length > 0 ? additionalTraceIds : null;
#else
string[]? additionalTraceIdsForResult = null;
@@ -440,6 +440,30 @@ private static void AccumulateStatus(ReportSummary summary, ReportTestResult tes
}
}
+#if NET
+ // The engine auto-registers the test's own traceId in TraceRegistry for OTLP correlation,
+ // so it shows up in GetTraceIds alongside any user-added traces. Strip it here so the
+ // primary trace (rendered as "Trace Timeline") isn't also rendered as a "Linked Trace".
+ internal static string[] FilterAdditionalTraceIds(string[] allTraceIds, string? primaryTraceId)
+ {
+ if (allTraceIds.Length == 0 || primaryTraceId is null)
+ {
+ return allTraceIds;
+ }
+
+ var filtered = new List(allTraceIds.Length);
+ foreach (var tid in allTraceIds)
+ {
+ if (!string.Equals(tid, primaryTraceId, StringComparison.OrdinalIgnoreCase))
+ {
+ filtered.Add(tid);
+ }
+ }
+
+ return filtered.Count == allTraceIds.Length ? allTraceIds : filtered.ToArray();
+ }
+#endif
+
private static ReportTestResult ExtractTestResult(string testId, TestNode testNode, string? traceId, string? spanId, int retryAttempt, string[]? additionalTraceIds)
{
IProperty? stateProperty = null;
diff --git a/TUnit.Engine/TUnit.Engine.csproj b/TUnit.Engine/TUnit.Engine.csproj
index 74ed3125c4..ff217cb492 100644
--- a/TUnit.Engine/TUnit.Engine.csproj
+++ b/TUnit.Engine/TUnit.Engine.csproj
@@ -9,6 +9,8 @@
+
+
diff --git a/TUnit.OpenTelemetry.Tests/ActivityCollectorIngestionTests.cs b/TUnit.OpenTelemetry.Tests/ActivityCollectorIngestionTests.cs
new file mode 100644
index 0000000000..a305e05592
--- /dev/null
+++ b/TUnit.OpenTelemetry.Tests/ActivityCollectorIngestionTests.cs
@@ -0,0 +1,117 @@
+using TUnit.Assertions;
+using TUnit.Assertions.Extensions;
+using TUnit.Engine.Reporters.Html;
+
+namespace TUnit.OpenTelemetry.Tests;
+
+public class ActivityCollectorIngestionTests
+{
+ [Test]
+ public async Task IngestExternalSpan_KnownTrace_AppearsInGetAllSpans()
+ {
+ using var collector = new ActivityCollector();
+ collector.Start();
+
+ var traceId = UniqueTraceId();
+ collector.RegisterExternalTrace(traceId);
+
+ collector.IngestExternalSpan(MakeSpan(traceId, "ABCD567812345678", "external-op"));
+
+ var ours = collector.GetAllSpans().Where(s => s.TraceId == traceId).ToList();
+ await Assert.That(ours.Count).IsEqualTo(1);
+ await Assert.That(ours[0].Name).IsEqualTo("external-op");
+ }
+
+ [Test]
+ public async Task IngestExternalSpan_UnknownTrace_IsDropped()
+ {
+ using var collector = new ActivityCollector();
+ collector.Start();
+
+ var unknownTraceId = UniqueTraceId();
+ collector.IngestExternalSpan(MakeSpan(unknownTraceId, "ABCD567812345678", "op"));
+
+ var ours = collector.GetAllSpans().Where(s => s.TraceId == unknownTraceId).ToList();
+ await Assert.That(ours.Count).IsEqualTo(0);
+ }
+
+ [Test]
+ public async Task IngestExternalSpan_ExceedsPerTraceCap_Drops()
+ {
+ using var collector = new ActivityCollector();
+ collector.Start();
+
+ var traceId = UniqueTraceId();
+ collector.RegisterExternalTrace(traceId);
+
+ var cap = ActivityCollector.MaxExternalSpans;
+ var attempts = cap + 50;
+ for (var i = 0; i < attempts; i++)
+ {
+ collector.IngestExternalSpan(MakeSpan(traceId, $"{i:X16}", $"op-{i}"));
+ }
+
+ var ours = collector.GetAllSpans().Where(s => s.TraceId == traceId).ToList();
+ await Assert.That(ours.Count).IsEqualTo(cap);
+ }
+
+ [Test]
+ public async Task Current_IsNonNull_DuringTestSession()
+ {
+ // HtmlReporter starts its own collector in BeforeRunAsync, so Current
+ // is populated before this test runs. We don't assert *which* collector
+ // it is — parallel tests may compete — only that the wiring is alive.
+ await Assert.That(ActivityCollector.Current).IsNotNull();
+ }
+
+ [Test]
+ public async Task IngestExternalSpan_TraceIdCaseMismatch_StillCorrelates()
+ {
+ using var collector = new ActivityCollector();
+ collector.Start();
+
+ // Activity.TraceId.ToString() produces lowercase; the OTLP parser produces uppercase.
+ // Registration and ingestion must correlate across that case boundary.
+ var registeredLower = UniqueTraceId().ToLowerInvariant();
+ var ingestedUpper = registeredLower.ToUpperInvariant();
+ collector.RegisterExternalTrace(registeredLower);
+
+ collector.IngestExternalSpan(MakeSpan(ingestedUpper, "ABCD567812345678", "op"));
+
+ var spans = collector.GetAllSpans()
+ .Where(s => string.Equals(s.TraceId, registeredLower, StringComparison.OrdinalIgnoreCase))
+ .ToList();
+ await Assert.That(spans.Count).IsEqualTo(1);
+ }
+
+ [Test]
+ public async Task IngestExternalSpan_UnknownParent_FallsBackToPerTraceCap()
+ {
+ using var collector = new ActivityCollector();
+ collector.Start();
+
+ var traceId = UniqueTraceId();
+ collector.RegisterExternalTrace(traceId);
+
+ // No test case span registered, so ParentSpanId falls through to per-trace cap.
+ collector.IngestExternalSpan(MakeSpan(traceId, "AAAA000000000001", "op", parentSpanId: "UNKNOWNPARENTID0"));
+
+ var ours = collector.GetAllSpans().Where(s => s.TraceId == traceId).ToList();
+ await Assert.That(ours.Count).IsEqualTo(1);
+ }
+
+ private static string UniqueTraceId() => Guid.NewGuid().ToString("N").ToUpperInvariant();
+
+ private static SpanData MakeSpan(string traceId, string spanId, string name, string? parentSpanId = null) => new()
+ {
+ TraceId = traceId,
+ SpanId = spanId,
+ ParentSpanId = parentSpanId,
+ Name = name,
+ Source = "external",
+ Kind = "Internal",
+ Status = "Ok",
+ StartTimeMs = 1000,
+ DurationMs = 10,
+ };
+}
diff --git a/TUnit.OpenTelemetry.Tests/AutoReceiverTests.cs b/TUnit.OpenTelemetry.Tests/AutoReceiverTests.cs
new file mode 100644
index 0000000000..c693ee625c
--- /dev/null
+++ b/TUnit.OpenTelemetry.Tests/AutoReceiverTests.cs
@@ -0,0 +1,20 @@
+using TUnit.Assertions;
+using TUnit.Assertions.Extensions;
+
+namespace TUnit.OpenTelemetry.Tests;
+
+public class AutoReceiverTests
+{
+ [Test]
+ public async Task AutoReceiver_StartedByHook_ExposesEndpoint()
+ {
+ await Assert.That(AutoReceiver.Endpoint).IsNotNull();
+ await Assert.That(AutoReceiver.Endpoint!).StartsWith("http://127.0.0.1:");
+ }
+
+ [Test]
+ public async Task AutoReceiver_HasReceiverForTesting_True()
+ {
+ await Assert.That(AutoReceiver.HasReceiverForTesting).IsTrue();
+ }
+}
diff --git a/TUnit.OpenTelemetry.Tests/AutoStartTests.cs b/TUnit.OpenTelemetry.Tests/AutoStartTests.cs
new file mode 100644
index 0000000000..cee3067a78
--- /dev/null
+++ b/TUnit.OpenTelemetry.Tests/AutoStartTests.cs
@@ -0,0 +1,139 @@
+using System.Diagnostics;
+using OpenTelemetry;
+using OpenTelemetry.Trace;
+using TUnit.Assertions;
+using TUnit.Assertions.Extensions;
+
+namespace TUnit.OpenTelemetry.Tests;
+
+[NotInParallel("TUnitOpenTelemetryGlobalState")] // these tests mutate process-wide AutoStart + env vars + TUnitOpenTelemetry configurators
+public class AutoStartTests
+{
+ [Test]
+ public async Task AutoStart_RegistersListenerForTUnitSource()
+ {
+ // AutoStart.Start fires via [Before(TestDiscovery)] before this test runs.
+ using var source = new ActivitySource("TUnit");
+ await Assert.That(source.HasListeners()).IsTrue();
+ }
+
+ [Test]
+ public async Task AutoStart_SkipsIfUserAlreadyAttachedListener()
+ {
+ AutoStart.Stop();
+
+ using var userListener = new ActivityListener
+ {
+ ShouldListenTo = s => s.Name == "TUnit",
+ Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData,
+ };
+ ActivitySource.AddActivityListener(userListener);
+
+ AutoStart.StartForTesting(resetFirst: true);
+ try
+ {
+ await Assert.That(AutoStart.HasProviderForTesting).IsFalse();
+ }
+ finally
+ {
+ // Leave AutoStart re-armed for subsequent tests (in this class and the runner
+ // at large) since the session-level AutoStart already disposed once.
+ AutoStart.StartForTesting(resetFirst: true);
+ }
+ }
+
+ [Test]
+ public async Task AutoStart_ForceOn_BuildsEvenWhenListenerPresent()
+ {
+ var original = Environment.GetEnvironmentVariable(AutoStart.AutoStartEnvVar);
+ Environment.SetEnvironmentVariable(AutoStart.AutoStartEnvVar, "1");
+ try
+ {
+ using var userListener = new ActivityListener
+ {
+ ShouldListenTo = s => s.Name == "TUnit",
+ Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData,
+ };
+ ActivitySource.AddActivityListener(userListener);
+
+ TUnitOpenTelemetry.ResetForTests();
+ TUnitOpenTelemetry.Configure(b => b.AddInMemoryExporter(new List()));
+
+ AutoStart.StartForTesting(resetFirst: true);
+ await Assert.That(AutoStart.HasProviderForTesting).IsTrue();
+ }
+ finally
+ {
+ Environment.SetEnvironmentVariable(AutoStart.AutoStartEnvVar, original);
+ TUnitOpenTelemetry.ResetForTests();
+ AutoStart.StartForTesting(resetFirst: true);
+ }
+ }
+
+ [Test]
+ public async Task Start_NoEndpointNoConfig_DoesNotBuildProvider()
+ {
+ var endpointOriginal = Environment.GetEnvironmentVariable(AutoStart.OtlpEndpointEnvVar);
+ var autostartOriginal = Environment.GetEnvironmentVariable(AutoStart.AutoStartEnvVar);
+ Environment.SetEnvironmentVariable(AutoStart.OtlpEndpointEnvVar, null);
+ Environment.SetEnvironmentVariable(AutoStart.AutoStartEnvVar, null);
+ TUnitOpenTelemetry.ResetForTests();
+ try
+ {
+ AutoStart.StartForTesting(resetFirst: true);
+ await Assert.That(AutoStart.HasProviderForTesting).IsFalse();
+ }
+ finally
+ {
+ Environment.SetEnvironmentVariable(AutoStart.OtlpEndpointEnvVar, endpointOriginal);
+ Environment.SetEnvironmentVariable(AutoStart.AutoStartEnvVar, autostartOriginal);
+ AutoStart.StartForTesting(resetFirst: true);
+ }
+ }
+
+ [Test]
+ public async Task Start_WithOptOutEnv_DoesNotBuildProvider()
+ {
+ var original = Environment.GetEnvironmentVariable(AutoStart.AutoStartEnvVar);
+ Environment.SetEnvironmentVariable(AutoStart.AutoStartEnvVar, "0");
+ try
+ {
+ AutoStart.StartForTesting(resetFirst: true);
+ await Assert.That(AutoStart.HasProviderForTesting).IsFalse();
+ }
+ finally
+ {
+ Environment.SetEnvironmentVariable(AutoStart.AutoStartEnvVar, original);
+ // Re-arm for subsequent tests in the runner
+ AutoStart.StartForTesting(resetFirst: true);
+ }
+ }
+
+ [Test]
+ public async Task Start_SetsDefaultServiceName()
+ {
+ var autostartOriginal = Environment.GetEnvironmentVariable(AutoStart.AutoStartEnvVar);
+ Environment.SetEnvironmentVariable(AutoStart.AutoStartEnvVar, "1");
+ TUnitOpenTelemetry.ResetForTests();
+ TUnitOpenTelemetry.Configure(b => b.AddInMemoryExporter(new List()));
+ try
+ {
+ AutoStart.StartForTesting(resetFirst: true);
+
+ var resource = AutoStart.GetResourceForTesting();
+ await Assert.That(resource).IsNotNull();
+ var serviceNameAttr = resource!.Attributes.FirstOrDefault(a => a.Key == "service.name");
+ await Assert.That(serviceNameAttr.Key).IsEqualTo("service.name");
+ var serviceName = serviceNameAttr.Value?.ToString();
+ // Not the OTel default placeholder ("unknown_service" or "unknown_service:")
+ await Assert.That(serviceName).IsNotNull();
+ await Assert.That(serviceName!.StartsWith("unknown_service", StringComparison.Ordinal)).IsFalse();
+ }
+ finally
+ {
+ Environment.SetEnvironmentVariable(AutoStart.AutoStartEnvVar, autostartOriginal);
+ TUnitOpenTelemetry.ResetForTests();
+ AutoStart.StartForTesting(resetFirst: true);
+ }
+ }
+}
diff --git a/TUnit.OpenTelemetry.Tests/ConfigureTests.cs b/TUnit.OpenTelemetry.Tests/ConfigureTests.cs
new file mode 100644
index 0000000000..8273a81960
--- /dev/null
+++ b/TUnit.OpenTelemetry.Tests/ConfigureTests.cs
@@ -0,0 +1,41 @@
+using OpenTelemetry;
+using OpenTelemetry.Trace;
+using TUnit.Assertions;
+using TUnit.Assertions.Extensions;
+
+namespace TUnit.OpenTelemetry.Tests;
+
+[NotInParallel("TUnitOpenTelemetryGlobalState")] // mutates TUnitOpenTelemetry._configurators, which AutoStartTests also touches
+public class ConfigureTests
+{
+ [Test]
+ public async Task Configure_StoresUserCallback()
+ {
+ TUnitOpenTelemetry.ResetForTests();
+ var called = false;
+ TUnitOpenTelemetry.Configure(_ => called = true);
+
+ TUnitOpenTelemetry.ApplyConfiguration(Sdk.CreateTracerProviderBuilder());
+
+ await Assert.That(called).IsTrue();
+ }
+
+ [Test]
+ public async Task Configure_MultipleCalls_AllInvoked()
+ {
+ TUnitOpenTelemetry.ResetForTests();
+ var count = 0;
+ TUnitOpenTelemetry.Configure(_ => count++);
+ TUnitOpenTelemetry.Configure(_ => count++);
+
+ TUnitOpenTelemetry.ApplyConfiguration(Sdk.CreateTracerProviderBuilder());
+
+ await Assert.That(count).IsEqualTo(2);
+ }
+
+ [Test]
+ public async Task Configure_NullCallback_Throws()
+ {
+ await Assert.That(() => TUnitOpenTelemetry.Configure(null!)).Throws();
+ }
+}
diff --git a/TUnit.OpenTelemetry.Tests/CorrelationProcessorTests.cs b/TUnit.OpenTelemetry.Tests/CorrelationProcessorTests.cs
new file mode 100644
index 0000000000..6321b39a30
--- /dev/null
+++ b/TUnit.OpenTelemetry.Tests/CorrelationProcessorTests.cs
@@ -0,0 +1,74 @@
+using System.Diagnostics;
+using OpenTelemetry;
+using TUnit.Assertions;
+using TUnit.Assertions.Extensions;
+
+namespace TUnit.OpenTelemetry.Tests;
+
+public class CorrelationProcessorTests
+{
+ [Test]
+ public async Task Processor_CopiesBaggageToTag()
+ {
+ using var listener = AttachPermissiveListener("CorrelationProcessorTests.Copies");
+ var processor = new TUnitTestCorrelationProcessor();
+
+ using var parent = new Activity("parent").Start();
+ parent.AddBaggage("tunit.test.id", "test-123");
+
+ using var child = new ActivitySource("CorrelationProcessorTests.Copies").StartActivity("child")!;
+ processor.OnStart(child);
+
+ await Assert.That(child.GetTagItem("tunit.test.id")).IsEqualTo("test-123");
+ }
+
+ [Test]
+ public async Task Processor_SkipsWhenAlreadyTagged()
+ {
+ using var listener = AttachPermissiveListener("CorrelationProcessorTests.Skips");
+ var processor = new TUnitTestCorrelationProcessor();
+
+ using var parent = new Activity("parent").Start();
+ parent.AddBaggage("tunit.test.id", "from-baggage");
+
+ using var child = new ActivitySource("CorrelationProcessorTests.Skips").StartActivity("child")!;
+ child.SetTag("tunit.test.id", "already-set");
+ processor.OnStart(child);
+
+ await Assert.That(child.GetTagItem("tunit.test.id")).IsEqualTo("already-set");
+ }
+
+ [Test]
+ public async Task Processor_NoOp_WhenNoBaggage()
+ {
+ using var listener = AttachPermissiveListener("CorrelationProcessorTests.NoBaggage");
+ var processor = new TUnitTestCorrelationProcessor();
+
+ // Suppress the ambient Activity.Current (which the TUnit test runner has set with
+ // tunit.test.id baggage) so we can exercise the "no baggage" code path in isolation.
+ var previous = Activity.Current;
+ Activity.Current = null;
+ try
+ {
+ using var child = new ActivitySource("CorrelationProcessorTests.NoBaggage").StartActivity("child")!;
+ processor.OnStart(child);
+
+ await Assert.That(child.GetTagItem("tunit.test.id")).IsNull();
+ }
+ finally
+ {
+ Activity.Current = previous;
+ }
+ }
+
+ private static ActivityListener AttachPermissiveListener(string sourceName)
+ {
+ var listener = new ActivityListener
+ {
+ ShouldListenTo = s => s.Name == sourceName,
+ Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData,
+ };
+ ActivitySource.AddActivityListener(listener);
+ return listener;
+ }
+}
diff --git a/TUnit.OpenTelemetry.Tests/EndToEndTests.cs b/TUnit.OpenTelemetry.Tests/EndToEndTests.cs
new file mode 100644
index 0000000000..7a3906a493
--- /dev/null
+++ b/TUnit.OpenTelemetry.Tests/EndToEndTests.cs
@@ -0,0 +1,52 @@
+using System.Diagnostics;
+using OpenTelemetry;
+using OpenTelemetry.Trace;
+using TUnit.Assertions;
+using TUnit.Assertions.Extensions;
+
+namespace TUnit.OpenTelemetry.Tests;
+
+[NotInParallel("TUnitOpenTelemetryGlobalState")] // mutates process-wide AutoStart + TUnitOpenTelemetry configurators
+public class EndToEndTests
+{
+ [Test]
+ public async Task TUnitSource_Spans_AreExported()
+ {
+ var autostartOriginal = Environment.GetEnvironmentVariable(AutoStart.AutoStartEnvVar);
+ // Force-build the provider even if a prior listener is still attached so the
+ // InMemory exporter is guaranteed to receive our spans.
+ Environment.SetEnvironmentVariable(AutoStart.AutoStartEnvVar, "1");
+
+ var spans = new List();
+ TUnitOpenTelemetry.ResetForTests();
+ TUnitOpenTelemetry.Configure(b => b.AddInMemoryExporter(spans));
+ AutoStart.StartForTesting(resetFirst: true);
+
+ // Suppress the ambient Activity.Current (the TUnit runner sets tunit.test.id baggage
+ // on it) so the child span we create is independent of the outer test context.
+ var previous = Activity.Current;
+ Activity.Current = null;
+ try
+ {
+ using (var source = new ActivitySource("TUnit"))
+ using (var activity = source.StartActivity("e2e-probe"))
+ {
+ activity?.SetTag("probe", "value");
+ }
+
+ AutoStart.Stop();
+
+ var probe = spans.Single(s => s.OperationName == "e2e-probe");
+ var probeTag = probe.TagObjects.FirstOrDefault(t => t.Key == "probe").Value?.ToString();
+ await Assert.That(probeTag).IsEqualTo("value");
+ }
+ finally
+ {
+ Activity.Current = previous;
+ Environment.SetEnvironmentVariable(AutoStart.AutoStartEnvVar, autostartOriginal);
+ // Re-arm for subsequent tests in this class and the runner at large.
+ TUnitOpenTelemetry.ResetForTests();
+ AutoStart.StartForTesting(resetFirst: true);
+ }
+ }
+}
diff --git a/TUnit.OpenTelemetry.Tests/GlobalUsings.cs b/TUnit.OpenTelemetry.Tests/GlobalUsings.cs
new file mode 100644
index 0000000000..65c1c1561f
--- /dev/null
+++ b/TUnit.OpenTelemetry.Tests/GlobalUsings.cs
@@ -0,0 +1,2 @@
+global using TUnit.Core;
+global using TUnit.OpenTelemetry;
diff --git a/TUnit.OpenTelemetry.Tests/Helpers/OtlpTraceCaptureServer.cs b/TUnit.OpenTelemetry.Tests/Helpers/OtlpTraceCaptureServer.cs
new file mode 100644
index 0000000000..d3121caaec
--- /dev/null
+++ b/TUnit.OpenTelemetry.Tests/Helpers/OtlpTraceCaptureServer.cs
@@ -0,0 +1,164 @@
+using System.Net;
+using TUnit.OpenTelemetry.Receiver;
+
+namespace TUnit.OpenTelemetry.Tests.Helpers;
+
+internal sealed class OtlpTraceCaptureServer : IAsyncDisposable
+{
+ private readonly HttpListener _listener;
+ private readonly CancellationTokenSource _cts = new();
+ private readonly object _stateLock = new();
+ private readonly List _requests = new();
+ private readonly List _waiters = new();
+ private Task? _listenTask;
+
+ public int Port { get; }
+
+ public OtlpTraceCaptureServer()
+ {
+ (_listener, Port) = LoopbackHttpListenerFactory.Create();
+ }
+
+ public void Start()
+ {
+ _listenTask = Task.Run(ListenLoopAsync);
+ }
+
+ public async Task WaitForRequestAsync(string path, int timeoutMs = 5000)
+ {
+ var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var waiter = new PendingWait(path, tcs);
+
+ lock (_stateLock)
+ {
+ foreach (var existing in _requests)
+ {
+ if (existing.Path == path)
+ {
+ return existing;
+ }
+ }
+
+ _waiters.Add(waiter);
+ }
+
+ using var cts = new CancellationTokenSource(timeoutMs);
+ using var registration = cts.Token.Register(static state =>
+ {
+ var w = (PendingWait)state!;
+ w.Completion.TrySetException(new TimeoutException(
+ $"Timed out waiting for OTLP request to '{w.Path}'."));
+ }, waiter);
+
+ try
+ {
+ return await tcs.Task;
+ }
+ finally
+ {
+ lock (_stateLock)
+ {
+ _waiters.Remove(waiter);
+ }
+ }
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ await _cts.CancelAsync();
+
+ try
+ {
+ _listener.Stop();
+ _listener.Close();
+ }
+ catch
+ {
+ }
+
+ if (_listenTask is not null)
+ {
+ try
+ {
+ await _listenTask;
+ }
+ catch (OperationCanceledException)
+ {
+ }
+ }
+
+ _cts.Dispose();
+ }
+
+ private async Task ListenLoopAsync()
+ {
+ while (!_cts.IsCancellationRequested)
+ {
+ HttpListenerContext context;
+ try
+ {
+ context = await _listener.GetContextAsync();
+ }
+ catch (HttpListenerException) when (_cts.IsCancellationRequested)
+ {
+ break;
+ }
+ catch (ObjectDisposedException)
+ {
+ break;
+ }
+
+ _ = Task.Run(() => ProcessRequestAsync(context));
+ }
+ }
+
+ private async Task ProcessRequestAsync(HttpListenerContext context)
+ {
+ try
+ {
+ using var ms = new MemoryStream();
+ await context.Request.InputStream.CopyToAsync(ms, _cts.Token);
+
+ var captured = new CapturedRequest(
+ context.Request.Url?.AbsolutePath ?? string.Empty,
+ ms.ToArray());
+ EnqueueAndSignal(captured);
+
+ context.Response.StatusCode = 200;
+ context.Response.ContentLength64 = 0;
+ context.Response.Close();
+ }
+ catch
+ {
+ try
+ {
+ context.Response.StatusCode = 500;
+ context.Response.Close();
+ }
+ catch
+ {
+ }
+ }
+ }
+
+ private void EnqueueAndSignal(CapturedRequest captured)
+ {
+ PendingWait[] matched;
+ lock (_stateLock)
+ {
+ _requests.Add(captured);
+ matched = _waiters
+ .Where(w => w.Path == captured.Path)
+ .ToArray();
+ }
+
+ foreach (var waiter in matched)
+ {
+ waiter.Completion.TrySetResult(captured);
+ }
+ }
+
+ private sealed record PendingWait(string Path, TaskCompletionSource Completion);
+}
+
+internal sealed record CapturedRequest(string Path, byte[] Body);
diff --git a/TUnit.OpenTelemetry.Tests/OtlpReceiverForwardingTests.cs b/TUnit.OpenTelemetry.Tests/OtlpReceiverForwardingTests.cs
new file mode 100644
index 0000000000..20d0b3bc86
--- /dev/null
+++ b/TUnit.OpenTelemetry.Tests/OtlpReceiverForwardingTests.cs
@@ -0,0 +1,69 @@
+using System.Diagnostics;
+using OpenTelemetry;
+using OpenTelemetry.Exporter;
+using OpenTelemetry.Trace;
+using TUnit.Assertions;
+using TUnit.Assertions.Extensions;
+using TUnit.OpenTelemetry.Receiver;
+using TUnit.OpenTelemetry.Tests.Helpers;
+
+namespace TUnit.OpenTelemetry.Tests;
+
+public class OtlpReceiverForwardingTests
+{
+ [Test]
+ public async Task Receiver_WithUpstream_ForwardsTraceBody()
+ {
+ await using var upstream = new OtlpTraceCaptureServer();
+ upstream.Start();
+
+ await using var receiver = new OtlpReceiver(upstreamEndpoint: $"http://127.0.0.1:{upstream.Port}");
+ receiver.Start();
+
+ var sourceName = $"TUnit.ForwardingTest.{Guid.NewGuid():N}";
+ using var source = new ActivitySource(sourceName);
+ using var provider = Sdk.CreateTracerProviderBuilder()
+ .AddSource(sourceName)
+ .AddOtlpExporter(o =>
+ {
+ o.Endpoint = new Uri($"http://127.0.0.1:{receiver.Port}/v1/traces");
+ o.Protocol = OtlpExportProtocol.HttpProtobuf;
+ })
+ .Build();
+
+ using (source.StartActivity("forwarded-op"))
+ {
+ }
+
+ provider!.ForceFlush(5000);
+
+ var captured = await upstream.WaitForRequestAsync("/v1/traces");
+ await Assert.That(captured.Body.Length).IsGreaterThan(0);
+
+ var parsed = OtlpTraceParser.Parse(captured.Body);
+ await Assert.That(parsed.Any(s => s.Name == "forwarded-op")).IsTrue();
+ }
+
+ [Test]
+ public async Task Receiver_WithoutUpstream_DoesNotForward()
+ {
+ await using var upstream = new OtlpTraceCaptureServer();
+ upstream.Start();
+
+ await using var receiver = new OtlpReceiver();
+ receiver.Start();
+
+ using var client = new HttpClient();
+ using var content = new ByteArrayContent([0x00]);
+ await client.PostAsync($"http://127.0.0.1:{receiver.Port}/v1/traces", content);
+ await receiver.WhenIdle();
+
+ // Upstream never told about the receiver — but we assert nothing arrived there.
+ // Poll briefly to allow any stray forwarding to surface.
+ var timeout = Task.Delay(200);
+ var waited = await Task.WhenAny(upstream.WaitForRequestAsync("/v1/traces", timeoutMs: 200), timeout);
+
+ // Either the wait timed out (expected) or the request faulted; both prove no forwarding.
+ await Assert.That(waited).IsEqualTo(timeout);
+ }
+}
diff --git a/TUnit.OpenTelemetry.Tests/OtlpReceiverIngestionTests.cs b/TUnit.OpenTelemetry.Tests/OtlpReceiverIngestionTests.cs
new file mode 100644
index 0000000000..22ea4c078c
--- /dev/null
+++ b/TUnit.OpenTelemetry.Tests/OtlpReceiverIngestionTests.cs
@@ -0,0 +1,50 @@
+using System.Diagnostics;
+using OpenTelemetry;
+using OpenTelemetry.Exporter;
+using OpenTelemetry.Trace;
+using TUnit.Assertions;
+using TUnit.Assertions.Extensions;
+using TUnit.Engine.Reporters.Html;
+using TUnit.OpenTelemetry.Receiver;
+
+namespace TUnit.OpenTelemetry.Tests;
+
+public class OtlpReceiverIngestionTests
+{
+ [Test]
+ public async Task Receiver_ParsedTrace_ReachesActivityCollector()
+ {
+ var collector = ActivityCollector.Current;
+ await Assert.That(collector).IsNotNull();
+
+ await using var receiver = new OtlpReceiver();
+ receiver.Start();
+
+ var sourceName = $"TUnit.ReceiverIngestionTest.{Guid.NewGuid():N}";
+ using var source = new ActivitySource(sourceName);
+ using var provider = Sdk.CreateTracerProviderBuilder()
+ .AddSource(sourceName)
+ .AddOtlpExporter(o =>
+ {
+ o.Endpoint = new Uri($"http://127.0.0.1:{receiver.Port}/v1/traces");
+ o.Protocol = OtlpExportProtocol.HttpProtobuf;
+ })
+ .Build();
+
+ string traceId;
+ using (var activity = source.StartActivity("sut-external-op"))
+ {
+ await Assert.That(activity).IsNotNull();
+ traceId = activity!.TraceId.ToString();
+ collector!.RegisterExternalTrace(traceId);
+ }
+
+ provider!.ForceFlush(5000);
+ await receiver.WhenIdle();
+
+ var span = collector!.GetAllSpans().FirstOrDefault(s =>
+ s.TraceId == traceId && s.Name == "sut-external-op");
+
+ await Assert.That(span).IsNotNull();
+ }
+}
diff --git a/TUnit.OpenTelemetry.Tests/OtlpTraceParserTests.cs b/TUnit.OpenTelemetry.Tests/OtlpTraceParserTests.cs
new file mode 100644
index 0000000000..03d6ee6af0
--- /dev/null
+++ b/TUnit.OpenTelemetry.Tests/OtlpTraceParserTests.cs
@@ -0,0 +1,129 @@
+using System.Diagnostics;
+using OpenTelemetry;
+using OpenTelemetry.Exporter;
+using OpenTelemetry.Resources;
+using OpenTelemetry.Trace;
+using TUnit.Assertions;
+using TUnit.Assertions.Extensions;
+using TUnit.OpenTelemetry.Receiver;
+using TUnit.OpenTelemetry.Tests.Helpers;
+
+namespace TUnit.OpenTelemetry.Tests;
+
+public class OtlpTraceParserTests
+{
+ [Test]
+ public async Task Parse_SingleSpan_ExtractsIdsAndName()
+ {
+ await using var server = new OtlpTraceCaptureServer();
+ server.Start();
+
+ var sourceName = $"TUnit.Tests.Parser.{Guid.NewGuid():N}";
+ var endpoint = $"http://127.0.0.1:{server.Port}/v1/traces";
+
+ string expectedTraceId;
+ string expectedSpanId;
+
+ using (var source = new ActivitySource(sourceName))
+ using (var provider = Sdk.CreateTracerProviderBuilder()
+ .AddSource(sourceName)
+ .SetResourceBuilder(ResourceBuilder.CreateDefault().AddService("parser-test-svc"))
+ .AddOtlpExporter(o =>
+ {
+ o.Endpoint = new Uri(endpoint);
+ o.Protocol = OtlpExportProtocol.HttpProtobuf;
+ })
+ .Build())
+ {
+ using var activity = source.StartActivity("parse-me", ActivityKind.Server);
+ activity!.SetTag("probe.key", "probe-value");
+ expectedTraceId = activity.TraceId.ToString();
+ expectedSpanId = activity.SpanId.ToString();
+ }
+
+ var request = await server.WaitForRequestAsync("/v1/traces");
+
+ var spans = OtlpTraceParser.Parse(request.Body);
+
+ await Assert.That(spans).Count().IsEqualTo(1);
+
+ var span = spans[0];
+ // Parser emits lowercase to match Activity.TraceId/SpanId serialization.
+ await Assert.That(span.TraceId).IsEqualTo(expectedTraceId);
+ await Assert.That(span.SpanId).IsEqualTo(expectedSpanId);
+ await Assert.That(span.Name).IsEqualTo("parse-me");
+ await Assert.That(span.Kind).IsEqualTo(2); // SERVER
+ await Assert.That(span.ResourceName).IsEqualTo("parser-test-svc");
+ var probeAttr = span.Attributes.FirstOrDefault(kv => kv.Key == "probe.key");
+ await Assert.That(probeAttr.Value).IsEqualTo("probe-value");
+ }
+
+ [Test]
+ public async Task Parse_ParentChildSpans_LinksViaParentSpanId()
+ {
+ await using var server = new OtlpTraceCaptureServer();
+ server.Start();
+
+ var sourceName = $"TUnit.Tests.Parser.{Guid.NewGuid():N}";
+
+ string parentSpanId;
+
+ using (var source = new ActivitySource(sourceName))
+ using (var provider = Sdk.CreateTracerProviderBuilder()
+ .AddSource(sourceName)
+ .AddOtlpExporter(o =>
+ {
+ o.Endpoint = new Uri($"http://127.0.0.1:{server.Port}/v1/traces");
+ o.Protocol = OtlpExportProtocol.HttpProtobuf;
+ })
+ .Build())
+ {
+ using var parent = source.StartActivity("parent")!;
+ parentSpanId = parent.SpanId.ToString();
+ using var child = source.StartActivity("child");
+ }
+
+ var request = await server.WaitForRequestAsync("/v1/traces");
+ var spans = OtlpTraceParser.Parse(request.Body);
+
+ var childSpan = spans.Single(s => s.Name == "child");
+ await Assert.That(childSpan.ParentSpanId).IsEqualTo(parentSpanId);
+ }
+
+ [Test]
+ public async Task Parse_ErrorStatus_ExtractsCodeAndMessage()
+ {
+ await using var server = new OtlpTraceCaptureServer();
+ server.Start();
+
+ var sourceName = $"TUnit.Tests.Parser.{Guid.NewGuid():N}";
+
+ using (var source = new ActivitySource(sourceName))
+ using (var provider = Sdk.CreateTracerProviderBuilder()
+ .AddSource(sourceName)
+ .AddOtlpExporter(o =>
+ {
+ o.Endpoint = new Uri($"http://127.0.0.1:{server.Port}/v1/traces");
+ o.Protocol = OtlpExportProtocol.HttpProtobuf;
+ })
+ .Build())
+ {
+ using var activity = source.StartActivity("failing")!;
+ activity.SetStatus(ActivityStatusCode.Error, "oh no");
+ }
+
+ var request = await server.WaitForRequestAsync("/v1/traces");
+ var spans = OtlpTraceParser.Parse(request.Body);
+
+ var span = spans.Single();
+ await Assert.That(span.StatusCode).IsEqualTo(2); // ERROR
+ await Assert.That(span.StatusMessage).IsEqualTo("oh no");
+ }
+
+ [Test]
+ public async Task Parse_Empty_ReturnsEmptyList()
+ {
+ var spans = OtlpTraceParser.Parse(Array.Empty());
+ await Assert.That(spans).Count().IsEqualTo(0);
+ }
+}
diff --git a/TUnit.OpenTelemetry.Tests/TUnit.OpenTelemetry.Tests.csproj b/TUnit.OpenTelemetry.Tests/TUnit.OpenTelemetry.Tests.csproj
new file mode 100644
index 0000000000..37af3fafdf
--- /dev/null
+++ b/TUnit.OpenTelemetry.Tests/TUnit.OpenTelemetry.Tests.csproj
@@ -0,0 +1,27 @@
+
+
+
+
+
+ net10.0
+ false
+ true
+ ..\strongname.snk
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/TUnit.OpenTelemetry.Tests/TestSessionSetup.cs b/TUnit.OpenTelemetry.Tests/TestSessionSetup.cs
new file mode 100644
index 0000000000..3bab9905f4
--- /dev/null
+++ b/TUnit.OpenTelemetry.Tests/TestSessionSetup.cs
@@ -0,0 +1,16 @@
+using System.Diagnostics;
+using OpenTelemetry.Trace;
+using TUnit.Core;
+
+namespace TUnit.OpenTelemetry.Tests;
+
+public static class TestSessionSetup
+{
+ [Before(HookType.TestDiscovery, Order = int.MinValue)]
+ public static void RegisterDummyConfigurator()
+ {
+ // Without this, AutoStart.Start() returns early because no endpoint + no user config.
+ // A no-op configurator is enough to keep the AutoStart path live for the coexistence tests.
+ TUnitOpenTelemetry.Configure(_ => { });
+ }
+}
diff --git a/TUnit.OpenTelemetry/AutoReceiver.cs b/TUnit.OpenTelemetry/AutoReceiver.cs
new file mode 100644
index 0000000000..058f25a029
--- /dev/null
+++ b/TUnit.OpenTelemetry/AutoReceiver.cs
@@ -0,0 +1,87 @@
+using TUnit.Core;
+using TUnit.OpenTelemetry.Receiver;
+
+namespace TUnit.OpenTelemetry;
+
+///
+/// Starts a process-wide OTLP/HTTP receiver at test discovery so that SUT
+/// processes (e.g., those started by WebApplicationFactory) can export spans
+/// back into the TUnit HTML report. Users opt out by setting the
+/// TUNIT_OTEL_RECEIVER environment variable to 0.
+///
+public static class AutoReceiver
+{
+ /// Hook order for . Runs early so the receiver
+ /// is listening before any test-time code tries to emit telemetry.
+ public const int AutoReceiverOrder = int.MinValue + 1000;
+
+ internal const string AutoReceiverEnvVar = "TUNIT_OTEL_RECEIVER";
+
+ private static OtlpReceiver? _receiver;
+ private static readonly Lock _lock = new();
+
+ ///
+ /// The URL of the local OTLP receiver (e.g., http://127.0.0.1:41234), or
+ /// null if the receiver is not running. Pass this to SUT processes as the
+ /// OTEL_EXPORTER_OTLP_ENDPOINT env var to route their telemetry back to TUnit.
+ ///
+ public static string? Endpoint
+ {
+ get
+ {
+ lock (_lock)
+ {
+ return _receiver is null ? null : $"http://127.0.0.1:{_receiver.Port}";
+ }
+ }
+ }
+
+ [Before(HookType.TestDiscovery, Order = AutoReceiverOrder)]
+ public static void Start()
+ {
+ if (Environment.GetEnvironmentVariable(AutoReceiverEnvVar) == "0")
+ {
+ return;
+ }
+
+ lock (_lock)
+ {
+ if (_receiver is not null)
+ {
+ return;
+ }
+
+ var upstream = Environment.GetEnvironmentVariable(AutoStart.OtlpEndpointEnvVar);
+ var receiver = new OtlpReceiver(upstreamEndpoint: upstream);
+ receiver.Start();
+ _receiver = receiver;
+ }
+ }
+
+ [After(HookType.TestSession, Order = AutoReceiverOrder)]
+ public static async Task Stop()
+ {
+ OtlpReceiver? toDispose;
+ lock (_lock)
+ {
+ toDispose = _receiver;
+ _receiver = null;
+ }
+
+ if (toDispose is not null)
+ {
+ await toDispose.DisposeAsync();
+ }
+ }
+
+ internal static bool HasReceiverForTesting
+ {
+ get
+ {
+ lock (_lock)
+ {
+ return _receiver is not null;
+ }
+ }
+ }
+}
diff --git a/TUnit.OpenTelemetry/AutoStart.cs b/TUnit.OpenTelemetry/AutoStart.cs
new file mode 100644
index 0000000000..ca3fe52956
--- /dev/null
+++ b/TUnit.OpenTelemetry/AutoStart.cs
@@ -0,0 +1,138 @@
+using System.ComponentModel;
+using System.Diagnostics;
+using OpenTelemetry;
+using OpenTelemetry.Resources;
+using OpenTelemetry.Trace;
+using TUnit.Core;
+
+namespace TUnit.OpenTelemetry;
+
+///
+/// Lifecycle hooks that build a around TUnit's activity
+/// sources at test discovery and dispose it at session end. Users who already register
+/// a listener or TracerProvider in their own [Before(TestDiscovery)] hook keep
+/// full control — this class detects existing listeners and steps aside.
+///
+public static class AutoStart
+{
+ /// Hook order for . Runs last so user hooks register first.
+ public const int AutoStartOrder = int.MaxValue;
+
+ internal const string AutoStartEnvVar = "TUNIT_OTEL_AUTOSTART";
+ internal const string OtlpEndpointEnvVar = "OTEL_EXPORTER_OTLP_ENDPOINT";
+ internal const string ServiceNameEnvVar = "OTEL_SERVICE_NAME";
+
+ private static readonly ActivitySource ProbeSource = new("TUnit");
+ private static TracerProvider? _provider;
+ private static readonly Lock _lock = new();
+
+ [Before(HookType.TestDiscovery, Order = AutoStartOrder)]
+ public static void Start()
+ {
+ var autostart = Environment.GetEnvironmentVariable(AutoStartEnvVar);
+ if (autostart == "0")
+ {
+ return;
+ }
+
+ var force = autostart == "1";
+ var otlpEndpoint = Environment.GetEnvironmentVariable(OtlpEndpointEnvVar);
+
+ lock (_lock)
+ {
+ if (_provider is not null)
+ {
+ return;
+ }
+
+ if (!force)
+ {
+ if (otlpEndpoint is null && !TUnitOpenTelemetry.HasConfiguration)
+ {
+ return;
+ }
+
+ if (ProbeSource.HasListeners())
+ {
+ return;
+ }
+ }
+ }
+
+ var builder = Sdk.CreateTracerProviderBuilder()
+ .AddSource("TUnit")
+ .AddSource("TUnit.Lifecycle")
+ .AddSource("TUnit.AspNetCore.Http")
+ .AddProcessor(new TUnitTestCorrelationProcessor());
+
+ if (otlpEndpoint is not null)
+ {
+ builder.AddOtlpExporter();
+ }
+
+ builder.SetResourceBuilder(
+ ResourceBuilder.CreateDefault().AddService(
+ serviceName: Environment.GetEnvironmentVariable(ServiceNameEnvVar)
+ ?? System.Reflection.Assembly.GetEntryAssembly()?.GetName().Name
+ ?? "TUnit.Tests"));
+
+ TUnitOpenTelemetry.ApplyConfiguration(builder);
+ var provider = builder.Build();
+
+ lock (_lock)
+ {
+ if (_provider is not null)
+ {
+ // Lost the race — another Start call built first. Dispose ours.
+ provider.Dispose();
+ return;
+ }
+ _provider = provider;
+ }
+ }
+
+ [After(HookType.TestSession, Order = int.MaxValue)]
+ public static void Stop()
+ {
+ TracerProvider? toDispose;
+ lock (_lock)
+ {
+ toDispose = _provider;
+ _provider = null;
+ }
+
+ toDispose?.Dispose();
+ }
+
+ [EditorBrowsable(EditorBrowsableState.Never)]
+ internal static void StartForTesting(bool resetFirst)
+ {
+ if (resetFirst)
+ {
+ Stop();
+ }
+
+ Start();
+ }
+
+ [EditorBrowsable(EditorBrowsableState.Never)]
+ internal static bool HasProviderForTesting
+ {
+ get
+ {
+ lock (_lock)
+ {
+ return _provider is not null;
+ }
+ }
+ }
+
+ [EditorBrowsable(EditorBrowsableState.Never)]
+ internal static Resource? GetResourceForTesting()
+ {
+ lock (_lock)
+ {
+ return _provider?.GetResource();
+ }
+ }
+}
diff --git a/TUnit.Aspire/Telemetry/OtlpLogParser.cs b/TUnit.OpenTelemetry/Receiver/OtlpLogParser.cs
similarity index 91%
rename from TUnit.Aspire/Telemetry/OtlpLogParser.cs
rename to TUnit.OpenTelemetry/Receiver/OtlpLogParser.cs
index d34ccc6fa3..89941403a2 100644
--- a/TUnit.Aspire/Telemetry/OtlpLogParser.cs
+++ b/TUnit.OpenTelemetry/Receiver/OtlpLogParser.cs
@@ -1,6 +1,6 @@
using System.Text;
-namespace TUnit.Aspire.Telemetry;
+namespace TUnit.OpenTelemetry.Receiver;
///
/// A parsed OTLP log record containing only the fields needed for test correlation.
@@ -24,6 +24,11 @@ internal readonly record struct OtlpLogRecord(
/// Minimal parser for OTLP ExportLogsServiceRequest protobuf messages.
/// Extracts only the fields needed for test correlation (TraceId, severity, body)
/// without requiring any external protobuf library.
+///
+/// Field numbers below are from the OTLP proto definitions:
+/// ExportLogsServiceRequest: https://github.com/open-telemetry/opentelemetry-proto/blob/main/opentelemetry/proto/collector/logs/v1/logs_service.proto
+/// ResourceLogs/ScopeLogs/LogRecord: https://github.com/open-telemetry/opentelemetry-proto/blob/main/opentelemetry/proto/logs/v1/logs.proto
+/// Resource/KeyValue/AnyValue: https://github.com/open-telemetry/opentelemetry-proto/blob/main/opentelemetry/proto/common/v1/common.proto
///
internal static class OtlpLogParser
{
@@ -296,6 +301,19 @@ public ReadOnlySpan ReadBytesAsSpan()
return ReadLengthDelimited();
}
+ public long ReadFixed64()
+ {
+ if (_data.Length < 8)
+ {
+ throw new InvalidOperationException(
+ $"Truncated fixed64 field: need 8 bytes but only {_data.Length} remain.");
+ }
+
+ var result = System.Buffers.Binary.BinaryPrimitives.ReadUInt64LittleEndian(_data);
+ _data = _data[8..];
+ return (long)result;
+ }
+
public string ReadString()
{
return Encoding.UTF8.GetString(ReadLengthDelimited());
diff --git a/TUnit.Aspire/Telemetry/OtlpReceiver.cs b/TUnit.OpenTelemetry/Receiver/OtlpReceiver.cs
similarity index 63%
rename from TUnit.Aspire/Telemetry/OtlpReceiver.cs
rename to TUnit.OpenTelemetry/Receiver/OtlpReceiver.cs
index 575c86f353..1970b48ebe 100644
--- a/TUnit.Aspire/Telemetry/OtlpReceiver.cs
+++ b/TUnit.OpenTelemetry/Receiver/OtlpReceiver.cs
@@ -3,8 +3,9 @@
using System.Net;
using System.Net.Sockets;
using TUnit.Core;
+using TUnit.Engine.Reporters.Html;
-namespace TUnit.Aspire.Telemetry;
+namespace TUnit.OpenTelemetry.Receiver;
///
/// A lightweight OTLP/HTTP receiver that accepts telemetry from SUT processes
@@ -25,7 +26,6 @@ namespace TUnit.Aspire.Telemetry;
///
internal sealed class OtlpReceiver : IAsyncDisposable
{
- private const int MaxPortBindingAttempts = 10;
private const long MaxBodyBytes = 16 * 1024 * 1024; // 16 MB
private readonly HttpListener _listener;
@@ -91,7 +91,6 @@ private async Task ListenLoop()
break;
}
- // Process each request without blocking the listen loop
TrackTask(Task.Run(() => ProcessRequestAsync(context)));
}
}
@@ -136,10 +135,16 @@ private async Task ProcessRequestAsync(HttpListenerContext context)
return;
}
- // Read the request body with size enforcement (ContentLength64 is -1 for chunked)
+ // ContentLength64 is -1 for chunked; size-known path avoids MemoryStream growth copies.
byte[] body;
- using (var ms = new MemoryStream())
+ if (request.ContentLength64 >= 0)
+ {
+ body = new byte[request.ContentLength64];
+ await request.InputStream.ReadExactlyAsync(body, _cts.Token).ConfigureAwait(false);
+ }
+ else
{
+ using var ms = new MemoryStream();
var buffer = new byte[8192];
long totalRead = 0;
int bytesRead;
@@ -165,8 +170,11 @@ private async Task ProcessRequestAsync(HttpListenerContext context)
{
ProcessLogs(body);
}
+ else if (path == "/v1/traces")
+ {
+ ProcessTraces(body);
+ }
- // Forward to upstream if configured
if (_upstreamEndpoint is not null && _forwardingClient is not null)
{
TrackTask(ForwardAsync(path, body, request.ContentType));
@@ -174,7 +182,6 @@ private async Task ProcessRequestAsync(HttpListenerContext context)
Interlocked.Increment(ref _requestCount);
- // Return 200 OK with empty protobuf response
response.StatusCode = 200;
response.ContentType = "application/x-protobuf";
response.ContentLength64 = 0;
@@ -182,7 +189,7 @@ private async Task ProcessRequestAsync(HttpListenerContext context)
}
catch (Exception ex)
{
- Trace.WriteLine($"[TUnit.Aspire] OTLP request processing failed: {ex.Message}");
+ Trace.WriteLine($"[TUnit.OpenTelemetry] OTLP request processing failed: {ex.Message}");
try
{
context.Response.StatusCode = 500;
@@ -195,6 +202,130 @@ private async Task ProcessRequestAsync(HttpListenerContext context)
}
}
+ private static void ProcessTraces(byte[] body)
+ {
+ var collector = ActivityCollector.Current;
+ if (collector is null)
+ {
+ return;
+ }
+
+ IReadOnlyList spans;
+ try
+ {
+ spans = OtlpTraceParser.Parse(body);
+ }
+ catch (Exception ex)
+ {
+ Trace.WriteLine($"[TUnit.OpenTelemetry] Failed to parse /v1/traces body: {ex.GetType().Name}: {ex.Message}");
+ return;
+ }
+
+ foreach (var span in spans)
+ {
+ collector.IngestExternalSpan(ToSpanData(span));
+ }
+ }
+
+ private static SpanData ToSpanData(OtlpSpanRecord span)
+ {
+ ReportKeyValue[]? tags = null;
+ if (span.Attributes.Count > 0)
+ {
+ tags = new ReportKeyValue[span.Attributes.Count];
+ for (var i = 0; i < span.Attributes.Count; i++)
+ {
+ tags[i] = new ReportKeyValue
+ {
+ Key = span.Attributes[i].Key,
+ Value = span.Attributes[i].Value,
+ };
+ }
+ }
+
+ SpanEvent[]? events = null;
+ if (span.Events.Count > 0)
+ {
+ events = new SpanEvent[span.Events.Count];
+ for (var i = 0; i < span.Events.Count; i++)
+ {
+ var evt = span.Events[i];
+ ReportKeyValue[]? evtTags = null;
+ if (evt.Attributes.Count > 0)
+ {
+ evtTags = new ReportKeyValue[evt.Attributes.Count];
+ for (var j = 0; j < evt.Attributes.Count; j++)
+ {
+ evtTags[j] = new ReportKeyValue
+ {
+ Key = evt.Attributes[j].Key,
+ Value = evt.Attributes[j].Value,
+ };
+ }
+ }
+
+ events[i] = new SpanEvent
+ {
+ Name = evt.Name,
+ TimestampMs = evt.TimeUnixNano / 1_000_000.0,
+ Tags = evtTags,
+ };
+ }
+ }
+
+ SpanLink[]? links = null;
+ if (span.Links.Count > 0)
+ {
+ links = new SpanLink[span.Links.Count];
+ for (var i = 0; i < span.Links.Count; i++)
+ {
+ links[i] = new SpanLink
+ {
+ TraceId = span.Links[i].TraceId,
+ SpanId = span.Links[i].SpanId,
+ };
+ }
+ }
+
+ var startMs = span.StartTimeUnixNano / 1_000_000.0;
+ var endMs = span.EndTimeUnixNano / 1_000_000.0;
+
+ return new SpanData
+ {
+ TraceId = span.TraceId,
+ SpanId = span.SpanId,
+ ParentSpanId = span.ParentSpanId,
+ Name = span.Name,
+ // SpanType classifies TUnit's own spans ("test_case", "test_suite", etc.)
+ // and stays null for external spans — no analogue exists in OTLP.
+ SpanType = null,
+ Source = string.IsNullOrEmpty(span.ScopeName) ? span.ResourceName : span.ScopeName,
+ Kind = MapSpanKind(span.Kind),
+ StartTimeMs = startMs,
+ DurationMs = endMs - startMs,
+ Status = MapStatusCode(span.StatusCode),
+ StatusMessage = string.IsNullOrEmpty(span.StatusMessage) ? null : span.StatusMessage,
+ Tags = tags,
+ Events = events,
+ Links = links,
+ };
+ }
+
+ private static string MapSpanKind(int kind) => kind switch
+ {
+ 1 => "Internal",
+ 2 => "Server",
+ 3 => "Client",
+ 4 => "Producer",
+ 5 => "Consumer",
+ _ => "Internal",
+ };
+
+ private static string MapStatusCode(int code) =>
+ code is >= (int)ActivityStatusCode.Unset and <= (int)ActivityStatusCode.Error
+ ? ((ActivityStatusCode)code).ToString()
+ : nameof(ActivityStatusCode.Unset);
+
private static void ProcessLogs(byte[] body)
{
List records;
@@ -202,9 +333,9 @@ private static void ProcessLogs(byte[] body)
{
records = OtlpLogParser.Parse(body);
}
- catch
+ catch (Exception ex)
{
- // Malformed protobuf -- skip silently
+ Trace.WriteLine($"[TUnit.OpenTelemetry] Failed to parse /v1/logs body: {ex.GetType().Name}: {ex.Message}");
return;
}
@@ -254,7 +385,7 @@ private async Task ForwardAsync(string path, byte[] body, string? contentType)
}
catch (Exception ex)
{
- Trace.WriteLine($"[TUnit.Aspire] OTLP forwarding to upstream failed: {ex.Message}");
+ Trace.WriteLine($"[TUnit.OpenTelemetry] OTLP forwarding to upstream failed: {ex.Message}");
}
}
@@ -303,7 +434,18 @@ public async ValueTask DisposeAsync()
/// Creates an bound to a free port. Uses a retry loop to
/// handle the TOCTOU window between discovering a free port and binding to it.
///
- private static (HttpListener Listener, int Port) CreateListener()
+ private static (HttpListener Listener, int Port) CreateListener() => LoopbackHttpListenerFactory.Create();
+}
+
+///
+/// Binds an to a free loopback port, retrying if the
+/// port is taken between probing and binding (TOCTOU window).
+///
+internal static class LoopbackHttpListenerFactory
+{
+ private const int MaxPortBindingAttempts = 10;
+
+ internal static (HttpListener Listener, int Port) Create()
{
for (var attempt = 0; attempt < MaxPortBindingAttempts; attempt++)
{
@@ -322,7 +464,7 @@ private static (HttpListener Listener, int Port) CreateListener()
}
}
- throw new InvalidOperationException($"Could not bind OTLP listener after {MaxPortBindingAttempts} attempts.");
+ throw new InvalidOperationException($"Could not bind loopback HttpListener after {MaxPortBindingAttempts} attempts.");
}
private static int FindFreePort()
diff --git a/TUnit.OpenTelemetry/Receiver/OtlpTraceParser.cs b/TUnit.OpenTelemetry/Receiver/OtlpTraceParser.cs
new file mode 100644
index 0000000000..2661dd68bb
--- /dev/null
+++ b/TUnit.OpenTelemetry/Receiver/OtlpTraceParser.cs
@@ -0,0 +1,456 @@
+namespace TUnit.OpenTelemetry.Receiver;
+
+///
+/// A parsed OTLP span record. Only the fields needed to render a TUnit HTML report
+/// span row are extracted; dropped_*_count fields are ignored.
+///
+internal sealed record OtlpSpanRecord(
+ string TraceId,
+ string SpanId,
+ string? ParentSpanId,
+ string Name,
+ int Kind,
+ long StartTimeUnixNano,
+ long EndTimeUnixNano,
+ int StatusCode,
+ string StatusMessage,
+ IReadOnlyList> Attributes,
+ IReadOnlyList Events,
+ IReadOnlyList Links,
+ string ResourceName,
+ string ScopeName);
+
+internal readonly record struct OtlpSpanEvent(
+ long TimeUnixNano,
+ string Name,
+ IReadOnlyList> Attributes);
+
+internal readonly record struct OtlpSpanLink(
+ string TraceId,
+ string SpanId);
+
+///
+/// Minimal parser for OTLP ExportTraceServiceRequest protobuf messages.
+/// Extracts only the fields needed to forward spans into TUnit's HTML report.
+/// Uses the same hand-rolled as —
+/// no external protobuf dependency.
+///
+/// Field numbers below are from the OTLP proto definitions:
+/// ExportTraceServiceRequest: https://github.com/open-telemetry/opentelemetry-proto/blob/main/opentelemetry/proto/collector/trace/v1/trace_service.proto
+/// ResourceSpans/ScopeSpans/Span/Status/Event/Link: https://github.com/open-telemetry/opentelemetry-proto/blob/main/opentelemetry/proto/trace/v1/trace.proto
+/// Resource/KeyValue/AnyValue: https://github.com/open-telemetry/opentelemetry-proto/blob/main/opentelemetry/proto/common/v1/common.proto
+///
+internal static class OtlpTraceParser
+{
+ public static IReadOnlyList Parse(ReadOnlySpan data)
+ {
+ var results = new List();
+ var reader = new ProtobufReader(data);
+
+ // ExportTraceServiceRequest: field 1 = repeated ResourceSpans
+ while (reader.TryReadTag(out var fieldNumber, out var wireType))
+ {
+ if (fieldNumber == 1 && wireType == WireType.LengthDelimited)
+ {
+ var embedded = reader.ReadEmbeddedMessage();
+ ParseResourceSpans(embedded, results);
+ }
+ else
+ {
+ reader.Skip(wireType);
+ }
+ }
+
+ return results;
+ }
+
+ private static void ParseResourceSpans(ProtobufReader reader, List results)
+ {
+ var resourceName = "";
+
+ while (reader.TryReadTag(out var fieldNumber, out var wireType))
+ {
+ // ResourceSpans: 1 = resource, 2 = scope_spans
+ if (fieldNumber == 1 && wireType == WireType.LengthDelimited)
+ {
+ var embedded = reader.ReadEmbeddedMessage();
+ resourceName = ParseResourceServiceName(embedded);
+ }
+ else if (fieldNumber == 2 && wireType == WireType.LengthDelimited)
+ {
+ var embedded = reader.ReadEmbeddedMessage();
+ ParseScopeSpans(embedded, resourceName, results);
+ }
+ else
+ {
+ reader.Skip(wireType);
+ }
+ }
+ }
+
+ private static string ParseResourceServiceName(ProtobufReader reader)
+ {
+ while (reader.TryReadTag(out var fieldNumber, out var wireType))
+ {
+ // Resource: 1 = attributes (repeated KeyValue)
+ if (fieldNumber == 1 && wireType == WireType.LengthDelimited)
+ {
+ var embedded = reader.ReadEmbeddedMessage();
+ var (key, value) = ParseKeyValue(embedded);
+ if (key == "service.name")
+ {
+ return value;
+ }
+ }
+ else
+ {
+ reader.Skip(wireType);
+ }
+ }
+
+ return "";
+ }
+
+ private static void ParseScopeSpans(
+ ProtobufReader reader,
+ string resourceName,
+ List results)
+ {
+ var scopeName = "";
+
+ while (reader.TryReadTag(out var fieldNumber, out var wireType))
+ {
+ // ScopeSpans: 1 = scope (InstrumentationScope), 2 = spans
+ if (fieldNumber == 1 && wireType == WireType.LengthDelimited)
+ {
+ var embedded = reader.ReadEmbeddedMessage();
+ scopeName = ParseInstrumentationScopeName(embedded);
+ }
+ else if (fieldNumber == 2 && wireType == WireType.LengthDelimited)
+ {
+ var embedded = reader.ReadEmbeddedMessage();
+ var span = ParseSpan(embedded, resourceName, scopeName);
+ if (span is not null)
+ {
+ results.Add(span);
+ }
+ }
+ else
+ {
+ reader.Skip(wireType);
+ }
+ }
+ }
+
+ private static string ParseInstrumentationScopeName(ProtobufReader reader)
+ {
+ while (reader.TryReadTag(out var fieldNumber, out var wireType))
+ {
+ // InstrumentationScope: 1 = name
+ if (fieldNumber == 1 && wireType == WireType.LengthDelimited)
+ {
+ return reader.ReadString();
+ }
+
+ reader.Skip(wireType);
+ }
+
+ return "";
+ }
+
+ private static OtlpSpanRecord? ParseSpan(ProtobufReader reader, string resourceName, string scopeName)
+ {
+ var traceId = "";
+ var spanId = "";
+ string? parentSpanId = null;
+ var name = "";
+ var kind = 0;
+ long startTimeUnixNano = 0;
+ long endTimeUnixNano = 0;
+ var statusCode = 0;
+ var statusMessage = "";
+ List>? attributes = null;
+ List? events = null;
+ List? links = null;
+
+ // Span fields: 1=trace_id, 2=span_id, 4=parent_span_id, 5=name, 6=kind,
+ // 7=start_time_unix_nano, 8=end_time_unix_nano, 9=attributes, 11=events,
+ // 13=links, 15=status. (3=trace_state, 10/12/14=dropped counts — skipped.)
+ while (reader.TryReadTag(out var fieldNumber, out var wireType))
+ {
+ switch (fieldNumber)
+ {
+ case 1 when wireType == WireType.LengthDelimited:
+ var traceBytes = reader.ReadBytesAsSpan();
+ if (traceBytes.Length == 16)
+ {
+ traceId = HexLower(traceBytes);
+ }
+
+ break;
+
+ case 2 when wireType == WireType.LengthDelimited:
+ var spanBytes = reader.ReadBytesAsSpan();
+ if (spanBytes.Length == 8)
+ {
+ spanId = HexLower(spanBytes);
+ }
+
+ break;
+
+ case 4 when wireType == WireType.LengthDelimited:
+ var parentBytes = reader.ReadBytesAsSpan();
+ if (parentBytes.Length == 8)
+ {
+ parentSpanId = HexLower(parentBytes);
+ }
+
+ break;
+
+ case 5 when wireType == WireType.LengthDelimited:
+ name = reader.ReadString();
+ break;
+
+ case 6 when wireType == WireType.Varint:
+ kind = (int)reader.ReadVarint();
+ break;
+
+ case 7 when wireType == WireType.Fixed64:
+ startTimeUnixNano = reader.ReadFixed64();
+ break;
+
+ case 8 when wireType == WireType.Fixed64:
+ endTimeUnixNano = reader.ReadFixed64();
+ break;
+
+ case 9 when wireType == WireType.LengthDelimited:
+ attributes ??= new List>();
+ var attr = reader.ReadEmbeddedMessage();
+ var (attrKey, attrValue) = ParseKeyValue(attr);
+ attributes.Add(new KeyValuePair(attrKey, attrValue));
+ break;
+
+ case 11 when wireType == WireType.LengthDelimited:
+ events ??= new List();
+ var eventMsg = reader.ReadEmbeddedMessage();
+ events.Add(ParseSpanEvent(eventMsg));
+ break;
+
+ case 13 when wireType == WireType.LengthDelimited:
+ links ??= new List();
+ var linkMsg = reader.ReadEmbeddedMessage();
+ var link = ParseSpanLink(linkMsg);
+ if (link is not null)
+ {
+ links.Add(link.Value);
+ }
+
+ break;
+
+ case 15 when wireType == WireType.LengthDelimited:
+ var statusMsg = reader.ReadEmbeddedMessage();
+ (statusCode, statusMessage) = ParseStatus(statusMsg);
+ break;
+
+ default:
+ reader.Skip(wireType);
+ break;
+ }
+ }
+
+ if (string.IsNullOrEmpty(traceId) || string.IsNullOrEmpty(spanId))
+ {
+ return null;
+ }
+
+ return new OtlpSpanRecord(
+ traceId,
+ spanId,
+ parentSpanId,
+ name,
+ kind,
+ startTimeUnixNano,
+ endTimeUnixNano,
+ statusCode,
+ statusMessage,
+ (IReadOnlyList>?)attributes ?? Array.Empty>(),
+ (IReadOnlyList?)events ?? Array.Empty(),
+ (IReadOnlyList?)links ?? Array.Empty(),
+ resourceName,
+ scopeName);
+ }
+
+ private static OtlpSpanEvent ParseSpanEvent(ProtobufReader reader)
+ {
+ long timeUnixNano = 0;
+ var name = "";
+ List>? attributes = null;
+
+ // Span.Event: 1 = time_unix_nano, 2 = name, 3 = attributes
+ while (reader.TryReadTag(out var fieldNumber, out var wireType))
+ {
+ switch (fieldNumber)
+ {
+ case 1 when wireType == WireType.Fixed64:
+ timeUnixNano = reader.ReadFixed64();
+ break;
+
+ case 2 when wireType == WireType.LengthDelimited:
+ name = reader.ReadString();
+ break;
+
+ case 3 when wireType == WireType.LengthDelimited:
+ attributes ??= new List>();
+ var attr = reader.ReadEmbeddedMessage();
+ var (k, v) = ParseKeyValue(attr);
+ attributes.Add(new KeyValuePair(k, v));
+ break;
+
+ default:
+ reader.Skip(wireType);
+ break;
+ }
+ }
+
+ return new OtlpSpanEvent(
+ timeUnixNano,
+ name,
+ (IReadOnlyList>?)attributes
+ ?? Array.Empty>());
+ }
+
+ private static OtlpSpanLink? ParseSpanLink(ProtobufReader reader)
+ {
+ var traceId = "";
+ var spanId = "";
+
+ // Span.Link: 1 = trace_id, 2 = span_id
+ while (reader.TryReadTag(out var fieldNumber, out var wireType))
+ {
+ switch (fieldNumber)
+ {
+ case 1 when wireType == WireType.LengthDelimited:
+ var traceBytes = reader.ReadBytesAsSpan();
+ if (traceBytes.Length == 16)
+ {
+ traceId = HexLower(traceBytes);
+ }
+
+ break;
+
+ case 2 when wireType == WireType.LengthDelimited:
+ var spanBytes = reader.ReadBytesAsSpan();
+ if (spanBytes.Length == 8)
+ {
+ spanId = HexLower(spanBytes);
+ }
+
+ break;
+
+ default:
+ reader.Skip(wireType);
+ break;
+ }
+ }
+
+ if (string.IsNullOrEmpty(traceId) || string.IsNullOrEmpty(spanId))
+ {
+ return null;
+ }
+
+ return new OtlpSpanLink(traceId, spanId);
+ }
+
+ private static (int Code, string Message) ParseStatus(ProtobufReader reader)
+ {
+ var code = 0;
+ var message = "";
+
+ // Status: 2 = message, 3 = code. (Field 1 = deprecated.)
+ while (reader.TryReadTag(out var fieldNumber, out var wireType))
+ {
+ switch (fieldNumber)
+ {
+ case 2 when wireType == WireType.LengthDelimited:
+ message = reader.ReadString();
+ break;
+
+ case 3 when wireType == WireType.Varint:
+ code = (int)reader.ReadVarint();
+ break;
+
+ default:
+ reader.Skip(wireType);
+ break;
+ }
+ }
+
+ return (code, message);
+ }
+
+ private static (string Key, string Value) ParseKeyValue(ProtobufReader reader)
+ {
+ var key = "";
+ var value = "";
+
+ // KeyValue: 1 = key, 2 = value (AnyValue)
+ while (reader.TryReadTag(out var fieldNumber, out var wireType))
+ {
+ if (fieldNumber == 1 && wireType == WireType.LengthDelimited)
+ {
+ key = reader.ReadString();
+ }
+ else if (fieldNumber == 2 && wireType == WireType.LengthDelimited)
+ {
+ var embedded = reader.ReadEmbeddedMessage();
+ value = ParseAnyValue(embedded);
+ }
+ else
+ {
+ reader.Skip(wireType);
+ }
+ }
+
+ return (key, value);
+ }
+
+ // Lowercase hex matches System.Diagnostics.Activity's TraceId/SpanId formatting,
+ // so external spans flowing into ActivityCollector share dictionary keys with
+ // in-process spans without needing case-insensitive comparers downstream.
+ private static string HexLower(ReadOnlySpan bytes) =>
+#if NET9_0_OR_GREATER
+ Convert.ToHexStringLower(bytes);
+#else
+ Convert.ToHexString(bytes).ToLowerInvariant();
+#endif
+
+ private static string ParseAnyValue(ProtobufReader reader)
+ {
+ // AnyValue (oneof value): 1=string, 2=bool, 3=int, 4=double. (5=array, 6=kvlist, 7=bytes — not rendered.)
+ while (reader.TryReadTag(out var fieldNumber, out var wireType))
+ {
+ switch (fieldNumber)
+ {
+ case 1 when wireType == WireType.LengthDelimited:
+ return reader.ReadString();
+
+ case 2 when wireType == WireType.Varint:
+ return reader.ReadVarint() != 0 ? "true" : "false";
+
+ case 3 when wireType == WireType.Varint:
+ return ((long)reader.ReadVarint()).ToString(System.Globalization.CultureInfo.InvariantCulture);
+
+ case 4 when wireType == WireType.Fixed64:
+ var bits = reader.ReadFixed64();
+ return BitConverter.Int64BitsToDouble(bits)
+ .ToString(System.Globalization.CultureInfo.InvariantCulture);
+
+ default:
+ reader.Skip(wireType);
+ break;
+ }
+ }
+
+ return "";
+ }
+}
diff --git a/TUnit.OpenTelemetry/TUnit.OpenTelemetry.csproj b/TUnit.OpenTelemetry/TUnit.OpenTelemetry.csproj
new file mode 100644
index 0000000000..742894f576
--- /dev/null
+++ b/TUnit.OpenTelemetry/TUnit.OpenTelemetry.csproj
@@ -0,0 +1,38 @@
+
+
+
+
+
+ net8.0;net9.0;net10.0
+ Auto-wires an OpenTelemetry TracerProvider for TUnit test processes. Install to get distributed tracing with zero configuration.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/TUnit.OpenTelemetry/TUnitOpenTelemetry.cs b/TUnit.OpenTelemetry/TUnitOpenTelemetry.cs
new file mode 100644
index 0000000000..8614645aa4
--- /dev/null
+++ b/TUnit.OpenTelemetry/TUnitOpenTelemetry.cs
@@ -0,0 +1,63 @@
+using System.ComponentModel;
+using OpenTelemetry.Trace;
+
+namespace TUnit.OpenTelemetry;
+
+///
+/// User-facing entry point for customizing the auto-wired
+/// built by . Callbacks are replayed on the shared builder after
+/// TUnit adds its default sources and processors.
+///
+public static class TUnitOpenTelemetry
+{
+ private static readonly List> _configurators = [];
+ private static readonly Lock _lock = new();
+
+ ///
+ /// Registers a callback to customize the auto-wired .
+ /// Call from a static constructor or [Before(HookType.TestDiscovery, Order = int.MinValue)]
+ /// hook so the callback runs before builds the provider.
+ ///
+ public static void Configure(Action configure)
+ {
+ ArgumentNullException.ThrowIfNull(configure);
+ lock (_lock)
+ {
+ _configurators.Add(configure);
+ }
+ }
+
+ [EditorBrowsable(EditorBrowsableState.Never)]
+ internal static void ApplyConfiguration(TracerProviderBuilder builder)
+ {
+ Action[] snapshot;
+ lock (_lock)
+ {
+ snapshot = [.. _configurators];
+ }
+
+ foreach (var configure in snapshot)
+ {
+ configure(builder);
+ }
+ }
+
+ internal static bool HasConfiguration
+ {
+ get
+ {
+ lock (_lock)
+ {
+ return _configurators.Count > 0;
+ }
+ }
+ }
+
+ internal static void ResetForTests()
+ {
+ lock (_lock)
+ {
+ _configurators.Clear();
+ }
+ }
+}
diff --git a/TUnit.OpenTelemetry/TUnitTestCorrelationProcessor.cs b/TUnit.OpenTelemetry/TUnitTestCorrelationProcessor.cs
new file mode 100644
index 0000000000..de1713bd51
--- /dev/null
+++ b/TUnit.OpenTelemetry/TUnitTestCorrelationProcessor.cs
@@ -0,0 +1,27 @@
+using System.Diagnostics;
+using OpenTelemetry;
+using TUnit.Core;
+
+namespace TUnit.OpenTelemetry;
+
+///
+/// Copies the tunit.test.id baggage item from the ambient Activity onto
+/// every new span as a tag, so spans produced by libraries with broken parent
+/// chains can still be filtered by test in backends like Jaeger or Seq.
+///
+public sealed class TUnitTestCorrelationProcessor : BaseProcessor
+{
+ public override void OnStart(Activity activity)
+ {
+ if (activity.GetTagItem(TUnitActivitySource.TagTestId) is not null)
+ {
+ return;
+ }
+
+ var testId = Activity.Current?.GetBaggageItem(TUnitActivitySource.TagTestId);
+ if (testId is not null)
+ {
+ activity.SetTag(TUnitActivitySource.TagTestId, testId);
+ }
+ }
+}
diff --git a/TUnit.Pipeline/Modules/GetPackageProjectsModule.cs b/TUnit.Pipeline/Modules/GetPackageProjectsModule.cs
index 931dbf412d..af7cd67bb2 100644
--- a/TUnit.Pipeline/Modules/GetPackageProjectsModule.cs
+++ b/TUnit.Pipeline/Modules/GetPackageProjectsModule.cs
@@ -23,6 +23,7 @@ public class GetPackageProjectsModule : Module>
Sourcy.DotNet.Projects.TUnit_AspNetCore,
Sourcy.DotNet.Projects.TUnit_AspNetCore_Core,
Sourcy.DotNet.Projects.TUnit_Aspire,
+ Sourcy.DotNet.Projects.TUnit_OpenTelemetry,
Sourcy.DotNet.Projects.TUnit_FsCheck,
Sourcy.DotNet.Projects.TUnit_Mocks,
Sourcy.DotNet.Projects.TUnit_Mocks_Assertions,
diff --git a/TUnit.Pipeline/Modules/RunOpenTelemetryTestsModule.cs b/TUnit.Pipeline/Modules/RunOpenTelemetryTestsModule.cs
new file mode 100644
index 0000000000..dff613875f
--- /dev/null
+++ b/TUnit.Pipeline/Modules/RunOpenTelemetryTestsModule.cs
@@ -0,0 +1,43 @@
+using ModularPipelines.Attributes;
+using ModularPipelines.Context;
+using ModularPipelines.DotNet.Extensions;
+using ModularPipelines.DotNet.Options;
+using ModularPipelines.Extensions;
+using ModularPipelines.Git.Extensions;
+using ModularPipelines.Models;
+using ModularPipelines.Modules;
+using ModularPipelines.Options;
+
+namespace TUnit.Pipeline.Modules;
+
+[NotInParallel]
+public class RunOpenTelemetryTestsModule : Module
+{
+ protected override async Task ExecuteAsync(IModuleContext context, CancellationToken cancellationToken)
+ {
+ var project = context.Git().RootDirectory.FindFile(x => x.Name == "TUnit.OpenTelemetry.Tests.csproj").AssertExists();
+
+ return await context.DotNet().Run(new DotNetRunOptions
+ {
+ Project = project.Name,
+ NoBuild = true,
+ Configuration = "Release",
+ Framework = "net10.0",
+ Arguments = ["--hangdump", "--hangdump-filename", "hangdump.opentelemetry-tests.dmp", "--hangdump-timeout", "5m"],
+ }, new CommandExecutionOptions
+ {
+ WorkingDirectory = project.Folder!.Path,
+ EnvironmentVariables = new Dictionary
+ {
+ ["DISABLE_GITHUB_REPORTER"] = "true",
+ },
+ LogSettings = new CommandLoggingOptions
+ {
+ ShowCommandArguments = true,
+ ShowStandardError = true,
+ ShowExecutionTime = true,
+ ShowExitCode = true
+ }
+ }, cancellationToken);
+ }
+}
diff --git a/TUnit.PublicAPI/TUnit.PublicAPI.csproj b/TUnit.PublicAPI/TUnit.PublicAPI.csproj
index 002dc90afa..b1aa4e0f2e 100644
--- a/TUnit.PublicAPI/TUnit.PublicAPI.csproj
+++ b/TUnit.PublicAPI/TUnit.PublicAPI.csproj
@@ -21,6 +21,10 @@
+
+
+
+
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 7d9f179b50..3fd21b094e 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
@@ -2363,6 +2363,28 @@ namespace .
public static . IsValidJsonObject(this string value) { }
}
}
+namespace .
+{
+ public class CountWrapper : ., .
+ where TCollection : .
+ {
+ public CountWrapper(. context) { }
+ public . Between(int minimum, int maximum, [.("minimum")] string? minExpression = null, [.("maximum")] string? maxExpression = null) { }
+ public . EqualTo(int expectedCount, [.("expectedCount")] string? expression = null) { }
+ public ._IsGreaterThan_TValue_Assertion GreaterThan(int expected, [.("expected")] string? expression = null) { }
+ public ._IsGreaterThanOrEqualTo_TValue_Assertion GreaterThanOrEqualTo(int expected, [.("expected")] string? expression = null) { }
+ public ._IsLessThan_TValue_Assertion LessThan(int expected, [.("expected")] string? expression = null) { }
+ public ._IsLessThanOrEqualTo_TValue_Assertion LessThanOrEqualTo(int expected, [.("expected")] string? expression = null) { }
+ public . NotEqualTo(int expected, [.("expected")] string? expression = null) { }
+ public ._IsGreaterThan_TValue_Assertion Positive() { }
+ public . Zero() { }
+ }
+ public class LengthWrapper : ., .
+ {
+ public LengthWrapper(. context) { }
+ public . EqualTo(int expectedLength, [.("expectedLength")] string? expression = null) { }
+ }
+}
namespace .Core
{
public class AndContinuation : . { }
@@ -2606,6 +2628,11 @@ namespace .Extensions
public static . CompletesWithin(this . source, timeout, [.("timeout")] string? expression = null) { }
public static . EqualTo(this . source, TValue? expected, [.("expected")] string? expression = null) { }
public static . Eventually(this . source, <., .> assertionBuilder, timeout, ? pollingInterval = default, [.("timeout")] string? timeoutExpression = null, [.("pollingInterval")] string? pollingIntervalExpression = null) { }
+ [("Use Length() instead, which provides all numeric assertion methods. Example: Asse" +
+ "(str).Length().IsGreaterThan(5)")]
+ public static ..LengthWrapper HasLength(this . source) { }
+ [("Use Length().IsEqualTo(expectedLength) instead.")]
+ public static . HasLength(this . source, int expectedLength, [.("expectedLength")] string? expression = null) { }
public static . HasMessage(this . source, string expectedMessage, [.("expectedMessage")] string? expression = null)
where TException : { }
public static . HasMessage(this . source, string expectedMessage, comparison, [.("expectedMessage")] string? expression = null)
@@ -6120,6 +6147,11 @@ namespace .Sources
protected override string GetExpectation() { }
public . HasAtLeast(int minCount, [.("minCount")] string? expression = null) { }
public . HasAtMost(int maxCount, [.("maxCount")] string? expression = null) { }
+ [("Use Count() instead, which provides all numeric assertion methods. Example: Asser" +
+ "(list).Count().IsGreaterThan(5)")]
+ public ..CountWrapper HasCount() { }
+ [("Use Count().IsEqualTo(expectedCount) instead.")]
+ public . HasCount(int expectedCount, [.("expectedCount")] string? expression = null) { }
public . HasCountBetween(int min, int max, [.("min")] string? minExpression = null, [.("max")] string? maxExpression = null) { }
public . HasDistinctItems() { }
public . HasDistinctItems(. comparer, [.("comparer")] string? comparerExpression = null) { }
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 ca201b0d65..137df82a5a 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
@@ -2342,6 +2342,28 @@ namespace .
public static . IsValidJsonObject(this string value) { }
}
}
+namespace .
+{
+ public class CountWrapper : ., .
+ where TCollection : .
+ {
+ public CountWrapper(. context) { }
+ public . Between(int minimum, int maximum, [.("minimum")] string? minExpression = null, [.("maximum")] string? maxExpression = null) { }
+ public . EqualTo(int expectedCount, [.("expectedCount")] string? expression = null) { }
+ public ._IsGreaterThan_TValue_Assertion GreaterThan(int expected, [.("expected")] string? expression = null) { }
+ public ._IsGreaterThanOrEqualTo_TValue_Assertion GreaterThanOrEqualTo(int expected, [.("expected")] string? expression = null) { }
+ public ._IsLessThan_TValue_Assertion LessThan(int expected, [.("expected")] string? expression = null) { }
+ public ._IsLessThanOrEqualTo_TValue_Assertion LessThanOrEqualTo(int expected, [.("expected")] string? expression = null) { }
+ public . NotEqualTo(int expected, [.("expected")] string? expression = null) { }
+ public ._IsGreaterThan_TValue_Assertion Positive() { }
+ public . Zero() { }
+ }
+ public class LengthWrapper : ., .
+ {
+ public LengthWrapper(. context) { }
+ public . EqualTo(int expectedLength, [.("expectedLength")] string? expression = null) { }
+ }
+}
namespace .Core
{
public class AndContinuation : . { }
@@ -2585,6 +2607,11 @@ namespace .Extensions
public static . CompletesWithin(this . source, timeout, [.("timeout")] string? expression = null) { }
public static . EqualTo(this . source, TValue? expected, [.("expected")] string? expression = null) { }
public static . Eventually(this . source, <., .> assertionBuilder, timeout, ? pollingInterval = default, [.("timeout")] string? timeoutExpression = null, [.("pollingInterval")] string? pollingIntervalExpression = null) { }
+ [("Use Length() instead, which provides all numeric assertion methods. Example: Asse" +
+ "(str).Length().IsGreaterThan(5)")]
+ public static ..LengthWrapper HasLength(this . source) { }
+ [("Use Length().IsEqualTo(expectedLength) instead.")]
+ public static . HasLength(this . source, int expectedLength, [.("expectedLength")] string? expression = null) { }
public static . HasMessage(this . source, string expectedMessage, [.("expectedMessage")] string? expression = null)
where TException : { }
public static . HasMessage(this . source, string expectedMessage, comparison, [.("expectedMessage")] string? expression = null)
@@ -6053,6 +6080,11 @@ namespace .Sources
protected override string GetExpectation() { }
public . HasAtLeast(int minCount, [.("minCount")] string? expression = null) { }
public . HasAtMost(int maxCount, [.("maxCount")] string? expression = null) { }
+ [("Use Count() instead, which provides all numeric assertion methods. Example: Asser" +
+ "(list).Count().IsGreaterThan(5)")]
+ public ..CountWrapper HasCount() { }
+ [("Use Count().IsEqualTo(expectedCount) instead.")]
+ public . HasCount(int expectedCount, [.("expectedCount")] string? expression = null) { }
public . HasCountBetween(int min, int max, [.("min")] string? minExpression = null, [.("max")] string? maxExpression = null) { }
public . HasDistinctItems() { }
public . HasDistinctItems(. comparer, [.("comparer")] string? comparerExpression = null) { }
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 6d6b727f5d..948eae45c8 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
@@ -2363,6 +2363,28 @@ namespace .
public static . IsValidJsonObject(this string value) { }
}
}
+namespace .
+{
+ public class CountWrapper : ., .
+ where TCollection : .
+ {
+ public CountWrapper(. context) { }
+ public . Between(int minimum, int maximum, [.("minimum")] string? minExpression = null, [.("maximum")] string? maxExpression = null) { }
+ public . EqualTo(int expectedCount, [.("expectedCount")] string? expression = null) { }
+ public ._IsGreaterThan_TValue_Assertion GreaterThan(int expected, [.("expected")] string? expression = null) { }
+ public ._IsGreaterThanOrEqualTo_TValue_Assertion GreaterThanOrEqualTo(int expected, [.("expected")] string? expression = null) { }
+ public ._IsLessThan_TValue_Assertion LessThan(int expected, [.("expected")] string? expression = null) { }
+ public ._IsLessThanOrEqualTo_TValue_Assertion LessThanOrEqualTo(int expected, [.("expected")] string? expression = null) { }
+ public . NotEqualTo(int expected, [.("expected")] string? expression = null) { }
+ public ._IsGreaterThan_TValue_Assertion Positive() { }
+ public . Zero() { }
+ }
+ public class LengthWrapper : ., .
+ {
+ public LengthWrapper(. context) { }
+ public . EqualTo(int expectedLength, [.("expectedLength")] string? expression = null) { }
+ }
+}
namespace .Core
{
public class AndContinuation : . { }
@@ -2606,6 +2628,11 @@ namespace .Extensions
public static . CompletesWithin(this . source, timeout, [.("timeout")] string? expression = null) { }
public static . EqualTo(this . source, TValue? expected, [.("expected")] string? expression = null) { }
public static . Eventually(this . source, <., .> assertionBuilder, timeout, ? pollingInterval = default, [.("timeout")] string? timeoutExpression = null, [.("pollingInterval")] string? pollingIntervalExpression = null) { }
+ [("Use Length() instead, which provides all numeric assertion methods. Example: Asse" +
+ "(str).Length().IsGreaterThan(5)")]
+ public static ..LengthWrapper HasLength(this . source) { }
+ [("Use Length().IsEqualTo(expectedLength) instead.")]
+ public static . HasLength(this . source, int expectedLength, [.("expectedLength")] string? expression = null) { }
public static . HasMessage(this . source, string expectedMessage, [.("expectedMessage")] string? expression = null)
where TException : { }
public static . HasMessage(this . source, string expectedMessage, comparison, [.("expectedMessage")] string? expression = null)
@@ -6120,6 +6147,11 @@ namespace .Sources
protected override string GetExpectation() { }
public . HasAtLeast(int minCount, [.("minCount")] string? expression = null) { }
public . HasAtMost(int maxCount, [.("maxCount")] string? expression = null) { }
+ [("Use Count() instead, which provides all numeric assertion methods. Example: Asser" +
+ "(list).Count().IsGreaterThan(5)")]
+ public ..CountWrapper HasCount() { }
+ [("Use Count().IsEqualTo(expectedCount) instead.")]
+ public . HasCount(int expectedCount, [.("expectedCount")] string? expression = null) { }
public . HasCountBetween(int min, int max, [.("min")] string? minExpression = null, [.("max")] string? maxExpression = null) { }
public . HasDistinctItems() { }
public . HasDistinctItems(. comparer, [.("comparer")] string? comparerExpression = null) { }
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 87a84f8adc..e9024b14e6 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
@@ -2114,6 +2114,28 @@ namespace .
public static . IsValidJsonObject(this string value) { }
}
}
+namespace .
+{
+ public class CountWrapper : ., .
+ where TCollection : .
+ {
+ public CountWrapper(. context) { }
+ public . Between(int minimum, int maximum, [.("minimum")] string? minExpression = null, [.("maximum")] string? maxExpression = null) { }
+ public . EqualTo(int expectedCount, [.("expectedCount")] string? expression = null) { }
+ public ._IsGreaterThan_TValue_Assertion GreaterThan(int expected, [.("expected")] string? expression = null) { }
+ public ._IsGreaterThanOrEqualTo_TValue_Assertion GreaterThanOrEqualTo(int expected, [.("expected")] string? expression = null) { }
+ public ._IsLessThan_TValue_Assertion LessThan(int expected, [.("expected")] string? expression = null) { }
+ public ._IsLessThanOrEqualTo_TValue_Assertion LessThanOrEqualTo(int expected, [.("expected")] string? expression = null) { }
+ public . NotEqualTo(int expected, [.("expected")] string? expression = null) { }
+ public ._IsGreaterThan_TValue_Assertion Positive() { }
+ public . Zero() { }
+ }
+ public class LengthWrapper : ., .
+ {
+ public LengthWrapper(. context) { }
+ public . EqualTo(int expectedLength, [.("expectedLength")] string? expression = null) { }
+ }
+}
namespace .Core
{
public class AndContinuation : . { }
@@ -2341,6 +2363,11 @@ namespace .Extensions
public static . CompletesWithin(this . source, timeout, [.("timeout")] string? expression = null) { }
public static . EqualTo(this . source, TValue? expected, [.("expected")] string? expression = null) { }
public static . Eventually(this . source, <., .> assertionBuilder, timeout, ? pollingInterval = default, [.("timeout")] string? timeoutExpression = null, [.("pollingInterval")] string? pollingIntervalExpression = null) { }
+ [("Use Length() instead, which provides all numeric assertion methods. Example: Asse" +
+ "(str).Length().IsGreaterThan(5)")]
+ public static ..LengthWrapper HasLength(this . source) { }
+ [("Use Length().IsEqualTo(expectedLength) instead.")]
+ public static . HasLength(this . source, int expectedLength, [.("expectedLength")] string? expression = null) { }
public static . HasMessage(this . source, string expectedMessage, [.("expectedMessage")] string? expression = null)
where TException : { }
public static . HasMessage(this . source, string expectedMessage, comparison, [.("expectedMessage")] string? expression = null)
@@ -5296,6 +5323,11 @@ namespace .Sources
protected override string GetExpectation() { }
public . HasAtLeast(int minCount, [.("minCount")] string? expression = null) { }
public . HasAtMost(int maxCount, [.("maxCount")] string? expression = null) { }
+ [("Use Count() instead, which provides all numeric assertion methods. Example: Asser" +
+ "(list).Count().IsGreaterThan(5)")]
+ public ..CountWrapper HasCount() { }
+ [("Use Count().IsEqualTo(expectedCount) instead.")]
+ public . HasCount(int expectedCount, [.("expectedCount")] string? expression = null) { }
public . HasCountBetween(int min, int max, [.("min")] string? minExpression = null, [.("max")] string? maxExpression = null) { }
public . HasDistinctItems() { }
public . HasDistinctItems(. comparer, [.("comparer")] string? comparerExpression = null) { }
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 41a65d4040..e3ef31b35f 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
@@ -4,6 +4,8 @@
[assembly: .(@", PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")]
[assembly: .(@".Microsoft, PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")]
[assembly: .(@", PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")]
+[assembly: .(@".Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")]
+[assembly: .(@", PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")]
[assembly: .(".NETCoreApp,Version=v10.0", FrameworkDisplayName=".NET 10.0")]
namespace
{
@@ -1330,6 +1332,7 @@ namespace
}
public static class TUnitActivitySource
{
+ public const string AspNetCoreHttpSourceName = ".Http";
public const string TagTestId = ".id";
}
public class TUnitAttribute : { }
@@ -1352,6 +1355,8 @@ namespace
public .IDataSourceAttribute? DataSourceAttribute { get; set; }
public string DefinitionId { get; }
public .TestContextEvents Events { get; set; }
+ [("Use StateBag property instead.")]
+ public . ObjectBag { get; }
public . StateBag { get; set; }
public required .MethodMetadata TestMetadata { get; init; }
public static .TestBuilderContext? Current { get; }
@@ -1647,6 +1652,8 @@ namespace
public TestRegisteredContext(.TestContext testContext) { }
public string? CustomDisplayName { get; }
public .DiscoveredTest DiscoveredTest { get; set; }
+ [("Use StateBag property instead.")]
+ public . ObjectBag { get; }
public . StateBag { get; }
public .TestContext TestContext { get; }
public .TestDetails TestDetails { get; }
@@ -1717,6 +1724,16 @@ namespace
public . OnHookRegistered(.HookRegisteredContext context) { }
public . OnTestDiscovered(.DiscoveredTestContext context) { }
}
+ [("Use OpenTelemetry activity spans instead. Hook timings are now automatically reco" +
+ "rded as OTel child spans of the test activity.")]
+ public class Timing : <.Timing>
+ {
+ public Timing(string StepName, Start, End) { }
+ public Duration { get; }
+ public End { get; init; }
+ public Start { get; init; }
+ public string StepName { get; init; }
+ }
public sealed class TypeArrayComparer : .<[]>
{
public static readonly .TypeArrayComparer Instance;
@@ -2644,10 +2661,16 @@ namespace .Interfaces
.<.Artifact> Artifacts { get; }
.TextWriter ErrorOutput { get; }
.TextWriter StandardOutput { get; }
+ [("Use OpenTelemetry activity spans instead. Hook timings are now automatically reco" +
+ "rded as OTel child spans of the test activity.")]
+ .<.Timing> Timings { get; }
void AttachArtifact(.Artifact artifact);
void AttachArtifact(string filePath, string? displayName = null, string? description = null);
string GetErrorOutput();
string GetStandardOutput();
+ [("Use OpenTelemetry activity spans instead. Hook timings are now automatically reco" +
+ "rded as OTel child spans of the test activity.")]
+ void RecordTiming(.Timing timing);
void WriteError(string message);
void WriteLine(string message);
}
diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt
index ca86d44ac6..55e8d42e0e 100644
--- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt
+++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt
@@ -4,6 +4,8 @@
[assembly: .(@", PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")]
[assembly: .(@".Microsoft, PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")]
[assembly: .(@", PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")]
+[assembly: .(@".Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")]
+[assembly: .(@", PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")]
[assembly: .(".NETCoreApp,Version=v8.0", FrameworkDisplayName=".NET 8.0")]
namespace
{
@@ -1330,6 +1332,7 @@ namespace
}
public static class TUnitActivitySource
{
+ public const string AspNetCoreHttpSourceName = ".Http";
public const string TagTestId = ".id";
}
public class TUnitAttribute : { }
@@ -1352,6 +1355,8 @@ namespace
public .IDataSourceAttribute? DataSourceAttribute { get; set; }
public string DefinitionId { get; }
public .TestContextEvents Events { get; set; }
+ [("Use StateBag property instead.")]
+ public . ObjectBag { get; }
public . StateBag { get; set; }
public required .MethodMetadata TestMetadata { get; init; }
public static .TestBuilderContext? Current { get; }
@@ -1647,6 +1652,8 @@ namespace
public TestRegisteredContext(.TestContext testContext) { }
public string? CustomDisplayName { get; }
public .DiscoveredTest DiscoveredTest { get; set; }
+ [("Use StateBag property instead.")]
+ public . ObjectBag { get; }
public . StateBag { get; }
public .TestContext TestContext { get; }
public .TestDetails TestDetails { get; }
@@ -1717,6 +1724,16 @@ namespace
public . OnHookRegistered(.HookRegisteredContext context) { }
public . OnTestDiscovered(.DiscoveredTestContext context) { }
}
+ [("Use OpenTelemetry activity spans instead. Hook timings are now automatically reco" +
+ "rded as OTel child spans of the test activity.")]
+ public class Timing : <.Timing>
+ {
+ public Timing(string StepName, Start, End) { }
+ public Duration { get; }
+ public End { get; init; }
+ public Start { get; init; }
+ public string StepName { get; init; }
+ }
public sealed class TypeArrayComparer : .<[]>
{
public static readonly .TypeArrayComparer Instance;
@@ -2644,10 +2661,16 @@ namespace .Interfaces
.<.Artifact> Artifacts { get; }
.TextWriter ErrorOutput { get; }
.TextWriter StandardOutput { get; }
+ [("Use OpenTelemetry activity spans instead. Hook timings are now automatically reco" +
+ "rded as OTel child spans of the test activity.")]
+ .<.Timing> Timings { get; }
void AttachArtifact(.Artifact artifact);
void AttachArtifact(string filePath, string? displayName = null, string? description = null);
string GetErrorOutput();
string GetStandardOutput();
+ [("Use OpenTelemetry activity spans instead. Hook timings are now automatically reco" +
+ "rded as OTel child spans of the test activity.")]
+ void RecordTiming(.Timing timing);
void WriteError(string message);
void WriteLine(string message);
}
diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt
index 16d097c942..201bf1bcee 100644
--- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt
+++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt
@@ -4,6 +4,8 @@
[assembly: .(@", PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")]
[assembly: .(@".Microsoft, PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")]
[assembly: .(@", PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")]
+[assembly: .(@".Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")]
+[assembly: .(@", PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")]
[assembly: .(".NETCoreApp,Version=v9.0", FrameworkDisplayName=".NET 9.0")]
namespace
{
@@ -1330,6 +1332,7 @@ namespace
}
public static class TUnitActivitySource
{
+ public const string AspNetCoreHttpSourceName = ".Http";
public const string TagTestId = ".id";
}
public class TUnitAttribute : { }
@@ -1352,6 +1355,8 @@ namespace
public .IDataSourceAttribute? DataSourceAttribute { get; set; }
public string DefinitionId { get; }
public .TestContextEvents Events { get; set; }
+ [("Use StateBag property instead.")]
+ public . ObjectBag { get; }
public . StateBag { get; set; }
public required .MethodMetadata TestMetadata { get; init; }
public static .TestBuilderContext? Current { get; }
@@ -1647,6 +1652,8 @@ namespace
public TestRegisteredContext(.TestContext testContext) { }
public string? CustomDisplayName { get; }
public .DiscoveredTest DiscoveredTest { get; set; }
+ [("Use StateBag property instead.")]
+ public . ObjectBag { get; }
public . StateBag { get; }
public .TestContext TestContext { get; }
public .TestDetails TestDetails { get; }
@@ -1717,6 +1724,16 @@ namespace
public . OnHookRegistered(.HookRegisteredContext context) { }
public . OnTestDiscovered(.DiscoveredTestContext context) { }
}
+ [("Use OpenTelemetry activity spans instead. Hook timings are now automatically reco" +
+ "rded as OTel child spans of the test activity.")]
+ public class Timing : <.Timing>
+ {
+ public Timing(string StepName, Start, End) { }
+ public Duration { get; }
+ public End { get; init; }
+ public Start { get; init; }
+ public string StepName { get; init; }
+ }
public sealed class TypeArrayComparer : .<[]>
{
public static readonly .TypeArrayComparer Instance;
@@ -2644,10 +2661,16 @@ namespace .Interfaces
.<.Artifact> Artifacts { get; }
.TextWriter ErrorOutput { get; }
.TextWriter StandardOutput { get; }
+ [("Use OpenTelemetry activity spans instead. Hook timings are now automatically reco" +
+ "rded as OTel child spans of the test activity.")]
+ .<.Timing> Timings { get; }
void AttachArtifact(.Artifact artifact);
void AttachArtifact(string filePath, string? displayName = null, string? description = null);
string GetErrorOutput();
string GetStandardOutput();
+ [("Use OpenTelemetry activity spans instead. Hook timings are now automatically reco" +
+ "rded as OTel child spans of the test activity.")]
+ void RecordTiming(.Timing timing);
void WriteError(string message);
void WriteLine(string message);
}
diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt
index 7f85cf8aa8..c22451a88c 100644
--- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt
+++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt
@@ -4,6 +4,8 @@
[assembly: .(@", PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")]
[assembly: .(@".Microsoft, PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")]
[assembly: .(@", PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")]
+[assembly: .(@".Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")]
+[assembly: .(@", PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")]
[assembly: .(".NETStandard,Version=v2.0", FrameworkDisplayName=".NET Standard 2.0")]
namespace
{
@@ -1300,6 +1302,8 @@ namespace
public .IDataSourceAttribute? DataSourceAttribute { get; set; }
public string DefinitionId { get; }
public .TestContextEvents Events { get; set; }
+ [("Use StateBag property instead.")]
+ public . ObjectBag { get; }
public . StateBag { get; set; }
public required .MethodMetadata TestMetadata { get; init; }
public static .TestBuilderContext? Current { get; }
@@ -1589,6 +1593,8 @@ namespace
public TestRegisteredContext(.TestContext testContext) { }
public string? CustomDisplayName { get; }
public .DiscoveredTest DiscoveredTest { get; set; }
+ [("Use StateBag property instead.")]
+ public . ObjectBag { get; }
public . StateBag { get; }
public .TestContext TestContext { get; }
public .TestDetails TestDetails { get; }
@@ -1659,6 +1665,16 @@ namespace
public . OnHookRegistered(.HookRegisteredContext context) { }
public . OnTestDiscovered(.DiscoveredTestContext context) { }
}
+ [("Use OpenTelemetry activity spans instead. Hook timings are now automatically reco" +
+ "rded as OTel child spans of the test activity.")]
+ public class Timing : <.Timing>
+ {
+ public Timing(string StepName, Start, End) { }
+ public Duration { get; }
+ public End { get; init; }
+ public Start { get; init; }
+ public string StepName { get; init; }
+ }
public sealed class TypeArrayComparer : .<[]>
{
public static readonly .TypeArrayComparer Instance;
@@ -2578,10 +2594,16 @@ namespace .Interfaces
.<.Artifact> Artifacts { get; }
.TextWriter ErrorOutput { get; }
.TextWriter StandardOutput { get; }
+ [("Use OpenTelemetry activity spans instead. Hook timings are now automatically reco" +
+ "rded as OTel child spans of the test activity.")]
+ .<.Timing> Timings { get; }
void AttachArtifact(.Artifact artifact);
void AttachArtifact(string filePath, string? displayName = null, string? description = null);
string GetErrorOutput();
string GetStandardOutput();
+ [("Use OpenTelemetry activity spans instead. Hook timings are now automatically reco" +
+ "rded as OTel child spans of the test activity.")]
+ void RecordTiming(.Timing timing);
void WriteError(string message);
void WriteLine(string message);
}
diff --git a/TUnit.PublicAPI/Tests.OpenTelemetry_Library_Has_No_API_Changes.DotNet10_0.verified.txt b/TUnit.PublicAPI/Tests.OpenTelemetry_Library_Has_No_API_Changes.DotNet10_0.verified.txt
new file mode 100644
index 0000000000..1e34ac90b8
--- /dev/null
+++ b/TUnit.PublicAPI/Tests.OpenTelemetry_Library_Has_No_API_Changes.DotNet10_0.verified.txt
@@ -0,0 +1,33 @@
+[assembly: .(@", PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")]
+[assembly: .(@".Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")]
+[assembly: .(@".Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")]
+[assembly: .(".NETCoreApp,Version=v10.0", FrameworkDisplayName=".NET 10.0")]
+namespace
+{
+ public static class AutoReceiver
+ {
+ public const int AutoReceiverOrder = -2147482648;
+ public static string? Endpoint { get; }
+ [.Before(., "PATH_SCRUBBED", 39, Order=-2147482648)]
+ public static void Start() { }
+ [.After(., "PATH_SCRUBBED", 61, Order=-2147482648)]
+ public static . Stop() { }
+ }
+ public static class AutoStart
+ {
+ public const int AutoStartOrder = 2147483647;
+ [.Before(., "PATH_SCRUBBED", 29, Order=2147483647)]
+ public static void Start() { }
+ [.After(., "PATH_SCRUBBED", 94, Order=2147483647)]
+ public static void Stop() { }
+ }
+ public static class TUnitOpenTelemetry
+ {
+ public static void Configure(<.TracerProviderBuilder> configure) { }
+ }
+ public sealed class TUnitTestCorrelationProcessor : <.Activity>
+ {
+ public TUnitTestCorrelationProcessor() { }
+ public override void OnStart(.Activity activity) { }
+ }
+}
\ No newline at end of file
diff --git a/TUnit.PublicAPI/Tests.OpenTelemetry_Library_Has_No_API_Changes.DotNet8_0.verified.txt b/TUnit.PublicAPI/Tests.OpenTelemetry_Library_Has_No_API_Changes.DotNet8_0.verified.txt
new file mode 100644
index 0000000000..ce0c12b5e9
--- /dev/null
+++ b/TUnit.PublicAPI/Tests.OpenTelemetry_Library_Has_No_API_Changes.DotNet8_0.verified.txt
@@ -0,0 +1,33 @@
+[assembly: .(@", PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")]
+[assembly: .(@".Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")]
+[assembly: .(@".Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")]
+[assembly: .(".NETCoreApp,Version=v8.0", FrameworkDisplayName=".NET 8.0")]
+namespace
+{
+ public static class AutoReceiver
+ {
+ public const int AutoReceiverOrder = -2147482648;
+ public static string? Endpoint { get; }
+ [.Before(., "PATH_SCRUBBED", 39, Order=-2147482648)]
+ public static void Start() { }
+ [.After(., "PATH_SCRUBBED", 61, Order=-2147482648)]
+ public static . Stop() { }
+ }
+ public static class AutoStart
+ {
+ public const int AutoStartOrder = 2147483647;
+ [.Before(., "PATH_SCRUBBED", 29, Order=2147483647)]
+ public static void Start() { }
+ [.After(., "PATH_SCRUBBED", 94, Order=2147483647)]
+ public static void Stop() { }
+ }
+ public static class TUnitOpenTelemetry
+ {
+ public static void Configure(<.TracerProviderBuilder> configure) { }
+ }
+ public sealed class TUnitTestCorrelationProcessor : <.Activity>
+ {
+ public TUnitTestCorrelationProcessor() { }
+ public override void OnStart(.Activity activity) { }
+ }
+}
\ No newline at end of file
diff --git a/TUnit.PublicAPI/Tests.OpenTelemetry_Library_Has_No_API_Changes.DotNet9_0.verified.txt b/TUnit.PublicAPI/Tests.OpenTelemetry_Library_Has_No_API_Changes.DotNet9_0.verified.txt
new file mode 100644
index 0000000000..ed3ca1b475
--- /dev/null
+++ b/TUnit.PublicAPI/Tests.OpenTelemetry_Library_Has_No_API_Changes.DotNet9_0.verified.txt
@@ -0,0 +1,33 @@
+[assembly: .(@", PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")]
+[assembly: .(@".Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")]
+[assembly: .(@".Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")]
+[assembly: .(".NETCoreApp,Version=v9.0", FrameworkDisplayName=".NET 9.0")]
+namespace
+{
+ public static class AutoReceiver
+ {
+ public const int AutoReceiverOrder = -2147482648;
+ public static string? Endpoint { get; }
+ [.Before(., "PATH_SCRUBBED", 39, Order=-2147482648)]
+ public static void Start() { }
+ [.After(., "PATH_SCRUBBED", 61, Order=-2147482648)]
+ public static . Stop() { }
+ }
+ public static class AutoStart
+ {
+ public const int AutoStartOrder = 2147483647;
+ [.Before(., "PATH_SCRUBBED", 29, Order=2147483647)]
+ public static void Start() { }
+ [.After(., "PATH_SCRUBBED", 94, Order=2147483647)]
+ public static void Stop() { }
+ }
+ public static class TUnitOpenTelemetry
+ {
+ public static void Configure(<.TracerProviderBuilder> configure) { }
+ }
+ public sealed class TUnitTestCorrelationProcessor : <.Activity>
+ {
+ public TUnitTestCorrelationProcessor() { }
+ public override void OnStart(.Activity activity) { }
+ }
+}
\ No newline at end of file
diff --git a/TUnit.PublicAPI/Tests.cs b/TUnit.PublicAPI/Tests.cs
index 1b3a19736c..4063dcb3e7 100644
--- a/TUnit.PublicAPI/Tests.cs
+++ b/TUnit.PublicAPI/Tests.cs
@@ -19,6 +19,12 @@ public Task Assertions_Library_Has_No_API_Changes()
public Task Playwright_Library_Has_No_API_Changes()
=> VerifyPublicApi(typeof(Playwright.PageTest).Assembly);
+#if NET
+ [Test]
+ public Task OpenTelemetry_Library_Has_No_API_Changes()
+ => VerifyPublicApi(typeof(TUnit.OpenTelemetry.TUnitOpenTelemetry).Assembly);
+#endif
+
private async Task VerifyPublicApi(Assembly assembly)
{
var publicApi = assembly.GeneratePublicApi(new ApiGeneratorOptions
@@ -30,6 +36,18 @@ private async Task VerifyPublicApi(Assembly assembly)
});
await VerifyTUnit.Verify(publicApi)
+ .AddScrubber(sb =>
+ {
+ // Scrub deterministic source paths (e.g. "/_/TUnit.OpenTelemetry/File.cs")
+ // before the URL regex mangles them into bare "/_/".
+ var replaced = System.Text.RegularExpressions.Regex.Replace(
+ sb.ToString(),
+ @"/_/[^""\s,)]*",
+ "PATH_SCRUBBED");
+ sb.Clear();
+ sb.Append(replaced);
+ return sb;
+ })
.AddScrubber(Scrub)
.AddScrubber(sb => new StringBuilder(sb.ToString().Replace("\r\n", "\n")))
.ScrubLinesWithReplace(x => x.Replace("\r\n", "\n"))
diff --git a/TUnit.Templates/content/TUnit.AspNet.FSharp/TestProject/TestProject.fsproj b/TUnit.Templates/content/TUnit.AspNet.FSharp/TestProject/TestProject.fsproj
index c0a2a8ee9e..e6ca51b4d5 100644
--- a/TUnit.Templates/content/TUnit.AspNet.FSharp/TestProject/TestProject.fsproj
+++ b/TUnit.Templates/content/TUnit.AspNet.FSharp/TestProject/TestProject.fsproj
@@ -10,8 +10,8 @@
-
-
+
+
diff --git a/TUnit.Templates/content/TUnit.AspNet/TestProject/TestProject.csproj b/TUnit.Templates/content/TUnit.AspNet/TestProject/TestProject.csproj
index fe9abe239e..1350e4b374 100644
--- a/TUnit.Templates/content/TUnit.AspNet/TestProject/TestProject.csproj
+++ b/TUnit.Templates/content/TUnit.AspNet/TestProject/TestProject.csproj
@@ -9,7 +9,7 @@
-
+
diff --git a/TUnit.Templates/content/TUnit.FSharp/TestProject.fsproj b/TUnit.Templates/content/TUnit.FSharp/TestProject.fsproj
index 0b9d281866..6ad7636d62 100644
--- a/TUnit.Templates/content/TUnit.FSharp/TestProject.fsproj
+++ b/TUnit.Templates/content/TUnit.FSharp/TestProject.fsproj
@@ -10,8 +10,8 @@
-
-
+
+
diff --git a/TUnit.Templates/content/TUnit.Playwright/TestProject.csproj b/TUnit.Templates/content/TUnit.Playwright/TestProject.csproj
index 490879a088..f39751a72a 100644
--- a/TUnit.Templates/content/TUnit.Playwright/TestProject.csproj
+++ b/TUnit.Templates/content/TUnit.Playwright/TestProject.csproj
@@ -8,7 +8,7 @@
-
+
diff --git a/TUnit.Templates/content/TUnit.VB/TestProject.vbproj b/TUnit.Templates/content/TUnit.VB/TestProject.vbproj
index a85a96c020..2474fed0b3 100644
--- a/TUnit.Templates/content/TUnit.VB/TestProject.vbproj
+++ b/TUnit.Templates/content/TUnit.VB/TestProject.vbproj
@@ -8,6 +8,6 @@
-
+
diff --git a/TUnit.UnitTests/PropagatorAlignmentTests.cs b/TUnit.UnitTests/PropagatorAlignmentTests.cs
new file mode 100644
index 0000000000..93dee28822
--- /dev/null
+++ b/TUnit.UnitTests/PropagatorAlignmentTests.cs
@@ -0,0 +1,56 @@
+#if NET
+using System.Diagnostics;
+using TUnit.Core;
+
+namespace TUnit.UnitTests;
+
+// Tests mutate DistributedContextPropagator.Current (process-global) — must not run concurrently.
+[NotInParallel(nameof(PropagatorAlignmentTests))]
+public class PropagatorAlignmentTests
+{
+ [Test]
+ public async Task ModuleInitializer_Replaces_Default_Legacy_Propagator()
+ {
+ // Module init runs on first touch of any TUnit.Core type, so by now the default
+ // LegacyPropagator must already be gone; otherwise cross-process baggage breaks.
+ var current = DistributedContextPropagator.Current.GetType().FullName;
+ await Assert.That(current).IsNotEqualTo("System.Diagnostics.LegacyPropagator");
+ }
+
+ [Test]
+ public async Task AlignIfDefault_Leaves_Custom_Propagator_Untouched()
+ {
+ var original = DistributedContextPropagator.Current;
+ var custom = DistributedContextPropagator.CreatePassThroughPropagator();
+
+ try
+ {
+ DistributedContextPropagator.Current = custom;
+ PropagatorAlignment.AlignIfDefault();
+ await Assert.That(DistributedContextPropagator.Current).IsSameReferenceAs(custom);
+ }
+ finally
+ {
+ DistributedContextPropagator.Current = original;
+ }
+ }
+
+ [Test]
+ public async Task AlignIfDefault_Does_Not_Replace_Existing_W3C_Propagator()
+ {
+ var original = DistributedContextPropagator.Current;
+ var w3c = PropagatorAlignment.CreateAlignedPropagator();
+
+ try
+ {
+ DistributedContextPropagator.Current = w3c;
+ PropagatorAlignment.AlignIfDefault();
+ await Assert.That(DistributedContextPropagator.Current).IsSameReferenceAs(w3c);
+ }
+ finally
+ {
+ DistributedContextPropagator.Current = original;
+ }
+ }
+}
+#endif
diff --git a/TUnit.slnx b/TUnit.slnx
index 58049aa178..3a13bcae2a 100644
--- a/TUnit.slnx
+++ b/TUnit.slnx
@@ -17,6 +17,7 @@
+
@@ -36,6 +37,7 @@
+
@@ -81,6 +83,7 @@
+
diff --git a/docs/docs/benchmarks/AsyncTests.md b/docs/docs/benchmarks/AsyncTests.md
index e2fb821208..0994b184f6 100644
--- a/docs/docs/benchmarks/AsyncTests.md
+++ b/docs/docs/benchmarks/AsyncTests.md
@@ -7,7 +7,7 @@ sidebar_position: 2
# AsyncTests Benchmark
:::info Last Updated
-This benchmark was automatically generated on **2026-04-16** from the latest CI run.
+This benchmark was automatically generated on **2026-04-17** from the latest CI run.
**Environment:** Ubuntu Latest • .NET SDK 10.0.202
:::
@@ -16,11 +16,11 @@ This benchmark was automatically generated on **2026-04-16** from the latest CI
| Framework | Version | Mean | Median | StdDev |
|-----------|---------|------|--------|--------|
-| **TUnit** | 1.34.5 | 522.2 ms | 522.0 ms | 3.23 ms |
-| NUnit | 4.5.1 | 711.0 ms | 709.9 ms | 9.73 ms |
-| MSTest | 4.2.1 | 620.7 ms | 620.7 ms | 3.57 ms |
-| xUnit3 | 3.2.2 | 761.8 ms | 762.2 ms | 5.98 ms |
-| **TUnit (AOT)** | 1.34.5 | 123.4 ms | 123.5 ms | 0.22 ms |
+| **TUnit** | 1.35.2 | 525.0 ms | 525.1 ms | 2.79 ms |
+| NUnit | 4.5.1 | 693.8 ms | 694.3 ms | 7.32 ms |
+| MSTest | 4.2.1 | 601.6 ms | 600.8 ms | 7.08 ms |
+| xUnit3 | 3.2.2 | 747.3 ms | 747.6 ms | 4.28 ms |
+| **TUnit (AOT)** | 1.35.2 | 121.5 ms | 121.5 ms | 0.18 ms |
## 📈 Visual Comparison
@@ -58,8 +58,8 @@ This benchmark was automatically generated on **2026-04-16** from the latest CI
xychart-beta
title "AsyncTests Performance Comparison"
x-axis ["TUnit", "NUnit", "MSTest", "xUnit3", "TUnit_AOT"]
- y-axis "Time (ms)" 0 --> 915
- bar [522.2, 711, 620.7, 761.8, 123.4]
+ y-axis "Time (ms)" 0 --> 897
+ bar [525, 693.8, 601.6, 747.3, 121.5]
```
## 🎯 Key Insights
@@ -72,4 +72,4 @@ This benchmark compares TUnit's performance against NUnit, MSTest, xUnit3 using
View the [benchmarks overview](/docs/benchmarks) for methodology details and environment information.
:::
-*Last generated: 2026-04-16T00:46:53.076Z*
+*Last generated: 2026-04-17T00:44:48.034Z*
diff --git a/docs/docs/benchmarks/BuildTime.md b/docs/docs/benchmarks/BuildTime.md
index 6f8dc2dcfc..436092ac7c 100644
--- a/docs/docs/benchmarks/BuildTime.md
+++ b/docs/docs/benchmarks/BuildTime.md
@@ -7,7 +7,7 @@ sidebar_position: 8
# Build Performance Benchmark
:::info Last Updated
-This benchmark was automatically generated on **2026-04-16** from the latest CI run.
+This benchmark was automatically generated on **2026-04-17** from the latest CI run.
**Environment:** Ubuntu Latest • .NET SDK 10.0.202
:::
@@ -18,10 +18,10 @@ Compilation time comparison across frameworks:
| Framework | Version | Mean | Median | StdDev |
|-----------|---------|------|--------|--------|
-| **TUnit** | 1.34.5 | 1.712 s | 1.724 s | 0.0336 s |
-| Build_NUnit | 4.5.1 | 1.522 s | 1.525 s | 0.0256 s |
-| Build_MSTest | 4.2.1 | 1.612 s | 1.619 s | 0.0185 s |
-| Build_xUnit3 | 3.2.2 | 1.533 s | 1.533 s | 0.0189 s |
+| **TUnit** | 1.35.2 | 1.922 s | 1.901 s | 0.0621 s |
+| Build_NUnit | 4.5.1 | 1.684 s | 1.683 s | 0.0159 s |
+| Build_MSTest | 4.2.1 | 1.801 s | 1.816 s | 0.0649 s |
+| Build_xUnit3 | 3.2.2 | 1.667 s | 1.665 s | 0.0118 s |
## 📈 Visual Comparison
@@ -60,7 +60,7 @@ xychart-beta
title "Build Time Comparison"
x-axis ["Build_TUnit", "Build_NUnit", "Build_MSTest", "Build_xUnit3"]
y-axis "Time (s)" 0 --> 3
- bar [1.712, 1.522, 1.612, 1.533]
+ bar [1.922, 1.684, 1.801, 1.667]
```
---
@@ -69,4 +69,4 @@ xychart-beta
View the [benchmarks overview](/docs/benchmarks) for methodology details and environment information.
:::
-*Last generated: 2026-04-16T00:46:53.078Z*
+*Last generated: 2026-04-17T00:44:48.037Z*
diff --git a/docs/docs/benchmarks/DataDrivenTests.md b/docs/docs/benchmarks/DataDrivenTests.md
index 4727bd1ba2..1e238f7901 100644
--- a/docs/docs/benchmarks/DataDrivenTests.md
+++ b/docs/docs/benchmarks/DataDrivenTests.md
@@ -7,7 +7,7 @@ sidebar_position: 3
# DataDrivenTests Benchmark
:::info Last Updated
-This benchmark was automatically generated on **2026-04-16** from the latest CI run.
+This benchmark was automatically generated on **2026-04-17** from the latest CI run.
**Environment:** Ubuntu Latest • .NET SDK 10.0.202
:::
@@ -16,11 +16,11 @@ This benchmark was automatically generated on **2026-04-16** from the latest CI
| Framework | Version | Mean | Median | StdDev |
|-----------|---------|------|--------|--------|
-| **TUnit** | 1.34.5 | 468.76 ms | 469.06 ms | 2.323 ms |
-| NUnit | 4.5.1 | 594.69 ms | 593.46 ms | 8.691 ms |
-| MSTest | 4.2.1 | 583.09 ms | 582.56 ms | 5.740 ms |
-| xUnit3 | 3.2.2 | 628.26 ms | 626.83 ms | 3.533 ms |
-| **TUnit (AOT)** | 1.34.5 | 22.20 ms | 21.76 ms | 0.762 ms |
+| **TUnit** | 1.35.2 | 466.15 ms | 465.55 ms | 3.227 ms |
+| NUnit | 4.5.1 | 544.72 ms | 545.66 ms | 5.569 ms |
+| MSTest | 4.2.1 | 449.73 ms | 448.52 ms | 8.973 ms |
+| xUnit3 | 3.2.2 | 582.30 ms | 580.80 ms | 6.885 ms |
+| **TUnit (AOT)** | 1.35.2 | 24.26 ms | 24.27 ms | 0.686 ms |
## 📈 Visual Comparison
@@ -58,8 +58,8 @@ This benchmark was automatically generated on **2026-04-16** from the latest CI
xychart-beta
title "DataDrivenTests Performance Comparison"
x-axis ["TUnit", "NUnit", "MSTest", "xUnit3", "TUnit_AOT"]
- y-axis "Time (ms)" 0 --> 754
- bar [468.76, 594.69, 583.09, 628.26, 22.2]
+ y-axis "Time (ms)" 0 --> 699
+ bar [466.15, 544.72, 449.73, 582.3, 24.26]
```
## 🎯 Key Insights
@@ -72,4 +72,4 @@ This benchmark compares TUnit's performance against NUnit, MSTest, xUnit3 using
View the [benchmarks overview](/docs/benchmarks) for methodology details and environment information.
:::
-*Last generated: 2026-04-16T00:46:53.077Z*
+*Last generated: 2026-04-17T00:44:48.035Z*
diff --git a/docs/docs/benchmarks/MassiveParallelTests.md b/docs/docs/benchmarks/MassiveParallelTests.md
index 92eb93e234..89f6e269fa 100644
--- a/docs/docs/benchmarks/MassiveParallelTests.md
+++ b/docs/docs/benchmarks/MassiveParallelTests.md
@@ -7,7 +7,7 @@ sidebar_position: 4
# MassiveParallelTests Benchmark
:::info Last Updated
-This benchmark was automatically generated on **2026-04-16** from the latest CI run.
+This benchmark was automatically generated on **2026-04-17** from the latest CI run.
**Environment:** Ubuntu Latest • .NET SDK 10.0.202
:::
@@ -16,11 +16,11 @@ This benchmark was automatically generated on **2026-04-16** from the latest CI
| Framework | Version | Mean | Median | StdDev |
|-----------|---------|------|--------|--------|
-| **TUnit** | 1.34.5 | 650.9 ms | 650.9 ms | 5.11 ms |
-| NUnit | 4.5.1 | 1,217.9 ms | 1,219.1 ms | 5.04 ms |
-| MSTest | 4.2.1 | 2,934.9 ms | 2,934.0 ms | 6.39 ms |
-| xUnit3 | 3.2.2 | 3,087.1 ms | 3,087.1 ms | 9.36 ms |
-| **TUnit (AOT)** | 1.34.5 | 225.0 ms | 225.0 ms | 0.57 ms |
+| **TUnit** | 1.35.2 | 559.4 ms | 559.6 ms | 2.08 ms |
+| NUnit | 4.5.1 | 1,132.4 ms | 1,131.0 ms | 9.57 ms |
+| MSTest | 4.2.1 | 2,870.1 ms | 2,870.6 ms | 7.14 ms |
+| xUnit3 | 3.2.2 | 2,987.5 ms | 2,988.3 ms | 5.63 ms |
+| **TUnit (AOT)** | 1.35.2 | 222.5 ms | 222.5 ms | 0.37 ms |
## 📈 Visual Comparison
@@ -58,8 +58,8 @@ This benchmark was automatically generated on **2026-04-16** from the latest CI
xychart-beta
title "MassiveParallelTests Performance Comparison"
x-axis ["TUnit", "NUnit", "MSTest", "xUnit3", "TUnit_AOT"]
- y-axis "Time (ms)" 0 --> 3705
- bar [650.9, 1217.9, 2934.9, 3087.1, 225]
+ y-axis "Time (ms)" 0 --> 3585
+ bar [559.4, 1132.4, 2870.1, 2987.5, 222.5]
```
## 🎯 Key Insights
@@ -72,4 +72,4 @@ This benchmark compares TUnit's performance against NUnit, MSTest, xUnit3 using
View the [benchmarks overview](/docs/benchmarks) for methodology details and environment information.
:::
-*Last generated: 2026-04-16T00:46:53.077Z*
+*Last generated: 2026-04-17T00:44:48.035Z*
diff --git a/docs/docs/benchmarks/MatrixTests.md b/docs/docs/benchmarks/MatrixTests.md
index b316c66154..05b0360abc 100644
--- a/docs/docs/benchmarks/MatrixTests.md
+++ b/docs/docs/benchmarks/MatrixTests.md
@@ -7,7 +7,7 @@ sidebar_position: 5
# MatrixTests Benchmark
:::info Last Updated
-This benchmark was automatically generated on **2026-04-16** from the latest CI run.
+This benchmark was automatically generated on **2026-04-17** from the latest CI run.
**Environment:** Ubuntu Latest • .NET SDK 10.0.202
:::
@@ -16,11 +16,11 @@ This benchmark was automatically generated on **2026-04-16** from the latest CI
| Framework | Version | Mean | Median | StdDev |
|-----------|---------|------|--------|--------|
-| **TUnit** | 1.34.5 | 590.5 ms | 590.4 ms | 2.70 ms |
-| NUnit | 4.5.1 | 1,616.7 ms | 1,617.1 ms | 5.89 ms |
-| MSTest | 4.2.1 | 1,509.9 ms | 1,509.0 ms | 4.30 ms |
-| xUnit3 | 3.2.2 | 1,661.7 ms | 1,661.2 ms | 6.20 ms |
-| **TUnit (AOT)** | 1.34.5 | 126.0 ms | 125.9 ms | 0.45 ms |
+| **TUnit** | 1.35.2 | 570.9 ms | 571.0 ms | 2.60 ms |
+| NUnit | 4.5.1 | 1,558.5 ms | 1,558.3 ms | 5.80 ms |
+| MSTest | 4.2.1 | 1,460.6 ms | 1,459.6 ms | 7.13 ms |
+| xUnit3 | 3.2.2 | 1,596.7 ms | 1,595.8 ms | 5.04 ms |
+| **TUnit (AOT)** | 1.35.2 | 126.9 ms | 126.9 ms | 0.47 ms |
## 📈 Visual Comparison
@@ -58,8 +58,8 @@ This benchmark was automatically generated on **2026-04-16** from the latest CI
xychart-beta
title "MatrixTests Performance Comparison"
x-axis ["TUnit", "NUnit", "MSTest", "xUnit3", "TUnit_AOT"]
- y-axis "Time (ms)" 0 --> 1995
- bar [590.5, 1616.7, 1509.9, 1661.7, 126]
+ y-axis "Time (ms)" 0 --> 1917
+ bar [570.9, 1558.5, 1460.6, 1596.7, 126.9]
```
## 🎯 Key Insights
@@ -72,4 +72,4 @@ This benchmark compares TUnit's performance against NUnit, MSTest, xUnit3 using
View the [benchmarks overview](/docs/benchmarks) for methodology details and environment information.
:::
-*Last generated: 2026-04-16T00:46:53.077Z*
+*Last generated: 2026-04-17T00:44:48.035Z*
diff --git a/docs/docs/benchmarks/ScaleTests.md b/docs/docs/benchmarks/ScaleTests.md
index 4d26872847..d93c994ba6 100644
--- a/docs/docs/benchmarks/ScaleTests.md
+++ b/docs/docs/benchmarks/ScaleTests.md
@@ -7,7 +7,7 @@ sidebar_position: 6
# ScaleTests Benchmark
:::info Last Updated
-This benchmark was automatically generated on **2026-04-16** from the latest CI run.
+This benchmark was automatically generated on **2026-04-17** from the latest CI run.
**Environment:** Ubuntu Latest • .NET SDK 10.0.202
:::
@@ -16,11 +16,11 @@ This benchmark was automatically generated on **2026-04-16** from the latest CI
| Framework | Version | Mean | Median | StdDev |
|-----------|---------|------|--------|--------|
-| **TUnit** | 1.34.5 | 481.60 ms | 480.64 ms | 4.637 ms |
-| NUnit | 4.5.1 | 603.89 ms | 601.77 ms | 6.162 ms |
-| MSTest | 4.2.1 | 469.93 ms | 466.39 ms | 9.133 ms |
-| xUnit3 | 3.2.2 | 608.23 ms | 607.43 ms | 7.084 ms |
-| **TUnit (AOT)** | 1.34.5 | 30.17 ms | 30.00 ms | 1.220 ms |
+| **TUnit** | 1.35.2 | 486.45 ms | 485.41 ms | 3.898 ms |
+| NUnit | 4.5.1 | 607.80 ms | 604.15 ms | 9.546 ms |
+| MSTest | 4.2.1 | 471.49 ms | 468.98 ms | 8.753 ms |
+| xUnit3 | 3.2.2 | 611.82 ms | 611.78 ms | 7.278 ms |
+| **TUnit (AOT)** | 1.35.2 | 30.52 ms | 30.28 ms | 1.966 ms |
## 📈 Visual Comparison
@@ -58,8 +58,8 @@ This benchmark was automatically generated on **2026-04-16** from the latest CI
xychart-beta
title "ScaleTests Performance Comparison"
x-axis ["TUnit", "NUnit", "MSTest", "xUnit3", "TUnit_AOT"]
- y-axis "Time (ms)" 0 --> 730
- bar [481.6, 603.89, 469.93, 608.23, 30.17]
+ y-axis "Time (ms)" 0 --> 735
+ bar [486.45, 607.8, 471.49, 611.82, 30.52]
```
## 🎯 Key Insights
@@ -72,4 +72,4 @@ This benchmark compares TUnit's performance against NUnit, MSTest, xUnit3 using
View the [benchmarks overview](/docs/benchmarks) for methodology details and environment information.
:::
-*Last generated: 2026-04-16T00:46:53.078Z*
+*Last generated: 2026-04-17T00:44:48.036Z*
diff --git a/docs/docs/benchmarks/SetupTeardownTests.md b/docs/docs/benchmarks/SetupTeardownTests.md
index 1c11708d00..8ce923c253 100644
--- a/docs/docs/benchmarks/SetupTeardownTests.md
+++ b/docs/docs/benchmarks/SetupTeardownTests.md
@@ -7,7 +7,7 @@ sidebar_position: 7
# SetupTeardownTests Benchmark
:::info Last Updated
-This benchmark was automatically generated on **2026-04-16** from the latest CI run.
+This benchmark was automatically generated on **2026-04-17** from the latest CI run.
**Environment:** Ubuntu Latest • .NET SDK 10.0.202
:::
@@ -16,11 +16,11 @@ This benchmark was automatically generated on **2026-04-16** from the latest CI
| Framework | Version | Mean | Median | StdDev |
|-----------|---------|------|--------|--------|
-| **TUnit** | 1.34.5 | 566.7 ms | 565.5 ms | 6.63 ms |
-| NUnit | 4.5.1 | 1,205.9 ms | 1,204.5 ms | 9.78 ms |
-| MSTest | 4.2.1 | 1,117.9 ms | 1,117.6 ms | 5.94 ms |
-| xUnit3 | 3.2.2 | 1,255.8 ms | 1,256.7 ms | 5.71 ms |
-| **TUnit (AOT)** | 1.34.5 | NA | NA | NA |
+| **TUnit** | 1.35.2 | 581.0 ms | 580.7 ms | 6.47 ms |
+| NUnit | 4.5.1 | 1,193.2 ms | 1,191.9 ms | 8.61 ms |
+| MSTest | 4.2.1 | 1,115.2 ms | 1,114.3 ms | 10.78 ms |
+| xUnit3 | 3.2.2 | 1,257.3 ms | 1,256.8 ms | 7.69 ms |
+| **TUnit (AOT)** | 1.35.2 | NA | NA | NA |
## 📈 Visual Comparison
@@ -58,8 +58,8 @@ This benchmark was automatically generated on **2026-04-16** from the latest CI
xychart-beta
title "SetupTeardownTests Performance Comparison"
x-axis ["TUnit", "NUnit", "MSTest", "xUnit3", "TUnit_AOT"]
- y-axis "Time (ms)" 0 --> 1507
- bar [566.7, 1205.9, 1117.9, 1255.8, 0]
+ y-axis "Time (ms)" 0 --> 1509
+ bar [581, 1193.2, 1115.2, 1257.3, 0]
```
## 🎯 Key Insights
@@ -72,4 +72,4 @@ This benchmark compares TUnit's performance against NUnit, MSTest, xUnit3 using
View the [benchmarks overview](/docs/benchmarks) for methodology details and environment information.
:::
-*Last generated: 2026-04-16T00:46:53.078Z*
+*Last generated: 2026-04-17T00:44:48.036Z*
diff --git a/docs/docs/benchmarks/index.md b/docs/docs/benchmarks/index.md
index e98f103cc5..e139fe2f62 100644
--- a/docs/docs/benchmarks/index.md
+++ b/docs/docs/benchmarks/index.md
@@ -7,7 +7,7 @@ sidebar_position: 1
# Performance Benchmarks
:::info Last Updated
-These benchmarks were automatically generated on **2026-04-16** from the latest CI run.
+These benchmarks were automatically generated on **2026-04-17** from the latest CI run.
**Environment:** Ubuntu Latest • .NET SDK 10.0.202
:::
@@ -16,17 +16,17 @@ These benchmarks were automatically generated on **2026-04-16** from the latest
Click on any benchmark to view detailed results:
-- [AsyncTests](AsyncTests) - Detailed performance analysis
-- [DataDrivenTests](DataDrivenTests) - Detailed performance analysis
-- [MassiveParallelTests](MassiveParallelTests) - Detailed performance analysis
-- [MatrixTests](MatrixTests) - Detailed performance analysis
-- [ScaleTests](ScaleTests) - Detailed performance analysis
-- [SetupTeardownTests](SetupTeardownTests) - Detailed performance analysis
+- [AsyncTests](./AsyncTests.md) — Realistic async/await patterns with I/O simulation
+- [DataDrivenTests](./DataDrivenTests.md) — Parameterized tests with multiple data sources
+- [MassiveParallelTests](./MassiveParallelTests.md) — Parallel execution stress tests
+- [MatrixTests](./MatrixTests.md) — Combinatorial test generation and execution
+- [ScaleTests](./ScaleTests.md) — Large test suites (150+ tests) measuring scalability
+- [SetupTeardownTests](./SetupTeardownTests.md) — Expensive test fixtures with setup/teardown overhead
## 🔨 Build Benchmarks
-- [Build Performance](BuildTime) - Compilation time comparison
+- [Build Performance](./BuildTime.md) - Compilation time comparison
---
@@ -37,7 +37,7 @@ These benchmarks compare TUnit against the most popular .NET testing frameworks:
| Framework | Version Tested |
|-----------|----------------|
-| **TUnit** | 1.34.5 |
+| **TUnit** | 1.35.2 |
| **xUnit v3** | 3.2.2 |
| **NUnit** | 4.5.1 |
| **MSTest** | 4.2.1 |
@@ -80,4 +80,4 @@ These benchmarks run automatically daily via [GitHub Actions](https://github.com
Each benchmark runs multiple iterations with statistical analysis to ensure accuracy. Results may vary based on hardware and test characteristics.
:::
-*Last generated: 2026-04-16T00:46:53.079Z*
+*Last generated: 2026-04-17T00:44:48.037Z*
diff --git a/docs/docs/benchmarks/mocks/Callback.md b/docs/docs/benchmarks/mocks/Callback.md
index b2f9a8c14d..6956d4ccfb 100644
--- a/docs/docs/benchmarks/mocks/Callback.md
+++ b/docs/docs/benchmarks/mocks/Callback.md
@@ -7,7 +7,7 @@ sidebar_position: 2
# Callback Benchmark
:::info Last Updated
-This benchmark was automatically generated on **2026-04-16** from the latest CI run.
+This benchmark was automatically generated on **2026-04-17** from the latest CI run.
**Environment:** Ubuntu Latest • .NET SDK 10.0.202
:::
@@ -18,12 +18,12 @@ Callback registration and execution:
| Library | Mean | Error | StdDev | Allocated |
|---------|------|-------|--------|-----------|
-| **TUnit.Mocks** | 689.5 ns | 6.56 ns | 6.14 ns | 3.13 KB |
-| Imposter | 459.8 ns | 1.45 ns | 1.29 ns | 2.66 KB |
-| Mockolate | 525.1 ns | 1.52 ns | 1.27 ns | 1.8 KB |
-| Moq | 135,900.5 ns | 637.16 ns | 564.82 ns | 13.29 KB |
-| NSubstitute | 4,148.8 ns | 12.77 ns | 10.66 ns | 7.93 KB |
-| FakeItEasy | 4,558.1 ns | 19.52 ns | 17.30 ns | 7.44 KB |
+| **TUnit.Mocks** | 680.2 ns | 8.94 ns | 7.93 ns | 3.13 KB |
+| Imposter | 487.3 ns | 9.46 ns | 11.62 ns | 2.66 KB |
+| Mockolate | 523.0 ns | 7.22 ns | 6.75 ns | 1.8 KB |
+| Moq | 186,330.0 ns | 1,314.46 ns | 1,229.54 ns | 13.14 KB |
+| NSubstitute | 4,551.5 ns | 52.21 ns | 48.84 ns | 7.93 KB |
+| FakeItEasy | 5,411.1 ns | 51.76 ns | 45.89 ns | 7.44 KB |
```mermaid
%%{init: {
@@ -49,8 +49,8 @@ Callback registration and execution:
xychart-beta
title "Callback Performance Comparison"
x-axis ["TUnit.Mocks", "Imposter", "Mockolate", "Moq", "NSubstitute", "FakeItEasy"]
- y-axis "Time (ns)" 0 --> 163081
- bar [689.5, 459.8, 525.1, 135900.5, 4148.8, 4558.1]
+ y-axis "Time (ns)" 0 --> 223596
+ bar [680.2, 487.3, 523, 186330, 4551.5, 5411.1]
```
---
@@ -59,12 +59,12 @@ xychart-beta
| Library | Mean | Error | StdDev | Allocated |
|---------|------|-------|--------|-----------|
-| **TUnit.Mocks** | 782.6 ns | 2.12 ns | 1.98 ns | 3.22 KB |
-| Imposter | 557.1 ns | 1.88 ns | 1.76 ns | 2.82 KB |
-| Mockolate | 753.7 ns | 1.33 ns | 1.04 ns | 2.13 KB |
-| Moq | 141,620.2 ns | 1,333.37 ns | 1,182.00 ns | 13.73 KB |
-| NSubstitute | 4,593.7 ns | 15.91 ns | 14.11 ns | 8.53 KB |
-| FakeItEasy | 5,469.7 ns | 27.34 ns | 22.83 ns | 9.26 KB |
+| **TUnit.Mocks** | 905.4 ns | 17.62 ns | 16.49 ns | 3.22 KB |
+| Imposter | 532.0 ns | 10.66 ns | 14.95 ns | 2.82 KB |
+| Mockolate | 682.8 ns | 13.51 ns | 14.46 ns | 2.13 KB |
+| Moq | 191,584.7 ns | 1,216.64 ns | 1,078.52 ns | 13.73 KB |
+| NSubstitute | 5,140.2 ns | 90.33 ns | 80.07 ns | 8.53 KB |
+| FakeItEasy | 6,140.7 ns | 100.33 ns | 78.33 ns | 9.26 KB |
```mermaid
%%{init: {
@@ -90,8 +90,8 @@ xychart-beta
xychart-beta
title "Callback (with args) Performance Comparison"
x-axis ["TUnit.Mocks", "Imposter", "Mockolate", "Moq", "NSubstitute", "FakeItEasy"]
- y-axis "Time (ns)" 0 --> 169945
- bar [782.6, 557.1, 753.7, 141620.2, 4593.7, 5469.7]
+ y-axis "Time (ns)" 0 --> 229902
+ bar [905.4, 532, 682.8, 191584.7, 5140.2, 6140.7]
```
## 🎯 Key Insights
@@ -104,4 +104,4 @@ This benchmark compares **TUnit.Mocks** (source-generated) against runtime proxy
View the [mock benchmarks overview](/docs/benchmarks/mocks) for methodology details and environment information.
:::
-*Last generated: 2026-04-16T03:23:00.282Z*
+*Last generated: 2026-04-17T03:23:50.633Z*
diff --git a/docs/docs/benchmarks/mocks/CombinedWorkflow.md b/docs/docs/benchmarks/mocks/CombinedWorkflow.md
index ff82000a8c..6d52267a16 100644
--- a/docs/docs/benchmarks/mocks/CombinedWorkflow.md
+++ b/docs/docs/benchmarks/mocks/CombinedWorkflow.md
@@ -7,7 +7,7 @@ sidebar_position: 3
# CombinedWorkflow Benchmark
:::info Last Updated
-This benchmark was automatically generated on **2026-04-16** from the latest CI run.
+This benchmark was automatically generated on **2026-04-17** from the latest CI run.
**Environment:** Ubuntu Latest • .NET SDK 10.0.202
:::
@@ -18,12 +18,12 @@ Full workflow: create → setup → invoke → verify:
| Library | Mean | Error | StdDev | Allocated |
|---------|------|-------|--------|-----------|
-| **TUnit.Mocks** | 1.992 μs | 0.0301 μs | 0.0281 μs | 6.34 KB |
-| Imposter | 2.628 μs | 0.0513 μs | 0.0549 μs | 15.71 KB |
-| Mockolate | 2.479 μs | 0.0239 μs | 0.0224 μs | 7.06 KB |
-| Moq | 309.995 μs | 2.2459 μs | 2.1009 μs | 36.16 KB |
-| NSubstitute | 16.228 μs | 0.3080 μs | 0.3296 μs | 26.72 KB |
-| FakeItEasy | 15.431 μs | 0.3011 μs | 0.2958 μs | 25.52 KB |
+| **TUnit.Mocks** | 1.836 μs | 0.0248 μs | 0.0232 μs | 6.34 KB |
+| Imposter | 2.680 μs | 0.0345 μs | 0.0306 μs | 15.71 KB |
+| Mockolate | 2.449 μs | 0.0474 μs | 0.0507 μs | 7.06 KB |
+| Moq | 398.443 μs | 3.5265 μs | 3.2986 μs | 36.42 KB |
+| NSubstitute | 17.009 μs | 0.1050 μs | 0.0982 μs | 26.72 KB |
+| FakeItEasy | 18.375 μs | 0.1999 μs | 0.1670 μs | 25.52 KB |
```mermaid
%%{init: {
@@ -49,8 +49,8 @@ Full workflow: create → setup → invoke → verify:
xychart-beta
title "CombinedWorkflow Performance Comparison"
x-axis ["TUnit.Mocks", "Imposter", "Mockolate", "Moq", "NSubstitute", "FakeItEasy"]
- y-axis "Time (μs)" 0 --> 372
- bar [1.992, 2.628, 2.479, 309.995, 16.228, 15.431]
+ y-axis "Time (μs)" 0 --> 479
+ bar [1.836, 2.68, 2.449, 398.443, 17.009, 18.375]
```
## 🎯 Key Insights
@@ -63,4 +63,4 @@ This benchmark compares **TUnit.Mocks** (source-generated) against runtime proxy
View the [mock benchmarks overview](/docs/benchmarks/mocks) for methodology details and environment information.
:::
-*Last generated: 2026-04-16T03:23:00.282Z*
+*Last generated: 2026-04-17T03:23:50.633Z*
diff --git a/docs/docs/benchmarks/mocks/Invocation.md b/docs/docs/benchmarks/mocks/Invocation.md
index 0784bd6cb5..055df12df4 100644
--- a/docs/docs/benchmarks/mocks/Invocation.md
+++ b/docs/docs/benchmarks/mocks/Invocation.md
@@ -7,7 +7,7 @@ sidebar_position: 4
# Invocation Benchmark
:::info Last Updated
-This benchmark was automatically generated on **2026-04-16** from the latest CI run.
+This benchmark was automatically generated on **2026-04-17** from the latest CI run.
**Environment:** Ubuntu Latest • .NET SDK 10.0.202
:::
@@ -18,12 +18,12 @@ Calling methods on mock objects:
| Library | Mean | Error | StdDev | Allocated |
|---------|------|-------|--------|-----------|
-| **TUnit.Mocks** | 262.9 ns | 87.03 ns | 4.77 ns | 120 B |
-| Imposter | 302.5 ns | 61.38 ns | 3.36 ns | 168 B |
-| Mockolate | 694.4 ns | 180.17 ns | 9.88 ns | 640 B |
-| Moq | 851.5 ns | 206.80 ns | 11.34 ns | 376 B |
-| NSubstitute | 734.4 ns | 148.63 ns | 8.15 ns | 304 B |
-| FakeItEasy | 1,812.9 ns | 408.72 ns | 22.40 ns | 944 B |
+| **TUnit.Mocks** | 263.5 ns | 74.99 ns | 4.11 ns | 120 B |
+| Imposter | 303.6 ns | 71.00 ns | 3.89 ns | 168 B |
+| Mockolate | 687.7 ns | 80.12 ns | 4.39 ns | 640 B |
+| Moq | 801.1 ns | 191.96 ns | 10.52 ns | 376 B |
+| NSubstitute | 743.6 ns | 122.94 ns | 6.74 ns | 304 B |
+| FakeItEasy | 1,772.1 ns | 397.78 ns | 21.80 ns | 944 B |
```mermaid
%%{init: {
@@ -49,8 +49,8 @@ Calling methods on mock objects:
xychart-beta
title "Invocation Performance Comparison"
x-axis ["TUnit.Mocks", "Imposter", "Mockolate", "Moq", "NSubstitute", "FakeItEasy"]
- y-axis "Time (ns)" 0 --> 2176
- bar [262.9, 302.5, 694.4, 851.5, 734.4, 1812.9]
+ y-axis "Time (ns)" 0 --> 2127
+ bar [263.5, 303.6, 687.7, 801.1, 743.6, 1772.1]
```
---
@@ -59,12 +59,12 @@ xychart-beta
| Library | Mean | Error | StdDev | Allocated |
|---------|------|-------|--------|-----------|
-| **TUnit.Mocks** | 157.3 ns | 81.04 ns | 4.44 ns | 88 B |
-| Imposter | 300.7 ns | 42.70 ns | 2.34 ns | 168 B |
-| Mockolate | 543.7 ns | 188.98 ns | 10.36 ns | 520 B |
-| Moq | 562.1 ns | 89.18 ns | 4.89 ns | 296 B |
-| NSubstitute | 628.7 ns | 78.20 ns | 4.29 ns | 272 B |
-| FakeItEasy | 1,645.3 ns | 253.86 ns | 13.92 ns | 776 B |
+| **TUnit.Mocks** | 166.1 ns | 71.02 ns | 3.89 ns | 88 B |
+| Imposter | 306.7 ns | 121.47 ns | 6.66 ns | 168 B |
+| Mockolate | 543.0 ns | 90.95 ns | 4.99 ns | 520 B |
+| Moq | 536.6 ns | 100.52 ns | 5.51 ns | 296 B |
+| NSubstitute | 653.8 ns | 679.02 ns | 37.22 ns | 272 B |
+| FakeItEasy | 1,619.8 ns | 547.84 ns | 30.03 ns | 776 B |
```mermaid
%%{init: {
@@ -90,8 +90,8 @@ xychart-beta
xychart-beta
title "Invocation (String) Performance Comparison"
x-axis ["TUnit.Mocks", "Imposter", "Mockolate", "Moq", "NSubstitute", "FakeItEasy"]
- y-axis "Time (ns)" 0 --> 1975
- bar [157.3, 300.7, 543.7, 562.1, 628.7, 1645.3]
+ y-axis "Time (ns)" 0 --> 1944
+ bar [166.1, 306.7, 543, 536.6, 653.8, 1619.8]
```
---
@@ -100,12 +100,12 @@ xychart-beta
| Library | Mean | Error | StdDev | Allocated |
|---------|------|-------|--------|-----------|
-| **TUnit.Mocks** | 26,674.2 ns | 15,350.85 ns | 841.43 ns | 11936 B |
-| Imposter | 29,524.3 ns | 10,411.38 ns | 570.68 ns | 16800 B |
-| Mockolate | 66,514.3 ns | 29,938.23 ns | 1,641.02 ns | 64000 B |
-| Moq | 84,478.0 ns | 24,794.97 ns | 1,359.10 ns | 37600 B |
-| NSubstitute | 78,702.1 ns | 11,264.77 ns | 617.46 ns | 36448 B |
-| FakeItEasy | 190,003.8 ns | 33,840.70 ns | 1,854.92 ns | 94400 B |
+| **TUnit.Mocks** | 26,494.2 ns | 13,807.64 ns | 756.84 ns | 11936 B |
+| Imposter | 30,093.3 ns | 8,247.06 ns | 452.05 ns | 16800 B |
+| Mockolate | 70,940.2 ns | 18,268.87 ns | 1,001.38 ns | 64000 B |
+| Moq | 82,141.8 ns | 5,729.62 ns | 314.06 ns | 37600 B |
+| NSubstitute | 73,823.5 ns | 5,831.24 ns | 319.63 ns | 30848 B |
+| FakeItEasy | 186,702.0 ns | 247,385.84 ns | 13,560.05 ns | 94400 B |
```mermaid
%%{init: {
@@ -131,8 +131,8 @@ xychart-beta
xychart-beta
title "Invocation (100 calls) Performance Comparison"
x-axis ["TUnit.Mocks", "Imposter", "Mockolate", "Moq", "NSubstitute", "FakeItEasy"]
- y-axis "Time (ns)" 0 --> 228005
- bar [26674.2, 29524.3, 66514.3, 84478, 78702.1, 190003.8]
+ y-axis "Time (ns)" 0 --> 224043
+ bar [26494.2, 30093.3, 70940.2, 82141.8, 73823.5, 186702]
```
## 🎯 Key Insights
@@ -145,4 +145,4 @@ This benchmark compares **TUnit.Mocks** (source-generated) against runtime proxy
View the [mock benchmarks overview](/docs/benchmarks/mocks) for methodology details and environment information.
:::
-*Last generated: 2026-04-16T03:23:00.282Z*
+*Last generated: 2026-04-17T03:23:50.633Z*
diff --git a/docs/docs/benchmarks/mocks/MockCreation.md b/docs/docs/benchmarks/mocks/MockCreation.md
index 581027df57..7440fc1b36 100644
--- a/docs/docs/benchmarks/mocks/MockCreation.md
+++ b/docs/docs/benchmarks/mocks/MockCreation.md
@@ -7,7 +7,7 @@ sidebar_position: 5
# MockCreation Benchmark
:::info Last Updated
-This benchmark was automatically generated on **2026-04-16** from the latest CI run.
+This benchmark was automatically generated on **2026-04-17** from the latest CI run.
**Environment:** Ubuntu Latest • .NET SDK 10.0.202
:::
@@ -18,12 +18,12 @@ Mock instance creation performance:
| Library | Mean | Error | StdDev | Allocated |
|---------|------|-------|--------|-----------|
-| **TUnit.Mocks** | 33.06 ns | 0.148 ns | 0.124 ns | 192 B |
-| Imposter | 87.58 ns | 0.274 ns | 0.229 ns | 440 B |
-| Mockolate | 72.01 ns | 0.253 ns | 0.224 ns | 384 B |
-| Moq | 1,304.59 ns | 18.161 ns | 16.988 ns | 2048 B |
-| NSubstitute | 1,755.00 ns | 6.823 ns | 6.383 ns | 5000 B |
-| FakeItEasy | 1,686.18 ns | 5.945 ns | 5.270 ns | 2715 B |
+| **TUnit.Mocks** | 28.82 ns | 0.467 ns | 0.437 ns | 192 B |
+| Imposter | 98.31 ns | 1.933 ns | 2.149 ns | 440 B |
+| Mockolate | 72.83 ns | 1.417 ns | 1.326 ns | 384 B |
+| Moq | 1,318.28 ns | 15.107 ns | 14.131 ns | 2048 B |
+| NSubstitute | 1,942.07 ns | 17.795 ns | 15.775 ns | 5000 B |
+| FakeItEasy | 1,743.46 ns | 34.281 ns | 52.350 ns | 2715 B |
```mermaid
%%{init: {
@@ -49,8 +49,8 @@ Mock instance creation performance:
xychart-beta
title "MockCreation Performance Comparison"
x-axis ["TUnit.Mocks", "Imposter", "Mockolate", "Moq", "NSubstitute", "FakeItEasy"]
- y-axis "Time (ns)" 0 --> 2106
- bar [33.06, 87.58, 72.01, 1304.59, 1755, 1686.18]
+ y-axis "Time (ns)" 0 --> 2331
+ bar [28.82, 98.31, 72.83, 1318.28, 1942.07, 1743.46]
```
---
@@ -59,12 +59,12 @@ xychart-beta
| Library | Mean | Error | StdDev | Allocated |
|---------|------|-------|--------|-----------|
-| **TUnit.Mocks** | 33.44 ns | 0.170 ns | 0.151 ns | 192 B |
-| Imposter | 139.23 ns | 0.719 ns | 0.672 ns | 696 B |
-| Mockolate | 63.28 ns | 0.304 ns | 0.284 ns | 384 B |
-| Moq | 1,357.45 ns | 4.724 ns | 3.945 ns | 1912 B |
-| NSubstitute | 1,725.98 ns | 14.482 ns | 13.546 ns | 5000 B |
-| FakeItEasy | 1,611.20 ns | 12.730 ns | 10.630 ns | 2715 B |
+| **TUnit.Mocks** | 28.89 ns | 0.634 ns | 0.825 ns | 192 B |
+| Imposter | 156.01 ns | 2.179 ns | 2.038 ns | 696 B |
+| Mockolate | 72.26 ns | 1.494 ns | 2.536 ns | 384 B |
+| Moq | 1,284.46 ns | 16.637 ns | 15.562 ns | 1912 B |
+| NSubstitute | 1,919.01 ns | 36.617 ns | 35.962 ns | 5000 B |
+| FakeItEasy | 1,661.42 ns | 15.968 ns | 13.334 ns | 2715 B |
```mermaid
%%{init: {
@@ -90,8 +90,8 @@ xychart-beta
xychart-beta
title "MockCreation (Repository) Performance Comparison"
x-axis ["TUnit.Mocks", "Imposter", "Mockolate", "Moq", "NSubstitute", "FakeItEasy"]
- y-axis "Time (ns)" 0 --> 2072
- bar [33.44, 139.23, 63.28, 1357.45, 1725.98, 1611.2]
+ y-axis "Time (ns)" 0 --> 2303
+ bar [28.89, 156.01, 72.26, 1284.46, 1919.01, 1661.42]
```
## 🎯 Key Insights
@@ -104,4 +104,4 @@ This benchmark compares **TUnit.Mocks** (source-generated) against runtime proxy
View the [mock benchmarks overview](/docs/benchmarks/mocks) for methodology details and environment information.
:::
-*Last generated: 2026-04-16T03:23:00.282Z*
+*Last generated: 2026-04-17T03:23:50.633Z*
diff --git a/docs/docs/benchmarks/mocks/Setup.md b/docs/docs/benchmarks/mocks/Setup.md
index 51a9935e6e..0ac1e2153d 100644
--- a/docs/docs/benchmarks/mocks/Setup.md
+++ b/docs/docs/benchmarks/mocks/Setup.md
@@ -7,7 +7,7 @@ sidebar_position: 6
# Setup Benchmark
:::info Last Updated
-This benchmark was automatically generated on **2026-04-16** from the latest CI run.
+This benchmark was automatically generated on **2026-04-17** from the latest CI run.
**Environment:** Ubuntu Latest • .NET SDK 10.0.202
:::
@@ -18,12 +18,12 @@ Mock behavior configuration (returns, matchers):
| Library | Mean | Error | StdDev | Allocated |
|---------|------|-------|--------|-----------|
-| **TUnit.Mocks** | 557.4 ns | 8.70 ns | 8.14 ns | 2.34 KB |
-| Imposter | 763.7 ns | 15.16 ns | 28.48 ns | 6.12 KB |
-| Mockolate | 437.8 ns | 2.22 ns | 1.85 ns | 2.03 KB |
-| Moq | 418,995.5 ns | 2,484.70 ns | 2,324.19 ns | 28.52 KB |
-| NSubstitute | 5,422.0 ns | 42.62 ns | 39.86 ns | 9.01 KB |
-| FakeItEasy | 8,015.8 ns | 72.55 ns | 67.86 ns | 10.45 KB |
+| **TUnit.Mocks** | 555.9 ns | 5.00 ns | 4.68 ns | 2.34 KB |
+| Imposter | 865.2 ns | 8.72 ns | 8.15 ns | 6.12 KB |
+| Mockolate | 483.5 ns | 6.29 ns | 5.58 ns | 2.03 KB |
+| Moq | 429,288.1 ns | 1,670.44 ns | 1,480.80 ns | 28.71 KB |
+| NSubstitute | 5,857.1 ns | 89.45 ns | 79.29 ns | 9.01 KB |
+| FakeItEasy | 8,801.0 ns | 82.60 ns | 77.27 ns | 10.45 KB |
```mermaid
%%{init: {
@@ -49,8 +49,8 @@ Mock behavior configuration (returns, matchers):
xychart-beta
title "Setup Performance Comparison"
x-axis ["TUnit.Mocks", "Imposter", "Mockolate", "Moq", "NSubstitute", "FakeItEasy"]
- y-axis "Time (ns)" 0 --> 502795
- bar [557.4, 763.7, 437.8, 418995.5, 5422, 8015.8]
+ y-axis "Time (ns)" 0 --> 515146
+ bar [555.9, 865.2, 483.5, 429288.1, 5857.1, 8801]
```
---
@@ -59,12 +59,12 @@ xychart-beta
| Library | Mean | Error | StdDev | Allocated |
|---------|------|-------|--------|-----------|
-| **TUnit.Mocks** | 743.7 ns | 6.78 ns | 6.34 ns | 2.93 KB |
-| Imposter | 1,384.7 ns | 10.61 ns | 9.93 ns | 10.59 KB |
-| Mockolate | 707.3 ns | 14.15 ns | 14.53 ns | 3.07 KB |
-| Moq | 111,746.9 ns | 736.94 ns | 653.28 ns | 16.53 KB |
-| NSubstitute | 11,563.4 ns | 58.98 ns | 52.29 ns | 20.31 KB |
-| FakeItEasy | 7,942.7 ns | 122.18 ns | 114.29 ns | 11.82 KB |
+| **TUnit.Mocks** | 756.4 ns | 7.27 ns | 6.80 ns | 2.93 KB |
+| Imposter | 1,480.8 ns | 19.28 ns | 18.03 ns | 10.59 KB |
+| Mockolate | 743.4 ns | 8.35 ns | 7.81 ns | 3.07 KB |
+| Moq | 116,804.7 ns | 677.37 ns | 633.61 ns | 16.53 KB |
+| NSubstitute | 12,220.0 ns | 82.31 ns | 64.26 ns | 20.34 KB |
+| FakeItEasy | 7,978.2 ns | 113.55 ns | 94.82 ns | 11.71 KB |
```mermaid
%%{init: {
@@ -90,8 +90,8 @@ xychart-beta
xychart-beta
title "Setup (Multiple) Performance Comparison"
x-axis ["TUnit.Mocks", "Imposter", "Mockolate", "Moq", "NSubstitute", "FakeItEasy"]
- y-axis "Time (ns)" 0 --> 134097
- bar [743.7, 1384.7, 707.3, 111746.9, 11563.4, 7942.7]
+ y-axis "Time (ns)" 0 --> 140166
+ bar [756.4, 1480.8, 743.4, 116804.7, 12220, 7978.2]
```
## 🎯 Key Insights
@@ -104,4 +104,4 @@ This benchmark compares **TUnit.Mocks** (source-generated) against runtime proxy
View the [mock benchmarks overview](/docs/benchmarks/mocks) for methodology details and environment information.
:::
-*Last generated: 2026-04-16T03:23:00.282Z*
+*Last generated: 2026-04-17T03:23:50.633Z*
diff --git a/docs/docs/benchmarks/mocks/Verification.md b/docs/docs/benchmarks/mocks/Verification.md
index adeed7eb33..ad2b623766 100644
--- a/docs/docs/benchmarks/mocks/Verification.md
+++ b/docs/docs/benchmarks/mocks/Verification.md
@@ -7,7 +7,7 @@ sidebar_position: 7
# Verification Benchmark
:::info Last Updated
-This benchmark was automatically generated on **2026-04-16** from the latest CI run.
+This benchmark was automatically generated on **2026-04-17** from the latest CI run.
**Environment:** Ubuntu Latest • .NET SDK 10.0.202
:::
@@ -18,12 +18,12 @@ Verifying mock method calls:
| Library | Mean | Error | StdDev | Allocated |
|---------|------|-------|--------|-----------|
-| **TUnit.Mocks** | 709.52 ns | 1.363 ns | 1.138 ns | 3080 B |
-| Imposter | 654.41 ns | 4.892 ns | 4.576 ns | 4688 B |
-| Mockolate | 922.90 ns | 1.553 ns | 1.377 ns | 3152 B |
-| Moq | 335,454.92 ns | 2,661.938 ns | 2,489.978 ns | 24325 B |
-| NSubstitute | 6,129.28 ns | 29.884 ns | 27.954 ns | 10064 B |
-| FakeItEasy | 7,205.45 ns | 21.911 ns | 19.424 ns | 10722 B |
+| **TUnit.Mocks** | 779.75 ns | 14.522 ns | 13.584 ns | 3080 B |
+| Imposter | 697.99 ns | 12.340 ns | 14.211 ns | 4688 B |
+| Mockolate | 923.67 ns | 13.586 ns | 12.708 ns | 3152 B |
+| Moq | 246,002.68 ns | 2,007.103 ns | 1,877.445 ns | 24675 B |
+| NSubstitute | 6,025.40 ns | 116.063 ns | 124.186 ns | 10064 B |
+| FakeItEasy | 6,484.46 ns | 96.148 ns | 80.288 ns | 10722 B |
```mermaid
%%{init: {
@@ -49,8 +49,8 @@ Verifying mock method calls:
xychart-beta
title "Verification Performance Comparison"
x-axis ["TUnit.Mocks", "Imposter", "Mockolate", "Moq", "NSubstitute", "FakeItEasy"]
- y-axis "Time (ns)" 0 --> 402546
- bar [709.52, 654.41, 922.9, 335454.92, 6129.28, 7205.45]
+ y-axis "Time (ns)" 0 --> 295204
+ bar [779.75, 697.99, 923.67, 246002.68, 6025.4, 6484.46]
```
---
@@ -59,12 +59,12 @@ xychart-beta
| Library | Mean | Error | StdDev | Allocated |
|---------|------|-------|--------|-----------|
-| **TUnit.Mocks** | 59.95 ns | 0.451 ns | 0.422 ns | 328 B |
-| Imposter | 314.43 ns | 1.048 ns | 0.929 ns | 2400 B |
-| Mockolate | 211.67 ns | 0.971 ns | 0.908 ns | 952 B |
-| Moq | 85,724.92 ns | 135.791 ns | 120.375 ns | 6918 B |
-| NSubstitute | 3,567.38 ns | 12.592 ns | 11.778 ns | 7088 B |
-| FakeItEasy | 3,545.33 ns | 15.567 ns | 12.999 ns | 5210 B |
+| **TUnit.Mocks** | 60.57 ns | 1.083 ns | 1.013 ns | 328 B |
+| Imposter | 339.67 ns | 4.611 ns | 4.087 ns | 2400 B |
+| Mockolate | 244.38 ns | 3.407 ns | 2.845 ns | 952 B |
+| Moq | 63,280.47 ns | 469.889 ns | 439.534 ns | 7037 B |
+| NSubstitute | 3,612.39 ns | 70.100 ns | 95.954 ns | 7088 B |
+| FakeItEasy | 3,636.87 ns | 22.107 ns | 18.461 ns | 5210 B |
```mermaid
%%{init: {
@@ -90,8 +90,8 @@ xychart-beta
xychart-beta
title "Verification (Never) Performance Comparison"
x-axis ["TUnit.Mocks", "Imposter", "Mockolate", "Moq", "NSubstitute", "FakeItEasy"]
- y-axis "Time (ns)" 0 --> 102870
- bar [59.95, 314.43, 211.67, 85724.92, 3567.38, 3545.33]
+ y-axis "Time (ns)" 0 --> 75937
+ bar [60.57, 339.67, 244.38, 63280.47, 3612.39, 3636.87]
```
---
@@ -100,12 +100,12 @@ xychart-beta
| Library | Mean | Error | StdDev | Allocated |
|---------|------|-------|--------|-----------|
-| **TUnit.Mocks** | 1,286.07 ns | 13.249 ns | 12.394 ns | 4608 B |
-| Imposter | 1,635.13 ns | 5.646 ns | 5.282 ns | 11192 B |
-| Mockolate | 1,783.27 ns | 7.720 ns | 6.843 ns | 5496 B |
-| Moq | 458,716.34 ns | 1,253.308 ns | 1,046.569 ns | 34699 B |
-| NSubstitute | 11,132.14 ns | 69.879 ns | 65.365 ns | 16763 B |
-| FakeItEasy | 12,981.76 ns | 101.066 ns | 89.593 ns | 19233 B |
+| **TUnit.Mocks** | 1,391.12 ns | 9.208 ns | 8.163 ns | 4608 B |
+| Imposter | 1,934.79 ns | 36.515 ns | 34.157 ns | 11192 B |
+| Mockolate | 1,969.13 ns | 28.229 ns | 25.024 ns | 5496 B |
+| Moq | 354,693.14 ns | 3,496.978 ns | 3,271.075 ns | 34699 B |
+| NSubstitute | 11,172.45 ns | 70.593 ns | 66.032 ns | 16763 B |
+| FakeItEasy | 13,089.09 ns | 99.377 ns | 88.095 ns | 19568 B |
```mermaid
%%{init: {
@@ -131,8 +131,8 @@ xychart-beta
xychart-beta
title "Verification (Multiple) Performance Comparison"
x-axis ["TUnit.Mocks", "Imposter", "Mockolate", "Moq", "NSubstitute", "FakeItEasy"]
- y-axis "Time (ns)" 0 --> 550460
- bar [1286.07, 1635.13, 1783.27, 458716.34, 11132.14, 12981.76]
+ y-axis "Time (ns)" 0 --> 425632
+ bar [1391.12, 1934.79, 1969.13, 354693.14, 11172.45, 13089.09]
```
## 🎯 Key Insights
@@ -145,4 +145,4 @@ This benchmark compares **TUnit.Mocks** (source-generated) against runtime proxy
View the [mock benchmarks overview](/docs/benchmarks/mocks) for methodology details and environment information.
:::
-*Last generated: 2026-04-16T03:23:00.282Z*
+*Last generated: 2026-04-17T03:23:50.633Z*
diff --git a/docs/docs/benchmarks/mocks/index.md b/docs/docs/benchmarks/mocks/index.md
index 96ca9be043..54548a95a7 100644
--- a/docs/docs/benchmarks/mocks/index.md
+++ b/docs/docs/benchmarks/mocks/index.md
@@ -7,7 +7,7 @@ sidebar_position: 1
# Mock Library Benchmarks
:::info Last Updated
-These benchmarks were automatically generated on **2026-04-16** from the latest CI run.
+These benchmarks were automatically generated on **2026-04-17** from the latest CI run.
**Environment:** Ubuntu Latest • .NET SDK 10.0.202
:::
@@ -29,12 +29,12 @@ These benchmarks compare source-generated, AOT-compatible mocking libraries agai
Click on any benchmark to view detailed results:
-- [Callback](Callback) - Callback registration and execution
-- [CombinedWorkflow](CombinedWorkflow) - Full workflow: create → setup → invoke → verify
-- [Invocation](Invocation) - Calling methods on mock objects
-- [MockCreation](MockCreation) - Mock instance creation performance
-- [Setup](Setup) - Mock behavior configuration (returns, matchers)
-- [Verification](Verification) - Verifying mock method calls
+- [Callback](./Callback.md) - Callback registration and execution
+- [CombinedWorkflow](./CombinedWorkflow.md) - Full workflow: create → setup → invoke → verify
+- [Invocation](./Invocation.md) - Calling methods on mock objects
+- [MockCreation](./MockCreation.md) - Mock instance creation performance
+- [Setup](./Setup.md) - Mock behavior configuration (returns, matchers)
+- [Verification](./Verification.md) - Verifying mock method calls
## 📈 What's Measured
@@ -76,4 +76,4 @@ These benchmarks run automatically daily via [GitHub Actions](https://github.com
Each benchmark runs multiple iterations with statistical analysis to ensure accuracy. Results may vary based on hardware and test characteristics.
:::
-*Last generated: 2026-04-16T03:23:00.282Z*
+*Last generated: 2026-04-17T03:23:50.633Z*
diff --git a/docs/docs/examples/aspnet.md b/docs/docs/examples/aspnet.md
index 36b929d339..568daf2871 100644
--- a/docs/docs/examples/aspnet.md
+++ b/docs/docs/examples/aspnet.md
@@ -2,6 +2,25 @@
TUnit provides first-class support for ASP.NET Core integration testing through the `TUnit.AspNetCore` package. This package enables per-test isolation with shared infrastructure, making it easy to write fast, parallel integration tests.
+:::warning Use `TestWebApplicationFactory`, not the vanilla `WebApplicationFactory