From 8c823c543d058b3ef9e4b62c75c26bbe1a4e50be Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Thu, 16 Apr 2026 22:52:54 +0100 Subject: [PATCH 01/17] fix: don't render test's own trace as Linked Trace in HTML report (#5580) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The engine auto-registers the test's own traceId in TraceRegistry for OTLP cross-process log correlation. HtmlReporter then read it back as an "additional" trace, causing the primary trace to render twice — once filtered under "Trace Timeline", once raw under "Linked Trace: TUnit". Filter out the primary traceId so only user-registered external traces (via TestContext.RegisterTrace) appear as Linked Traces, matching documented behavior. --- TUnit.Engine.Tests/HtmlReporterTests.cs | 42 +++++++++++++++++++++ TUnit.Engine/Reporters/Html/HtmlReporter.cs | 26 ++++++++++++- 2 files changed, 67 insertions(+), 1 deletion(-) 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/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; From 7a72f9d538fb1a52fdfdc6ab8efb7f14a7159be1 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Thu, 16 Apr 2026 23:54:13 +0100 Subject: [PATCH 02/17] chore(deps): update tunit to 1.35.2 (#5581) Co-authored-by: Renovate Bot --- Directory.Packages.props | 12 ++++++------ .../TestProject/TestProject.fsproj | 4 ++-- .../TUnit.AspNet/TestProject/TestProject.csproj | 2 +- .../content/TUnit.FSharp/TestProject.fsproj | 4 ++-- .../content/TUnit.Playwright/TestProject.csproj | 2 +- TUnit.Templates/content/TUnit.VB/TestProject.vbproj | 2 +- 6 files changed, 13 insertions(+), 13 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index a59f8dc490..0a2822eb62 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -98,14 +98,14 @@ - + - - - - - + + + + + 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 @@ - + From 4f1033cbf4375e3b45afd2e302163b9ccedc4625 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Fri, 17 Apr 2026 01:23:43 +0100 Subject: [PATCH 03/17] chore(deps): update dependency typescript to ~6.0.3 (#5582) Co-authored-by: Renovate Bot --- docs/package.json | 2 +- docs/yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/package.json b/docs/package.json index 7958d63670..bc8b63cc46 100644 --- a/docs/package.json +++ b/docs/package.json @@ -29,7 +29,7 @@ "@docusaurus/tsconfig": "3.10.0", "@docusaurus/types": "3.10.0", "docusaurus-plugin-llms": "^0.3.1", - "typescript": "~6.0.0" + "typescript": "~6.0.3" }, "browserslist": { "production": [ diff --git a/docs/yarn.lock b/docs/yarn.lock index 8dae8b6617..ff4577b385 100644 --- a/docs/yarn.lock +++ b/docs/yarn.lock @@ -9210,10 +9210,10 @@ typedarray-to-buffer@^3.1.5: dependencies: is-typedarray "^1.0.0" -typescript@~6.0.0: - version "6.0.2" - resolved "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz" - integrity sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ== +typescript@~6.0.3: + version "6.0.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-6.0.3.tgz#90251dc007916e972786cb94d74d15b185577d21" + integrity sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw== ufo@^1.6.1: version "1.6.1" From b22d2b669ce43ed9323954b4325f863a1c86846b Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Fri, 17 Apr 2026 01:45:04 +0100 Subject: [PATCH 04/17] chore: update benchmark results (#5583) --- docs/docs/benchmarks/AsyncTests.md | 18 +- docs/docs/benchmarks/BuildTime.md | 14 +- docs/docs/benchmarks/DataDrivenTests.md | 18 +- docs/docs/benchmarks/MassiveParallelTests.md | 18 +- docs/docs/benchmarks/MatrixTests.md | 18 +- docs/docs/benchmarks/ScaleTests.md | 18 +- docs/docs/benchmarks/SetupTeardownTests.md | 18 +- docs/docs/benchmarks/index.md | 6 +- docs/static/benchmarks/AsyncTests.json | 46 +-- docs/static/benchmarks/BuildTime.json | 36 +-- docs/static/benchmarks/DataDrivenTests.json | 46 +-- .../benchmarks/MassiveParallelTests.json | 46 +-- docs/static/benchmarks/MatrixTests.json | 46 +-- docs/static/benchmarks/ScaleTests.json | 46 +-- .../static/benchmarks/SetupTeardownTests.json | 38 +-- docs/static/benchmarks/historical.json | 8 +- docs/static/benchmarks/latest.json | 294 +++++++++--------- docs/static/benchmarks/summary.json | 2 +- 18 files changed, 368 insertions(+), 368 deletions(-) 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..21f13246b5 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 ::: @@ -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/static/benchmarks/AsyncTests.json b/docs/static/benchmarks/AsyncTests.json index 6660e1c4fe..023ffa78b6 100644 --- a/docs/static/benchmarks/AsyncTests.json +++ b/docs/static/benchmarks/AsyncTests.json @@ -1,5 +1,5 @@ { - "timestamp": "2026-04-16T00:46:53.077Z", + "timestamp": "2026-04-17T00:44:48.035Z", "category": "AsyncTests", "environment": { "benchmarkDotNetVersion": "BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.4 LTS (Noble Numbat)", @@ -9,43 +9,43 @@ "results": [ { "Method": "TUnit", - "Version": "1.34.5", - "Mean": "522.2 ms", - "Error": "3.64 ms", - "StdDev": "3.23 ms", - "Median": "522.0 ms" + "Version": "1.35.2", + "Mean": "525.0 ms", + "Error": "2.98 ms", + "StdDev": "2.79 ms", + "Median": "525.1 ms" }, { "Method": "NUnit", "Version": "4.5.1", - "Mean": "711.0 ms", - "Error": "10.98 ms", - "StdDev": "9.73 ms", - "Median": "709.9 ms" + "Mean": "693.8 ms", + "Error": "8.77 ms", + "StdDev": "7.32 ms", + "Median": "694.3 ms" }, { "Method": "MSTest", "Version": "4.2.1", - "Mean": "620.7 ms", - "Error": "4.28 ms", - "StdDev": "3.57 ms", - "Median": "620.7 ms" + "Mean": "601.6 ms", + "Error": "7.57 ms", + "StdDev": "7.08 ms", + "Median": "600.8 ms" }, { "Method": "xUnit3", "Version": "3.2.2", - "Mean": "761.8 ms", - "Error": "7.16 ms", - "StdDev": "5.98 ms", - "Median": "762.2 ms" + "Mean": "747.3 ms", + "Error": "4.82 ms", + "StdDev": "4.28 ms", + "Median": "747.6 ms" }, { "Method": "TUnit_AOT", - "Version": "1.34.5", - "Mean": "123.4 ms", - "Error": "0.23 ms", - "StdDev": "0.22 ms", - "Median": "123.5 ms" + "Version": "1.35.2", + "Mean": "121.5 ms", + "Error": "0.20 ms", + "StdDev": "0.18 ms", + "Median": "121.5 ms" } ] } \ No newline at end of file diff --git a/docs/static/benchmarks/BuildTime.json b/docs/static/benchmarks/BuildTime.json index 4d27d0956c..93f7fba31c 100644 --- a/docs/static/benchmarks/BuildTime.json +++ b/docs/static/benchmarks/BuildTime.json @@ -1,5 +1,5 @@ { - "timestamp": "2026-04-16T00:46:53.079Z", + "timestamp": "2026-04-17T00:44:48.037Z", "category": "BuildTime", "environment": { "benchmarkDotNetVersion": "BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.4 LTS (Noble Numbat)", @@ -9,35 +9,35 @@ "results": [ { "Method": "Build_TUnit", - "Version": "1.34.5", - "Mean": "1.712 s", - "Error": "0.0328 s", - "StdDev": "0.0336 s", - "Median": "1.724 s" + "Version": "1.35.2", + "Mean": "1.922 s", + "Error": "0.0378 s", + "StdDev": "0.0621 s", + "Median": "1.901 s" }, { "Method": "Build_NUnit", "Version": "4.5.1", - "Mean": "1.522 s", - "Error": "0.0274 s", - "StdDev": "0.0256 s", - "Median": "1.525 s" + "Mean": "1.684 s", + "Error": "0.0204 s", + "StdDev": "0.0159 s", + "Median": "1.683 s" }, { "Method": "Build_MSTest", "Version": "4.2.1", - "Mean": "1.612 s", - "Error": "0.0198 s", - "StdDev": "0.0185 s", - "Median": "1.619 s" + "Mean": "1.801 s", + "Error": "0.0360 s", + "StdDev": "0.0649 s", + "Median": "1.816 s" }, { "Method": "Build_xUnit3", "Version": "3.2.2", - "Mean": "1.533 s", - "Error": "0.0202 s", - "StdDev": "0.0189 s", - "Median": "1.533 s" + "Mean": "1.667 s", + "Error": "0.0126 s", + "StdDev": "0.0118 s", + "Median": "1.665 s" } ] } \ No newline at end of file diff --git a/docs/static/benchmarks/DataDrivenTests.json b/docs/static/benchmarks/DataDrivenTests.json index f2cafbf4ab..4113786652 100644 --- a/docs/static/benchmarks/DataDrivenTests.json +++ b/docs/static/benchmarks/DataDrivenTests.json @@ -1,5 +1,5 @@ { - "timestamp": "2026-04-16T00:46:53.077Z", + "timestamp": "2026-04-17T00:44:48.035Z", "category": "DataDrivenTests", "environment": { "benchmarkDotNetVersion": "BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.4 LTS (Noble Numbat)", @@ -9,43 +9,43 @@ "results": [ { "Method": "TUnit", - "Version": "1.34.5", - "Mean": "468.76 ms", - "Error": "2.620 ms", - "StdDev": "2.323 ms", - "Median": "469.06 ms" + "Version": "1.35.2", + "Mean": "466.15 ms", + "Error": "3.641 ms", + "StdDev": "3.227 ms", + "Median": "465.55 ms" }, { "Method": "NUnit", "Version": "4.5.1", - "Mean": "594.69 ms", - "Error": "9.804 ms", - "StdDev": "8.691 ms", - "Median": "593.46 ms" + "Mean": "544.72 ms", + "Error": "6.282 ms", + "StdDev": "5.569 ms", + "Median": "545.66 ms" }, { "Method": "MSTest", "Version": "4.2.1", - "Mean": "583.09 ms", - "Error": "6.475 ms", - "StdDev": "5.740 ms", - "Median": "582.56 ms" + "Mean": "449.73 ms", + "Error": "8.738 ms", + "StdDev": "8.973 ms", + "Median": "448.52 ms" }, { "Method": "xUnit3", "Version": "3.2.2", - "Mean": "628.26 ms", - "Error": "4.231 ms", - "StdDev": "3.533 ms", - "Median": "626.83 ms" + "Mean": "582.30 ms", + "Error": "7.361 ms", + "StdDev": "6.885 ms", + "Median": "580.80 ms" }, { "Method": "TUnit_AOT", - "Version": "1.34.5", - "Mean": "22.20 ms", - "Error": "0.442 ms", - "StdDev": "0.762 ms", - "Median": "21.76 ms" + "Version": "1.35.2", + "Mean": "24.26 ms", + "Error": "0.478 ms", + "StdDev": "0.686 ms", + "Median": "24.27 ms" } ] } \ No newline at end of file diff --git a/docs/static/benchmarks/MassiveParallelTests.json b/docs/static/benchmarks/MassiveParallelTests.json index 73bbab8500..507fb0bae7 100644 --- a/docs/static/benchmarks/MassiveParallelTests.json +++ b/docs/static/benchmarks/MassiveParallelTests.json @@ -1,5 +1,5 @@ { - "timestamp": "2026-04-16T00:46:53.077Z", + "timestamp": "2026-04-17T00:44:48.035Z", "category": "MassiveParallelTests", "environment": { "benchmarkDotNetVersion": "BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.4 LTS (Noble Numbat)", @@ -9,43 +9,43 @@ "results": [ { "Method": "TUnit", - "Version": "1.34.5", - "Mean": "650.9 ms", - "Error": "5.77 ms", - "StdDev": "5.11 ms", - "Median": "650.9 ms" + "Version": "1.35.2", + "Mean": "559.4 ms", + "Error": "2.35 ms", + "StdDev": "2.08 ms", + "Median": "559.6 ms" }, { "Method": "NUnit", "Version": "4.5.1", - "Mean": "1,217.9 ms", - "Error": "5.69 ms", - "StdDev": "5.04 ms", - "Median": "1,219.1 ms" + "Mean": "1,132.4 ms", + "Error": "10.79 ms", + "StdDev": "9.57 ms", + "Median": "1,131.0 ms" }, { "Method": "MSTest", "Version": "4.2.1", - "Mean": "2,934.9 ms", - "Error": "6.83 ms", - "StdDev": "6.39 ms", - "Median": "2,934.0 ms" + "Mean": "2,870.1 ms", + "Error": "7.63 ms", + "StdDev": "7.14 ms", + "Median": "2,870.6 ms" }, { "Method": "xUnit3", "Version": "3.2.2", - "Mean": "3,087.1 ms", - "Error": "11.20 ms", - "StdDev": "9.36 ms", - "Median": "3,087.1 ms" + "Mean": "2,987.5 ms", + "Error": "6.02 ms", + "StdDev": "5.63 ms", + "Median": "2,988.3 ms" }, { "Method": "TUnit_AOT", - "Version": "1.34.5", - "Mean": "225.0 ms", - "Error": "0.61 ms", - "StdDev": "0.57 ms", - "Median": "225.0 ms" + "Version": "1.35.2", + "Mean": "222.5 ms", + "Error": "0.40 ms", + "StdDev": "0.37 ms", + "Median": "222.5 ms" } ] } \ No newline at end of file diff --git a/docs/static/benchmarks/MatrixTests.json b/docs/static/benchmarks/MatrixTests.json index 19c5b27ce2..741f3be652 100644 --- a/docs/static/benchmarks/MatrixTests.json +++ b/docs/static/benchmarks/MatrixTests.json @@ -1,5 +1,5 @@ { - "timestamp": "2026-04-16T00:46:53.078Z", + "timestamp": "2026-04-17T00:44:48.036Z", "category": "MatrixTests", "environment": { "benchmarkDotNetVersion": "BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.4 LTS (Noble Numbat)", @@ -9,43 +9,43 @@ "results": [ { "Method": "TUnit", - "Version": "1.34.5", - "Mean": "590.5 ms", - "Error": "3.04 ms", - "StdDev": "2.70 ms", - "Median": "590.4 ms" + "Version": "1.35.2", + "Mean": "570.9 ms", + "Error": "2.93 ms", + "StdDev": "2.60 ms", + "Median": "571.0 ms" }, { "Method": "NUnit", "Version": "4.5.1", - "Mean": "1,616.7 ms", - "Error": "6.30 ms", - "StdDev": "5.89 ms", - "Median": "1,617.1 ms" + "Mean": "1,558.5 ms", + "Error": "6.95 ms", + "StdDev": "5.80 ms", + "Median": "1,558.3 ms" }, { "Method": "MSTest", "Version": "4.2.1", - "Mean": "1,509.9 ms", - "Error": "4.59 ms", - "StdDev": "4.30 ms", - "Median": "1,509.0 ms" + "Mean": "1,460.6 ms", + "Error": "7.62 ms", + "StdDev": "7.13 ms", + "Median": "1,459.6 ms" }, { "Method": "xUnit3", "Version": "3.2.2", - "Mean": "1,661.7 ms", - "Error": "7.42 ms", - "StdDev": "6.20 ms", - "Median": "1,661.2 ms" + "Mean": "1,596.7 ms", + "Error": "5.69 ms", + "StdDev": "5.04 ms", + "Median": "1,595.8 ms" }, { "Method": "TUnit_AOT", - "Version": "1.34.5", - "Mean": "126.0 ms", - "Error": "0.48 ms", - "StdDev": "0.45 ms", - "Median": "125.9 ms" + "Version": "1.35.2", + "Mean": "126.9 ms", + "Error": "0.52 ms", + "StdDev": "0.47 ms", + "Median": "126.9 ms" } ] } \ No newline at end of file diff --git a/docs/static/benchmarks/ScaleTests.json b/docs/static/benchmarks/ScaleTests.json index 3976159391..a092fe6ed1 100644 --- a/docs/static/benchmarks/ScaleTests.json +++ b/docs/static/benchmarks/ScaleTests.json @@ -1,5 +1,5 @@ { - "timestamp": "2026-04-16T00:46:53.078Z", + "timestamp": "2026-04-17T00:44:48.036Z", "category": "ScaleTests", "environment": { "benchmarkDotNetVersion": "BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.4 LTS (Noble Numbat)", @@ -9,43 +9,43 @@ "results": [ { "Method": "TUnit", - "Version": "1.34.5", - "Mean": "481.60 ms", - "Error": "5.231 ms", - "StdDev": "4.637 ms", - "Median": "480.64 ms" + "Version": "1.35.2", + "Mean": "486.45 ms", + "Error": "4.397 ms", + "StdDev": "3.898 ms", + "Median": "485.41 ms" }, { "Method": "NUnit", "Version": "4.5.1", - "Mean": "603.89 ms", - "Error": "6.951 ms", - "StdDev": "6.162 ms", - "Median": "601.77 ms" + "Mean": "607.80 ms", + "Error": "10.768 ms", + "StdDev": "9.546 ms", + "Median": "604.15 ms" }, { "Method": "MSTest", "Version": "4.2.1", - "Mean": "469.93 ms", - "Error": "8.893 ms", - "StdDev": "9.133 ms", - "Median": "466.39 ms" + "Mean": "471.49 ms", + "Error": "9.358 ms", + "StdDev": "8.753 ms", + "Median": "468.98 ms" }, { "Method": "xUnit3", "Version": "3.2.2", - "Mean": "608.23 ms", - "Error": "7.991 ms", - "StdDev": "7.084 ms", - "Median": "607.43 ms" + "Mean": "611.82 ms", + "Error": "8.210 ms", + "StdDev": "7.278 ms", + "Median": "611.78 ms" }, { "Method": "TUnit_AOT", - "Version": "1.34.5", - "Mean": "30.17 ms", - "Error": "0.597 ms", - "StdDev": "1.220 ms", - "Median": "30.00 ms" + "Version": "1.35.2", + "Mean": "30.52 ms", + "Error": "0.674 ms", + "StdDev": "1.966 ms", + "Median": "30.28 ms" } ] } \ No newline at end of file diff --git a/docs/static/benchmarks/SetupTeardownTests.json b/docs/static/benchmarks/SetupTeardownTests.json index f32f8969b9..c56985f864 100644 --- a/docs/static/benchmarks/SetupTeardownTests.json +++ b/docs/static/benchmarks/SetupTeardownTests.json @@ -1,5 +1,5 @@ { - "timestamp": "2026-04-16T00:46:53.078Z", + "timestamp": "2026-04-17T00:44:48.036Z", "category": "SetupTeardownTests", "environment": { "benchmarkDotNetVersion": "BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.4 LTS (Noble Numbat)", @@ -9,39 +9,39 @@ "results": [ { "Method": "TUnit", - "Version": "1.34.5", - "Mean": "566.7 ms", - "Error": "7.09 ms", - "StdDev": "6.63 ms", - "Median": "565.5 ms" + "Version": "1.35.2", + "Mean": "581.0 ms", + "Error": "7.29 ms", + "StdDev": "6.47 ms", + "Median": "580.7 ms" }, { "Method": "NUnit", "Version": "4.5.1", - "Mean": "1,205.9 ms", - "Error": "11.04 ms", - "StdDev": "9.78 ms", - "Median": "1,204.5 ms" + "Mean": "1,193.2 ms", + "Error": "9.71 ms", + "StdDev": "8.61 ms", + "Median": "1,191.9 ms" }, { "Method": "MSTest", "Version": "4.2.1", - "Mean": "1,117.9 ms", - "Error": "6.71 ms", - "StdDev": "5.94 ms", - "Median": "1,117.6 ms" + "Mean": "1,115.2 ms", + "Error": "12.16 ms", + "StdDev": "10.78 ms", + "Median": "1,114.3 ms" }, { "Method": "xUnit3", "Version": "3.2.2", - "Mean": "1,255.8 ms", - "Error": "6.84 ms", - "StdDev": "5.71 ms", - "Median": "1,256.7 ms" + "Mean": "1,257.3 ms", + "Error": "8.67 ms", + "StdDev": "7.69 ms", + "Median": "1,256.8 ms" }, { "Method": "TUnit_AOT", - "Version": "1.34.5", + "Version": "1.35.2", "Mean": "NA", "Error": "NA", "StdDev": "NA", diff --git a/docs/static/benchmarks/historical.json b/docs/static/benchmarks/historical.json index 7b83bdc40b..f300a34db1 100644 --- a/docs/static/benchmarks/historical.json +++ b/docs/static/benchmarks/historical.json @@ -1,8 +1,4 @@ [ - { - "date": "2026-01-19", - "environment": "Ubuntu" - }, { "date": "2026-01-20", "environment": "Ubuntu" @@ -358,5 +354,9 @@ { "date": "2026-04-16", "environment": "Ubuntu" + }, + { + "date": "2026-04-17", + "environment": "Ubuntu" } ] \ No newline at end of file diff --git a/docs/static/benchmarks/latest.json b/docs/static/benchmarks/latest.json index 8a110cf7f3..05b924a9bb 100644 --- a/docs/static/benchmarks/latest.json +++ b/docs/static/benchmarks/latest.json @@ -1,5 +1,5 @@ { - "timestamp": "2026-04-16T00:46:53.079Z", + "timestamp": "2026-04-17T00:44:48.037Z", "environment": { "benchmarkDotNetVersion": "BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.4 LTS (Noble Numbat)", "sdk": ".NET SDK 10.0.202", @@ -9,249 +9,249 @@ "AsyncTests": [ { "Method": "TUnit", - "Version": "1.34.5", - "Mean": "522.2 ms", - "Error": "3.64 ms", - "StdDev": "3.23 ms", - "Median": "522.0 ms" + "Version": "1.35.2", + "Mean": "525.0 ms", + "Error": "2.98 ms", + "StdDev": "2.79 ms", + "Median": "525.1 ms" }, { "Method": "NUnit", "Version": "4.5.1", - "Mean": "711.0 ms", - "Error": "10.98 ms", - "StdDev": "9.73 ms", - "Median": "709.9 ms" + "Mean": "693.8 ms", + "Error": "8.77 ms", + "StdDev": "7.32 ms", + "Median": "694.3 ms" }, { "Method": "MSTest", "Version": "4.2.1", - "Mean": "620.7 ms", - "Error": "4.28 ms", - "StdDev": "3.57 ms", - "Median": "620.7 ms" + "Mean": "601.6 ms", + "Error": "7.57 ms", + "StdDev": "7.08 ms", + "Median": "600.8 ms" }, { "Method": "xUnit3", "Version": "3.2.2", - "Mean": "761.8 ms", - "Error": "7.16 ms", - "StdDev": "5.98 ms", - "Median": "762.2 ms" + "Mean": "747.3 ms", + "Error": "4.82 ms", + "StdDev": "4.28 ms", + "Median": "747.6 ms" }, { "Method": "TUnit_AOT", - "Version": "1.34.5", - "Mean": "123.4 ms", - "Error": "0.23 ms", - "StdDev": "0.22 ms", - "Median": "123.5 ms" + "Version": "1.35.2", + "Mean": "121.5 ms", + "Error": "0.20 ms", + "StdDev": "0.18 ms", + "Median": "121.5 ms" } ], "DataDrivenTests": [ { "Method": "TUnit", - "Version": "1.34.5", - "Mean": "468.76 ms", - "Error": "2.620 ms", - "StdDev": "2.323 ms", - "Median": "469.06 ms" + "Version": "1.35.2", + "Mean": "466.15 ms", + "Error": "3.641 ms", + "StdDev": "3.227 ms", + "Median": "465.55 ms" }, { "Method": "NUnit", "Version": "4.5.1", - "Mean": "594.69 ms", - "Error": "9.804 ms", - "StdDev": "8.691 ms", - "Median": "593.46 ms" + "Mean": "544.72 ms", + "Error": "6.282 ms", + "StdDev": "5.569 ms", + "Median": "545.66 ms" }, { "Method": "MSTest", "Version": "4.2.1", - "Mean": "583.09 ms", - "Error": "6.475 ms", - "StdDev": "5.740 ms", - "Median": "582.56 ms" + "Mean": "449.73 ms", + "Error": "8.738 ms", + "StdDev": "8.973 ms", + "Median": "448.52 ms" }, { "Method": "xUnit3", "Version": "3.2.2", - "Mean": "628.26 ms", - "Error": "4.231 ms", - "StdDev": "3.533 ms", - "Median": "626.83 ms" + "Mean": "582.30 ms", + "Error": "7.361 ms", + "StdDev": "6.885 ms", + "Median": "580.80 ms" }, { "Method": "TUnit_AOT", - "Version": "1.34.5", - "Mean": "22.20 ms", - "Error": "0.442 ms", - "StdDev": "0.762 ms", - "Median": "21.76 ms" + "Version": "1.35.2", + "Mean": "24.26 ms", + "Error": "0.478 ms", + "StdDev": "0.686 ms", + "Median": "24.27 ms" } ], "MassiveParallelTests": [ { "Method": "TUnit", - "Version": "1.34.5", - "Mean": "650.9 ms", - "Error": "5.77 ms", - "StdDev": "5.11 ms", - "Median": "650.9 ms" + "Version": "1.35.2", + "Mean": "559.4 ms", + "Error": "2.35 ms", + "StdDev": "2.08 ms", + "Median": "559.6 ms" }, { "Method": "NUnit", "Version": "4.5.1", - "Mean": "1,217.9 ms", - "Error": "5.69 ms", - "StdDev": "5.04 ms", - "Median": "1,219.1 ms" + "Mean": "1,132.4 ms", + "Error": "10.79 ms", + "StdDev": "9.57 ms", + "Median": "1,131.0 ms" }, { "Method": "MSTest", "Version": "4.2.1", - "Mean": "2,934.9 ms", - "Error": "6.83 ms", - "StdDev": "6.39 ms", - "Median": "2,934.0 ms" + "Mean": "2,870.1 ms", + "Error": "7.63 ms", + "StdDev": "7.14 ms", + "Median": "2,870.6 ms" }, { "Method": "xUnit3", "Version": "3.2.2", - "Mean": "3,087.1 ms", - "Error": "11.20 ms", - "StdDev": "9.36 ms", - "Median": "3,087.1 ms" + "Mean": "2,987.5 ms", + "Error": "6.02 ms", + "StdDev": "5.63 ms", + "Median": "2,988.3 ms" }, { "Method": "TUnit_AOT", - "Version": "1.34.5", - "Mean": "225.0 ms", - "Error": "0.61 ms", - "StdDev": "0.57 ms", - "Median": "225.0 ms" + "Version": "1.35.2", + "Mean": "222.5 ms", + "Error": "0.40 ms", + "StdDev": "0.37 ms", + "Median": "222.5 ms" } ], "MatrixTests": [ { "Method": "TUnit", - "Version": "1.34.5", - "Mean": "590.5 ms", - "Error": "3.04 ms", - "StdDev": "2.70 ms", - "Median": "590.4 ms" + "Version": "1.35.2", + "Mean": "570.9 ms", + "Error": "2.93 ms", + "StdDev": "2.60 ms", + "Median": "571.0 ms" }, { "Method": "NUnit", "Version": "4.5.1", - "Mean": "1,616.7 ms", - "Error": "6.30 ms", - "StdDev": "5.89 ms", - "Median": "1,617.1 ms" + "Mean": "1,558.5 ms", + "Error": "6.95 ms", + "StdDev": "5.80 ms", + "Median": "1,558.3 ms" }, { "Method": "MSTest", "Version": "4.2.1", - "Mean": "1,509.9 ms", - "Error": "4.59 ms", - "StdDev": "4.30 ms", - "Median": "1,509.0 ms" + "Mean": "1,460.6 ms", + "Error": "7.62 ms", + "StdDev": "7.13 ms", + "Median": "1,459.6 ms" }, { "Method": "xUnit3", "Version": "3.2.2", - "Mean": "1,661.7 ms", - "Error": "7.42 ms", - "StdDev": "6.20 ms", - "Median": "1,661.2 ms" + "Mean": "1,596.7 ms", + "Error": "5.69 ms", + "StdDev": "5.04 ms", + "Median": "1,595.8 ms" }, { "Method": "TUnit_AOT", - "Version": "1.34.5", - "Mean": "126.0 ms", - "Error": "0.48 ms", - "StdDev": "0.45 ms", - "Median": "125.9 ms" + "Version": "1.35.2", + "Mean": "126.9 ms", + "Error": "0.52 ms", + "StdDev": "0.47 ms", + "Median": "126.9 ms" } ], "ScaleTests": [ { "Method": "TUnit", - "Version": "1.34.5", - "Mean": "481.60 ms", - "Error": "5.231 ms", - "StdDev": "4.637 ms", - "Median": "480.64 ms" + "Version": "1.35.2", + "Mean": "486.45 ms", + "Error": "4.397 ms", + "StdDev": "3.898 ms", + "Median": "485.41 ms" }, { "Method": "NUnit", "Version": "4.5.1", - "Mean": "603.89 ms", - "Error": "6.951 ms", - "StdDev": "6.162 ms", - "Median": "601.77 ms" + "Mean": "607.80 ms", + "Error": "10.768 ms", + "StdDev": "9.546 ms", + "Median": "604.15 ms" }, { "Method": "MSTest", "Version": "4.2.1", - "Mean": "469.93 ms", - "Error": "8.893 ms", - "StdDev": "9.133 ms", - "Median": "466.39 ms" + "Mean": "471.49 ms", + "Error": "9.358 ms", + "StdDev": "8.753 ms", + "Median": "468.98 ms" }, { "Method": "xUnit3", "Version": "3.2.2", - "Mean": "608.23 ms", - "Error": "7.991 ms", - "StdDev": "7.084 ms", - "Median": "607.43 ms" + "Mean": "611.82 ms", + "Error": "8.210 ms", + "StdDev": "7.278 ms", + "Median": "611.78 ms" }, { "Method": "TUnit_AOT", - "Version": "1.34.5", - "Mean": "30.17 ms", - "Error": "0.597 ms", - "StdDev": "1.220 ms", - "Median": "30.00 ms" + "Version": "1.35.2", + "Mean": "30.52 ms", + "Error": "0.674 ms", + "StdDev": "1.966 ms", + "Median": "30.28 ms" } ], "SetupTeardownTests": [ { "Method": "TUnit", - "Version": "1.34.5", - "Mean": "566.7 ms", - "Error": "7.09 ms", - "StdDev": "6.63 ms", - "Median": "565.5 ms" + "Version": "1.35.2", + "Mean": "581.0 ms", + "Error": "7.29 ms", + "StdDev": "6.47 ms", + "Median": "580.7 ms" }, { "Method": "NUnit", "Version": "4.5.1", - "Mean": "1,205.9 ms", - "Error": "11.04 ms", - "StdDev": "9.78 ms", - "Median": "1,204.5 ms" + "Mean": "1,193.2 ms", + "Error": "9.71 ms", + "StdDev": "8.61 ms", + "Median": "1,191.9 ms" }, { "Method": "MSTest", "Version": "4.2.1", - "Mean": "1,117.9 ms", - "Error": "6.71 ms", - "StdDev": "5.94 ms", - "Median": "1,117.6 ms" + "Mean": "1,115.2 ms", + "Error": "12.16 ms", + "StdDev": "10.78 ms", + "Median": "1,114.3 ms" }, { "Method": "xUnit3", "Version": "3.2.2", - "Mean": "1,255.8 ms", - "Error": "6.84 ms", - "StdDev": "5.71 ms", - "Median": "1,256.7 ms" + "Mean": "1,257.3 ms", + "Error": "8.67 ms", + "StdDev": "7.69 ms", + "Median": "1,256.8 ms" }, { "Method": "TUnit_AOT", - "Version": "1.34.5", + "Version": "1.35.2", "Mean": "NA", "Error": "NA", "StdDev": "NA", @@ -263,35 +263,35 @@ "BuildTime": [ { "Method": "Build_TUnit", - "Version": "1.34.5", - "Mean": "1.712 s", - "Error": "0.0328 s", - "StdDev": "0.0336 s", - "Median": "1.724 s" + "Version": "1.35.2", + "Mean": "1.922 s", + "Error": "0.0378 s", + "StdDev": "0.0621 s", + "Median": "1.901 s" }, { "Method": "Build_NUnit", "Version": "4.5.1", - "Mean": "1.522 s", - "Error": "0.0274 s", - "StdDev": "0.0256 s", - "Median": "1.525 s" + "Mean": "1.684 s", + "Error": "0.0204 s", + "StdDev": "0.0159 s", + "Median": "1.683 s" }, { "Method": "Build_MSTest", "Version": "4.2.1", - "Mean": "1.612 s", - "Error": "0.0198 s", - "StdDev": "0.0185 s", - "Median": "1.619 s" + "Mean": "1.801 s", + "Error": "0.0360 s", + "StdDev": "0.0649 s", + "Median": "1.816 s" }, { "Method": "Build_xUnit3", "Version": "3.2.2", - "Mean": "1.533 s", - "Error": "0.0202 s", - "StdDev": "0.0189 s", - "Median": "1.533 s" + "Mean": "1.667 s", + "Error": "0.0126 s", + "StdDev": "0.0118 s", + "Median": "1.665 s" } ] }, @@ -299,6 +299,6 @@ "runtimeCategories": 6, "buildCategories": 1, "totalBenchmarks": 7, - "lastUpdated": "2026-04-16T00:46:53.076Z" + "lastUpdated": "2026-04-17T00:44:48.034Z" } } \ No newline at end of file diff --git a/docs/static/benchmarks/summary.json b/docs/static/benchmarks/summary.json index e7575a8477..7604473b06 100644 --- a/docs/static/benchmarks/summary.json +++ b/docs/static/benchmarks/summary.json @@ -10,6 +10,6 @@ "build": [ "BuildTime" ], - "timestamp": "2026-04-16", + "timestamp": "2026-04-17", "environment": "Ubuntu Latest • .NET SDK 10.0.202" } \ No newline at end of file From e9ed809c34642780027d0c6d76c3984ff11ea936 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Fri, 17 Apr 2026 04:24:04 +0100 Subject: [PATCH 05/17] chore: update mock benchmark results (#5584) --- docs/docs/benchmarks/mocks/Callback.md | 36 +- .../docs/benchmarks/mocks/CombinedWorkflow.md | 20 +- docs/docs/benchmarks/mocks/Invocation.md | 52 +- docs/docs/benchmarks/mocks/MockCreation.md | 36 +- docs/docs/benchmarks/mocks/Setup.md | 36 +- docs/docs/benchmarks/mocks/Verification.md | 52 +- docs/docs/benchmarks/mocks/index.md | 4 +- docs/static/benchmarks/mocks/Callback.json | 94 +-- .../benchmarks/mocks/CombinedWorkflow.json | 44 +- docs/static/benchmarks/mocks/Invocation.json | 120 ++-- .../static/benchmarks/mocks/MockCreation.json | 76 +-- docs/static/benchmarks/mocks/Setup.json | 102 ++-- .../static/benchmarks/mocks/Verification.json | 140 ++--- docs/static/benchmarks/mocks/latest.json | 568 +++++++++--------- docs/static/benchmarks/mocks/summary.json | 2 +- 15 files changed, 679 insertions(+), 703 deletions(-) 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..4b24cddc2e 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 ::: @@ -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/static/benchmarks/mocks/Callback.json b/docs/static/benchmarks/mocks/Callback.json index 33a78dfe64..f080590bb5 100644 --- a/docs/static/benchmarks/mocks/Callback.json +++ b/docs/static/benchmarks/mocks/Callback.json @@ -1,5 +1,5 @@ { - "timestamp": "2026-04-16T03:23:00.282Z", + "timestamp": "2026-04-17T03:23:50.633Z", "category": "Callback", "description": "Callback registration and execution", "environment": { @@ -10,110 +10,110 @@ "results": [ { "Method": "TUnit.Mocks", - "Mean": "689.5 ns", - "Error": "6.56 ns", - "StdDev": "6.14 ns", + "Mean": "680.2 ns", + "Error": "8.94 ns", + "StdDev": "7.93 ns", "Gen0": "0.1917", "Gen1": "0.0019", "Allocated": "3.13 KB" }, { "Method": "Imposter", - "Mean": "459.8 ns", - "Error": "1.45 ns", - "StdDev": "1.29 ns", - "Gen0": "0.1626", - "Gen1": "0.0014", + "Mean": "487.3 ns", + "Error": "9.46 ns", + "StdDev": "11.62 ns", + "Gen0": "0.1621", + "Gen1": "0.0010", "Allocated": "2.66 KB" }, { "Method": "Mockolate", - "Mean": "525.1 ns", - "Error": "1.52 ns", - "StdDev": "1.27 ns", + "Mean": "523.0 ns", + "Error": "7.22 ns", + "StdDev": "6.75 ns", "Gen0": "0.1097", "Gen1": "-", "Allocated": "1.8 KB" }, { "Method": "Moq", - "Mean": "135,900.5 ns", - "Error": "637.16 ns", - "StdDev": "564.82 ns", - "Gen0": "0.7324", - "Gen1": "0.4883", - "Allocated": "13.29 KB" + "Mean": "186,330.0 ns", + "Error": "1,314.46 ns", + "StdDev": "1,229.54 ns", + "Gen0": "0.4883", + "Gen1": "-", + "Allocated": "13.14 KB" }, { "Method": "NSubstitute", - "Mean": "4,148.8 ns", - "Error": "12.77 ns", - "StdDev": "10.66 ns", - "Gen0": "0.4578", - "Gen1": "-", + "Mean": "4,551.5 ns", + "Error": "52.21 ns", + "StdDev": "48.84 ns", + "Gen0": "0.4807", + "Gen1": "0.0076", "Allocated": "7.93 KB" }, { "Method": "FakeItEasy", - "Mean": "4,558.1 ns", - "Error": "19.52 ns", - "StdDev": "17.30 ns", + "Mean": "5,411.1 ns", + "Error": "51.76 ns", + "StdDev": "45.89 ns", "Gen0": "0.4501", "Gen1": "0.0153", "Allocated": "7.44 KB" }, { "Method": "'TUnit.Mocks (with args)'", - "Mean": "782.6 ns", - "Error": "2.12 ns", - "StdDev": "1.98 ns", + "Mean": "905.4 ns", + "Error": "17.62 ns", + "StdDev": "16.49 ns", "Gen0": "0.1965", "Gen1": "0.0019", "Allocated": "3.22 KB" }, { "Method": "'Imposter (with args)'", - "Mean": "557.1 ns", - "Error": "1.88 ns", - "StdDev": "1.76 ns", + "Mean": "532.0 ns", + "Error": "10.66 ns", + "StdDev": "14.95 ns", "Gen0": "0.1726", "Gen1": "0.0010", "Allocated": "2.82 KB" }, { "Method": "'Mockolate (with args)'", - "Mean": "753.7 ns", - "Error": "1.33 ns", - "StdDev": "1.04 ns", + "Mean": "682.8 ns", + "Error": "13.51 ns", + "StdDev": "14.46 ns", "Gen0": "0.1297", "Gen1": "-", "Allocated": "2.13 KB" }, { "Method": "'Moq (with args)'", - "Mean": "141,620.2 ns", - "Error": "1,333.37 ns", - "StdDev": "1,182.00 ns", + "Mean": "191,584.7 ns", + "Error": "1,216.64 ns", + "StdDev": "1,078.52 ns", "Gen0": "0.4883", "Gen1": "-", "Allocated": "13.73 KB" }, { "Method": "'NSubstitute (with args)'", - "Mean": "4,593.7 ns", - "Error": "15.91 ns", - "StdDev": "14.11 ns", - "Gen0": "0.5188", - "Gen1": "0.0076", + "Mean": "5,140.2 ns", + "Error": "90.33 ns", + "StdDev": "80.07 ns", + "Gen0": "0.4883", + "Gen1": "-", "Allocated": "8.53 KB" }, { "Method": "'FakeItEasy (with args)'", - "Mean": "5,469.7 ns", - "Error": "27.34 ns", - "StdDev": "22.83 ns", + "Mean": "6,140.7 ns", + "Error": "100.33 ns", + "StdDev": "78.33 ns", "Gen0": "0.5493", - "Gen1": "0.0305", + "Gen1": "0.0610", "Allocated": "9.26 KB" } ] diff --git a/docs/static/benchmarks/mocks/CombinedWorkflow.json b/docs/static/benchmarks/mocks/CombinedWorkflow.json index 028da66ca7..48838f7aa0 100644 --- a/docs/static/benchmarks/mocks/CombinedWorkflow.json +++ b/docs/static/benchmarks/mocks/CombinedWorkflow.json @@ -1,5 +1,5 @@ { - "timestamp": "2026-04-16T03:23:00.282Z", + "timestamp": "2026-04-17T03:23:50.633Z", "category": "CombinedWorkflow", "description": "Full workflow: create → setup → invoke → verify", "environment": { @@ -10,54 +10,54 @@ "results": [ { "Method": "TUnit.Mocks", - "Mean": "1.992 μs", - "Error": "0.0301 μs", - "StdDev": "0.0281 μs", - "Gen0": "0.3853", - "Gen1": "0.0038", + "Mean": "1.836 μs", + "Error": "0.0248 μs", + "StdDev": "0.0232 μs", + "Gen0": "0.3872", + "Gen1": "0.0057", "Allocated": "6.34 KB" }, { "Method": "Imposter", - "Mean": "2.628 μs", - "Error": "0.0513 μs", - "StdDev": "0.0549 μs", + "Mean": "2.680 μs", + "Error": "0.0345 μs", + "StdDev": "0.0306 μs", "Gen0": "0.9613", "Gen1": "0.0458", "Allocated": "15.71 KB" }, { "Method": "Mockolate", - "Mean": "2.479 μs", - "Error": "0.0239 μs", - "StdDev": "0.0224 μs", + "Mean": "2.449 μs", + "Error": "0.0474 μs", + "StdDev": "0.0507 μs", "Gen0": "0.4311", "Gen1": "0.0038", "Allocated": "7.06 KB" }, { "Method": "Moq", - "Mean": "309.995 μs", - "Error": "2.2459 μs", - "StdDev": "2.1009 μs", + "Mean": "398.443 μs", + "Error": "3.5265 μs", + "StdDev": "3.2986 μs", "Gen0": "1.9531", "Gen1": "0.9766", - "Allocated": "36.16 KB" + "Allocated": "36.42 KB" }, { "Method": "NSubstitute", - "Mean": "16.228 μs", - "Error": "0.3080 μs", - "StdDev": "0.3296 μs", + "Mean": "17.009 μs", + "Error": "0.1050 μs", + "StdDev": "0.0982 μs", "Gen0": "1.6174", "Gen1": "0.0305", "Allocated": "26.72 KB" }, { "Method": "FakeItEasy", - "Mean": "15.431 μs", - "Error": "0.3011 μs", - "StdDev": "0.2958 μs", + "Mean": "18.375 μs", + "Error": "0.1999 μs", + "StdDev": "0.1670 μs", "Gen0": "1.4648", "Gen1": "0.1221", "Allocated": "25.52 KB" diff --git a/docs/static/benchmarks/mocks/Invocation.json b/docs/static/benchmarks/mocks/Invocation.json index 9b7fcad251..6ba692d746 100644 --- a/docs/static/benchmarks/mocks/Invocation.json +++ b/docs/static/benchmarks/mocks/Invocation.json @@ -1,5 +1,5 @@ { - "timestamp": "2026-04-16T03:23:00.282Z", + "timestamp": "2026-04-17T03:23:50.633Z", "category": "Invocation", "description": "Calling methods on mock objects", "environment": { @@ -10,162 +10,162 @@ "results": [ { "Method": "TUnit.Mocks", - "Mean": "262.9 ns", - "Error": "87.03 ns", - "StdDev": "4.77 ns", + "Mean": "263.5 ns", + "Error": "74.99 ns", + "StdDev": "4.11 ns", "Gen0": "0.0062", "Gen1": "0.0057", "Allocated": "120 B" }, { "Method": "Imposter", - "Mean": "302.5 ns", - "Error": "61.38 ns", - "StdDev": "3.36 ns", + "Mean": "303.6 ns", + "Error": "71.00 ns", + "StdDev": "3.89 ns", "Gen0": "0.0100", "Gen1": "0.0095", "Allocated": "168 B" }, { "Method": "Mockolate", - "Mean": "694.4 ns", - "Error": "180.17 ns", - "StdDev": "9.88 ns", + "Mean": "687.7 ns", + "Error": "80.12 ns", + "StdDev": "4.39 ns", "Gen0": "0.0381", "Gen1": "0.0191", "Allocated": "640 B" }, { "Method": "Moq", - "Mean": "851.5 ns", - "Error": "206.80 ns", - "StdDev": "11.34 ns", + "Mean": "801.1 ns", + "Error": "191.96 ns", + "StdDev": "10.52 ns", "Gen0": "0.0219", "Gen1": "0.0210", "Allocated": "376 B" }, { "Method": "NSubstitute", - "Mean": "734.4 ns", - "Error": "148.63 ns", - "StdDev": "8.15 ns", + "Mean": "743.6 ns", + "Error": "122.94 ns", + "StdDev": "6.74 ns", "Gen0": "0.0172", "Gen1": "0.0162", "Allocated": "304 B" }, { "Method": "FakeItEasy", - "Mean": "1,812.9 ns", - "Error": "408.72 ns", - "StdDev": "22.40 ns", - "Gen0": "0.0534", - "Gen1": "0.0496", + "Mean": "1,772.1 ns", + "Error": "397.78 ns", + "StdDev": "21.80 ns", + "Gen0": "0.0553", + "Gen1": "0.0534", "Allocated": "944 B" }, { "Method": "'TUnit.Mocks (String)'", - "Mean": "157.3 ns", - "Error": "81.04 ns", - "StdDev": "4.44 ns", + "Mean": "166.1 ns", + "Error": "71.02 ns", + "StdDev": "3.89 ns", "Gen0": "0.0048", "Gen1": "0.0045", "Allocated": "88 B" }, { "Method": "'Imposter (String)'", - "Mean": "300.7 ns", - "Error": "42.70 ns", - "StdDev": "2.34 ns", + "Mean": "306.7 ns", + "Error": "121.47 ns", + "StdDev": "6.66 ns", "Gen0": "0.0100", "Gen1": "0.0095", "Allocated": "168 B" }, { "Method": "'Mockolate (String)'", - "Mean": "543.7 ns", - "Error": "188.98 ns", - "StdDev": "10.36 ns", + "Mean": "543.0 ns", + "Error": "90.95 ns", + "StdDev": "4.99 ns", "Gen0": "0.0305", "Gen1": "0.0153", "Allocated": "520 B" }, { "Method": "'Moq (String)'", - "Mean": "562.1 ns", - "Error": "89.18 ns", - "StdDev": "4.89 ns", + "Mean": "536.6 ns", + "Error": "100.52 ns", + "StdDev": "5.51 ns", "Gen0": "0.0172", "Gen1": "0.0162", "Allocated": "296 B" }, { "Method": "'NSubstitute (String)'", - "Mean": "628.7 ns", - "Error": "78.20 ns", - "StdDev": "4.29 ns", + "Mean": "653.8 ns", + "Error": "679.02 ns", + "StdDev": "37.22 ns", "Gen0": "0.0153", "Gen1": "0.0143", "Allocated": "272 B" }, { "Method": "'FakeItEasy (String)'", - "Mean": "1,645.3 ns", - "Error": "253.86 ns", - "StdDev": "13.92 ns", + "Mean": "1,619.8 ns", + "Error": "547.84 ns", + "StdDev": "30.03 ns", "Gen0": "0.0458", "Gen1": "0.0439", "Allocated": "776 B" }, { "Method": "'TUnit.Mocks (100 calls)'", - "Mean": "26,674.2 ns", - "Error": "15,350.85 ns", - "StdDev": "841.43 ns", + "Mean": "26,494.2 ns", + "Error": "13,807.64 ns", + "StdDev": "756.84 ns", "Gen0": "0.6104", "Gen1": "0.5798", "Allocated": "11936 B" }, { "Method": "'Imposter (100 calls)'", - "Mean": "29,524.3 ns", - "Error": "10,411.38 ns", - "StdDev": "570.68 ns", + "Mean": "30,093.3 ns", + "Error": "8,247.06 ns", + "StdDev": "452.05 ns", "Gen0": "0.9766", "Gen1": "0.9155", "Allocated": "16800 B" }, { "Method": "'Mockolate (100 calls)'", - "Mean": "66,514.3 ns", - "Error": "29,938.23 ns", - "StdDev": "1,641.02 ns", + "Mean": "70,940.2 ns", + "Error": "18,268.87 ns", + "StdDev": "1,001.38 ns", "Gen0": "3.7842", "Gen1": "1.8311", "Allocated": "64000 B" }, { "Method": "'Moq (100 calls)'", - "Mean": "84,478.0 ns", - "Error": "24,794.97 ns", - "StdDev": "1,359.10 ns", + "Mean": "82,141.8 ns", + "Error": "5,729.62 ns", + "StdDev": "314.06 ns", "Gen0": "2.1973", "Gen1": "2.0752", "Allocated": "37600 B" }, { "Method": "'NSubstitute (100 calls)'", - "Mean": "78,702.1 ns", - "Error": "11,264.77 ns", - "StdDev": "617.46 ns", - "Gen0": "1.9531", - "Gen1": "1.8311", - "Allocated": "36448 B" + "Mean": "73,823.5 ns", + "Error": "5,831.24 ns", + "StdDev": "319.63 ns", + "Gen0": "1.7090", + "Gen1": "1.5869", + "Allocated": "30848 B" }, { "Method": "'FakeItEasy (100 calls)'", - "Mean": "190,003.8 ns", - "Error": "33,840.70 ns", - "StdDev": "1,854.92 ns", + "Mean": "186,702.0 ns", + "Error": "247,385.84 ns", + "StdDev": "13,560.05 ns", "Gen0": "5.6152", "Gen1": "5.3711", "Allocated": "94400 B" diff --git a/docs/static/benchmarks/mocks/MockCreation.json b/docs/static/benchmarks/mocks/MockCreation.json index d76e75071b..8407cc09b8 100644 --- a/docs/static/benchmarks/mocks/MockCreation.json +++ b/docs/static/benchmarks/mocks/MockCreation.json @@ -1,5 +1,5 @@ { - "timestamp": "2026-04-16T03:23:00.282Z", + "timestamp": "2026-04-17T03:23:50.633Z", "category": "MockCreation", "description": "Mock instance creation performance", "environment": { @@ -10,9 +10,9 @@ "results": [ { "Method": "TUnit.Mocks", - "Mean": "33.06 ns", - "Error": "0.148 ns", - "StdDev": "0.124 ns", + "Mean": "28.82 ns", + "Error": "0.467 ns", + "StdDev": "0.437 ns", "Gen0": "0.0114", "Gen1": "-", "Gen2": "-", @@ -20,9 +20,9 @@ }, { "Method": "Imposter", - "Mean": "87.58 ns", - "Error": "0.274 ns", - "StdDev": "0.229 ns", + "Mean": "98.31 ns", + "Error": "1.933 ns", + "StdDev": "2.149 ns", "Gen0": "0.0262", "Gen1": "-", "Gen2": "-", @@ -30,9 +30,9 @@ }, { "Method": "Mockolate", - "Mean": "72.01 ns", - "Error": "0.253 ns", - "StdDev": "0.224 ns", + "Mean": "72.83 ns", + "Error": "1.417 ns", + "StdDev": "1.326 ns", "Gen0": "0.0229", "Gen1": "-", "Gen2": "-", @@ -40,9 +40,9 @@ }, { "Method": "Moq", - "Mean": "1,304.59 ns", - "Error": "18.161 ns", - "StdDev": "16.988 ns", + "Mean": "1,318.28 ns", + "Error": "15.107 ns", + "StdDev": "14.131 ns", "Gen0": "0.1221", "Gen1": "-", "Gen2": "-", @@ -50,19 +50,19 @@ }, { "Method": "NSubstitute", - "Mean": "1,755.00 ns", - "Error": "6.823 ns", - "StdDev": "6.383 ns", + "Mean": "1,942.07 ns", + "Error": "17.795 ns", + "StdDev": "15.775 ns", "Gen0": "0.2975", - "Gen1": "0.0019", + "Gen1": "-", "Gen2": "-", "Allocated": "5000 B" }, { "Method": "FakeItEasy", - "Mean": "1,686.18 ns", - "Error": "5.945 ns", - "StdDev": "5.270 ns", + "Mean": "1,743.46 ns", + "Error": "34.281 ns", + "StdDev": "52.350 ns", "Gen0": "0.1602", "Gen1": "0.0038", "Gen2": "0.0019", @@ -70,9 +70,9 @@ }, { "Method": "'TUnit.Mocks (Repository)'", - "Mean": "33.44 ns", - "Error": "0.170 ns", - "StdDev": "0.151 ns", + "Mean": "28.89 ns", + "Error": "0.634 ns", + "StdDev": "0.825 ns", "Gen0": "0.0114", "Gen1": "-", "Gen2": "-", @@ -80,9 +80,9 @@ }, { "Method": "'Imposter (Repository)'", - "Mean": "139.23 ns", - "Error": "0.719 ns", - "StdDev": "0.672 ns", + "Mean": "156.01 ns", + "Error": "2.179 ns", + "StdDev": "2.038 ns", "Gen0": "0.0415", "Gen1": "-", "Gen2": "-", @@ -90,9 +90,9 @@ }, { "Method": "'Mockolate (Repository)'", - "Mean": "63.28 ns", - "Error": "0.304 ns", - "StdDev": "0.284 ns", + "Mean": "72.26 ns", + "Error": "1.494 ns", + "StdDev": "2.536 ns", "Gen0": "0.0229", "Gen1": "-", "Gen2": "-", @@ -100,9 +100,9 @@ }, { "Method": "'Moq (Repository)'", - "Mean": "1,357.45 ns", - "Error": "4.724 ns", - "StdDev": "3.945 ns", + "Mean": "1,284.46 ns", + "Error": "16.637 ns", + "StdDev": "15.562 ns", "Gen0": "0.1125", "Gen1": "-", "Gen2": "-", @@ -110,9 +110,9 @@ }, { "Method": "'NSubstitute (Repository)'", - "Mean": "1,725.98 ns", - "Error": "14.482 ns", - "StdDev": "13.546 ns", + "Mean": "1,919.01 ns", + "Error": "36.617 ns", + "StdDev": "35.962 ns", "Gen0": "0.2975", "Gen1": "0.0019", "Gen2": "-", @@ -120,9 +120,9 @@ }, { "Method": "'FakeItEasy (Repository)'", - "Mean": "1,611.20 ns", - "Error": "12.730 ns", - "StdDev": "10.630 ns", + "Mean": "1,661.42 ns", + "Error": "15.968 ns", + "StdDev": "13.334 ns", "Gen0": "0.1602", "Gen1": "0.0038", "Gen2": "0.0019", diff --git a/docs/static/benchmarks/mocks/Setup.json b/docs/static/benchmarks/mocks/Setup.json index 2b6b0a021a..fde4955106 100644 --- a/docs/static/benchmarks/mocks/Setup.json +++ b/docs/static/benchmarks/mocks/Setup.json @@ -1,5 +1,5 @@ { - "timestamp": "2026-04-16T03:23:00.282Z", + "timestamp": "2026-04-17T03:23:50.633Z", "category": "Setup", "description": "Mock behavior configuration (returns, matchers)", "environment": { @@ -10,123 +10,111 @@ "results": [ { "Method": "TUnit.Mocks", - "Mean": "557.4 ns", - "Error": "8.70 ns", - "StdDev": "8.14 ns", - "Median": "560.5 ns", + "Mean": "555.9 ns", + "Error": "5.00 ns", + "StdDev": "4.68 ns", "Gen0": "0.1421", "Gen1": "0.0010", "Allocated": "2.34 KB" }, { "Method": "Imposter", - "Mean": "763.7 ns", - "Error": "15.16 ns", - "StdDev": "28.48 ns", - "Median": "748.9 ns", + "Mean": "865.2 ns", + "Error": "8.72 ns", + "StdDev": "8.15 ns", "Gen0": "0.3738", "Gen1": "0.0076", "Allocated": "6.12 KB" }, { "Method": "Mockolate", - "Mean": "437.8 ns", - "Error": "2.22 ns", - "StdDev": "1.85 ns", - "Median": "438.2 ns", + "Mean": "483.5 ns", + "Error": "6.29 ns", + "StdDev": "5.58 ns", "Gen0": "0.1240", "Gen1": "0.0010", "Allocated": "2.03 KB" }, { "Method": "Moq", - "Mean": "418,995.5 ns", - "Error": "2,484.70 ns", - "StdDev": "2,324.19 ns", - "Median": "419,266.4 ns", + "Mean": "429,288.1 ns", + "Error": "1,670.44 ns", + "StdDev": "1,480.80 ns", "Gen0": "0.9766", "Gen1": "-", - "Allocated": "28.52 KB" + "Allocated": "28.71 KB" }, { "Method": "NSubstitute", - "Mean": "5,422.0 ns", - "Error": "42.62 ns", - "StdDev": "39.86 ns", - "Median": "5,411.3 ns", + "Mean": "5,857.1 ns", + "Error": "89.45 ns", + "StdDev": "79.29 ns", "Gen0": "0.5493", - "Gen1": "0.0076", + "Gen1": "-", "Allocated": "9.01 KB" }, { "Method": "FakeItEasy", - "Mean": "8,015.8 ns", - "Error": "72.55 ns", - "StdDev": "67.86 ns", - "Median": "8,005.7 ns", + "Mean": "8,801.0 ns", + "Error": "82.60 ns", + "StdDev": "77.27 ns", "Gen0": "0.6256", "Gen1": "0.0153", "Allocated": "10.45 KB" }, { "Method": "'TUnit.Mocks (Multiple)'", - "Mean": "743.7 ns", - "Error": "6.78 ns", - "StdDev": "6.34 ns", - "Median": "743.7 ns", + "Mean": "756.4 ns", + "Error": "7.27 ns", + "StdDev": "6.80 ns", "Gen0": "0.1793", "Gen1": "0.0010", "Allocated": "2.93 KB" }, { "Method": "'Imposter (Multiple)'", - "Mean": "1,384.7 ns", - "Error": "10.61 ns", - "StdDev": "9.93 ns", - "Median": "1,384.0 ns", + "Mean": "1,480.8 ns", + "Error": "19.28 ns", + "StdDev": "18.03 ns", "Gen0": "0.6485", "Gen1": "0.0248", "Allocated": "10.59 KB" }, { "Method": "'Mockolate (Multiple)'", - "Mean": "707.3 ns", - "Error": "14.15 ns", - "StdDev": "14.53 ns", - "Median": "705.5 ns", + "Mean": "743.4 ns", + "Error": "8.35 ns", + "StdDev": "7.81 ns", "Gen0": "0.1879", "Gen1": "0.0019", "Allocated": "3.07 KB" }, { "Method": "'Moq (Multiple)'", - "Mean": "111,746.9 ns", - "Error": "736.94 ns", - "StdDev": "653.28 ns", - "Median": "111,723.2 ns", + "Mean": "116,804.7 ns", + "Error": "677.37 ns", + "StdDev": "633.61 ns", "Gen0": "0.9766", "Gen1": "0.7324", "Allocated": "16.53 KB" }, { "Method": "'NSubstitute (Multiple)'", - "Mean": "11,563.4 ns", - "Error": "58.98 ns", - "StdDev": "52.29 ns", - "Median": "11,570.9 ns", - "Gen0": "1.2360", - "Gen1": "0.0305", - "Allocated": "20.31 KB" + "Mean": "12,220.0 ns", + "Error": "82.31 ns", + "StdDev": "64.26 ns", + "Gen0": "1.2207", + "Gen1": "-", + "Allocated": "20.34 KB" }, { "Method": "'FakeItEasy (Multiple)'", - "Mean": "7,942.7 ns", - "Error": "122.18 ns", - "StdDev": "114.29 ns", - "Median": "7,904.2 ns", - "Gen0": "0.7172", - "Gen1": "0.0153", - "Allocated": "11.82 KB" + "Mean": "7,978.2 ns", + "Error": "113.55 ns", + "StdDev": "94.82 ns", + "Gen0": "0.6714", + "Gen1": "0.0610", + "Allocated": "11.71 KB" } ] } \ No newline at end of file diff --git a/docs/static/benchmarks/mocks/Verification.json b/docs/static/benchmarks/mocks/Verification.json index 5b2dd57bb2..dbb008e697 100644 --- a/docs/static/benchmarks/mocks/Verification.json +++ b/docs/static/benchmarks/mocks/Verification.json @@ -1,5 +1,5 @@ { - "timestamp": "2026-04-16T03:23:00.282Z", + "timestamp": "2026-04-17T03:23:50.633Z", "category": "Verification", "description": "Verifying mock method calls", "environment": { @@ -10,165 +10,165 @@ "results": [ { "Method": "TUnit.Mocks", - "Mean": "709.52 ns", - "Error": "1.363 ns", - "StdDev": "1.138 ns", + "Mean": "779.75 ns", + "Error": "14.522 ns", + "StdDev": "13.584 ns", "Gen0": "0.1841", "Gen1": "0.0010", "Allocated": "3080 B" }, { "Method": "Imposter", - "Mean": "654.41 ns", - "Error": "4.892 ns", - "StdDev": "4.576 ns", + "Mean": "697.99 ns", + "Error": "12.340 ns", + "StdDev": "14.211 ns", "Gen0": "0.2794", "Gen1": "0.0038", "Allocated": "4688 B" }, { "Method": "Mockolate", - "Mean": "922.90 ns", - "Error": "1.553 ns", - "StdDev": "1.377 ns", + "Mean": "923.67 ns", + "Error": "13.586 ns", + "StdDev": "12.708 ns", "Gen0": "0.1879", "Gen1": "0.0010", "Allocated": "3152 B" }, { "Method": "Moq", - "Mean": "335,454.92 ns", - "Error": "2,661.938 ns", - "StdDev": "2,489.978 ns", - "Gen0": "0.9766", - "Gen1": "-", - "Allocated": "24325 B" + "Mean": "246,002.68 ns", + "Error": "2,007.103 ns", + "StdDev": "1,877.445 ns", + "Gen0": "1.4648", + "Gen1": "0.9766", + "Allocated": "24675 B" }, { "Method": "NSubstitute", - "Mean": "6,129.28 ns", - "Error": "29.884 ns", - "StdDev": "27.954 ns", - "Gen0": "0.5951", - "Gen1": "0.0076", + "Mean": "6,025.40 ns", + "Error": "116.063 ns", + "StdDev": "124.186 ns", + "Gen0": "0.5798", + "Gen1": "-", "Allocated": "10064 B" }, { "Method": "FakeItEasy", - "Mean": "7,205.45 ns", - "Error": "21.911 ns", - "StdDev": "19.424 ns", + "Mean": "6,484.46 ns", + "Error": "96.148 ns", + "StdDev": "80.288 ns", "Gen0": "0.6409", - "Gen1": "0.0153", + "Gen1": "0.0305", "Allocated": "10722 B" }, { "Method": "'TUnit.Mocks (Never)'", - "Mean": "59.95 ns", - "Error": "0.451 ns", - "StdDev": "0.422 ns", + "Mean": "60.57 ns", + "Error": "1.083 ns", + "StdDev": "1.013 ns", "Gen0": "0.0196", "Gen1": "-", "Allocated": "328 B" }, { "Method": "'Imposter (Never)'", - "Mean": "314.43 ns", - "Error": "1.048 ns", - "StdDev": "0.929 ns", + "Mean": "339.67 ns", + "Error": "4.611 ns", + "StdDev": "4.087 ns", "Gen0": "0.1431", "Gen1": "0.0010", "Allocated": "2400 B" }, { "Method": "'Mockolate (Never)'", - "Mean": "211.67 ns", - "Error": "0.971 ns", - "StdDev": "0.908 ns", + "Mean": "244.38 ns", + "Error": "3.407 ns", + "StdDev": "2.845 ns", "Gen0": "0.0567", "Gen1": "-", "Allocated": "952 B" }, { "Method": "'Moq (Never)'", - "Mean": "85,724.92 ns", - "Error": "135.791 ns", - "StdDev": "120.375 ns", - "Gen0": "0.2441", - "Gen1": "-", - "Allocated": "6918 B" + "Mean": "63,280.47 ns", + "Error": "469.889 ns", + "StdDev": "439.534 ns", + "Gen0": "0.3662", + "Gen1": "0.2441", + "Allocated": "7037 B" }, { "Method": "'NSubstitute (Never)'", - "Mean": "3,567.38 ns", - "Error": "12.592 ns", - "StdDev": "11.778 ns", + "Mean": "3,612.39 ns", + "Error": "70.100 ns", + "StdDev": "95.954 ns", "Gen0": "0.4234", "Gen1": "0.0038", "Allocated": "7088 B" }, { "Method": "'FakeItEasy (Never)'", - "Mean": "3,545.33 ns", - "Error": "15.567 ns", - "StdDev": "12.999 ns", - "Gen0": "0.3090", - "Gen1": "0.0076", + "Mean": "3,636.87 ns", + "Error": "22.107 ns", + "StdDev": "18.461 ns", + "Gen0": "0.3052", + "Gen1": "0.0153", "Allocated": "5210 B" }, { "Method": "'TUnit.Mocks (Multiple)'", - "Mean": "1,286.07 ns", - "Error": "13.249 ns", - "StdDev": "12.394 ns", + "Mean": "1,391.12 ns", + "Error": "9.208 ns", + "StdDev": "8.163 ns", "Gen0": "0.2747", "Gen1": "0.0038", "Allocated": "4608 B" }, { "Method": "'Imposter (Multiple)'", - "Mean": "1,635.13 ns", - "Error": "5.646 ns", - "StdDev": "5.282 ns", + "Mean": "1,934.79 ns", + "Error": "36.515 ns", + "StdDev": "34.157 ns", "Gen0": "0.6676", "Gen1": "0.0210", "Allocated": "11192 B" }, { "Method": "'Mockolate (Multiple)'", - "Mean": "1,783.27 ns", - "Error": "7.720 ns", - "StdDev": "6.843 ns", + "Mean": "1,969.13 ns", + "Error": "28.229 ns", + "StdDev": "25.024 ns", "Gen0": "0.3281", - "Gen1": "0.0019", + "Gen1": "-", "Allocated": "5496 B" }, { "Method": "'Moq (Multiple)'", - "Mean": "458,716.34 ns", - "Error": "1,253.308 ns", - "StdDev": "1,046.569 ns", + "Mean": "354,693.14 ns", + "Error": "3,496.978 ns", + "StdDev": "3,271.075 ns", "Gen0": "1.9531", "Gen1": "0.9766", "Allocated": "34699 B" }, { "Method": "'NSubstitute (Multiple)'", - "Mean": "11,132.14 ns", - "Error": "69.879 ns", - "StdDev": "65.365 ns", + "Mean": "11,172.45 ns", + "Error": "70.593 ns", + "StdDev": "66.032 ns", "Gen0": "0.9766", "Gen1": "-", "Allocated": "16763 B" }, { "Method": "'FakeItEasy (Multiple)'", - "Mean": "12,981.76 ns", - "Error": "101.066 ns", - "StdDev": "89.593 ns", - "Gen0": "1.0986", - "Gen1": "-", - "Allocated": "19233 B" + "Mean": "13,089.09 ns", + "Error": "99.377 ns", + "StdDev": "88.095 ns", + "Gen0": "1.1597", + "Gen1": "0.0610", + "Allocated": "19568 B" } ] } \ No newline at end of file diff --git a/docs/static/benchmarks/mocks/latest.json b/docs/static/benchmarks/mocks/latest.json index ca6be48777..3407353661 100644 --- a/docs/static/benchmarks/mocks/latest.json +++ b/docs/static/benchmarks/mocks/latest.json @@ -1,5 +1,5 @@ { - "timestamp": "2026-04-16T03:23:00.282Z", + "timestamp": "2026-04-17T03:23:50.633Z", "environment": { "benchmarkDotNetVersion": "BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.4 LTS (Noble Numbat)", "sdk": ".NET SDK 10.0.202", @@ -9,164 +9,164 @@ "Callback": [ { "Method": "TUnit.Mocks", - "Mean": "689.5 ns", - "Error": "6.56 ns", - "StdDev": "6.14 ns", + "Mean": "680.2 ns", + "Error": "8.94 ns", + "StdDev": "7.93 ns", "Gen0": "0.1917", "Gen1": "0.0019", "Allocated": "3.13 KB" }, { "Method": "Imposter", - "Mean": "459.8 ns", - "Error": "1.45 ns", - "StdDev": "1.29 ns", - "Gen0": "0.1626", - "Gen1": "0.0014", + "Mean": "487.3 ns", + "Error": "9.46 ns", + "StdDev": "11.62 ns", + "Gen0": "0.1621", + "Gen1": "0.0010", "Allocated": "2.66 KB" }, { "Method": "Mockolate", - "Mean": "525.1 ns", - "Error": "1.52 ns", - "StdDev": "1.27 ns", + "Mean": "523.0 ns", + "Error": "7.22 ns", + "StdDev": "6.75 ns", "Gen0": "0.1097", "Gen1": "-", "Allocated": "1.8 KB" }, { "Method": "Moq", - "Mean": "135,900.5 ns", - "Error": "637.16 ns", - "StdDev": "564.82 ns", - "Gen0": "0.7324", - "Gen1": "0.4883", - "Allocated": "13.29 KB" + "Mean": "186,330.0 ns", + "Error": "1,314.46 ns", + "StdDev": "1,229.54 ns", + "Gen0": "0.4883", + "Gen1": "-", + "Allocated": "13.14 KB" }, { "Method": "NSubstitute", - "Mean": "4,148.8 ns", - "Error": "12.77 ns", - "StdDev": "10.66 ns", - "Gen0": "0.4578", - "Gen1": "-", + "Mean": "4,551.5 ns", + "Error": "52.21 ns", + "StdDev": "48.84 ns", + "Gen0": "0.4807", + "Gen1": "0.0076", "Allocated": "7.93 KB" }, { "Method": "FakeItEasy", - "Mean": "4,558.1 ns", - "Error": "19.52 ns", - "StdDev": "17.30 ns", + "Mean": "5,411.1 ns", + "Error": "51.76 ns", + "StdDev": "45.89 ns", "Gen0": "0.4501", "Gen1": "0.0153", "Allocated": "7.44 KB" }, { "Method": "'TUnit.Mocks (with args)'", - "Mean": "782.6 ns", - "Error": "2.12 ns", - "StdDev": "1.98 ns", + "Mean": "905.4 ns", + "Error": "17.62 ns", + "StdDev": "16.49 ns", "Gen0": "0.1965", "Gen1": "0.0019", "Allocated": "3.22 KB" }, { "Method": "'Imposter (with args)'", - "Mean": "557.1 ns", - "Error": "1.88 ns", - "StdDev": "1.76 ns", + "Mean": "532.0 ns", + "Error": "10.66 ns", + "StdDev": "14.95 ns", "Gen0": "0.1726", "Gen1": "0.0010", "Allocated": "2.82 KB" }, { "Method": "'Mockolate (with args)'", - "Mean": "753.7 ns", - "Error": "1.33 ns", - "StdDev": "1.04 ns", + "Mean": "682.8 ns", + "Error": "13.51 ns", + "StdDev": "14.46 ns", "Gen0": "0.1297", "Gen1": "-", "Allocated": "2.13 KB" }, { "Method": "'Moq (with args)'", - "Mean": "141,620.2 ns", - "Error": "1,333.37 ns", - "StdDev": "1,182.00 ns", + "Mean": "191,584.7 ns", + "Error": "1,216.64 ns", + "StdDev": "1,078.52 ns", "Gen0": "0.4883", "Gen1": "-", "Allocated": "13.73 KB" }, { "Method": "'NSubstitute (with args)'", - "Mean": "4,593.7 ns", - "Error": "15.91 ns", - "StdDev": "14.11 ns", - "Gen0": "0.5188", - "Gen1": "0.0076", + "Mean": "5,140.2 ns", + "Error": "90.33 ns", + "StdDev": "80.07 ns", + "Gen0": "0.4883", + "Gen1": "-", "Allocated": "8.53 KB" }, { "Method": "'FakeItEasy (with args)'", - "Mean": "5,469.7 ns", - "Error": "27.34 ns", - "StdDev": "22.83 ns", + "Mean": "6,140.7 ns", + "Error": "100.33 ns", + "StdDev": "78.33 ns", "Gen0": "0.5493", - "Gen1": "0.0305", + "Gen1": "0.0610", "Allocated": "9.26 KB" } ], "CombinedWorkflow": [ { "Method": "TUnit.Mocks", - "Mean": "1.992 μs", - "Error": "0.0301 μs", - "StdDev": "0.0281 μs", - "Gen0": "0.3853", - "Gen1": "0.0038", + "Mean": "1.836 μs", + "Error": "0.0248 μs", + "StdDev": "0.0232 μs", + "Gen0": "0.3872", + "Gen1": "0.0057", "Allocated": "6.34 KB" }, { "Method": "Imposter", - "Mean": "2.628 μs", - "Error": "0.0513 μs", - "StdDev": "0.0549 μs", + "Mean": "2.680 μs", + "Error": "0.0345 μs", + "StdDev": "0.0306 μs", "Gen0": "0.9613", "Gen1": "0.0458", "Allocated": "15.71 KB" }, { "Method": "Mockolate", - "Mean": "2.479 μs", - "Error": "0.0239 μs", - "StdDev": "0.0224 μs", + "Mean": "2.449 μs", + "Error": "0.0474 μs", + "StdDev": "0.0507 μs", "Gen0": "0.4311", "Gen1": "0.0038", "Allocated": "7.06 KB" }, { "Method": "Moq", - "Mean": "309.995 μs", - "Error": "2.2459 μs", - "StdDev": "2.1009 μs", + "Mean": "398.443 μs", + "Error": "3.5265 μs", + "StdDev": "3.2986 μs", "Gen0": "1.9531", "Gen1": "0.9766", - "Allocated": "36.16 KB" + "Allocated": "36.42 KB" }, { "Method": "NSubstitute", - "Mean": "16.228 μs", - "Error": "0.3080 μs", - "StdDev": "0.3296 μs", + "Mean": "17.009 μs", + "Error": "0.1050 μs", + "StdDev": "0.0982 μs", "Gen0": "1.6174", "Gen1": "0.0305", "Allocated": "26.72 KB" }, { "Method": "FakeItEasy", - "Mean": "15.431 μs", - "Error": "0.3011 μs", - "StdDev": "0.2958 μs", + "Mean": "18.375 μs", + "Error": "0.1999 μs", + "StdDev": "0.1670 μs", "Gen0": "1.4648", "Gen1": "0.1221", "Allocated": "25.52 KB" @@ -175,162 +175,162 @@ "Invocation": [ { "Method": "TUnit.Mocks", - "Mean": "262.9 ns", - "Error": "87.03 ns", - "StdDev": "4.77 ns", + "Mean": "263.5 ns", + "Error": "74.99 ns", + "StdDev": "4.11 ns", "Gen0": "0.0062", "Gen1": "0.0057", "Allocated": "120 B" }, { "Method": "Imposter", - "Mean": "302.5 ns", - "Error": "61.38 ns", - "StdDev": "3.36 ns", + "Mean": "303.6 ns", + "Error": "71.00 ns", + "StdDev": "3.89 ns", "Gen0": "0.0100", "Gen1": "0.0095", "Allocated": "168 B" }, { "Method": "Mockolate", - "Mean": "694.4 ns", - "Error": "180.17 ns", - "StdDev": "9.88 ns", + "Mean": "687.7 ns", + "Error": "80.12 ns", + "StdDev": "4.39 ns", "Gen0": "0.0381", "Gen1": "0.0191", "Allocated": "640 B" }, { "Method": "Moq", - "Mean": "851.5 ns", - "Error": "206.80 ns", - "StdDev": "11.34 ns", + "Mean": "801.1 ns", + "Error": "191.96 ns", + "StdDev": "10.52 ns", "Gen0": "0.0219", "Gen1": "0.0210", "Allocated": "376 B" }, { "Method": "NSubstitute", - "Mean": "734.4 ns", - "Error": "148.63 ns", - "StdDev": "8.15 ns", + "Mean": "743.6 ns", + "Error": "122.94 ns", + "StdDev": "6.74 ns", "Gen0": "0.0172", "Gen1": "0.0162", "Allocated": "304 B" }, { "Method": "FakeItEasy", - "Mean": "1,812.9 ns", - "Error": "408.72 ns", - "StdDev": "22.40 ns", - "Gen0": "0.0534", - "Gen1": "0.0496", + "Mean": "1,772.1 ns", + "Error": "397.78 ns", + "StdDev": "21.80 ns", + "Gen0": "0.0553", + "Gen1": "0.0534", "Allocated": "944 B" }, { "Method": "'TUnit.Mocks (String)'", - "Mean": "157.3 ns", - "Error": "81.04 ns", - "StdDev": "4.44 ns", + "Mean": "166.1 ns", + "Error": "71.02 ns", + "StdDev": "3.89 ns", "Gen0": "0.0048", "Gen1": "0.0045", "Allocated": "88 B" }, { "Method": "'Imposter (String)'", - "Mean": "300.7 ns", - "Error": "42.70 ns", - "StdDev": "2.34 ns", + "Mean": "306.7 ns", + "Error": "121.47 ns", + "StdDev": "6.66 ns", "Gen0": "0.0100", "Gen1": "0.0095", "Allocated": "168 B" }, { "Method": "'Mockolate (String)'", - "Mean": "543.7 ns", - "Error": "188.98 ns", - "StdDev": "10.36 ns", + "Mean": "543.0 ns", + "Error": "90.95 ns", + "StdDev": "4.99 ns", "Gen0": "0.0305", "Gen1": "0.0153", "Allocated": "520 B" }, { "Method": "'Moq (String)'", - "Mean": "562.1 ns", - "Error": "89.18 ns", - "StdDev": "4.89 ns", + "Mean": "536.6 ns", + "Error": "100.52 ns", + "StdDev": "5.51 ns", "Gen0": "0.0172", "Gen1": "0.0162", "Allocated": "296 B" }, { "Method": "'NSubstitute (String)'", - "Mean": "628.7 ns", - "Error": "78.20 ns", - "StdDev": "4.29 ns", + "Mean": "653.8 ns", + "Error": "679.02 ns", + "StdDev": "37.22 ns", "Gen0": "0.0153", "Gen1": "0.0143", "Allocated": "272 B" }, { "Method": "'FakeItEasy (String)'", - "Mean": "1,645.3 ns", - "Error": "253.86 ns", - "StdDev": "13.92 ns", + "Mean": "1,619.8 ns", + "Error": "547.84 ns", + "StdDev": "30.03 ns", "Gen0": "0.0458", "Gen1": "0.0439", "Allocated": "776 B" }, { "Method": "'TUnit.Mocks (100 calls)'", - "Mean": "26,674.2 ns", - "Error": "15,350.85 ns", - "StdDev": "841.43 ns", + "Mean": "26,494.2 ns", + "Error": "13,807.64 ns", + "StdDev": "756.84 ns", "Gen0": "0.6104", "Gen1": "0.5798", "Allocated": "11936 B" }, { "Method": "'Imposter (100 calls)'", - "Mean": "29,524.3 ns", - "Error": "10,411.38 ns", - "StdDev": "570.68 ns", + "Mean": "30,093.3 ns", + "Error": "8,247.06 ns", + "StdDev": "452.05 ns", "Gen0": "0.9766", "Gen1": "0.9155", "Allocated": "16800 B" }, { "Method": "'Mockolate (100 calls)'", - "Mean": "66,514.3 ns", - "Error": "29,938.23 ns", - "StdDev": "1,641.02 ns", + "Mean": "70,940.2 ns", + "Error": "18,268.87 ns", + "StdDev": "1,001.38 ns", "Gen0": "3.7842", "Gen1": "1.8311", "Allocated": "64000 B" }, { "Method": "'Moq (100 calls)'", - "Mean": "84,478.0 ns", - "Error": "24,794.97 ns", - "StdDev": "1,359.10 ns", + "Mean": "82,141.8 ns", + "Error": "5,729.62 ns", + "StdDev": "314.06 ns", "Gen0": "2.1973", "Gen1": "2.0752", "Allocated": "37600 B" }, { "Method": "'NSubstitute (100 calls)'", - "Mean": "78,702.1 ns", - "Error": "11,264.77 ns", - "StdDev": "617.46 ns", - "Gen0": "1.9531", - "Gen1": "1.8311", - "Allocated": "36448 B" + "Mean": "73,823.5 ns", + "Error": "5,831.24 ns", + "StdDev": "319.63 ns", + "Gen0": "1.7090", + "Gen1": "1.5869", + "Allocated": "30848 B" }, { "Method": "'FakeItEasy (100 calls)'", - "Mean": "190,003.8 ns", - "Error": "33,840.70 ns", - "StdDev": "1,854.92 ns", + "Mean": "186,702.0 ns", + "Error": "247,385.84 ns", + "StdDev": "13,560.05 ns", "Gen0": "5.6152", "Gen1": "5.3711", "Allocated": "94400 B" @@ -339,9 +339,9 @@ "MockCreation": [ { "Method": "TUnit.Mocks", - "Mean": "33.06 ns", - "Error": "0.148 ns", - "StdDev": "0.124 ns", + "Mean": "28.82 ns", + "Error": "0.467 ns", + "StdDev": "0.437 ns", "Gen0": "0.0114", "Gen1": "-", "Gen2": "-", @@ -349,9 +349,9 @@ }, { "Method": "Imposter", - "Mean": "87.58 ns", - "Error": "0.274 ns", - "StdDev": "0.229 ns", + "Mean": "98.31 ns", + "Error": "1.933 ns", + "StdDev": "2.149 ns", "Gen0": "0.0262", "Gen1": "-", "Gen2": "-", @@ -359,9 +359,9 @@ }, { "Method": "Mockolate", - "Mean": "72.01 ns", - "Error": "0.253 ns", - "StdDev": "0.224 ns", + "Mean": "72.83 ns", + "Error": "1.417 ns", + "StdDev": "1.326 ns", "Gen0": "0.0229", "Gen1": "-", "Gen2": "-", @@ -369,9 +369,9 @@ }, { "Method": "Moq", - "Mean": "1,304.59 ns", - "Error": "18.161 ns", - "StdDev": "16.988 ns", + "Mean": "1,318.28 ns", + "Error": "15.107 ns", + "StdDev": "14.131 ns", "Gen0": "0.1221", "Gen1": "-", "Gen2": "-", @@ -379,19 +379,19 @@ }, { "Method": "NSubstitute", - "Mean": "1,755.00 ns", - "Error": "6.823 ns", - "StdDev": "6.383 ns", + "Mean": "1,942.07 ns", + "Error": "17.795 ns", + "StdDev": "15.775 ns", "Gen0": "0.2975", - "Gen1": "0.0019", + "Gen1": "-", "Gen2": "-", "Allocated": "5000 B" }, { "Method": "FakeItEasy", - "Mean": "1,686.18 ns", - "Error": "5.945 ns", - "StdDev": "5.270 ns", + "Mean": "1,743.46 ns", + "Error": "34.281 ns", + "StdDev": "52.350 ns", "Gen0": "0.1602", "Gen1": "0.0038", "Gen2": "0.0019", @@ -399,9 +399,9 @@ }, { "Method": "'TUnit.Mocks (Repository)'", - "Mean": "33.44 ns", - "Error": "0.170 ns", - "StdDev": "0.151 ns", + "Mean": "28.89 ns", + "Error": "0.634 ns", + "StdDev": "0.825 ns", "Gen0": "0.0114", "Gen1": "-", "Gen2": "-", @@ -409,9 +409,9 @@ }, { "Method": "'Imposter (Repository)'", - "Mean": "139.23 ns", - "Error": "0.719 ns", - "StdDev": "0.672 ns", + "Mean": "156.01 ns", + "Error": "2.179 ns", + "StdDev": "2.038 ns", "Gen0": "0.0415", "Gen1": "-", "Gen2": "-", @@ -419,9 +419,9 @@ }, { "Method": "'Mockolate (Repository)'", - "Mean": "63.28 ns", - "Error": "0.304 ns", - "StdDev": "0.284 ns", + "Mean": "72.26 ns", + "Error": "1.494 ns", + "StdDev": "2.536 ns", "Gen0": "0.0229", "Gen1": "-", "Gen2": "-", @@ -429,9 +429,9 @@ }, { "Method": "'Moq (Repository)'", - "Mean": "1,357.45 ns", - "Error": "4.724 ns", - "StdDev": "3.945 ns", + "Mean": "1,284.46 ns", + "Error": "16.637 ns", + "StdDev": "15.562 ns", "Gen0": "0.1125", "Gen1": "-", "Gen2": "-", @@ -439,9 +439,9 @@ }, { "Method": "'NSubstitute (Repository)'", - "Mean": "1,725.98 ns", - "Error": "14.482 ns", - "StdDev": "13.546 ns", + "Mean": "1,919.01 ns", + "Error": "36.617 ns", + "StdDev": "35.962 ns", "Gen0": "0.2975", "Gen1": "0.0019", "Gen2": "-", @@ -449,9 +449,9 @@ }, { "Method": "'FakeItEasy (Repository)'", - "Mean": "1,611.20 ns", - "Error": "12.730 ns", - "StdDev": "10.630 ns", + "Mean": "1,661.42 ns", + "Error": "15.968 ns", + "StdDev": "13.334 ns", "Gen0": "0.1602", "Gen1": "0.0038", "Gen2": "0.0019", @@ -461,293 +461,281 @@ "Setup": [ { "Method": "TUnit.Mocks", - "Mean": "557.4 ns", - "Error": "8.70 ns", - "StdDev": "8.14 ns", - "Median": "560.5 ns", + "Mean": "555.9 ns", + "Error": "5.00 ns", + "StdDev": "4.68 ns", "Gen0": "0.1421", "Gen1": "0.0010", "Allocated": "2.34 KB" }, { "Method": "Imposter", - "Mean": "763.7 ns", - "Error": "15.16 ns", - "StdDev": "28.48 ns", - "Median": "748.9 ns", + "Mean": "865.2 ns", + "Error": "8.72 ns", + "StdDev": "8.15 ns", "Gen0": "0.3738", "Gen1": "0.0076", "Allocated": "6.12 KB" }, { "Method": "Mockolate", - "Mean": "437.8 ns", - "Error": "2.22 ns", - "StdDev": "1.85 ns", - "Median": "438.2 ns", + "Mean": "483.5 ns", + "Error": "6.29 ns", + "StdDev": "5.58 ns", "Gen0": "0.1240", "Gen1": "0.0010", "Allocated": "2.03 KB" }, { "Method": "Moq", - "Mean": "418,995.5 ns", - "Error": "2,484.70 ns", - "StdDev": "2,324.19 ns", - "Median": "419,266.4 ns", + "Mean": "429,288.1 ns", + "Error": "1,670.44 ns", + "StdDev": "1,480.80 ns", "Gen0": "0.9766", "Gen1": "-", - "Allocated": "28.52 KB" + "Allocated": "28.71 KB" }, { "Method": "NSubstitute", - "Mean": "5,422.0 ns", - "Error": "42.62 ns", - "StdDev": "39.86 ns", - "Median": "5,411.3 ns", + "Mean": "5,857.1 ns", + "Error": "89.45 ns", + "StdDev": "79.29 ns", "Gen0": "0.5493", - "Gen1": "0.0076", + "Gen1": "-", "Allocated": "9.01 KB" }, { "Method": "FakeItEasy", - "Mean": "8,015.8 ns", - "Error": "72.55 ns", - "StdDev": "67.86 ns", - "Median": "8,005.7 ns", + "Mean": "8,801.0 ns", + "Error": "82.60 ns", + "StdDev": "77.27 ns", "Gen0": "0.6256", "Gen1": "0.0153", "Allocated": "10.45 KB" }, { "Method": "'TUnit.Mocks (Multiple)'", - "Mean": "743.7 ns", - "Error": "6.78 ns", - "StdDev": "6.34 ns", - "Median": "743.7 ns", + "Mean": "756.4 ns", + "Error": "7.27 ns", + "StdDev": "6.80 ns", "Gen0": "0.1793", "Gen1": "0.0010", "Allocated": "2.93 KB" }, { "Method": "'Imposter (Multiple)'", - "Mean": "1,384.7 ns", - "Error": "10.61 ns", - "StdDev": "9.93 ns", - "Median": "1,384.0 ns", + "Mean": "1,480.8 ns", + "Error": "19.28 ns", + "StdDev": "18.03 ns", "Gen0": "0.6485", "Gen1": "0.0248", "Allocated": "10.59 KB" }, { "Method": "'Mockolate (Multiple)'", - "Mean": "707.3 ns", - "Error": "14.15 ns", - "StdDev": "14.53 ns", - "Median": "705.5 ns", + "Mean": "743.4 ns", + "Error": "8.35 ns", + "StdDev": "7.81 ns", "Gen0": "0.1879", "Gen1": "0.0019", "Allocated": "3.07 KB" }, { "Method": "'Moq (Multiple)'", - "Mean": "111,746.9 ns", - "Error": "736.94 ns", - "StdDev": "653.28 ns", - "Median": "111,723.2 ns", + "Mean": "116,804.7 ns", + "Error": "677.37 ns", + "StdDev": "633.61 ns", "Gen0": "0.9766", "Gen1": "0.7324", "Allocated": "16.53 KB" }, { "Method": "'NSubstitute (Multiple)'", - "Mean": "11,563.4 ns", - "Error": "58.98 ns", - "StdDev": "52.29 ns", - "Median": "11,570.9 ns", - "Gen0": "1.2360", - "Gen1": "0.0305", - "Allocated": "20.31 KB" + "Mean": "12,220.0 ns", + "Error": "82.31 ns", + "StdDev": "64.26 ns", + "Gen0": "1.2207", + "Gen1": "-", + "Allocated": "20.34 KB" }, { "Method": "'FakeItEasy (Multiple)'", - "Mean": "7,942.7 ns", - "Error": "122.18 ns", - "StdDev": "114.29 ns", - "Median": "7,904.2 ns", - "Gen0": "0.7172", - "Gen1": "0.0153", - "Allocated": "11.82 KB" + "Mean": "7,978.2 ns", + "Error": "113.55 ns", + "StdDev": "94.82 ns", + "Gen0": "0.6714", + "Gen1": "0.0610", + "Allocated": "11.71 KB" } ], "Verification": [ { "Method": "TUnit.Mocks", - "Mean": "709.52 ns", - "Error": "1.363 ns", - "StdDev": "1.138 ns", + "Mean": "779.75 ns", + "Error": "14.522 ns", + "StdDev": "13.584 ns", "Gen0": "0.1841", "Gen1": "0.0010", "Allocated": "3080 B" }, { "Method": "Imposter", - "Mean": "654.41 ns", - "Error": "4.892 ns", - "StdDev": "4.576 ns", + "Mean": "697.99 ns", + "Error": "12.340 ns", + "StdDev": "14.211 ns", "Gen0": "0.2794", "Gen1": "0.0038", "Allocated": "4688 B" }, { "Method": "Mockolate", - "Mean": "922.90 ns", - "Error": "1.553 ns", - "StdDev": "1.377 ns", + "Mean": "923.67 ns", + "Error": "13.586 ns", + "StdDev": "12.708 ns", "Gen0": "0.1879", "Gen1": "0.0010", "Allocated": "3152 B" }, { "Method": "Moq", - "Mean": "335,454.92 ns", - "Error": "2,661.938 ns", - "StdDev": "2,489.978 ns", - "Gen0": "0.9766", - "Gen1": "-", - "Allocated": "24325 B" + "Mean": "246,002.68 ns", + "Error": "2,007.103 ns", + "StdDev": "1,877.445 ns", + "Gen0": "1.4648", + "Gen1": "0.9766", + "Allocated": "24675 B" }, { "Method": "NSubstitute", - "Mean": "6,129.28 ns", - "Error": "29.884 ns", - "StdDev": "27.954 ns", - "Gen0": "0.5951", - "Gen1": "0.0076", + "Mean": "6,025.40 ns", + "Error": "116.063 ns", + "StdDev": "124.186 ns", + "Gen0": "0.5798", + "Gen1": "-", "Allocated": "10064 B" }, { "Method": "FakeItEasy", - "Mean": "7,205.45 ns", - "Error": "21.911 ns", - "StdDev": "19.424 ns", + "Mean": "6,484.46 ns", + "Error": "96.148 ns", + "StdDev": "80.288 ns", "Gen0": "0.6409", - "Gen1": "0.0153", + "Gen1": "0.0305", "Allocated": "10722 B" }, { "Method": "'TUnit.Mocks (Never)'", - "Mean": "59.95 ns", - "Error": "0.451 ns", - "StdDev": "0.422 ns", + "Mean": "60.57 ns", + "Error": "1.083 ns", + "StdDev": "1.013 ns", "Gen0": "0.0196", "Gen1": "-", "Allocated": "328 B" }, { "Method": "'Imposter (Never)'", - "Mean": "314.43 ns", - "Error": "1.048 ns", - "StdDev": "0.929 ns", + "Mean": "339.67 ns", + "Error": "4.611 ns", + "StdDev": "4.087 ns", "Gen0": "0.1431", "Gen1": "0.0010", "Allocated": "2400 B" }, { "Method": "'Mockolate (Never)'", - "Mean": "211.67 ns", - "Error": "0.971 ns", - "StdDev": "0.908 ns", + "Mean": "244.38 ns", + "Error": "3.407 ns", + "StdDev": "2.845 ns", "Gen0": "0.0567", "Gen1": "-", "Allocated": "952 B" }, { "Method": "'Moq (Never)'", - "Mean": "85,724.92 ns", - "Error": "135.791 ns", - "StdDev": "120.375 ns", - "Gen0": "0.2441", - "Gen1": "-", - "Allocated": "6918 B" + "Mean": "63,280.47 ns", + "Error": "469.889 ns", + "StdDev": "439.534 ns", + "Gen0": "0.3662", + "Gen1": "0.2441", + "Allocated": "7037 B" }, { "Method": "'NSubstitute (Never)'", - "Mean": "3,567.38 ns", - "Error": "12.592 ns", - "StdDev": "11.778 ns", + "Mean": "3,612.39 ns", + "Error": "70.100 ns", + "StdDev": "95.954 ns", "Gen0": "0.4234", "Gen1": "0.0038", "Allocated": "7088 B" }, { "Method": "'FakeItEasy (Never)'", - "Mean": "3,545.33 ns", - "Error": "15.567 ns", - "StdDev": "12.999 ns", - "Gen0": "0.3090", - "Gen1": "0.0076", + "Mean": "3,636.87 ns", + "Error": "22.107 ns", + "StdDev": "18.461 ns", + "Gen0": "0.3052", + "Gen1": "0.0153", "Allocated": "5210 B" }, { "Method": "'TUnit.Mocks (Multiple)'", - "Mean": "1,286.07 ns", - "Error": "13.249 ns", - "StdDev": "12.394 ns", + "Mean": "1,391.12 ns", + "Error": "9.208 ns", + "StdDev": "8.163 ns", "Gen0": "0.2747", "Gen1": "0.0038", "Allocated": "4608 B" }, { "Method": "'Imposter (Multiple)'", - "Mean": "1,635.13 ns", - "Error": "5.646 ns", - "StdDev": "5.282 ns", + "Mean": "1,934.79 ns", + "Error": "36.515 ns", + "StdDev": "34.157 ns", "Gen0": "0.6676", "Gen1": "0.0210", "Allocated": "11192 B" }, { "Method": "'Mockolate (Multiple)'", - "Mean": "1,783.27 ns", - "Error": "7.720 ns", - "StdDev": "6.843 ns", + "Mean": "1,969.13 ns", + "Error": "28.229 ns", + "StdDev": "25.024 ns", "Gen0": "0.3281", - "Gen1": "0.0019", + "Gen1": "-", "Allocated": "5496 B" }, { "Method": "'Moq (Multiple)'", - "Mean": "458,716.34 ns", - "Error": "1,253.308 ns", - "StdDev": "1,046.569 ns", + "Mean": "354,693.14 ns", + "Error": "3,496.978 ns", + "StdDev": "3,271.075 ns", "Gen0": "1.9531", "Gen1": "0.9766", "Allocated": "34699 B" }, { "Method": "'NSubstitute (Multiple)'", - "Mean": "11,132.14 ns", - "Error": "69.879 ns", - "StdDev": "65.365 ns", + "Mean": "11,172.45 ns", + "Error": "70.593 ns", + "StdDev": "66.032 ns", "Gen0": "0.9766", "Gen1": "-", "Allocated": "16763 B" }, { "Method": "'FakeItEasy (Multiple)'", - "Mean": "12,981.76 ns", - "Error": "101.066 ns", - "StdDev": "89.593 ns", - "Gen0": "1.0986", - "Gen1": "-", - "Allocated": "19233 B" + "Mean": "13,089.09 ns", + "Error": "99.377 ns", + "StdDev": "88.095 ns", + "Gen0": "1.1597", + "Gen1": "0.0610", + "Allocated": "19568 B" } ] }, "stats": { "categoryCount": 6, "totalBenchmarks": 78, - "lastUpdated": "2026-04-16T03:23:00.282Z" + "lastUpdated": "2026-04-17T03:23:50.633Z" } } \ No newline at end of file diff --git a/docs/static/benchmarks/mocks/summary.json b/docs/static/benchmarks/mocks/summary.json index b297939d2a..8d99b60d23 100644 --- a/docs/static/benchmarks/mocks/summary.json +++ b/docs/static/benchmarks/mocks/summary.json @@ -7,7 +7,7 @@ "Setup", "Verification" ], - "timestamp": "2026-04-16", + "timestamp": "2026-04-17", "environment": "Ubuntu Latest • .NET SDK 10.0.202", "libraries": [ "TUnit.Mocks", From 90bdbd0e8dd636e52784d64e1fff10d7ef3ab35a Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Fri, 17 Apr 2026 11:43:59 +0100 Subject: [PATCH 06/17] fix(docs): benchmark index links 404 (#5587) Bare relative links like `[AsyncTests](AsyncTests)` resolved to `/docs/AsyncTests` instead of `/docs/benchmarks/AsyncTests`. Use `./Name.md` form so Docusaurus resolves to the sibling route. Fixes #5585 --- .github/scripts/process-benchmarks.js | 4 ++-- .github/scripts/process-mock-benchmarks.js | 2 +- docs/docs/benchmarks/index.md | 14 +++++++------- docs/docs/benchmarks/mocks/index.md | 12 ++++++------ 4 files changed, 16 insertions(+), 16 deletions(-) diff --git a/.github/scripts/process-benchmarks.js b/.github/scripts/process-benchmarks.js index 8852efabbf..734ae85011 100644 --- a/.github/scripts/process-benchmarks.js +++ b/.github/scripts/process-benchmarks.js @@ -371,13 +371,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) - Detailed performance analysis` ).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/docs/docs/benchmarks/index.md b/docs/docs/benchmarks/index.md index 21f13246b5..2abc86f2a1 100644 --- a/docs/docs/benchmarks/index.md +++ b/docs/docs/benchmarks/index.md @@ -16,17 +16,17 @@ These benchmarks were automatically generated on **2026-04-17** 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) - Detailed performance analysis +- [DataDrivenTests](./DataDrivenTests.md) - Detailed performance analysis +- [MassiveParallelTests](./MassiveParallelTests.md) - Detailed performance analysis +- [MatrixTests](./MatrixTests.md) - Detailed performance analysis +- [ScaleTests](./ScaleTests.md) - Detailed performance analysis +- [SetupTeardownTests](./SetupTeardownTests.md) - Detailed performance analysis ## 🔨 Build Benchmarks -- [Build Performance](BuildTime) - Compilation time comparison +- [Build Performance](./BuildTime.md) - Compilation time comparison --- diff --git a/docs/docs/benchmarks/mocks/index.md b/docs/docs/benchmarks/mocks/index.md index 4b24cddc2e..54548a95a7 100644 --- a/docs/docs/benchmarks/mocks/index.md +++ b/docs/docs/benchmarks/mocks/index.md @@ -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 From 3488effb185d1b6e3cd4cf7ac912437102e9930b Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Fri, 17 Apr 2026 11:59:41 +0100 Subject: [PATCH 07/17] docs: replace repeated 'Detailed performance analysis' with per-benchmark descriptions (#5588) --- .github/scripts/process-benchmarks.js | 11 ++++++++++- docs/docs/benchmarks/index.md | 12 ++++++------ 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/.github/scripts/process-benchmarks.js b/.github/scripts/process-benchmarks.js index 734ae85011..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,7 +380,7 @@ 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}.md) - Detailed performance analysis` + `- [${testClass}](./${testClass}.md)${RUNTIME_DESCRIPTIONS[testClass] ? ` — ${RUNTIME_DESCRIPTIONS[testClass]}` : ''}` ).join('\n')} ${Object.keys(categories.build).length > 0 ? ` diff --git a/docs/docs/benchmarks/index.md b/docs/docs/benchmarks/index.md index 2abc86f2a1..e139fe2f62 100644 --- a/docs/docs/benchmarks/index.md +++ b/docs/docs/benchmarks/index.md @@ -16,12 +16,12 @@ These benchmarks were automatically generated on **2026-04-17** from the latest Click on any benchmark to view detailed results: -- [AsyncTests](./AsyncTests.md) - Detailed performance analysis -- [DataDrivenTests](./DataDrivenTests.md) - Detailed performance analysis -- [MassiveParallelTests](./MassiveParallelTests.md) - Detailed performance analysis -- [MatrixTests](./MatrixTests.md) - Detailed performance analysis -- [ScaleTests](./ScaleTests.md) - Detailed performance analysis -- [SetupTeardownTests](./SetupTeardownTests.md) - 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 From 5d3df9b6206cd45344617956e6d24a3c121ef12f Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Fri, 17 Apr 2026 12:45:04 +0100 Subject: [PATCH 08/17] docs: expand and clarify distributed tracing setup and troubleshooting (#5597) Adds symptom-driven troubleshooting to the OpenTelemetry guide and a new Distributed Tracing guide consolidating per-backend setup, correlation strategies, and known limitations. Moves the "use TestWebApplicationFactory" guidance into a top-of-page warning callout so users don't silently lose trace correlation by inheriting from the vanilla WebApplicationFactory. Cross-references issues #5589, #5590, #5591, #5592, #5593, #5594, #5595, and #5596 which track the planned automation that will let later doc revisions trim each manual recipe. --- docs/docs/examples/aspnet.md | 17 +++ docs/docs/examples/opentelemetry.md | 106 +++++++++++++++ docs/docs/guides/distributed-tracing.md | 167 ++++++++++++++++++++++++ docs/docs/guides/html-report.md | 4 + docs/sidebars.ts | 1 + 5 files changed, 295 insertions(+) create mode 100644 docs/docs/guides/distributed-tracing.md diff --git a/docs/docs/examples/aspnet.md b/docs/docs/examples/aspnet.md index 36b929d339..9aa4ece2a3 100644 --- a/docs/docs/examples/aspnet.md +++ b/docs/docs/examples/aspnet.md @@ -2,6 +2,23 @@ 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` +If you inherit from `Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactory` directly, you'll lose three things you almost certainly want: + +- **Trace correlation** — server-side spans won't link back to the test that triggered them. +- **Per-test logging** — `ILogger` output from inside your app won't appear under the right test. +- **Test context** — `TestContext.Current` won't resolve inside request handlers. + +`TestWebApplicationFactory` sets all of this up for you. If you can't change the inheritance (e.g. you're migrating from an existing setup), wrap your factory instead: + +```csharp +var traced = new TracedWebApplicationFactory(myExistingFactory); +var client = traced.CreateClient(); // tracing + logging now wired up +``` + +See [Distributed Tracing](/docs/guides/distributed-tracing) for what happens under the hood. +::: + ## Installation ```bash diff --git a/docs/docs/examples/opentelemetry.md b/docs/docs/examples/opentelemetry.md index fe511f38c3..480905c285 100644 --- a/docs/docs/examples/opentelemetry.md +++ b/docs/docs/examples/opentelemetry.md @@ -235,6 +235,112 @@ public async Task Api_Returns_Expected_Result() For scenarios without OpenTelemetry, see [Cross-Thread Output Correlation](/docs/extending/logging#cross-thread-output-correlation) for the manual `TestContext.MakeCurrent()` approach. +## Troubleshooting + +Find your symptom below. + +### "I see one trace per test instead of one trace per class" + +That's expected. Each test gets its own trace ID so spans, logs, and exports stay tied to a single test run. To group across tests, search by tag in your backend: + +| Want to see... | Search for | +|----------------|-----------| +| One specific test run | `tunit.test.id = ""` | +| All retries of one test | `tunit.test.node_uid = ""` | +| Everything from one test session | `tunit.session.id = ""` | +| All tests in a class | `tunit.test.class = "MyNamespace.MyTests"` | + +In Seq use `tunit.session.id = ''`. In Jaeger or Tempo use the tag filter box. + +### "Spans from test A are showing up under test B" + +Usually a background worker (a hosted service, a message broker like DotPulsar, or a connection pool) started during one test and kept running. Anything it produces inherits whichever test was current when it started. + +**Quickest fix**: tag every span with the current test's ID so you can still filter even when the parent is wrong. Add this processor to your OpenTelemetry setup: + +```csharp +using System.Diagnostics; +using OpenTelemetry; + +public sealed class TUnitTagProcessor : BaseProcessor +{ + public override void OnStart(Activity activity) + { + var testId = Activity.Current?.GetBaggageItem("tunit.test.id"); + if (testId is not null) + { + activity.SetTag("tunit.test.id", testId); + } + } +} + +// then in your tracer builder: +.AddProcessor(new TUnitTagProcessor()) +``` + +Now you can filter by `tunit.test.id` in your backend even when the trace hierarchy is wrong. (Tracking automation: [#5591](https://github.com/thomhurst/TUnit/issues/5591).) + +**Better fix** if you control the worker: stop it from capturing the test's context in the first place. + +```csharp +using (ExecutionContext.SuppressFlow()) +{ + _ = Task.Run(BackgroundLoopAsync); +} +``` + +Around `IHostedService` registrations the same idea applies. (Tracking automation: [#5589](https://github.com/thomhurst/TUnit/issues/5589).) + +**Last resort**: run affected tests one at a time with `[NotInParallel]`. + +### "My SUT spans show no parent / appear orphaned" + +Two common causes. + +**1. The parent span isn't exported to the same backend.** The test-side `test case` span lives in the test process. If you only export from the SUT, the backend sees a child whose parent it has never seen. Either export the `"TUnit"` source from the test process too, or rely on the `tunit.test.id` tag (above) instead of trace hierarchy. + +**2. The two processes use different baggage formats.** .NET defaults to `Correlation-Context`. The OpenTelemetry SDK reads W3C `baggage`. The two don't speak to each other. Use the same propagator on both sides: + +```csharp +using OpenTelemetry; +using OpenTelemetry.Context.Propagation; + +Sdk.SetDefaultTextMapPropagator(new CompositeTextMapPropagator( +[ + new TraceContextPropagator(), + new BaggagePropagator(), +])); +``` + +### "My HTTP calls don't carry the test trace" + +If you inherit from `Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactory` directly, the `HttpClient` it returns skips .NET's normal HTTP tracing. No `traceparent` header is sent, so the server starts a fresh trace. + +Switch your factory to `TestWebApplicationFactory`: + +```csharp +public class MyFactory : TestWebApplicationFactory { } +``` + +Or, if you can't change the inheritance, wrap your existing factory: + +```csharp +var traced = new TracedWebApplicationFactory(myExistingFactory); +var client = traced.CreateClient(); +``` + +Both attach the trace propagation handler automatically. See [ASP.NET Core integration](./aspnet.md) for full setup. + +For HTTP calls the SUT itself makes through `IHttpClientFactory`, today you have to add the handler manually (`.AddHttpMessageHandler()`). Tracking automation: [#5590](https://github.com/thomhurst/TUnit/issues/5590). + +### "No spans show up in my exporter at all" + +Check in order: + +1. Did you register the listener in `[Before(TestDiscovery)]`? `[Before(Test)]` or `[Before(Class)]` is too late. +2. Did you call `.AddSource("TUnit")` (and `"TUnit.Lifecycle"` if you want runner spans)? Each source has to be added explicitly. +3. Did you dispose the `TracerProvider` in `[After(TestSession)]`? Without disposal, buffered spans never get flushed. + ## HTML Report Integration TUnit's built-in [HTML test report](/docs/guides/html-report) automatically captures activity spans and renders them as trace timelines — no OpenTelemetry SDK required. The report also captures spans from instrumented libraries like HttpClient, ASP.NET Core, and EF Core when they execute within a test's context. diff --git a/docs/docs/guides/distributed-tracing.md b/docs/docs/guides/distributed-tracing.md new file mode 100644 index 0000000000..902b7a5f7d --- /dev/null +++ b/docs/docs/guides/distributed-tracing.md @@ -0,0 +1,167 @@ +--- +sidebar_position: 20 +--- + +# Distributed Tracing + +This page is for users wiring TUnit up to a tracing backend like Seq, Jaeger, Tempo, or the Aspire dashboard. If you just want a working setup, start with [OpenTelemetry Tracing](/docs/examples/opentelemetry). If you're hitting problems, jump straight to its [Troubleshooting](/docs/examples/opentelemetry#troubleshooting) section. + +:::note +Distributed tracing requires .NET 8 or later. +::: + +## What TUnit emits + +Every test produces: + +- A **test case** span (the root of the test's trace) — gets a fresh trace ID. +- A **test body** span underneath it. +- Any spans your code or libraries (HttpClient, ASP.NET Core, EF Core, etc.) create — automatically nested under the test body. + +Separately, TUnit also emits **lifecycle spans** for the run as a whole (test session, assembly, suite). These are on a different source so backends don't mix them with per-test data. + +```text +test case (one per test, own trace ID) + └── test body + └── HttpClient / EF Core / your code +``` + +```text +test session + ├── test discovery + └── test assembly + └── test suite (one per class) + └── shared setup / teardown / hooks +``` + +The two sources you usually subscribe to: + +| Source name | What's in it | +|-------------|--------------| +| `TUnit` | The test case + test body spans | +| `TUnit.Lifecycle` | Session, discovery, assembly, suite, shared setup/teardown | + +## Backend setup + +The general setup in [OpenTelemetry Tracing](/docs/examples/opentelemetry#setup) works everywhere. Backend-specific notes follow. + +### Seq + +Point the OTLP exporter at Seq's ingestion endpoint: + +```csharp +.AddOtlpExporter(opts => +{ + opts.Endpoint = new Uri("http://localhost:5341/ingest/otlp/v1/traces"); + opts.Protocol = OtlpExportProtocol.HttpProtobuf; + opts.Headers = "X-Seq-ApiKey=your-key"; +}) +``` + +Useful Seq queries: + +```text +tunit.session.id = '' -- one full test run +tunit.test.class = 'MyTests' -- one class +tunit.test.id = '' -- one specific test invocation +test.case.result.status = 'fail' -- only failures +``` + +### Jaeger or Tempo + +```csharp +.AddOtlpExporter(opts => opts.Endpoint = new Uri("http://localhost:4317")) +``` + +Jaeger groups by trace ID, so each test appears as a separate trace. Use the tag search box (`tunit.session.id=""`) to find all traces from one run. + +### Aspire dashboard + +Aspire is wired up automatically through `TUnit.Aspire`. See [Aspire integration](/docs/examples/aspire). The dashboard understands the link between `test case` and `test suite` spans, so it groups them naturally. + +### Other backends (Honeycomb, Datadog, etc.) + +Use the OTLP exporter pointed at your vendor endpoint and set `OTEL_EXPORTER_OTLP_HEADERS` for auth. No TUnit-specific config needed. + +## How to find spans for a test + +Every TUnit-emitted span carries these tags. Use them in your backend's search UI: + +| Tag | What it identifies | +|-----|--------------------| +| `tunit.test.id` | One specific test invocation (one retry attempt) | +| `tunit.test.node_uid` | All retry attempts of the same logical test | +| `tunit.session.id` | One whole test run | +| `tunit.test.class` | All tests in a class | +| `tunit.assembly.name` | All tests in an assembly | + +For cross-process correlation (your test calling your SUT), use `tunit.test.id`. It's the most reliable — see [Limitations](#limitations) below for why trace IDs alone aren't always enough. + +## When tracing across processes + +If your test process and your SUT are different processes (or you're using `WebApplicationFactory` heavily), make sure both sides agree on the propagator: + +```csharp +using OpenTelemetry; +using OpenTelemetry.Context.Propagation; + +Sdk.SetDefaultTextMapPropagator(new CompositeTextMapPropagator( +[ + new TraceContextPropagator(), + new BaggagePropagator(), +])); +``` + +Without this, .NET's default propagator emits `Correlation-Context`, but the OpenTelemetry SDK only reads W3C `baggage`. The mismatch silently drops baggage and you lose `tunit.test.id` on the SUT side. + +## Limitations + +### Static `ActivitySource` in third-party libraries + +Some libraries (message brokers like DotPulsar, EF providers, connection pools) hold a `static` `ActivitySource` and emit spans from background threads. Those threads may have captured the wrong test's context, so the spans end up under the wrong trace. + +**You can't fix the parent chain after the fact.** What works: + +- Add the [`TUnitTagProcessor`](/docs/examples/opentelemetry#spans-from-test-a-are-showing-up-under-test-b) so spans always carry `tunit.test.id` even when the trace ID is wrong, then filter by tag. +- Suppress `ExecutionContext` flow when starting hosted services so they capture a clean context — see the same troubleshooting section. + +### `WebApplicationFactory` without TUnit's wrapper + +The vanilla `WebApplicationFactory` returns an `HttpClient` that skips .NET's HTTP tracing. No `traceparent` is injected and the server starts a fresh trace. + +Use [`TestWebApplicationFactory`](/docs/examples/aspnet) or wrap with `TracedWebApplicationFactory`. + +### `IHttpClientFactory` clients in the SUT + +Outbound HTTP calls the SUT itself makes (e.g. to a downstream service) are not auto-instrumented yet. Add the handler manually: + +```csharp +services.AddHttpClient() + .AddHttpMessageHandler(); +``` + +Tracking automation: [#5590](https://github.com/thomhurst/TUnit/issues/5590). + +### Raw `HttpClient` + +`new HttpClient()` can't be intercepted. Either route through `IHttpClientFactory` or set the `traceparent` header manually. + +## HTML report vs OpenTelemetry backends + +TUnit's HTML report and a backend like Seq render the same data differently: + +| | HTML report | OpenTelemetry backend | +|--|-------------|------------------------| +| Hierarchy | Folds each test under its class using span links | Each test is a separate trace | +| Filtering | Built-in UI controls | Backend query language | +| Cross-service spans | Only what the engine sees in-process | Everything every exporter sends in | +| Persistence | One file per run | Long-term, queryable across runs | + +Use the HTML report for debugging a single run. Use a backend for run-over-run analysis and cross-service correlation. + +## Related pages + +- [OpenTelemetry Tracing](/docs/examples/opentelemetry) — first-time setup, full attribute reference, troubleshooting. +- [ASP.NET Core Integration Testing](/docs/examples/aspnet) — `TestWebApplicationFactory` setup. +- [HTML Test Report — Distributed Tracing](/docs/guides/html-report#distributed-tracing) — how the HTML report renders the data. +- [Aspire integration](/docs/examples/aspire) — Aspire dashboard and OTLP receiver. diff --git a/docs/docs/guides/html-report.md b/docs/docs/guides/html-report.md index 0a104c6d58..d2dca1ed87 100644 --- a/docs/docs/guides/html-report.md +++ b/docs/docs/guides/html-report.md @@ -128,6 +128,10 @@ public async Task GetUsers_ReturnsOk() The trace timeline for this test will show the HttpClient request span, ASP.NET Core hosting span, and any middleware or database spans — all nested under the test's root span. +:::note Same data, different views +The HTML report groups each test under its class. Backends like Seq, Jaeger, and the Aspire dashboard show every test as its own trace — that's by design. See [Distributed Tracing](./distributed-tracing.md#html-report-vs-opentelemetry-backends) for why, and how to group spans across tests in your backend. +::: + ### Linking External Traces If your test communicates with an external service that runs in a **separate process** (and therefore has a different trace context), you can manually link its trace to the test: diff --git a/docs/sidebars.ts b/docs/sidebars.ts index bea58dc141..e6aa266301 100644 --- a/docs/sidebars.ts +++ b/docs/sidebars.ts @@ -177,6 +177,7 @@ const sidebars: SidebarsConfig = { 'examples/complex-test-infrastructure', 'examples/fscheck', 'examples/opentelemetry', + 'guides/distributed-tracing', 'examples/filebased-csharp', 'examples/fsharp-interactive', 'examples/tunit-ci-pipeline', From 76c4d4ed05863902dcdf9d944953d39c41f18bdd Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Fri, 17 Apr 2026 13:51:53 +0100 Subject: [PATCH 09/17] fix: auto-suppress ExecutionContext flow for hosted services (#5589) (#5598) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: auto-suppress ExecutionContext flow for hosted services (#5589) `TestWebApplicationFactory` now wraps every registered `IHostedService` so its `StartAsync` runs under `ExecutionContext.SuppressFlow`. Background tasks spawned inside `StartAsync` capture a clean execution context, preventing spans from hosted-service work in test B from being attributed to test A's `TraceId`. The wrapper also implements `IHostedLifecycleService` so the Host's `StartingAsync`/`StartedAsync`/`StoppingAsync`/`StoppedAsync` hooks keep firing for inner services that implement it (the Host uses an `is` check against the registered instance). Override `SuppressHostedServiceExecutionContextFlow` and return `false` to preserve ambient context flow. * fix: wrap StartAsync in Task.Run under SuppressFlow `using var _ = SuppressFlow(); return inner.StartAsync(ct);` only suppresses context capture during the synchronous portion of StartAsync — `Task.Run` after a prior `await` re-captures the test's Activity.Current. Combine SuppressFlow with `Task.Run(() => inner.StartAsync(ct), ct)` so the inner hosted service runs on a thread-pool worker whose ExecutionContext was captured under suppression. Activity.Current starts null and stays null through every await point, so background tasks spawned anywhere inside StartAsync inherit a clean context. Also drops the Limitation xmldoc since it no longer applies, and adds a deep-async test (StartAsync_SuppressesFlow_WhenSpawnIsAfterAwait) proving the fix holds past the first await. --- .../FlowSuppressingHostedService.cs | 60 +++++++ .../TestWebApplicationFactory.cs | 77 ++++++++ .../HostedServiceFlowSuppressionTests.cs | 170 ++++++++++++++++++ docs/docs/examples/opentelemetry.md | 2 +- docs/docs/guides/distributed-tracing.md | 2 +- 5 files changed, 309 insertions(+), 2 deletions(-) create mode 100644 TUnit.AspNetCore.Core/FlowSuppressingHostedService.cs create mode 100644 TUnit.AspNetCore.Tests/HostedServiceFlowSuppressionTests.cs 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/TestWebApplicationFactory.cs b/TUnit.AspNetCore.Core/TestWebApplicationFactory.cs index 86850f6e77..4c772834aa 100644 --- a/TUnit.AspNetCore.Core/TestWebApplicationFactory.cs +++ b/TUnit.AspNetCore.Core/TestWebApplicationFactory.cs @@ -81,6 +81,83 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) }); } + /// + /// 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. 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/docs/docs/examples/opentelemetry.md b/docs/docs/examples/opentelemetry.md index 480905c285..86a130934d 100644 --- a/docs/docs/examples/opentelemetry.md +++ b/docs/docs/examples/opentelemetry.md @@ -289,7 +289,7 @@ using (ExecutionContext.SuppressFlow()) } ``` -Around `IHostedService` registrations the same idea applies. (Tracking automation: [#5589](https://github.com/thomhurst/TUnit/issues/5589).) +For `IHostedService` registrations inside ASP.NET Core integration tests, `TestWebApplicationFactory` does this automatically — every registered hosted service has its `StartAsync` wrapped in `ExecutionContext.SuppressFlow()`, so background tasks it spawns capture a clean context. Override `SuppressHostedServiceExecutionContextFlow` and return `false` to opt out if you intentionally rely on `Activity.Current` flowing into a hosted service. **Last resort**: run affected tests one at a time with `[NotInParallel]`. diff --git a/docs/docs/guides/distributed-tracing.md b/docs/docs/guides/distributed-tracing.md index 902b7a5f7d..48ef44ef3c 100644 --- a/docs/docs/guides/distributed-tracing.md +++ b/docs/docs/guides/distributed-tracing.md @@ -123,7 +123,7 @@ Some libraries (message brokers like DotPulsar, EF providers, connection pools) **You can't fix the parent chain after the fact.** What works: - Add the [`TUnitTagProcessor`](/docs/examples/opentelemetry#spans-from-test-a-are-showing-up-under-test-b) so spans always carry `tunit.test.id` even when the trace ID is wrong, then filter by tag. -- Suppress `ExecutionContext` flow when starting hosted services so they capture a clean context — see the same troubleshooting section. +- For hosted services inside `TestWebApplicationFactory`, this leak is auto-mitigated — each `IHostedService.StartAsync` runs under `ExecutionContext.SuppressFlow()`, so background tasks it spawns capture a clean context. Override `SuppressHostedServiceExecutionContextFlow` and return `false` to opt out. Third-party `ActivitySource` instances captured at class-load time remain a residual concern. ### `WebApplicationFactory` without TUnit's wrapper From e7fde9beb5813ad113d3ac3f6866e795420f48b0 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Fri, 17 Apr 2026 14:38:11 +0100 Subject: [PATCH 10/17] feat: auto-align DistributedContextPropagator to W3C (#5599) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: auto-align DistributedContextPropagator to W3C (#5592) .NET's default LegacyPropagator emits Correlation-Context; the OpenTelemetry SDK's BaggagePropagator only reads W3C baggage. The mismatch silently drops tunit.test.id across processes so test correlation breaks on the SUT side. Add a module initializer in TUnit.Core that swaps the runtime-default LegacyPropagator for CreateW3CPropagator(), leaving user-customised propagators untouched. TestWebApplicationFactory.ConfigureWebHost re-applies the same alignment so SUT startup code cannot accidentally revert it. Opt out via TUNIT_KEEP_LEGACY_PROPAGATOR=1. Docs updated to reflect automatic alignment; manual OTel SetDefaultTextMapPropagator snippet retained only for out-of-process SUTs that don't reference TUnit.Core. * fix: provide W3C propagator on net8/net9 DistributedContextPropagator.CreateW3CPropagator() was added in .NET 10; on net8/net9 supply a minimal in-library W3CBaggagePropagator that delegates traceparent/tracestate to the runtime default and emits/parses W3C baggage. * refactor: dedupe baggage utilities, tighten W3CBaggagePropagator - Promote BaggageHeader constant onto TUnitActivitySource; consume from W3CBaggagePropagator and the ASP.NET Core / Aspire propagation handlers. - Reuse TUnitActivitySource.TryBuildBaggageHeader in W3CBaggagePropagator instead of duplicating the URI-escape/comma-join logic. - Rename PropagatorAlignment.CreateW3CPropagator -> CreateAlignedPropagator to avoid shadowing the BCL static. - Parse baggage via span-walker (no Split allocation), lazy-init the result list so empty headers return null. * refactor: register PropagatorAlignment via IStartupFilter ConfigureWebHost callbacks register builder actions that run before user Program.cs/Startup code; calling AlignIfDefault() there lets SUT startup clobber the propagator again. IStartupFilter runs when the request pipeline is built, after all service registration and startup assignments, so alignment wins. Also drop the "TUnit 0.x (issue #5592)" placeholder from the OpenTelemetry docs — the auto-alignment is just how it works now. --- .../Http/ActivityPropagationHandler.cs | 4 +- .../PropagatorAlignmentStartupFilter.cs | 23 +++ .../TestWebApplicationFactory.cs | 4 + .../Http/TUnitBaggagePropagationHandler.cs | 9 +- TUnit.Core/PropagatorAlignment.cs | 176 ++++++++++++++++++ TUnit.Core/TUnitActivitySource.cs | 3 + TUnit.UnitTests/PropagatorAlignmentTests.cs | 56 ++++++ docs/docs/examples/opentelemetry.md | 4 +- docs/docs/guides/distributed-tracing.md | 8 +- 9 files changed, 277 insertions(+), 10 deletions(-) create mode 100644 TUnit.AspNetCore.Core/PropagatorAlignmentStartupFilter.cs create mode 100644 TUnit.Core/PropagatorAlignment.cs create mode 100644 TUnit.UnitTests/PropagatorAlignmentTests.cs 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/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/TestWebApplicationFactory.cs b/TUnit.AspNetCore.Core/TestWebApplicationFactory.cs index 4c772834aa..1a3c33542e 100644 --- a/TUnit.AspNetCore.Core/TestWebApplicationFactory.cs +++ b/TUnit.AspNetCore.Core/TestWebApplicationFactory.cs @@ -69,6 +69,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,6 +80,7 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) builder.ConfigureServices(services => { + services.AddSingleton(); services.AddCorrelatedTUnitLogging(); }); } 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.Core/PropagatorAlignment.cs b/TUnit.Core/PropagatorAlignment.cs new file mode 100644 index 0000000000..e19e75ca75 --- /dev/null +++ b/TUnit.Core/PropagatorAlignment.cs @@ -0,0 +1,176 @@ +#if NET + +using System.Diagnostics; +using System.Runtime.CompilerServices; + +namespace TUnit.Core; + +/// +/// Auto-aligns to a W3C-compatible +/// propagator (traceparent + W3C baggage) when the current propagator is .NET's default +/// LegacyPropagator. Without this, cross-process test correlation baggage +/// (tunit.test.id) is emitted as Correlation-Context, which the OpenTelemetry +/// SDK's BaggagePropagator does not read — the baggage silently drops between +/// the test process and the SUT. +/// +/// +/// On .NET 10+, delegates to the runtime's DistributedContextPropagator.CreateW3CPropagator(). +/// On .NET 8/9, uses a minimal built-in W3C baggage propagator. +/// Set TUNIT_KEEP_LEGACY_PROPAGATOR=1 to opt out. +/// +internal static class PropagatorAlignment +{ + private const string LegacyPropagatorTypeName = "System.Diagnostics.LegacyPropagator"; + + // Read once: env vars don't change within a process and GetEnvironmentVariable allocates. + private static readonly bool OptedOut = + Environment.GetEnvironmentVariable("TUNIT_KEEP_LEGACY_PROPAGATOR") == "1"; + +#pragma warning disable CA2255 // Module initializer is the intended entry point per issue #5592. + [ModuleInitializer] +#pragma warning restore CA2255 + internal static void AlignOnModuleLoad() => AlignIfDefault(); + + /// + /// Idempotent: re-align only if the current propagator is still the runtime default. + /// + internal static void AlignIfDefault() + { + if (OptedOut) + { + return; + } + + if (DistributedContextPropagator.Current.GetType().FullName == LegacyPropagatorTypeName) + { + DistributedContextPropagator.Current = CreateAlignedPropagator(); + } + } + + /// + /// Returns the propagator TUnit aligns to. On .NET 10+ this is the runtime's + /// built-in W3C propagator; on .NET 8/9 a minimal in-library equivalent. + /// + internal static DistributedContextPropagator CreateAlignedPropagator() + { +#if NET10_0_OR_GREATER + return DistributedContextPropagator.CreateW3CPropagator(); +#else + return new W3CBaggagePropagator(); +#endif + } + +#if !NET10_0_OR_GREATER + /// + /// Minimal W3C propagator for .NET 8/9: delegates traceparent/tracestate + /// to the default runtime propagator, and emits/parses baggage using the W3C + /// baggage header rather than the legacy Correlation-Context. + /// + private sealed class W3CBaggagePropagator : DistributedContextPropagator + { + private const string LegacyBaggageHeader = "Correlation-Context"; + + private static readonly DistributedContextPropagator DefaultPropagator = CreateDefaultPropagator(); + private static readonly IReadOnlyCollection FieldNames = new[] { "traceparent", "tracestate", TUnitActivitySource.BaggageHeader }; + + public override IReadOnlyCollection Fields => FieldNames; + + public override void Inject(Activity? activity, object? carrier, PropagatorSetterCallback? setter) + { + if (activity is null || setter is null) + { + return; + } + + // Filter the legacy baggage header from the default propagator's output + // so we don't emit both Correlation-Context and W3C baggage for the same values. + DefaultPropagator.Inject(activity, carrier, (c, key, value) => + { + if (!string.Equals(key, LegacyBaggageHeader, StringComparison.OrdinalIgnoreCase)) + { + setter(c, key, value); + } + }); + + if (TUnitActivitySource.TryBuildBaggageHeader(activity) is { } baggage) + { + setter(carrier, TUnitActivitySource.BaggageHeader, baggage); + } + } + + public override void ExtractTraceIdAndState(object? carrier, PropagatorGetterCallback? getter, out string? traceId, out string? traceState) + => DefaultPropagator.ExtractTraceIdAndState(carrier, getter, out traceId, out traceState); + + public override IEnumerable>? ExtractBaggage(object? carrier, PropagatorGetterCallback? getter) + { + if (getter is null) + { + return null; + } + + getter(carrier, TUnitActivitySource.BaggageHeader, out var header, out var headers); + if (string.IsNullOrEmpty(header) && headers is not null) + { + foreach (var h in headers) + { + if (!string.IsNullOrEmpty(h)) + { + header = h; + break; + } + } + } + + return string.IsNullOrEmpty(header) ? null : ParseBaggage(header!); + } + + private static List>? ParseBaggage(string header) + { + List>? result = null; + var remaining = header.AsSpan(); + + while (!remaining.IsEmpty) + { + ReadOnlySpan entry; + var comma = remaining.IndexOf(','); + if (comma < 0) + { + entry = remaining; + remaining = default; + } + else + { + entry = remaining[..comma]; + remaining = remaining[(comma + 1)..]; + } + + entry = entry.Trim(); + var semi = entry.IndexOf(';'); + if (semi >= 0) + { + entry = entry[..semi]; + } + + var eq = entry.IndexOf('='); + if (eq <= 0) + { + continue; + } + + var key = Uri.UnescapeDataString(entry[..eq].Trim().ToString()); + if (key.Length == 0) + { + continue; + } + + var value = Uri.UnescapeDataString(entry[(eq + 1)..].Trim().ToString()); + (result ??= new List>()).Add(new KeyValuePair(key, value)); + } + + return result; + } + } +#endif +} + +#endif diff --git a/TUnit.Core/TUnitActivitySource.cs b/TUnit.Core/TUnitActivitySource.cs index 64d7652802..9440d0009a 100644 --- a/TUnit.Core/TUnitActivitySource.cs +++ b/TUnit.Core/TUnitActivitySource.cs @@ -12,6 +12,9 @@ public static class TUnitActivitySource internal const string SourceName = "TUnit"; internal const string LifecycleSourceName = "TUnit.Lifecycle"; + /// 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.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/docs/docs/examples/opentelemetry.md b/docs/docs/examples/opentelemetry.md index 86a130934d..b4b20aec22 100644 --- a/docs/docs/examples/opentelemetry.md +++ b/docs/docs/examples/opentelemetry.md @@ -299,7 +299,9 @@ Two common causes. **1. The parent span isn't exported to the same backend.** The test-side `test case` span lives in the test process. If you only export from the SUT, the backend sees a child whose parent it has never seen. Either export the `"TUnit"` source from the test process too, or rely on the `tunit.test.id` tag (above) instead of trace hierarchy. -**2. The two processes use different baggage formats.** .NET defaults to `Correlation-Context`. The OpenTelemetry SDK reads W3C `baggage`. The two don't speak to each other. Use the same propagator on both sides: +**2. The two processes use different baggage formats.** .NET defaults to `Correlation-Context`. The OpenTelemetry SDK reads W3C `baggage`. TUnit auto-aligns `DistributedContextPropagator.Current` to W3C on module load, and `TestWebApplicationFactory` re-applies this for in-process SUTs via an `IStartupFilter` — no manual wiring needed. Set `TUNIT_KEEP_LEGACY_PROPAGATOR=1` to opt out. + +For an **out-of-process** SUT that doesn't reference `TUnit.Core`, you still need to align it yourself: ```csharp using OpenTelemetry; diff --git a/docs/docs/guides/distributed-tracing.md b/docs/docs/guides/distributed-tracing.md index 48ef44ef3c..1f5a3941b2 100644 --- a/docs/docs/guides/distributed-tracing.md +++ b/docs/docs/guides/distributed-tracing.md @@ -99,7 +99,11 @@ For cross-process correlation (your test calling your SUT), use `tunit.test.id`. ## When tracing across processes -If your test process and your SUT are different processes (or you're using `WebApplicationFactory` heavily), make sure both sides agree on the propagator: +Cross-process baggage propagation (e.g. `tunit.test.id` reaching your SUT) depends on both sides using the W3C `baggage` header rather than .NET's default `Correlation-Context`. + +TUnit handles this automatically: a module initializer in `TUnit.Core` replaces the default `DistributedContextPropagator.LegacyPropagator` with `DistributedContextPropagator.CreateW3CPropagator()`. Any custom propagator you set yourself is left alone. If you want to retain the legacy behavior, set `TUNIT_KEEP_LEGACY_PROPAGATOR=1`. + +For the SUT side, if it shares the test process (e.g. `TestWebApplicationFactory`), alignment flows automatically. For out-of-process SUTs that don't reference `TUnit.Core`, align the propagator yourself on startup — either match `DistributedContextPropagator.Current` or, if you use the OpenTelemetry SDK: ```csharp using OpenTelemetry; @@ -112,8 +116,6 @@ Sdk.SetDefaultTextMapPropagator(new CompositeTextMapPropagator( ])); ``` -Without this, .NET's default propagator emits `Correlation-Context`, but the OpenTelemetry SDK only reads W3C `baggage`. The mismatch silently drops baggage and you lose `tunit.test.id` on the SUT side. - ## Limitations ### Static `ActivitySource` in third-party libraries From e6d42c3d6c85747f697a69a4235a5e4f9c956091 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Fri, 17 Apr 2026 15:13:49 +0100 Subject: [PATCH 11/17] chore(deps): update dependency coverlet.collector to v10 (#5600) Co-authored-by: Renovate Bot --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 0a2822eb62..3507227881 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -10,7 +10,7 @@ - + From f394eef5920f3e745b50e356cffd8dd0b036cef8 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Fri, 17 Apr 2026 15:40:14 +0100 Subject: [PATCH 12/17] feat: TUnit0064 analyzer + code fix for direct WebApplicationFactory inheritance (#5601) * feat: add TUnit0064 analyzer + code fix for direct WebApplicationFactory inheritance Flags classes inheriting directly from `Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactory` and offers a code fix to rewrite the base to `TUnit.AspNetCore.TestWebApplicationFactory`, preserving distributed tracing, per-test logging correlation, and `TestContext.Current` resolution inside request handlers. Closes #5596 * fix: detect namespace-scoped TUnit.AspNetCore usings when applying code fix Also guards against ignoring pre-existing top-level usings and adds tests for both cases (top-level + namespace-scoped) so BatchFixer can't emit duplicate directives. * fix: skip analysis when TestWebApplicationFactory is unavailable If `TUnit.AspNetCore` isn't referenced, warning a user to migrate to a type they can't resolve is unhelpful and would make the code fix produce a compile error. Bail out of the analyzer early in that case. Also adds tests for file-scoped-namespace using deduplication and partial class declarations. --- ...nit.AspNetCore.Analyzers.CodeFixers.csproj | 37 +++++ ...estWebApplicationFactoryCodeFixProvider.cs | 128 +++++++++++++++++ ...licationFactoryInheritanceAnalyzerTests.cs | 86 +++++++++++ .../TUnit.AspNetCore.Analyzers.Tests.csproj | 2 + ...bApplicationFactoryCodeFixProviderTests.cs | 135 ++++++++++++++++++ .../Verifiers/CSharpCodeFixVerifier.cs | 64 +++++++++ .../WebApplicationFactoryStubs.cs | 23 +++ .../AnalyzerReleases.Unshipped.md | 1 + ...ebApplicationFactoryInheritanceAnalyzer.cs | 97 +++++++++++++ .../Resources.Designer.cs | 27 ++++ TUnit.AspNetCore.Analyzers/Resources.resx | 9 ++ TUnit.AspNetCore.Analyzers/Rules.cs | 16 ++- .../TUnit.AspNetCore.Core.csproj | 6 + TUnit.CI.slnx | 1 + TUnit.Dev.slnx | 1 + TUnit.slnx | 1 + docs/docs/examples/aspnet.md | 2 + docs/docs/guides/distributed-tracing.md | 2 +- 18 files changed, 635 insertions(+), 3 deletions(-) create mode 100644 TUnit.AspNetCore.Analyzers.CodeFixers/TUnit.AspNetCore.Analyzers.CodeFixers.csproj create mode 100644 TUnit.AspNetCore.Analyzers.CodeFixers/UseTestWebApplicationFactoryCodeFixProvider.cs create mode 100644 TUnit.AspNetCore.Analyzers.Tests/DirectWebApplicationFactoryInheritanceAnalyzerTests.cs create mode 100644 TUnit.AspNetCore.Analyzers.Tests/UseTestWebApplicationFactoryCodeFixProviderTests.cs create mode 100644 TUnit.AspNetCore.Analyzers.Tests/Verifiers/CSharpCodeFixVerifier.cs create mode 100644 TUnit.AspNetCore.Analyzers.Tests/WebApplicationFactoryStubs.cs create mode 100644 TUnit.AspNetCore.Analyzers/DirectWebApplicationFactoryInheritanceAnalyzer.cs 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/TUnit.AspNetCore.Core.csproj b/TUnit.AspNetCore.Core/TUnit.AspNetCore.Core.csproj index 5ebc439f06..9f9f7bc802 100644 --- a/TUnit.AspNetCore.Core/TUnit.AspNetCore.Core.csproj +++ b/TUnit.AspNetCore.Core/TUnit.AspNetCore.Core.csproj @@ -36,6 +36,8 @@ + @@ -51,6 +53,10 @@ + + diff --git a/TUnit.CI.slnx b/TUnit.CI.slnx index 398211c7ae..f138b66d92 100644 --- a/TUnit.CI.slnx +++ b/TUnit.CI.slnx @@ -36,6 +36,7 @@ + diff --git a/TUnit.Dev.slnx b/TUnit.Dev.slnx index b8541146e4..9f3de40426 100644 --- a/TUnit.Dev.slnx +++ b/TUnit.Dev.slnx @@ -33,6 +33,7 @@ + diff --git a/TUnit.slnx b/TUnit.slnx index 58049aa178..b77c87419d 100644 --- a/TUnit.slnx +++ b/TUnit.slnx @@ -36,6 +36,7 @@ + diff --git a/docs/docs/examples/aspnet.md b/docs/docs/examples/aspnet.md index 9aa4ece2a3..5e57287721 100644 --- a/docs/docs/examples/aspnet.md +++ b/docs/docs/examples/aspnet.md @@ -16,6 +16,8 @@ var traced = new TracedWebApplicationFactory(myExistingFactory); var client = traced.CreateClient(); // tracing + logging now wired up ``` +The `TUnit0064` analyzer (warning) flags direct `WebApplicationFactory` inheritance and offers a code fix that rewrites the base type to `TestWebApplicationFactory`. + See [Distributed Tracing](/docs/guides/distributed-tracing) for what happens under the hood. ::: diff --git a/docs/docs/guides/distributed-tracing.md b/docs/docs/guides/distributed-tracing.md index 1f5a3941b2..019a8a84b3 100644 --- a/docs/docs/guides/distributed-tracing.md +++ b/docs/docs/guides/distributed-tracing.md @@ -131,7 +131,7 @@ Some libraries (message brokers like DotPulsar, EF providers, connection pools) The vanilla `WebApplicationFactory` returns an `HttpClient` that skips .NET's HTTP tracing. No `traceparent` is injected and the server starts a fresh trace. -Use [`TestWebApplicationFactory`](/docs/examples/aspnet) or wrap with `TracedWebApplicationFactory`. +Use [`TestWebApplicationFactory`](/docs/examples/aspnet) or wrap with `TracedWebApplicationFactory`. The `TUnit0064` analyzer raises a warning (with a code fix) when a class inherits directly from `WebApplicationFactory`. ### `IHttpClientFactory` clients in the SUT From b8f9bb53eb77ff488a54df1695add4da47d2ac3d Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Fri, 17 Apr 2026 17:47:12 +0100 Subject: [PATCH 13/17] feat: auto-propagate test trace context through IHttpClientFactory (#5603) * feat: auto-propagate test trace context through IHttpClientFactory Registers an IHttpMessageHandlerBuilderFilter in TestWebApplicationFactory so that every IHttpClientFactory pipeline built inside the SUT (AddHttpClient(), named clients, typed clients) automatically carries the test's traceparent, baggage, and X-TUnit-TestId headers on outbound calls. Opt out per-test via WebApplicationTestOptions.AutoPropagateHttpClientFactory = false. Closes #5590. * address review: document statelessness + tighten opt-out assertion - Document that both handler types inserted by TUnitHttpClientFilter must remain stateless/thread-safe because IHttpClientFactory caches pipelines and shares handler instances across concurrent parallel-test requests. - Explain the outermost-insert intent so a future refactor doesn't reverse the order. - Opt-out test now also asserts the downstream hop does not carry baggage, mirroring the positive test. --- .../Http/TUnitHttpClientFilter.cs | 43 ++++++++++++++ .../Http/TUnitTestIdHandler.cs | 2 +- .../TestWebApplicationFactory.cs | 15 +++-- .../TracedWebApplicationFactory.cs | 9 +-- .../WebApplicationTestOptions.cs | 14 +++++ TUnit.AspNetCore.Tests.WebApp/Program.cs | 26 +++++++++ .../IHttpClientFactoryPropagationTests.cs | 56 +++++++++++++++++++ docs/docs/examples/aspnet.md | 7 +++ docs/docs/examples/opentelemetry.md | 2 +- docs/docs/guides/distributed-tracing.md | 12 ++-- 10 files changed, 168 insertions(+), 18 deletions(-) create mode 100644 TUnit.AspNetCore.Core/Http/TUnitHttpClientFilter.cs create mode 100644 TUnit.AspNetCore.Tests/IHttpClientFactoryPropagationTests.cs 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/TestWebApplicationFactory.cs b/TUnit.AspNetCore.Core/TestWebApplicationFactory.cs index 1a3c33542e..3bc0e810c3 100644 --- a/TUnit.AspNetCore.Core/TestWebApplicationFactory.cs +++ b/TUnit.AspNetCore.Core/TestWebApplicationFactory.cs @@ -3,8 +3,11 @@ 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 TUnit.AspNetCore.Extensions; +using TUnit.AspNetCore.Http; using TUnit.AspNetCore.Interception; using TUnit.AspNetCore.Logging; using TUnit.Core; @@ -41,6 +44,12 @@ public WebApplicationFactory GetIsolatedFactory( configureIsolatedServices(services); services.AddSingleton(testContext); services.AddTUnitLogging(testContext); + + if (options.AutoPropagateHttpClientFactory) + { + services.TryAddEnumerable( + ServiceDescriptor.Singleton()); + } }); if (options.EnableHttpExchangeCapture) @@ -173,11 +182,7 @@ private static ServiceDescriptor WrapHostedServiceDescriptor(ServiceDescriptor d /// 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..91050dd0fc 100644 --- a/TUnit.AspNetCore.Core/WebApplicationTestOptions.cs +++ b/TUnit.AspNetCore.Core/WebApplicationTestOptions.cs @@ -8,4 +8,18 @@ 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; } 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/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/docs/docs/examples/aspnet.md b/docs/docs/examples/aspnet.md index 5e57287721..c58e88bb21 100644 --- a/docs/docs/examples/aspnet.md +++ b/docs/docs/examples/aspnet.md @@ -82,6 +82,13 @@ public class TodoApiTests : TestsBase } ``` +`TestWebApplicationFactory` wires up these behaviors automatically: + +- **Client-side tracing**: `CreateClient()` / `CreateDefaultClient()` return an `HttpClient` that propagates `traceparent`, `baggage`, and `X-TUnit-TestId` headers to the SUT. +- **SUT `IHttpClientFactory` tracing**: Every pipeline built inside the SUT via `AddHttpClient()`, named clients, or typed clients also gets those headers prepended — outbound calls from your app to downstream services correlate with the originating test. Opt out per-test with `WebApplicationTestOptions.AutoPropagateHttpClientFactory = false`. +- **Correlated logging**: Server-side `ILogger` output is routed to the test that triggered the request. +- **Hosted-service context hygiene**: `IHostedService.StartAsync` runs under `ExecutionContext.SuppressFlow()` so background work doesn't inherit the first test's `Activity.Current`. + ## Core Concepts ### Why Test Isolation Matters diff --git a/docs/docs/examples/opentelemetry.md b/docs/docs/examples/opentelemetry.md index b4b20aec22..e8cde82e32 100644 --- a/docs/docs/examples/opentelemetry.md +++ b/docs/docs/examples/opentelemetry.md @@ -333,7 +333,7 @@ var client = traced.CreateClient(); Both attach the trace propagation handler automatically. See [ASP.NET Core integration](./aspnet.md) for full setup. -For HTTP calls the SUT itself makes through `IHttpClientFactory`, today you have to add the handler manually (`.AddHttpMessageHandler()`). Tracking automation: [#5590](https://github.com/thomhurst/TUnit/issues/5590). +Outbound HTTP calls the SUT itself makes through `IHttpClientFactory` (`AddHttpClient()`, named clients, typed clients) are also auto-instrumented by `TestWebApplicationFactory`. Opt out per-test via `WebApplicationTestOptions.AutoPropagateHttpClientFactory = false` when the SUT already owns its outbound tracing. ### "No spans show up in my exporter at all" diff --git a/docs/docs/guides/distributed-tracing.md b/docs/docs/guides/distributed-tracing.md index 019a8a84b3..87a8694aa3 100644 --- a/docs/docs/guides/distributed-tracing.md +++ b/docs/docs/guides/distributed-tracing.md @@ -135,15 +135,17 @@ Use [`TestWebApplicationFactory`](/docs/examples/aspnet) or wrap with `Traced ### `IHttpClientFactory` clients in the SUT -Outbound HTTP calls the SUT itself makes (e.g. to a downstream service) are not auto-instrumented yet. Add the handler manually: +`TestWebApplicationFactory` auto-registers an `IHttpMessageHandlerBuilderFilter` that prepends the TUnit tracing and test-id handlers to every `IHttpClientFactory` pipeline built in the SUT. Outbound calls from `AddHttpClient()`, named clients, and typed clients all carry `traceparent`, `baggage`, and `X-TUnit-TestId` automatically — no manual `.AddHttpMessageHandler<>()` wiring required. + +Opt out per-test when the SUT already instruments its own outbound HTTP (for example via the OpenTelemetry HttpClient instrumentation) by setting `WebApplicationTestOptions.AutoPropagateHttpClientFactory = false`: ```csharp -services.AddHttpClient() - .AddHttpMessageHandler(); +protected override void ConfigureTestOptions(WebApplicationTestOptions options) +{ + options.AutoPropagateHttpClientFactory = false; +} ``` -Tracking automation: [#5590](https://github.com/thomhurst/TUnit/issues/5590). - ### Raw `HttpClient` `new HttpClient()` can't be intercepted. Either route through `IHttpClientFactory` or set the `traceparent` header manually. From b700a16794155874f437dfa03f1742c59a6f2048 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Fri, 17 Apr 2026 17:51:03 +0100 Subject: [PATCH 14/17] feat: TUnit.OpenTelemetry zero-config tracing package (#5602) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: scaffold TUnit.OpenTelemetry project * chore: scaffold TUnit.OpenTelemetry.Tests project * feat(otel): add TUnitOpenTelemetry.Configure hook * feat(otel): auto-wire TracerProvider at TestDiscovery with coexistence * test(otel): cover TUNIT_OTEL_AUTOSTART=0 opt-out * test(otel): cover TUnitTestCorrelationProcessor baggage-to-tag behavior * feat(otel): skip auto-wire when no endpoint and no user Configure * feat(otel): set default service.name resource attribute * test(otel): add public API snapshot for TUnit.OpenTelemetry * refactor(aspire): delegate TracerProvider to TUnit.OpenTelemetry * test(otel): end-to-end span export smoke test * docs(otel): document TUnit.OpenTelemetry zero-config setup * chore(otel): wire TUnit.OpenTelemetry into pipeline (pack + test) * refactor(otel): reuse TagTestId constant, extract env-var names, release lock during Build - TUnitTestCorrelationProcessor now references TUnit.Core.TUnitActivitySource.TagTestId instead of duplicating the "tunit.test.id" literal. - AutoStart extracts AutoStartEnvVar / OtlpEndpointEnvVar / ServiceNameEnvVar as internal consts; tests reference them by name rather than raw strings. - Environment.GetEnvironmentVariable("OTEL_EXPORTER_OTLP_ENDPOINT") is read once and reused by both the gate check and AddOtlpExporter branch. - TracerProvider.Build() and user Configure callbacks now execute outside the AutoStart lock; a CAS-like re-check inside the lock disposes the loser if two Start calls race. * refactor(otel): address PR review — tighten TOCTOU, drop test-only wrapper - AutoStart.Start moves the HasListeners/endpoint/HasConfiguration checks inside the existing _lock section so the listener probe and the provider assignment are no longer separated by a gap (PR #5602 issue 2). Build() still runs outside the lock; the post-Build re-check disposes the loser on a race. - provider.Dispose() is called unconditionally after Build — the local is known non-null, so the ?. was misleading (issue 3). - TestTraceExporter.CreateTracerProvider is removed — it only existed for one Aspire test that duplicated AddToBuilder_ExportsTracesForRegisteredSource anyway. Production code uses AddToBuilder (issue 4). Issue 1 (AutoStart hard-coding "TUnit.AspNetCore.Http") is acknowledged but not acted on: the plan explicitly requires TUnit.AspNetCore to stay OTel-agnostic, so the suggested self-registration pattern would create the very dep we want to avoid. Users who disagree can already use TUnitOpenTelemetry.Configure(b => b.AddSource(...)) to add/override sources. --- Directory.Packages.props | 2 + TUnit.Aspire.Tests/TestTraceExporterTests.cs | 26 ++-- TUnit.Aspire/AspireTelemetryHooks.cs | 23 +-- TUnit.Aspire/TUnit.Aspire.csproj | 2 +- TUnit.Aspire/Telemetry/TestTraceExporter.cs | 76 +--------- TUnit.CI.slnx | 2 + TUnit.Dev.slnx | 2 + TUnit.OpenTelemetry.Tests/AutoStartTests.cs | 139 ++++++++++++++++++ TUnit.OpenTelemetry.Tests/ConfigureTests.cs | 41 ++++++ .../CorrelationProcessorTests.cs | 74 ++++++++++ TUnit.OpenTelemetry.Tests/EndToEndTests.cs | 52 +++++++ TUnit.OpenTelemetry.Tests/GlobalUsings.cs | 2 + .../TUnit.OpenTelemetry.Tests.csproj | 26 ++++ TUnit.OpenTelemetry.Tests/TestSessionSetup.cs | 16 ++ TUnit.OpenTelemetry/AutoStart.cs | 138 +++++++++++++++++ .../TUnit.OpenTelemetry.csproj | 31 ++++ TUnit.OpenTelemetry/TUnitOpenTelemetry.cs | 63 ++++++++ .../TUnitTestCorrelationProcessor.cs | 27 ++++ .../Modules/GetPackageProjectsModule.cs | 1 + .../Modules/RunOpenTelemetryTestsModule.cs | 43 ++++++ TUnit.PublicAPI/TUnit.PublicAPI.csproj | 4 + ...Has_No_API_Changes.DotNet10_0.verified.txt | 22 +++ ..._Has_No_API_Changes.DotNet8_0.verified.txt | 22 +++ ..._Has_No_API_Changes.DotNet9_0.verified.txt | 22 +++ TUnit.PublicAPI/Tests.cs | 18 +++ TUnit.slnx | 2 + docs/docs/examples/opentelemetry.md | 63 +++++++- docs/docs/guides/distributed-tracing.md | 4 + 28 files changed, 843 insertions(+), 100 deletions(-) create mode 100644 TUnit.OpenTelemetry.Tests/AutoStartTests.cs create mode 100644 TUnit.OpenTelemetry.Tests/ConfigureTests.cs create mode 100644 TUnit.OpenTelemetry.Tests/CorrelationProcessorTests.cs create mode 100644 TUnit.OpenTelemetry.Tests/EndToEndTests.cs create mode 100644 TUnit.OpenTelemetry.Tests/GlobalUsings.cs create mode 100644 TUnit.OpenTelemetry.Tests/TUnit.OpenTelemetry.Tests.csproj create mode 100644 TUnit.OpenTelemetry.Tests/TestSessionSetup.cs create mode 100644 TUnit.OpenTelemetry/AutoStart.cs create mode 100644 TUnit.OpenTelemetry/TUnit.OpenTelemetry.csproj create mode 100644 TUnit.OpenTelemetry/TUnitOpenTelemetry.cs create mode 100644 TUnit.OpenTelemetry/TUnitTestCorrelationProcessor.cs create mode 100644 TUnit.Pipeline/Modules/RunOpenTelemetryTestsModule.cs create mode 100644 TUnit.PublicAPI/Tests.OpenTelemetry_Library_Has_No_API_Changes.DotNet10_0.verified.txt create mode 100644 TUnit.PublicAPI/Tests.OpenTelemetry_Library_Has_No_API_Changes.DotNet8_0.verified.txt create mode 100644 TUnit.PublicAPI/Tests.OpenTelemetry_Library_Has_No_API_Changes.DotNet9_0.verified.txt diff --git a/Directory.Packages.props b/Directory.Packages.props index 3507227881..277ab48bda 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -65,6 +65,8 @@ + + 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/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/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.CI.slnx b/TUnit.CI.slnx index f138b66d92..b643d853ea 100644 --- a/TUnit.CI.slnx +++ b/TUnit.CI.slnx @@ -17,6 +17,7 @@ + @@ -80,6 +81,7 @@ + diff --git a/TUnit.Dev.slnx b/TUnit.Dev.slnx index 9f3de40426..333c1d5a6f 100644 --- a/TUnit.Dev.slnx +++ b/TUnit.Dev.slnx @@ -17,6 +17,7 @@ + @@ -61,6 +62,7 @@ + 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/TUnit.OpenTelemetry.Tests.csproj b/TUnit.OpenTelemetry.Tests/TUnit.OpenTelemetry.Tests.csproj new file mode 100644 index 0000000000..1b34a80380 --- /dev/null +++ b/TUnit.OpenTelemetry.Tests/TUnit.OpenTelemetry.Tests.csproj @@ -0,0 +1,26 @@ + + + + + + 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/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.OpenTelemetry/TUnit.OpenTelemetry.csproj b/TUnit.OpenTelemetry/TUnit.OpenTelemetry.csproj new file mode 100644 index 0000000000..9ea7132710 --- /dev/null +++ b/TUnit.OpenTelemetry/TUnit.OpenTelemetry.csproj @@ -0,0 +1,31 @@ + + + + + + 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.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..988645ea32 --- /dev/null +++ b/TUnit.PublicAPI/Tests.OpenTelemetry_Library_Has_No_API_Changes.DotNet10_0.verified.txt @@ -0,0 +1,22 @@ +[assembly: .(@".Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")] +[assembly: .(".NETCoreApp,Version=v10.0", FrameworkDisplayName=".NET 10.0")] +namespace +{ + 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..77813e3a34 --- /dev/null +++ b/TUnit.PublicAPI/Tests.OpenTelemetry_Library_Has_No_API_Changes.DotNet8_0.verified.txt @@ -0,0 +1,22 @@ +[assembly: .(@".Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")] +[assembly: .(".NETCoreApp,Version=v8.0", FrameworkDisplayName=".NET 8.0")] +namespace +{ + 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..bbc727ddf5 --- /dev/null +++ b/TUnit.PublicAPI/Tests.OpenTelemetry_Library_Has_No_API_Changes.DotNet9_0.verified.txt @@ -0,0 +1,22 @@ +[assembly: .(@".Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100698a70398fa0b2230c5a72e3bd9d56b48f809f6173e49a19fbb942d621be93ad48c5566b47b28faabc359b9ad3ff4e00bbdea88f5bdfa250f391fedd28182b2e37b55d429c0151a42a98ea7a5821818cd15a79fef9903e8607a88304cf3e0317bf86ec96e32e1381535a6582251e5a6eed40b5a3ed82bc444598b1269cce57a7")] +[assembly: .(".NETCoreApp,Version=v9.0", FrameworkDisplayName=".NET 9.0")] +namespace +{ + 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.slnx b/TUnit.slnx index b77c87419d..3a13bcae2a 100644 --- a/TUnit.slnx +++ b/TUnit.slnx @@ -17,6 +17,7 @@ + @@ -82,6 +83,7 @@ + diff --git a/docs/docs/examples/opentelemetry.md b/docs/docs/examples/opentelemetry.md index e8cde82e32..099b6d6c8c 100644 --- a/docs/docs/examples/opentelemetry.md +++ b/docs/docs/examples/opentelemetry.md @@ -8,7 +8,58 @@ Activity tracing requires .NET 8 or later. It is not available on .NET Framework ## Setup -### Option A: OpenTelemetry SDK (recommended) +### Option A: Zero-config (`TUnit.OpenTelemetry`) + +Install the meta-package. OTLP is already bundled — no other OpenTelemetry packages needed for the common case: + +```bash +dotnet add package TUnit.OpenTelemetry +``` + +Point it at a backend: + +```bash +export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 +``` + +That's it. The package auto-wires a `TracerProvider` at `[Before(TestDiscovery)]` (subscribed to `TUnit`, `TUnit.Lifecycle`, and `TUnit.AspNetCore.Http`), includes a pre-registered `TUnitTestCorrelationProcessor`, and disposes the provider at `[After(TestSession)]`. + +#### Customizing (add exporters, processors, resources) + +Call `TUnitOpenTelemetry.Configure` from any `[Before(TestDiscovery)]` hook with `Order` less than `int.MaxValue`: + +```csharp +using OpenTelemetry.Trace; +using TUnit.OpenTelemetry; + +public static class TraceCustomization +{ + [Before(TestDiscovery)] + public static void Configure() + { + TUnitOpenTelemetry.Configure(builder => builder + .AddConsoleExporter() + .AddProcessor(new MyCustomProcessor())); + } +} +``` + +Each callback is applied in registration order after the package's defaults, so you can override the resource or add additional exporters. + +#### Environment switches + +| Variable | Behavior | +|----------|----------| +| `TUNIT_OTEL_AUTOSTART=0` | Opt out — package does nothing, user hooks run as normal | +| `TUNIT_OTEL_AUTOSTART=1` | Force on — build the provider even if another listener is already attached | +| `OTEL_SERVICE_NAME` | Override `service.name` (defaults to the entry assembly name) | +| `OTEL_EXPORTER_OTLP_ENDPOINT` | Enables OTLP export when set; when unset, the package stays dormant unless `Configure` is called | + +#### Coexistence with hand-rolled setup + +If you already register your own `TracerProvider` or `ActivityListener` in a `[Before(TestDiscovery)]` hook, the package detects the attached listener and stays out of the way — no duplicate spans. `Configure(...)` applies only to the package's provider; do not mix it with a separately built `Sdk.CreateTracerProviderBuilder()` in the same project. Pick one. + +### Option B: Manual (full control) Add the OpenTelemetry packages to your test project: @@ -58,7 +109,7 @@ Use one stable service name for the test runner (for example, `MyTests`) rather `service.name` per test. Individual tests are already distinguished by their own trace IDs and TUnit tags such as `tunit.test.id`. -### Option B: Raw `ActivityListener` (no SDK dependency) +### Option C: Raw `ActivityListener` (no SDK dependency) If you don't want the OpenTelemetry SDK, you can subscribe directly with a `System.Diagnostics.ActivityListener`: @@ -256,7 +307,11 @@ In Seq use `tunit.session.id = ''`. In Jaeger or Tempo use the tag filter bo Usually a background worker (a hosted service, a message broker like DotPulsar, or a connection pool) started during one test and kept running. Anything it produces inherits whichever test was current when it started. -**Quickest fix**: tag every span with the current test's ID so you can still filter even when the parent is wrong. Add this processor to your OpenTelemetry setup: +**Quickest fix**: tag every span with the current test's ID so you can still filter even when the parent is wrong. + +If you installed `TUnit.OpenTelemetry` (Option A), `TUnitTestCorrelationProcessor` is already registered for you — no additional setup. + +For manual setups, add this processor to your tracer builder: ```csharp using System.Diagnostics; @@ -278,7 +333,7 @@ public sealed class TUnitTagProcessor : BaseProcessor .AddProcessor(new TUnitTagProcessor()) ``` -Now you can filter by `tunit.test.id` in your backend even when the trace hierarchy is wrong. (Tracking automation: [#5591](https://github.com/thomhurst/TUnit/issues/5591).) +Now you can filter by `tunit.test.id` in your backend even when the trace hierarchy is wrong. **Better fix** if you control the worker: stop it from capturing the test's context in the first place. diff --git a/docs/docs/guides/distributed-tracing.md b/docs/docs/guides/distributed-tracing.md index 87a8694aa3..be75cb986b 100644 --- a/docs/docs/guides/distributed-tracing.md +++ b/docs/docs/guides/distributed-tracing.md @@ -45,6 +45,10 @@ The two sources you usually subscribe to: The general setup in [OpenTelemetry Tracing](/docs/examples/opentelemetry#setup) works everywhere. Backend-specific notes follow. +:::tip Zero-config setup +Install [`TUnit.OpenTelemetry`](/docs/examples/opentelemetry#option-a-zero-config-tunitopentelemetry) and set `OTEL_EXPORTER_OTLP_ENDPOINT`. The package auto-wires a `TracerProvider` at test discovery, pre-registers `TUnitTestCorrelationProcessor`, and flushes at session end. Call `TUnitOpenTelemetry.Configure(...)` to add exporters or processors. +::: + ### Seq Point the OTLP exporter at Seq's ingestion endpoint: From 02d2c96f892a5ad7e0c9d4153d983a27bcb26c39 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Fri, 17 Apr 2026 18:24:42 +0100 Subject: [PATCH 15/17] fix: restore [Obsolete] members removed in v1.27 (#5539) (#5605) * fix: restore [Obsolete] members removed in v1.27 (#5539) PR #5384 deleted previously [Obsolete]-marked public APIs in a minor release, breaking semver. Restore them with [Obsolete] reapplied so v1.x consumers can upgrade without compile errors. Actual deletion is deferred to the v2 major bump (tracked in #5604). Restored: - TUnit.Assertions: CountWrapper, LengthWrapper, HasCount/HasLength overloads on CollectionAssertionBase / AssertionExtensions - TUnit.Core: ObjectBag on TestBuilderContext + TestRegisteredContext, Timing record, ITestOutput.Timings + RecordTiming bridged to internal TimingEntry storage with new _timingsLock for user-facing concurrent RecordTiming calls - PublicAPI snapshots regenerated for net8/9/10/472 * refactor: address PR review feedback - Extract GetCount/MapToCount helpers in CountWrapper to remove 6x duplication - Use on ObjectBag aliases (drop duplicated XML) - Clarify _timingsLock comment: engine writes are sequential, lock guards user-facing obsolete RecordTiming --- .../Conditions/Wrappers/CountWrapper.cs | 190 ++++++++++++++++++ .../Conditions/Wrappers/LengthWrapper.cs | 93 +++++++++ .../Extensions/AssertionExtensions.cs | 27 +++ .../Sources/CollectionAssertionBase.cs | 27 +++ TUnit.Core/Contexts/TestRegisteredContext.cs | 4 + TUnit.Core/Interfaces/ITestOutput.cs | 15 ++ TUnit.Core/TestBuilderContext.cs | 4 + TUnit.Core/TestContext.Output.cs | 25 ++- TUnit.Core/Timing.cs | 7 + ...Has_No_API_Changes.DotNet10_0.verified.txt | 32 +++ ..._Has_No_API_Changes.DotNet8_0.verified.txt | 32 +++ ..._Has_No_API_Changes.DotNet9_0.verified.txt | 32 +++ ...ary_Has_No_API_Changes.Net4_7.verified.txt | 32 +++ ...Has_No_API_Changes.DotNet10_0.verified.txt | 20 ++ ..._Has_No_API_Changes.DotNet8_0.verified.txt | 20 ++ ..._Has_No_API_Changes.DotNet9_0.verified.txt | 20 ++ ...ary_Has_No_API_Changes.Net4_7.verified.txt | 20 ++ 17 files changed, 598 insertions(+), 2 deletions(-) create mode 100644 TUnit.Assertions/Conditions/Wrappers/CountWrapper.cs create mode 100644 TUnit.Assertions/Conditions/Wrappers/LengthWrapper.cs create mode 100644 TUnit.Core/Timing.cs 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().Count(); + + private AssertionContext MapToCount() => _context.Map(GetCount); + + /// + /// Not supported on CountWrapper - use IsTypeOf on the assertion source before calling HasCount(). + /// + TypeOfAssertion IAssertionSource.IsTypeOf() + { + throw new NotSupportedException( + "IsTypeOf is not supported after HasCount(). " + + "Use: Assert.That(value).IsTypeOf>().HasCount().EqualTo(5)"); + } + + /// + /// Not supported on CountWrapper - use IsAssignableTo on the assertion source before calling HasCount(). + /// + IsAssignableToAssertion IAssertionSource.IsAssignableTo() + { + throw new NotSupportedException( + "IsAssignableTo is not supported after HasCount(). " + + "Use: Assert.That(value).IsAssignableTo>().HasCount().EqualTo(5)"); + } + + /// + /// Not supported on CountWrapper - use IsNotAssignableTo on the assertion source before calling HasCount(). + /// + IsNotAssignableToAssertion IAssertionSource.IsNotAssignableTo() + { + throw new NotSupportedException( + "IsNotAssignableTo is not supported after HasCount(). " + + "Use: Assert.That(value).IsNotAssignableTo>().HasCount().EqualTo(5)"); + } + + /// + /// Not supported on CountWrapper - use IsAssignableFrom on the assertion source before calling HasCount(). + /// + IsAssignableFromAssertion IAssertionSource.IsAssignableFrom() + { + throw new NotSupportedException( + "IsAssignableFrom is not supported after HasCount(). " + + "Use: Assert.That(value).IsAssignableFrom>().HasCount().EqualTo(5)"); + } + + /// + /// Not supported on CountWrapper - use IsNotAssignableFrom on the assertion source before calling HasCount(). + /// + IsNotAssignableFromAssertion IAssertionSource.IsNotAssignableFrom() + { + throw new NotSupportedException( + "IsNotAssignableFrom is not supported after HasCount(). " + + "Use: Assert.That(value).IsNotAssignableFrom>().HasCount().EqualTo(5)"); + } + + /// + /// Not supported on CountWrapper - use IsNotTypeOf on the assertion source before calling HasCount(). + /// + IsNotTypeOfAssertion IAssertionSource.IsNotTypeOf() + { + throw new NotSupportedException( + "IsNotTypeOf is not supported after HasCount(). " + + "Use: Assert.That(value).IsNotTypeOf>().HasCount().EqualTo(5)"); + } + + /// + /// Asserts that the collection count is equal to the expected count. + /// + public CollectionCountAssertion EqualTo( + int expectedCount, + [CallerArgumentExpression(nameof(expectedCount))] string? expression = null) + { + _context.ExpressionBuilder.Append($".EqualTo({expression})"); + return new CollectionCountAssertion(_context, expectedCount); + } + + /// + /// Asserts that the collection count is greater than or equal to the expected count. + /// + public TValue_IsGreaterThanOrEqualTo_TValue_Assertion GreaterThanOrEqualTo( + int expected, + [CallerArgumentExpression(nameof(expected))] string? expression = null) + { + _context.ExpressionBuilder.Append($".GreaterThanOrEqualTo({expression})"); + return new TValue_IsGreaterThanOrEqualTo_TValue_Assertion(MapToCount(), expected); + } + + /// + /// Asserts that the collection count is positive (greater than 0). + /// + public TValue_IsGreaterThan_TValue_Assertion Positive() + { + _context.ExpressionBuilder.Append(".Positive()"); + return new TValue_IsGreaterThan_TValue_Assertion(MapToCount(), 0); + } + + /// + /// Asserts that the collection count is greater than the expected count. + /// + public TValue_IsGreaterThan_TValue_Assertion GreaterThan( + int expected, + [CallerArgumentExpression(nameof(expected))] string? expression = null) + { + _context.ExpressionBuilder.Append($".GreaterThan({expression})"); + return new TValue_IsGreaterThan_TValue_Assertion(MapToCount(), expected); + } + + /// + /// Asserts that the collection count is less than the expected count. + /// + public TValue_IsLessThan_TValue_Assertion LessThan( + int expected, + [CallerArgumentExpression(nameof(expected))] string? expression = null) + { + _context.ExpressionBuilder.Append($".LessThan({expression})"); + return new TValue_IsLessThan_TValue_Assertion(MapToCount(), expected); + } + + /// + /// Asserts that the collection count is less than or equal to the expected count. + /// + public TValue_IsLessThanOrEqualTo_TValue_Assertion LessThanOrEqualTo( + int expected, + [CallerArgumentExpression(nameof(expected))] string? expression = null) + { + _context.ExpressionBuilder.Append($".LessThanOrEqualTo({expression})"); + return new TValue_IsLessThanOrEqualTo_TValue_Assertion(MapToCount(), expected); + } + + /// + /// Asserts that the collection count is between the minimum and maximum values. + /// + public BetweenAssertion Between( + int minimum, + int maximum, + [CallerArgumentExpression(nameof(minimum))] string? minExpression = null, + [CallerArgumentExpression(nameof(maximum))] string? maxExpression = null) + { + _context.ExpressionBuilder.Append($".Between({minExpression}, {maxExpression})"); + return new BetweenAssertion(MapToCount(), minimum, maximum); + } + + /// + /// Asserts that the collection count is zero (empty collection). + /// + public CollectionCountAssertion Zero() + { + _context.ExpressionBuilder.Append(".Zero()"); + return new CollectionCountAssertion(_context, 0); + } + + /// + /// Asserts that the collection count is not equal to the expected count. + /// + public NotEqualsAssertion NotEqualTo( + int expected, + [CallerArgumentExpression(nameof(expected))] string? expression = null) + { + _context.ExpressionBuilder.Append($".NotEqualTo({expression})"); + return new NotEqualsAssertion(MapToCount(), expected); + } +} diff --git a/TUnit.Assertions/Conditions/Wrappers/LengthWrapper.cs b/TUnit.Assertions/Conditions/Wrappers/LengthWrapper.cs new file mode 100644 index 0000000000..4518e6068f --- /dev/null +++ b/TUnit.Assertions/Conditions/Wrappers/LengthWrapper.cs @@ -0,0 +1,93 @@ +using System.Runtime.CompilerServices; +using System.Text; +using TUnit.Assertions.Conditions; +using TUnit.Assertions.Core; + +namespace TUnit.Assertions.Conditions.Wrappers; + +/// +/// Wrapper for string length assertions that provides .EqualTo() method. +/// Example: await Assert.That(str).HasLength().EqualTo(5); +/// +public class LengthWrapper : IAssertionSource +{ + private readonly AssertionContext _context; + + public LengthWrapper(AssertionContext context) + { + _context = context; + } + + AssertionContext IAssertionSource.Context => _context; + + /// + /// Not supported on LengthWrapper - use IsTypeOf on the assertion source before calling HasLength(). + /// + TypeOfAssertion IAssertionSource.IsTypeOf() + { + throw new NotSupportedException( + "IsTypeOf is not supported after HasLength(). " + + "Use: Assert.That(value).IsTypeOf().HasLength().EqualTo(5)"); + } + + /// + /// Not supported on LengthWrapper - use IsAssignableTo on the assertion source before calling HasLength(). + /// + IsAssignableToAssertion IAssertionSource.IsAssignableTo() + { + throw new NotSupportedException( + "IsAssignableTo is not supported after HasLength(). " + + "Use: Assert.That(value).IsAssignableTo().HasLength().EqualTo(5)"); + } + + /// + /// Not supported on LengthWrapper - use IsNotAssignableTo on the assertion source before calling HasLength(). + /// + IsNotAssignableToAssertion IAssertionSource.IsNotAssignableTo() + { + throw new NotSupportedException( + "IsNotAssignableTo is not supported after HasLength(). " + + "Use: Assert.That(value).IsNotAssignableTo().HasLength().EqualTo(5)"); + } + + /// + /// Not supported on LengthWrapper - use IsAssignableFrom on the assertion source before calling HasLength(). + /// + IsAssignableFromAssertion IAssertionSource.IsAssignableFrom() + { + throw new NotSupportedException( + "IsAssignableFrom is not supported after HasLength(). " + + "Use: Assert.That(value).IsAssignableFrom().HasLength().EqualTo(5)"); + } + + /// + /// Not supported on LengthWrapper - use IsNotAssignableFrom on the assertion source before calling HasLength(). + /// + IsNotAssignableFromAssertion IAssertionSource.IsNotAssignableFrom() + { + throw new NotSupportedException( + "IsNotAssignableFrom is not supported after HasLength(). " + + "Use: Assert.That(value).IsNotAssignableFrom().HasLength().EqualTo(5)"); + } + + /// + /// Not supported on LengthWrapper - use IsNotTypeOf on the assertion source before calling HasLength(). + /// + IsNotTypeOfAssertion IAssertionSource.IsNotTypeOf() + { + throw new NotSupportedException( + "IsNotTypeOf is not supported after HasLength(). " + + "Use: Assert.That(value).IsNotTypeOf().HasLength().EqualTo(5)"); + } + + /// + /// Asserts that the string length is equal to the expected length. + /// + public StringLengthAssertion EqualTo( + int expectedLength, + [CallerArgumentExpression(nameof(expectedLength))] string? expression = null) + { + _context.ExpressionBuilder.Append($".EqualTo({expression})"); + return new StringLengthAssertion(_context, expectedLength); + } +} diff --git a/TUnit.Assertions/Extensions/AssertionExtensions.cs b/TUnit.Assertions/Extensions/AssertionExtensions.cs index 8772e932ba..9f8a5e918b 100644 --- a/TUnit.Assertions/Extensions/AssertionExtensions.cs +++ b/TUnit.Assertions/Extensions/AssertionExtensions.cs @@ -5,6 +5,7 @@ using System.Text.RegularExpressions; using TUnit.Assertions.Chaining; using TUnit.Assertions.Conditions; +using TUnit.Assertions.Conditions.Wrappers; using TUnit.Assertions.Core; using TUnit.Assertions.Sources; @@ -881,6 +882,32 @@ public static StringLengthWithInlineAssertionAssertion Length( return new StringLengthWithInlineAssertionAssertion(source.Context, lengthAssertion); } + /// + /// Returns a wrapper for string length assertions. + /// Example: await Assert.That(str).HasLength().EqualTo(5); + /// + [Obsolete("Use Length() instead, which provides all numeric assertion methods. Example: Assert.That(str).Length().IsGreaterThan(5)")] + public static LengthWrapper HasLength( + this IAssertionSource source) + { + source.Context.ExpressionBuilder.Append(".HasLength()"); + return new LengthWrapper(source.Context); + } + + /// + /// Asserts that the string has the expected length. + /// Example: await Assert.That(str).HasLength(5); + /// + [Obsolete("Use Length().IsEqualTo(expectedLength) instead.")] + public static StringLengthAssertion HasLength( + this IAssertionSource source, + int expectedLength, + [CallerArgumentExpression(nameof(expectedLength))] string? expression = null) + { + source.Context.ExpressionBuilder.Append($".HasLength({expression})"); + return new StringLengthAssertion(source.Context, expectedLength); + } + /// /// Asserts that the value is structurally equivalent to the expected value. /// Performs deep comparison of properties and fields. diff --git a/TUnit.Assertions/Sources/CollectionAssertionBase.cs b/TUnit.Assertions/Sources/CollectionAssertionBase.cs index 00dc5d3d29..62447cf0ed 100644 --- a/TUnit.Assertions/Sources/CollectionAssertionBase.cs +++ b/TUnit.Assertions/Sources/CollectionAssertionBase.cs @@ -1,6 +1,7 @@ using System.Collections; using System.Runtime.CompilerServices; using TUnit.Assertions.Conditions; +using TUnit.Assertions.Conditions.Wrappers; using TUnit.Assertions.Core; namespace TUnit.Assertions.Sources; @@ -299,6 +300,32 @@ public CollectionHasAtMostAssertion HasAtMost( return new CollectionHasAtMostAssertion(Context, maxCount); } + /// + /// Asserts that the collection has the specified number of items. + /// This instance method enables calling HasCount with proper type inference. + /// Example: await Assert.That(list).HasCount(5); + /// + [Obsolete("Use Count().IsEqualTo(expectedCount) instead.")] + public CollectionCountAssertion HasCount( + int expectedCount, + [CallerArgumentExpression(nameof(expectedCount))] string? expression = null) + { + Context.ExpressionBuilder.Append($".HasCount({expression})"); + return new CollectionCountAssertion(Context, expectedCount); + } + + /// + /// Returns a wrapper for fluent count assertions. + /// This enables the pattern: .HasCount().GreaterThan(5) + /// Example: await Assert.That(list).HasCount().EqualTo(5); + /// + [Obsolete("Use Count() instead, which provides all numeric assertion methods. Example: Assert.That(list).Count().IsGreaterThan(5)")] + public CountWrapper HasCount() + { + Context.ExpressionBuilder.Append(".HasCount()"); + return new CountWrapper(Context); + } + /// /// Asserts that the collection count is between the specified minimum and maximum (inclusive). /// This instance method enables calling HasCountBetween with proper type inference. diff --git a/TUnit.Core/Contexts/TestRegisteredContext.cs b/TUnit.Core/Contexts/TestRegisteredContext.cs index 1ef19e648d..7c0c462760 100644 --- a/TUnit.Core/Contexts/TestRegisteredContext.cs +++ b/TUnit.Core/Contexts/TestRegisteredContext.cs @@ -25,6 +25,10 @@ public TestRegisteredContext(TestContext testContext) /// public ConcurrentDictionary StateBag => TestContext.StateBag.Items; + /// + [Obsolete("Use StateBag property instead.")] + public ConcurrentDictionary ObjectBag => StateBag; + /// /// Gets the test details from the underlying TestContext /// diff --git a/TUnit.Core/Interfaces/ITestOutput.cs b/TUnit.Core/Interfaces/ITestOutput.cs index 74aed40d9b..2546cf1674 100644 --- a/TUnit.Core/Interfaces/ITestOutput.cs +++ b/TUnit.Core/Interfaces/ITestOutput.cs @@ -20,12 +20,27 @@ public interface ITestOutput /// TextWriter ErrorOutput { get; } + /// + /// Gets the collection of timing measurements recorded during test execution. + /// Useful for performance profiling and identifying bottlenecks. + /// + [Obsolete("Use OpenTelemetry activity spans instead. Hook timings are now automatically recorded as OTel child spans of the test activity.")] + IReadOnlyCollection Timings { get; } + /// /// Gets the collection of artifacts (files, screenshots, logs) attached to this test. /// Artifacts are preserved after test execution for review and debugging. /// IReadOnlyCollection Artifacts { get; } + /// + /// Records a timing measurement for a specific operation or phase. + /// Thread-safe for concurrent calls. + /// + /// The timing information to record + [Obsolete("Use OpenTelemetry activity spans instead. Hook timings are now automatically recorded as OTel child spans of the test activity.")] + void RecordTiming(Timing timing); + /// /// Attaches an artifact (file, screenshot, log, etc.) to this test. /// Artifacts are preserved after test execution. 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.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..f06c61bcb3 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 @@ -1352,6 +1352,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 +1649,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 +1721,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 +2658,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..4fa9b816b6 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 @@ -1352,6 +1352,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 +1649,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 +1721,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 +2658,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..f9f244f3d2 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 @@ -1352,6 +1352,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 +1649,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 +1721,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 +2658,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..3993b342f2 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 @@ -1300,6 +1300,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 +1591,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 +1663,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 +2592,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); } From 08617403737b3540e0e68268a90eda3e9eb285e5 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Fri, 17 Apr 2026 21:35:02 +0100 Subject: [PATCH 16/17] feat: generalize OTLP receiver for use outside TUnit.Aspire (#5606) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: generalize OTLP receiver for use outside TUnit.Aspire Extracts OtlpReceiver + parsers from TUnit.Aspire to TUnit.OpenTelemetry and auto-starts them at test discovery. Out-of-process SUTs (spawned processes, testcontainers, external services) can now export spans into TUnit's HTML report via OTLP/HTTP without Aspire. ActivityCollector gains RegisterExternalTrace/IngestExternalSpan and a process-wide Current pointer so the receiver can route spans without explicit wiring. Unknown trace IDs are dropped; registered traces are capped at 100 external spans. Opt out with TUNIT_OTEL_RECEIVER=0. Adds OtlpTraceParser (field-by-field protobuf parser, no external dep) so incoming /v1/traces are ingested, not just forwarded. Closes #5595 * docs: address PR review on OTLP receiver generalization - Add OTLP proto spec links and field-name comments to OtlpTraceParser and OtlpLogParser so magic field numbers can be cross-referenced. - Make external span cap overridable via TUNIT_OTEL_MAX_EXTERNAL_SPANS env var and emit a one-time stderr warning when the cap is first hit. * refactor: tidy external span cap helpers - Move TUNIT_OTEL_MAX_EXTERNAL_SPANS into EnvironmentConstants so the env var name isn't duplicated across resolver and warning message. - Collapse the MaxExternalSpansPerTest/MaxExternalSpansPerTrace alias pair into a single MaxExternalSpans field — per-test vs per-trace distinction is already clear at the use sites. - Use Interlocked.CompareExchange for the one-shot warning latch so we don't re-write the flag on every dropped span once the cap is hit. * fix: correlate external spans regardless of hex case OTLP parser emitted uppercase hex IDs (via Convert.ToHexString) while System.Diagnostics.Activity serializes lowercase. That meant external spans landed in a separate trace bucket from in-process spans on the same logical trace, the per-test cap never matched a test case span ID, and users had to know to call .ToUpperInvariant() when registering traces manually. - Parser now emits lowercase via a HexLower helper (uses ToHexStringLower on net9+, falls back to ToLowerInvariant on net8) so IDs round-trip with Activity's format without caller ceremony. - All trace/span ID dictionaries in ActivityCollector are now OrdinalIgnoreCase as defense-in-depth against any remaining mixed-case caller. - SpanType no longer duplicates Name for external spans — it's a TUnit-only classifier and has no OTLP analogue. - Regression test IngestExternalSpan_TraceIdCaseMismatch_StillCorrelates. - Docs and existing tests updated to drop the now-unnecessary ToUpperInvariant. * chore: address PR review stragglers - Log parse failures in ProcessTraces/ProcessLogs via Trace.WriteLine instead of swallowing silently, matching the rest of OtlpReceiver's error paths. - Drop the [EditorBrowsable(Never)] attribute on the internal HasReceiverForTesting property — it's a no-op on internal members. - Expose ActivityCollector.MaxExternalSpans internal so the cap test reads the runtime value instead of a hardcoded 100, keeping it correct when TUNIT_OTEL_MAX_EXTERNAL_SPANS is set in the environment. * chore: update PublicAPI snapshots for OTLP receiver generalization New AutoReceiver class in TUnit.OpenTelemetry and InternalsVisibleTo entries for TUnit.OpenTelemetry.Tests/TUnit.Aspire.Tests. * fix: read proto fixed64 as uint64 before cast Matches proto semantics (fixed64 is unsigned). Bit pattern unchanged; this expresses intent and keeps the cast explicit at the call site. --- .../Helpers/OtlpTraceCaptureServer.cs | 40 +- TUnit.Aspire.Tests/OtlpLogParserTests.cs | 2 +- TUnit.Aspire.Tests/OtlpReceiverTests.cs | 2 +- TUnit.Aspire/AspireFixture.cs | 5 +- TUnit.Core/TUnit.Core.csproj | 2 + .../Configuration/EnvironmentConstants.cs | 1 + .../Reporters/Html/ActivityCollector.cs | 139 +++++- TUnit.Engine/TUnit.Engine.csproj | 2 + .../ActivityCollectorIngestionTests.cs | 117 +++++ .../AutoReceiverTests.cs | 20 + .../Helpers/OtlpTraceCaptureServer.cs | 164 +++++++ .../OtlpReceiverForwardingTests.cs | 69 +++ .../OtlpReceiverIngestionTests.cs | 50 ++ .../OtlpTraceParserTests.cs | 129 +++++ .../TUnit.OpenTelemetry.Tests.csproj | 1 + TUnit.OpenTelemetry/AutoReceiver.cs | 87 ++++ .../Receiver}/OtlpLogParser.cs | 20 +- .../Receiver}/OtlpReceiver.cs | 168 ++++++- .../Receiver/OtlpTraceParser.cs | 456 ++++++++++++++++++ .../TUnit.OpenTelemetry.csproj | 7 + ...Has_No_API_Changes.DotNet10_0.verified.txt | 2 + ..._Has_No_API_Changes.DotNet8_0.verified.txt | 2 + ..._Has_No_API_Changes.DotNet9_0.verified.txt | 2 + ...ary_Has_No_API_Changes.Net4_7.verified.txt | 2 + ...Has_No_API_Changes.DotNet10_0.verified.txt | 11 + ..._Has_No_API_Changes.DotNet8_0.verified.txt | 11 + ..._Has_No_API_Changes.DotNet9_0.verified.txt | 11 + docs/docs/guides/distributed-tracing.md | 28 +- 28 files changed, 1476 insertions(+), 74 deletions(-) create mode 100644 TUnit.OpenTelemetry.Tests/ActivityCollectorIngestionTests.cs create mode 100644 TUnit.OpenTelemetry.Tests/AutoReceiverTests.cs create mode 100644 TUnit.OpenTelemetry.Tests/Helpers/OtlpTraceCaptureServer.cs create mode 100644 TUnit.OpenTelemetry.Tests/OtlpReceiverForwardingTests.cs create mode 100644 TUnit.OpenTelemetry.Tests/OtlpReceiverIngestionTests.cs create mode 100644 TUnit.OpenTelemetry.Tests/OtlpTraceParserTests.cs create mode 100644 TUnit.OpenTelemetry/AutoReceiver.cs rename {TUnit.Aspire/Telemetry => TUnit.OpenTelemetry/Receiver}/OtlpLogParser.cs (91%) rename {TUnit.Aspire/Telemetry => TUnit.OpenTelemetry/Receiver}/OtlpReceiver.cs (63%) create mode 100644 TUnit.OpenTelemetry/Receiver/OtlpTraceParser.cs 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/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.Core/TUnit.Core.csproj b/TUnit.Core/TUnit.Core.csproj index 88433adfb8..7be70f84f3 100644 --- a/TUnit.Core/TUnit.Core.csproj +++ b/TUnit.Core/TUnit.Core.csproj @@ -9,6 +9,8 @@ + + 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/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/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 index 1b34a80380..37af3fafdf 100644 --- a/TUnit.OpenTelemetry.Tests/TUnit.OpenTelemetry.Tests.csproj +++ b/TUnit.OpenTelemetry.Tests/TUnit.OpenTelemetry.Tests.csproj @@ -19,6 +19,7 @@ + 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.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 index 9ea7132710..742894f576 100644 --- a/TUnit.OpenTelemetry/TUnit.OpenTelemetry.csproj +++ b/TUnit.OpenTelemetry/TUnit.OpenTelemetry.csproj @@ -7,8 +7,15 @@ Auto-wires an OpenTelemetry TracerProvider for TUnit test processes. Install to get distributed tracing with zero configuration. + + + + + + + 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 f06c61bcb3..90892f65f7 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 { 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 4fa9b816b6..53c33af04a 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 { 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 f9f244f3d2..852980045f 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 { 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 3993b342f2..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 { 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 index 988645ea32..1e34ac90b8 100644 --- 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 @@ -1,7 +1,18 @@ +[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; 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 index 77813e3a34..ce0c12b5e9 100644 --- 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 @@ -1,7 +1,18 @@ +[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; 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 index bbc727ddf5..ed3ca1b475 100644 --- 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 @@ -1,7 +1,18 @@ +[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; diff --git a/docs/docs/guides/distributed-tracing.md b/docs/docs/guides/distributed-tracing.md index be75cb986b..0b8016bb7a 100644 --- a/docs/docs/guides/distributed-tracing.md +++ b/docs/docs/guides/distributed-tracing.md @@ -154,6 +154,32 @@ protected override void ConfigureTestOptions(WebApplicationTestOptions options) `new HttpClient()` can't be intercepted. Either route through `IHttpClientFactory` or set the `traceparent` header manually. +## Capturing spans from out-of-process SUTs + +Install [`TUnit.OpenTelemetry`](/docs/examples/opentelemetry#option-a-zero-config-tunitopentelemetry) and an OTLP/HTTP receiver starts automatically at test discovery. Spawned child processes, testcontainers, or any SUT reachable from the test host can push spans into TUnit's HTML report by exporting OTLP to that receiver. + +Read the endpoint from `AutoReceiver.Endpoint` and plumb it into the SUT: + +```csharp +using TUnit.OpenTelemetry; + +var endpoint = AutoReceiver.Endpoint; // e.g. "http://127.0.0.1:41234" +process.StartInfo.EnvironmentVariables["OTEL_EXPORTER_OTLP_ENDPOINT"] = endpoint; +process.StartInfo.EnvironmentVariables["OTEL_EXPORTER_OTLP_PROTOCOL"] = "http/protobuf"; +``` + +For the receiver to associate incoming spans with the right test, register the SUT's trace ID before it runs: + +```csharp +using TUnit.Engine.Reporters.Html; + +ActivityCollector.Current?.RegisterExternalTrace(Activity.Current!.TraceId.ToString()); +``` + +Spans arriving on a trace ID that wasn't registered are dropped (protects the report from unrelated traffic on shared runners). Each registered trace is capped at 100 external spans. + +Opt out with `TUNIT_OTEL_RECEIVER=0`. + ## HTML report vs OpenTelemetry backends TUnit's HTML report and a backend like Seq render the same data differently: @@ -162,7 +188,7 @@ TUnit's HTML report and a backend like Seq render the same data differently: |--|-------------|------------------------| | Hierarchy | Folds each test under its class using span links | Each test is a separate trace | | Filtering | Built-in UI controls | Backend query language | -| Cross-service spans | Only what the engine sees in-process | Everything every exporter sends in | +| Cross-service spans | In-process by default; out-of-process SUTs can push spans via the [OTLP receiver](#capturing-spans-from-out-of-process-suts) | Everything every exporter sends in | | Persistence | One file per run | Long-term, queryable across runs | Use the HTML report for debugging a single run. Use a backend for run-over-run analysis and cross-service correlation. From 9e6ca6f0efaf4cdf2838ed9c9f30df1d44d326f6 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Fri, 17 Apr 2026 21:39:41 +0100 Subject: [PATCH 17/17] +semver:minor - feat: auto-configure OpenTelemetry in TestWebApplicationFactory SUT (#5607) * feat: auto-configure OpenTelemetry in TestWebApplicationFactory SUT (#5594) TestWebApplicationFactory now augments the SUT's TracerProvider with TUnit's AspNetCore activity source, TUnitTestCorrelationProcessor, and ASP.NET Core + HttpClient instrumentation. Spans emitted inside the SUT stay queryable per-test (tunit.test.id tag) even when third-party libs break the parent chain. Opt out per-test with WebApplicationTestOptions.AutoConfigureOpenTelemetry = false. TUnitTestCorrelationProcessor moves from TUnit.OpenTelemetry to TUnit.Core (NET-only) so the AspNetCore wrapper can reference it without pulling in the zero-config package. Public namespace (TUnit.OpenTelemetry) is unchanged. * refactor: keep TUnit.Core OTel-free; ref TUnit.OpenTelemetry from AspNetCore.Core Addresses PR review: TUnit.Core should not own an OpenTelemetry dependency (every TUnit user would get OTel transitively). Revert the processor move: TUnitTestCorrelationProcessor stays in TUnit.OpenTelemetry, and TUnit.AspNetCore.Core references that project directly. Also document zero-config + SUT double-processor safety on the helper. * test: update Core public API snapshot for AspNetCoreHttpSourceName Adds the newly introduced public const on TUnitActivitySource to the verified PublicAPI snapshots for net8.0, net9.0, and net10.0. --- .../TUnit.AspNetCore.Core.csproj | 7 ++ .../TestWebApplicationFactory.cs | 25 +++++++ .../WebApplicationTestOptions.cs | 18 +++++ .../AutoConfigureOpenTelemetryTests.cs | 72 +++++++++++++++++++ .../TUnit.AspNetCore.Tests.csproj | 4 ++ TUnit.Core/TUnitActivitySource.cs | 7 ++ ...Has_No_API_Changes.DotNet10_0.verified.txt | 1 + ..._Has_No_API_Changes.DotNet8_0.verified.txt | 1 + ..._Has_No_API_Changes.DotNet9_0.verified.txt | 1 + docs/docs/examples/aspnet.md | 1 + docs/docs/examples/opentelemetry.md | 11 ++- docs/docs/guides/distributed-tracing.md | 6 ++ 12 files changed, 152 insertions(+), 2 deletions(-) create mode 100644 TUnit.AspNetCore.Tests/AutoConfigureOpenTelemetryTests.cs diff --git a/TUnit.AspNetCore.Core/TUnit.AspNetCore.Core.csproj b/TUnit.AspNetCore.Core/TUnit.AspNetCore.Core.csproj index 9f9f7bc802..4daa3782ed 100644 --- a/TUnit.AspNetCore.Core/TUnit.AspNetCore.Core.csproj +++ b/TUnit.AspNetCore.Core/TUnit.AspNetCore.Core.csproj @@ -16,10 +16,17 @@ + + + + + + + diff --git a/TUnit.AspNetCore.Core/TestWebApplicationFactory.cs b/TUnit.AspNetCore.Core/TestWebApplicationFactory.cs index 3bc0e810c3..12af759107 100644 --- a/TUnit.AspNetCore.Core/TestWebApplicationFactory.cs +++ b/TUnit.AspNetCore.Core/TestWebApplicationFactory.cs @@ -6,11 +6,13 @@ 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; @@ -50,6 +52,11 @@ public WebApplicationFactory GetIsolatedFactory( services.TryAddEnumerable( ServiceDescriptor.Singleton()); } + + if (options.AutoConfigureOpenTelemetry) + { + AddTUnitOpenTelemetry(services); + } }); if (options.EnableHttpExchangeCapture) @@ -94,6 +101,24 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) }); } + /// + /// 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 diff --git a/TUnit.AspNetCore.Core/WebApplicationTestOptions.cs b/TUnit.AspNetCore.Core/WebApplicationTestOptions.cs index 91050dd0fc..2c924f79df 100644 --- a/TUnit.AspNetCore.Core/WebApplicationTestOptions.cs +++ b/TUnit.AspNetCore.Core/WebApplicationTestOptions.cs @@ -22,4 +22,22 @@ public record WebApplicationTestOptions /// /// 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/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/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.Core/TUnitActivitySource.cs b/TUnit.Core/TUnitActivitySource.cs index 9440d0009a..5f547cb713 100644 --- a/TUnit.Core/TUnitActivitySource.cs +++ b/TUnit.Core/TUnitActivitySource.cs @@ -12,6 +12,13 @@ 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"; 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 90892f65f7..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 @@ -1332,6 +1332,7 @@ namespace } public static class TUnitActivitySource { + public const string AspNetCoreHttpSourceName = ".Http"; public const string TagTestId = ".id"; } public class TUnitAttribute : { } 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 53c33af04a..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 @@ -1332,6 +1332,7 @@ namespace } public static class TUnitActivitySource { + public const string AspNetCoreHttpSourceName = ".Http"; public const string TagTestId = ".id"; } public class TUnitAttribute : { } 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 852980045f..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 @@ -1332,6 +1332,7 @@ namespace } public static class TUnitActivitySource { + public const string AspNetCoreHttpSourceName = ".Http"; public const string TagTestId = ".id"; } public class TUnitAttribute : { } diff --git a/docs/docs/examples/aspnet.md b/docs/docs/examples/aspnet.md index c58e88bb21..568daf2871 100644 --- a/docs/docs/examples/aspnet.md +++ b/docs/docs/examples/aspnet.md @@ -86,6 +86,7 @@ public class TodoApiTests : TestsBase - **Client-side tracing**: `CreateClient()` / `CreateDefaultClient()` return an `HttpClient` that propagates `traceparent`, `baggage`, and `X-TUnit-TestId` headers to the SUT. - **SUT `IHttpClientFactory` tracing**: Every pipeline built inside the SUT via `AddHttpClient()`, named clients, or typed clients also gets those headers prepended — outbound calls from your app to downstream services correlate with the originating test. Opt out per-test with `WebApplicationTestOptions.AutoPropagateHttpClientFactory = false`. +- **SUT-side OpenTelemetry**: The SUT's `TracerProvider` is augmented with the `TUnit.AspNetCore.Http` activity source, the `TUnitTestCorrelationProcessor` (stamps the `tunit.test.id` baggage item onto every span as a tag), and ASP.NET Core + HttpClient instrumentation. Spans emitted inside the SUT stay queryable per-test in backends like Jaeger or Seq, even when third-party libraries break the parent-chain. Opt out per-test with `WebApplicationTestOptions.AutoConfigureOpenTelemetry = false`. - **Correlated logging**: Server-side `ILogger` output is routed to the test that triggered the request. - **Hosted-service context hygiene**: `IHostedService.StartAsync` runs under `ExecutionContext.SuppressFlow()` so background work doesn't inherit the first test's `Activity.Current`. diff --git a/docs/docs/examples/opentelemetry.md b/docs/docs/examples/opentelemetry.md index 099b6d6c8c..f0f09d75e1 100644 --- a/docs/docs/examples/opentelemetry.md +++ b/docs/docs/examples/opentelemetry.md @@ -258,8 +258,15 @@ dotnet add package OpenTelemetry.Exporter.Zipkin If you use `TestWebApplicationFactory` or `TracedWebApplicationFactory`, outgoing requests automatically propagate the current test trace via W3C `traceparent` and `baggage` headers. -Add `"TUnit.AspNetCore.Http"` as a source only if you also want TUnit's synthetic client spans -to appear in your exporter. Header propagation works either way. +The factory also augments the SUT's `TracerProvider` automatically — no manual `services.AddOpenTelemetry().WithTracing(...)` wiring is needed for the basics: + +- Registers the `TUnit.AspNetCore.Http` activity source. +- Adds the `TUnitTestCorrelationProcessor` so spans from libraries with broken parent chains are still tagged with `tunit.test.id`. +- Adds ASP.NET Core and HttpClient instrumentation. + +Your own `WithTracing` callback on the SUT is preserved; TUnit's defaults are layered on top. If you configure your own exporter (OTLP, Jaeger, Zipkin, in-memory), test spans flow straight through it. + +Set `WebApplicationTestOptions.AutoConfigureOpenTelemetry = false` per-test to opt out — useful if the SUT owns its own processors and you don't want TUnit's defaults layered on top. ## Test Context Correlation via Activity Baggage diff --git a/docs/docs/guides/distributed-tracing.md b/docs/docs/guides/distributed-tracing.md index 0b8016bb7a..bf911919c9 100644 --- a/docs/docs/guides/distributed-tracing.md +++ b/docs/docs/guides/distributed-tracing.md @@ -150,6 +150,12 @@ protected override void ConfigureTestOptions(WebApplicationTestOptions options) } ``` +### SUT-side OpenTelemetry wiring + +`TestWebApplicationFactory` also augments the SUT's `TracerProvider` automatically — the `TUnit.AspNetCore.Http` activity source, the `TUnitTestCorrelationProcessor`, and ASP.NET Core + HttpClient instrumentation are layered on top of whatever `AddOpenTelemetry().WithTracing(...)` wiring the SUT already has. That means spans emitted inside the SUT stay queryable per-test (`tunit.test.id` tag) even when third-party libraries break the parent chain. + +Opt out per-test with `WebApplicationTestOptions.AutoConfigureOpenTelemetry = false` when the SUT owns its own processors and you don't want TUnit's defaults layered on top. + ### Raw `HttpClient` `new HttpClient()` can't be intercepted. Either route through `IHttpClientFactory` or set the `traceparent` header manually.