From 929e54a465aa2fcee788fdd1cf65debc61fe90c3 Mon Sep 17 00:00:00 2001 From: Steven Date: Sun, 26 Oct 2025 01:36:16 +0200 Subject: [PATCH 01/67] [Docs] Add section on using return values from awaited assertions (#3523) * Initial plan * Add comprehensive section on using return values from awaited assertions Co-authored-by: sliekens <1583241+sliekens@users.noreply.github.com> * Simplify return values section to keep only type casting example Co-authored-by: sliekens <1583241+sliekens@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: sliekens <1583241+sliekens@users.noreply.github.com> --- docs/docs/assertions/awaiting.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/docs/docs/assertions/awaiting.md b/docs/docs/assertions/awaiting.md index e87dec0060..2185db045c 100644 --- a/docs/docs/assertions/awaiting.md +++ b/docs/docs/assertions/awaiting.md @@ -36,6 +36,29 @@ This won't: TUnit is able to take in asynchronous delegates. To be able to assert on these, we need to execute the code. We want to avoid sync-over-async, as this can cause problems and block the thread pool, slowing down your test suite. And with how fast .NET has become, the overhead of `Task`s and `async` methods shouldn't be noticeable. +## Using Return Values from Awaited Assertions + +When you `await` an assertion in TUnit, it returns a reference to the subject that was asserted on. This allows you to capture the validated value and use it in subsequent operations or assertions, creating a fluent and readable test flow. + +### Type Casting with Confidence + +```csharp +[Test] +public async Task CastAndUseSpecificType() +{ + object shape = new Circle { Radius = 5.0 }; + + // Assert type and capture strongly-typed reference + var circle = await Assert.That(shape).IsTypeOf(); + + // Now you can use circle-specific properties without casting + await Assert.That(circle.Radius).IsEqualTo(5.0); + + var area = Math.PI * circle.Radius * circle.Radius; + await Assert.That(area).IsEqualTo(Math.PI * 25).Within(0.0001); +} +``` + ## Complex Assertion Examples ### Chaining Multiple Assertions From 4ecce4a9560f691caf1c161237d709214ee973f6 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 26 Oct 2025 00:59:34 +0100 Subject: [PATCH 02/67] chore(deps): update tunit to 0.76.18 (#3524) Co-authored-by: Renovate Bot --- Directory.Packages.props | 6 +++--- .../TUnit.AspNet.FSharp/TestProject/TestProject.fsproj | 4 ++-- .../content/TUnit.AspNet/TestProject/TestProject.csproj | 2 +- .../ExampleNamespace.TestProject.csproj | 2 +- .../content/TUnit.Aspire.Test/ExampleNamespace.csproj | 2 +- TUnit.Templates/content/TUnit.FSharp/TestProject.fsproj | 4 ++-- TUnit.Templates/content/TUnit.Playwright/TestProject.csproj | 2 +- TUnit.Templates/content/TUnit.VB/TestProject.vbproj | 2 +- TUnit.Templates/content/TUnit/TestProject.csproj | 2 +- 9 files changed, 13 insertions(+), 13 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 09b555dc41..fcef98b852 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -83,9 +83,9 @@ - - - + + + diff --git a/TUnit.Templates/content/TUnit.AspNet.FSharp/TestProject/TestProject.fsproj b/TUnit.Templates/content/TUnit.AspNet.FSharp/TestProject/TestProject.fsproj index 1e789f6706..8e18828a2d 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 82beefd563..ba023fd72f 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.Aspire.Starter/ExampleNamespace.TestProject/ExampleNamespace.TestProject.csproj b/TUnit.Templates/content/TUnit.Aspire.Starter/ExampleNamespace.TestProject/ExampleNamespace.TestProject.csproj index 2a5e943d43..db66778c00 100644 --- a/TUnit.Templates/content/TUnit.Aspire.Starter/ExampleNamespace.TestProject/ExampleNamespace.TestProject.csproj +++ b/TUnit.Templates/content/TUnit.Aspire.Starter/ExampleNamespace.TestProject/ExampleNamespace.TestProject.csproj @@ -11,7 +11,7 @@ - + diff --git a/TUnit.Templates/content/TUnit.Aspire.Test/ExampleNamespace.csproj b/TUnit.Templates/content/TUnit.Aspire.Test/ExampleNamespace.csproj index c5bc9ec8ea..75439e4d14 100644 --- a/TUnit.Templates/content/TUnit.Aspire.Test/ExampleNamespace.csproj +++ b/TUnit.Templates/content/TUnit.Aspire.Test/ExampleNamespace.csproj @@ -10,7 +10,7 @@ - + diff --git a/TUnit.Templates/content/TUnit.FSharp/TestProject.fsproj b/TUnit.Templates/content/TUnit.FSharp/TestProject.fsproj index b3ae846a8b..c739525068 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 0f43a16a0c..077fb150fd 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 c9045808f4..7076083044 100644 --- a/TUnit.Templates/content/TUnit.VB/TestProject.vbproj +++ b/TUnit.Templates/content/TUnit.VB/TestProject.vbproj @@ -8,6 +8,6 @@ - + diff --git a/TUnit.Templates/content/TUnit/TestProject.csproj b/TUnit.Templates/content/TUnit/TestProject.csproj index 30677d21dc..53f327da27 100644 --- a/TUnit.Templates/content/TUnit/TestProject.csproj +++ b/TUnit.Templates/content/TUnit/TestProject.csproj @@ -8,7 +8,7 @@ - + \ No newline at end of file From ac9c45eea433ffd5f63818456aa82cd920f94736 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 26 Oct 2025 10:29:47 +0000 Subject: [PATCH 03/67] chore(deps): update dependency verify to 31.0.5 (#3525) 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 fcef98b852..e0906879b3 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -81,7 +81,7 @@ - + From ee8a2620d633ebee2197da6691cfa4aa6ab7b6af Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 26 Oct 2025 11:01:40 +0000 Subject: [PATCH 04/67] chore(deps): update dependency verify.nunit to 31.0.5 (#3526) 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 e0906879b3..15c1380f16 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -82,7 +82,7 @@ - + From 74b0eb403ce32e5d7c4af79c3a67ddeb7eb3c6fe Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 26 Oct 2025 11:31:24 +0000 Subject: [PATCH 05/67] chore(deps): update dependency verify.tunit to 31.0.5 (#3527) 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 15c1380f16..e903eca582 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -86,7 +86,7 @@ - + From 80931a6a31a2f882cef0045e4297a9505f9b9314 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 26 Oct 2025 15:56:27 +0000 Subject: [PATCH 06/67] feat: add IsNotNullAssertionSuppressor to suppress nullability warnings after non-null assertions (#3506) * feat: add IsNotNullAssertionSuppressor to suppress nullability warnings after non-null assertions * feat: add NotNull and Null assertion methods to enhance nullability checks * feat: suppress analyzer release tracking warnings in compilation options * feat: implement IsNotNullAssertionSuppressor to suppress nullability warnings after non-null assertions * feat: enhance analyzer test helpers to support dynamic reference assemblies based on current framework version * feat: refactor analyzer test helpers to use framework-specific reference assemblies * feat: enhance IsNotNullAssertionSuppressor to support Assert.That() chaining --- .../TUnit.Analyzers.Tests.csproj | 12 +- .../Verifiers/CSharpAnalyzerVerifier.cs | 7 +- .../Verifiers/CSharpCodeFixVerifier.cs | 9 +- .../CSharpCodeRefactoringVerifier.cs | 8 +- .../AnalyzerTestHelpers.cs | 186 ++++++++ .../IsNotNullAssertionSuppressorTests.cs | 425 ++++++++++++++++++ .../IsNotNullAssertionSuppressor.cs | 242 ++++++++++ TUnit.Assertions.Tests/AssertNotNullTests.cs | 149 ++++++ TUnit.Assertions/Extensions/Assert.cs | 79 +++- ...Has_No_API_Changes.DotNet10_0.verified.txt | 8 + ..._Has_No_API_Changes.DotNet8_0.verified.txt | 8 + ..._Has_No_API_Changes.DotNet9_0.verified.txt | 8 + ...ary_Has_No_API_Changes.Net4_7.verified.txt | 8 + 13 files changed, 1138 insertions(+), 11 deletions(-) create mode 100644 TUnit.Assertions.Analyzers.Tests/AnalyzerTestHelpers.cs create mode 100644 TUnit.Assertions.Analyzers.Tests/IsNotNullAssertionSuppressorTests.cs create mode 100644 TUnit.Assertions.Analyzers/IsNotNullAssertionSuppressor.cs create mode 100644 TUnit.Assertions.Tests/AssertNotNullTests.cs diff --git a/TUnit.Analyzers.Tests/TUnit.Analyzers.Tests.csproj b/TUnit.Analyzers.Tests/TUnit.Analyzers.Tests.csproj index 4118449776..dabf679d09 100644 --- a/TUnit.Analyzers.Tests/TUnit.Analyzers.Tests.csproj +++ b/TUnit.Analyzers.Tests/TUnit.Analyzers.Tests.csproj @@ -22,20 +22,20 @@ - - + + - - + + - - + + diff --git a/TUnit.Analyzers.Tests/Verifiers/CSharpAnalyzerVerifier.cs b/TUnit.Analyzers.Tests/Verifiers/CSharpAnalyzerVerifier.cs index b1268538d6..296fc46c1c 100644 --- a/TUnit.Analyzers.Tests/Verifiers/CSharpAnalyzerVerifier.cs +++ b/TUnit.Analyzers.Tests/Verifiers/CSharpAnalyzerVerifier.cs @@ -1,3 +1,4 @@ +using System.Collections.Immutable; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Testing; @@ -42,7 +43,11 @@ public Test() } compilationOptions = compilationOptions - .WithSpecificDiagnosticOptions(compilationOptions.SpecificDiagnosticOptions.SetItems(CSharpVerifierHelper.NullableWarnings)); + .WithSpecificDiagnosticOptions(compilationOptions.SpecificDiagnosticOptions + .SetItems(CSharpVerifierHelper.NullableWarnings) + // Suppress analyzer release tracking warnings - we're testing TUnit analyzers, not release tracking + .SetItem("RS2007", ReportDiagnostic.Suppress) + .SetItem("RS2008", ReportDiagnostic.Suppress)); solution = solution.WithProjectCompilationOptions(projectId, compilationOptions) .WithProjectParseOptions(projectId, parseOptions diff --git a/TUnit.Analyzers.Tests/Verifiers/CSharpCodeFixVerifier.cs b/TUnit.Analyzers.Tests/Verifiers/CSharpCodeFixVerifier.cs index 79656d8ff4..3683793e41 100644 --- a/TUnit.Analyzers.Tests/Verifiers/CSharpCodeFixVerifier.cs +++ b/TUnit.Analyzers.Tests/Verifiers/CSharpCodeFixVerifier.cs @@ -1,3 +1,5 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CodeFixes; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Testing; @@ -35,7 +37,12 @@ public Test() return solution; } - compilationOptions = compilationOptions.WithSpecificDiagnosticOptions(compilationOptions.SpecificDiagnosticOptions.SetItems(CSharpVerifierHelper.NullableWarnings)); + compilationOptions = compilationOptions + .WithSpecificDiagnosticOptions(compilationOptions.SpecificDiagnosticOptions + .SetItems(CSharpVerifierHelper.NullableWarnings) + // Suppress analyzer release tracking warnings - we're testing TUnit analyzers, not release tracking + .SetItem("RS2007", ReportDiagnostic.Suppress) + .SetItem("RS2008", ReportDiagnostic.Suppress)); solution = solution.WithProjectCompilationOptions(projectId, compilationOptions) .WithProjectParseOptions(projectId, parseOptions.WithLanguageVersion(LanguageVersion.Preview)); diff --git a/TUnit.Analyzers.Tests/Verifiers/CSharpCodeRefactoringVerifier.cs b/TUnit.Analyzers.Tests/Verifiers/CSharpCodeRefactoringVerifier.cs index 017501bcdf..18db324888 100644 --- a/TUnit.Analyzers.Tests/Verifiers/CSharpCodeRefactoringVerifier.cs +++ b/TUnit.Analyzers.Tests/Verifiers/CSharpCodeRefactoringVerifier.cs @@ -1,3 +1,4 @@ +using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CodeRefactorings; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Testing; @@ -33,7 +34,12 @@ public Test() return solution; } - compilationOptions = compilationOptions.WithSpecificDiagnosticOptions(compilationOptions.SpecificDiagnosticOptions.SetItems(CSharpVerifierHelper.NullableWarnings)); + compilationOptions = compilationOptions + .WithSpecificDiagnosticOptions(compilationOptions.SpecificDiagnosticOptions + .SetItems(CSharpVerifierHelper.NullableWarnings) + // Suppress analyzer release tracking warnings - we're testing TUnit analyzers, not release tracking + .SetItem("RS2007", ReportDiagnostic.Suppress) + .SetItem("RS2008", ReportDiagnostic.Suppress)); solution = solution.WithProjectCompilationOptions(projectId, compilationOptions) .WithProjectParseOptions(projectId, parseOptions.WithLanguageVersion(LanguageVersion.Preview)); diff --git a/TUnit.Assertions.Analyzers.Tests/AnalyzerTestHelpers.cs b/TUnit.Assertions.Analyzers.Tests/AnalyzerTestHelpers.cs new file mode 100644 index 0000000000..58efd18d48 --- /dev/null +++ b/TUnit.Assertions.Analyzers.Tests/AnalyzerTestHelpers.cs @@ -0,0 +1,186 @@ +using System.Collections.Immutable; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Testing; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Testing; + +namespace TUnit.Assertions.Analyzers.Tests; + +public static class AnalyzerTestHelpers +{ + public static CSharpAnalyzerTest CreateAnalyzerTest( + [StringSyntax("c#-test")] string inputSource + ) + where TAnalyzer : DiagnosticAnalyzer, new() + { + var csTest = new CSharpAnalyzerTest + { + TestState = + { + Sources = { inputSource }, + ReferenceAssemblies = new ReferenceAssemblies( + "net8.0", + new PackageIdentity( + "Microsoft.NETCore.App.Ref", + "8.0.0"), + Path.Combine("ref", "net8.0")), + }, + }; + + csTest.TestState.AdditionalReferences + .AddRange( + [ + MetadataReference.CreateFromFile(typeof(TUnitAttribute).Assembly.Location), + MetadataReference.CreateFromFile(typeof(Assert).Assembly.Location), + ] + ); + + return csTest; + } + + public sealed class CSharpSuppressorTest : CSharpAnalyzerTest + where TSuppressor : DiagnosticSuppressor, new() + where TVerifier : IVerifier, new() + { + private readonly List _analyzers = []; + + protected override IEnumerable GetDiagnosticAnalyzers() + { + return base.GetDiagnosticAnalyzers().Concat(_analyzers); + } + + public CSharpSuppressorTest WithAnalyzer(bool enableDiagnostics = false) + where TAnalyzer : DiagnosticAnalyzer, new() + { + var analyzer = new TAnalyzer(); + _analyzers.Add(analyzer); + + if (enableDiagnostics) + { + var diagnosticOptions = analyzer.SupportedDiagnostics + .ToImmutableDictionary( + descriptor => descriptor.Id, + descriptor => descriptor.DefaultSeverity.ToReportDiagnostic() + ); + + SolutionTransforms.Clear(); + SolutionTransforms.Add(EnableDiagnostics(diagnosticOptions)); + } + + return this; + } + + public CSharpSuppressorTest WithSpecificDiagnostics( + params DiagnosticResult[] diagnostics + ) + { + var diagnosticOptions = diagnostics + .ToImmutableDictionary( + descriptor => descriptor.Id, + descriptor => descriptor.Severity.ToReportDiagnostic() + ); + + SolutionTransforms.Clear(); + SolutionTransforms.Add(EnableDiagnostics(diagnosticOptions)); + return this; + } + + private static Func EnableDiagnostics( + ImmutableDictionary diagnostics + ) => + (solution, id) => + { + var options = solution.GetProject(id)?.CompilationOptions + ?? throw new InvalidOperationException("Compilation options missing."); + + return solution + .WithProjectCompilationOptions( + id, + options + .WithSpecificDiagnosticOptions(diagnostics) + ); + }; + + public CSharpSuppressorTest WithExpectedDiagnosticsResults( + params DiagnosticResult[] diagnostics + ) + { + ExpectedDiagnostics.AddRange(diagnostics); + return this; + } + + public CSharpSuppressorTest WithCompilerDiagnostics( + CompilerDiagnostics diagnostics + ) + { + CompilerDiagnostics = diagnostics; + return this; + } + + public CSharpSuppressorTest IgnoringDiagnostics(params string[] diagnostics) + { + DisabledDiagnostics.AddRange(diagnostics); + return this; + } + } + + public static CSharpSuppressorTest CreateSuppressorTest( + [StringSyntax("c#-test")] string inputSource + ) + where TSuppressor : DiagnosticSuppressor, new() + { + var test = new CSharpSuppressorTest + { + TestCode = inputSource, + ReferenceAssemblies = GetReferenceAssemblies() + }; + + test.TestState.AdditionalReferences + .AddRange([ + MetadataReference.CreateFromFile(typeof(TUnitAttribute).Assembly.Location), + MetadataReference.CreateFromFile(typeof(Assert).Assembly.Location), + ]); + + return test; + } + + private static ReferenceAssemblies GetReferenceAssemblies() + { +#if NET472 + return ReferenceAssemblies.NetFramework.Net472.Default; +#elif NET8_0 + return ReferenceAssemblies.Net.Net80; +#elif NET9_0 + return ReferenceAssemblies.Net.Net90; +#elif NET10_0_OR_GREATER + return ReferenceAssemblies.Net.Net90; +#else + return ReferenceAssemblies.Net.Net80; // Default fallback +#endif + } + + public static CSharpSuppressorTest CreateSuppressorTest( + [StringSyntax("c#-test")] string inputSource + ) + where TSuppressor : DiagnosticSuppressor, new() + where TAnalyzer : DiagnosticAnalyzer, new() + { + return CreateSuppressorTest(inputSource) + .WithAnalyzer(enableDiagnostics: true); + } +} + +static file class DiagnosticSeverityExtensions +{ + public static ReportDiagnostic ToReportDiagnostic(this DiagnosticSeverity severity) + => severity switch + { + DiagnosticSeverity.Hidden => ReportDiagnostic.Hidden, + DiagnosticSeverity.Info => ReportDiagnostic.Info, + DiagnosticSeverity.Warning => ReportDiagnostic.Warn, + DiagnosticSeverity.Error => ReportDiagnostic.Error, + _ => throw new InvalidEnumArgumentException(nameof(severity), (int) severity, typeof(DiagnosticSeverity)), + }; +} diff --git a/TUnit.Assertions.Analyzers.Tests/IsNotNullAssertionSuppressorTests.cs b/TUnit.Assertions.Analyzers.Tests/IsNotNullAssertionSuppressorTests.cs new file mode 100644 index 0000000000..28e196efff --- /dev/null +++ b/TUnit.Assertions.Analyzers.Tests/IsNotNullAssertionSuppressorTests.cs @@ -0,0 +1,425 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Testing; +using TUnit.Core; + +namespace TUnit.Assertions.Analyzers.Tests; + +/// +/// Tests for the IsNotNullAssertionSuppressor which suppresses nullability warnings +/// (CS8600, CS8602, CS8604, CS8618) for variables after Assert.That(x).IsNotNull(). +/// +/// Note: These tests verify that the suppressor correctly identifies and suppresses +/// nullability warnings. The suppressor does not change null-state flow analysis, +/// only suppresses the resulting warnings. +/// +public class IsNotNullAssertionSuppressorTests +{ + private static readonly DiagnosticResult CS8602 = new("CS8602", DiagnosticSeverity.Warning); + private static readonly DiagnosticResult CS8604 = new("CS8604", DiagnosticSeverity.Warning); + + [Test] + public async Task Suppresses_CS8602_After_IsNotNull_Assertion() + { + const string code = """ + #nullable enable + using System.Threading.Tasks; + using TUnit.Assertions; + using TUnit.Assertions.Extensions; + + public class MyTests + { + public async Task TestMethod() + { + string? nullableString = GetNullableString(); + + await Assert.That(nullableString).IsNotNull(); + + // This would normally produce CS8602: Dereference of a possibly null reference + // But the suppressor should suppress it after IsNotNull assertion + var length = {|#0:nullableString|}.Length; + } + + private string? GetNullableString() => "test"; + } + """; + + await AnalyzerTestHelpers + .CreateSuppressorTest(code) + .IgnoringDiagnostics("CS1591") + .WithSpecificDiagnostics(CS8602) + .WithExpectedDiagnosticsResults(CS8602.WithLocation(0).WithIsSuppressed(true)) + .WithCompilerDiagnostics(CompilerDiagnostics.Warnings) + .RunAsync(); + } + + [Test] + public async Task Suppresses_CS8604_After_IsNotNull_Assertion() + { + const string code = """ + #nullable enable + using System.Threading.Tasks; + using TUnit.Assertions; + using TUnit.Assertions.Extensions; + + public class MyTests + { + public async Task TestMethod() + { + string? nullableString = GetNullableString(); + + await Assert.That(nullableString).IsNotNull(); + + // This would normally produce CS8604: Possible null reference argument + // But the suppressor should suppress it after IsNotNull assertion + AcceptsNonNull({|#0:nullableString|}); + } + + private void AcceptsNonNull(string nonNull) { } + private string? GetNullableString() => "test"; + } + """; + + await AnalyzerTestHelpers + .CreateSuppressorTest(code) + .IgnoringDiagnostics("CS1591") + .WithSpecificDiagnostics(CS8604) + .WithExpectedDiagnosticsResults(CS8604.WithLocation(0).WithIsSuppressed(true)) + .WithCompilerDiagnostics(CompilerDiagnostics.Warnings) + .RunAsync(); + } + + [Test] + public async Task Does_Not_Suppress_Without_IsNotNull_Assertion() + { + const string code = """ + #nullable enable + using System.Threading.Tasks; + using TUnit.Assertions; + using TUnit.Assertions.Extensions; + + public class MyTests + { + public void TestMethod() + { + string? nullableString = GetNullableString(); + + // No IsNotNull assertion here + + // This should still produce CS8602 warning + var length = {|#0:nullableString|}.Length; + } + + private string? GetNullableString() => "test"; + } + """; + + await AnalyzerTestHelpers + .CreateSuppressorTest(code) + .IgnoringDiagnostics("CS1591") + .WithSpecificDiagnostics(CS8602) + .WithExpectedDiagnosticsResults(CS8602.WithLocation(0).WithIsSuppressed(false)) + .WithCompilerDiagnostics(CompilerDiagnostics.Warnings) + .RunAsync(); + } + + [Test] + public async Task Suppresses_Multiple_Uses_After_IsNotNull() + { + const string code = """ + #nullable enable + using System.Threading.Tasks; + using TUnit.Assertions; + using TUnit.Assertions.Extensions; + + public class MyTests + { + public async Task TestMethod() + { + string? nullableString = GetNullableString(); + + await Assert.That(nullableString).IsNotNull(); + + // Multiple uses should all be suppressed + var length = {|#0:nullableString|}.Length; + var upper = {|#1:nullableString|}.ToUpper(); + AcceptsNonNull({|#2:nullableString|}); + } + + private void AcceptsNonNull(string nonNull) { } + private string? GetNullableString() => "test"; + } + """; + + await AnalyzerTestHelpers + .CreateSuppressorTest(code) + .IgnoringDiagnostics("CS1591") + .WithSpecificDiagnostics(CS8602) + .WithExpectedDiagnosticsResults( + // Only the first usage generates a warning; subsequent uses benefit from flow analysis + CS8602.WithLocation(0).WithIsSuppressed(true) + ) + .WithCompilerDiagnostics(CompilerDiagnostics.Warnings) + .RunAsync(); + } + + [Test] + public async Task Suppresses_Only_Asserted_Variable() + { + const string code = """ + #nullable enable + using System.Threading.Tasks; + using TUnit.Assertions; + using TUnit.Assertions.Extensions; + + public class MyTests + { + public async Task TestMethod() + { + string? nullableString1 = GetNullableString(); + string? nullableString2 = GetNullableString(); + + await Assert.That(nullableString1).IsNotNull(); + + // nullableString1 should be suppressed + var length1 = {|#0:nullableString1|}.Length; + + // nullableString2 should NOT be suppressed (not asserted) + var length2 = {|#1:nullableString2|}.Length; + } + + private string? GetNullableString() => "test"; + } + """; + + await AnalyzerTestHelpers + .CreateSuppressorTest(code) + .IgnoringDiagnostics("CS1591") + .WithSpecificDiagnostics(CS8602) + .WithExpectedDiagnosticsResults( + CS8602.WithLocation(0).WithIsSuppressed(true), + CS8602.WithLocation(1).WithIsSuppressed(false) + ) + .WithCompilerDiagnostics(CompilerDiagnostics.Warnings) + .RunAsync(); + } + + [Test] + public async Task Suppresses_Property_Access_Chain() + { + const string code = """ + #nullable enable + using System.Threading.Tasks; + using TUnit.Assertions; + using TUnit.Assertions.Extensions; + + public class MyClass + { + public string? Property { get; set; } + } + + public class MyTests + { + public async Task TestMethod() + { + MyClass? obj = GetNullableObject(); + + await Assert.That(obj).IsNotNull(); + + // This should be suppressed + var prop = {|#0:obj|}.Property; + } + + private MyClass? GetNullableObject() => new MyClass(); + } + """; + + await AnalyzerTestHelpers + .CreateSuppressorTest(code) + .IgnoringDiagnostics("CS1591") + .WithSpecificDiagnostics(CS8602) + .WithExpectedDiagnosticsResults(CS8602.WithLocation(0).WithIsSuppressed(true)) + .WithCompilerDiagnostics(CompilerDiagnostics.Warnings) + .RunAsync(); + } + + [Test] + public async Task Suppresses_After_IsNotNull_At_Start_Of_Assertion_Chain() + { + const string code = """ + #nullable enable + using System.Threading.Tasks; + using TUnit.Assertions; + using TUnit.Assertions.Extensions; + + public class MyTests + { + public async Task TestMethod() + { + string? nullableString = GetNullableString(); + + // IsNotNull at the START of the chain + await Assert.That(nullableString).IsNotNull().And.Contains("test"); + + // After the assertion chain, should be suppressed + var length = {|#0:nullableString|}.Length; + } + + private string? GetNullableString() => "test"; + } + """; + + await AnalyzerTestHelpers + .CreateSuppressorTest(code) + .IgnoringDiagnostics("CS1591") + .WithSpecificDiagnostics(CS8602) + .WithExpectedDiagnosticsResults(CS8602.WithLocation(0).WithIsSuppressed(true)) + .WithCompilerDiagnostics(CompilerDiagnostics.Warnings) + .RunAsync(); + } + + [Test] + public async Task Suppresses_After_IsNotNull_At_End_Of_Assertion_Chain() + { + const string code = """ + #nullable enable + using System.Threading.Tasks; + using TUnit.Assertions; + using TUnit.Assertions.Extensions; + + public class MyTests + { + public async Task TestMethod() + { + string? nullableString = GetNullableString(); + + // IsNotNull at the END of the chain + await Assert.That(nullableString).Contains("test").And.IsNotNull(); + + // After the assertion chain, should be suppressed + var length = {|#0:nullableString|}.Length; + } + + private string? GetNullableString() => "test"; + } + """; + + await AnalyzerTestHelpers + .CreateSuppressorTest(code) + .IgnoringDiagnostics("CS1591") + .WithSpecificDiagnostics(CS8602) + .WithExpectedDiagnosticsResults(CS8602.WithLocation(0).WithIsSuppressed(true)) + .WithCompilerDiagnostics(CompilerDiagnostics.Warnings) + .RunAsync(); + } + + [Test] + public async Task Suppresses_After_IsNotNull_In_Middle_Of_Assertion_Chain() + { + const string code = """ + #nullable enable + using System.Threading.Tasks; + using TUnit.Assertions; + using TUnit.Assertions.Extensions; + + public class MyTests + { + public async Task TestMethod() + { + string? nullableString = GetNullableString(); + + // IsNotNull in the MIDDLE of the chain + await Assert.That(nullableString).Contains("t").And.IsNotNull().And.Contains("test"); + + // After the assertion chain, should be suppressed + var length = {|#0:nullableString|}.Length; + } + + private string? GetNullableString() => "test"; + } + """; + + await AnalyzerTestHelpers + .CreateSuppressorTest(code) + .IgnoringDiagnostics("CS1591") + .WithSpecificDiagnostics(CS8602) + .WithExpectedDiagnosticsResults(CS8602.WithLocation(0).WithIsSuppressed(true)) + .WithCompilerDiagnostics(CompilerDiagnostics.Warnings) + .RunAsync(); + } + + [Test] + public async Task Suppresses_After_IsNotNull_With_Or_Chain() + { + const string code = """ + #nullable enable + using System.Threading.Tasks; + using TUnit.Assertions; + using TUnit.Assertions.Extensions; + + public class MyTests + { + public async Task TestMethod() + { + string? nullableString = GetNullableString(); + + // IsNotNull with Or chain + await Assert.That(nullableString).IsNotNull().Or.IsEqualTo("fallback"); + + // After the assertion, should be suppressed + var length = {|#0:nullableString|}.Length; + } + + private string? GetNullableString() => "test"; + } + """; + + await AnalyzerTestHelpers + .CreateSuppressorTest(code) + .IgnoringDiagnostics("CS1591") + .WithSpecificDiagnostics(CS8602) + .WithExpectedDiagnosticsResults(CS8602.WithLocation(0).WithIsSuppressed(true)) + .WithCompilerDiagnostics(CompilerDiagnostics.Warnings) + .RunAsync(); + } + + [Test] + public async Task Suppresses_Multiple_Variables_With_Chained_Assertions() + { + const string code = """ + #nullable enable + using System.Threading.Tasks; + using TUnit.Assertions; + using TUnit.Assertions.Extensions; + + public class MyTests + { + public async Task TestMethod() + { + string? str1 = GetNullableString(); + string? str2 = GetNullableString(); + + // Both variables asserted + await Assert.That(str1).IsNotNull().And.Contains("test"); + await Assert.That(str2).IsNotNull(); + + // Both should be suppressed + var length1 = {|#0:str1|}.Length; + var length2 = {|#1:str2|}.Length; + } + + private string? GetNullableString() => "test"; + } + """; + + await AnalyzerTestHelpers + .CreateSuppressorTest(code) + .IgnoringDiagnostics("CS1591") + .WithSpecificDiagnostics(CS8602) + .WithExpectedDiagnosticsResults( + CS8602.WithLocation(0).WithIsSuppressed(true), + CS8602.WithLocation(1).WithIsSuppressed(true) + ) + .WithCompilerDiagnostics(CompilerDiagnostics.Warnings) + .RunAsync(); + } +} diff --git a/TUnit.Assertions.Analyzers/IsNotNullAssertionSuppressor.cs b/TUnit.Assertions.Analyzers/IsNotNullAssertionSuppressor.cs new file mode 100644 index 0000000000..495afe2af2 --- /dev/null +++ b/TUnit.Assertions.Analyzers/IsNotNullAssertionSuppressor.cs @@ -0,0 +1,242 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace TUnit.Assertions.Analyzers; + +/// +/// Suppresses nullability warnings (CS8600, CS8602, CS8604, CS8618) for variables +/// after they have been asserted as non-null using Assert.That(x).IsNotNull(). +/// +/// Note: This suppressor only hides the warnings; it does not change the compiler's +/// null-state flow analysis. Variables will still appear as nullable in IntelliSense. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public class IsNotNullAssertionSuppressor : DiagnosticSuppressor +{ + public override void ReportSuppressions(SuppressionAnalysisContext context) + { + foreach (var diagnostic in context.ReportedDiagnostics) + { + // Only process nullability warnings + if (!IsNullabilityWarning(diagnostic.Id)) + { + continue; + } + + // Get the syntax tree and semantic model + if (diagnostic.Location.SourceTree is not { } sourceTree) + { + continue; + } + + var root = sourceTree.GetRoot(); + var diagnosticSpan = diagnostic.Location.SourceSpan; + var node = root.FindNode(diagnosticSpan); + + if (node is null) + { + continue; + } + + var semanticModel = context.GetSemanticModel(sourceTree); + + // Find the variable being referenced that caused the warning + var identifierName = GetIdentifierFromNode(node); + if (identifierName is null) + { + continue; + } + + // Check if this variable was previously asserted as non-null + if (WasAssertedNotNull(identifierName, semanticModel, context.CancellationToken)) + { + Suppress(context, diagnostic); + } + } + } + + private bool IsNullabilityWarning(string diagnosticId) + { + return diagnosticId is "CS8600" // Converting null literal or possible null value to non-nullable type + or "CS8602" // Dereference of a possibly null reference + or "CS8604" // Possible null reference argument + or "CS8618"; // Non-nullable field/property uninitialized + } + + private IdentifierNameSyntax? GetIdentifierFromNode(SyntaxNode node) + { + // The warning might be on the identifier itself or a parent node + return node switch + { + IdentifierNameSyntax identifier => identifier, + MemberAccessExpressionSyntax { Expression: IdentifierNameSyntax identifier } => identifier, + ArgumentSyntax { Expression: IdentifierNameSyntax identifier } => identifier, + _ => node.DescendantNodesAndSelf().OfType().FirstOrDefault() + }; + } + + private bool WasAssertedNotNull( + IdentifierNameSyntax identifierName, + SemanticModel semanticModel, + CancellationToken cancellationToken) + { + var symbol = semanticModel.GetSymbolInfo(identifierName, cancellationToken).Symbol; + if (symbol is null) + { + return false; + } + + // Find the containing method/block + var containingMethod = identifierName.FirstAncestorOrSelf(); + if (containingMethod is null) + { + return false; + } + + // Look for Assert.That(variable).IsNotNull() patterns before this usage + var allStatements = containingMethod.DescendantNodes().OfType().ToList(); + var identifierStatement = identifierName.FirstAncestorOrSelf(); + + if (identifierStatement is null) + { + return false; + } + + var identifierStatementIndex = allStatements.IndexOf(identifierStatement); + if (identifierStatementIndex < 0) + { + return false; + } + + // Check all statements before the current one + for (int i = 0; i < identifierStatementIndex; i++) + { + var statement = allStatements[i]; + + // Look for await Assert.That(x).IsNotNull() pattern + if (IsNotNullAssertion(statement, symbol, semanticModel, cancellationToken)) + { + return true; + } + } + + return false; + } + + private bool IsNotNullAssertion( + StatementSyntax statement, + ISymbol targetSymbol, + SemanticModel semanticModel, + CancellationToken cancellationToken) + { + // Pattern: await Assert.That(variable).IsNotNull() + // or: await Assert.That(variable).Contains("test").And.IsNotNull() + // or: Assert.That(variable).IsNotNull().GetAwaiter().GetResult() + + var invocations = statement.DescendantNodes().OfType(); + + foreach (var invocation in invocations) + { + // Check if this is a call to IsNotNull() + if (invocation.Expression is not MemberAccessExpressionSyntax { Name.Identifier.Text: "IsNotNull" }) + { + continue; + } + + // Walk up the expression chain to find Assert.That() call + var assertThatCall = FindAssertThatInChain(invocation); + if (assertThatCall is null) + { + continue; + } + + // Get the argument to Assert.That() + if (assertThatCall.ArgumentList.Arguments.Count != 1) + { + continue; + } + + var argument = assertThatCall.ArgumentList.Arguments[0].Expression; + + // Get the symbol of the argument + var argumentSymbol = semanticModel.GetSymbolInfo(argument, cancellationToken).Symbol; + + // Check if it's the same symbol we're looking for + if (SymbolEqualityComparer.Default.Equals(argumentSymbol, targetSymbol)) + { + return true; + } + } + + return false; + } + + private InvocationExpressionSyntax? FindAssertThatInChain(InvocationExpressionSyntax invocation) + { + // Walk up the expression chain looking for Assert.That() + var current = invocation.Expression; + + while (current is not null) + { + if (current is InvocationExpressionSyntax invocationExpr) + { + // Check if this is Assert.That() + if (invocationExpr.Expression is MemberAccessExpressionSyntax + { + Name.Identifier.Text: "That", + Expression: IdentifierNameSyntax { Identifier.Text: "Assert" } + }) + { + return invocationExpr; + } + + // Continue walking up from this invocation + current = invocationExpr.Expression; + } + else if (current is MemberAccessExpressionSyntax memberAccess) + { + // Move to the expression being accessed + current = memberAccess.Expression; + } + else + { + break; + } + } + + return null; + } + + private void Suppress(SuppressionAnalysisContext context, Diagnostic diagnostic) + { + var suppression = SupportedSuppressions.FirstOrDefault(s => s.SuppressedDiagnosticId == diagnostic.Id); + + if (suppression is not null) + { + context.ReportSuppression( + Suppression.Create( + suppression, + diagnostic + ) + ); + } + } + + public override ImmutableArray SupportedSuppressions { get; } = + ImmutableArray.Create( + CreateDescriptor("CS8600"), + CreateDescriptor("CS8602"), + CreateDescriptor("CS8604"), + CreateDescriptor("CS8618") + ); + + private static SuppressionDescriptor CreateDescriptor(string id) + => new( + id: $"{id}Suppression", + suppressedDiagnosticId: id, + justification: $"Suppress {id} for variables asserted as non-null via Assert.That(x).IsNotNull()." + ); +} diff --git a/TUnit.Assertions.Tests/AssertNotNullTests.cs b/TUnit.Assertions.Tests/AssertNotNullTests.cs new file mode 100644 index 0000000000..537ab1f2aa --- /dev/null +++ b/TUnit.Assertions.Tests/AssertNotNullTests.cs @@ -0,0 +1,149 @@ +#nullable enable +using TUnit.Assertions.Exceptions; +using TUnit.Core; + +namespace TUnit.Assertions.Tests; + +/// +/// Tests for Assert.NotNull() and Assert.Null() methods. +/// These methods properly update null-state flow analysis via [NotNull] attribute. +/// +public class AssertNotNullTests +{ + [Test] + public async Task NotNull_WithNonNullReferenceType_DoesNotThrow() + { + string? value = "test"; + + Assert.NotNull(value); + + // After NotNull, the compiler should know value is non-null + // This should compile without warnings + var length = value.Length; + + await Assert.That(length).IsEqualTo(4); + } + + [Test] + public async Task NotNull_WithNullReferenceType_Throws() + { + string? value = null; + + var exception = Assert.Throws(() => Assert.NotNull(value)); + + await Assert.That(exception.Message).Contains("to not be null"); + } + + [Test] + public async Task NotNull_WithNonNullableValueType_DoesNotThrow() + { + int? value = 42; + + Assert.NotNull(value); + + // After NotNull, the compiler should know value has a value + var intValue = value.Value; + + await Assert.That(intValue).IsEqualTo(42); + } + + [Test] + public async Task NotNull_WithNullableValueType_Throws() + { + int? value = null; + + var exception = Assert.Throws(() => Assert.NotNull(value)); + + await Assert.That(exception.Message).Contains("to not be null"); + } + + [Test] + public void Null_WithNullReferenceType_DoesNotThrow() + { + string? value = null; + + Assert.Null(value); + + // Test passes if no exception thrown + } + + [Test] + public async Task Null_WithNonNullReferenceType_Throws() + { + string? value = "test"; + + var exception = Assert.Throws(() => Assert.Null(value)); + + await Assert.That(exception.Message).Contains("to be null"); + } + + [Test] + public void Null_WithNullValueType_DoesNotThrow() + { + int? value = null; + + Assert.Null(value); + + // Test passes if no exception thrown + } + + [Test] + public async Task Null_WithNonNullValueType_Throws() + { + int? value = 42; + + var exception = Assert.Throws(() => Assert.Null(value)); + + await Assert.That(exception.Message).Contains("to be null"); + } + + [Test] + public async Task NotNull_CapturesExpressionInMessage() + { + string? myVariable = null; + + var exception = Assert.Throws(() => Assert.NotNull(myVariable)); + + await Assert.That(exception.Message).Contains("myVariable"); + } + + [Test] + public async Task Null_CapturesExpressionInMessage() + { + string myVariable = "not null"; + + var exception = Assert.Throws(() => Assert.Null(myVariable)); + + await Assert.That(exception.Message).Contains("myVariable"); + } + + [Test] + public async Task NotNull_AllowsChainingWithOtherAssertions() + { + string? value = "test"; + + Assert.NotNull(value); + + // Can use the value directly without null-forgiving operator + await Assert.That(value.ToUpper()).IsEqualTo("TEST"); + await Assert.That(value.Length).IsEqualTo(4); + } + + [Test] + public async Task NotNull_WithComplexObject_UpdatesNullState() + { + var obj = new TestClass { Name = "test" }; + TestClass? nullableObj = obj; + + Assert.NotNull(nullableObj); + + // Should be able to access properties without warnings + var name = nullableObj.Name; + await Assert.That(name).IsEqualTo("test"); + } + + private class TestClass + { + public string? Name { get; set; } + } +} diff --git a/TUnit.Assertions/Extensions/Assert.cs b/TUnit.Assertions/Extensions/Assert.cs index 6b9d5119fb..1cd31aee78 100644 --- a/TUnit.Assertions/Extensions/Assert.cs +++ b/TUnit.Assertions/Extensions/Assert.cs @@ -192,7 +192,7 @@ public static TException Throws(Action action) action(); throw new AssertionException($"Expected {typeof(TException).Name} but no exception was thrown"); } - catch (TException ex) when (ex is not AssertionException) + catch (TException ex) when (typeof(AssertionException).IsAssignableFrom(typeof(TException)) || ex is not AssertionException) { return ex; } @@ -219,7 +219,7 @@ public static Exception Throws(Type exceptionType, Action action) action(); throw new AssertionException($"Expected {exceptionType.Name} but no exception was thrown"); } - catch (Exception ex) when (exceptionType.IsInstanceOfType(ex) && ex is not AssertionException) + catch (Exception ex) when (exceptionType.IsInstanceOfType(ex) && (typeof(AssertionException).IsAssignableFrom(exceptionType) || ex is not AssertionException)) { return ex; } @@ -358,4 +358,79 @@ public static ExceptionParameterNameAssertion ThrowsExactlyAsync(parameterName); } + + /// + /// Asserts that a value is not null (for reference types). + /// This method properly updates null-state flow analysis, allowing the compiler to treat the value as non-null after this assertion. + /// Unlike Assert.That(x).IsNotNull() (fluent API), this method changes the compiler's null-state tracking. + /// Example: Assert.NotNull(myString); // After this, myString is treated as non-null + /// + /// The value to check for null + /// The expression being asserted (captured automatically) + /// Thrown if the value is null + public static void NotNull( + [NotNull] T? value, + [CallerArgumentExpression(nameof(value))] string? expression = null) + where T : class + { + if (value is null) + { + throw new AssertionException($"Expected {expression ?? "value"} to not be null, but it was null"); + } + } + + /// + /// Asserts that a nullable value type is not null. + /// This method properly updates null-state flow analysis, allowing the compiler to treat the value as non-null after this assertion. + /// Example: Assert.NotNull(myNullableInt); // After this, myNullableInt is treated as having a value + /// + /// The nullable value to check + /// The expression being asserted (captured automatically) + /// Thrown if the value is null + public static void NotNull( + [NotNull] T? value, + [CallerArgumentExpression(nameof(value))] string? expression = null) + where T : struct + { + if (!value.HasValue) + { + throw new AssertionException($"Expected {expression ?? "value"} to not be null, but it was null"); + } + } + + /// + /// Asserts that a value is null (for reference types). + /// Example: Assert.Null(myString); + /// + /// The value to check for null + /// The expression being asserted (captured automatically) + /// Thrown if the value is not null + public static void Null( + T? value, + [CallerArgumentExpression(nameof(value))] string? expression = null) + where T : class + { + if (value is not null) + { + throw new AssertionException($"Expected {expression ?? "value"} to be null, but it was {value}"); + } + } + + /// + /// Asserts that a nullable value type is null. + /// Example: Assert.Null(myNullableInt); + /// + /// The nullable value to check + /// The expression being asserted (captured automatically) + /// Thrown if the value is not null + public static void Null( + T? value, + [CallerArgumentExpression(nameof(value))] string? expression = null) + where T : struct + { + if (value.HasValue) + { + throw new AssertionException($"Expected {expression ?? "value"} to be null, but it was {value.Value}"); + } + } } 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 8b838e794c..cbef291244 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 @@ -5,6 +5,14 @@ namespace { public static void Fail(string reason) { } public static Multiple() { } + public static void NotNull([.] T? value, [.("value")] string? expression = null) + where T : class { } + public static void NotNull([.] T? value, [.("value")] string? expression = null) + where T : struct { } + public static void Null(T? value, [.("value")] string? expression = null) + where T : class { } + public static void Null(T? value, [.("value")] string? expression = null) + where T : struct { } public static . That( action, [.("action")] string? expression = null) { } public static . That(.IEnumerable value, [.("value")] string? expression = null) { } public static . That(<.> action, [.("action")] string? expression = 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 05c906c479..13d23a7731 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 @@ -5,6 +5,14 @@ namespace { public static void Fail(string reason) { } public static Multiple() { } + public static void NotNull([.] T? value, [.("value")] string? expression = null) + where T : class { } + public static void NotNull([.] T? value, [.("value")] string? expression = null) + where T : struct { } + public static void Null(T? value, [.("value")] string? expression = null) + where T : class { } + public static void Null(T? value, [.("value")] string? expression = null) + where T : struct { } public static . That( action, [.("action")] string? expression = null) { } public static . That(.IEnumerable value, [.("value")] string? expression = null) { } public static . That(<.> action, [.("action")] string? expression = 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 941d60bc07..9adf2916fc 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 @@ -5,6 +5,14 @@ namespace { public static void Fail(string reason) { } public static Multiple() { } + public static void NotNull([.] T? value, [.("value")] string? expression = null) + where T : class { } + public static void NotNull([.] T? value, [.("value")] string? expression = null) + where T : struct { } + public static void Null(T? value, [.("value")] string? expression = null) + where T : class { } + public static void Null(T? value, [.("value")] string? expression = null) + where T : struct { } public static . That( action, [.("action")] string? expression = null) { } public static . That(.IEnumerable value, [.("value")] string? expression = null) { } public static . That(<.> action, [.("action")] string? expression = 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 67f8cfe8c3..a2f6c9c0ea 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 @@ -5,6 +5,14 @@ namespace { public static void Fail(string reason) { } public static Multiple() { } + public static void NotNull([.] T? value, [.("value")] string? expression = null) + where T : class { } + public static void NotNull([.] T? value, [.("value")] string? expression = null) + where T : struct { } + public static void Null(T? value, [.("value")] string? expression = null) + where T : class { } + public static void Null(T? value, [.("value")] string? expression = null) + where T : struct { } public static . That( action, [.("action")] string? expression = null) { } public static . That(.IEnumerable value, [.("value")] string? expression = null) { } public static . That(<.> action, [.("action")] string? expression = null) { } From b3f06cfac19da3b3b4e8ece2a2cfdbaba93b08b9 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 26 Oct 2025 17:29:04 +0000 Subject: [PATCH 07/67] refactor: optimize task handling by replacing Task with ValueTask for improved performance (#3528) --- TUnit.Engine/Events/EventReceiverRegistry.cs | 58 +++++-- TUnit.Engine/Services/BeforeHookTaskCache.cs | 21 +-- .../Services/EventReceiverOrchestrator.cs | 21 ++- TUnit.Engine/Services/HookExecutor.cs | 147 +++++++++++++----- 4 files changed, 174 insertions(+), 73 deletions(-) diff --git a/TUnit.Engine/Events/EventReceiverRegistry.cs b/TUnit.Engine/Events/EventReceiverRegistry.cs index 87f4e8df18..d7bfd127db 100644 --- a/TUnit.Engine/Events/EventReceiverRegistry.cs +++ b/TUnit.Engine/Events/EventReceiverRegistry.cs @@ -26,6 +26,7 @@ private enum EventTypes private volatile EventTypes _registeredEvents = EventTypes.None; private readonly Dictionary _receiversByType = new(); + private readonly Dictionary _cachedTypedReceivers = new(); private readonly ReaderWriterLockSlim _lock = new(); /// @@ -66,7 +67,7 @@ public void RegisterReceiver(object receiver) private void RegisterReceiverInternal(object receiver) { UpdateEventFlags(receiver); - + // Register for each interface type the object implements // We use a simpler approach that doesn't require reflection RegisterIfImplements(receiver); @@ -79,6 +80,8 @@ private void RegisterReceiverInternal(object receiver) RegisterIfImplements(receiver); RegisterIfImplements(receiver); RegisterIfImplements(receiver); + + _cachedTypedReceivers.Clear(); } private void RegisterIfImplements(object receiver) where T : class @@ -136,30 +139,67 @@ private void RegisterIfImplements(object receiver) where T : class [MethodImpl(MethodImplOptions.AggressiveInlining)] public bool HasAnyReceivers() => _registeredEvents != EventTypes.None; - /// - /// Get receivers of specific type (for invocation) - /// public T[] GetReceiversOfType() where T : class { + var typeKey = typeof(T); + _lock.EnterReadLock(); try { - if (_receiversByType.TryGetValue(typeof(T), out var receivers)) + if (_cachedTypedReceivers.TryGetValue(typeKey, out var cached)) + { + return (T[])cached; + } + } + finally + { + _lock.ExitReadLock(); + } + + _lock.EnterUpgradeableReadLock(); + try + { + if (_cachedTypedReceivers.TryGetValue(typeKey, out var cached)) + { + return (T[])cached; + } + + if (_receiversByType.TryGetValue(typeKey, out var receivers)) { - // Cast array to specific type var typedArray = new T[receivers.Length]; for (var i = 0; i < receivers.Length; i++) { typedArray[i] = (T)receivers[i]; } + + _lock.EnterWriteLock(); + try + { + _cachedTypedReceivers[typeKey] = typedArray; + } + finally + { + _lock.ExitWriteLock(); + } + return typedArray; } - return [ - ]; + + T[] emptyArray = []; + _lock.EnterWriteLock(); + try + { + _cachedTypedReceivers[typeKey] = emptyArray; + } + finally + { + _lock.ExitWriteLock(); + } + return emptyArray; } finally { - _lock.ExitReadLock(); + _lock.ExitUpgradeableReadLock(); } } diff --git a/TUnit.Engine/Services/BeforeHookTaskCache.cs b/TUnit.Engine/Services/BeforeHookTaskCache.cs index bc22427393..3b76326dac 100644 --- a/TUnit.Engine/Services/BeforeHookTaskCache.cs +++ b/TUnit.Engine/Services/BeforeHookTaskCache.cs @@ -17,33 +17,34 @@ internal sealed class BeforeHookTaskCache private Task? _beforeTestSessionTask; private readonly object _testSessionLock = new(); - public Task GetOrCreateBeforeTestSessionTask(Func taskFactory) + public ValueTask GetOrCreateBeforeTestSessionTask(Func taskFactory) { if (_beforeTestSessionTask != null) { - return _beforeTestSessionTask; + return new ValueTask(_beforeTestSessionTask); } lock (_testSessionLock) { - // Double-check after acquiring lock if (_beforeTestSessionTask == null) { - _beforeTestSessionTask = taskFactory(); + _beforeTestSessionTask = taskFactory().AsTask(); } - return _beforeTestSessionTask; + return new ValueTask(_beforeTestSessionTask); } } - public Task GetOrCreateBeforeAssemblyTask(Assembly assembly, Func taskFactory) + public ValueTask GetOrCreateBeforeAssemblyTask(Assembly assembly, Func taskFactory) { - return _beforeAssemblyTasks.GetOrAdd(assembly, taskFactory); + var task = _beforeAssemblyTasks.GetOrAdd(assembly, a => taskFactory(a).AsTask()); + return new ValueTask(task); } - public Task GetOrCreateBeforeClassTask( + public ValueTask GetOrCreateBeforeClassTask( [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.PublicMethods)] - Type testClass, Func taskFactory) + Type testClass, Func taskFactory) { - return _beforeClassTasks.GetOrAdd(testClass, taskFactory); + var task = _beforeClassTasks.GetOrAdd(testClass, t => taskFactory(t).AsTask()); + return new ValueTask(task); } } diff --git a/TUnit.Engine/Services/EventReceiverOrchestrator.cs b/TUnit.Engine/Services/EventReceiverOrchestrator.cs index 68696795ce..4a03c9f2e6 100644 --- a/TUnit.Engine/Services/EventReceiverOrchestrator.cs +++ b/TUnit.Engine/Services/EventReceiverOrchestrator.cs @@ -214,20 +214,19 @@ public async ValueTask InvokeHookRegistrationEventReceiversAsync(HookRegisteredC // First/Last event methods with fast-path checks [MethodImpl(MethodImplOptions.AggressiveInlining)] - public async ValueTask InvokeFirstTestInSessionEventReceiversAsync( + public ValueTask InvokeFirstTestInSessionEventReceiversAsync( TestContext context, TestSessionContext sessionContext, CancellationToken cancellationToken) { if (!_registry.HasFirstTestInSessionReceivers()) { - return; + return default; } - // Use GetOrAdd to ensure exactly one task is created per session and all tests await it var task = _firstTestInSessionTasks.GetOrAdd("session", _ => InvokeFirstTestInSessionEventReceiversCoreAsync(context, sessionContext, cancellationToken)); - await task; + return new ValueTask(task); } private async Task InvokeFirstTestInSessionEventReceiversCoreAsync( @@ -244,21 +243,20 @@ private async Task InvokeFirstTestInSessionEventReceiversCoreAsync( } [MethodImpl(MethodImplOptions.AggressiveInlining)] - public async ValueTask InvokeFirstTestInAssemblyEventReceiversAsync( + public ValueTask InvokeFirstTestInAssemblyEventReceiversAsync( TestContext context, AssemblyHookContext assemblyContext, CancellationToken cancellationToken) { if (!_registry.HasFirstTestInAssemblyReceivers()) { - return; + return default; } var assemblyName = assemblyContext.Assembly.GetName().FullName ?? ""; - // Use GetOrAdd to ensure exactly one task is created per assembly and all tests await it var task = _firstTestInAssemblyTasks.GetOrAdd(assemblyName, _ => InvokeFirstTestInAssemblyEventReceiversCoreAsync(context, assemblyContext, cancellationToken)); - await task; + return new ValueTask(task); } private async Task InvokeFirstTestInAssemblyEventReceiversCoreAsync( @@ -275,21 +273,20 @@ private async Task InvokeFirstTestInAssemblyEventReceiversCoreAsync( } [MethodImpl(MethodImplOptions.AggressiveInlining)] - public async ValueTask InvokeFirstTestInClassEventReceiversAsync( + public ValueTask InvokeFirstTestInClassEventReceiversAsync( TestContext context, ClassHookContext classContext, CancellationToken cancellationToken) { if (!_registry.HasFirstTestInClassReceivers()) { - return; + return default; } var classType = classContext.ClassType; - // Use GetOrAdd to ensure exactly one task is created per class and all tests await it var task = _firstTestInClassTasks.GetOrAdd(classType, _ => InvokeFirstTestInClassEventReceiversCoreAsync(context, classContext, cancellationToken)); - await task; + return new ValueTask(task); } private async Task InvokeFirstTestInClassEventReceiversCoreAsync( diff --git a/TUnit.Engine/Services/HookExecutor.cs b/TUnit.Engine/Services/HookExecutor.cs index a30eb270ef..b189f1ee01 100644 --- a/TUnit.Engine/Services/HookExecutor.cs +++ b/TUnit.Engine/Services/HookExecutor.cs @@ -28,10 +28,15 @@ public HookExecutor( _eventReceiverOrchestrator = eventReceiverOrchestrator; } - public async Task ExecuteBeforeTestSessionHooksAsync(CancellationToken cancellationToken) + public async ValueTask ExecuteBeforeTestSessionHooksAsync(CancellationToken cancellationToken) { var hooks = await _hookCollectionService.CollectBeforeTestSessionHooksAsync().ConfigureAwait(false); + if (hooks.Count == 0) + { + return; + } + foreach (var hook in hooks) { try @@ -48,10 +53,16 @@ public async Task ExecuteBeforeTestSessionHooksAsync(CancellationToken cancellat } } - public async Task> ExecuteAfterTestSessionHooksAsync(CancellationToken cancellationToken) + public async ValueTask> ExecuteAfterTestSessionHooksAsync(CancellationToken cancellationToken) { var exceptions = new List(); var hooks = await _hookCollectionService.CollectAfterTestSessionHooksAsync().ConfigureAwait(false); + + if (hooks.Count == 0) + { + return exceptions; + } + foreach (var hook in hooks) { try @@ -70,9 +81,15 @@ public async Task> ExecuteAfterTestSessionHooksAsync(Cancellatio return exceptions; } - public async Task ExecuteBeforeAssemblyHooksAsync(Assembly assembly, CancellationToken cancellationToken) + public async ValueTask ExecuteBeforeAssemblyHooksAsync(Assembly assembly, CancellationToken cancellationToken) { var hooks = await _hookCollectionService.CollectBeforeAssemblyHooksAsync(assembly).ConfigureAwait(false); + + if (hooks.Count == 0) + { + return; + } + foreach (var hook in hooks) { try @@ -88,10 +105,16 @@ public async Task ExecuteBeforeAssemblyHooksAsync(Assembly assembly, Cancellatio } } - public async Task> ExecuteAfterAssemblyHooksAsync(Assembly assembly, CancellationToken cancellationToken) + public async ValueTask> ExecuteAfterAssemblyHooksAsync(Assembly assembly, CancellationToken cancellationToken) { var exceptions = new List(); var hooks = await _hookCollectionService.CollectAfterAssemblyHooksAsync(assembly).ConfigureAwait(false); + + if (hooks.Count == 0) + { + return exceptions; + } + foreach (var hook in hooks) { try @@ -111,11 +134,17 @@ public async Task> ExecuteAfterAssemblyHooksAsync(Assembly assem return exceptions; } - public async Task ExecuteBeforeClassHooksAsync( + public async ValueTask ExecuteBeforeClassHooksAsync( [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.PublicMethods)] Type testClass, CancellationToken cancellationToken) { var hooks = await _hookCollectionService.CollectBeforeClassHooksAsync(testClass).ConfigureAwait(false); + + if (hooks.Count == 0) + { + return; + } + foreach (var hook in hooks) { try @@ -131,12 +160,18 @@ public async Task ExecuteBeforeClassHooksAsync( } } - public async Task> ExecuteAfterClassHooksAsync( + public async ValueTask> ExecuteAfterClassHooksAsync( [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.PublicMethods)] Type testClass, CancellationToken cancellationToken) { var exceptions = new List(); var hooks = await _hookCollectionService.CollectAfterClassHooksAsync(testClass).ConfigureAwait(false); + + if (hooks.Count == 0) + { + return exceptions; + } + foreach (var hook in hooks) { try @@ -156,79 +191,101 @@ public async Task> ExecuteAfterClassHooksAsync( return exceptions; } - public async Task ExecuteBeforeTestHooksAsync(AbstractExecutableTest test, CancellationToken cancellationToken) + public async ValueTask ExecuteBeforeTestHooksAsync(AbstractExecutableTest test, CancellationToken cancellationToken) { var testClassType = test.Metadata.TestClassType; // Execute BeforeEvery(Test) hooks first (global test hooks run before specific hooks) var beforeEveryTestHooks = await _hookCollectionService.CollectBeforeEveryTestHooksAsync(testClassType).ConfigureAwait(false); - foreach (var hook in beforeEveryTestHooks) + + if (beforeEveryTestHooks.Count > 0) { - try + foreach (var hook in beforeEveryTestHooks) { - test.Context.RestoreExecutionContext(); - await hook(test.Context, cancellationToken).ConfigureAwait(false); - } - catch (Exception ex) - { - throw new BeforeTestException("BeforeEveryTest hook failed", ex); + try + { + test.Context.RestoreExecutionContext(); + await hook(test.Context, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + throw new BeforeTestException("BeforeEveryTest hook failed", ex); + } } } // Execute Before(Test) hooks after BeforeEvery hooks var beforeTestHooks = await _hookCollectionService.CollectBeforeTestHooksAsync(testClassType).ConfigureAwait(false); - foreach (var hook in beforeTestHooks) + + if (beforeTestHooks.Count > 0) { - try - { - test.Context.RestoreExecutionContext(); - await hook(test.Context, cancellationToken).ConfigureAwait(false); - } - catch (Exception ex) + foreach (var hook in beforeTestHooks) { - throw new BeforeTestException("BeforeTest hook failed", ex); + try + { + test.Context.RestoreExecutionContext(); + await hook(test.Context, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + throw new BeforeTestException("BeforeTest hook failed", ex); + } } } } - public async Task ExecuteAfterTestHooksAsync(AbstractExecutableTest test, CancellationToken cancellationToken) + public async ValueTask ExecuteAfterTestHooksAsync(AbstractExecutableTest test, CancellationToken cancellationToken) { var testClassType = test.Metadata.TestClassType; // Execute After(Test) hooks first (specific hooks run before global hooks for cleanup) var afterTestHooks = await _hookCollectionService.CollectAfterTestHooksAsync(testClassType).ConfigureAwait(false); - foreach (var hook in afterTestHooks) + + if (afterTestHooks.Count > 0) { - try - { - test.Context.RestoreExecutionContext(); - await hook(test.Context, cancellationToken).ConfigureAwait(false); - } - catch (Exception ex) + foreach (var hook in afterTestHooks) { - throw new AfterTestException("AfterTest hook failed", ex); + try + { + test.Context.RestoreExecutionContext(); + await hook(test.Context, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + throw new AfterTestException("AfterTest hook failed", ex); + } } } // Execute AfterEvery(Test) hooks after After hooks (global test hooks run last for cleanup) var afterEveryTestHooks = await _hookCollectionService.CollectAfterEveryTestHooksAsync(testClassType).ConfigureAwait(false); - foreach (var hook in afterEveryTestHooks) + + if (afterEveryTestHooks.Count > 0) { - try - { - test.Context.RestoreExecutionContext(); - await hook(test.Context, cancellationToken).ConfigureAwait(false); - } - catch (Exception ex) + foreach (var hook in afterEveryTestHooks) { - throw new AfterTestException("AfterEveryTest hook failed", ex); + try + { + test.Context.RestoreExecutionContext(); + await hook(test.Context, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + throw new AfterTestException("AfterEveryTest hook failed", ex); + } } } } - public async Task ExecuteBeforeTestDiscoveryHooksAsync(CancellationToken cancellationToken) + public async ValueTask ExecuteBeforeTestDiscoveryHooksAsync(CancellationToken cancellationToken) { var hooks = await _hookCollectionService.CollectBeforeTestDiscoveryHooksAsync().ConfigureAwait(false); + + if (hooks.Count == 0) + { + return; + } + foreach (var hook in hooks) { try @@ -243,9 +300,15 @@ public async Task ExecuteBeforeTestDiscoveryHooksAsync(CancellationToken cancell } } - public async Task ExecuteAfterTestDiscoveryHooksAsync(CancellationToken cancellationToken) + public async ValueTask ExecuteAfterTestDiscoveryHooksAsync(CancellationToken cancellationToken) { var hooks = await _hookCollectionService.CollectAfterTestDiscoveryHooksAsync().ConfigureAwait(false); + + if (hooks.Count == 0) + { + return; + } + foreach (var hook in hooks) { try From c979a5ceb0db1fd1c9320f9dde012f3c37cf36cd Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 26 Oct 2025 18:40:21 +0000 Subject: [PATCH 08/67] feat: implement custom hook executor support for test execution (#3357) --- TUnit.Core/Contexts/TestRegisteredContext.cs | 9 ++ TUnit.Core/Hooks/AfterTestHookMethod.cs | 10 ++ TUnit.Core/Hooks/BeforeTestHookMethod.cs | 10 ++ TUnit.Core/TestContext.cs | 6 + TUnit.Engine/Building/TestBuilder.cs | 65 +++++++++- .../Framework/TUnitServiceProvider.cs | 2 +- TUnit.Engine/Helpers/HookTimeoutHelper.cs | 47 ++++++- .../Services/HookCollectionService.cs | 41 ++++-- .../TestArgumentRegistrationService.cs | 15 +-- TUnit.Engine/Services/TestFilterService.cs | 11 +- ...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 + TUnit.TestProject/SetHookExecutorTests.cs | 119 ++++++++++++++++++ 15 files changed, 316 insertions(+), 27 deletions(-) create mode 100644 TUnit.TestProject/SetHookExecutorTests.cs diff --git a/TUnit.Core/Contexts/TestRegisteredContext.cs b/TUnit.Core/Contexts/TestRegisteredContext.cs index 49fbbbdc24..7b3786ff9d 100644 --- a/TUnit.Core/Contexts/TestRegisteredContext.cs +++ b/TUnit.Core/Contexts/TestRegisteredContext.cs @@ -34,6 +34,15 @@ public void SetTestExecutor(ITestExecutor executor) DiscoveredTest.TestExecutor = executor; } + /// + /// Sets a custom hook executor that will be used for all test-level hooks (Before/After Test). + /// This allows you to wrap hook execution in custom logic (e.g., running on a specific thread). + /// + public void SetHookExecutor(IHookExecutor executor) + { + TestContext.CustomHookExecutor = executor; + } + /// /// Sets the parallel limiter for the test /// diff --git a/TUnit.Core/Hooks/AfterTestHookMethod.cs b/TUnit.Core/Hooks/AfterTestHookMethod.cs index 510ffa81bf..90ac145667 100644 --- a/TUnit.Core/Hooks/AfterTestHookMethod.cs +++ b/TUnit.Core/Hooks/AfterTestHookMethod.cs @@ -4,6 +4,16 @@ public record AfterTestHookMethod : StaticHookMethod { public override ValueTask ExecuteAsync(TestContext context, CancellationToken cancellationToken) { + // Check if a custom hook executor has been set (e.g., via SetHookExecutor()) + // This ensures static hooks respect the custom executor even in AOT/trimmed builds + if (context.CustomHookExecutor != null) + { + return context.CustomHookExecutor.ExecuteAfterTestHook(MethodInfo, context, + () => Body!.Invoke(context, cancellationToken) + ); + } + + // Use the default executor specified at hook registration time return HookExecutor.ExecuteAfterTestHook(MethodInfo, context, () => Body!.Invoke(context, cancellationToken) ); diff --git a/TUnit.Core/Hooks/BeforeTestHookMethod.cs b/TUnit.Core/Hooks/BeforeTestHookMethod.cs index eea40c6cc1..867af57b7c 100644 --- a/TUnit.Core/Hooks/BeforeTestHookMethod.cs +++ b/TUnit.Core/Hooks/BeforeTestHookMethod.cs @@ -4,6 +4,16 @@ public record BeforeTestHookMethod : StaticHookMethod { public override ValueTask ExecuteAsync(TestContext context, CancellationToken cancellationToken) { + // Check if a custom hook executor has been set (e.g., via SetHookExecutor()) + // This ensures static hooks respect the custom executor even in AOT/trimmed builds + if (context.CustomHookExecutor != null) + { + return context.CustomHookExecutor.ExecuteBeforeTestHook(MethodInfo, context, + () => Body!.Invoke(context, cancellationToken) + ); + } + + // Use the default executor specified at hook registration time return HookExecutor.ExecuteBeforeTestHook(MethodInfo, context, () => Body!.Invoke(context, cancellationToken) ); diff --git a/TUnit.Core/TestContext.cs b/TUnit.Core/TestContext.cs index e585327cb0..125a72f85d 100644 --- a/TUnit.Core/TestContext.cs +++ b/TUnit.Core/TestContext.cs @@ -103,6 +103,12 @@ public static string WorkingDirectory public Type? DisplayNameFormatter { get; set; } + /// + /// Custom hook executor that overrides the default hook executor for all test-level hooks. + /// Set via TestRegisteredContext.SetHookExecutor() during test registration. + /// + public IHookExecutor? CustomHookExecutor { get; set; } + public Func>? RetryFunc { get; set; } // New: Support multiple parallel constraints diff --git a/TUnit.Engine/Building/TestBuilder.cs b/TUnit.Engine/Building/TestBuilder.cs index 133dff646a..020cb6a06d 100644 --- a/TUnit.Engine/Building/TestBuilder.cs +++ b/TUnit.Engine/Building/TestBuilder.cs @@ -6,6 +6,7 @@ using TUnit.Core.Interfaces; using TUnit.Core.Services; using TUnit.Engine.Building.Interfaces; +using TUnit.Engine.Extensions; using TUnit.Engine.Helpers; using TUnit.Engine.Services; using TUnit.Engine.Utilities; @@ -20,6 +21,7 @@ internal sealed class TestBuilder : ITestBuilder private readonly PropertyInjectionService _propertyInjectionService; private readonly DataSourceInitializer _dataSourceInitializer; private readonly Discovery.IHookDiscoveryService _hookDiscoveryService; + private readonly TestArgumentRegistrationService _testArgumentRegistrationService; public TestBuilder( string sessionId, @@ -27,7 +29,8 @@ public TestBuilder( IContextProvider contextProvider, PropertyInjectionService propertyInjectionService, DataSourceInitializer dataSourceInitializer, - Discovery.IHookDiscoveryService hookDiscoveryService) + Discovery.IHookDiscoveryService hookDiscoveryService, + TestArgumentRegistrationService testArgumentRegistrationService) { _sessionId = sessionId; _hookDiscoveryService = hookDiscoveryService; @@ -35,6 +38,7 @@ public TestBuilder( _contextProvider = contextProvider; _propertyInjectionService = propertyInjectionService; _dataSourceInitializer = dataSourceInitializer; + _testArgumentRegistrationService = testArgumentRegistrationService; } /// @@ -764,8 +768,8 @@ public async Task BuildTestAsync(TestMetadata metadata, // Arguments will be tracked by TestArgumentTrackingService during TestRegistered event // This ensures proper reference counting for shared instances - await InvokeDiscoveryEventReceiversAsync(context); - + // Create the test object BEFORE invoking event receivers + // This ensures context.InternalExecutableTest is set for error handling in registration var creationContext = new ExecutableTestCreationContext { TestId = testId, @@ -778,7 +782,27 @@ public async Task BuildTestAsync(TestMetadata metadata, ResolvedClassGenericArguments = testData.ResolvedClassGenericArguments }; - return metadata.CreateExecutableTestFactory(creationContext, metadata); + var test = metadata.CreateExecutableTestFactory(creationContext, metadata); + + // Set InternalExecutableTest so it's available during registration for error handling + context.InternalExecutableTest = test; + + // Invoke test registered event receivers BEFORE discovery event receivers + // This is critical for allowing attributes to set custom hook executors + try + { + await InvokeTestRegisteredEventReceiversAsync(context); + } + catch (Exception ex) + { + // Property registration or other registration logic failed + // Mark the test as failed immediately, as the old code did + test.SetResult(TestState.Failed, ex); + } + + await InvokeDiscoveryEventReceiversAsync(context); + + return test; } /// @@ -854,6 +878,37 @@ private async ValueTask CreateTestContextAsync(string testId, TestM return context; } +#if NET6_0_OR_GREATER + [System.Diagnostics.CodeAnalysis.RequiresUnreferencedCode("Type comes from runtime objects that cannot be annotated")] +#endif + private async Task InvokeTestRegisteredEventReceiversAsync(TestContext context) + { + var discoveredTest = new DiscoveredTest + { + TestContext = context + }; + + var registeredContext = new TestRegisteredContext(context) + { + DiscoveredTest = discoveredTest + }; + + context.InternalDiscoveredTest = discoveredTest; + + // First, invoke the global test argument registration service to register shared instances + await _testArgumentRegistrationService.OnTestRegistered(registeredContext); + + var eventObjects = context.GetEligibleEventObjects(); + + foreach (var receiver in eventObjects.OfType()) + { + await receiver.OnTestRegistered(registeredContext); + } + } + +#if NET6_0_OR_GREATER + [System.Diagnostics.CodeAnalysis.RequiresUnreferencedCode("Scoped attribute filtering uses Type.GetInterfaces and reflection")] +#endif private async Task InvokeDiscoveryEventReceiversAsync(TestContext context) { var discoveredContext = new DiscoveredTestContext( @@ -877,8 +932,6 @@ private async Task CreateFailedTestForDataGenerationErro var testDetails = await CreateFailedTestDetails(metadata, testId); var context = CreateFailedTestContext(metadata, testDetails); - await InvokeDiscoveryEventReceiversAsync(context); - return new FailedExecutableTest(exception) { TestId = testId, diff --git a/TUnit.Engine/Framework/TUnitServiceProvider.cs b/TUnit.Engine/Framework/TUnitServiceProvider.cs index a266526c39..673e73c91a 100644 --- a/TUnit.Engine/Framework/TUnitServiceProvider.cs +++ b/TUnit.Engine/Framework/TUnitServiceProvider.cs @@ -166,7 +166,7 @@ public TUnitServiceProvider(IExtension extension, } var testBuilder = Register( - new TestBuilder(TestSessionId, EventReceiverOrchestrator, ContextProvider, PropertyInjectionService, DataSourceInitializer, hookDiscoveryService)); + new TestBuilder(TestSessionId, EventReceiverOrchestrator, ContextProvider, PropertyInjectionService, DataSourceInitializer, hookDiscoveryService, testArgumentRegistrationService)); TestBuilderPipeline = Register( new TestBuilderPipeline( diff --git a/TUnit.Engine/Helpers/HookTimeoutHelper.cs b/TUnit.Engine/Helpers/HookTimeoutHelper.cs index 7205caaf05..155b6c34a1 100644 --- a/TUnit.Engine/Helpers/HookTimeoutHelper.cs +++ b/TUnit.Engine/Helpers/HookTimeoutHelper.cs @@ -1,4 +1,6 @@ +using TUnit.Core; using TUnit.Core.Hooks; +using TUnit.Core.Interfaces; namespace TUnit.Engine.Helpers; @@ -15,11 +17,14 @@ public static Func CreateTimeoutHookAction( T context, CancellationToken cancellationToken) { + // CENTRAL POINT: At execution time, check if we should use a custom hook executor + // This happens AFTER OnTestRegistered, so CustomHookExecutor will be set if the user called SetHookExecutor var timeout = hook.Timeout; + if (timeout == null) { - // No timeout specified, execute normally - return async () => await hook.ExecuteAsync(context, cancellationToken); + // No timeout specified, execute with potential custom executor + return async () => await ExecuteHookWithPotentialCustomExecutor(hook, context, cancellationToken); } return async () => @@ -30,7 +35,7 @@ public static Func CreateTimeoutHookAction( try { - await hook.ExecuteAsync(context, cts.Token); + await ExecuteHookWithPotentialCustomExecutor(hook, context, cts.Token); } catch (OperationCanceledException) when (cts.IsCancellationRequested && !cancellationToken.IsCancellationRequested) { @@ -39,6 +44,40 @@ public static Func CreateTimeoutHookAction( }; } + /// + /// Executes a hook, using a custom executor if one is set on the TestContext + /// + private static ValueTask ExecuteHookWithPotentialCustomExecutor(StaticHookMethod hook, T context, CancellationToken cancellationToken) + { + // Check if this is a TestContext with a custom hook executor + if (context is TestContext testContext && testContext.CustomHookExecutor != null) + { + // BYPASS the hook's default executor and call the custom executor directly with the hook's body + var customExecutor = testContext.CustomHookExecutor; + + // Determine which executor method to call based on hook type + if (hook is BeforeTestHookMethod || hook is InstanceHookMethod) + { + return customExecutor.ExecuteBeforeTestHook( + hook.MethodInfo, + testContext, + () => hook.Body!.Invoke(context, cancellationToken) + ); + } + else if (hook is AfterTestHookMethod) + { + return customExecutor.ExecuteAfterTestHook( + hook.MethodInfo, + testContext, + () => hook.Body!.Invoke(context, cancellationToken) + ); + } + } + + // No custom executor, use the hook's default executor + return hook.ExecuteAsync(context, cancellationToken); + } + /// /// Creates a timeout-aware action wrapper for a hook delegate /// @@ -74,6 +113,8 @@ public static Func CreateTimeoutHookAction( /// /// Creates a timeout-aware action wrapper for a hook delegate that returns ValueTask + /// This overload is used for instance hooks (InstanceHookMethod) + /// Custom executor handling for instance hooks is done in HookCollectionService.CreateInstanceHookDelegateAsync /// public static Func CreateTimeoutHookAction( Func hookDelegate, diff --git a/TUnit.Engine/Services/HookCollectionService.cs b/TUnit.Engine/Services/HookCollectionService.cs index 902f7ed057..ab31b36e89 100644 --- a/TUnit.Engine/Services/HookCollectionService.cs +++ b/TUnit.Engine/Services/HookCollectionService.cs @@ -621,14 +621,41 @@ private async Task> CreateInstanceHoo return async (context, cancellationToken) => { - var timeoutAction = HookTimeoutHelper.CreateTimeoutHookAction( - (ctx, ct) => hook.ExecuteAsync(ctx, ct), - context, - hook.Timeout, - hook.Name, - cancellationToken); + // Check at EXECUTION time if a custom executor should be used + if (context.CustomHookExecutor != null) + { + // BYPASS the hook's default executor and call the custom executor directly + var customExecutor = context.CustomHookExecutor; - await timeoutAction(); + // Skip skipped test instances + if (context.TestDetails.ClassInstance is SkippedTestInstance) + { + return; + } + + if (context.TestDetails.ClassInstance is PlaceholderInstance) + { + throw new InvalidOperationException($"Cannot execute instance hook {hook.Name} because the test instance has not been created yet. This is likely a framework bug."); + } + + await customExecutor.ExecuteBeforeTestHook( + hook.MethodInfo, + context, + () => hook.Body!.Invoke(context.TestDetails.ClassInstance, context, cancellationToken) + ); + } + else + { + // No custom executor, use normal execution path + var timeoutAction = HookTimeoutHelper.CreateTimeoutHookAction( + (ctx, ct) => hook.ExecuteAsync(ctx, ct), + context, + hook.Timeout, + hook.Name, + cancellationToken); + + await timeoutAction(); + } }; } diff --git a/TUnit.Engine/Services/TestArgumentRegistrationService.cs b/TUnit.Engine/Services/TestArgumentRegistrationService.cs index 5698233ef3..d92139a881 100644 --- a/TUnit.Engine/Services/TestArgumentRegistrationService.cs +++ b/TUnit.Engine/Services/TestArgumentRegistrationService.cs @@ -139,24 +139,21 @@ await _objectRegistrationService.RegisterObjectAsync( } catch (Exception ex) { - // Capture the exception for this property - mark the test as failed + // Capture the exception for this property and re-throw + // The test building process will handle marking it as failed var exceptionMessage = $"Failed to generate data for property '{metadata.PropertyName}': {ex.Message}"; var propertyException = new InvalidOperationException(exceptionMessage, ex); - - // Mark the test as failed immediately during registration - testContext.InternalExecutableTest.SetResult(TestState.Failed, propertyException); - return; // Stop processing further properties for this test + throw propertyException; } } } catch (Exception ex) { - // Capture any top-level exceptions (e.g., getting property source) + // Capture any top-level exceptions (e.g., getting property source) and re-throw + // The test building process will handle marking it as failed var exceptionMessage = $"Failed to register properties for test '{testContext.TestDetails.TestName}': {ex.Message}"; var registrationException = new InvalidOperationException(exceptionMessage, ex); - - // Mark the test as failed immediately during registration - testContext.InternalExecutableTest.SetResult(TestState.Failed, registrationException); + throw registrationException; } } } diff --git a/TUnit.Engine/Services/TestFilterService.cs b/TUnit.Engine/Services/TestFilterService.cs index 7854b9f9c1..ddc8998467 100644 --- a/TUnit.Engine/Services/TestFilterService.cs +++ b/TUnit.Engine/Services/TestFilterService.cs @@ -70,7 +70,16 @@ private async Task RegisterTest(AbstractExecutableTest test) test.Context.InternalDiscoveredTest = discoveredTest; - await testArgumentRegistrationService.OnTestRegistered(registeredContext); + try + { + await testArgumentRegistrationService.OnTestRegistered(registeredContext); + } + catch (Exception ex) + { + // Mark the test as failed and skip further event receiver processing + test.SetResult(TestState.Failed, ex); + return; + } var eventObjects = test.Context.GetEligibleEventObjects(); 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 de865ee675..098be8d6d7 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 @@ -1268,6 +1268,7 @@ namespace public .CancellationToken CancellationToken { get; set; } public .ClassHookContext ClassContext { get; } public int CurrentRetryAttempt { get; } + public .? CustomHookExecutor { get; set; } public .<.TestDetails> Dependencies { get; } public ? DisplayNameFormatter { get; set; } public .TestContextEvents Events { get; } @@ -1471,6 +1472,7 @@ namespace public .TestContext TestContext { get; } public .TestDetails TestDetails { get; } public string TestName { get; } + public void SetHookExecutor(. executor) { } public void SetParallelLimiter(. parallelLimit) { } public void SetTestExecutor(. executor) { } } 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 f7b3d69196..cec9588a0a 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 @@ -1268,6 +1268,7 @@ namespace public .CancellationToken CancellationToken { get; set; } public .ClassHookContext ClassContext { get; } public int CurrentRetryAttempt { get; } + public .? CustomHookExecutor { get; set; } public .<.TestDetails> Dependencies { get; } public ? DisplayNameFormatter { get; set; } public .TestContextEvents Events { get; } @@ -1471,6 +1472,7 @@ namespace public .TestContext TestContext { get; } public .TestDetails TestDetails { get; } public string TestName { get; } + public void SetHookExecutor(. executor) { } public void SetParallelLimiter(. parallelLimit) { } public void SetTestExecutor(. executor) { } } 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 c7e96f00ab..475f2d4304 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 @@ -1268,6 +1268,7 @@ namespace public .CancellationToken CancellationToken { get; set; } public .ClassHookContext ClassContext { get; } public int CurrentRetryAttempt { get; } + public .? CustomHookExecutor { get; set; } public .<.TestDetails> Dependencies { get; } public ? DisplayNameFormatter { get; set; } public .TestContextEvents Events { get; } @@ -1471,6 +1472,7 @@ namespace public .TestContext TestContext { get; } public .TestDetails TestDetails { get; } public string TestName { get; } + public void SetHookExecutor(. executor) { } public void SetParallelLimiter(. parallelLimit) { } public void SetTestExecutor(. executor) { } } 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 2f9c6ad709..cfa7c50d42 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 @@ -1222,6 +1222,7 @@ namespace public .CancellationToken CancellationToken { get; set; } public .ClassHookContext ClassContext { get; } public int CurrentRetryAttempt { get; } + public .? CustomHookExecutor { get; set; } public .<.TestDetails> Dependencies { get; } public ? DisplayNameFormatter { get; set; } public .TestContextEvents Events { get; } @@ -1423,6 +1424,7 @@ namespace public .TestContext TestContext { get; } public .TestDetails TestDetails { get; } public string TestName { get; } + public void SetHookExecutor(. executor) { } public void SetParallelLimiter(. parallelLimit) { } public void SetTestExecutor(. executor) { } } diff --git a/TUnit.TestProject/SetHookExecutorTests.cs b/TUnit.TestProject/SetHookExecutorTests.cs new file mode 100644 index 0000000000..420fbe9ae5 --- /dev/null +++ b/TUnit.TestProject/SetHookExecutorTests.cs @@ -0,0 +1,119 @@ +using TUnit.Core.Executors; +using TUnit.Core.Interfaces; +using TUnit.TestProject.Attributes; +using TUnit.TestProject.TestExecutors; + +namespace TUnit.TestProject; + +/// +/// Attribute that sets both test and hook executors - mimics the user's [Dispatch] attribute from issue #2666 +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] +public class SetBothExecutorsAttribute : Attribute, ITestRegisteredEventReceiver +{ + public int Order => 0; + + public ValueTask OnTestRegistered(TestRegisteredContext context) + { + // Set both test and hook executors to use the same custom executor + // This is the key feature - users can now wrap all methods (test + hooks) in their custom dispatcher + var customExecutor = new CrossPlatformTestExecutor(); + context.SetTestExecutor(customExecutor); + context.SetHookExecutor(customExecutor); + + return default; + } +} + +/// +/// Tests demonstrating SetHookExecutor functionality for issue #2666 +/// Users can use an attribute that calls context.SetHookExecutor() to wrap both tests and their hooks in the same executor +/// +[EngineTest(ExpectedResult.Pass)] +[SetBothExecutors] // This attribute sets both executors +public class SetHookExecutorTests +{ + private static bool _beforeTestHookExecutedWithCustomExecutor; + private static bool _afterTestHookExecutedWithCustomExecutor; + + [Before(Test)] + public async Task BeforeTestHook(TestContext context) + { + // This hook should execute with the custom executor set by the attribute + await Assert.That(Thread.CurrentThread.Name).IsEqualTo("CrossPlatformTestExecutor"); + await Assert.That(CrossPlatformTestExecutor.IsRunningInTestExecutor.Value).IsTrue(); + _beforeTestHookExecutedWithCustomExecutor = true; + } + + [After(Test)] + public async Task AfterTestHook(TestContext context) + { + // This hook should execute with the custom executor set by the attribute + await Assert.That(Thread.CurrentThread.Name).IsEqualTo("CrossPlatformTestExecutor"); + await Assert.That(CrossPlatformTestExecutor.IsRunningInTestExecutor.Value).IsTrue(); + _afterTestHookExecutedWithCustomExecutor = true; + } + + [Test] + public async Task Test_ExecutesInCustomExecutor() + { + // Test should execute in custom executor + await Assert.That(Thread.CurrentThread.Name).IsEqualTo("CrossPlatformTestExecutor"); + await Assert.That(CrossPlatformTestExecutor.IsRunningInTestExecutor.Value).IsTrue(); + } + + [Test] + public async Task Test_HooksAlsoExecuteInCustomExecutor() + { + // Verify that the hooks executed in the custom executor + await Assert.That(_beforeTestHookExecutedWithCustomExecutor).IsTrue(); + + // After hook will be verified by its own assertions + await Assert.That(Thread.CurrentThread.Name).IsEqualTo("CrossPlatformTestExecutor"); + } +} + +/// +/// Tests demonstrating SetHookExecutor with static hooks +/// +[EngineTest(ExpectedResult.Pass)] +[SetBothExecutors] // This attribute sets both executors +public class SetHookExecutorWithStaticHooksTests +{ + [BeforeEvery(Test)] + public static async Task BeforeEveryTest(TestContext context) + { + // This static hook is GLOBAL and runs for ALL tests in the assembly + // Only run assertions for tests in SetHookExecutorWithStaticHooksTests class + if (context.TestDetails.ClassType == typeof(SetHookExecutorWithStaticHooksTests)) + { + // This static hook should execute with the custom executor when CustomHookExecutor is set + await Assert.That(Thread.CurrentThread.Name).IsEqualTo("CrossPlatformTestExecutor"); + await Assert.That(CrossPlatformTestExecutor.IsRunningInTestExecutor.Value).IsTrue(); + context.ObjectBag["BeforeEveryExecuted"] = true; + } + } + + [AfterEvery(Test)] + public static async Task AfterEveryTest(TestContext context) + { + // This static hook is GLOBAL and runs for ALL tests in the assembly + // Only run assertions for tests in SetHookExecutorWithStaticHooksTests class + if (context.TestDetails.ClassType == typeof(SetHookExecutorWithStaticHooksTests)) + { + // This static hook should execute with the custom executor when CustomHookExecutor is set + await Assert.That(Thread.CurrentThread.Name).IsEqualTo("CrossPlatformTestExecutor"); + await Assert.That(CrossPlatformTestExecutor.IsRunningInTestExecutor.Value).IsTrue(); + } + } + + [Test] + public async Task Test_StaticHooksExecuteInCustomExecutor() + { + // Verify the BeforeEvery hook ran + await Assert.That(TestContext.Current?.ObjectBag["BeforeEveryExecuted"]).IsEquatableOrEqualTo(true); + + // Test itself runs in custom executor + await Assert.That(Thread.CurrentThread.Name).IsEqualTo("CrossPlatformTestExecutor"); + } +} From b4a1bfe6487fa9d39a97e3799d15e80a9cef3f4b Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 26 Oct 2025 19:30:39 +0000 Subject: [PATCH 09/67] chore(deps): update tunit to 0.76.26 (#3531) Co-authored-by: Renovate Bot --- Directory.Packages.props | 6 +++--- .../TUnit.AspNet.FSharp/TestProject/TestProject.fsproj | 4 ++-- .../content/TUnit.AspNet/TestProject/TestProject.csproj | 2 +- .../ExampleNamespace.TestProject.csproj | 2 +- .../content/TUnit.Aspire.Test/ExampleNamespace.csproj | 2 +- TUnit.Templates/content/TUnit.FSharp/TestProject.fsproj | 4 ++-- TUnit.Templates/content/TUnit.Playwright/TestProject.csproj | 2 +- TUnit.Templates/content/TUnit.VB/TestProject.vbproj | 2 +- TUnit.Templates/content/TUnit/TestProject.csproj | 2 +- 9 files changed, 13 insertions(+), 13 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index e903eca582..b7e54df848 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -83,9 +83,9 @@ - - - + + + diff --git a/TUnit.Templates/content/TUnit.AspNet.FSharp/TestProject/TestProject.fsproj b/TUnit.Templates/content/TUnit.AspNet.FSharp/TestProject/TestProject.fsproj index 8e18828a2d..d99ea78447 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 ba023fd72f..a5d0584afe 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.Aspire.Starter/ExampleNamespace.TestProject/ExampleNamespace.TestProject.csproj b/TUnit.Templates/content/TUnit.Aspire.Starter/ExampleNamespace.TestProject/ExampleNamespace.TestProject.csproj index db66778c00..095cfce880 100644 --- a/TUnit.Templates/content/TUnit.Aspire.Starter/ExampleNamespace.TestProject/ExampleNamespace.TestProject.csproj +++ b/TUnit.Templates/content/TUnit.Aspire.Starter/ExampleNamespace.TestProject/ExampleNamespace.TestProject.csproj @@ -11,7 +11,7 @@ - + diff --git a/TUnit.Templates/content/TUnit.Aspire.Test/ExampleNamespace.csproj b/TUnit.Templates/content/TUnit.Aspire.Test/ExampleNamespace.csproj index 75439e4d14..778697668b 100644 --- a/TUnit.Templates/content/TUnit.Aspire.Test/ExampleNamespace.csproj +++ b/TUnit.Templates/content/TUnit.Aspire.Test/ExampleNamespace.csproj @@ -10,7 +10,7 @@ - + diff --git a/TUnit.Templates/content/TUnit.FSharp/TestProject.fsproj b/TUnit.Templates/content/TUnit.FSharp/TestProject.fsproj index c739525068..86b4ed7c86 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 077fb150fd..2f9dca5c37 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 7076083044..5aaf9813b0 100644 --- a/TUnit.Templates/content/TUnit.VB/TestProject.vbproj +++ b/TUnit.Templates/content/TUnit.VB/TestProject.vbproj @@ -8,6 +8,6 @@ - + diff --git a/TUnit.Templates/content/TUnit/TestProject.csproj b/TUnit.Templates/content/TUnit/TestProject.csproj index 53f327da27..dcdb0ac86c 100644 --- a/TUnit.Templates/content/TUnit/TestProject.csproj +++ b/TUnit.Templates/content/TUnit/TestProject.csproj @@ -8,7 +8,7 @@ - + \ No newline at end of file From efb9a6b438b3b59a4c9cd2e3fcec43b5e0f4f8bc Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 26 Oct 2025 20:36:15 +0000 Subject: [PATCH 10/67] refactor: simplify speed comparison workflow by removing OS matrix and standardizing on ubuntu-latest --- .github/workflows/speed-comparison.yml | 33 +++++++++----------------- 1 file changed, 11 insertions(+), 22 deletions(-) diff --git a/.github/workflows/speed-comparison.yml b/.github/workflows/speed-comparison.yml index 96ae2d33e2..397b546ae0 100644 --- a/.github/workflows/speed-comparison.yml +++ b/.github/workflows/speed-comparison.yml @@ -8,11 +8,7 @@ on: jobs: build-test-artifacts: environment: ${{ github.ref == 'refs/heads/main' && 'Production' || 'Pull Requests' }} - strategy: - matrix: - os: [ubuntu-latest, windows-latest, macos-latest] - fail-fast: false - runs-on: ${{ matrix.os }} + runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 @@ -34,13 +30,13 @@ jobs: - name: Publish TUnit AOT run: | - dotnet publish -c Release -p:TestFramework=TUNIT -p:Aot=true --runtime ${{ matrix.os == 'windows-latest' && 'win-x64' || matrix.os == 'ubuntu-latest' && 'linux-x64' || 'osx-arm64' }} --output bin/Release-TUNIT-AOT/net10.0 + dotnet publish -c Release -p:TestFramework=TUNIT -p:Aot=true --use-current-runtime --output bin/Release-TUNIT-AOT/net10.0 working-directory: "tools/speed-comparison/UnifiedTests" - name: Upload Build Artifacts uses: actions/upload-artifact@v5 with: - name: test-builds-${{ matrix.os }} + name: test-builds-ubuntu path: tools/speed-comparison/UnifiedTests/bin/ retention-days: 1 @@ -49,12 +45,11 @@ jobs: environment: ${{ github.ref == 'refs/heads/main' && 'Production' || 'Pull Requests' }} strategy: matrix: - os: [ubuntu-latest, windows-latest, macos-latest] class: [DataDrivenTests, AsyncTests, ScaleTests, MatrixTests, LifecycleTests, MassiveParallelTests] fail-fast: false - runs-on: ${{ matrix.os }} + runs-on: ubuntu-latest concurrency: - group: "speed-comparison-run-time-${{matrix.os}}-${{matrix.class}}" + group: "speed-comparison-run-time-${{matrix.class}}" cancel-in-progress: true steps: @@ -70,11 +65,10 @@ jobs: - name: Download Build Artifacts uses: actions/download-artifact@v6 with: - name: test-builds-${{ matrix.os }} + name: test-builds-ubuntu path: tools/speed-comparison/UnifiedTests/bin/ - - name: Set Execute Permissions (Linux/macOS) - if: runner.os != 'Windows' + - name: Set Execute Permissions run: | find tools/speed-comparison/UnifiedTests/bin -type f -name "UnifiedTests" -exec chmod +x {} \; 2>/dev/null || true find tools/speed-comparison/UnifiedTests/bin -type f -name "xunit.v3.runner.console" -exec chmod +x {} \; 2>/dev/null || true @@ -90,21 +84,16 @@ jobs: uses: actions/upload-artifact@v5 if: always() with: - name: ${{ matrix.os }}_markdown_run_time_${{ matrix.class }} + name: ubuntu_markdown_run_time_${{ matrix.class }} path: | **/BenchmarkDotNet.Artifacts/** build-time-benchmarks: environment: ${{ github.ref == 'refs/heads/main' && 'Production' || 'Pull Requests' }} - strategy: - matrix: - os: [ubuntu-latest, windows-latest, macos-latest] - # framework: [net8.0, net9.0] - fail-fast: false - runs-on: ${{ matrix.os }} + runs-on: ubuntu-latest concurrency: - group: "speed-comparison-build-time-${{matrix.os}}" + group: "speed-comparison-build-time" cancel-in-progress: true steps: @@ -130,7 +119,7 @@ jobs: uses: actions/upload-artifact@v5 if: always() with: - name: ${{ matrix.os }}_markdown_build_time + name: ubuntu_markdown_build_time path: | **/BenchmarkDotNet.Artifacts/** From 0ad6e3d93b6cd8b4adea1341031a094e31fcb7ca Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 26 Oct 2025 20:41:53 +0000 Subject: [PATCH 11/67] refactor: enhance job configuration comments for clarity in BenchmarkConfig --- tools/speed-comparison/Tests.Benchmark/BenchmarkConfig.cs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tools/speed-comparison/Tests.Benchmark/BenchmarkConfig.cs b/tools/speed-comparison/Tests.Benchmark/BenchmarkConfig.cs index 5c0eff4abc..f60eba4fa6 100644 --- a/tools/speed-comparison/Tests.Benchmark/BenchmarkConfig.cs +++ b/tools/speed-comparison/Tests.Benchmark/BenchmarkConfig.cs @@ -11,7 +11,13 @@ public class BenchmarkConfig : ManualConfig { public BenchmarkConfig() { - AddJob(Job.Default.WithRuntime(CoreRuntime.Core10_0)); + // Configure job for CI compatibility - prevents power plan enforcement + // Note: GitHub Actions may still show priority warnings, which are harmless + var job = Job.Default + .WithRuntime(CoreRuntime.Core10_0) + .DontEnforcePowerPlan(); + + AddJob(job); AddLogger(ConsoleLogger.Default); AddExporter(MarkdownExporter.GitHub); From 30ab41169e45655ae092902ee3ad1874b84efc7a Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 26 Oct 2025 21:05:58 +0000 Subject: [PATCH 12/67] refactor: update job configuration for improved CI compatibility in BenchmarkConfig --- tools/speed-comparison/Tests.Benchmark/BenchmarkConfig.cs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tools/speed-comparison/Tests.Benchmark/BenchmarkConfig.cs b/tools/speed-comparison/Tests.Benchmark/BenchmarkConfig.cs index f60eba4fa6..bf8b669662 100644 --- a/tools/speed-comparison/Tests.Benchmark/BenchmarkConfig.cs +++ b/tools/speed-comparison/Tests.Benchmark/BenchmarkConfig.cs @@ -4,6 +4,7 @@ using BenchmarkDotNet.Exporters; using BenchmarkDotNet.Jobs; using BenchmarkDotNet.Loggers; +using Perfolizer.Mathematics.OutlierDetection; namespace Tests.Benchmark; @@ -11,10 +12,10 @@ public class BenchmarkConfig : ManualConfig { public BenchmarkConfig() { - // Configure job for CI compatibility - prevents power plan enforcement - // Note: GitHub Actions may still show priority warnings, which are harmless - var job = Job.Default + var job = Job.RyuJitX64 .WithRuntime(CoreRuntime.Core10_0) + .WithGcConcurrent(true) + .WithGcServer(true) .DontEnforcePowerPlan(); AddJob(job); From c05f98db05cfbabb218d1663a33ff0c9c585762f Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 26 Oct 2025 21:13:02 +0000 Subject: [PATCH 13/67] Remove SetupTeardownTests and SharedFixtureTests files to streamline test suite and eliminate redundant test cases. --- .../UnifiedTests/ConstructorCostTests.cs | 526 --------------- .../UnifiedTests/PerTestFixtureTests.cs | 493 -------------- .../UnifiedTests/SetupTeardownTests.cs | 611 ------------------ .../UnifiedTests/SharedFixtureTests.cs | 609 ----------------- 4 files changed, 2239 deletions(-) delete mode 100644 tools/speed-comparison/UnifiedTests/ConstructorCostTests.cs delete mode 100644 tools/speed-comparison/UnifiedTests/PerTestFixtureTests.cs delete mode 100644 tools/speed-comparison/UnifiedTests/SetupTeardownTests.cs delete mode 100644 tools/speed-comparison/UnifiedTests/SharedFixtureTests.cs diff --git a/tools/speed-comparison/UnifiedTests/ConstructorCostTests.cs b/tools/speed-comparison/UnifiedTests/ConstructorCostTests.cs deleted file mode 100644 index e7be0aa25c..0000000000 --- a/tools/speed-comparison/UnifiedTests/ConstructorCostTests.cs +++ /dev/null @@ -1,526 +0,0 @@ -using System.Text.Json; -using System.Threading.Tasks; - -namespace UnifiedTests; - -/// -/// Tests measuring the overhead of expensive constructor initialization. -/// All tests in this class share the same constructor, allowing measurement -/// of per-class initialization cost amortized across multiple tests. -/// -#if MSTEST -[TestClass] -public class ConstructorCostTests -#elif NUNIT -[TestFixture] -public class ConstructorCostTests -#elif XUNIT || XUNIT3 -public class ConstructorCostTests -#else -public class ConstructorCostTests -#endif -{ - private readonly Dictionary _configCache; - private readonly List _processedData; - private readonly HashSet _indexLookup; - private readonly string _serializedConfig; - private readonly int _totalRecords; - - public ConstructorCostTests() - { - // Simulate expensive constructor initialization - // Realistic work: JSON parsing, collection building, computation - - // Build configuration cache (simulates loading config) - _configCache = []; - for (var i = 0; i < 50; i++) - { - var config = new - { - Id = i, - Name = $"Config_{i}", - Enabled = i % 2 == 0, - Priority = i * 10, - Tags = new[] { $"tag_{i}", $"category_{i % 5}" }, - Timestamp = DateTime.UtcNow.Ticks - }; - _configCache[$"config_{i}"] = config; - } - - // Process and cache data (simulates data transformation) - _processedData = []; - for (var i = 0; i < 100; i++) - { - var data = $"Record_{i}_Data_{Guid.NewGuid().ToString()[..8]}"; - var processed = data.ToUpperInvariant() + $"_Processed_{i * 2}"; - _processedData.Add(processed); - } - - // Build index lookup (simulates index creation) - _indexLookup = []; - for (var i = 0; i < 100; i++) - { - _indexLookup.Add(i * 3); - } - - // Serialize configuration (simulates config serialization) - _serializedConfig = JsonSerializer.Serialize(_configCache); - - _totalRecords = _processedData.Count; - } - -#if TUNIT - [Test] - public async Task Constructor_Test_AccessConfigCache() -#elif XUNIT || XUNIT3 - [Fact] - public void Constructor_Test_AccessConfigCache() -#elif NUNIT - [Test] - public void Constructor_Test_AccessConfigCache() -#elif MSTEST - [TestMethod] - public void Constructor_Test_AccessConfigCache() -#endif - { - var config = _configCache["config_10"]; - -#if TUNIT - await Assert.That(config).IsNotNull(); - await Assert.That(_configCache).HasCount(50); -#elif XUNIT || XUNIT3 - Assert.NotNull(config); - Assert.Equal(50, _configCache.Count); -#elif NUNIT - Assert.That(config, Is.Not.Null); - Assert.That(_configCache.Count, Is.EqualTo(50)); -#elif MSTEST - Assert.IsNotNull(config); - Assert.AreEqual(50, _configCache.Count); -#endif - } - -#if TUNIT - [Test] - public async Task Constructor_Test_AccessProcessedData() -#elif XUNIT || XUNIT3 - [Fact] - public void Constructor_Test_AccessProcessedData() -#elif NUNIT - [Test] - public void Constructor_Test_AccessProcessedData() -#elif MSTEST - [TestMethod] - public void Constructor_Test_AccessProcessedData() -#endif - { - var firstRecord = _processedData[0]; - -#if TUNIT - await Assert.That(firstRecord).Contains("RECORD_0"); - await Assert.That(firstRecord).Contains("Processed_0"); -#elif XUNIT || XUNIT3 - Assert.Contains("RECORD_0", firstRecord); - Assert.Contains("Processed_0", firstRecord); -#elif NUNIT - Assert.That(firstRecord, Does.Contain("RECORD_0")); - Assert.That(firstRecord, Does.Contain("Processed_0")); -#elif MSTEST - Assert.IsTrue(firstRecord.Contains("RECORD_0")); - Assert.IsTrue(firstRecord.Contains("Processed_0")); -#endif - } - -#if TUNIT - [Test] - public async Task Constructor_Test_IndexLookup() -#elif XUNIT || XUNIT3 - [Fact] - public void Constructor_Test_IndexLookup() -#elif NUNIT - [Test] - public void Constructor_Test_IndexLookup() -#elif MSTEST - [TestMethod] - public void Constructor_Test_IndexLookup() -#endif - { - var hasValue = _indexLookup.Contains(15); - -#if TUNIT - await Assert.That(hasValue).IsTrue(); - await Assert.That(_indexLookup).HasCount(100); -#elif XUNIT || XUNIT3 - Assert.True(hasValue); - Assert.Equal(100, _indexLookup.Count); -#elif NUNIT - Assert.That(hasValue, Is.True); - Assert.That(_indexLookup.Count, Is.EqualTo(100)); -#elif MSTEST - Assert.IsTrue(hasValue); - Assert.AreEqual(100, _indexLookup.Count); -#endif - } - -#if TUNIT - [Test] - public async Task Constructor_Test_SerializedConfig() -#elif XUNIT || XUNIT3 - [Fact] - public void Constructor_Test_SerializedConfig() -#elif NUNIT - [Test] - public void Constructor_Test_SerializedConfig() -#elif MSTEST - [TestMethod] - public void Constructor_Test_SerializedConfig() -#endif - { - var isValid = _serializedConfig.Length > 100; - -#if TUNIT - await Assert.That(_serializedConfig).IsNotEmpty(); - await Assert.That(isValid).IsTrue(); -#elif XUNIT || XUNIT3 - Assert.NotEmpty(_serializedConfig); - Assert.True(isValid); -#elif NUNIT - Assert.That(_serializedConfig, Is.Not.Empty); - Assert.That(isValid, Is.True); -#elif MSTEST - Assert.IsTrue(_serializedConfig.Length > 0); - Assert.IsTrue(isValid); -#endif - } - -#if TUNIT - [Test] - public async Task Constructor_Test_TotalRecords() -#elif XUNIT || XUNIT3 - [Fact] - public void Constructor_Test_TotalRecords() -#elif NUNIT - [Test] - public void Constructor_Test_TotalRecords() -#elif MSTEST - [TestMethod] - public void Constructor_Test_TotalRecords() -#endif - { -#if TUNIT - await Assert.That(_totalRecords).IsEqualTo(100); - await Assert.That(_processedData).HasCount(_totalRecords); -#elif XUNIT || XUNIT3 - Assert.Equal(100, _totalRecords); - Assert.Equal(_totalRecords, _processedData.Count); -#elif NUNIT - Assert.That(_totalRecords, Is.EqualTo(100)); - Assert.That(_processedData.Count, Is.EqualTo(_totalRecords)); -#elif MSTEST - Assert.AreEqual(100, _totalRecords); - Assert.AreEqual(_totalRecords, _processedData.Count); -#endif - } - -#if TUNIT - [Test] - public async Task Constructor_Test_DataRange() -#elif XUNIT || XUNIT3 - [Fact] - public void Constructor_Test_DataRange() -#elif NUNIT - [Test] - public void Constructor_Test_DataRange() -#elif MSTEST - [TestMethod] - public void Constructor_Test_DataRange() -#endif - { - var midRecord = _processedData[50]; - -#if TUNIT - await Assert.That(midRecord).Contains("RECORD_50"); - await Assert.That(midRecord).IsNotEmpty(); -#elif XUNIT || XUNIT3 - Assert.Contains("RECORD_50", midRecord); - Assert.NotEmpty(midRecord); -#elif NUNIT - Assert.That(midRecord, Does.Contain("RECORD_50")); - Assert.That(midRecord, Is.Not.Empty); -#elif MSTEST - Assert.IsTrue(midRecord.Contains("RECORD_50")); - Assert.IsTrue(midRecord.Length > 0); -#endif - } - -#if TUNIT - [Test] - public async Task Constructor_Test_ConfigLookup() -#elif XUNIT || XUNIT3 - [Fact] - public void Constructor_Test_ConfigLookup() -#elif NUNIT - [Test] - public void Constructor_Test_ConfigLookup() -#elif MSTEST - [TestMethod] - public void Constructor_Test_ConfigLookup() -#endif - { - var hasKey = _configCache.ContainsKey("config_25"); - -#if TUNIT - await Assert.That(hasKey).IsTrue(); - await Assert.That(_configCache["config_25"]).IsNotNull(); -#elif XUNIT || XUNIT3 - Assert.True(hasKey); - Assert.NotNull(_configCache["config_25"]); -#elif NUNIT - Assert.That(hasKey, Is.True); - Assert.That(_configCache["config_25"], Is.Not.Null); -#elif MSTEST - Assert.IsTrue(hasKey); - Assert.IsNotNull(_configCache["config_25"]); -#endif - } - -#if TUNIT - [Test] - public async Task Constructor_Test_IndexContains() -#elif XUNIT || XUNIT3 - [Fact] - public void Constructor_Test_IndexContains() -#elif NUNIT - [Test] - public void Constructor_Test_IndexContains() -#elif MSTEST - [TestMethod] - public void Constructor_Test_IndexContains() -#endif - { - var contains30 = _indexLookup.Contains(30); - var contains99 = _indexLookup.Contains(99); - -#if TUNIT - await Assert.That(contains30).IsTrue(); - await Assert.That(contains99).IsTrue(); -#elif XUNIT || XUNIT3 - Assert.True(contains30); - Assert.True(contains99); -#elif NUNIT - Assert.That(contains30, Is.True); - Assert.That(contains99, Is.True); -#elif MSTEST - Assert.IsTrue(contains30); - Assert.IsTrue(contains99); -#endif - } - -#if TUNIT - [Test] - public async Task Constructor_Test_DataEndRange() -#elif XUNIT || XUNIT3 - [Fact] - public void Constructor_Test_DataEndRange() -#elif NUNIT - [Test] - public void Constructor_Test_DataEndRange() -#elif MSTEST - [TestMethod] - public void Constructor_Test_DataEndRange() -#endif - { - var lastRecord = _processedData[^1]; - -#if TUNIT - await Assert.That(lastRecord).Contains("RECORD_99"); - await Assert.That(lastRecord).Contains("Processed_198"); -#elif XUNIT || XUNIT3 - Assert.Contains("RECORD_99", lastRecord); - Assert.Contains("Processed_198", lastRecord); -#elif NUNIT - Assert.That(lastRecord, Does.Contain("RECORD_99")); - Assert.That(lastRecord, Does.Contain("Processed_198")); -#elif MSTEST - Assert.IsTrue(lastRecord.Contains("RECORD_99")); - Assert.IsTrue(lastRecord.Contains("Processed_198")); -#endif - } - -#if TUNIT - [Test] - public async Task Constructor_Test_ConfigRange() -#elif XUNIT || XUNIT3 - [Fact] - public void Constructor_Test_ConfigRange() -#elif NUNIT - [Test] - public void Constructor_Test_ConfigRange() -#elif MSTEST - [TestMethod] - public void Constructor_Test_ConfigRange() -#endif - { - var firstConfig = _configCache["config_0"]; - var lastConfig = _configCache["config_49"]; - -#if TUNIT - await Assert.That(firstConfig).IsNotNull(); - await Assert.That(lastConfig).IsNotNull(); -#elif XUNIT || XUNIT3 - Assert.NotNull(firstConfig); - Assert.NotNull(lastConfig); -#elif NUNIT - Assert.That(firstConfig, Is.Not.Null); - Assert.That(lastConfig, Is.Not.Null); -#elif MSTEST - Assert.IsNotNull(firstConfig); - Assert.IsNotNull(lastConfig); -#endif - } - -#if TUNIT - [Test] - public async Task Constructor_Test_IndexMax() -#elif XUNIT || XUNIT3 - [Fact] - public void Constructor_Test_IndexMax() -#elif NUNIT - [Test] - public void Constructor_Test_IndexMax() -#elif MSTEST - [TestMethod] - public void Constructor_Test_IndexMax() -#endif - { - var maxValue = _indexLookup.Max(); - -#if TUNIT - await Assert.That(maxValue).IsEqualTo(297); -#elif XUNIT || XUNIT3 - Assert.Equal(297, maxValue); -#elif NUNIT - Assert.That(maxValue, Is.EqualTo(297)); -#elif MSTEST - Assert.AreEqual(297, maxValue); -#endif - } - -#if TUNIT - [Test] - public async Task Constructor_Test_SerializedLength() -#elif XUNIT || XUNIT3 - [Fact] - public void Constructor_Test_SerializedLength() -#elif NUNIT - [Test] - public void Constructor_Test_SerializedLength() -#elif MSTEST - [TestMethod] - public void Constructor_Test_SerializedLength() -#endif - { - var length = _serializedConfig.Length; - var hasContent = length > 1000; - -#if TUNIT - await Assert.That(hasContent).IsTrue(); -#elif XUNIT || XUNIT3 - Assert.True(hasContent); -#elif NUNIT - Assert.That(hasContent, Is.True); -#elif MSTEST - Assert.IsTrue(hasContent); -#endif - } - -#if TUNIT - [Test] - public async Task Constructor_Test_DataConsistency() -#elif XUNIT || XUNIT3 - [Fact] - public void Constructor_Test_DataConsistency() -#elif NUNIT - [Test] - public void Constructor_Test_DataConsistency() -#elif MSTEST - [TestMethod] - public void Constructor_Test_DataConsistency() -#endif - { - var allProcessed = _processedData.All(d => d.Contains("Processed_")); - -#if TUNIT - await Assert.That(allProcessed).IsTrue(); - await Assert.That(_processedData).IsNotEmpty(); -#elif XUNIT || XUNIT3 - Assert.True(allProcessed); - Assert.NotEmpty(_processedData); -#elif NUNIT - Assert.That(allProcessed, Is.True); - Assert.That(_processedData, Is.Not.Empty); -#elif MSTEST - Assert.IsTrue(allProcessed); - Assert.IsTrue(_processedData.Count > 0); -#endif - } - -#if TUNIT - [Test] - public async Task Constructor_Test_IndexMin() -#elif XUNIT || XUNIT3 - [Fact] - public void Constructor_Test_IndexMin() -#elif NUNIT - [Test] - public void Constructor_Test_IndexMin() -#elif MSTEST - [TestMethod] - public void Constructor_Test_IndexMin() -#endif - { - var minValue = _indexLookup.Min(); - -#if TUNIT - await Assert.That(minValue).IsEqualTo(0); -#elif XUNIT || XUNIT3 - Assert.Equal(0, minValue); -#elif NUNIT - Assert.That(minValue, Is.EqualTo(0)); -#elif MSTEST - Assert.AreEqual(0, minValue); -#endif - } - -#if TUNIT - [Test] - public async Task Constructor_Test_ConfigCount() -#elif XUNIT || XUNIT3 - [Fact] - public void Constructor_Test_ConfigCount() -#elif NUNIT - [Test] - public void Constructor_Test_ConfigCount() -#elif MSTEST - [TestMethod] - public void Constructor_Test_ConfigCount() -#endif - { - var count = _configCache.Count; - var keysValid = _configCache.Keys.All(k => k.StartsWith("config_")); - -#if TUNIT - await Assert.That(count).IsEqualTo(50); - await Assert.That(keysValid).IsTrue(); -#elif XUNIT || XUNIT3 - Assert.Equal(50, count); - Assert.True(keysValid); -#elif NUNIT - Assert.That(count, Is.EqualTo(50)); - Assert.That(keysValid, Is.True); -#elif MSTEST - Assert.AreEqual(50, count); - Assert.IsTrue(keysValid); -#endif - } -} diff --git a/tools/speed-comparison/UnifiedTests/PerTestFixtureTests.cs b/tools/speed-comparison/UnifiedTests/PerTestFixtureTests.cs deleted file mode 100644 index 53c18649b1..0000000000 --- a/tools/speed-comparison/UnifiedTests/PerTestFixtureTests.cs +++ /dev/null @@ -1,493 +0,0 @@ -using System.Text.Json; -using System.Threading.Tasks; - -namespace UnifiedTests; - -/// -/// Tests measuring the overhead of per-test isolated fixture creation. -/// Each test gets its own fresh fixture instance, simulating scenarios -/// where tests require isolated state and cannot share resources. -/// -#if MSTEST -[TestClass] -public class PerTestFixtureTests : IDisposable -#elif NUNIT -[TestFixture] -public class PerTestFixtureTests : IDisposable -#elif XUNIT || XUNIT3 -public class PerTestFixtureTests : IDisposable -#else -public class PerTestFixtureTests : IDisposable -#endif -{ - private Dictionary _testCache; - private List _processedItems; - private HashSet _usedIds; - private string _testId; - - public PerTestFixtureTests() - { - // Per-test initialization - each test gets a fresh fixture - // Simulates scenarios requiring isolation (e.g., database transactions, file handles) - - _testId = Guid.NewGuid().ToString()[..8]; - _testCache = []; - _processedItems = []; - _usedIds = []; - - // Initialize test-specific data - for (var i = 0; i < 30; i++) - { - _testCache[$"item_{i}"] = i * 10; - _usedIds.Add(i); - } - - // Process some initial data - for (var i = 0; i < 20; i++) - { - var item = $"TestData_{_testId}_{i}_{DateTime.UtcNow.Ticks % 1000}"; - _processedItems.Add(item); - } - } - - public void Dispose() - { - // Cleanup per-test resources - _testCache?.Clear(); - _processedItems?.Clear(); - _usedIds?.Clear(); - } - -#if TUNIT - [Test] - public async Task PerTestFixture_Test1() -#elif XUNIT || XUNIT3 - [Fact] - public void PerTestFixture_Test1() -#elif NUNIT - [Test] - public void PerTestFixture_Test1() -#elif MSTEST - [TestMethod] - public void PerTestFixture_Test1() -#endif - { - var value = _testCache["item_5"]; - -#if TUNIT - await Assert.That(value).IsEqualTo(50); - await Assert.That(_testCache).HasCount(30); -#elif XUNIT || XUNIT3 - Assert.Equal(50, value); - Assert.Equal(30, _testCache.Count); -#elif NUNIT - Assert.That(value, Is.EqualTo(50)); - Assert.That(_testCache.Count, Is.EqualTo(30)); -#elif MSTEST - Assert.AreEqual(50, value); - Assert.AreEqual(30, _testCache.Count); -#endif - } - -#if TUNIT - [Test] - public async Task PerTestFixture_Test2() -#elif XUNIT || XUNIT3 - [Fact] - public void PerTestFixture_Test2() -#elif NUNIT - [Test] - public void PerTestFixture_Test2() -#elif MSTEST - [TestMethod] - public void PerTestFixture_Test2() -#endif - { - var count = _processedItems.Count; - -#if TUNIT - await Assert.That(count).IsEqualTo(20); - await Assert.That(_processedItems[0]).Contains(_testId); -#elif XUNIT || XUNIT3 - Assert.Equal(20, count); - Assert.Contains(_testId, _processedItems[0]); -#elif NUNIT - Assert.That(count, Is.EqualTo(20)); - Assert.That(_processedItems[0], Does.Contain(_testId)); -#elif MSTEST - Assert.AreEqual(20, count); - Assert.IsTrue(_processedItems[0].Contains(_testId)); -#endif - } - -#if TUNIT - [Test] - public async Task PerTestFixture_Test3() -#elif XUNIT || XUNIT3 - [Fact] - public void PerTestFixture_Test3() -#elif NUNIT - [Test] - public void PerTestFixture_Test3() -#elif MSTEST - [TestMethod] - public void PerTestFixture_Test3() -#endif - { - var hasId = _usedIds.Contains(15); - -#if TUNIT - await Assert.That(hasId).IsTrue(); - await Assert.That(_usedIds).HasCount(30); -#elif XUNIT || XUNIT3 - Assert.True(hasId); - Assert.Equal(30, _usedIds.Count); -#elif NUNIT - Assert.That(hasId, Is.True); - Assert.That(_usedIds.Count, Is.EqualTo(30)); -#elif MSTEST - Assert.IsTrue(hasId); - Assert.AreEqual(30, _usedIds.Count); -#endif - } - -#if TUNIT - [Test] - public async Task PerTestFixture_Test4() -#elif XUNIT || XUNIT3 - [Fact] - public void PerTestFixture_Test4() -#elif NUNIT - [Test] - public void PerTestFixture_Test4() -#elif MSTEST - [TestMethod] - public void PerTestFixture_Test4() -#endif - { - var testIdLength = _testId.Length; - -#if TUNIT - await Assert.That(testIdLength).IsEqualTo(8); - await Assert.That(_testId).IsNotEmpty(); -#elif XUNIT || XUNIT3 - Assert.Equal(8, testIdLength); - Assert.NotEmpty(_testId); -#elif NUNIT - Assert.That(testIdLength, Is.EqualTo(8)); - Assert.That(_testId, Is.Not.Empty); -#elif MSTEST - Assert.AreEqual(8, testIdLength); - Assert.IsTrue(_testId.Length > 0); -#endif - } - -#if TUNIT - [Test] - public async Task PerTestFixture_Test5() -#elif XUNIT || XUNIT3 - [Fact] - public void PerTestFixture_Test5() -#elif NUNIT - [Test] - public void PerTestFixture_Test5() -#elif MSTEST - [TestMethod] - public void PerTestFixture_Test5() -#endif - { - var sum = _testCache.Values.Sum(); - -#if TUNIT - await Assert.That(sum).IsEqualTo(4350); -#elif XUNIT || XUNIT3 - Assert.Equal(4350, sum); -#elif NUNIT - Assert.That(sum, Is.EqualTo(4350)); -#elif MSTEST - Assert.AreEqual(4350, sum); -#endif - } - -#if TUNIT - [Test] - public async Task PerTestFixture_Test6() -#elif XUNIT || XUNIT3 - [Fact] - public void PerTestFixture_Test6() -#elif NUNIT - [Test] - public void PerTestFixture_Test6() -#elif MSTEST - [TestMethod] - public void PerTestFixture_Test6() -#endif - { - var lastItem = _processedItems[^1]; - -#if TUNIT - await Assert.That(lastItem).Contains("TestData_"); - await Assert.That(lastItem).Contains(_testId); -#elif XUNIT || XUNIT3 - Assert.Contains("TestData_", lastItem); - Assert.Contains(_testId, lastItem); -#elif NUNIT - Assert.That(lastItem, Does.Contain("TestData_")); - Assert.That(lastItem, Does.Contain(_testId)); -#elif MSTEST - Assert.IsTrue(lastItem.Contains("TestData_")); - Assert.IsTrue(lastItem.Contains(_testId)); -#endif - } - -#if TUNIT - [Test] - public async Task PerTestFixture_Test7() -#elif XUNIT || XUNIT3 - [Fact] - public void PerTestFixture_Test7() -#elif NUNIT - [Test] - public void PerTestFixture_Test7() -#elif MSTEST - [TestMethod] - public void PerTestFixture_Test7() -#endif - { - var maxId = _usedIds.Max(); - -#if TUNIT - await Assert.That(maxId).IsEqualTo(29); -#elif XUNIT || XUNIT3 - Assert.Equal(29, maxId); -#elif NUNIT - Assert.That(maxId, Is.EqualTo(29)); -#elif MSTEST - Assert.AreEqual(29, maxId); -#endif - } - -#if TUNIT - [Test] - public async Task PerTestFixture_Test8() -#elif XUNIT || XUNIT3 - [Fact] - public void PerTestFixture_Test8() -#elif NUNIT - [Test] - public void PerTestFixture_Test8() -#elif MSTEST - [TestMethod] - public void PerTestFixture_Test8() -#endif - { - var hasKey = _testCache.ContainsKey("item_20"); - -#if TUNIT - await Assert.That(hasKey).IsTrue(); - await Assert.That(_testCache["item_20"]).IsEqualTo(200); -#elif XUNIT || XUNIT3 - Assert.True(hasKey); - Assert.Equal(200, _testCache["item_20"]); -#elif NUNIT - Assert.That(hasKey, Is.True); - Assert.That(_testCache["item_20"], Is.EqualTo(200)); -#elif MSTEST - Assert.IsTrue(hasKey); - Assert.AreEqual(200, _testCache["item_20"]); -#endif - } - -#if TUNIT - [Test] - public async Task PerTestFixture_Test9() -#elif XUNIT || XUNIT3 - [Fact] - public void PerTestFixture_Test9() -#elif NUNIT - [Test] - public void PerTestFixture_Test9() -#elif MSTEST - [TestMethod] - public void PerTestFixture_Test9() -#endif - { - var allContainTestId = _processedItems.All(item => item.Contains(_testId)); - -#if TUNIT - await Assert.That(allContainTestId).IsTrue(); -#elif XUNIT || XUNIT3 - Assert.True(allContainTestId); -#elif NUNIT - Assert.That(allContainTestId, Is.True); -#elif MSTEST - Assert.IsTrue(allContainTestId); -#endif - } - -#if TUNIT - [Test] - public async Task PerTestFixture_Test10() -#elif XUNIT || XUNIT3 - [Fact] - public void PerTestFixture_Test10() -#elif NUNIT - [Test] - public void PerTestFixture_Test10() -#elif MSTEST - [TestMethod] - public void PerTestFixture_Test10() -#endif - { - var minId = _usedIds.Min(); - -#if TUNIT - await Assert.That(minId).IsEqualTo(0); -#elif XUNIT || XUNIT3 - Assert.Equal(0, minId); -#elif NUNIT - Assert.That(minId, Is.EqualTo(0)); -#elif MSTEST - Assert.AreEqual(0, minId); -#endif - } - -#if TUNIT - [Test] - public async Task PerTestFixture_Test11() -#elif XUNIT || XUNIT3 - [Fact] - public void PerTestFixture_Test11() -#elif NUNIT - [Test] - public void PerTestFixture_Test11() -#elif MSTEST - [TestMethod] - public void PerTestFixture_Test11() -#endif - { - var firstValue = _testCache["item_0"]; - -#if TUNIT - await Assert.That(firstValue).IsEqualTo(0); -#elif XUNIT || XUNIT3 - Assert.Equal(0, firstValue); -#elif NUNIT - Assert.That(firstValue, Is.EqualTo(0)); -#elif MSTEST - Assert.AreEqual(0, firstValue); -#endif - } - -#if TUNIT - [Test] - public async Task PerTestFixture_Test12() -#elif XUNIT || XUNIT3 - [Fact] - public void PerTestFixture_Test12() -#elif NUNIT - [Test] - public void PerTestFixture_Test12() -#elif MSTEST - [TestMethod] - public void PerTestFixture_Test12() -#endif - { - var avg = _testCache.Values.Average(); - -#if TUNIT - await Assert.That(avg).IsEqualTo(145.0); -#elif XUNIT || XUNIT3 - Assert.Equal(145.0, avg); -#elif NUNIT - Assert.That(avg, Is.EqualTo(145.0)); -#elif MSTEST - Assert.AreEqual(145.0, avg); -#endif - } - -#if TUNIT - [Test] - public async Task PerTestFixture_Test13() -#elif XUNIT || XUNIT3 - [Fact] - public void PerTestFixture_Test13() -#elif NUNIT - [Test] - public void PerTestFixture_Test13() -#elif MSTEST - [TestMethod] - public void PerTestFixture_Test13() -#endif - { - var firstItem = _processedItems[0]; - -#if TUNIT - await Assert.That(firstItem).StartsWith("TestData_"); -#elif XUNIT || XUNIT3 - Assert.StartsWith("TestData_", firstItem); -#elif NUNIT - Assert.That(firstItem, Does.StartWith("TestData_")); -#elif MSTEST - Assert.IsTrue(firstItem.StartsWith("TestData_")); -#endif - } - -#if TUNIT - [Test] - public async Task PerTestFixture_Test14() -#elif XUNIT || XUNIT3 - [Fact] - public void PerTestFixture_Test14() -#elif NUNIT - [Test] - public void PerTestFixture_Test14() -#elif MSTEST - [TestMethod] - public void PerTestFixture_Test14() -#endif - { - var lastValue = _testCache["item_29"]; - -#if TUNIT - await Assert.That(lastValue).IsEqualTo(290); -#elif XUNIT || XUNIT3 - Assert.Equal(290, lastValue); -#elif NUNIT - Assert.That(lastValue, Is.EqualTo(290)); -#elif MSTEST - Assert.AreEqual(290, lastValue); -#endif - } - -#if TUNIT - [Test] - public async Task PerTestFixture_Test15() -#elif XUNIT || XUNIT3 - [Fact] - public void PerTestFixture_Test15() -#elif NUNIT - [Test] - public void PerTestFixture_Test15() -#elif MSTEST - [TestMethod] - public void PerTestFixture_Test15() -#endif - { - var keys = _testCache.Keys.ToList(); - -#if TUNIT - await Assert.That(keys).HasCount(30); - await Assert.That(keys.All(k => k.StartsWith("item_"))).IsTrue(); -#elif XUNIT || XUNIT3 - Assert.Equal(30, keys.Count); - Assert.True(keys.All(k => k.StartsWith("item_"))); -#elif NUNIT - Assert.That(keys.Count, Is.EqualTo(30)); - Assert.That(keys.All(k => k.StartsWith("item_")), Is.True); -#elif MSTEST - Assert.AreEqual(30, keys.Count); - Assert.IsTrue(keys.All(k => k.StartsWith("item_"))); -#endif - } -} diff --git a/tools/speed-comparison/UnifiedTests/SetupTeardownTests.cs b/tools/speed-comparison/UnifiedTests/SetupTeardownTests.cs deleted file mode 100644 index d550afea0a..0000000000 --- a/tools/speed-comparison/UnifiedTests/SetupTeardownTests.cs +++ /dev/null @@ -1,611 +0,0 @@ -using System.Threading.Tasks; - -namespace UnifiedTests; - -/// -/// Tests measuring the overhead of setup and teardown lifecycle hooks. -/// Each test goes through setup before execution and teardown after, -/// allowing measurement of per-test lifecycle hook overhead. -/// -#if MSTEST -[TestClass] -public class SetupTeardownTests : IDisposable -#elif NUNIT -[TestFixture] -public class SetupTeardownTests : IDisposable -#elif XUNIT || XUNIT3 -public class SetupTeardownTests : IDisposable -#else -public class SetupTeardownTests : IDisposable -#endif -{ - private Dictionary _testState; - private List _workingData; - private HashSet _processedKeys; - private int _setupCounter; - - public SetupTeardownTests() - { - // Lightweight constructor - just initialize empty collections - _testState = []; - _workingData = []; - _processedKeys = []; - _setupCounter = 0; - } - -#if !XUNIT && !XUNIT3 -#if TUNIT - [Before(Test)] -#elif MSTEST - [TestInitialize] -#elif NUNIT - [SetUp] -#endif - public void Setup() - { - // Reset and prepare state for each test - _testState.Clear(); - _workingData.Clear(); - _processedKeys.Clear(); - - // Simulate realistic setup work - for (var i = 0; i < 20; i++) - { - _testState[$"key_{i}"] = $"value_{i}_{Guid.NewGuid().ToString()[..8]}"; - _workingData.Add(i * 10); - } - - _setupCounter++; - } -#else - // xUnit doesn't have per-test setup - constructor is called per test - // This creates a fair comparison of lifecycle overhead - private void Setup() - { - // Reset and prepare state for each test - _testState.Clear(); - _workingData.Clear(); - _processedKeys.Clear(); - - // Simulate realistic setup work - for (var i = 0; i < 20; i++) - { - _testState[$"key_{i}"] = $"value_{i}_{Guid.NewGuid().ToString()[..8]}"; - _workingData.Add(i * 10); - } - - _setupCounter++; - } -#endif - -#if !XUNIT && !XUNIT3 -#if TUNIT - [After(Test)] -#elif MSTEST - [TestCleanup] -#elif NUNIT - [TearDown] -#endif - public void Teardown() - { - // Actual cleanup work - _testState.Clear(); - _workingData.Clear(); - _processedKeys.Clear(); - } -#endif - - public void Dispose() - { -#if XUNIT || XUNIT3 - // For xUnit, Dispose is called after each test - _testState?.Clear(); - _workingData?.Clear(); - _processedKeys?.Clear(); -#else - // For other frameworks, cleanup happens in class disposal - _testState?.Clear(); - _workingData?.Clear(); - _processedKeys?.Clear(); -#endif - } - -#if TUNIT - [Test] - public async Task SetupTeardown_Test1() -#elif XUNIT || XUNIT3 - [Fact] - public void SetupTeardown_Test1() -#elif NUNIT - [Test] - public void SetupTeardown_Test1() -#elif MSTEST - [TestMethod] - public void SetupTeardown_Test1() -#endif - { -#if XUNIT || XUNIT3 - // Simulate setup for xUnit to maintain fairness - Setup(); -#endif - - var value = _testState.GetValueOrDefault("key_5", ""); - -#if TUNIT - await Assert.That(value).IsNotEmpty(); - await Assert.That(value).StartsWith("value_5"); -#elif XUNIT || XUNIT3 - Assert.NotEmpty(value); - Assert.StartsWith("value_5", value); -#elif NUNIT - Assert.That(value, Is.Not.Empty); - Assert.That(value, Does.StartWith("value_5")); -#elif MSTEST - Assert.IsTrue(value.Length > 0); - Assert.IsTrue(value.StartsWith("value_5")); -#endif - } - -#if TUNIT - [Test] - public async Task SetupTeardown_Test2() -#elif XUNIT || XUNIT3 - [Fact] - public void SetupTeardown_Test2() -#elif NUNIT - [Test] - public void SetupTeardown_Test2() -#elif MSTEST - [TestMethod] - public void SetupTeardown_Test2() -#endif - { -#if XUNIT || XUNIT3 - Setup(); -#endif - - var count = _workingData.Count; - -#if TUNIT - await Assert.That(count).IsEqualTo(20); - await Assert.That(_workingData[0]).IsEqualTo(0); -#elif XUNIT || XUNIT3 - Assert.Equal(20, count); - Assert.Equal(0, _workingData[0]); -#elif NUNIT - Assert.That(count, Is.EqualTo(20)); - Assert.That(_workingData[0], Is.EqualTo(0)); -#elif MSTEST - Assert.AreEqual(20, count); - Assert.AreEqual(0, _workingData[0]); -#endif - } - -#if TUNIT - [Test] - public async Task SetupTeardown_Test3() -#elif XUNIT || XUNIT3 - [Fact] - public void SetupTeardown_Test3() -#elif NUNIT - [Test] - public void SetupTeardown_Test3() -#elif MSTEST - [TestMethod] - public void SetupTeardown_Test3() -#endif - { -#if XUNIT || XUNIT3 - Setup(); -#endif - - _processedKeys.Add("test_key_1"); - _processedKeys.Add("test_key_2"); - -#if TUNIT - await Assert.That(_processedKeys).HasCount(2); - await Assert.That(_processedKeys.Contains("test_key_1")).IsTrue(); -#elif XUNIT || XUNIT3 - Assert.Equal(2, _processedKeys.Count); - Assert.True(_processedKeys.Contains("test_key_1")); -#elif NUNIT - Assert.That(_processedKeys.Count, Is.EqualTo(2)); - Assert.That(_processedKeys.Contains("test_key_1"), Is.True); -#elif MSTEST - Assert.AreEqual(2, _processedKeys.Count); - Assert.IsTrue(_processedKeys.Contains("test_key_1")); -#endif - } - -#if TUNIT - [Test] - public async Task SetupTeardown_Test4() -#elif XUNIT || XUNIT3 - [Fact] - public void SetupTeardown_Test4() -#elif NUNIT - [Test] - public void SetupTeardown_Test4() -#elif MSTEST - [TestMethod] - public void SetupTeardown_Test4() -#endif - { -#if XUNIT || XUNIT3 - Setup(); -#endif - - var lastValue = _workingData[^1]; - -#if TUNIT - await Assert.That(lastValue).IsEqualTo(190); -#elif XUNIT || XUNIT3 - Assert.Equal(190, lastValue); -#elif NUNIT - Assert.That(lastValue, Is.EqualTo(190)); -#elif MSTEST - Assert.AreEqual(190, lastValue); -#endif - } - -#if TUNIT - [Test] - public async Task SetupTeardown_Test5() -#elif XUNIT || XUNIT3 - [Fact] - public void SetupTeardown_Test5() -#elif NUNIT - [Test] - public void SetupTeardown_Test5() -#elif MSTEST - [TestMethod] - public void SetupTeardown_Test5() -#endif - { -#if XUNIT || XUNIT3 - Setup(); -#endif - - var hasKey = _testState.ContainsKey("key_10"); - -#if TUNIT - await Assert.That(hasKey).IsTrue(); - await Assert.That(_testState).HasCount(20); -#elif XUNIT || XUNIT3 - Assert.True(hasKey); - Assert.Equal(20, _testState.Count); -#elif NUNIT - Assert.That(hasKey, Is.True); - Assert.That(_testState.Count, Is.EqualTo(20)); -#elif MSTEST - Assert.IsTrue(hasKey); - Assert.AreEqual(20, _testState.Count); -#endif - } - -#if TUNIT - [Test] - public async Task SetupTeardown_Test6() -#elif XUNIT || XUNIT3 - [Fact] - public void SetupTeardown_Test6() -#elif NUNIT - [Test] - public void SetupTeardown_Test6() -#elif MSTEST - [TestMethod] - public void SetupTeardown_Test6() -#endif - { -#if XUNIT || XUNIT3 - Setup(); -#endif - - var sum = _workingData.Sum(); - -#if TUNIT - await Assert.That(sum).IsEqualTo(1900); -#elif XUNIT || XUNIT3 - Assert.Equal(1900, sum); -#elif NUNIT - Assert.That(sum, Is.EqualTo(1900)); -#elif MSTEST - Assert.AreEqual(1900, sum); -#endif - } - -#if TUNIT - [Test] - public async Task SetupTeardown_Test7() -#elif XUNIT || XUNIT3 - [Fact] - public void SetupTeardown_Test7() -#elif NUNIT - [Test] - public void SetupTeardown_Test7() -#elif MSTEST - [TestMethod] - public void SetupTeardown_Test7() -#endif - { -#if XUNIT || XUNIT3 - Setup(); -#endif - - var keys = _testState.Keys.ToList(); - -#if TUNIT - await Assert.That(keys).HasCount(20); - await Assert.That(keys[0]).StartsWith("key_"); -#elif XUNIT || XUNIT3 - Assert.Equal(20, keys.Count); - Assert.StartsWith("key_", keys[0]); -#elif NUNIT - Assert.That(keys.Count, Is.EqualTo(20)); - Assert.That(keys[0], Does.StartWith("key_")); -#elif MSTEST - Assert.AreEqual(20, keys.Count); - Assert.IsTrue(keys[0].StartsWith("key_")); -#endif - } - -#if TUNIT - [Test] - public async Task SetupTeardown_Test8() -#elif XUNIT || XUNIT3 - [Fact] - public void SetupTeardown_Test8() -#elif NUNIT - [Test] - public void SetupTeardown_Test8() -#elif MSTEST - [TestMethod] - public void SetupTeardown_Test8() -#endif - { -#if XUNIT || XUNIT3 - Setup(); -#endif - - _processedKeys.Add("processed_1"); - var hasProcessed = _processedKeys.Contains("processed_1"); - -#if TUNIT - await Assert.That(hasProcessed).IsTrue(); -#elif XUNIT || XUNIT3 - Assert.True(hasProcessed); -#elif NUNIT - Assert.That(hasProcessed, Is.True); -#elif MSTEST - Assert.IsTrue(hasProcessed); -#endif - } - -#if TUNIT - [Test] - public async Task SetupTeardown_Test9() -#elif XUNIT || XUNIT3 - [Fact] - public void SetupTeardown_Test9() -#elif NUNIT - [Test] - public void SetupTeardown_Test9() -#elif MSTEST - [TestMethod] - public void SetupTeardown_Test9() -#endif - { -#if XUNIT || XUNIT3 - Setup(); -#endif - - var midValue = _workingData[10]; - -#if TUNIT - await Assert.That(midValue).IsEqualTo(100); -#elif XUNIT || XUNIT3 - Assert.Equal(100, midValue); -#elif NUNIT - Assert.That(midValue, Is.EqualTo(100)); -#elif MSTEST - Assert.AreEqual(100, midValue); -#endif - } - -#if TUNIT - [Test] - public async Task SetupTeardown_Test10() -#elif XUNIT || XUNIT3 - [Fact] - public void SetupTeardown_Test10() -#elif NUNIT - [Test] - public void SetupTeardown_Test10() -#elif MSTEST - [TestMethod] - public void SetupTeardown_Test10() -#endif - { -#if XUNIT || XUNIT3 - Setup(); -#endif - - var allValues = _testState.Values.ToList(); - -#if TUNIT - await Assert.That(allValues).HasCount(20); - await Assert.That(allValues.All(v => v.StartsWith("value_"))).IsTrue(); -#elif XUNIT || XUNIT3 - Assert.Equal(20, allValues.Count); - Assert.True(allValues.All(v => v.StartsWith("value_"))); -#elif NUNIT - Assert.That(allValues.Count, Is.EqualTo(20)); - Assert.That(allValues.All(v => v.StartsWith("value_")), Is.True); -#elif MSTEST - Assert.AreEqual(20, allValues.Count); - Assert.IsTrue(allValues.All(v => v.StartsWith("value_"))); -#endif - } - -#if TUNIT - [Test] - public async Task SetupTeardown_Test11() -#elif XUNIT || XUNIT3 - [Fact] - public void SetupTeardown_Test11() -#elif NUNIT - [Test] - public void SetupTeardown_Test11() -#elif MSTEST - [TestMethod] - public void SetupTeardown_Test11() -#endif - { -#if XUNIT || XUNIT3 - Setup(); -#endif - - var avg = _workingData.Average(); - -#if TUNIT - await Assert.That(avg).IsEqualTo(95.0); -#elif XUNIT || XUNIT3 - Assert.Equal(95.0, avg); -#elif NUNIT - Assert.That(avg, Is.EqualTo(95.0)); -#elif MSTEST - Assert.AreEqual(95.0, avg); -#endif - } - -#if TUNIT - [Test] - public async Task SetupTeardown_Test12() -#elif XUNIT || XUNIT3 - [Fact] - public void SetupTeardown_Test12() -#elif NUNIT - [Test] - public void SetupTeardown_Test12() -#elif MSTEST - [TestMethod] - public void SetupTeardown_Test12() -#endif - { -#if XUNIT || XUNIT3 - Setup(); -#endif - - var firstKey = "key_0"; - var value = _testState[firstKey]; - -#if TUNIT - await Assert.That(value).StartsWith("value_0"); -#elif XUNIT || XUNIT3 - Assert.StartsWith("value_0", value); -#elif NUNIT - Assert.That(value, Does.StartWith("value_0")); -#elif MSTEST - Assert.IsTrue(value.StartsWith("value_0")); -#endif - } - -#if TUNIT - [Test] - public async Task SetupTeardown_Test13() -#elif XUNIT || XUNIT3 - [Fact] - public void SetupTeardown_Test13() -#elif NUNIT - [Test] - public void SetupTeardown_Test13() -#elif MSTEST - [TestMethod] - public void SetupTeardown_Test13() -#endif - { -#if XUNIT || XUNIT3 - Setup(); -#endif - - var max = _workingData.Max(); - -#if TUNIT - await Assert.That(max).IsEqualTo(190); -#elif XUNIT || XUNIT3 - Assert.Equal(190, max); -#elif NUNIT - Assert.That(max, Is.EqualTo(190)); -#elif MSTEST - Assert.AreEqual(190, max); -#endif - } - -#if TUNIT - [Test] - public async Task SetupTeardown_Test14() -#elif XUNIT || XUNIT3 - [Fact] - public void SetupTeardown_Test14() -#elif NUNIT - [Test] - public void SetupTeardown_Test14() -#elif MSTEST - [TestMethod] - public void SetupTeardown_Test14() -#elif MSTEST - [TestMethod] - public void SetupTeardown_Test14() -#endif - { -#if XUNIT || XUNIT3 - Setup(); -#endif - - _processedKeys.Add("temp_1"); - _processedKeys.Add("temp_2"); - _processedKeys.Add("temp_3"); - -#if TUNIT - await Assert.That(_processedKeys).HasCount(3); -#elif XUNIT || XUNIT3 - Assert.Equal(3, _processedKeys.Count); -#elif NUNIT - Assert.That(_processedKeys.Count, Is.EqualTo(3)); -#elif MSTEST - Assert.AreEqual(3, _processedKeys.Count); -#endif - } - -#if TUNIT - [Test] - public async Task SetupTeardown_Test15() -#elif XUNIT || XUNIT3 - [Fact] - public void SetupTeardown_Test15() -#elif NUNIT - [Test] - public void SetupTeardown_Test15() -#elif MSTEST - [TestMethod] - public void SetupTeardown_Test15() -#endif - { -#if XUNIT || XUNIT3 - Setup(); -#endif - - var lastKey = "key_19"; - var hasLastKey = _testState.ContainsKey(lastKey); - -#if TUNIT - await Assert.That(hasLastKey).IsTrue(); -#elif XUNIT || XUNIT3 - Assert.True(hasLastKey); -#elif NUNIT - Assert.That(hasLastKey, Is.True); -#elif MSTEST - Assert.IsTrue(hasLastKey); -#endif - } -} diff --git a/tools/speed-comparison/UnifiedTests/SharedFixtureTests.cs b/tools/speed-comparison/UnifiedTests/SharedFixtureTests.cs deleted file mode 100644 index 5776af7639..0000000000 --- a/tools/speed-comparison/UnifiedTests/SharedFixtureTests.cs +++ /dev/null @@ -1,609 +0,0 @@ -using System.Text.Json; -using System.Threading.Tasks; - -namespace UnifiedTests; - -/// -/// Tests measuring the efficiency of shared class-level fixtures. -/// The fixture is initialized once and reused across all tests, -/// allowing measurement of fixture sharing and reuse overhead. -/// - -// Shared fixture class for xUnit -#if XUNIT || XUNIT3 -public class SharedTestFixture : IDisposable -{ - public Dictionary SharedCache { get; } - public List ProcessedRecords { get; } - public string ConfigurationJson { get; } - - public SharedTestFixture() - { - // Expensive one-time initialization - SharedCache = []; - ProcessedRecords = []; - - // Build shared cache - for (var i = 0; i < 100; i++) - { - SharedCache[i] = $"CachedValue_{i}_{Guid.NewGuid().ToString()[..8]}"; - } - - // Process records - for (var i = 0; i < 50; i++) - { - ProcessedRecords.Add(new ProcessedRecord - { - Id = i, - Name = $"Record_{i}", - Value = i * 100, - Timestamp = DateTime.UtcNow - }); - } - - // Serialize configuration - ConfigurationJson = JsonSerializer.Serialize(new - { - Version = "1.0", - Environment = "Test", - Settings = SharedCache.Take(10).ToDictionary(kvp => kvp.Key.ToString(), kvp => kvp.Value) - }); - } - - public void Dispose() - { - SharedCache?.Clear(); - ProcessedRecords?.Clear(); - } -} - -public class ProcessedRecord -{ - public int Id { get; set; } - public string Name { get; set; } = ""; - public int Value { get; set; } - public DateTime Timestamp { get; set; } -} -#endif - -#if MSTEST -[TestClass] -public class SharedFixtureTests -#elif NUNIT -[TestFixture] -public class SharedFixtureTests -#elif XUNIT || XUNIT3 -public class SharedFixtureTests : IClassFixture -#else -public class SharedFixtureTests -#endif -{ -#if !XUNIT && !XUNIT3 - private static Dictionary _sharedCache; - private static List _processedRecords; - private static string _configurationJson; - - public class ProcessedRecord - { - public int Id { get; set; } - public string Name { get; set; } = ""; - public int Value { get; set; } - public DateTime Timestamp { get; set; } - } -#endif - -#if XUNIT || XUNIT3 - private readonly SharedTestFixture _fixture; - - public SharedFixtureTests(SharedTestFixture fixture) - { - _fixture = fixture; - } -#endif - -#if !XUNIT && !XUNIT3 -#if TUNIT - [Before(Class)] - public static void ClassSetup() -#elif MSTEST - [ClassInitialize] - public static void ClassSetup(TestContext context) -#elif NUNIT - [OneTimeSetUp] - public void ClassSetup() -#endif - { - // One-time expensive initialization shared across all tests - _sharedCache = []; - _processedRecords = []; - - // Build shared cache - for (var i = 0; i < 100; i++) - { - _sharedCache[i] = $"CachedValue_{i}_{Guid.NewGuid().ToString()[..8]}"; - } - - // Process records - for (var i = 0; i < 50; i++) - { - _processedRecords.Add(new ProcessedRecord - { - Id = i, - Name = $"Record_{i}", - Value = i * 100, - Timestamp = DateTime.UtcNow - }); - } - - // Serialize configuration - _configurationJson = JsonSerializer.Serialize(new - { - Version = "1.0", - Environment = "Test", - Settings = _sharedCache.Take(10).ToDictionary(kvp => kvp.Key.ToString(), kvp => kvp.Value) - }); - } - -#if TUNIT - [After(Class)] - public static void ClassCleanup() -#elif MSTEST - [ClassCleanup] - public static void ClassCleanup() -#elif NUNIT - [OneTimeTearDown] - public void ClassCleanup() -#endif - { - _sharedCache?.Clear(); - _processedRecords?.Clear(); - } -#endif - -#if TUNIT - [Test] - public async Task SharedFixture_Test1() -#elif XUNIT || XUNIT3 - [Fact] - public void SharedFixture_Test1() -#elif NUNIT - [Test] - public void SharedFixture_Test1() -#elif MSTEST - [TestMethod] - public void SharedFixture_Test1() -#endif - { -#if XUNIT || XUNIT3 - var value = _fixture.SharedCache[10]; - Assert.NotEmpty(value); - Assert.Contains("CachedValue_10", value); -#elif TUNIT - var value = _sharedCache[10]; - await Assert.That(value).IsNotEmpty(); - await Assert.That(value).Contains("CachedValue_10"); -#elif NUNIT - var value = _sharedCache[10]; - Assert.That(value, Is.Not.Empty); - Assert.That(value, Does.Contain("CachedValue_10")); -#elif MSTEST - var value = _sharedCache[10]; - Assert.IsTrue(value.Length > 0); - Assert.IsTrue(value.Contains("CachedValue_10")); -#endif - } - -#if TUNIT - [Test] - public async Task SharedFixture_Test2() -#elif XUNIT || XUNIT3 - [Fact] - public void SharedFixture_Test2() -#elif NUNIT - [Test] - public void SharedFixture_Test2() -#elif MSTEST - [TestMethod] - public void SharedFixture_Test2() -#endif - { -#if XUNIT || XUNIT3 - var count = _fixture.SharedCache.Count; - Assert.Equal(100, count); -#elif TUNIT - var count = _sharedCache.Count; - await Assert.That(count).IsEqualTo(100); -#elif NUNIT - var count = _sharedCache.Count; - Assert.That(count, Is.EqualTo(100)); -#elif MSTEST - var count = _sharedCache.Count; - Assert.AreEqual(100, count); -#endif - } - -#if TUNIT - [Test] - public async Task SharedFixture_Test3() -#elif XUNIT || XUNIT3 - [Fact] - public void SharedFixture_Test3() -#elif NUNIT - [Test] - public void SharedFixture_Test3() -#elif MSTEST - [TestMethod] - public void SharedFixture_Test3() -#endif - { -#if XUNIT || XUNIT3 - var record = _fixture.ProcessedRecords[0]; - Assert.Equal(0, record.Id); - Assert.Equal("Record_0", record.Name); -#elif TUNIT - var record = _processedRecords[0]; - await Assert.That(record.Id).IsEqualTo(0); - await Assert.That(record.Name).IsEqualTo("Record_0"); -#elif NUNIT - var record = _processedRecords[0]; - Assert.That(record.Id, Is.EqualTo(0)); - Assert.That(record.Name, Is.EqualTo("Record_0")); -#elif MSTEST - var record = _processedRecords[0]; - Assert.AreEqual(0, record.Id); - Assert.AreEqual("Record_0", record.Name); -#endif - } - -#if TUNIT - [Test] - public async Task SharedFixture_Test4() -#elif XUNIT || XUNIT3 - [Fact] - public void SharedFixture_Test4() -#elif NUNIT - [Test] - public void SharedFixture_Test4() -#elif MSTEST - [TestMethod] - public void SharedFixture_Test4() -#endif - { -#if XUNIT || XUNIT3 - var recordCount = _fixture.ProcessedRecords.Count; - Assert.Equal(50, recordCount); -#elif TUNIT - var recordCount = _processedRecords.Count; - await Assert.That(recordCount).IsEqualTo(50); -#elif NUNIT - var recordCount = _processedRecords.Count; - Assert.That(recordCount, Is.EqualTo(50)); -#elif MSTEST - var recordCount = _processedRecords.Count; - Assert.AreEqual(50, recordCount); -#endif - } - -#if TUNIT - [Test] - public async Task SharedFixture_Test5() -#elif XUNIT || XUNIT3 - [Fact] - public void SharedFixture_Test5() -#elif NUNIT - [Test] - public void SharedFixture_Test5() -#elif MSTEST - [TestMethod] - public void SharedFixture_Test5() -#endif - { -#if XUNIT || XUNIT3 - var json = _fixture.ConfigurationJson; - Assert.NotEmpty(json); - Assert.Contains("Version", json); -#elif TUNIT - var json = _configurationJson; - await Assert.That(json).IsNotEmpty(); - await Assert.That(json).Contains("Version"); -#elif NUNIT - var json = _configurationJson; - Assert.That(json, Is.Not.Empty); - Assert.That(json, Does.Contain("Version")); -#elif MSTEST - var json = _configurationJson; - Assert.IsTrue(json.Length > 0); - Assert.IsTrue(json.Contains("Version")); -#endif - } - -#if TUNIT - [Test] - public async Task SharedFixture_Test6() -#elif XUNIT || XUNIT3 - [Fact] - public void SharedFixture_Test6() -#elif NUNIT - [Test] - public void SharedFixture_Test6() -#elif MSTEST - [TestMethod] - public void SharedFixture_Test6() -#endif - { -#if XUNIT || XUNIT3 - var lastRecord = _fixture.ProcessedRecords[^1]; - Assert.Equal(49, lastRecord.Id); -#elif TUNIT - var lastRecord = _processedRecords[^1]; - await Assert.That(lastRecord.Id).IsEqualTo(49); -#elif NUNIT - var lastRecord = _processedRecords[^1]; - Assert.That(lastRecord.Id, Is.EqualTo(49)); -#elif MSTEST - var lastRecord = _processedRecords[^1]; - Assert.AreEqual(49, lastRecord.Id); -#endif - } - -#if TUNIT - [Test] - public async Task SharedFixture_Test7() -#elif XUNIT || XUNIT3 - [Fact] - public void SharedFixture_Test7() -#elif NUNIT - [Test] - public void SharedFixture_Test7() -#elif MSTEST - [TestMethod] - public void SharedFixture_Test7() -#endif - { -#if XUNIT || XUNIT3 - var midValue = _fixture.SharedCache[50]; - Assert.Contains("CachedValue_50", midValue); -#elif TUNIT - var midValue = _sharedCache[50]; - await Assert.That(midValue).Contains("CachedValue_50"); -#elif NUNIT - var midValue = _sharedCache[50]; - Assert.That(midValue, Does.Contain("CachedValue_50")); -#elif MSTEST - var midValue = _sharedCache[50]; - Assert.IsTrue(midValue.Contains("CachedValue_50")); -#endif - } - -#if TUNIT - [Test] - public async Task SharedFixture_Test8() -#elif XUNIT || XUNIT3 - [Fact] - public void SharedFixture_Test8() -#elif NUNIT - [Test] - public void SharedFixture_Test8() -#elif MSTEST - [TestMethod] - public void SharedFixture_Test8() -#endif - { -#if XUNIT || XUNIT3 - var totalValue = _fixture.ProcessedRecords.Sum(r => r.Value); - Assert.Equal(122500, totalValue); -#elif TUNIT - var totalValue = _processedRecords.Sum(r => r.Value); - await Assert.That(totalValue).IsEqualTo(122500); -#elif NUNIT - var totalValue = _processedRecords.Sum(r => r.Value); - Assert.That(totalValue, Is.EqualTo(122500)); -#elif MSTEST - var totalValue = _processedRecords.Sum(r => r.Value); - Assert.AreEqual(122500, totalValue); -#endif - } - -#if TUNIT - [Test] - public async Task SharedFixture_Test9() -#elif XUNIT || XUNIT3 - [Fact] - public void SharedFixture_Test9() -#elif NUNIT - [Test] - public void SharedFixture_Test9() -#elif MSTEST - [TestMethod] - public void SharedFixture_Test9() -#endif - { -#if XUNIT || XUNIT3 - var hasKey = _fixture.SharedCache.ContainsKey(99); - Assert.True(hasKey); -#elif TUNIT - var hasKey = _sharedCache.ContainsKey(99); - await Assert.That(hasKey).IsTrue(); -#elif NUNIT - var hasKey = _sharedCache.ContainsKey(99); - Assert.That(hasKey, Is.True); -#elif MSTEST - var hasKey = _sharedCache.ContainsKey(99); - Assert.IsTrue(hasKey); -#endif - } - -#if TUNIT - [Test] - public async Task SharedFixture_Test10() -#elif XUNIT || XUNIT3 - [Fact] - public void SharedFixture_Test10() -#elif NUNIT - [Test] - public void SharedFixture_Test10() -#elif MSTEST - [TestMethod] - public void SharedFixture_Test10() -#endif - { -#if XUNIT || XUNIT3 - var record = _fixture.ProcessedRecords[25]; - Assert.Equal(2500, record.Value); -#elif TUNIT - var record = _processedRecords[25]; - await Assert.That(record.Value).IsEqualTo(2500); -#elif NUNIT - var record = _processedRecords[25]; - Assert.That(record.Value, Is.EqualTo(2500)); -#elif MSTEST - var record = _processedRecords[25]; - Assert.AreEqual(2500, record.Value); -#endif - } - -#if TUNIT - [Test] - public async Task SharedFixture_Test11() -#elif XUNIT || XUNIT3 - [Fact] - public void SharedFixture_Test11() -#elif NUNIT - [Test] - public void SharedFixture_Test11() -#elif MSTEST - [TestMethod] - public void SharedFixture_Test11() -#endif - { -#if XUNIT || XUNIT3 - var firstValue = _fixture.SharedCache[0]; - Assert.StartsWith("CachedValue_0", firstValue); -#elif TUNIT - var firstValue = _sharedCache[0]; - await Assert.That(firstValue).StartsWith("CachedValue_0"); -#elif NUNIT - var firstValue = _sharedCache[0]; - Assert.That(firstValue, Does.StartWith("CachedValue_0")); -#elif MSTEST - var firstValue = _sharedCache[0]; - Assert.IsTrue(firstValue.StartsWith("CachedValue_0")); -#endif - } - -#if TUNIT - [Test] - public async Task SharedFixture_Test12() -#elif XUNIT || XUNIT3 - [Fact] - public void SharedFixture_Test12() -#elif NUNIT - [Test] - public void SharedFixture_Test12() -#elif MSTEST - [TestMethod] - public void SharedFixture_Test12() -#endif - { -#if XUNIT || XUNIT3 - var names = _fixture.ProcessedRecords.Select(r => r.Name).ToList(); - Assert.Equal(50, names.Count); -#elif TUNIT - var names = _processedRecords.Select(r => r.Name).ToList(); - await Assert.That(names).HasCount(50); -#elif NUNIT - var names = _processedRecords.Select(r => r.Name).ToList(); - Assert.That(names.Count, Is.EqualTo(50)); -#elif MSTEST - var names = _processedRecords.Select(r => r.Name).ToList(); - Assert.AreEqual(50, names.Count); -#endif - } - -#if TUNIT - [Test] - public async Task SharedFixture_Test13() -#elif XUNIT || XUNIT3 - [Fact] - public void SharedFixture_Test13() -#elif NUNIT - [Test] - public void SharedFixture_Test13() -#elif MSTEST - [TestMethod] - public void SharedFixture_Test13() -#endif - { -#if XUNIT || XUNIT3 - var json = _fixture.ConfigurationJson; - Assert.Contains("Environment", json); -#elif TUNIT - var json = _configurationJson; - await Assert.That(json).Contains("Environment"); -#elif NUNIT - var json = _configurationJson; - Assert.That(json, Does.Contain("Environment")); -#elif MSTEST - var json = _configurationJson; - Assert.IsTrue(json.Contains("Environment")); -#endif - } - -#if TUNIT - [Test] - public async Task SharedFixture_Test14() -#elif XUNIT || XUNIT3 - [Fact] - public void SharedFixture_Test14() -#elif NUNIT - [Test] - public void SharedFixture_Test14() -#elif MSTEST - [TestMethod] - public void SharedFixture_Test14() -#endif - { -#if XUNIT || XUNIT3 - var lastValue = _fixture.SharedCache[99]; - Assert.NotEmpty(lastValue); -#elif TUNIT - var lastValue = _sharedCache[99]; - await Assert.That(lastValue).IsNotEmpty(); -#elif NUNIT - var lastValue = _sharedCache[99]; - Assert.That(lastValue, Is.Not.Empty); -#elif MSTEST - var lastValue = _sharedCache[99]; - Assert.IsTrue(lastValue.Length > 0); -#endif - } - -#if TUNIT - [Test] - public async Task SharedFixture_Test15() -#elif XUNIT || XUNIT3 - [Fact] - public void SharedFixture_Test15() -#elif NUNIT - [Test] - public void SharedFixture_Test15() -#elif MSTEST - [TestMethod] - public void SharedFixture_Test15() -#endif - { -#if XUNIT || XUNIT3 - var avgValue = _fixture.ProcessedRecords.Average(r => r.Value); - Assert.Equal(2450.0, avgValue); -#elif TUNIT - var avgValue = _processedRecords.Average(r => r.Value); - await Assert.That(avgValue).IsEqualTo(2450.0); -#elif NUNIT - var avgValue = _processedRecords.Average(r => r.Value); - Assert.That(avgValue, Is.EqualTo(2450.0)); -#elif MSTEST - var avgValue = _processedRecords.Average(r => r.Value); - Assert.AreEqual(2450.0, avgValue); -#endif - } -} From 4021d1a7c7deeb4703450283907350623060bcff Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 26 Oct 2025 21:13:21 +0000 Subject: [PATCH 14/67] refactor: remove LifecycleTests from benchmark job matrix to streamline test execution --- .github/workflows/speed-comparison.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/speed-comparison.yml b/.github/workflows/speed-comparison.yml index 397b546ae0..d2adcb9346 100644 --- a/.github/workflows/speed-comparison.yml +++ b/.github/workflows/speed-comparison.yml @@ -45,7 +45,7 @@ jobs: environment: ${{ github.ref == 'refs/heads/main' && 'Production' || 'Pull Requests' }} strategy: matrix: - class: [DataDrivenTests, AsyncTests, ScaleTests, MatrixTests, LifecycleTests, MassiveParallelTests] + class: [DataDrivenTests, AsyncTests, ScaleTests, MatrixTests, MassiveParallelTests] fail-fast: false runs-on: ubuntu-latest concurrency: From 6657916b3c802cdd219b9a251b5fc7a7cb7ba065 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 26 Oct 2025 22:01:05 +0000 Subject: [PATCH 15/67] +semver:minor + refactor: enhance maximum parallelism configuration with command line and environment variable support (#3533) --- TUnit.Engine/Scheduling/TestScheduler.cs | 42 +++++++++++++++++++++--- 1 file changed, 37 insertions(+), 5 deletions(-) diff --git a/TUnit.Engine/Scheduling/TestScheduler.cs b/TUnit.Engine/Scheduling/TestScheduler.cs index 4fe130f97a..0d07540594 100644 --- a/TUnit.Engine/Scheduling/TestScheduler.cs +++ b/TUnit.Engine/Scheduling/TestScheduler.cs @@ -316,14 +316,46 @@ private async Task WaitForTasksWithFailFastHandling(IEnumerable tasks, Can private static int GetMaxParallelism(ILogger logger, ICommandLineOptions commandLineOptions) { - if (!commandLineOptions.TryGetOptionArgumentList( + // Check command line argument first (highest priority) + if (commandLineOptions.TryGetOptionArgumentList( MaximumParallelTestsCommandProvider.MaximumParallelTests, - out var args) || args.Length <= 0 || !int.TryParse(args[0], out var maxParallelTests) || maxParallelTests <= 0) + out var args) && args.Length > 0 && int.TryParse(args[0], out var maxParallelTests)) { - return int.MaxValue; + if (maxParallelTests == 0) + { + // 0 means unlimited (backwards compat for advanced users) + logger.LogDebug("Maximum parallel tests: unlimited (from command line)"); + return int.MaxValue; + } + + if (maxParallelTests > 0) + { + logger.LogDebug($"Maximum parallel tests limit set to {maxParallelTests} (from command line)"); + return maxParallelTests; + } + } + + // Check environment variable (second priority) + if (Environment.GetEnvironmentVariable("TUNIT_MAX_PARALLEL_TESTS") is string envVar + && int.TryParse(envVar, out var envLimit)) + { + if (envLimit == 0) + { + logger.LogDebug("Maximum parallel tests: unlimited (from TUNIT_MAX_PARALLEL_TESTS environment variable)"); + return int.MaxValue; + } + + if (envLimit > 0) + { + logger.LogDebug($"Maximum parallel tests limit set to {envLimit} (from TUNIT_MAX_PARALLEL_TESTS environment variable)"); + return envLimit; + } } - logger.LogDebug($"Maximum parallel tests limit set to {maxParallelTests}"); - return maxParallelTests; + // Default: 4x CPU cores (balances CPU-bound and I/O-bound tests) + // This prevents resource exhaustion (DB connections, memory, etc.) while allowing I/O overlap + var defaultLimit = Environment.ProcessorCount * 4; + logger.LogDebug($"Maximum parallel tests limit defaulting to {defaultLimit} ({Environment.ProcessorCount} processors * 4)"); + return defaultLimit; } } From 5f7990e8da6a43fc8caed4d9cb076d8fd5d9683a Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 26 Oct 2025 23:03:16 +0000 Subject: [PATCH 16/67] refactor: include referenced assemblies when retrieving all named types in compilation (#3529) --- TUnit.Analyzers/AbstractTestClassWithDataSourcesAnalyzer.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/TUnit.Analyzers/AbstractTestClassWithDataSourcesAnalyzer.cs b/TUnit.Analyzers/AbstractTestClassWithDataSourcesAnalyzer.cs index 66727629db..6e8c53893f 100644 --- a/TUnit.Analyzers/AbstractTestClassWithDataSourcesAnalyzer.cs +++ b/TUnit.Analyzers/AbstractTestClassWithDataSourcesAnalyzer.cs @@ -99,8 +99,8 @@ private void AnalyzeSymbol(SymbolAnalysisContext context) private static bool HasConcreteInheritingClassesWithInheritsTests(SymbolAnalysisContext context, INamedTypeSymbol abstractClass) { - // Get all named types in the compilation - var allTypes = GetAllNamedTypes(context.Compilation.Assembly.GlobalNamespace); + // Get all named types in the compilation (including referenced assemblies) + var allTypes = GetAllNamedTypes(context.Compilation.GlobalNamespace); // Check if any concrete class inherits from the abstract class and has [InheritsTests] foreach (var type in allTypes) From 5a05731c4e2ada051fd0150e5c39c16b102b35e7 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 26 Oct 2025 23:32:15 +0000 Subject: [PATCH 17/67] chore(deps): update tunit to 0.77.0 (#3534) Co-authored-by: Renovate Bot --- Directory.Packages.props | 6 +++--- .../TUnit.AspNet.FSharp/TestProject/TestProject.fsproj | 4 ++-- .../content/TUnit.AspNet/TestProject/TestProject.csproj | 2 +- .../ExampleNamespace.TestProject.csproj | 2 +- .../content/TUnit.Aspire.Test/ExampleNamespace.csproj | 2 +- TUnit.Templates/content/TUnit.FSharp/TestProject.fsproj | 4 ++-- TUnit.Templates/content/TUnit.Playwright/TestProject.csproj | 2 +- TUnit.Templates/content/TUnit.VB/TestProject.vbproj | 2 +- TUnit.Templates/content/TUnit/TestProject.csproj | 2 +- 9 files changed, 13 insertions(+), 13 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index b7e54df848..7c5d08daf0 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -83,9 +83,9 @@ - - - + + + diff --git a/TUnit.Templates/content/TUnit.AspNet.FSharp/TestProject/TestProject.fsproj b/TUnit.Templates/content/TUnit.AspNet.FSharp/TestProject/TestProject.fsproj index d99ea78447..57e512f7fa 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 a5d0584afe..3798f772a3 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.Aspire.Starter/ExampleNamespace.TestProject/ExampleNamespace.TestProject.csproj b/TUnit.Templates/content/TUnit.Aspire.Starter/ExampleNamespace.TestProject/ExampleNamespace.TestProject.csproj index 095cfce880..45fb1fee62 100644 --- a/TUnit.Templates/content/TUnit.Aspire.Starter/ExampleNamespace.TestProject/ExampleNamespace.TestProject.csproj +++ b/TUnit.Templates/content/TUnit.Aspire.Starter/ExampleNamespace.TestProject/ExampleNamespace.TestProject.csproj @@ -11,7 +11,7 @@ - + diff --git a/TUnit.Templates/content/TUnit.Aspire.Test/ExampleNamespace.csproj b/TUnit.Templates/content/TUnit.Aspire.Test/ExampleNamespace.csproj index 778697668b..c7633ba283 100644 --- a/TUnit.Templates/content/TUnit.Aspire.Test/ExampleNamespace.csproj +++ b/TUnit.Templates/content/TUnit.Aspire.Test/ExampleNamespace.csproj @@ -10,7 +10,7 @@ - + diff --git a/TUnit.Templates/content/TUnit.FSharp/TestProject.fsproj b/TUnit.Templates/content/TUnit.FSharp/TestProject.fsproj index 86b4ed7c86..6bbee956ef 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 2f9dca5c37..c6051ab96b 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 5aaf9813b0..75bda64de7 100644 --- a/TUnit.Templates/content/TUnit.VB/TestProject.vbproj +++ b/TUnit.Templates/content/TUnit.VB/TestProject.vbproj @@ -8,6 +8,6 @@ - + diff --git a/TUnit.Templates/content/TUnit/TestProject.csproj b/TUnit.Templates/content/TUnit/TestProject.csproj index dcdb0ac86c..5f40856c51 100644 --- a/TUnit.Templates/content/TUnit/TestProject.csproj +++ b/TUnit.Templates/content/TUnit/TestProject.csproj @@ -8,7 +8,7 @@ - + \ No newline at end of file From 88a51fa1fcca3fa7f10c1e19ca24b1355331b7c7 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Mon, 27 Oct 2025 00:39:04 +0000 Subject: [PATCH 18/67] perf: optimize asynchronous event handling with lock-free operations and immutable collections (#3535) * refactor: optimize asynchronous event handling with lock-free operations and immutable collections * refactor: optimize collection handling and reduce allocations in test identifier generation * refactor: streamline event receiver orchestration and improve task management with array pooling * refactor: optimize AsyncEvent handling by removing locks and improving invocation management * refactor: update AsyncEvent class to replace Order property with InvocationList and new callback methods --- .../Helpers/CollectionEquivalencyChecker.cs | 5 +- TUnit.Core/AsyncEvent.cs | 95 +++++----- TUnit.Core/DataGeneratorMetadataCreator.cs | 4 +- TUnit.Core/TUnit.Core.csproj | 1 + TUnit.Engine/Building/TestBuilder.cs | 13 +- .../Capabilities/StopExecutionCapability.cs | 2 +- TUnit.Engine/ConcurrentHashSet.cs | 116 ++---------- .../Discovery/ReflectionTestMetadata.cs | 15 +- TUnit.Engine/Extensions/JsonExtensions.cs | 50 ++++- TUnit.Engine/Scheduling/TestScheduler.cs | 117 ++++++++---- .../Services/CircularDependencyDetector.cs | 11 +- .../Services/EventReceiverOrchestrator.cs | 61 +----- .../Services/HookCollectionService.cs | 52 ++--- .../Services/TestExecution/TestCoordinator.cs | 2 +- TUnit.Engine/Services/TestFinder.cs | 27 ++- .../Services/TestIdentifierService.cs | 178 +++++++++++------- ...Has_No_API_Changes.DotNet10_0.verified.txt | 4 +- ..._Has_No_API_Changes.DotNet8_0.verified.txt | 4 +- ..._Has_No_API_Changes.DotNet9_0.verified.txt | 4 +- ...ary_Has_No_API_Changes.Net4_7.verified.txt | 4 +- 20 files changed, 394 insertions(+), 371 deletions(-) diff --git a/TUnit.Assertions/Conditions/Helpers/CollectionEquivalencyChecker.cs b/TUnit.Assertions/Conditions/Helpers/CollectionEquivalencyChecker.cs index a5447ed9e6..0d7ae09386 100644 --- a/TUnit.Assertions/Conditions/Helpers/CollectionEquivalencyChecker.cs +++ b/TUnit.Assertions/Conditions/Helpers/CollectionEquivalencyChecker.cs @@ -34,8 +34,9 @@ public static CheckResult AreEquivalent( return CheckResult.Failure("collection was null"); } - var actualList = actual.ToList(); - var expectedList = expected.ToList(); + // Optimize for collections that are already lists to avoid re-enumeration + var actualList = actual is List actualListCasted ? actualListCasted : actual.ToList(); + var expectedList = expected is List expectedListCasted ? expectedListCasted : expected.ToList(); // Check counts first if (actualList.Count != expectedList.Count) diff --git a/TUnit.Core/AsyncEvent.cs b/TUnit.Core/AsyncEvent.cs index 918504ebdc..9fbaf3fd22 100644 --- a/TUnit.Core/AsyncEvent.cs +++ b/TUnit.Core/AsyncEvent.cs @@ -1,35 +1,14 @@ -using TUnit.Core.Interfaces; +using TUnit.Core.Interfaces; namespace TUnit.Core; public class AsyncEvent { - public int Order - { - get; - set - { - field = value; - - if (InvocationList.Count > 0) - { - InvocationList[^1].Order = field; - } - } - } = int.MaxValue / 2; - - internal List InvocationList { get; } = []; - - private static readonly Lock _newEventLock = new(); - private readonly Lock _locker = new(); + private List? _handlers; public class Invocation(Func factory, int order) : IEventReceiver { - public int Order - { - get; - internal set; - } = order; + public int Order { get; } = order; public async ValueTask InvokeAsync(object sender, TEventArgs eventArgs) { @@ -37,42 +16,70 @@ public async ValueTask InvokeAsync(object sender, TEventArgs eventArgs) } } - public static AsyncEvent operator +( - AsyncEvent? e, Func callback - ) + public void Add(Func callback, int order = int.MaxValue / 2) { if (callback == null) { - throw new NullReferenceException("callback is null"); + throw new ArgumentNullException(nameof(callback)); } - lock (_newEventLock) - { - e ??= new AsyncEvent(); - } + var invocation = new Invocation(callback, order); + var insertIndex = FindInsertionIndex(order); + (_handlers ??= []).Insert(insertIndex, invocation); + } - lock (e._locker) + public void AddAt(Func callback, int index, int order = int.MaxValue / 2) + { + if (callback == null) { - e.InvocationList.Add(new Invocation(callback, e.Order)); - e.Order = int.MaxValue / 2; + throw new ArgumentNullException(nameof(callback)); } - return e; + var invocation = new Invocation(callback, order); + var handlers = _handlers ??= []; + var clampedIndex = index < 0 ? 0 : (index > handlers.Count ? handlers.Count : index); + handlers.Insert(clampedIndex, invocation); } - public AsyncEvent InsertAtFront(Func callback) + public IReadOnlyList InvocationList { - if (callback == null) + get { - throw new NullReferenceException("callback is null"); - } + if (_handlers == null) + { + return []; + } + + return _handlers; - lock (_locker) - { - InvocationList.Insert(0, new Invocation(callback, Order)); - Order = int.MaxValue / 2; } + } + public AsyncEvent InsertAtFront(Func callback) + { + AddAt(callback, 0); return this; } + + public static AsyncEvent operator +( + AsyncEvent? e, Func callback) + { + e ??= new AsyncEvent(); + e.Add(callback); + return e; + } + + private int FindInsertionIndex(int order) + { + int left = 0, right = (_handlers ??= []).Count; + while (left < right) + { + var mid = left + (right - left) / 2; + if (_handlers[mid].Order <= order) + left = mid + 1; + else + right = mid; + } + return left; + } } diff --git a/TUnit.Core/DataGeneratorMetadataCreator.cs b/TUnit.Core/DataGeneratorMetadataCreator.cs index 1d86ee4f87..4dc62c5af6 100644 --- a/TUnit.Core/DataGeneratorMetadataCreator.cs +++ b/TUnit.Core/DataGeneratorMetadataCreator.cs @@ -22,7 +22,7 @@ public static DataGeneratorMetadata CreateDataGeneratorMetadata( // Filter out CancellationToken if it's the last parameter (handled by the engine) if (generatorType == DataGeneratorType.TestParameters && parametersToGenerate.Length > 0) { - var lastParam = parametersToGenerate[parametersToGenerate.Length - 1]; + var lastParam = parametersToGenerate[^1]; if (lastParam.Type == typeof(CancellationToken)) { var newArray = new ParameterMetadata[parametersToGenerate.Length - 1]; @@ -244,7 +244,7 @@ private static ParameterMetadata[] FilterOutCancellationToken(ParameterMetadata[ { if (parameters.Length > 0) { - var lastParam = parameters[parameters.Length - 1]; + var lastParam = parameters[^1]; if (lastParam.Type == typeof(CancellationToken)) { var newArray = new ParameterMetadata[parameters.Length - 1]; diff --git a/TUnit.Core/TUnit.Core.csproj b/TUnit.Core/TUnit.Core.csproj index 6df09d2573..56f619768d 100644 --- a/TUnit.Core/TUnit.Core.csproj +++ b/TUnit.Core/TUnit.Core.csproj @@ -63,6 +63,7 @@ + diff --git a/TUnit.Engine/Building/TestBuilder.cs b/TUnit.Engine/Building/TestBuilder.cs index 020cb6a06d..0545329b93 100644 --- a/TUnit.Engine/Building/TestBuilder.cs +++ b/TUnit.Engine/Building/TestBuilder.cs @@ -813,16 +813,15 @@ public async Task BuildTestAsync(TestMetadata metadata, private static string? GetBasicSkipReason(TestMetadata metadata, Attribute[]? cachedAttributes = null) { var attributes = cachedAttributes ?? metadata.AttributeFactory(); - var skipAttributes = attributes.OfType().ToList(); + var skipAttributes = attributes.OfType(); - if (skipAttributes.Count == 0) - { - return null; // No skip attributes - } + SkipAttribute? firstSkipAttribute = null; // Check if all skip attributes are basic (non-derived) SkipAttribute instances foreach (var skipAttribute in skipAttributes) { + firstSkipAttribute ??= skipAttribute; + var attributeType = skipAttribute.GetType(); if (attributeType != typeof(SkipAttribute)) { @@ -832,8 +831,8 @@ public async Task BuildTestAsync(TestMetadata metadata, } // All skip attributes are basic SkipAttribute instances - // Return the first reason (they all should skip) - return skipAttributes[0].Reason; + // Return the first reason (they all should skip), or null if no skip attributes + return firstSkipAttribute?.Reason; } diff --git a/TUnit.Engine/Capabilities/StopExecutionCapability.cs b/TUnit.Engine/Capabilities/StopExecutionCapability.cs index 31c328d400..5aafe6c172 100644 --- a/TUnit.Engine/Capabilities/StopExecutionCapability.cs +++ b/TUnit.Engine/Capabilities/StopExecutionCapability.cs @@ -15,7 +15,7 @@ public async Task StopTestExecutionAsync(CancellationToken cancellationToken) if (OnStopRequested != null) { - foreach (var invocation in OnStopRequested.InvocationList.OrderBy(x => x.Order)) + foreach (var invocation in OnStopRequested.InvocationList) { await invocation.InvokeAsync(this, EventArgs.Empty); } diff --git a/TUnit.Engine/ConcurrentHashSet.cs b/TUnit.Engine/ConcurrentHashSet.cs index 85df4becb8..a45c787962 100644 --- a/TUnit.Engine/ConcurrentHashSet.cs +++ b/TUnit.Engine/ConcurrentHashSet.cs @@ -1,122 +1,34 @@ -namespace TUnit.Engine; +using System.Collections.Concurrent; -internal class ConcurrentHashSet -{ - private readonly ReaderWriterLockSlim _lock = new(LockRecursionPolicy.SupportsRecursion); - private readonly HashSet _hashSet = []; +namespace TUnit.Engine; - #region Implementation of ICollection ...ish +/// +/// Thread-safe hash set implementation using ConcurrentDictionary for better performance. +/// Provides lock-free reads and fine-grained locking for writes. +/// +internal class ConcurrentHashSet where T : notnull +{ + private readonly ConcurrentDictionary _dictionary = new(); public bool Add(T item) { - _lock.EnterWriteLock(); - - try - { - return _hashSet.Add(item); - } - finally - { - if (_lock.IsWriteLockHeld) - { - _lock.ExitWriteLock(); - } - } + return _dictionary.TryAdd(item, 0); } public void Clear() { - _lock.EnterWriteLock(); - - try - { - _hashSet.Clear(); - } - finally - { - if (_lock.IsWriteLockHeld) - { - _lock.ExitWriteLock(); - } - } + _dictionary.Clear(); } public bool Contains(T item) { - _lock.EnterReadLock(); - - try - { - return _hashSet.Contains(item); - } - finally - { - if (_lock.IsReadLockHeld) - { - _lock.ExitReadLock(); - } - } + return _dictionary.ContainsKey(item); } public bool Remove(T item) { - _lock.EnterWriteLock(); - - try - { - return _hashSet.Remove(item); - } - finally - { - if (_lock.IsWriteLockHeld) - { - _lock.ExitWriteLock(); - } - } - } - - public int Count - { - get - { - _lock.EnterReadLock(); - - try - { - return _hashSet.Count; - } - finally - { - if (_lock.IsReadLockHeld) - { - _lock.ExitReadLock(); - } - } - } - } - - #endregion - - #region Dispose - - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - protected virtual void Dispose(bool disposing) - { - if (disposing) - { - _lock.Dispose(); - } - } - - ~ConcurrentHashSet() - { - Dispose(false); + return _dictionary.TryRemove(item, out _); } - #endregion + public int Count => _dictionary.Count; } diff --git a/TUnit.Engine/Discovery/ReflectionTestMetadata.cs b/TUnit.Engine/Discovery/ReflectionTestMetadata.cs index d52c6fcba9..9354c9525f 100644 --- a/TUnit.Engine/Discovery/ReflectionTestMetadata.cs +++ b/TUnit.Engine/Discovery/ReflectionTestMetadata.cs @@ -81,11 +81,16 @@ async Task CreateInstance(TestContext testContext) // Create test invoker with CancellationToken support // Determine if the test method has a CancellationToken parameter - var parameterTypes = MethodMetadata.Parameters.Select(static p => p.Type).ToArray(); - var hasCancellationToken = parameterTypes.Any(t => t == typeof(CancellationToken)); - var cancellationTokenIndex = hasCancellationToken - ? Array.IndexOf(parameterTypes, typeof(CancellationToken)) - : -1; + var cancellationTokenIndex = -1; + for (var i = 0; i < MethodMetadata.Parameters.Length; i++) + { + if (MethodMetadata.Parameters[i].Type == typeof(CancellationToken)) + { + cancellationTokenIndex = i; + break; + } + } + var hasCancellationToken = cancellationTokenIndex != -1; Func invokeTest = async (instance, args, testContext, cancellationToken) => { diff --git a/TUnit.Engine/Extensions/JsonExtensions.cs b/TUnit.Engine/Extensions/JsonExtensions.cs index aae632e86b..791aa05b9a 100644 --- a/TUnit.Engine/Extensions/JsonExtensions.cs +++ b/TUnit.Engine/Extensions/JsonExtensions.cs @@ -7,27 +7,45 @@ internal static class JsonExtensions { public static TestSessionJson ToJsonModel(this TestSessionContext context) { + var assemblies = new TestAssemblyJson[context.Assemblies.Count]; + for (var i = 0; i < context.Assemblies.Count; i++) + { + assemblies[i] = context.Assemblies[i].ToJsonModel(); + } + return new TestSessionJson { - Assemblies = context.Assemblies.Select(static x => x.ToJsonModel()).ToArray() + Assemblies = assemblies }; } public static TestAssemblyJson ToJsonModel(this AssemblyHookContext context) { + var classes = new TestClassJson[context.TestClasses.Count]; + for (var i = 0; i < context.TestClasses.Count; i++) + { + classes[i] = context.TestClasses[i].ToJsonModel(); + } + return new TestAssemblyJson { AssemblyName = context.Assembly.GetName().FullName, - Classes = context.TestClasses.Select(static x => x.ToJsonModel()).ToArray() + Classes = classes }; } public static TestClassJson ToJsonModel(this ClassHookContext context) { + var tests = new TestJson[context.Tests.Count]; + for (var i = 0; i < context.Tests.Count; i++) + { + tests[i] = context.Tests[i].ToJsonModel(); + } + return new TestClassJson { Type = context.ClassType.FullName, - Tests = context.Tests.Select(static x => x.ToJsonModel()).ToArray() + Tests = tests }; } @@ -39,6 +57,28 @@ public static TestJson ToJsonModel(this TestContext context) throw new InvalidOperationException("TestDetails is null"); } + Type[]? classParameterTypes = testDetails.TestClassParameterTypes; + string[] classParamTypeNames; + if (classParameterTypes != null) + { + classParamTypeNames = new string[classParameterTypes.Length]; + for (var i = 0; i < classParameterTypes.Length; i++) + { + classParamTypeNames[i] = classParameterTypes[i].FullName ?? "Unknown"; + } + } + else + { + classParamTypeNames = []; + } + + var methodParameters = testDetails.MethodMetadata.Parameters; + var methodParamTypeNames = new string[methodParameters.Length]; + for (var i = 0; i < methodParameters.Length; i++) + { + methodParamTypeNames[i] = methodParameters[i].Type.FullName ?? "Unknown"; + } + return new TestJson { Categories = testDetails.Categories, @@ -58,8 +98,8 @@ public static TestJson ToJsonModel(this TestContext context) TestFilePath = testDetails.TestFilePath, TestLineNumber = testDetails.TestLineNumber, TestMethodArguments = testDetails.TestMethodArguments, - TestClassParameterTypes = testDetails.TestClassParameterTypes?.Select(static x => x.FullName ?? "Unknown").ToArray() ?? [], - TestMethodParameterTypes = testDetails.MethodMetadata.Parameters.Select(static p => p.Type.FullName ?? "Unknown").ToArray(), + TestClassParameterTypes = classParamTypeNames, + TestMethodParameterTypes = methodParamTypeNames, }; } diff --git a/TUnit.Engine/Scheduling/TestScheduler.cs b/TUnit.Engine/Scheduling/TestScheduler.cs index 0d07540594..b009bea717 100644 --- a/TUnit.Engine/Scheduling/TestScheduler.cs +++ b/TUnit.Engine/Scheduling/TestScheduler.cs @@ -1,3 +1,4 @@ +using System.Buffers; using Microsoft.Testing.Platform.CommandLine; using TUnit.Core; using TUnit.Core.Exceptions; @@ -83,12 +84,11 @@ public async Task ScheduleAndExecuteAsync( foreach (var (test, dependencyChain) in circularDependencies) { // Format the error message to match the expected format - var simpleNames = dependencyChain.Select(t => + var simpleNames = new List(dependencyChain.Count); + foreach (var t in dependencyChain) { - var className = t.Metadata.TestClassType.Name; - var testName = t.Metadata.TestMethodName; - return $"{className}.{testName}"; - }).ToList(); + simpleNames.Add($"{t.Metadata.TestClassType.Name}.{t.Metadata.TestMethodName}"); + } var errorMessage = $"DependsOn Conflict: {string.Join(" > ", simpleNames)}"; var exception = new CircularDependencyException(errorMessage); @@ -104,8 +104,17 @@ public async Task ScheduleAndExecuteAsync( } } - var executableTests = testList.Where(t => !testsInCircularDependencies.Contains(t)).ToArray(); - if (executableTests.Length == 0) + var executableTests = new List(testList.Count); + foreach (var test in testList) + { + if (!testsInCircularDependencies.Contains(test)) + { + executableTests.Add(test); + } + } + + var executableTestsArray = executableTests.ToArray(); + if (executableTestsArray.Length == 0) { await _logger.LogDebugAsync("No executable tests found after removing circular dependencies").ConfigureAwait(false); return true; @@ -118,7 +127,7 @@ public async Task ScheduleAndExecuteAsync( _staticPropertyHandler.TrackStaticProperties(); // Group tests by their parallel constraints - var groupedTests = await _groupingService.GroupTestsByConstraintsAsync(executableTests).ConfigureAwait(false); + var groupedTests = await _groupingService.GroupTestsByConstraintsAsync(executableTestsArray).ConfigureAwait(false); // Execute tests according to their grouping await ExecuteGroupedTestsAsync(groupedTests, cancellationToken).ConfigureAwait(false); @@ -154,9 +163,15 @@ private async Task ExecuteGroupedTestsAsync( foreach (var group in groupedTests.ParallelGroups) { - var orderedTests = group.Value.OrderBy(t => t.Key).SelectMany(x => x.Value).ToArray(); - await _logger.LogDebugAsync($"Starting parallel group '{group.Key}' with {orderedTests.Length} orders").ConfigureAwait(false); - await ExecuteTestsAsync(orderedTests, cancellationToken).ConfigureAwait(false); + var orderedTests = new List(); + foreach (var kvp in group.Value.OrderBy(t => t.Key)) + { + orderedTests.AddRange(kvp.Value); + } + var orderedTestsArray = orderedTests.ToArray(); + + await _logger.LogDebugAsync($"Starting parallel group '{group.Key}' with {orderedTestsArray.Length} orders").ConfigureAwait(false); + await ExecuteTestsAsync(orderedTestsArray, cancellationToken).ConfigureAwait(false); } foreach (var kvp in groupedTests.ConstrainedParallelGroups) @@ -205,11 +220,21 @@ private async Task ExecuteTestsAsync( } else { - var tasks = tests.Select(test => - test.ExecutionTask ??= Task.Run(() => ExecuteSingleTestAsync(test, cancellationToken), CancellationToken.None) - ); + var tasks = ArrayPool.Shared.Rent(tests.Length); + try + { + for (var i = 0; i < tests.Length; i++) + { + var test = tests[i]; + tasks[i] = test.ExecutionTask ??= Task.Run(() => ExecuteSingleTestAsync(test, cancellationToken), CancellationToken.None); + } - await WaitForTasksWithFailFastHandling(tasks, cancellationToken).ConfigureAwait(false); + await WaitForTasksWithFailFastHandling(new ArraySegment(tasks, 0, tests.Length), cancellationToken).ConfigureAwait(false); + } + finally + { + ArrayPool.Shared.Return(tasks); + } } } @@ -260,36 +285,48 @@ private async Task ExecuteWithGlobalLimitAsync( AbstractExecutableTest[] tests, CancellationToken cancellationToken) { - var tasks = tests.Select(async test => + var tasks = ArrayPool.Shared.Rent(tests.Length); + try { - SemaphoreSlim? parallelLimiterSemaphore = null; - - if (test.Context.ParallelLimiter != null) - { - parallelLimiterSemaphore = _parallelLimitLockProvider.GetLock(test.Context.ParallelLimiter); - await parallelLimiterSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); - } - - try + for (var i = 0; i < tests.Length; i++) { - await _maxParallelismSemaphore!.WaitAsync(cancellationToken).ConfigureAwait(false); - try - { - test.ExecutionTask ??= _testRunner.ExecuteTestAsync(test, cancellationToken); - await test.ExecutionTask.ConfigureAwait(false); - } - finally + var test = tests[i]; + tasks[i] = Task.Run(async () => { - _maxParallelismSemaphore.Release(); - } - } - finally - { - parallelLimiterSemaphore?.Release(); + SemaphoreSlim? parallelLimiterSemaphore = null; + + if (test.Context.ParallelLimiter != null) + { + parallelLimiterSemaphore = _parallelLimitLockProvider.GetLock(test.Context.ParallelLimiter); + await parallelLimiterSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + } + + try + { + await _maxParallelismSemaphore!.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + test.ExecutionTask ??= _testRunner.ExecuteTestAsync(test, cancellationToken); + await test.ExecutionTask.ConfigureAwait(false); + } + finally + { + _maxParallelismSemaphore.Release(); + } + } + finally + { + parallelLimiterSemaphore?.Release(); + } + }, CancellationToken.None); } - }); - await WaitForTasksWithFailFastHandling(tasks, cancellationToken).ConfigureAwait(false); + await WaitForTasksWithFailFastHandling(new ArraySegment(tasks, 0, tests.Length), cancellationToken).ConfigureAwait(false); + } + finally + { + ArrayPool.Shared.Return(tasks); + } } private async Task WaitForTasksWithFailFastHandling(IEnumerable tasks, CancellationToken cancellationToken) diff --git a/TUnit.Engine/Services/CircularDependencyDetector.cs b/TUnit.Engine/Services/CircularDependencyDetector.cs index 70bd5cf353..8a597997f4 100644 --- a/TUnit.Engine/Services/CircularDependencyDetector.cs +++ b/TUnit.Engine/Services/CircularDependencyDetector.cs @@ -16,7 +16,7 @@ internal sealed class CircularDependencyDetector public List<(AbstractExecutableTest Test, List DependencyChain)> DetectCircularDependencies( IEnumerable tests) { - var testList = tests.ToList(); + var testList = tests as IList ?? tests.ToList(); var circularDependencies = new List<(AbstractExecutableTest Test, List DependencyChain)>(); var visitedStates = new Dictionary(capacity: testList.Count); @@ -27,7 +27,8 @@ internal sealed class CircularDependencyDetector continue; } - var path = new List(); + // Typical cycle depth is small (2-5 tests), pre-size to 4 + var path = new List(4); if (HasCycleDfs(test, testList, visitedStates, path)) { // Found a cycle - add all tests in the cycle to circular dependencies @@ -47,9 +48,9 @@ private enum VisitState } private bool HasCycleDfs( - AbstractExecutableTest test, - List allTests, - Dictionary visitedStates, + AbstractExecutableTest test, + IList allTests, + Dictionary visitedStates, List currentPath) { if (visitedStates.TryGetValue(test.TestId, out var state)) diff --git a/TUnit.Engine/Services/EventReceiverOrchestrator.cs b/TUnit.Engine/Services/EventReceiverOrchestrator.cs index 4a03c9f2e6..37354a5252 100644 --- a/TUnit.Engine/Services/EventReceiverOrchestrator.cs +++ b/TUnit.Engine/Services/EventReceiverOrchestrator.cs @@ -12,7 +12,6 @@ namespace TUnit.Engine.Services; -/// Optimized event receiver orchestrator with fast-path checks, batching, and lifecycle tracking internal sealed class EventReceiverOrchestrator : IDisposable { private readonly EventReceiverRegistry _registry = new(); @@ -43,23 +42,20 @@ public EventReceiverOrchestrator(TUnitFrameworkLogger logger, TrackableObjectGra public void RegisterReceivers(TestContext context, CancellationToken cancellationToken) { - var eligibleObjects = context.GetEligibleEventObjects().ToArray(); - var objectsToRegister = new List(); - foreach (var obj in eligibleObjects) + foreach (var obj in context.GetEligibleEventObjects()) { if (_initializedObjects.Add(obj)) // Add returns false if already present { // For First event receivers, only register one instance per type - var objType = obj.GetType(); bool isFirstEventReceiver = obj is IFirstTestInTestSessionEventReceiver || obj is IFirstTestInAssemblyEventReceiver || obj is IFirstTestInClassEventReceiver; if (isFirstEventReceiver) { - if (_registeredFirstEventReceiverTypes.Add(objType)) + if (_registeredFirstEventReceiverTypes.Add(obj.GetType())) { // First instance of this type, register it objectsToRegister.Add(obj); @@ -97,24 +93,14 @@ public async ValueTask InvokeTestStartEventReceiversAsync(TestContext context, C private async ValueTask InvokeTestStartEventReceiversCore(TestContext context, CancellationToken cancellationToken) { - // Filter scoped attributes - FilterScopedAttributes will materialize the collection var filteredReceivers = ScopedAttributeFilter.FilterScopedAttributes( context.GetEligibleEventObjects() .OfType() .OrderBy(static r => r.Order)); - // Batch invocation for multiple receivers - if (filteredReceivers.Count > 3) + foreach (var receiver in filteredReceivers) { - await InvokeBatchedAsync(filteredReceivers.ToArray(), r => r.OnTestStart(context), cancellationToken); - } - else - { - // Sequential for small counts - foreach (var receiver in filteredReceivers) - { - await receiver.OnTestStart(context); - } + await receiver.OnTestStart(context); } } @@ -178,9 +164,7 @@ private async ValueTask InvokeTestSkippedEventReceiversCore(TestContext context, public async ValueTask InvokeTestDiscoveryEventReceiversAsync(TestContext context, DiscoveredTestContext discoveredContext, CancellationToken cancellationToken) { var eventReceivers = context.GetEligibleEventObjects() - .OfType() - .OrderBy(static r => r.Order) - .ToList(); + .OfType(); // Filter scoped attributes to ensure only the highest priority one of each type is invoked var filteredReceivers = ScopedAttributeFilter.FilterScopedAttributes(eventReceivers); @@ -427,7 +411,7 @@ private async ValueTask InvokeLastTestInClassEventReceiversCore( /// public void InitializeTestCounts(IEnumerable allTestContexts) { - var contexts = allTestContexts.ToList(); + var contexts = allTestContexts as IList ?? allTestContexts.ToList(); _sessionTestCount = contexts.Count; // Clear first-event tracking to ensure clean state for each test execution @@ -438,8 +422,9 @@ public void InitializeTestCounts(IEnumerable allTestContexts) foreach (var group in contexts.GroupBy(c => c.ClassContext.AssemblyContext.Assembly.GetName().FullName)) { var counter = _assemblyTestCounts.GetOrAdd(group.Key, static _ => new Counter()); + var groupCount = group.Count(); - for (var i = 0; i < group.Count(); i++) + for (var i = 0; i < groupCount; i++) { counter.Increment(); } @@ -448,41 +433,15 @@ public void InitializeTestCounts(IEnumerable allTestContexts) foreach (var group in contexts.GroupBy(c => c.ClassContext.ClassType)) { var counter = _classTestCounts.GetOrAdd(group.Key, static _ => new Counter()); + var groupCount = group.Count(); - for (var i = 0; i < group.Count(); i++) + for (var i = 0; i < groupCount; i++) { counter.Increment(); } } } - /// - /// Batch multiple receiver invocations - /// - private async ValueTask InvokeBatchedAsync( - T[] receivers, - Func invoker, - CancellationToken cancellationToken) where T : IEventReceiver - { - // Parallelize for larger counts - var tasks = new Task[receivers.Length]; - for (var i = 0; i < receivers.Length; i++) - { - var receiver = receivers[i]; - tasks[i] = InvokeReceiverAsync(receiver, invoker, cancellationToken); - } - - await Task.WhenAll(tasks); - } - - private async Task InvokeReceiverAsync( - T receiver, - Func invoker, - CancellationToken cancellationToken) where T : IEventReceiver - { - await invoker(receiver); - } - public void Dispose() { _registry.Dispose(); diff --git a/TUnit.Engine/Services/HookCollectionService.cs b/TUnit.Engine/Services/HookCollectionService.cs index ab31b36e89..539f110929 100644 --- a/TUnit.Engine/Services/HookCollectionService.cs +++ b/TUnit.Engine/Services/HookCollectionService.cs @@ -54,7 +54,7 @@ public async ValueTask InitializeAsync() private async Task>> BuildGlobalBeforeEveryTestHooksAsync() { - var allHooks = new List<(int order, int registrationIndex, Func hook)>(); + var allHooks = new List<(int order, int registrationIndex, Func hook)>(Sources.BeforeEveryTestHooks.Count); foreach (var hook in Sources.BeforeEveryTestHooks) { @@ -71,7 +71,7 @@ private async Task>> Bu private async Task>> BuildGlobalAfterEveryTestHooksAsync() { - var allHooks = new List<(int order, int registrationIndex, Func hook)>(); + var allHooks = new List<(int order, int registrationIndex, Func hook)>(Sources.AfterEveryTestHooks.Count); foreach (var hook in Sources.AfterEveryTestHooks) { @@ -88,7 +88,7 @@ private async Task>> Bu private IReadOnlyList> BuildGlobalBeforeTestSessionHooks() { - var allHooks = new List<(int order, int registrationIndex, Func hook)>(); + var allHooks = new List<(int order, int registrationIndex, Func hook)>(Sources.BeforeTestSessionHooks.Count); foreach (var hook in Sources.BeforeTestSessionHooks) { @@ -105,7 +105,7 @@ private IReadOnlyList> BuildGl private IReadOnlyList> BuildGlobalAfterTestSessionHooks() { - var allHooks = new List<(int order, int registrationIndex, Func hook)>(); + var allHooks = new List<(int order, int registrationIndex, Func hook)>(Sources.AfterTestSessionHooks.Count); foreach (var hook in Sources.AfterTestSessionHooks) { @@ -122,7 +122,7 @@ private IReadOnlyList> BuildGl private IReadOnlyList> BuildGlobalBeforeTestDiscoveryHooks() { - var allHooks = new List<(int order, int registrationIndex, Func hook)>(); + var allHooks = new List<(int order, int registrationIndex, Func hook)>(Sources.BeforeTestDiscoveryHooks.Count); foreach (var hook in Sources.BeforeTestDiscoveryHooks) { @@ -139,7 +139,7 @@ private IReadOnlyList> private IReadOnlyList> BuildGlobalAfterTestDiscoveryHooks() { - var allHooks = new List<(int order, int registrationIndex, Func hook)>(); + var allHooks = new List<(int order, int registrationIndex, Func hook)>(Sources.AfterTestDiscoveryHooks.Count); foreach (var hook in Sources.AfterTestDiscoveryHooks) { @@ -156,7 +156,7 @@ private IReadOnlyList> Build private IReadOnlyList> BuildGlobalBeforeEveryClassHooks() { - var allHooks = new List<(int order, int registrationIndex, Func hook)>(); + var allHooks = new List<(int order, int registrationIndex, Func hook)>(Sources.BeforeEveryClassHooks.Count); foreach (var hook in Sources.BeforeEveryClassHooks) { @@ -173,7 +173,7 @@ private IReadOnlyList> BuildGlob private IReadOnlyList> BuildGlobalAfterEveryClassHooks() { - var allHooks = new List<(int order, int registrationIndex, Func hook)>(); + var allHooks = new List<(int order, int registrationIndex, Func hook)>(Sources.AfterEveryClassHooks.Count); foreach (var hook in Sources.AfterEveryClassHooks) { @@ -190,7 +190,7 @@ private IReadOnlyList> BuildGlob private IReadOnlyList> BuildGlobalBeforeEveryAssemblyHooks() { - var allHooks = new List<(int order, int registrationIndex, Func hook)>(); + var allHooks = new List<(int order, int registrationIndex, Func hook)>(Sources.BeforeEveryAssemblyHooks.Count); foreach (var hook in Sources.BeforeEveryAssemblyHooks) { @@ -207,7 +207,7 @@ private IReadOnlyList> BuildG private IReadOnlyList> BuildGlobalAfterEveryAssemblyHooks() { - var allHooks = new List<(int order, int registrationIndex, Func hook)>(); + var allHooks = new List<(int order, int registrationIndex, Func hook)>(Sources.AfterEveryAssemblyHooks.Count); foreach (var hook in Sources.AfterEveryAssemblyHooks) { @@ -530,15 +530,17 @@ public ValueTask { - var allHooks = new List<(int order, Func hook)>(); + if (!Sources.BeforeAssemblyHooks.TryGetValue(asm, out var assemblyHooks)) + { + return []; + } + + var allHooks = new List<(int order, Func hook)>(assemblyHooks.Count); - if (Sources.BeforeAssemblyHooks.TryGetValue(asm, out var assemblyHooks)) + foreach (var hook in assemblyHooks) { - foreach (var hook in assemblyHooks) - { - var hookFunc = CreateAssemblyHookDelegate(hook); - allHooks.Add((hook.Order, hookFunc)); - } + var hookFunc = CreateAssemblyHookDelegate(hook); + allHooks.Add((hook.Order, hookFunc)); } return allHooks @@ -554,15 +556,17 @@ public ValueTask { - var allHooks = new List<(int order, Func hook)>(); + if (!Sources.AfterAssemblyHooks.TryGetValue(asm, out var assemblyHooks)) + { + return []; + } + + var allHooks = new List<(int order, Func hook)>(assemblyHooks.Count); - if (Sources.AfterAssemblyHooks.TryGetValue(asm, out var assemblyHooks)) + foreach (var hook in assemblyHooks) { - foreach (var hook in assemblyHooks) - { - var hookFunc = CreateAssemblyHookDelegate(hook); - allHooks.Add((hook.Order, hookFunc)); - } + var hookFunc = CreateAssemblyHookDelegate(hook); + allHooks.Add((hook.Order, hookFunc)); } return allHooks diff --git a/TUnit.Engine/Services/TestExecution/TestCoordinator.cs b/TUnit.Engine/Services/TestExecution/TestCoordinator.cs index 692bcf8481..46344b1faf 100644 --- a/TUnit.Engine/Services/TestExecution/TestCoordinator.cs +++ b/TUnit.Engine/Services/TestExecution/TestCoordinator.cs @@ -111,7 +111,7 @@ await RetryHelper.ExecuteWithRetry(test.Context, async () => // This ensures each retry gets a fresh instance if (test.Context.Events.OnDispose?.InvocationList != null) { - foreach (var invocation in test.Context.Events.OnDispose.InvocationList.OrderBy(x => x.Order)) + foreach (var invocation in test.Context.Events.OnDispose.InvocationList) { try { diff --git a/TUnit.Engine/Services/TestFinder.cs b/TUnit.Engine/Services/TestFinder.cs index bf1e782825..8d88c87949 100644 --- a/TUnit.Engine/Services/TestFinder.cs +++ b/TUnit.Engine/Services/TestFinder.cs @@ -33,11 +33,11 @@ public IEnumerable GetTests(Type classType) /// /// Gets test contexts by name and parameters /// - public TestContext[] GetTestsByNameAndParameters(string testName, IEnumerable methodParameterTypes, - Type classType, IEnumerable classParameterTypes, IEnumerable classArguments) + public TestContext[] GetTestsByNameAndParameters(string testName, IEnumerable? methodParameterTypes, + Type classType, IEnumerable? classParameterTypes, IEnumerable? classArguments) { - var paramTypes = methodParameterTypes?.ToArray() ?? []; - var classParamTypes = classParameterTypes?.ToArray() ?? []; + var paramTypes = methodParameterTypes as Type[] ?? methodParameterTypes?.ToArray() ?? []; + var classParamTypes = classParameterTypes as Type[] ?? classParameterTypes?.ToArray() ?? []; var allTests = _discoveryService.GetCachedTestContexts(); var results = new List(); @@ -63,7 +63,7 @@ public TestContext[] GetTestsByNameAndParameters(string testName, IEnumerable classArguments) + private bool ClassParametersMatch(TestContext context, Type[] classParamTypes, IEnumerable? classArguments) { // For now, just check parameter count - var argCount = classArguments?.Count() ?? 0; + int argCount; + if (classArguments == null) + { + argCount = 0; + } + else if (classArguments is ICollection collection) + { + argCount = collection.Count; + } + else + { + argCount = classArguments.Count(); + } + var actualArgCount = context.TestDetails?.TestClassArguments?.Length ?? 0; return argCount == actualArgCount; } diff --git a/TUnit.Engine/Services/TestIdentifierService.cs b/TUnit.Engine/Services/TestIdentifierService.cs index 84ab118e2f..102332affa 100644 --- a/TUnit.Engine/Services/TestIdentifierService.cs +++ b/TUnit.Engine/Services/TestIdentifierService.cs @@ -1,3 +1,4 @@ +using System.Buffers; using System.Text; using TUnit.Core; using TUnit.Engine.Building; @@ -6,54 +7,73 @@ namespace TUnit.Engine.Services; internal static class TestIdentifierService { + private const int MaxStackAllocSize = 16; + public static string GenerateTestId(TestMetadata metadata, TestBuilder.TestData combination) { var methodMetadata = metadata.MethodMetadata; var classMetadata = methodMetadata.Class; - // Pre-size arrays to avoid LINQ chains and multiple enumerations var constructorParameters = classMetadata.Parameters; - var constructorParameterTypes = new Type[constructorParameters.Length]; - for (var i = 0; i < constructorParameters.Length; i++) - { - constructorParameterTypes[i] = constructorParameters[i].Type; - } - var methodParameters = methodMetadata.Parameters; - var methodParameterTypes = new Type[methodParameters.Length]; - for (var i = 0; i < methodParameters.Length; i++) + + // Use ArrayPool to avoid heap allocations for Type arrays + // Note: Cannot use stackalloc because Type is a managed reference type + var constructorParameterTypes = ArrayPool.Shared.Rent(constructorParameters.Length); + var methodParameterTypes = ArrayPool.Shared.Rent(methodParameters.Length); + + try { - methodParameterTypes[i] = methodParameters[i].Type; - } + // Fill arrays with actual types + for (var i = 0; i < constructorParameters.Length; i++) + { + constructorParameterTypes[i] = constructorParameters[i].Type; + } + + for (var i = 0; i < methodParameters.Length; i++) + { + methodParameterTypes[i] = methodParameters[i].Type; + } - var classTypeWithParameters = BuildTypeWithParameters(GetTypeNameWithGenerics(metadata.TestClassType), constructorParameterTypes); - var methodWithParameters = BuildTypeWithParameters(metadata.TestMethodName, methodParameterTypes); - - // Use StringBuilder for efficient string concatenation - var sb = new StringBuilder(256); // Pre-size for typical test ID length - sb.Append(methodMetadata.Class.Namespace) - .Append('.') - .Append(classTypeWithParameters) - .Append('.') - .Append(combination.ClassDataSourceAttributeIndex) - .Append('.') - .Append(combination.ClassDataLoopIndex) - .Append('.') - .Append(methodWithParameters) - .Append('.') - .Append(combination.MethodDataSourceAttributeIndex) - .Append('.') - .Append(combination.MethodDataLoopIndex) - .Append('.') - .Append(combination.RepeatIndex); - - // Add inheritance information to ensure uniqueness - if (combination.InheritanceDepth > 0) + var classTypeWithParameters = BuildTypeWithParameters( + GetTypeNameWithGenerics(metadata.TestClassType), + constructorParameterTypes.AsSpan(0, constructorParameters.Length)); + + var methodWithParameters = BuildTypeWithParameters( + metadata.TestMethodName, + methodParameterTypes.AsSpan(0, methodParameters.Length)); + + // Use StringBuilder for efficient string concatenation + var sb = new StringBuilder(256); // Pre-size for typical test ID length + sb.Append(methodMetadata.Class.Namespace) + .Append('.') + .Append(classTypeWithParameters) + .Append('.') + .Append(combination.ClassDataSourceAttributeIndex) + .Append('.') + .Append(combination.ClassDataLoopIndex) + .Append('.') + .Append(methodWithParameters) + .Append('.') + .Append(combination.MethodDataSourceAttributeIndex) + .Append('.') + .Append(combination.MethodDataLoopIndex) + .Append('.') + .Append(combination.RepeatIndex); + + // Add inheritance information to ensure uniqueness + if (combination.InheritanceDepth > 0) + { + sb.Append("_inherited").Append(combination.InheritanceDepth); + } + + return sb.ToString(); + } + finally { - sb.Append("_inherited").Append(combination.InheritanceDepth); + ArrayPool.Shared.Return(constructorParameterTypes); + ArrayPool.Shared.Return(methodParameterTypes); } - - return sb.ToString(); } public static string GenerateFailedTestId(TestMetadata metadata) @@ -67,44 +87,60 @@ public static string GenerateFailedTestId(TestMetadata metadata, TestDataCombina var methodMetadata = metadata.MethodMetadata; var classMetadata = methodMetadata.Class; - // Pre-size arrays to avoid LINQ chains and multiple enumerations var constructorParameters = classMetadata.Parameters; - var constructorParameterTypes = new Type[constructorParameters.Length]; - for (var i = 0; i < constructorParameters.Length; i++) - { - constructorParameterTypes[i] = constructorParameters[i].Type; - } - var methodParameters = methodMetadata.Parameters; - var methodParameterTypes = new Type[methodParameters.Length]; - for (var i = 0; i < methodParameters.Length; i++) + + // Use ArrayPool to avoid heap allocations for Type arrays + var constructorParameterTypes = ArrayPool.Shared.Rent(constructorParameters.Length); + var methodParameterTypes = ArrayPool.Shared.Rent(methodParameters.Length); + + try { - methodParameterTypes[i] = methodParameters[i].Type; - } + // Fill arrays with actual types + for (var i = 0; i < constructorParameters.Length; i++) + { + constructorParameterTypes[i] = constructorParameters[i].Type; + } - var classTypeWithParameters = BuildTypeWithParameters(GetTypeNameWithGenerics(metadata.TestClassType), constructorParameterTypes); - var methodWithParameters = BuildTypeWithParameters(metadata.TestMethodName, methodParameterTypes); - - // Use StringBuilder for efficient string concatenation - var sb = new StringBuilder(256); // Pre-size for typical test ID length - sb.Append(methodMetadata.Class.Namespace) - .Append('.') - .Append(classTypeWithParameters) - .Append('.') - .Append(combination.ClassDataSourceIndex) - .Append('.') - .Append(combination.ClassLoopIndex) - .Append('.') - .Append(methodWithParameters) - .Append('.') - .Append(combination.MethodDataSourceIndex) - .Append('.') - .Append(combination.MethodLoopIndex) - .Append('.') - .Append(combination.RepeatIndex) - .Append("_DataGenerationError"); + for (var i = 0; i < methodParameters.Length; i++) + { + methodParameterTypes[i] = methodParameters[i].Type; + } - return sb.ToString(); + var classTypeWithParameters = BuildTypeWithParameters( + GetTypeNameWithGenerics(metadata.TestClassType), + constructorParameterTypes.AsSpan(0, constructorParameters.Length)); + + var methodWithParameters = BuildTypeWithParameters( + metadata.TestMethodName, + methodParameterTypes.AsSpan(0, methodParameters.Length)); + + // Use StringBuilder for efficient string concatenation + var sb = new StringBuilder(256); // Pre-size for typical test ID length + sb.Append(methodMetadata.Class.Namespace) + .Append('.') + .Append(classTypeWithParameters) + .Append('.') + .Append(combination.ClassDataSourceIndex) + .Append('.') + .Append(combination.ClassLoopIndex) + .Append('.') + .Append(methodWithParameters) + .Append('.') + .Append(combination.MethodDataSourceIndex) + .Append('.') + .Append(combination.MethodLoopIndex) + .Append('.') + .Append(combination.RepeatIndex) + .Append("_DataGenerationError"); + + return sb.ToString(); + } + finally + { + ArrayPool.Shared.Return(constructorParameterTypes); + ArrayPool.Shared.Return(methodParameterTypes); + } } private static string GetTypeNameWithGenerics(Type type) @@ -148,7 +184,7 @@ private static string GetTypeNameWithGenerics(Type type) return sb.ToString(); } - private static string BuildTypeWithParameters(string typeName, Type[] parameterTypes) + private static string BuildTypeWithParameters(string typeName, ReadOnlySpan parameterTypes) { if (parameterTypes.Length == 0) { 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 098be8d6d7..4352c6266d 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 @@ -169,7 +169,9 @@ namespace public class AsyncEvent { public AsyncEvent() { } - public int Order { get; set; } + public .<.AsyncEvent.Invocation> InvocationList { get; } + public void Add( callback, int order = 1073741823) { } + public void AddAt( callback, int index, int order = 1073741823) { } public .AsyncEvent InsertAtFront( callback) { } public static .AsyncEvent operator +(.AsyncEvent? e, callback) { } public class Invocation : . 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 cec9588a0a..f64342699b 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 @@ -169,7 +169,9 @@ namespace public class AsyncEvent { public AsyncEvent() { } - public int Order { get; set; } + public .<.AsyncEvent.Invocation> InvocationList { get; } + public void Add( callback, int order = 1073741823) { } + public void AddAt( callback, int index, int order = 1073741823) { } public .AsyncEvent InsertAtFront( callback) { } public static .AsyncEvent operator +(.AsyncEvent? e, callback) { } public class Invocation : . 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 475f2d4304..443782f791 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 @@ -169,7 +169,9 @@ namespace public class AsyncEvent { public AsyncEvent() { } - public int Order { get; set; } + public .<.AsyncEvent.Invocation> InvocationList { get; } + public void Add( callback, int order = 1073741823) { } + public void AddAt( callback, int index, int order = 1073741823) { } public .AsyncEvent InsertAtFront( callback) { } public static .AsyncEvent operator +(.AsyncEvent? e, callback) { } public class Invocation : . 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 cfa7c50d42..44febaf0b5 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 @@ -166,7 +166,9 @@ namespace public class AsyncEvent { public AsyncEvent() { } - public int Order { get; set; } + public .<.AsyncEvent.Invocation> InvocationList { get; } + public void Add( callback, int order = 1073741823) { } + public void AddAt( callback, int index, int order = 1073741823) { } public .AsyncEvent InsertAtFront( callback) { } public static .AsyncEvent operator +(.AsyncEvent? e, callback) { } public class Invocation : . From fafe4708a31a838aea0c9df3217b37482c3eccf6 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Mon, 27 Oct 2025 01:14:01 +0000 Subject: [PATCH 19/67] chore(deps): update tunit to 0.77.3 (#3536) Co-authored-by: Renovate Bot --- Directory.Packages.props | 6 +++--- .../TUnit.AspNet.FSharp/TestProject/TestProject.fsproj | 4 ++-- .../content/TUnit.AspNet/TestProject/TestProject.csproj | 2 +- .../ExampleNamespace.TestProject.csproj | 2 +- .../content/TUnit.Aspire.Test/ExampleNamespace.csproj | 2 +- TUnit.Templates/content/TUnit.FSharp/TestProject.fsproj | 4 ++-- TUnit.Templates/content/TUnit.Playwright/TestProject.csproj | 2 +- TUnit.Templates/content/TUnit.VB/TestProject.vbproj | 2 +- TUnit.Templates/content/TUnit/TestProject.csproj | 2 +- 9 files changed, 13 insertions(+), 13 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 7c5d08daf0..e8332d9341 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -83,9 +83,9 @@ - - - + + + diff --git a/TUnit.Templates/content/TUnit.AspNet.FSharp/TestProject/TestProject.fsproj b/TUnit.Templates/content/TUnit.AspNet.FSharp/TestProject/TestProject.fsproj index 57e512f7fa..2b80e58a62 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 3798f772a3..08d17501b4 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.Aspire.Starter/ExampleNamespace.TestProject/ExampleNamespace.TestProject.csproj b/TUnit.Templates/content/TUnit.Aspire.Starter/ExampleNamespace.TestProject/ExampleNamespace.TestProject.csproj index 45fb1fee62..dce660bd96 100644 --- a/TUnit.Templates/content/TUnit.Aspire.Starter/ExampleNamespace.TestProject/ExampleNamespace.TestProject.csproj +++ b/TUnit.Templates/content/TUnit.Aspire.Starter/ExampleNamespace.TestProject/ExampleNamespace.TestProject.csproj @@ -11,7 +11,7 @@ - + diff --git a/TUnit.Templates/content/TUnit.Aspire.Test/ExampleNamespace.csproj b/TUnit.Templates/content/TUnit.Aspire.Test/ExampleNamespace.csproj index c7633ba283..95d7a78f4c 100644 --- a/TUnit.Templates/content/TUnit.Aspire.Test/ExampleNamespace.csproj +++ b/TUnit.Templates/content/TUnit.Aspire.Test/ExampleNamespace.csproj @@ -10,7 +10,7 @@ - + diff --git a/TUnit.Templates/content/TUnit.FSharp/TestProject.fsproj b/TUnit.Templates/content/TUnit.FSharp/TestProject.fsproj index 6bbee956ef..3ee8ed4d7c 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 c6051ab96b..8495b0595a 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 75bda64de7..a347213711 100644 --- a/TUnit.Templates/content/TUnit.VB/TestProject.vbproj +++ b/TUnit.Templates/content/TUnit.VB/TestProject.vbproj @@ -8,6 +8,6 @@ - + diff --git a/TUnit.Templates/content/TUnit/TestProject.csproj b/TUnit.Templates/content/TUnit/TestProject.csproj index 5f40856c51..e7b61c658b 100644 --- a/TUnit.Templates/content/TUnit/TestProject.csproj +++ b/TUnit.Templates/content/TUnit/TestProject.csproj @@ -8,7 +8,7 @@ - + \ No newline at end of file From 04a4c58ef300d1ad10480e7e4ae231d4ecb86db2 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Mon, 27 Oct 2025 01:21:14 +0000 Subject: [PATCH 20/67] Upgrade .NET version from 9.0.x to 10.0.x --- .github/workflows/generate-readme.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/generate-readme.yml b/.github/workflows/generate-readme.yml index 08bd9e860d..9b06bcf623 100644 --- a/.github/workflows/generate-readme.yml +++ b/.github/workflows/generate-readme.yml @@ -17,11 +17,11 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v5 with: - dotnet-version: 9.0.x + dotnet-version: 10.0.x - name: Generate ReadMe uses: ./.github/actions/execute-pipeline with: categories: ReadMe admin-token: ${{ secrets.ADMIN_TOKEN }} - environment: Development \ No newline at end of file + environment: Development From 7e74263b51072e29c58f99792625159b4dd2196c Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Mon, 27 Oct 2025 01:33:09 +0000 Subject: [PATCH 21/67] refactor: update test descriptions for clarity and specificity in GenerateReadMeModule --- TUnit.Pipeline/Modules/GenerateReadMeModule.cs | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/TUnit.Pipeline/Modules/GenerateReadMeModule.cs b/TUnit.Pipeline/Modules/GenerateReadMeModule.cs index 4fa520a012..f9c306ea2e 100644 --- a/TUnit.Pipeline/Modules/GenerateReadMeModule.cs +++ b/TUnit.Pipeline/Modules/GenerateReadMeModule.cs @@ -118,14 +118,11 @@ private string GetScenario(string fileName) return fileName.Split("_").Last() switch { - "AssertionTests" => "Tests focused on assertion performance and validation", "AsyncTests" => "Tests running asynchronous operations and async/await patterns", - "BasicTests" => "Simple tests with basic operations and assertions", "DataDrivenTests" => "Parameterized tests with multiple test cases using data attributes", - "FixtureTests" => "Tests utilizing class fixtures and shared test context", - "ParallelTests" => "Tests executing in parallel to test framework parallelization", - "RepeatTests" => "A test that takes 50ms to execute, repeated 100 times", - "SetupTeardownTests" => "Tests with setup and teardown lifecycle methods", + "MassiveParallelTests" => "Tests executing massively parallel workloads with CPU-bound, I/O-bound, and mixed operations", + "MatrixTests" => "Tests with complex parameter combinations creating 25-125 test variations", + "ScaleTests" => "Large-scale parameterized tests with 100+ test cases testing framework scalability", _ => throw new ArgumentException($"Unknown class name: {fileName}", nameof(fileName)) }; } From 84b2525f126ef2ba1a0d1d2032e696f9b891b9c4 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Mon, 27 Oct 2025 01:45:03 +0000 Subject: [PATCH 22/67] refactor: enhance artifact processing and logging in GenerateReadMeModule --- .../Modules/GenerateReadMeModule.cs | 26 ++++++++++++------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/TUnit.Pipeline/Modules/GenerateReadMeModule.cs b/TUnit.Pipeline/Modules/GenerateReadMeModule.cs index f9c306ea2e..45b8f5f4fb 100644 --- a/TUnit.Pipeline/Modules/GenerateReadMeModule.cs +++ b/TUnit.Pipeline/Modules/GenerateReadMeModule.cs @@ -58,18 +58,28 @@ public class GenerateReadMeModule : Module var fileContents = new StringBuilder(); - // Grouping is by Scenario - foreach (var groupedArtifacts in artifacts.Artifacts + // Expected artifact name pattern: ubuntu_markdown_{build_time|run_time_{class}} + // Example: ubuntu_markdown_run_time_AsyncTests, ubuntu_markdown_build_time + var benchmarkArtifacts = artifacts.Artifacts + .Where(x => x.Name.StartsWith("ubuntu_markdown_")) + .ToList(); + + if (benchmarkArtifacts.Count == 0) + { + context.Logger.LogWarning("No benchmark markdown artifacts found."); + return null; + } + + // Grouping is by Scenario (e.g., "markdown_run_time_AsyncTests" or "markdown_build_time") + foreach (var groupedArtifacts in benchmarkArtifacts .OrderBy(x => x.Name) - .GroupBy(x => x.Name.Split("_", 2)[1])) + .GroupBy(x => x.Name.Substring("ubuntu_".Length))) { fileContents.AppendLine($"### Scenario: {GetScenario(groupedArtifacts.Key)}"); foreach (var artifact in groupedArtifacts.OrderBy(x => x.Name)) { - var operatingSystem = artifact.Name.Split("_")[0]; - - context.Logger.LogInformation("Processing artifact: {ArtifactName} for OS: {OperatingSystem}", artifact.Name, operatingSystem); + context.Logger.LogInformation("Processing artifact: {ArtifactName}", artifact.Name); var stream = await context.GitHub().Client.Actions.Artifacts.DownloadArtifact( context.GitHub().RepositoryInfo.Owner, @@ -86,13 +96,11 @@ public class GenerateReadMeModule : Module var contents = await markdownFile.ReadAsync(cancellationToken); - fileContents.AppendLine(); - fileContents.AppendLine($"#### {operatingSystem}"); fileContents.AppendLine(); fileContents.AppendLine(contents); fileContents.AppendLine(); - context.Logger.LogInformation("Added contents from {MarkdownFile} for OS: {OperatingSystem}", markdownFile.Name, operatingSystem); + context.Logger.LogInformation("Added contents from {MarkdownFile}", markdownFile.Name); } } From a38a4a3264e60d96a09ae51fb0a288ee10623dcb Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 27 Oct 2025 01:51:00 +0000 Subject: [PATCH 23/67] Update README.md (#3537) Co-authored-by: thomhurst <30480171_thomhurst@users.noreply.github.com> --- README.md | 688 ++++++------------------------------------------------ 1 file changed, 75 insertions(+), 613 deletions(-) diff --git a/README.md b/README.md index 5525b39b47..f15d27f180 100644 --- a/README.md +++ b/README.md @@ -375,683 +375,145 @@ dotnet add package TUnit --prerelease ### Scenario: Building the test project -#### macos-latest - -``` - -BenchmarkDotNet v0.15.4, macOS Sequoia 15.6.1 (24G90) [Darwin 24.6.0] -Apple M1 (Virtual), 1 CPU, 3 logical and 3 physical cores -.NET SDK 9.0.305 - [Host] : .NET 9.0.9 (9.0.9, 9.0.925.41916), Arm64 RyuJIT armv8.0-a - Job-YNJDZW : .NET 9.0.9 (9.0.9, 9.0.925.41916), Arm64 RyuJIT armv8.0-a - -Runtime=.NET 9.0 - -``` -| Method | Version | Mean | Error | StdDev | Median | -|------------- |-------- |--------:|---------:|---------:|--------:| -| Build_TUnit | 0.66.6 | 1.425 s | 0.0954 s | 0.2722 s | 1.384 s | -| Build_NUnit | 4.4.0 | 1.171 s | 0.0670 s | 0.1934 s | 1.150 s | -| Build_xUnit | 2.9.3 | 1.149 s | 0.0853 s | 0.2448 s | 1.101 s | -| Build_MSTest | 3.11.0 | 1.108 s | 0.0468 s | 0.1365 s | 1.103 s | -| Build_xUnit3 | 3.1.0 | 1.144 s | 0.0763 s | 0.2238 s | 1.093 s | - - - -#### ubuntu-latest - ``` BenchmarkDotNet v0.15.4, Linux Ubuntu 24.04.3 LTS (Noble Numbat) AMD EPYC 7763 2.45GHz, 1 CPU, 4 logical and 2 physical cores -.NET SDK 9.0.305 - [Host] : .NET 9.0.9 (9.0.9, 9.0.925.41916), X64 RyuJIT x86-64-v3 - Job-YNJDZW : .NET 9.0.9 (9.0.9, 9.0.925.41916), X64 RyuJIT x86-64-v3 +.NET SDK 10.0.100-rc.2.25502.107 + [Host] : .NET 10.0.0 (10.0.0-rc.2.25502.107, 10.0.25.50307), X64 RyuJIT x86-64-v3 + RyuJitX64 : .NET 10.0.0 (10.0.0-rc.2.25502.107, 10.0.25.50307), X64 RyuJIT x86-64-v3 -Runtime=.NET 9.0 +Job=RyuJitX64 Jit=RyuJit Platform=X64 +PowerPlanMode=00000000-0000-0000-0000-000000000000 Runtime=.NET 10.0 Concurrent=True +Server=True ``` | Method | Version | Mean | Error | StdDev | Median | |------------- |-------- |--------:|---------:|---------:|--------:| -| Build_TUnit | 0.66.6 | 1.645 s | 0.0212 s | 0.0188 s | 1.645 s | -| Build_NUnit | 4.4.0 | 1.500 s | 0.0294 s | 0.0302 s | 1.502 s | -| Build_xUnit | 2.9.3 | 1.513 s | 0.0175 s | 0.0163 s | 1.513 s | -| Build_MSTest | 3.11.0 | 1.534 s | 0.0136 s | 0.0127 s | 1.539 s | -| Build_xUnit3 | 3.1.0 | 1.496 s | 0.0247 s | 0.0219 s | 1.500 s | - - - -#### windows-latest - -``` - -BenchmarkDotNet v0.15.4, Windows 11 (10.0.26100.6584/24H2/2024Update/HudsonValley) (Hyper-V) -AMD EPYC 7763 2.44GHz, 1 CPU, 4 logical and 2 physical cores -.NET SDK 9.0.305 - [Host] : .NET 9.0.9 (9.0.9, 9.0.925.41916), X64 RyuJIT x86-64-v3 - Job-YNJDZW : .NET 9.0.9 (9.0.9, 9.0.925.41916), X64 RyuJIT x86-64-v3 - -Runtime=.NET 9.0 - -``` -| Method | Version | Mean | Error | StdDev | Median | -|------------- |-------- |--------:|---------:|---------:|--------:| -| Build_TUnit | 0.66.6 | 2.134 s | 0.0440 s | 0.1275 s | 2.143 s | -| Build_NUnit | 4.4.0 | 2.085 s | 0.0360 s | 0.0319 s | 2.085 s | -| Build_xUnit | 2.9.3 | 2.055 s | 0.0406 s | 0.0865 s | 2.057 s | -| Build_MSTest | 3.11.0 | 2.160 s | 0.0431 s | 0.1142 s | 2.152 s | -| Build_xUnit3 | 3.1.0 | 2.136 s | 0.0424 s | 0.0505 s | 2.124 s | - - -### Scenario: Tests focused on assertion performance and validation - -#### macos-latest - -``` - -BenchmarkDotNet v0.15.4, macOS Sequoia 15.6.1 (24G90) [Darwin 24.6.0] -Apple M1 (Virtual), 1 CPU, 3 logical and 3 physical cores -.NET SDK 9.0.305 - [Host] : .NET 9.0.9 (9.0.9, 9.0.925.41916), Arm64 RyuJIT armv8.0-a - Job-YNJDZW : .NET 9.0.9 (9.0.9, 9.0.925.41916), Arm64 RyuJIT armv8.0-a - -Runtime=.NET 9.0 - -``` -| Method | Version | Mean | Error | StdDev | Median | -|---------- |-------- |----------:|----------:|----------:|----------:| -| TUnit | 0.66.6 | 507.19 ms | 28.541 ms | 83.71 ms | 497.91 ms | -| NUnit | 4.4.0 | NA | NA | NA | NA | -| xUnit | 2.9.3 | 884.34 ms | 54.651 ms | 160.28 ms | 895.71 ms | -| MSTest | 3.11.0 | 768.27 ms | 39.207 ms | 115.60 ms | 746.33 ms | -| xUnit3 | 3.1.0 | 493.87 ms | 16.161 ms | 47.40 ms | 503.28 ms | -| TUnit_AOT | 0.66.6 | 70.85 ms | 7.276 ms | 21.34 ms | 67.87 ms | - -Benchmarks with issues: - RuntimeBenchmarks.NUnit: Job-YNJDZW(Runtime=.NET 9.0) - - - -#### ubuntu-latest - -``` - -BenchmarkDotNet v0.15.4, Linux Ubuntu 24.04.3 LTS (Noble Numbat) -AMD EPYC 7763 2.61GHz, 1 CPU, 4 logical and 2 physical cores -.NET SDK 9.0.305 - [Host] : .NET 9.0.9 (9.0.9, 9.0.925.41916), X64 RyuJIT x86-64-v3 - Job-YNJDZW : .NET 9.0.9 (9.0.9, 9.0.925.41916), X64 RyuJIT x86-64-v3 - -Runtime=.NET 9.0 - -``` -| Method | Version | Mean | Error | StdDev | Median | -|---------- |-------- |----------:|----------:|----------:|----------:| -| TUnit | 0.66.6 | 487.37 ms | 3.093 ms | 2.893 ms | 487.43 ms | -| NUnit | 4.4.0 | 908.34 ms | 17.276 ms | 16.967 ms | 911.94 ms | -| xUnit | 2.9.3 | 984.77 ms | 19.259 ms | 18.015 ms | 979.06 ms | -| MSTest | 3.11.0 | 836.95 ms | 11.507 ms | 10.764 ms | 832.08 ms | -| xUnit3 | 3.1.0 | 458.16 ms | 4.159 ms | 3.890 ms | 457.83 ms | -| TUnit_AOT | 0.66.6 | 25.31 ms | 0.431 ms | 0.382 ms | 25.16 ms | - - - -#### windows-latest - -``` - -BenchmarkDotNet v0.15.4, Windows 11 (10.0.26100.6584/24H2/2024Update/HudsonValley) (Hyper-V) -AMD EPYC 7763 2.44GHz, 1 CPU, 4 logical and 2 physical cores -.NET SDK 9.0.305 - [Host] : .NET 9.0.9 (9.0.9, 9.0.925.41916), X64 RyuJIT x86-64-v3 - Job-YNJDZW : .NET 9.0.9 (9.0.9, 9.0.925.41916), X64 RyuJIT x86-64-v3 - -Runtime=.NET 9.0 - -``` -| Method | Version | Mean | Error | StdDev | Median | -|---------- |-------- |------------:|----------:|----------:|------------:| -| TUnit | 0.66.6 | 533.36 ms | 3.679 ms | 3.441 ms | 532.96 ms | -| NUnit | 4.4.0 | 1,016.18 ms | 20.186 ms | 34.820 ms | 1,015.43 ms | -| xUnit | 2.9.3 | 1,068.97 ms | 20.224 ms | 16.888 ms | 1,073.74 ms | -| MSTest | 3.11.0 | 968.58 ms | 17.772 ms | 31.127 ms | 964.24 ms | -| xUnit3 | 3.1.0 | 516.29 ms | 7.819 ms | 6.931 ms | 515.39 ms | -| TUnit_AOT | 0.66.6 | 65.97 ms | 2.281 ms | 6.724 ms | 65.43 ms | +| Build_TUnit | 0.77.3 | 1.781 s | 0.0327 s | 0.0306 s | 1.775 s | +| Build_NUnit | 4.4.0 | 1.567 s | 0.0224 s | 0.0199 s | 1.572 s | +| Build_MSTest | 4.0.1 | 1.653 s | 0.0255 s | 0.0226 s | 1.657 s | +| Build_xUnit3 | 3.1.0 | 1.569 s | 0.0135 s | 0.0126 s | 1.566 s | ### Scenario: Tests running asynchronous operations and async/await patterns -#### macos-latest - -``` - -BenchmarkDotNet v0.15.4, macOS Sequoia 15.6.1 (24G90) [Darwin 24.6.0] -Apple M1 (Virtual), 1 CPU, 3 logical and 3 physical cores -.NET SDK 9.0.305 - [Host] : .NET 9.0.9 (9.0.9, 9.0.925.41916), Arm64 RyuJIT armv8.0-a - Job-YNJDZW : .NET 9.0.9 (9.0.9, 9.0.925.41916), Arm64 RyuJIT armv8.0-a - -Runtime=.NET 9.0 - -``` -| Method | Version | Mean | Error | StdDev | Median | -|---------- |-------- |-----------:|---------:|----------:|-----------:| -| TUnit | 0.66.6 | 608.9 ms | 39.85 ms | 117.49 ms | 577.8 ms | -| NUnit | 4.4.0 | 1,260.3 ms | 57.30 ms | 166.24 ms | 1,274.6 ms | -| xUnit | 2.9.3 | NA | NA | NA | NA | -| MSTest | 3.11.0 | 1,050.5 ms | 41.59 ms | 119.99 ms | 1,053.4 ms | -| xUnit3 | 3.1.0 | 559.6 ms | 21.67 ms | 62.18 ms | 567.1 ms | -| TUnit_AOT | 0.66.6 | 144.6 ms | 11.89 ms | 34.88 ms | 145.0 ms | - -Benchmarks with issues: - RuntimeBenchmarks.xUnit: Job-YNJDZW(Runtime=.NET 9.0) - - - -#### ubuntu-latest - -``` - -BenchmarkDotNet v0.15.4, Linux Ubuntu 24.04.3 LTS (Noble Numbat) -AMD EPYC 7763 2.45GHz, 1 CPU, 4 logical and 2 physical cores -.NET SDK 9.0.305 - [Host] : .NET 9.0.9 (9.0.9, 9.0.925.41916), X64 RyuJIT x86-64-v3 - Job-YNJDZW : .NET 9.0.9 (9.0.9, 9.0.925.41916), X64 RyuJIT x86-64-v3 - -Runtime=.NET 9.0 - -``` -| Method | Version | Mean | Error | StdDev | Median | -|---------- |-------- |----------:|----------:|----------:|----------:| -| TUnit | 0.66.6 | 447.34 ms | 3.704 ms | 3.465 ms | 447.86 ms | -| NUnit | 4.4.0 | 907.30 ms | 17.922 ms | 18.404 ms | 901.36 ms | -| xUnit | 2.9.3 | 988.70 ms | 19.061 ms | 17.830 ms | 983.11 ms | -| MSTest | 3.11.0 | 842.79 ms | 16.374 ms | 17.520 ms | 842.60 ms | -| xUnit3 | 3.1.0 | 460.86 ms | 2.968 ms | 2.777 ms | 459.81 ms | -| TUnit_AOT | 0.66.6 | 26.67 ms | 0.506 ms | 0.562 ms | 26.78 ms | - - - -#### windows-latest - -``` - -BenchmarkDotNet v0.15.4, Windows 11 (10.0.26100.6584/24H2/2024Update/HudsonValley) (Hyper-V) -AMD EPYC 7763 2.44GHz, 1 CPU, 4 logical and 2 physical cores -.NET SDK 9.0.305 - [Host] : .NET 9.0.9 (9.0.9, 9.0.925.41916), X64 RyuJIT x86-64-v3 - Job-YNJDZW : .NET 9.0.9 (9.0.9, 9.0.925.41916), X64 RyuJIT x86-64-v3 - -Runtime=.NET 9.0 - -``` -| Method | Version | Mean | Error | StdDev | Median | -|---------- |-------- |------------:|----------:|----------:|------------:| -| TUnit | 0.66.6 | 494.36 ms | 7.194 ms | 6.729 ms | 490.64 ms | -| NUnit | 4.4.0 | 1,005.78 ms | 18.596 ms | 17.394 ms | 1,007.02 ms | -| xUnit | 2.9.3 | 1,096.34 ms | 20.797 ms | 22.253 ms | 1,097.98 ms | -| MSTest | 3.11.0 | 962.06 ms | 18.672 ms | 26.175 ms | 957.03 ms | -| xUnit3 | 3.1.0 | 522.65 ms | 9.988 ms | 9.343 ms | 521.69 ms | -| TUnit_AOT | 0.66.6 | 89.31 ms | 2.294 ms | 6.728 ms | 89.53 ms | - - -### Scenario: Simple tests with basic operations and assertions - -#### macos-latest - -``` - -BenchmarkDotNet v0.15.4, macOS Sequoia 15.6.1 (24G90) [Darwin 24.6.0] -Apple M1 (Virtual), 1 CPU, 3 logical and 3 physical cores -.NET SDK 9.0.305 - [Host] : .NET 9.0.9 (9.0.9, 9.0.925.41916), Arm64 RyuJIT armv8.0-a - Job-YNJDZW : .NET 9.0.9 (9.0.9, 9.0.925.41916), Arm64 RyuJIT armv8.0-a - -Runtime=.NET 9.0 - -``` -| Method | Version | Mean | Error | StdDev | Median | -|---------- |-------- |----------:|----------:|---------:|----------:| -| TUnit | 0.66.6 | 413.11 ms | 29.400 ms | 85.30 ms | 389.28 ms | -| NUnit | 4.4.0 | NA | NA | NA | NA | -| xUnit | 2.9.3 | NA | NA | NA | NA | -| MSTest | 3.11.0 | NA | NA | NA | NA | -| xUnit3 | 3.1.0 | 289.63 ms | 7.455 ms | 21.51 ms | 285.72 ms | -| TUnit_AOT | 0.66.6 | 66.85 ms | 9.402 ms | 27.57 ms | 61.87 ms | - -Benchmarks with issues: - RuntimeBenchmarks.NUnit: Job-YNJDZW(Runtime=.NET 9.0) - RuntimeBenchmarks.xUnit: Job-YNJDZW(Runtime=.NET 9.0) - RuntimeBenchmarks.MSTest: Job-YNJDZW(Runtime=.NET 9.0) - - - -#### ubuntu-latest - ``` BenchmarkDotNet v0.15.4, Linux Ubuntu 24.04.3 LTS (Noble Numbat) -AMD EPYC 7763 2.45GHz, 1 CPU, 4 logical and 2 physical cores -.NET SDK 9.0.305 - [Host] : .NET 9.0.9 (9.0.9, 9.0.925.41916), X64 RyuJIT x86-64-v3 - Job-YNJDZW : .NET 9.0.9 (9.0.9, 9.0.925.41916), X64 RyuJIT x86-64-v3 - -Runtime=.NET 9.0 - -``` -| Method | Version | Mean | Error | StdDev | Median | -|---------- |-------- |----------:|----------:|----------:|----------:| -| TUnit | 0.66.6 | 466.81 ms | 5.399 ms | 4.786 ms | 466.44 ms | -| NUnit | 4.4.0 | 919.54 ms | 16.718 ms | 15.638 ms | 919.80 ms | -| xUnit | 2.9.3 | 994.05 ms | 10.533 ms | 9.337 ms | 992.55 ms | -| MSTest | 3.11.0 | 853.21 ms | 16.534 ms | 17.691 ms | 854.80 ms | -| xUnit3 | 3.1.0 | 459.93 ms | 3.301 ms | 2.926 ms | 458.96 ms | -| TUnit_AOT | 0.66.6 | 25.34 ms | 0.444 ms | 0.415 ms | 25.14 ms | - - - -#### windows-latest - -``` - -BenchmarkDotNet v0.15.4, Windows 11 (10.0.26100.6584/24H2/2024Update/HudsonValley) (Hyper-V) -AMD EPYC 7763 2.44GHz, 1 CPU, 4 logical and 2 physical cores -.NET SDK 9.0.305 - [Host] : .NET 9.0.9 (9.0.9, 9.0.925.41916), X64 RyuJIT x86-64-v3 - Job-YNJDZW : .NET 9.0.9 (9.0.9, 9.0.925.41916), X64 RyuJIT x86-64-v3 +AMD EPYC 7763 2.60GHz, 1 CPU, 4 logical and 2 physical cores +.NET SDK 10.0.100-rc.2.25502.107 + [Host] : .NET 10.0.0 (10.0.0-rc.2.25502.107, 10.0.25.50307), X64 RyuJIT x86-64-v3 + RyuJitX64 : .NET 10.0.0 (10.0.0-rc.2.25502.107, 10.0.25.50307), X64 RyuJIT x86-64-v3 -Runtime=.NET 9.0 +Job=RyuJitX64 Jit=RyuJit Platform=X64 +PowerPlanMode=00000000-0000-0000-0000-000000000000 Runtime=.NET 10.0 Concurrent=True +Server=True ``` -| Method | Version | Mean | Error | StdDev | Median | -|---------- |-------- |------------:|----------:|----------:|------------:| -| TUnit | 0.66.6 | 517.27 ms | 4.416 ms | 4.130 ms | 518.39 ms | -| NUnit | 4.4.0 | 1,018.48 ms | 20.084 ms | 19.725 ms | 1,014.70 ms | -| xUnit | 2.9.3 | 1,079.55 ms | 19.399 ms | 18.145 ms | 1,079.08 ms | -| MSTest | 3.11.0 | 950.05 ms | 18.521 ms | 21.329 ms | 953.91 ms | -| xUnit3 | 3.1.0 | 509.16 ms | 6.805 ms | 6.365 ms | 507.00 ms | -| TUnit_AOT | 0.66.6 | 66.58 ms | 1.505 ms | 4.414 ms | 65.67 ms | +| Method | Version | Mean | Error | StdDev | Median | +|---------- |-------- |----------:|---------:|----------:|----------:| +| TUnit | 0.77.3 | 478.94 ms | 5.325 ms | 4.981 ms | 479.13 ms | +| NUnit | 4.4.0 | 526.96 ms | 9.078 ms | 8.048 ms | 527.32 ms | +| MSTest | 4.0.1 | 503.03 ms | 9.642 ms | 12.872 ms | 499.80 ms | +| xUnit3 | 3.1.0 | 501.98 ms | 6.870 ms | 6.426 ms | 500.00 ms | +| TUnit_AOT | 0.77.3 | 34.13 ms | 0.487 ms | 0.406 ms | 33.97 ms | ### Scenario: Parameterized tests with multiple test cases using data attributes -#### macos-latest - ``` -BenchmarkDotNet v0.15.4, macOS Sequoia 15.6.1 (24G90) [Darwin 24.6.0] -Apple M1 (Virtual), 1 CPU, 3 logical and 3 physical cores -.NET SDK 9.0.305 - [Host] : .NET 9.0.9 (9.0.9, 9.0.925.41916), Arm64 RyuJIT armv8.0-a - Job-YNJDZW : .NET 9.0.9 (9.0.9, 9.0.925.41916), Arm64 RyuJIT armv8.0-a +BenchmarkDotNet v0.15.4, Linux Ubuntu 24.04.3 LTS (Noble Numbat) +AMD EPYC 7763 2.75GHz, 1 CPU, 4 logical and 2 physical cores +.NET SDK 10.0.100-rc.2.25502.107 + [Host] : .NET 10.0.0 (10.0.0-rc.2.25502.107, 10.0.25.50307), X64 RyuJIT x86-64-v3 + RyuJitX64 : .NET 10.0.0 (10.0.0-rc.2.25502.107, 10.0.25.50307), X64 RyuJIT x86-64-v3 -Runtime=.NET 9.0 +Job=RyuJitX64 Jit=RyuJit Platform=X64 +PowerPlanMode=00000000-0000-0000-0000-000000000000 Runtime=.NET 10.0 Concurrent=True +Server=True ``` | Method | Version | Mean | Error | StdDev | Median | |---------- |-------- |----------:|----------:|---------:|----------:| -| TUnit | 0.66.6 | 408.74 ms | 18.928 ms | 55.21 ms | 396.30 ms | -| NUnit | 4.4.0 | NA | NA | NA | NA | -| xUnit | 2.9.3 | NA | NA | NA | NA | -| MSTest | 3.11.0 | 659.03 ms | 22.634 ms | 66.38 ms | 666.53 ms | -| xUnit3 | 3.1.0 | 377.11 ms | 11.684 ms | 33.90 ms | 379.14 ms | -| TUnit_AOT | 0.66.6 | 44.69 ms | 3.968 ms | 11.39 ms | 41.36 ms | - -Benchmarks with issues: - RuntimeBenchmarks.NUnit: Job-YNJDZW(Runtime=.NET 9.0) - RuntimeBenchmarks.xUnit: Job-YNJDZW(Runtime=.NET 9.0) - +| TUnit | 0.77.3 | 485.31 ms | 3.767 ms | 3.524 ms | 484.81 ms | +| NUnit | 4.4.0 | 687.51 ms | 11.332 ms | 8.847 ms | 683.38 ms | +| MSTest | 4.0.1 | 689.18 ms | 8.267 ms | 7.329 ms | 688.63 ms | +| xUnit3 | 3.1.0 | 502.63 ms | 2.875 ms | 2.690 ms | 502.82 ms | +| TUnit_AOT | 0.77.3 | 34.64 ms | 0.688 ms | 1.995 ms | 34.20 ms | -#### ubuntu-latest +### Scenario: Tests executing massively parallel workloads with CPU-bound, I/O-bound, and mixed operations ``` BenchmarkDotNet v0.15.4, Linux Ubuntu 24.04.3 LTS (Noble Numbat) AMD EPYC 7763 2.45GHz, 1 CPU, 4 logical and 2 physical cores -.NET SDK 9.0.305 - [Host] : .NET 9.0.9 (9.0.9, 9.0.925.41916), X64 RyuJIT x86-64-v3 - Job-YNJDZW : .NET 9.0.9 (9.0.9, 9.0.925.41916), X64 RyuJIT x86-64-v3 +.NET SDK 10.0.100-rc.2.25502.107 + [Host] : .NET 10.0.0 (10.0.0-rc.2.25502.107, 10.0.25.50307), X64 RyuJIT x86-64-v3 + RyuJitX64 : .NET 10.0.0 (10.0.0-rc.2.25502.107, 10.0.25.50307), X64 RyuJIT x86-64-v3 -Runtime=.NET 9.0 - -``` -| Method | Version | Mean | Error | StdDev | Median | -|---------- |-------- |------------:|----------:|----------:|------------:| -| TUnit | 0.66.6 | 460.22 ms | 2.117 ms | 1.877 ms | 460.03 ms | -| NUnit | 4.4.0 | 909.38 ms | 16.413 ms | 15.352 ms | 917.73 ms | -| xUnit | 2.9.3 | 1,016.83 ms | 18.472 ms | 17.279 ms | 1,008.66 ms | -| MSTest | 3.11.0 | 866.29 ms | 16.852 ms | 18.031 ms | 865.01 ms | -| xUnit3 | 3.1.0 | 486.65 ms | 3.559 ms | 3.155 ms | 487.03 ms | -| TUnit_AOT | 0.66.6 | 28.22 ms | 0.343 ms | 0.304 ms | 28.31 ms | - - - -#### windows-latest - -``` - -BenchmarkDotNet v0.15.4, Windows 11 (10.0.26100.6584/24H2/2024Update/HudsonValley) (Hyper-V) -AMD EPYC 7763 2.44GHz, 1 CPU, 4 logical and 2 physical cores -.NET SDK 9.0.305 - [Host] : .NET 9.0.9 (9.0.9, 9.0.925.41916), X64 RyuJIT x86-64-v3 - Job-YNJDZW : .NET 9.0.9 (9.0.9, 9.0.925.41916), X64 RyuJIT x86-64-v3 - -Runtime=.NET 9.0 - -``` -| Method | Version | Mean | Error | StdDev | Median | -|---------- |-------- |------------:|----------:|-----------:|------------:| -| TUnit | 0.66.6 | 550.13 ms | 10.668 ms | 10.477 ms | 545.52 ms | -| NUnit | 4.4.0 | 1,115.22 ms | 22.111 ms | 23.658 ms | 1,115.33 ms | -| xUnit | 2.9.3 | 1,209.43 ms | 35.495 ms | 100.694 ms | 1,223.99 ms | -| MSTest | 3.11.0 | 1,041.10 ms | 20.681 ms | 23.817 ms | 1,038.52 ms | -| xUnit3 | 3.1.0 | 565.34 ms | 10.258 ms | 9.596 ms | 565.75 ms | -| TUnit_AOT | 0.66.6 | 71.69 ms | 1.423 ms | 3.410 ms | 71.39 ms | - - -### Scenario: Tests utilizing class fixtures and shared test context - -#### macos-latest - -``` - -BenchmarkDotNet v0.15.4, macOS Sequoia 15.6.1 (24G90) [Darwin 24.6.0] -Apple M1 (Virtual), 1 CPU, 3 logical and 3 physical cores -.NET SDK 9.0.305 - [Host] : .NET 9.0.9 (9.0.9, 9.0.925.41916), Arm64 RyuJIT armv8.0-a - Job-YNJDZW : .NET 9.0.9 (9.0.9, 9.0.925.41916), Arm64 RyuJIT armv8.0-a - -Runtime=.NET 9.0 +Job=RyuJitX64 Jit=RyuJit Platform=X64 +PowerPlanMode=00000000-0000-0000-0000-000000000000 Runtime=.NET 10.0 Concurrent=True +Server=True ``` | Method | Version | Mean | Error | StdDev | Median | |---------- |-------- |----------:|----------:|---------:|----------:| -| TUnit | 0.66.6 | 286.49 ms | 6.152 ms | 17.75 ms | 285.41 ms | -| NUnit | 4.4.0 | NA | NA | NA | NA | -| xUnit | 2.9.3 | NA | NA | NA | NA | -| MSTest | 3.11.0 | NA | NA | NA | NA | -| xUnit3 | 3.1.0 | 375.36 ms | 22.387 ms | 66.01 ms | 362.13 ms | -| TUnit_AOT | 0.66.6 | 64.68 ms | 6.634 ms | 19.03 ms | 62.08 ms | +| TUnit | 0.77.3 | 499.03 ms | 6.589 ms | 5.841 ms | 498.34 ms | +| NUnit | 4.4.0 | 582.75 ms | 10.155 ms | 9.499 ms | 583.13 ms | +| MSTest | 4.0.1 | 560.42 ms | 8.550 ms | 7.997 ms | 560.16 ms | +| xUnit3 | 3.1.0 | 567.61 ms | 4.273 ms | 3.788 ms | 566.90 ms | +| TUnit_AOT | 0.77.3 | 39.00 ms | 0.255 ms | 0.238 ms | 39.04 ms | -Benchmarks with issues: - RuntimeBenchmarks.NUnit: Job-YNJDZW(Runtime=.NET 9.0) - RuntimeBenchmarks.xUnit: Job-YNJDZW(Runtime=.NET 9.0) - RuntimeBenchmarks.MSTest: Job-YNJDZW(Runtime=.NET 9.0) - - -#### ubuntu-latest +### Scenario: Tests with complex parameter combinations creating 25-125 test variations ``` BenchmarkDotNet v0.15.4, Linux Ubuntu 24.04.3 LTS (Noble Numbat) AMD EPYC 7763 2.45GHz, 1 CPU, 4 logical and 2 physical cores -.NET SDK 9.0.305 - [Host] : .NET 9.0.9 (9.0.9, 9.0.925.41916), X64 RyuJIT x86-64-v3 - Job-YNJDZW : .NET 9.0.9 (9.0.9, 9.0.925.41916), X64 RyuJIT x86-64-v3 - -Runtime=.NET 9.0 - -``` -| Method | Version | Mean | Error | StdDev | Median | -|---------- |-------- |------------:|----------:|----------:|------------:| -| TUnit | 0.66.6 | 452.99 ms | 3.907 ms | 3.263 ms | 453.44 ms | -| NUnit | 4.4.0 | 930.95 ms | 14.776 ms | 13.821 ms | 935.97 ms | -| xUnit | 2.9.3 | 1,006.26 ms | 16.690 ms | 15.611 ms | 1,000.82 ms | -| MSTest | 3.11.0 | 850.14 ms | 16.934 ms | 18.119 ms | 852.12 ms | -| xUnit3 | 3.1.0 | 450.19 ms | 4.025 ms | 3.765 ms | 450.09 ms | -| TUnit_AOT | 0.66.6 | 38.57 ms | 1.098 ms | 3.220 ms | 38.60 ms | - - +.NET SDK 10.0.100-rc.2.25502.107 + [Host] : .NET 10.0.0 (10.0.0-rc.2.25502.107, 10.0.25.50307), X64 RyuJIT x86-64-v3 + RyuJitX64 : .NET 10.0.0 (10.0.0-rc.2.25502.107, 10.0.25.50307), X64 RyuJIT x86-64-v3 -#### windows-latest - -``` - -BenchmarkDotNet v0.15.4, Windows 11 (10.0.26100.6584/24H2/2024Update/HudsonValley) (Hyper-V) -AMD EPYC 7763 2.44GHz, 1 CPU, 4 logical and 2 physical cores -.NET SDK 9.0.305 - [Host] : .NET 9.0.9 (9.0.9, 9.0.925.41916), X64 RyuJIT x86-64-v3 - Job-YNJDZW : .NET 9.0.9 (9.0.9, 9.0.925.41916), X64 RyuJIT x86-64-v3 - -Runtime=.NET 9.0 - -``` -| Method | Version | Mean | Error | StdDev | Median | -|---------- |-------- |------------:|----------:|----------:|------------:| -| TUnit | 0.66.6 | 495.87 ms | 8.424 ms | 10.653 ms | 492.34 ms | -| NUnit | 4.4.0 | 973.98 ms | 19.011 ms | 18.671 ms | 972.03 ms | -| xUnit | 2.9.3 | 1,049.78 ms | 14.531 ms | 13.593 ms | 1,044.49 ms | -| MSTest | 3.11.0 | 916.98 ms | 16.913 ms | 15.821 ms | 912.23 ms | -| xUnit3 | 3.1.0 | 486.78 ms | 3.897 ms | 3.645 ms | 486.01 ms | -| TUnit_AOT | 0.66.6 | 91.20 ms | 1.800 ms | 3.152 ms | 91.43 ms | - - -### Scenario: Tests executing in parallel to test framework parallelization - -#### macos-latest - -``` - -BenchmarkDotNet v0.15.4, macOS Sequoia 15.6.1 (24G90) [Darwin 24.6.0] -Apple M1 (Virtual), 1 CPU, 3 logical and 3 physical cores -.NET SDK 9.0.305 - [Host] : .NET 9.0.9 (9.0.9, 9.0.925.41916), Arm64 RyuJIT armv8.0-a - Job-YNJDZW : .NET 9.0.9 (9.0.9, 9.0.925.41916), Arm64 RyuJIT armv8.0-a - -Runtime=.NET 9.0 - -``` -| Method | Version | Mean | Error | StdDev | Median | -|---------- |-------- |------------:|-----------:|----------:|------------:| -| TUnit | 0.66.6 | 410.22 ms | 23.733 ms | 69.23 ms | 402.84 ms | -| NUnit | 4.4.0 | 1,342.07 ms | 95.053 ms | 274.25 ms | 1,303.84 ms | -| xUnit | 2.9.3 | 1,328.93 ms | 101.864 ms | 300.35 ms | 1,301.91 ms | -| MSTest | 3.11.0 | NA | NA | NA | NA | -| xUnit3 | 3.1.0 | 470.25 ms | 20.347 ms | 58.38 ms | 468.24 ms | -| TUnit_AOT | 0.66.6 | 58.77 ms | 7.644 ms | 21.93 ms | 53.57 ms | - -Benchmarks with issues: - RuntimeBenchmarks.MSTest: Job-YNJDZW(Runtime=.NET 9.0) - - - -#### ubuntu-latest - -``` - -BenchmarkDotNet v0.15.4, Linux Ubuntu 24.04.3 LTS (Noble Numbat) -AMD EPYC 7763 2.45GHz, 1 CPU, 4 logical and 2 physical cores -.NET SDK 9.0.305 - [Host] : .NET 9.0.9 (9.0.9, 9.0.925.41916), X64 RyuJIT x86-64-v3 - Job-YNJDZW : .NET 9.0.9 (9.0.9, 9.0.925.41916), X64 RyuJIT x86-64-v3 - -Runtime=.NET 9.0 +Job=RyuJitX64 Jit=RyuJit Platform=X64 +PowerPlanMode=00000000-0000-0000-0000-000000000000 Runtime=.NET 10.0 Concurrent=True +Server=True ``` | Method | Version | Mean | Error | StdDev | Median | |---------- |-------- |----------:|----------:|----------:|----------:| -| TUnit | 0.66.6 | 442.19 ms | 1.731 ms | 1.534 ms | 441.97 ms | -| NUnit | 4.4.0 | 918.06 ms | 17.680 ms | 17.364 ms | 921.88 ms | -| xUnit | 2.9.3 | 983.91 ms | 18.873 ms | 17.654 ms | 979.36 ms | -| MSTest | 3.11.0 | 838.79 ms | 14.777 ms | 13.823 ms | 844.27 ms | -| xUnit3 | 3.1.0 | 449.58 ms | 2.903 ms | 2.573 ms | 449.82 ms | -| TUnit_AOT | 0.66.6 | 24.97 ms | 0.208 ms | 0.184 ms | 24.95 ms | +| TUnit | 0.77.3 | 509.47 ms | 6.776 ms | 6.007 ms | 508.85 ms | +| NUnit | 4.4.0 | 596.89 ms | 11.807 ms | 16.934 ms | 594.68 ms | +| MSTest | 4.0.1 | 610.32 ms | 11.764 ms | 11.004 ms | 613.12 ms | +| xUnit3 | 3.1.0 | 533.11 ms | 3.656 ms | 3.053 ms | 533.28 ms | +| TUnit_AOT | 0.77.3 | 37.61 ms | 0.499 ms | 0.467 ms | 37.68 ms | - -#### windows-latest +### Scenario: Large-scale parameterized tests with 100+ test cases testing framework scalability ``` -BenchmarkDotNet v0.15.4, Windows 11 (10.0.26100.6584/24H2/2024Update/HudsonValley) (Hyper-V) -AMD EPYC 7763 2.44GHz, 1 CPU, 4 logical and 2 physical cores -.NET SDK 9.0.305 - [Host] : .NET 9.0.9 (9.0.9, 9.0.925.41916), X64 RyuJIT x86-64-v3 - Job-YNJDZW : .NET 9.0.9 (9.0.9, 9.0.925.41916), X64 RyuJIT x86-64-v3 - -Runtime=.NET 9.0 - -``` -| Method | Version | Mean | Error | StdDev | Median | -|---------- |-------- |------------:|----------:|----------:|------------:| -| TUnit | 0.66.6 | 510.80 ms | 3.865 ms | 3.616 ms | 511.67 ms | -| NUnit | 4.4.0 | 1,085.21 ms | 21.543 ms | 37.161 ms | 1,079.30 ms | -| xUnit | 2.9.3 | 1,125.35 ms | 22.326 ms | 27.418 ms | 1,124.00 ms | -| MSTest | 3.11.0 | 979.31 ms | 15.894 ms | 14.867 ms | 979.73 ms | -| xUnit3 | 3.1.0 | 526.16 ms | 5.769 ms | 5.396 ms | 526.42 ms | -| TUnit_AOT | 0.66.6 | 65.78 ms | 1.726 ms | 5.063 ms | 65.93 ms | - - -### Scenario: A test that takes 50ms to execute, repeated 100 times - -#### macos-latest - -``` - -BenchmarkDotNet v0.15.4, macOS Sequoia 15.6.1 (24G90) [Darwin 24.6.0] -Apple M1 (Virtual), 1 CPU, 3 logical and 3 physical cores -.NET SDK 9.0.305 - [Host] : .NET 9.0.9 (9.0.9, 9.0.925.41916), Arm64 RyuJIT armv8.0-a - Job-YNJDZW : .NET 9.0.9 (9.0.9, 9.0.925.41916), Arm64 RyuJIT armv8.0-a +BenchmarkDotNet v0.15.4, Linux Ubuntu 24.04.3 LTS (Noble Numbat) +AMD EPYC 7763 2.74GHz, 1 CPU, 4 logical and 2 physical cores +.NET SDK 10.0.100-rc.2.25502.107 + [Host] : .NET 10.0.0 (10.0.0-rc.2.25502.107, 10.0.25.50307), X64 RyuJIT x86-64-v3 + RyuJitX64 : .NET 10.0.0 (10.0.0-rc.2.25502.107, 10.0.25.50307), X64 RyuJIT x86-64-v3 -Runtime=.NET 9.0 +Job=RyuJitX64 Jit=RyuJit Platform=X64 +PowerPlanMode=00000000-0000-0000-0000-000000000000 Runtime=.NET 10.0 Concurrent=True +Server=True ``` | Method | Version | Mean | Error | StdDev | Median | |---------- |-------- |----------:|----------:|----------:|----------:| -| TUnit | 0.66.6 | 338.92 ms | 19.812 ms | 58.105 ms | 325.53 ms | -| NUnit | 4.4.0 | 574.90 ms | 9.258 ms | 7.228 ms | 572.83 ms | -| xUnit | 2.9.3 | NA | NA | NA | NA | -| MSTest | 3.11.0 | NA | NA | NA | NA | -| xUnit3 | 3.1.0 | 324.91 ms | 10.115 ms | 29.507 ms | 318.54 ms | -| TUnit_AOT | 0.66.6 | 60.53 ms | 8.531 ms | 24.613 ms | 56.15 ms | - -Benchmarks with issues: - RuntimeBenchmarks.xUnit: Job-YNJDZW(Runtime=.NET 9.0) - RuntimeBenchmarks.MSTest: Job-YNJDZW(Runtime=.NET 9.0) - - - -#### ubuntu-latest - -``` - -BenchmarkDotNet v0.15.4, Linux Ubuntu 24.04.3 LTS (Noble Numbat) -AMD EPYC 7763 2.90GHz, 1 CPU, 4 logical and 2 physical cores -.NET SDK 9.0.305 - [Host] : .NET 9.0.9 (9.0.9, 9.0.925.41916), X64 RyuJIT x86-64-v3 - Job-YNJDZW : .NET 9.0.9 (9.0.9, 9.0.925.41916), X64 RyuJIT x86-64-v3 - -Runtime=.NET 9.0 - -``` -| Method | Version | Mean | Error | StdDev | Median | -|---------- |-------- |------------:|----------:|----------:|------------:| -| TUnit | 0.66.6 | 486.94 ms | 3.897 ms | 3.645 ms | 487.44 ms | -| NUnit | 4.4.0 | 925.61 ms | 17.307 ms | 16.189 ms | 922.59 ms | -| xUnit | 2.9.3 | 1,085.95 ms | 20.200 ms | 17.906 ms | 1,082.26 ms | -| MSTest | 3.11.0 | 858.03 ms | 16.000 ms | 14.966 ms | 856.06 ms | -| xUnit3 | 3.1.0 | 491.88 ms | 4.490 ms | 3.980 ms | 489.95 ms | -| TUnit_AOT | 0.66.6 | 40.90 ms | 0.680 ms | 0.636 ms | 40.67 ms | - - - -#### windows-latest - -``` - -BenchmarkDotNet v0.15.4, Windows 11 (10.0.26100.6584/24H2/2024Update/HudsonValley) (Hyper-V) -AMD EPYC 7763 2.44GHz, 1 CPU, 4 logical and 2 physical cores -.NET SDK 9.0.305 - [Host] : .NET 9.0.9 (9.0.9, 9.0.925.41916), X64 RyuJIT x86-64-v3 - Job-YNJDZW : .NET 9.0.9 (9.0.9, 9.0.925.41916), X64 RyuJIT x86-64-v3 - -Runtime=.NET 9.0 - -``` -| Method | Version | Mean | Error | StdDev | Median | -|---------- |-------- |------------:|----------:|----------:|------------:| -| TUnit | 0.66.6 | 560.34 ms | 10.543 ms | 9.862 ms | 559.08 ms | -| NUnit | 4.4.0 | 1,072.50 ms | 20.980 ms | 45.608 ms | 1,074.71 ms | -| xUnit | 2.9.3 | 1,232.15 ms | 24.387 ms | 26.094 ms | 1,232.77 ms | -| MSTest | 3.11.0 | 994.43 ms | 19.847 ms | 54.995 ms | 991.14 ms | -| xUnit3 | 3.1.0 | 580.80 ms | 11.371 ms | 15.180 ms | 581.16 ms | -| TUnit_AOT | 0.66.6 | 81.43 ms | 2.104 ms | 6.203 ms | 82.90 ms | - - -### Scenario: Tests with setup and teardown lifecycle methods - -#### macos-latest - -``` - -BenchmarkDotNet v0.15.4, macOS Sequoia 15.6.1 (24G90) [Darwin 24.6.0] -Apple M1 (Virtual), 1 CPU, 3 logical and 3 physical cores -.NET SDK 9.0.305 - [Host] : .NET 9.0.9 (9.0.9, 9.0.925.41916), Arm64 RyuJIT armv8.0-a - Job-YNJDZW : .NET 9.0.9 (9.0.9, 9.0.925.41916), Arm64 RyuJIT armv8.0-a - -Runtime=.NET 9.0 - -``` -| Method | Version | Mean | Error | StdDev | Median | -|---------- |-------- |------------:|----------:|----------:|------------:| -| TUnit | 0.66.6 | 525.73 ms | 37.455 ms | 108.66 ms | 518.72 ms | -| NUnit | 4.4.0 | 1,163.42 ms | 50.082 ms | 143.69 ms | 1,154.49 ms | -| xUnit | 2.9.3 | 1,063.35 ms | 38.811 ms | 113.83 ms | 1,063.37 ms | -| MSTest | 3.11.0 | 864.08 ms | 79.507 ms | 234.43 ms | 768.70 ms | -| xUnit3 | 3.1.0 | 389.16 ms | 18.371 ms | 53.59 ms | 391.37 ms | -| TUnit_AOT | 0.66.6 | 50.16 ms | 6.305 ms | 18.19 ms | 48.60 ms | - - - -#### ubuntu-latest - -``` - -BenchmarkDotNet v0.15.4, Linux Ubuntu 24.04.3 LTS (Noble Numbat) -AMD EPYC 7763 3.14GHz, 1 CPU, 4 logical and 2 physical cores -.NET SDK 9.0.305 - [Host] : .NET 9.0.9 (9.0.9, 9.0.925.41916), X64 RyuJIT x86-64-v3 - Job-YNJDZW : .NET 9.0.9 (9.0.9, 9.0.925.41916), X64 RyuJIT x86-64-v3 - -Runtime=.NET 9.0 - -``` -| Method | Version | Mean | Error | StdDev | Median | -|---------- |-------- |------------:|----------:|----------:|------------:| -| TUnit | 0.66.6 | 467.37 ms | 2.088 ms | 1.953 ms | 467.00 ms | -| NUnit | 4.4.0 | 937.52 ms | 18.192 ms | 19.465 ms | 935.06 ms | -| xUnit | 2.9.3 | 1,033.10 ms | 18.060 ms | 16.894 ms | 1,028.90 ms | -| MSTest | 3.11.0 | 885.00 ms | 17.336 ms | 17.026 ms | 882.50 ms | -| xUnit3 | 3.1.0 | 465.87 ms | 3.757 ms | 3.331 ms | 465.39 ms | -| TUnit_AOT | 0.66.6 | 26.79 ms | 0.208 ms | 0.174 ms | 26.82 ms | - - - -#### windows-latest - -``` - -BenchmarkDotNet v0.15.4, Windows 11 (10.0.26100.6584/24H2/2024Update/HudsonValley) (Hyper-V) -AMD EPYC 7763 2.44GHz, 1 CPU, 4 logical and 2 physical cores -.NET SDK 9.0.305 - [Host] : .NET 9.0.9 (9.0.9, 9.0.925.41916), X64 RyuJIT x86-64-v3 - Job-YNJDZW : .NET 9.0.9 (9.0.9, 9.0.925.41916), X64 RyuJIT x86-64-v3 - -Runtime=.NET 9.0 - -``` -| Method | Version | Mean | Error | StdDev | Median | -|---------- |-------- |------------:|----------:|----------:|------------:| -| TUnit | 0.66.6 | 527.84 ms | 10.425 ms | 24.160 ms | 521.87 ms | -| NUnit | 4.4.0 | 1,069.13 ms | 21.368 ms | 22.864 ms | 1,069.02 ms | -| xUnit | 2.9.3 | 1,152.17 ms | 22.381 ms | 29.102 ms | 1,160.05 ms | -| MSTest | 3.11.0 | 994.82 ms | 19.730 ms | 21.929 ms | 994.32 ms | -| xUnit3 | 3.1.0 | 544.24 ms | 10.540 ms | 14.071 ms | 543.06 ms | -| TUnit_AOT | 0.66.6 | 68.69 ms | 2.349 ms | 6.815 ms | 68.38 ms | +| TUnit | 0.77.3 | 507.33 ms | 5.869 ms | 5.490 ms | 509.06 ms | +| NUnit | 4.4.0 | 613.34 ms | 4.137 ms | 3.455 ms | 612.68 ms | +| MSTest | 4.0.1 | 632.89 ms | 11.515 ms | 10.208 ms | 635.09 ms | +| xUnit3 | 3.1.0 | 510.91 ms | 3.648 ms | 3.413 ms | 510.42 ms | +| TUnit_AOT | 0.77.3 | 56.39 ms | 1.220 ms | 3.597 ms | 56.85 ms | From 6ed64168a4d269bd43db372e70bb7eff8d5d2d25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hakan=20F=C4=B1st=C4=B1k?= Date: Mon, 27 Oct 2025 17:43:44 +0100 Subject: [PATCH 24/67] Update Docs (#3541) * Update Docs - Add advanced sidebar item - Add --log-level update #3538 * fix --- docs/docs/customization-extensibility/logging.md | 9 +++++++++ docs/sidebars.ts | 9 +++++++++ 2 files changed, 18 insertions(+) diff --git a/docs/docs/customization-extensibility/logging.md b/docs/docs/customization-extensibility/logging.md index 619870a05f..90c072ece3 100644 --- a/docs/docs/customization-extensibility/logging.md +++ b/docs/docs/customization-extensibility/logging.md @@ -20,3 +20,12 @@ If you want to override this, you can inherit from `TUnitLogger` or `DefaultLogg return logLevel >= LogLevel.Error; } ``` + +## Log Level Command Line +If you are executing tests via the command line, you can set the log level via the `--log-level` argument: + +``` +dotnet run --log-level Warning +``` + +The above will show only logs that are `Warning` or higher (e.g. `Error`, `Critical`) while executing the test. diff --git a/docs/sidebars.ts b/docs/sidebars.ts index 64b47e48ca..04ae12559b 100644 --- a/docs/sidebars.ts +++ b/docs/sidebars.ts @@ -147,6 +147,15 @@ const sidebars: SidebarsConfig = { 'reference/test-configuration', ], }, + { + type: 'category', + label: 'Advanced', + items: [ + 'advanced/exception-handling', + 'advanced/extension-points', + 'advanced/performance-best-practices', + ], + }, { type: 'category', label: 'Migration Guides', From 125bdfb102ad5381f539f83385c93bc68f1a22c4 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 27 Oct 2025 21:18:43 +0000 Subject: [PATCH 25/67] Fix ArgumentDisplayFormatter ignored due to premature display name caching (#3543) * Initial plan * Initial exploration - understanding ArgumentDisplayFormatter issue Co-authored-by: thomhurst <30480171+thomhurst@users.noreply.github.com> * Fix ArgumentDisplayFormatter being ignored - invalidate cache after discovery Co-authored-by: thomhurst <30480171+thomhurst@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: thomhurst <30480171+thomhurst@users.noreply.github.com> --- TUnit.Core/TestContext.cs | 9 +++ TUnit.Engine/Building/TestBuilder.cs | 5 ++ .../ArgumentDisplayFormatterTests.cs | 72 +++++++++++++++++++ global.json | 5 +- 4 files changed, 89 insertions(+), 2 deletions(-) create mode 100644 TUnit.TestProject/ArgumentDisplayFormatterTests.cs diff --git a/TUnit.Core/TestContext.cs b/TUnit.Core/TestContext.cs index 125a72f85d..b5acc046c2 100644 --- a/TUnit.Core/TestContext.cs +++ b/TUnit.Core/TestContext.cs @@ -242,6 +242,15 @@ public string GetDisplayName() return _cachedDisplayName; } + /// + /// Clears the cached display name, forcing it to be recomputed on next access. + /// This is called after discovery event receivers run to ensure custom argument formatters are applied. + /// + internal void InvalidateDisplayNameCache() + { + _cachedDisplayName = null; + } + public Dictionary ObjectBag => _testBuilderContext.ObjectBag; public bool ReportResult { get; set; } = true; diff --git a/TUnit.Engine/Building/TestBuilder.cs b/TUnit.Engine/Building/TestBuilder.cs index 0545329b93..11ed1b0075 100644 --- a/TUnit.Engine/Building/TestBuilder.cs +++ b/TUnit.Engine/Building/TestBuilder.cs @@ -802,6 +802,11 @@ public async Task BuildTestAsync(TestMetadata metadata, await InvokeDiscoveryEventReceiversAsync(context); + // Clear the cached display name after discovery events + // This ensures that ArgumentDisplayFormatterAttribute and similar attributes + // have a chance to register their formatters before the display name is finalized + context.InvalidateDisplayNameCache(); + return test; } diff --git a/TUnit.TestProject/ArgumentDisplayFormatterTests.cs b/TUnit.TestProject/ArgumentDisplayFormatterTests.cs new file mode 100644 index 0000000000..c02c7f0d14 --- /dev/null +++ b/TUnit.TestProject/ArgumentDisplayFormatterTests.cs @@ -0,0 +1,72 @@ +using TUnit.TestProject.Attributes; + +namespace TUnit.TestProject; + +[EngineTest(ExpectedResult.Pass)] +public class ArgumentDisplayFormatterTests +{ + [Test] + [MethodDataSource(nameof(Data1))] + [ArgumentDisplayFormatter] + public async Task FormatterShouldBeAppliedToMethodDataSource(Foo foo) + { + // Verify the formatter was applied by checking the display name + var displayName = TestContext.Current!.GetDisplayName(); + await Assert.That(displayName).IsEqualTo("FormatterShouldBeAppliedToMethodDataSource(FooFormatterValue)"); + } + + [Test] + [Arguments(1, 2, 3)] + [ArgumentDisplayFormatter] + public async Task FormatterShouldBeAppliedToArguments(int a, int b, int c) + { + // Verify the formatter was applied by checking the display name + var displayName = TestContext.Current!.GetDisplayName(); + await Assert.That(displayName).IsEqualTo("FormatterShouldBeAppliedToArguments(INT:1, INT:2, INT:3)"); + } + + [Test] + [MethodDataSource(nameof(DataWithException))] + [ArgumentDisplayFormatter] + public async Task FormatterShouldPreventExceptionInToString(Bar bar) + { + // The Bar.ToString() throws, but the formatter should prevent that + var displayName = TestContext.Current!.GetDisplayName(); + await Assert.That(displayName).IsEqualTo("FormatterShouldPreventExceptionInToString(BarFormatterValue)"); + } + + public static IEnumerable Data1() => [new Foo()]; + + public static IEnumerable DataWithException() => [new Bar()]; +} + +public class Foo +{ + public override string ToString() => throw new Exception("Foo.ToString should not be called"); +} + +public class Bar +{ + public override string ToString() => throw new Exception("Bar.ToString should not be called"); +} + +public class FooFormatter : ArgumentDisplayFormatter +{ + public override bool CanHandle(object? value) => value is Foo; + + public override string FormatValue(object? value) => "FooFormatterValue"; +} + +public class BarFormatter : ArgumentDisplayFormatter +{ + public override bool CanHandle(object? value) => value is Bar; + + public override string FormatValue(object? value) => "BarFormatterValue"; +} + +public class IntFormatter : ArgumentDisplayFormatter +{ + public override bool CanHandle(object? value) => value is int; + + public override string FormatValue(object? value) => $"INT:{value}"; +} diff --git a/global.json b/global.json index 2e2218a904..08c5374eff 100644 --- a/global.json +++ b/global.json @@ -1,7 +1,8 @@ { "sdk": { - "version": "10.0.100-rc.1.25451.107", - "rollForward": "latestFeature" + "version": "9.0.305", + "rollForward": "latestMajor", + "allowPrerelease": true }, "test": { "runner": "Microsoft.Testing.Platform" From 1b20ad1f7e50c52f95b56dfa5249a2d9cc4fd5b4 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Mon, 27 Oct 2025 21:58:40 +0000 Subject: [PATCH 26/67] chore(deps): update dependency dotnet-sdk to v9.0.306 (#3544) Co-authored-by: Renovate Bot --- global.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/global.json b/global.json index 08c5374eff..095c4095fd 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "9.0.305", + "version": "9.0.306", "rollForward": "latestMajor", "allowPrerelease": true }, From 75bfe6d63c6519a8f9ea410af4323967ff676474 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Mon, 27 Oct 2025 22:34:14 +0000 Subject: [PATCH 27/67] chore(deps): update tunit to 0.77.10 (#3545) Co-authored-by: Renovate Bot --- Directory.Packages.props | 6 +++--- .../TUnit.AspNet.FSharp/TestProject/TestProject.fsproj | 4 ++-- .../content/TUnit.AspNet/TestProject/TestProject.csproj | 2 +- .../ExampleNamespace.TestProject.csproj | 2 +- .../content/TUnit.Aspire.Test/ExampleNamespace.csproj | 2 +- TUnit.Templates/content/TUnit.FSharp/TestProject.fsproj | 4 ++-- TUnit.Templates/content/TUnit.Playwright/TestProject.csproj | 2 +- TUnit.Templates/content/TUnit.VB/TestProject.vbproj | 2 +- TUnit.Templates/content/TUnit/TestProject.csproj | 2 +- 9 files changed, 13 insertions(+), 13 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index e8332d9341..086e126af0 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -83,9 +83,9 @@ - - - + + + diff --git a/TUnit.Templates/content/TUnit.AspNet.FSharp/TestProject/TestProject.fsproj b/TUnit.Templates/content/TUnit.AspNet.FSharp/TestProject/TestProject.fsproj index 2b80e58a62..88df95cefc 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 08d17501b4..43fbea50cc 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.Aspire.Starter/ExampleNamespace.TestProject/ExampleNamespace.TestProject.csproj b/TUnit.Templates/content/TUnit.Aspire.Starter/ExampleNamespace.TestProject/ExampleNamespace.TestProject.csproj index dce660bd96..3ab8921c1b 100644 --- a/TUnit.Templates/content/TUnit.Aspire.Starter/ExampleNamespace.TestProject/ExampleNamespace.TestProject.csproj +++ b/TUnit.Templates/content/TUnit.Aspire.Starter/ExampleNamespace.TestProject/ExampleNamespace.TestProject.csproj @@ -11,7 +11,7 @@ - + diff --git a/TUnit.Templates/content/TUnit.Aspire.Test/ExampleNamespace.csproj b/TUnit.Templates/content/TUnit.Aspire.Test/ExampleNamespace.csproj index 95d7a78f4c..e0e5e43f05 100644 --- a/TUnit.Templates/content/TUnit.Aspire.Test/ExampleNamespace.csproj +++ b/TUnit.Templates/content/TUnit.Aspire.Test/ExampleNamespace.csproj @@ -10,7 +10,7 @@ - + diff --git a/TUnit.Templates/content/TUnit.FSharp/TestProject.fsproj b/TUnit.Templates/content/TUnit.FSharp/TestProject.fsproj index 3ee8ed4d7c..f5b9826038 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 8495b0595a..56c7f5808c 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 a347213711..305fd42dac 100644 --- a/TUnit.Templates/content/TUnit.VB/TestProject.vbproj +++ b/TUnit.Templates/content/TUnit.VB/TestProject.vbproj @@ -8,6 +8,6 @@ - + diff --git a/TUnit.Templates/content/TUnit/TestProject.csproj b/TUnit.Templates/content/TUnit/TestProject.csproj index e7b61c658b..caec831354 100644 --- a/TUnit.Templates/content/TUnit/TestProject.csproj +++ b/TUnit.Templates/content/TUnit/TestProject.csproj @@ -8,7 +8,7 @@ - + \ No newline at end of file From 1e9da1c22c73e3b1644f1cbbc68b6f5df0133383 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Tue, 28 Oct 2025 00:54:24 +0000 Subject: [PATCH 28/67] chore(deps): update dependency node to v24 (#3548) Co-authored-by: Renovate Bot --- .github/workflows/deploy-pages-test.yml | 2 +- .github/workflows/deploy-pages.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy-pages-test.yml b/.github/workflows/deploy-pages-test.yml index 4ae2a0a1f9..261a136ea7 100644 --- a/.github/workflows/deploy-pages-test.yml +++ b/.github/workflows/deploy-pages-test.yml @@ -18,7 +18,7 @@ jobs: fetch-depth: 0 - uses: actions/setup-node@v6 with: - node-version: 22 + node-version: 24 cache: yarn cache-dependency-path: 'docs/yarn.lock' diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml index f3b44cd88a..e41288cdef 100644 --- a/.github/workflows/deploy-pages.yml +++ b/.github/workflows/deploy-pages.yml @@ -21,7 +21,7 @@ jobs: fetch-depth: 0 - uses: actions/setup-node@v6 with: - node-version: 22 + node-version: 24 cache: yarn cache-dependency-path: 'docs/yarn.lock' From 98672d3c16c45a0f65e72378d7628092530c0f60 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Tue, 28 Oct 2025 01:05:34 +0000 Subject: [PATCH 29/67] feat: add WaitsFor assertion for polling asynchronous conditions (#3546) --- .../DisposableFieldPropertyAnalyzerTests.cs | 88 ++++++ .../WaitsForAssertionTests.cs | 265 ++++++++++++++++++ .../Conditions/WaitsForAssertion.cs | 113 ++++++++ TUnit.Assertions/Core/EvaluationContext.cs | 26 ++ .../Extensions/AssertionExtensions.cs | 27 ++ ...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 + ...ary_Has_No_API_Changes.Net4_7.verified.txt | 11 + 9 files changed, 563 insertions(+) create mode 100644 TUnit.Assertions.Tests/WaitsForAssertionTests.cs create mode 100644 TUnit.Assertions/Conditions/WaitsForAssertion.cs diff --git a/TUnit.Analyzers.Tests/DisposableFieldPropertyAnalyzerTests.cs b/TUnit.Analyzers.Tests/DisposableFieldPropertyAnalyzerTests.cs index 2a9a31064a..54e85764fa 100644 --- a/TUnit.Analyzers.Tests/DisposableFieldPropertyAnalyzerTests.cs +++ b/TUnit.Analyzers.Tests/DisposableFieldPropertyAnalyzerTests.cs @@ -494,4 +494,92 @@ public void Test1() """ ); } + + + [Test] + [Arguments("Class")] + [Arguments("Assembly")] + [Arguments("TestSession")] + public async Task Bug3213(string hook) + { + await Verifier + .VerifyAnalyzerAsync( + $$""" + using System; + using System.Linq; + using System.Net; + using System.Net.Http; + using System.Threading; + using System.Threading.Tasks; + using TUnit.Core; + + record RegisterPaymentHttp(string BookingId, string RoomId, decimal Amount, DateTimeOffset PaidAt); + record BookingState; + class Result { public class Ok { public HttpStatusCode StatusCode { get; set; } } } + class BookingEvents { public record BookingFullyPaid(DateTimeOffset PaidAt); } + class Booking { public object Payload { get; set; } = null!; } + class RestRequest { + public RestRequest(string path) { } + public RestRequest AddJsonBody(object obj) => this; + } + class ServerFixture { + public HttpClient GetClient() => null!; + public static BookRoom GetBookRoom() => null!; + public Task> ReadStream(string id) => Task.FromResult(Enumerable.Empty()); + } + class BookRoom { + public string BookingId => string.Empty; + public string RoomId => string.Empty; + } + class TestEventListener : IDisposable { + public void Dispose() { } + } + static class HttpClientExtensions { + public static Task PostJsonAsync(this HttpClient client, string path, object body, CancellationToken cancellationToken) => Task.CompletedTask; + public static Task ExecutePostAsync(this HttpClient client, RestRequest request, CancellationToken cancellationToken) => Task.FromResult(default!); + } + static class ObjectExtensions { + public static void ShouldBe(this object obj, object expected) { } + public static void ShouldBeEquivalentTo(this object obj, object expected) { } + } + + [ClassDataSource] + public class ControllerTests { + readonly ServerFixture _fixture = null!; + + public ControllerTests(string value) { + } + + [Test] + public async Task RecordPaymentUsingMappedCommand(CancellationToken cancellationToken) { + using var client = _fixture.GetClient(); + + var bookRoom = ServerFixture.GetBookRoom(); + + await client.PostJsonAsync("/book", bookRoom, cancellationToken: cancellationToken); + + var registerPayment = new RegisterPaymentHttp(bookRoom.BookingId, bookRoom.RoomId, 100, DateTimeOffset.Now); + + var request = new RestRequest("/v2/pay").AddJsonBody(registerPayment); + var response = await client.ExecutePostAsync.Ok>(request, cancellationToken: cancellationToken); + response.StatusCode.ShouldBe(HttpStatusCode.OK); + + var expected = new BookingEvents.BookingFullyPaid(registerPayment.PaidAt); + + var events = await _fixture.ReadStream(bookRoom.BookingId); + var last = events.LastOrDefault(); + last!.Payload.ShouldBeEquivalentTo(expected); + } + + static TestEventListener? listener; + + [After(HookType.{{hook}})] + public static void Dispose() => listener?.Dispose(); + + [Before(HookType.{{hook}})] + public static void BeforeClass() => listener = new(); + } + """ + ); + } } diff --git a/TUnit.Assertions.Tests/WaitsForAssertionTests.cs b/TUnit.Assertions.Tests/WaitsForAssertionTests.cs new file mode 100644 index 0000000000..2e37d8c04b --- /dev/null +++ b/TUnit.Assertions.Tests/WaitsForAssertionTests.cs @@ -0,0 +1,265 @@ +using System.Diagnostics; + +namespace TUnit.Assertions.Tests; + +[NotInParallel] +public class WaitsForAssertionTests +{ + [Test] + public async Task WaitsFor_Passes_Immediately_When_Assertion_Succeeds() + { + var stopwatch = Stopwatch.StartNew(); + + var value = 42; + await Assert.That(value).WaitsFor( + assert => assert.IsEqualTo(42), + timeout: TimeSpan.FromSeconds(5)); + + stopwatch.Stop(); + + // Should complete very quickly since assertion passes immediately + await Assert.That(stopwatch.Elapsed).IsLessThan(TimeSpan.FromMilliseconds(100)); + } + + [Test] + public async Task WaitsFor_Passes_After_Multiple_Retries() + { + var counter = 0; + var stopwatch = Stopwatch.StartNew(); + + // Use a func that returns different values based on call count + Func getValue = () => Interlocked.Increment(ref counter); + + await Assert.That(getValue).WaitsFor( + assert => assert.IsGreaterThan(3), + timeout: TimeSpan.FromSeconds(5), + pollingInterval: TimeSpan.FromMilliseconds(10)); + + stopwatch.Stop(); + + // Should have retried at least 3 times + await Assert.That(counter).IsGreaterThanOrEqualTo(4); + } + + [Test] + public async Task WaitsFor_Fails_When_Timeout_Expires() + { + var stopwatch = Stopwatch.StartNew(); + var value = 1; + + var exception = await Assert.That( + async () => await Assert.That(value).WaitsFor( + assert => assert.IsEqualTo(999), + timeout: TimeSpan.FromMilliseconds(100), + pollingInterval: TimeSpan.FromMilliseconds(10)) + ).Throws(); + + stopwatch.Stop(); + + // Verify timeout was respected (should be close to 100ms, not significantly longer) + await Assert.That(stopwatch.Elapsed).IsLessThan(TimeSpan.FromMilliseconds(200)); + + // Verify error message contains useful information + await Assert.That(exception.Message).Contains("assertion did not pass within 100ms"); + await Assert.That(exception.Message).Contains("Last error:"); + } + + [Test] + public async Task WaitsFor_Supports_And_Chaining() + { + var value = 42; + + // WaitsFor should support chaining with And + await Assert.That(value) + .WaitsFor(assert => assert.IsGreaterThan(40), timeout: TimeSpan.FromSeconds(1)) + .And.IsLessThan(50); + } + + [Test] + public async Task WaitsFor_Supports_Or_Chaining() + { + var value = 42; + + // WaitsFor should support chaining with Or + await Assert.That(value) + .WaitsFor(assert => assert.IsEqualTo(42), timeout: TimeSpan.FromSeconds(1)) + .Or.IsEqualTo(43); + } + + [Test] + public async Task WaitsFor_With_Custom_Polling_Interval() + { + var counter = 0; + Func getValue = () => Interlocked.Increment(ref counter); + + var stopwatch = Stopwatch.StartNew(); + + await Assert.That(getValue).WaitsFor( + assert => assert.IsGreaterThan(2), + timeout: TimeSpan.FromSeconds(1), + pollingInterval: TimeSpan.FromMilliseconds(50)); + + stopwatch.Stop(); + + // With 50ms interval and needing >2 (3rd increment), should take at least one polling interval + // The timing may vary slightly due to execution overhead, so we check for at least 40ms + await Assert.That(stopwatch.Elapsed).IsGreaterThan(TimeSpan.FromMilliseconds(40)); + + // Verify counter was actually incremented multiple times + await Assert.That(counter).IsGreaterThanOrEqualTo(3); + } + + [Test] + public async Task WaitsFor_With_Eventually_Changing_Value() + { + var value = 0; + + // Start a task that changes the value after 100ms + _ = Task.Run(async () => + { + await Task.Delay(100); + Interlocked.Exchange(ref value, 42); + }); + + // WaitsFor should poll and eventually see the new value + await Assert.That(() => value).WaitsFor( + assert => assert.IsEqualTo(42), + timeout: TimeSpan.FromSeconds(5), + pollingInterval: TimeSpan.FromMilliseconds(10)); + } + + [Test] + public async Task WaitsFor_Works_With_Complex_Assertions() + { + var list = new List { 1, 2, 3 }; + + // Start a task that adds to the list after 50ms + _ = Task.Run(async () => + { + await Task.Delay(50); + lock (list) + { + list.Add(4); + list.Add(5); + } + }); + + // Wait for list to have 5 items + await Assert.That(() => + { + lock (list) + { + return list.Count; + } + }).WaitsFor( + assert => assert.IsEqualTo(5), + timeout: TimeSpan.FromSeconds(5), + pollingInterval: TimeSpan.FromMilliseconds(10)); + } + + [Test] + public async Task WaitsFor_Throws_ArgumentException_For_Zero_Timeout() + { + var value = 42; + +#pragma warning disable TUnitAssertions0002 // Testing constructor exception, not awaiting + var exception = Assert.Throws(() => + Assert.That(value).WaitsFor( + assert => assert.IsEqualTo(42), + timeout: TimeSpan.Zero)); +#pragma warning restore TUnitAssertions0002 + + await Assert.That(exception.Message).Contains("Timeout must be positive"); + } + + [Test] + public async Task WaitsFor_Throws_ArgumentException_For_Negative_Timeout() + { + var value = 42; + +#pragma warning disable TUnitAssertions0002 // Testing constructor exception, not awaiting + var exception = Assert.Throws(() => + Assert.That(value).WaitsFor( + assert => assert.IsEqualTo(42), + timeout: TimeSpan.FromSeconds(-1))); +#pragma warning restore TUnitAssertions0002 + + await Assert.That(exception.Message).Contains("Timeout must be positive"); + } + + [Test] + public async Task WaitsFor_Throws_ArgumentException_For_Zero_PollingInterval() + { + var value = 42; + +#pragma warning disable TUnitAssertions0002 // Testing constructor exception, not awaiting + var exception = Assert.Throws(() => + Assert.That(value).WaitsFor( + assert => assert.IsEqualTo(42), + timeout: TimeSpan.FromSeconds(1), + pollingInterval: TimeSpan.Zero)); +#pragma warning restore TUnitAssertions0002 + + await Assert.That(exception.Message).Contains("Polling interval must be positive"); + } + + [Test] + public async Task WaitsFor_Throws_ArgumentNullException_For_Null_AssertionBuilder() + { + var value = 42; + +#pragma warning disable TUnitAssertions0002 // Testing constructor exception, not awaiting + var exception = Assert.Throws(() => + Assert.That(value).WaitsFor( + assertionBuilder: null!, + timeout: TimeSpan.FromSeconds(1))); +#pragma warning restore TUnitAssertions0002 + + await Assert.That(exception.ParamName).IsEqualTo("assertionBuilder"); + } + + [Test] + public async Task WaitsFor_Real_World_Scenario_GPIO_Event() + { + // Simulate the real-world scenario from the GitHub issue: + // Testing GPIO events that take time to propagate + + var pinValue = false; + + // Simulate an async GPIO event that changes state after 75ms + _ = Task.Run(async () => + { + await Task.Delay(75); + pinValue = true; + }); + + // Wait for the pin to become true + await Assert.That(() => pinValue).WaitsFor( + assert => assert.IsEqualTo(true), + timeout: TimeSpan.FromSeconds(2), + pollingInterval: TimeSpan.FromMilliseconds(10)); + } + + [Test] + public async Task WaitsFor_Performance_Many_Quick_Polls() + { + var counter = 0; + var stopwatch = Stopwatch.StartNew(); + + // This will take many polls before succeeding + Func getValue = () => Interlocked.Increment(ref counter); + + await Assert.That(getValue).WaitsFor( + assert => assert.IsGreaterThan(100), + timeout: TimeSpan.FromSeconds(5), + pollingInterval: TimeSpan.FromMilliseconds(1)); + + stopwatch.Stop(); + + // Should have made at least 100 attempts + await Assert.That(counter).IsGreaterThanOrEqualTo(101); + + // Should complete in a reasonable time (well under 5 seconds) + await Assert.That(stopwatch.Elapsed).IsLessThan(TimeSpan.FromSeconds(2)); + } +} diff --git a/TUnit.Assertions/Conditions/WaitsForAssertion.cs b/TUnit.Assertions/Conditions/WaitsForAssertion.cs new file mode 100644 index 0000000000..3629d2a297 --- /dev/null +++ b/TUnit.Assertions/Conditions/WaitsForAssertion.cs @@ -0,0 +1,113 @@ +using System.Diagnostics; +using TUnit.Assertions.Core; +using TUnit.Assertions.Exceptions; +using TUnit.Assertions.Sources; + +namespace TUnit.Assertions.Conditions; + +/// +/// Asserts that an assertion passes within a specified timeout by polling repeatedly. +/// Useful for testing asynchronous or event-driven code where state changes take time to propagate. +/// +/// The type of value being asserted +public class WaitsForAssertion : Assertion +{ + private readonly Func, Assertion> _assertionBuilder; + private readonly TimeSpan _timeout; + private readonly TimeSpan _pollingInterval; + + public WaitsForAssertion( + AssertionContext context, + Func, Assertion> assertionBuilder, + TimeSpan timeout, + TimeSpan? pollingInterval = null) + : base(context) + { + _assertionBuilder = assertionBuilder ?? throw new ArgumentNullException(nameof(assertionBuilder)); + _timeout = timeout; + _pollingInterval = pollingInterval ?? TimeSpan.FromMilliseconds(10); + + if (_timeout <= TimeSpan.Zero) + { + throw new ArgumentException("Timeout must be positive", nameof(timeout)); + } + + if (_pollingInterval <= TimeSpan.Zero) + { + throw new ArgumentException("Polling interval must be positive", nameof(pollingInterval)); + } + } + + protected override async Task CheckAsync(EvaluationMetadata metadata) + { + var stopwatch = Stopwatch.StartNew(); + Exception? lastException = null; + var attemptCount = 0; + + using var cts = new CancellationTokenSource(_timeout); + + while (stopwatch.Elapsed < _timeout) + { + attemptCount++; + + try + { + var (currentValue, currentException) = await Context.Evaluation.ReevaluateAsync(); + var assertionSource = new ValueAssertion(currentValue, "polled value"); + var assertion = _assertionBuilder(assertionSource); + await assertion.AssertAsync(); + + return AssertionResult.Passed; + } + catch (AssertionException ex) + { + lastException = ex; + + // Check if we've exceeded timeout before waiting + if (stopwatch.Elapsed + _pollingInterval >= _timeout) + { + break; + } + + try + { + await Task.Delay(_pollingInterval, cts.Token); + } + catch (OperationCanceledException) + { + break; + } + } + } + + stopwatch.Stop(); + + var lastErrorMessage = lastException != null + ? $"Last error: {ExtractAssertionMessage(lastException)}" + : "No attempts were made"; + + return AssertionResult.Failed( + $"assertion did not pass within {_timeout.TotalMilliseconds:F0}ms after {attemptCount} attempts. {lastErrorMessage}"); + } + + protected override string GetExpectation() => + $"assertion to pass within {_timeout.TotalMilliseconds:F0} milliseconds " + + $"(polling every {_pollingInterval.TotalMilliseconds:F0}ms)"; + + /// + /// Extracts the core assertion message from an exception, + /// removing the stack trace and location info for cleaner output. + /// + private static string ExtractAssertionMessage(Exception exception) + { + var message = exception.Message; + var atIndex = message.IndexOf("\nat Assert.That", StringComparison.Ordinal); + + if (atIndex > 0) + { + message = message.Substring(0, atIndex).Trim(); + } + + return message; + } +} diff --git a/TUnit.Assertions/Core/EvaluationContext.cs b/TUnit.Assertions/Core/EvaluationContext.cs index 93beef0113..94c9432fff 100644 --- a/TUnit.Assertions/Core/EvaluationContext.cs +++ b/TUnit.Assertions/Core/EvaluationContext.cs @@ -49,6 +49,32 @@ public EvaluationContext(TValue? value) return (_value, _exception); } + /// + /// Re-evaluates the source by bypassing the cache and invoking the evaluator again. + /// Used by polling assertions like WaitsFor that need to observe changing values. + /// For immediate values (created without an evaluator), returns the cached value. + /// + /// The freshly evaluated value and any exception that occurred + public async Task<(TValue? Value, Exception? Exception)> ReevaluateAsync() + { + if (_evaluator == null) + { + return (_value, _exception); + } + + var startTime = DateTimeOffset.Now; + var (value, exception) = await _evaluator(); + var endTime = DateTimeOffset.Now; + + _value = value; + _exception = exception; + _startTime = startTime; + _endTime = endTime; + _evaluated = true; + + return (value, exception); + } + /// /// Creates a derived context by mapping the value to a different type. /// Used for type transformations like IsTypeOf<T>(). diff --git a/TUnit.Assertions/Extensions/AssertionExtensions.cs b/TUnit.Assertions/Extensions/AssertionExtensions.cs index 487243a262..fdcb2916f3 100644 --- a/TUnit.Assertions/Extensions/AssertionExtensions.cs +++ b/TUnit.Assertions/Extensions/AssertionExtensions.cs @@ -1408,6 +1408,33 @@ public static CompletesWithinAsyncAssertion CompletesWithin( return new CompletesWithinAsyncAssertion(asyncAction, timeout); } + /// + /// Asserts that an assertion passes within the specified timeout by polling repeatedly. + /// The assertion builder is invoked on each polling attempt until it passes or the timeout expires. + /// Useful for testing asynchronous or event-driven code where state changes take time to propagate. + /// Example: await Assert.That(value).WaitsFor(assert => assert.IsEqualTo(2), timeout: TimeSpan.FromSeconds(5)); + /// + /// The type of value being asserted + /// The assertion source + /// A function that builds the assertion to be evaluated on each poll + /// The maximum time to wait for the assertion to pass + /// The interval between polling attempts (defaults to 10ms if not specified) + /// Captured expression for the timeout parameter + /// Captured expression for the polling interval parameter + /// An assertion that can be awaited or chained with And/Or + public static WaitsForAssertion WaitsFor( + this IAssertionSource source, + Func, Assertion> assertionBuilder, + TimeSpan timeout, + TimeSpan? pollingInterval = null, + [CallerArgumentExpression(nameof(timeout))] string? timeoutExpression = null, + [CallerArgumentExpression(nameof(pollingInterval))] string? pollingIntervalExpression = null) + { + var intervalExpr = pollingInterval.HasValue ? $", pollingInterval: {pollingIntervalExpression}" : ""; + source.Context.ExpressionBuilder.Append($".WaitsFor(..., timeout: {timeoutExpression}{intervalExpr})"); + return new WaitsForAssertion(source.Context, assertionBuilder, timeout, pollingInterval); + } + private static Action GetActionFromDelegate(DelegateAssertion source) { return source.Action; 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 cbef291244..32d6a99f6e 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 @@ -1591,6 +1591,12 @@ namespace .Conditions [.(ExpectationMessage="to not be a major version")] public static bool IsNotMajorVersion(this value) { } } + public class WaitsForAssertion : . + { + public WaitsForAssertion(. context, <., .> assertionBuilder, timeout, ? pollingInterval = default) { } + protected override .<.> CheckAsync(. metadata) { } + protected override string GetExpectation() { } + } [.<>("IsAlive", CustomName="IsNotAlive", ExpectationMessage="be alive", NegateLogic=true)] [.<>("IsAlive", ExpectationMessage="be alive")] [.<>("TrackResurrection", CustomName="DoesNotTrackResurrection", ExpectationMessage="track resurrection", NegateLogic=true)] @@ -1714,6 +1720,10 @@ namespace .Core public . Map( mapper) { } public . MapException() where TException : { } + [return: .(new string?[]?[] { + "Value", + "Exception"})] + public .<> ReevaluateAsync() { } } public readonly struct EvaluationMetadata { @@ -1952,6 +1962,7 @@ namespace .Extensions public static . ThrowsException(this . source) where TException : { } public static . ThrowsNothing(this . source) { } + public static . WaitsFor(this . source, <., .> assertionBuilder, timeout, ? pollingInterval = default, [.("timeout")] string? timeoutExpression = null, [.("pollingInterval")] string? pollingIntervalExpression = null) { } public static ..WhenParsedIntoAssertion WhenParsedInto<[.(..None | ..PublicMethods | ..Interfaces)] T>(this . source) { } public static . WithMessage(this . source, string expectedMessage, [.("expectedMessage")] string? expression = null) where TException : { } 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 13d23a7731..d0e272c035 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 @@ -1588,6 +1588,12 @@ namespace .Conditions [.(ExpectationMessage="to not be a major version")] public static bool IsNotMajorVersion(this value) { } } + public class WaitsForAssertion : . + { + public WaitsForAssertion(. context, <., .> assertionBuilder, timeout, ? pollingInterval = default) { } + protected override .<.> CheckAsync(. metadata) { } + protected override string GetExpectation() { } + } [.<>("IsAlive", CustomName="IsNotAlive", ExpectationMessage="be alive", NegateLogic=true)] [.<>("IsAlive", ExpectationMessage="be alive")] [.<>("TrackResurrection", CustomName="DoesNotTrackResurrection", ExpectationMessage="track resurrection", NegateLogic=true)] @@ -1711,6 +1717,10 @@ namespace .Core public . Map( mapper) { } public . MapException() where TException : { } + [return: .(new string?[]?[] { + "Value", + "Exception"})] + public .<> ReevaluateAsync() { } } public readonly struct EvaluationMetadata { @@ -1942,6 +1952,7 @@ namespace .Extensions public static . ThrowsException(this . source) where TException : { } public static . ThrowsNothing(this . source) { } + public static . WaitsFor(this . source, <., .> assertionBuilder, timeout, ? pollingInterval = default, [.("timeout")] string? timeoutExpression = null, [.("pollingInterval")] string? pollingIntervalExpression = null) { } public static ..WhenParsedIntoAssertion WhenParsedInto<[.(..None | ..PublicMethods | ..Interfaces)] T>(this . source) { } public static . WithMessage(this . source, string expectedMessage, [.("expectedMessage")] string? expression = null) where TException : { } 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 9adf2916fc..dc4a6d144e 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 @@ -1591,6 +1591,12 @@ namespace .Conditions [.(ExpectationMessage="to not be a major version")] public static bool IsNotMajorVersion(this value) { } } + public class WaitsForAssertion : . + { + public WaitsForAssertion(. context, <., .> assertionBuilder, timeout, ? pollingInterval = default) { } + protected override .<.> CheckAsync(. metadata) { } + protected override string GetExpectation() { } + } [.<>("IsAlive", CustomName="IsNotAlive", ExpectationMessage="be alive", NegateLogic=true)] [.<>("IsAlive", ExpectationMessage="be alive")] [.<>("TrackResurrection", CustomName="DoesNotTrackResurrection", ExpectationMessage="track resurrection", NegateLogic=true)] @@ -1714,6 +1720,10 @@ namespace .Core public . Map( mapper) { } public . MapException() where TException : { } + [return: .(new string?[]?[] { + "Value", + "Exception"})] + public .<> ReevaluateAsync() { } } public readonly struct EvaluationMetadata { @@ -1952,6 +1962,7 @@ namespace .Extensions public static . ThrowsException(this . source) where TException : { } public static . ThrowsNothing(this . source) { } + public static . WaitsFor(this . source, <., .> assertionBuilder, timeout, ? pollingInterval = default, [.("timeout")] string? timeoutExpression = null, [.("pollingInterval")] string? pollingIntervalExpression = null) { } public static ..WhenParsedIntoAssertion WhenParsedInto<[.(..None | ..PublicMethods | ..Interfaces)] T>(this . source) { } public static . WithMessage(this . source, string expectedMessage, [.("expectedMessage")] string? expression = null) where TException : { } 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 a2f6c9c0ea..d875beeb7d 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 @@ -1492,6 +1492,12 @@ namespace .Conditions [.(ExpectationMessage="to not be a major version")] public static bool IsNotMajorVersion(this value) { } } + public class WaitsForAssertion : . + { + public WaitsForAssertion(. context, <., .> assertionBuilder, timeout, ? pollingInterval = default) { } + protected override .<.> CheckAsync(. metadata) { } + protected override string GetExpectation() { } + } [.<>("IsAlive", CustomName="IsNotAlive", ExpectationMessage="be alive", NegateLogic=true)] [.<>("IsAlive", ExpectationMessage="be alive")] [.<>("TrackResurrection", CustomName="DoesNotTrackResurrection", ExpectationMessage="track resurrection", NegateLogic=true)] @@ -1613,6 +1619,10 @@ namespace .Core public . Map( mapper) { } public . MapException() where TException : { } + [return: .(new string?[]?[] { + "Value", + "Exception"})] + public .<> ReevaluateAsync() { } } public readonly struct EvaluationMetadata { @@ -1814,6 +1824,7 @@ namespace .Extensions public static . ThrowsException(this . source) where TException : { } public static . ThrowsNothing(this . source) { } + public static . WaitsFor(this . source, <., .> assertionBuilder, timeout, ? pollingInterval = default, [.("timeout")] string? timeoutExpression = null, [.("pollingInterval")] string? pollingIntervalExpression = null) { } public static ..WhenParsedIntoAssertion WhenParsedInto(this . source) { } public static . WithMessage(this . source, string expectedMessage, [.("expectedMessage")] string? expression = null) where TException : { } From 47ee614dea1479a1d228642433557babf2bf1a90 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Tue, 28 Oct 2025 02:03:02 +0000 Subject: [PATCH 30/67] chore(deps): update dependency verify.nunit to 31.1.0 (#3550) 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 086e126af0..a2d4f48ddf 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -82,7 +82,7 @@ - + From ea52f86d25c717b4b7d713cbb24b88ad5725ac7e Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Tue, 28 Oct 2025 02:44:07 +0000 Subject: [PATCH 31/67] +semver:minor - feat: introduce TestBuildingContext for optimized test building and filtering (#3547) * feat: introduce TestBuildingContext for optimized test building and filtering * fix: handle null case for test discovery in TestDiscoveryAfterTests --- .../Building/Interfaces/ITestBuilder.cs | 3 +- TUnit.Engine/Building/TestBuilder.cs | 123 +++++++++++++++++- TUnit.Engine/Building/TestBuilderPipeline.cs | 15 ++- TUnit.Engine/Building/TestBuildingContext.cs | 19 +++ TUnit.Engine/Services/TestRegistry.cs | 4 +- TUnit.Engine/TestDiscoveryService.cs | 13 +- .../AfterTests/TestDiscoveryAfterTests.cs | 7 +- 7 files changed, 169 insertions(+), 15 deletions(-) create mode 100644 TUnit.Engine/Building/TestBuildingContext.cs diff --git a/TUnit.Engine/Building/Interfaces/ITestBuilder.cs b/TUnit.Engine/Building/Interfaces/ITestBuilder.cs index e2066eadff..c50bda428e 100644 --- a/TUnit.Engine/Building/Interfaces/ITestBuilder.cs +++ b/TUnit.Engine/Building/Interfaces/ITestBuilder.cs @@ -22,11 +22,12 @@ internal interface ITestBuilder /// This is the main method that replaces the old DataSourceExpander approach. /// /// The test metadata with DataCombinationGenerator + /// Context for optimizing test building (e.g., pre-filtering during execution) /// Collection of executable tests for all data combinations #if NET6_0_OR_GREATER [RequiresUnreferencedCode("Test building in reflection mode uses generic type resolution which requires unreferenced code")] #endif - Task> BuildTestsFromMetadataAsync(TestMetadata metadata); + Task> BuildTestsFromMetadataAsync(TestMetadata metadata, TestBuildingContext buildingContext); /// /// Streaming version that yields tests as they're built without buffering diff --git a/TUnit.Engine/Building/TestBuilder.cs b/TUnit.Engine/Building/TestBuilder.cs index 11ed1b0075..78fa82ca94 100644 --- a/TUnit.Engine/Building/TestBuilder.cs +++ b/TUnit.Engine/Building/TestBuilder.cs @@ -1,4 +1,6 @@ using System.Diagnostics.CodeAnalysis; +using Microsoft.Testing.Platform.Extensions.Messages; +using Microsoft.Testing.Platform.Requests; using TUnit.Core; using TUnit.Core.Enums; using TUnit.Core.Exceptions; @@ -114,8 +116,18 @@ private async Task CreateInstance(TestMetadata metadata, Type[] resolved #if NET6_0_OR_GREATER [RequiresUnreferencedCode("Test building in reflection mode uses generic type resolution which requires unreferenced code")] #endif - public async Task> BuildTestsFromMetadataAsync(TestMetadata metadata) + public async Task> BuildTestsFromMetadataAsync(TestMetadata metadata, TestBuildingContext buildingContext) { + // OPTIMIZATION: Pre-filter in execution mode to skip building tests that cannot match the filter + if (buildingContext.IsForExecution && buildingContext.Filter != null) + { + if (!CouldTestMatchFilter(buildingContext.Filter, metadata)) + { + // This test class cannot match the filter - skip all expensive work! + return Array.Empty(); + } + } + var tests = new List(); try @@ -126,7 +138,7 @@ public async Task> BuildTestsFromMetadataAsy // Build tests from each concrete instantiation foreach (var concreteMetadata in genericMetadata.ConcreteInstantiations.Values) { - var concreteTests = await BuildTestsFromMetadataAsync(concreteMetadata); + var concreteTests = await BuildTestsFromMetadataAsync(concreteMetadata, buildingContext); tests.AddRange(concreteTests); } return tests; @@ -1563,4 +1575,111 @@ public async IAsyncEnumerable BuildTestsStreamingAsync( return await CreateFailedTestForDataGenerationError(metadata, ex); } } + + /// + /// Determines if a test could potentially match the filter without building the full test object. + /// This is a conservative check - returns true unless we can definitively rule out the test. + /// + private bool CouldTestMatchFilter(ITestExecutionFilter filter, TestMetadata metadata) + { +#pragma warning disable TPEXP + return filter switch + { + null => true, + NopFilter => true, + TreeNodeFilter treeFilter => CouldMatchTreeNodeFilter(treeFilter, metadata), + TestNodeUidListFilter uidFilter => CouldMatchUidFilter(uidFilter, metadata), + _ => true // Unknown filter type - be conservative + }; +#pragma warning restore TPEXP + } + + /// + /// Checks if a test could match a TestNodeUidListFilter by checking if any UID contains + /// the namespace, class name, and method name. + /// + private static bool CouldMatchUidFilter(TestNodeUidListFilter filter, TestMetadata metadata) + { + var classMetadata = metadata.MethodMetadata.Class; + var namespaceName = classMetadata.Namespace ?? ""; + var className = metadata.TestClassType.Name; + var methodName = metadata.TestMethodName; + + // Check if any UID in the filter contains all three components + foreach (var uid in filter.TestNodeUids) + { + var uidValue = uid.Value; + if (uidValue.Contains(namespaceName) && + uidValue.Contains(className) && + uidValue.Contains(methodName)) + { + return true; + } + } + + return false; + } + + /// + /// Checks if a test could match a TreeNodeFilter by building the test path and checking the filter. + /// +#pragma warning disable TPEXP + private bool CouldMatchTreeNodeFilter(TreeNodeFilter filter, TestMetadata metadata) + { + var filterString = filter.Filter; + + // No filter means match all + if (string.IsNullOrEmpty(filterString)) + { + return true; + } + + // If the filter contains property conditions, strip them for path-only matching + // Property conditions will be evaluated in the second pass after tests are fully built + TreeNodeFilter pathOnlyFilter; + if (filterString.Contains('[')) + { + // Strip all property conditions: [key=value] + // Use regex to remove all [...] blocks + var strippedFilterString = System.Text.RegularExpressions.Regex.Replace(filterString, @"\[([^\]]*)\]", ""); + + // Create a new TreeNodeFilter with the stripped filter string using reflection + pathOnlyFilter = CreateTreeNodeFilterViaReflection(strippedFilterString); + } + else + { + pathOnlyFilter = filter; + } + + var path = BuildPathFromMetadata(metadata); + var emptyPropertyBag = new PropertyBag(); + return pathOnlyFilter.MatchesFilter(path, emptyPropertyBag); + } + + /// + /// Creates a TreeNodeFilter instance via reflection since it doesn't have a public constructor. + /// + private static TreeNodeFilter CreateTreeNodeFilterViaReflection(string filterString) + { + var constructor = typeof(TreeNodeFilter).GetConstructors( + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)[0]; + + return (TreeNodeFilter)constructor.Invoke(new object[] { filterString }); + } +#pragma warning restore TPEXP + + /// + /// Builds the test path from metadata, matching the format used by TestFilterService. + /// Path format: /AssemblyName/Namespace/ClassName/MethodName + /// + private static string BuildPathFromMetadata(TestMetadata metadata) + { + var classMetadata = metadata.MethodMetadata.Class; + var assemblyName = classMetadata.Assembly.Name ?? metadata.TestClassType.Assembly.GetName().Name ?? "*"; + var namespaceName = classMetadata.Namespace ?? "*"; + var className = classMetadata.Name; + var methodName = metadata.TestMethodName; + + return $"/{assemblyName}/{namespaceName}/{className}/{methodName}"; + } } diff --git a/TUnit.Engine/Building/TestBuilderPipeline.cs b/TUnit.Engine/Building/TestBuilderPipeline.cs index 1ccb75146d..ddeb311691 100644 --- a/TUnit.Engine/Building/TestBuilderPipeline.cs +++ b/TUnit.Engine/Building/TestBuilderPipeline.cs @@ -61,7 +61,9 @@ public async Task> BuildTestsAsync(string te { var collectedMetadata = await _dataCollector.CollectTestsAsync(testSessionId).ConfigureAwait(false); - return await BuildTestsFromMetadataAsync(collectedMetadata).ConfigureAwait(false); + // For this method (non-streaming), we're not in execution mode so no filter optimization + var buildingContext = new TestBuildingContext(IsForExecution: false, Filter: null); + return await BuildTestsFromMetadataAsync(collectedMetadata, buildingContext).ConfigureAwait(false); } /// @@ -71,6 +73,7 @@ public async Task> BuildTestsAsync(string te [UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Reflection mode is not used in AOT scenarios")] public async Task> BuildTestsStreamingAsync( string testSessionId, + TestBuildingContext buildingContext, CancellationToken cancellationToken = default) { // Get metadata streaming if supported @@ -78,7 +81,7 @@ public async Task> BuildTestsStreamingAsync( var collectedMetadata = await _dataCollector.CollectTestsAsync(testSessionId).ConfigureAwait(false); return await collectedMetadata - .SelectManyAsync(BuildTestsFromSingleMetadataAsync, cancellationToken: cancellationToken) + .SelectManyAsync(metadata => BuildTestsFromSingleMetadataAsync(metadata, buildingContext), cancellationToken: cancellationToken) .ProcessInParallel(cancellationToken: cancellationToken); } @@ -93,7 +96,7 @@ private async IAsyncEnumerable ToAsyncEnumerable(IEnumerable> BuildTestsFromMetadataAsync(IEnumerable testMetadata) + public async Task> BuildTestsFromMetadataAsync(IEnumerable testMetadata, TestBuildingContext buildingContext) { var testGroups = await testMetadata.SelectAsync(async metadata => { @@ -105,7 +108,7 @@ public async Task> BuildTestsFromMetadataAsy return await GenerateDynamicTests(metadata).ConfigureAwait(false); } - return await _testBuilder.BuildTestsFromMetadataAsync(metadata).ConfigureAwait(false); + return await _testBuilder.BuildTestsFromMetadataAsync(metadata, buildingContext).ConfigureAwait(false); } catch (Exception ex) { @@ -210,7 +213,7 @@ private async Task GenerateDynamicTests(TestMetadata m #if NET6_0_OR_GREATER [RequiresUnreferencedCode("Test building in reflection mode uses generic type resolution which requires unreferenced code")] #endif - private async IAsyncEnumerable BuildTestsFromSingleMetadataAsync(TestMetadata metadata) + private async IAsyncEnumerable BuildTestsFromSingleMetadataAsync(TestMetadata metadata, TestBuildingContext buildingContext) { TestMetadata resolvedMetadata; Exception? resolutionError = null; @@ -324,7 +327,7 @@ private async IAsyncEnumerable BuildTestsFromSingleMetad else { // Normal test metadata goes through the standard test builder - var testsFromMetadata = await _testBuilder.BuildTestsFromMetadataAsync(resolvedMetadata).ConfigureAwait(false); + var testsFromMetadata = await _testBuilder.BuildTestsFromMetadataAsync(resolvedMetadata, buildingContext).ConfigureAwait(false); testsToYield = new List(testsFromMetadata); } } diff --git a/TUnit.Engine/Building/TestBuildingContext.cs b/TUnit.Engine/Building/TestBuildingContext.cs new file mode 100644 index 0000000000..b48669f9af --- /dev/null +++ b/TUnit.Engine/Building/TestBuildingContext.cs @@ -0,0 +1,19 @@ +using Microsoft.Testing.Platform.Requests; + +namespace TUnit.Engine.Building; + +/// +/// Context information for building tests, used to optimize test discovery and execution. +/// +internal record TestBuildingContext( + /// + /// Indicates whether tests are being built for execution (true) or discovery/display (false). + /// When true, optimizations like early filtering can be applied. + /// + bool IsForExecution, + + /// + /// The filter to apply during test building. Only relevant when IsForExecution is true. + /// + ITestExecutionFilter? Filter +); diff --git a/TUnit.Engine/Services/TestRegistry.cs b/TUnit.Engine/Services/TestRegistry.cs index 131c9feb58..737998f8f5 100644 --- a/TUnit.Engine/Services/TestRegistry.cs +++ b/TUnit.Engine/Services/TestRegistry.cs @@ -90,7 +90,9 @@ private async Task ProcessPendingDynamicTests() testMetadataList.Add(metadata); } - var builtTests = await _testBuilderPipeline!.BuildTestsFromMetadataAsync(testMetadataList); + // These are dynamic tests registered after discovery, so not in execution mode with a filter + var buildingContext = new Building.TestBuildingContext(IsForExecution: false, Filter: null); + var builtTests = await _testBuilderPipeline!.BuildTestsFromMetadataAsync(testMetadataList, buildingContext); foreach (var test in builtTests) { diff --git a/TUnit.Engine/TestDiscoveryService.cs b/TUnit.Engine/TestDiscoveryService.cs index 05de9b3332..9d40643ac2 100644 --- a/TUnit.Engine/TestDiscoveryService.cs +++ b/TUnit.Engine/TestDiscoveryService.cs @@ -55,12 +55,15 @@ public async Task DiscoverTests(string testSessionId, ITest contextProvider.BeforeTestDiscoveryContext.RestoreExecutionContext(); + // Create building context for optimization + var buildingContext = new Building.TestBuildingContext(isForExecution, filter); + // Stage 1: Stream independent tests immediately while buffering dependent tests var independentTests = new List(); var dependentTests = new List(); var allTests = new List(); - await foreach (var test in DiscoverTestsStreamAsync(testSessionId, cancellationToken).ConfigureAwait(false)) + await foreach (var test in DiscoverTestsStreamAsync(testSessionId, buildingContext, cancellationToken).ConfigureAwait(false)) { allTests.Add(test); @@ -131,6 +134,7 @@ public async Task DiscoverTests(string testSessionId, ITest /// Streams test discovery for parallel discovery and execution private async IAsyncEnumerable DiscoverTestsStreamAsync( string testSessionId, + Building.TestBuildingContext buildingContext, [EnumeratorCancellation] CancellationToken cancellationToken = default) { using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); @@ -138,7 +142,7 @@ private async IAsyncEnumerable DiscoverTestsStreamAsync( // Set a reasonable timeout for test discovery (5 minutes) cts.CancelAfter(TimeSpan.FromMinutes(5)); - var tests = await _testBuilderPipeline.BuildTestsStreamingAsync(testSessionId, cancellationToken).ConfigureAwait(false); + var tests = await _testBuilderPipeline.BuildTestsStreamingAsync(testSessionId, buildingContext, cancellationToken).ConfigureAwait(false); foreach (var test in tests) { @@ -164,9 +168,12 @@ public async IAsyncEnumerable DiscoverTestsFullyStreamin { await _testExecutor.ExecuteBeforeTestDiscoveryHooksAsync(cancellationToken).ConfigureAwait(false); + // Create building context - this is for discovery/streaming, not execution filtering + var buildingContext = new Building.TestBuildingContext(IsForExecution: false, Filter: null); + // Collect all tests first (like source generation mode does) var allTests = new List(); - await foreach (var test in DiscoverTestsStreamAsync(testSessionId, cancellationToken).ConfigureAwait(false)) + await foreach (var test in DiscoverTestsStreamAsync(testSessionId, buildingContext, cancellationToken).ConfigureAwait(false)) { allTests.Add(test); } diff --git a/TUnit.TestProject/AfterTests/TestDiscoveryAfterTests.cs b/TUnit.TestProject/AfterTests/TestDiscoveryAfterTests.cs index b1f80522e1..5d02d5dac8 100644 --- a/TUnit.TestProject/AfterTests/TestDiscoveryAfterTests.cs +++ b/TUnit.TestProject/AfterTests/TestDiscoveryAfterTests.cs @@ -13,10 +13,13 @@ public static async Task AfterEveryTestDiscovery(TestDiscoveryContext context) { await FilePolyfill.WriteAllTextAsync($"TestDiscoveryAfterTests{Guid.NewGuid():N}.txt", $"{context.AllTests.Count()} tests found"); - var test = context.AllTests.First(x => + var test = context.AllTests.FirstOrDefault(x => x.TestDetails.TestName == nameof(TestDiscoveryAfterTests.EnsureAfterEveryTestDiscoveryHit)); - test.ObjectBag.Add("AfterEveryTestDiscoveryHit", true); + if (test is not null) + { + test.ObjectBag.Add("AfterEveryTestDiscoveryHit", true); + } } } From 02392207c89a0ba8f43c5e7129aceec8b916d0ac Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Tue, 28 Oct 2025 02:58:33 +0000 Subject: [PATCH 32/67] chore(deps): update dependency verify to 31.1.0 (#3549) 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 a2d4f48ddf..8c97635b52 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -81,7 +81,7 @@ - + From e7f064e13af69aaa919a098cac313bdd9aeb7c59 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Tue, 28 Oct 2025 03:16:19 +0000 Subject: [PATCH 33/67] chore(deps): update tunit (#3551) Co-authored-by: Renovate Bot --- Directory.Packages.props | 4 ++-- .../TUnit.AspNet.FSharp/TestProject/TestProject.fsproj | 2 +- .../content/TUnit.AspNet/TestProject/TestProject.csproj | 2 +- .../ExampleNamespace.TestProject.csproj | 2 +- .../content/TUnit.Aspire.Test/ExampleNamespace.csproj | 2 +- TUnit.Templates/content/TUnit.FSharp/TestProject.fsproj | 2 +- TUnit.Templates/content/TUnit.VB/TestProject.vbproj | 2 +- TUnit.Templates/content/TUnit/TestProject.csproj | 2 +- 8 files changed, 9 insertions(+), 9 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 8c97635b52..680f08087c 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -83,10 +83,10 @@ - + - + diff --git a/TUnit.Templates/content/TUnit.AspNet.FSharp/TestProject/TestProject.fsproj b/TUnit.Templates/content/TUnit.AspNet.FSharp/TestProject/TestProject.fsproj index 88df95cefc..c96b28ea38 100644 --- a/TUnit.Templates/content/TUnit.AspNet.FSharp/TestProject/TestProject.fsproj +++ b/TUnit.Templates/content/TUnit.AspNet.FSharp/TestProject/TestProject.fsproj @@ -10,7 +10,7 @@ - + diff --git a/TUnit.Templates/content/TUnit.AspNet/TestProject/TestProject.csproj b/TUnit.Templates/content/TUnit.AspNet/TestProject/TestProject.csproj index 43fbea50cc..4559917c1e 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.Aspire.Starter/ExampleNamespace.TestProject/ExampleNamespace.TestProject.csproj b/TUnit.Templates/content/TUnit.Aspire.Starter/ExampleNamespace.TestProject/ExampleNamespace.TestProject.csproj index 3ab8921c1b..f805d84519 100644 --- a/TUnit.Templates/content/TUnit.Aspire.Starter/ExampleNamespace.TestProject/ExampleNamespace.TestProject.csproj +++ b/TUnit.Templates/content/TUnit.Aspire.Starter/ExampleNamespace.TestProject/ExampleNamespace.TestProject.csproj @@ -11,7 +11,7 @@ - + diff --git a/TUnit.Templates/content/TUnit.Aspire.Test/ExampleNamespace.csproj b/TUnit.Templates/content/TUnit.Aspire.Test/ExampleNamespace.csproj index e0e5e43f05..d591cef40f 100644 --- a/TUnit.Templates/content/TUnit.Aspire.Test/ExampleNamespace.csproj +++ b/TUnit.Templates/content/TUnit.Aspire.Test/ExampleNamespace.csproj @@ -10,7 +10,7 @@ - + diff --git a/TUnit.Templates/content/TUnit.FSharp/TestProject.fsproj b/TUnit.Templates/content/TUnit.FSharp/TestProject.fsproj index f5b9826038..40a132475e 100644 --- a/TUnit.Templates/content/TUnit.FSharp/TestProject.fsproj +++ b/TUnit.Templates/content/TUnit.FSharp/TestProject.fsproj @@ -10,7 +10,7 @@ - + diff --git a/TUnit.Templates/content/TUnit.VB/TestProject.vbproj b/TUnit.Templates/content/TUnit.VB/TestProject.vbproj index 305fd42dac..295f8824c9 100644 --- a/TUnit.Templates/content/TUnit.VB/TestProject.vbproj +++ b/TUnit.Templates/content/TUnit.VB/TestProject.vbproj @@ -8,6 +8,6 @@ - + diff --git a/TUnit.Templates/content/TUnit/TestProject.csproj b/TUnit.Templates/content/TUnit/TestProject.csproj index caec831354..8c46e7b4b9 100644 --- a/TUnit.Templates/content/TUnit/TestProject.csproj +++ b/TUnit.Templates/content/TUnit/TestProject.csproj @@ -8,7 +8,7 @@ - + \ No newline at end of file From a5bd4cb0c9bd75716f33f7d1a8864253614c3ed9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 28 Oct 2025 03:22:06 +0000 Subject: [PATCH 34/67] Update README.md (#3552) Co-authored-by: thomhurst <30480171_thomhurst@users.noreply.github.com> --- README.md | 86 +++++++++++++++++++++++++++---------------------------- 1 file changed, 43 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index f15d27f180..41afeb4395 100644 --- a/README.md +++ b/README.md @@ -378,7 +378,7 @@ dotnet add package TUnit --prerelease ``` BenchmarkDotNet v0.15.4, Linux Ubuntu 24.04.3 LTS (Noble Numbat) -AMD EPYC 7763 2.45GHz, 1 CPU, 4 logical and 2 physical cores +AMD EPYC 7763 2.73GHz, 1 CPU, 4 logical and 2 physical cores .NET SDK 10.0.100-rc.2.25502.107 [Host] : .NET 10.0.0 (10.0.0-rc.2.25502.107, 10.0.25.50307), X64 RyuJIT x86-64-v3 RyuJitX64 : .NET 10.0.0 (10.0.0-rc.2.25502.107, 10.0.25.50307), X64 RyuJIT x86-64-v3 @@ -390,10 +390,10 @@ Server=True ``` | Method | Version | Mean | Error | StdDev | Median | |------------- |-------- |--------:|---------:|---------:|--------:| -| Build_TUnit | 0.77.3 | 1.781 s | 0.0327 s | 0.0306 s | 1.775 s | -| Build_NUnit | 4.4.0 | 1.567 s | 0.0224 s | 0.0199 s | 1.572 s | -| Build_MSTest | 4.0.1 | 1.653 s | 0.0255 s | 0.0226 s | 1.657 s | -| Build_xUnit3 | 3.1.0 | 1.569 s | 0.0135 s | 0.0126 s | 1.566 s | +| Build_TUnit | 0.78.0 | 1.777 s | 0.0332 s | 0.0341 s | 1.765 s | +| Build_NUnit | 4.4.0 | 1.582 s | 0.0152 s | 0.0143 s | 1.577 s | +| Build_MSTest | 4.0.1 | 1.667 s | 0.0137 s | 0.0128 s | 1.668 s | +| Build_xUnit3 | 3.1.0 | 1.570 s | 0.0101 s | 0.0090 s | 1.569 s | ### Scenario: Tests running asynchronous operations and async/await patterns @@ -401,7 +401,7 @@ Server=True ``` BenchmarkDotNet v0.15.4, Linux Ubuntu 24.04.3 LTS (Noble Numbat) -AMD EPYC 7763 2.60GHz, 1 CPU, 4 logical and 2 physical cores +AMD EPYC 7763 2.45GHz, 1 CPU, 4 logical and 2 physical cores .NET SDK 10.0.100-rc.2.25502.107 [Host] : .NET 10.0.0 (10.0.0-rc.2.25502.107, 10.0.25.50307), X64 RyuJIT x86-64-v3 RyuJitX64 : .NET 10.0.0 (10.0.0-rc.2.25502.107, 10.0.25.50307), X64 RyuJIT x86-64-v3 @@ -411,13 +411,13 @@ PowerPlanMode=00000000-0000-0000-0000-000000000000 Runtime=.NET 10.0 Concurren Server=True ``` -| Method | Version | Mean | Error | StdDev | Median | -|---------- |-------- |----------:|---------:|----------:|----------:| -| TUnit | 0.77.3 | 478.94 ms | 5.325 ms | 4.981 ms | 479.13 ms | -| NUnit | 4.4.0 | 526.96 ms | 9.078 ms | 8.048 ms | 527.32 ms | -| MSTest | 4.0.1 | 503.03 ms | 9.642 ms | 12.872 ms | 499.80 ms | -| xUnit3 | 3.1.0 | 501.98 ms | 6.870 ms | 6.426 ms | 500.00 ms | -| TUnit_AOT | 0.77.3 | 34.13 ms | 0.487 ms | 0.406 ms | 33.97 ms | +| Method | Version | Mean | Error | StdDev | Median | +|---------- |-------- |----------:|----------:|----------:|----------:| +| TUnit | 0.78.0 | 452.17 ms | 3.388 ms | 2.829 ms | 452.69 ms | +| NUnit | 4.4.0 | 565.57 ms | 9.621 ms | 8.528 ms | 567.10 ms | +| MSTest | 4.0.1 | 578.39 ms | 11.254 ms | 12.960 ms | 574.71 ms | +| xUnit3 | 3.1.0 | 522.50 ms | 4.072 ms | 3.610 ms | 522.72 ms | +| TUnit_AOT | 0.78.0 | 24.35 ms | 0.406 ms | 0.380 ms | 24.34 ms | ### Scenario: Parameterized tests with multiple test cases using data attributes @@ -425,7 +425,7 @@ Server=True ``` BenchmarkDotNet v0.15.4, Linux Ubuntu 24.04.3 LTS (Noble Numbat) -AMD EPYC 7763 2.75GHz, 1 CPU, 4 logical and 2 physical cores +AMD EPYC 7763 3.23GHz, 1 CPU, 4 logical and 2 physical cores .NET SDK 10.0.100-rc.2.25502.107 [Host] : .NET 10.0.0 (10.0.0-rc.2.25502.107, 10.0.25.50307), X64 RyuJIT x86-64-v3 RyuJitX64 : .NET 10.0.0 (10.0.0-rc.2.25502.107, 10.0.25.50307), X64 RyuJIT x86-64-v3 @@ -435,13 +435,13 @@ PowerPlanMode=00000000-0000-0000-0000-000000000000 Runtime=.NET 10.0 Concurren Server=True ``` -| Method | Version | Mean | Error | StdDev | Median | -|---------- |-------- |----------:|----------:|---------:|----------:| -| TUnit | 0.77.3 | 485.31 ms | 3.767 ms | 3.524 ms | 484.81 ms | -| NUnit | 4.4.0 | 687.51 ms | 11.332 ms | 8.847 ms | 683.38 ms | -| MSTest | 4.0.1 | 689.18 ms | 8.267 ms | 7.329 ms | 688.63 ms | -| xUnit3 | 3.1.0 | 502.63 ms | 2.875 ms | 2.690 ms | 502.82 ms | -| TUnit_AOT | 0.77.3 | 34.64 ms | 0.688 ms | 1.995 ms | 34.20 ms | +| Method | Version | Mean | Error | StdDev | Median | +|---------- |-------- |----------:|---------:|----------:|----------:| +| TUnit | 0.78.0 | 497.97 ms | 9.414 ms | 10.073 ms | 496.53 ms | +| NUnit | 4.4.0 | 616.29 ms | 8.142 ms | 7.218 ms | 613.56 ms | +| MSTest | 4.0.1 | 631.89 ms | 8.377 ms | 6.995 ms | 630.92 ms | +| xUnit3 | 3.1.0 | 549.54 ms | 5.447 ms | 4.829 ms | 548.19 ms | +| TUnit_AOT | 0.78.0 | 24.02 ms | 0.201 ms | 0.157 ms | 24.04 ms | ### Scenario: Tests executing massively parallel workloads with CPU-bound, I/O-bound, and mixed operations @@ -449,10 +449,10 @@ Server=True ``` BenchmarkDotNet v0.15.4, Linux Ubuntu 24.04.3 LTS (Noble Numbat) -AMD EPYC 7763 2.45GHz, 1 CPU, 4 logical and 2 physical cores +Intel Xeon Platinum 8370C CPU 2.80GHz (Max: 2.62GHz), 1 CPU, 4 logical and 2 physical cores .NET SDK 10.0.100-rc.2.25502.107 - [Host] : .NET 10.0.0 (10.0.0-rc.2.25502.107, 10.0.25.50307), X64 RyuJIT x86-64-v3 - RyuJitX64 : .NET 10.0.0 (10.0.0-rc.2.25502.107, 10.0.25.50307), X64 RyuJIT x86-64-v3 + [Host] : .NET 10.0.0 (10.0.0-rc.2.25502.107, 10.0.25.50307), X64 RyuJIT x86-64-v4 + RyuJitX64 : .NET 10.0.0 (10.0.0-rc.2.25502.107, 10.0.25.50307), X64 RyuJIT x86-64-v4 Job=RyuJitX64 Jit=RyuJit Platform=X64 PowerPlanMode=00000000-0000-0000-0000-000000000000 Runtime=.NET 10.0 Concurrent=True @@ -461,11 +461,11 @@ Server=True ``` | Method | Version | Mean | Error | StdDev | Median | |---------- |-------- |----------:|----------:|---------:|----------:| -| TUnit | 0.77.3 | 499.03 ms | 6.589 ms | 5.841 ms | 498.34 ms | -| NUnit | 4.4.0 | 582.75 ms | 10.155 ms | 9.499 ms | 583.13 ms | -| MSTest | 4.0.1 | 560.42 ms | 8.550 ms | 7.997 ms | 560.16 ms | -| xUnit3 | 3.1.0 | 567.61 ms | 4.273 ms | 3.788 ms | 566.90 ms | -| TUnit_AOT | 0.77.3 | 39.00 ms | 0.255 ms | 0.238 ms | 39.04 ms | +| TUnit | 0.78.0 | 439.78 ms | 3.590 ms | 2.998 ms | 440.24 ms | +| NUnit | 4.4.0 | 579.16 ms | 10.529 ms | 9.334 ms | 576.13 ms | +| MSTest | 4.0.1 | 592.15 ms | 11.068 ms | 9.242 ms | 592.37 ms | +| xUnit3 | 3.1.0 | 541.26 ms | 3.818 ms | 3.385 ms | 541.16 ms | +| TUnit_AOT | 0.78.0 | 29.11 ms | 0.289 ms | 0.256 ms | 29.09 ms | ### Scenario: Tests with complex parameter combinations creating 25-125 test variations @@ -473,7 +473,7 @@ Server=True ``` BenchmarkDotNet v0.15.4, Linux Ubuntu 24.04.3 LTS (Noble Numbat) -AMD EPYC 7763 2.45GHz, 1 CPU, 4 logical and 2 physical cores +AMD EPYC 7763 2.77GHz, 1 CPU, 4 logical and 2 physical cores .NET SDK 10.0.100-rc.2.25502.107 [Host] : .NET 10.0.0 (10.0.0-rc.2.25502.107, 10.0.25.50307), X64 RyuJIT x86-64-v3 RyuJitX64 : .NET 10.0.0 (10.0.0-rc.2.25502.107, 10.0.25.50307), X64 RyuJIT x86-64-v3 @@ -483,13 +483,13 @@ PowerPlanMode=00000000-0000-0000-0000-000000000000 Runtime=.NET 10.0 Concurren Server=True ``` -| Method | Version | Mean | Error | StdDev | Median | -|---------- |-------- |----------:|----------:|----------:|----------:| -| TUnit | 0.77.3 | 509.47 ms | 6.776 ms | 6.007 ms | 508.85 ms | -| NUnit | 4.4.0 | 596.89 ms | 11.807 ms | 16.934 ms | 594.68 ms | -| MSTest | 4.0.1 | 610.32 ms | 11.764 ms | 11.004 ms | 613.12 ms | -| xUnit3 | 3.1.0 | 533.11 ms | 3.656 ms | 3.053 ms | 533.28 ms | -| TUnit_AOT | 0.77.3 | 37.61 ms | 0.499 ms | 0.467 ms | 37.68 ms | +| Method | Version | Mean | Error | StdDev | Median | +|---------- |-------- |----------:|---------:|---------:|----------:| +| TUnit | 0.78.0 | 477.67 ms | 5.540 ms | 5.182 ms | 477.54 ms | +| NUnit | 4.4.0 | 603.24 ms | 9.112 ms | 7.609 ms | 603.63 ms | +| MSTest | 4.0.1 | 618.86 ms | 9.034 ms | 7.544 ms | 618.20 ms | +| xUnit3 | 3.1.0 | 541.77 ms | 4.720 ms | 4.184 ms | 542.61 ms | +| TUnit_AOT | 0.78.0 | 28.99 ms | 0.374 ms | 0.331 ms | 28.99 ms | ### Scenario: Large-scale parameterized tests with 100+ test cases testing framework scalability @@ -497,7 +497,7 @@ Server=True ``` BenchmarkDotNet v0.15.4, Linux Ubuntu 24.04.3 LTS (Noble Numbat) -AMD EPYC 7763 2.74GHz, 1 CPU, 4 logical and 2 physical cores +AMD EPYC 7763 2.45GHz, 1 CPU, 4 logical and 2 physical cores .NET SDK 10.0.100-rc.2.25502.107 [Host] : .NET 10.0.0 (10.0.0-rc.2.25502.107, 10.0.25.50307), X64 RyuJIT x86-64-v3 RyuJitX64 : .NET 10.0.0 (10.0.0-rc.2.25502.107, 10.0.25.50307), X64 RyuJIT x86-64-v3 @@ -509,11 +509,11 @@ Server=True ``` | Method | Version | Mean | Error | StdDev | Median | |---------- |-------- |----------:|----------:|----------:|----------:| -| TUnit | 0.77.3 | 507.33 ms | 5.869 ms | 5.490 ms | 509.06 ms | -| NUnit | 4.4.0 | 613.34 ms | 4.137 ms | 3.455 ms | 612.68 ms | -| MSTest | 4.0.1 | 632.89 ms | 11.515 ms | 10.208 ms | 635.09 ms | -| xUnit3 | 3.1.0 | 510.91 ms | 3.648 ms | 3.413 ms | 510.42 ms | -| TUnit_AOT | 0.77.3 | 56.39 ms | 1.220 ms | 3.597 ms | 56.85 ms | +| TUnit | 0.78.0 | 478.45 ms | 5.465 ms | 4.844 ms | 478.34 ms | +| NUnit | 4.4.0 | 585.18 ms | 10.662 ms | 10.471 ms | 583.94 ms | +| MSTest | 4.0.1 | 569.01 ms | 11.041 ms | 15.114 ms | 568.46 ms | +| xUnit3 | 3.1.0 | 508.09 ms | 5.421 ms | 4.806 ms | 508.19 ms | +| TUnit_AOT | 0.78.0 | 46.62 ms | 1.520 ms | 4.481 ms | 47.06 ms | From 7d2e24d21099cb6414f2f7d02bb1b3f217927e84 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Tue, 28 Oct 2025 03:50:05 +0000 Subject: [PATCH 35/67] chore(deps): update tunit to 0.78.0 (#3553) Co-authored-by: Renovate Bot --- Directory.Packages.props | 4 ++-- .../TUnit.AspNet.FSharp/TestProject/TestProject.fsproj | 2 +- TUnit.Templates/content/TUnit.FSharp/TestProject.fsproj | 2 +- TUnit.Templates/content/TUnit.Playwright/TestProject.csproj | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 680f08087c..2a1c161ad7 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -84,8 +84,8 @@ - - + + diff --git a/TUnit.Templates/content/TUnit.AspNet.FSharp/TestProject/TestProject.fsproj b/TUnit.Templates/content/TUnit.AspNet.FSharp/TestProject/TestProject.fsproj index c96b28ea38..fde1468878 100644 --- a/TUnit.Templates/content/TUnit.AspNet.FSharp/TestProject/TestProject.fsproj +++ b/TUnit.Templates/content/TUnit.AspNet.FSharp/TestProject/TestProject.fsproj @@ -11,7 +11,7 @@ - + diff --git a/TUnit.Templates/content/TUnit.FSharp/TestProject.fsproj b/TUnit.Templates/content/TUnit.FSharp/TestProject.fsproj index 40a132475e..0b5a70177f 100644 --- a/TUnit.Templates/content/TUnit.FSharp/TestProject.fsproj +++ b/TUnit.Templates/content/TUnit.FSharp/TestProject.fsproj @@ -11,7 +11,7 @@ - + diff --git a/TUnit.Templates/content/TUnit.Playwright/TestProject.csproj b/TUnit.Templates/content/TUnit.Playwright/TestProject.csproj index 56c7f5808c..7c5da2ee96 100644 --- a/TUnit.Templates/content/TUnit.Playwright/TestProject.csproj +++ b/TUnit.Templates/content/TUnit.Playwright/TestProject.csproj @@ -8,7 +8,7 @@ - + From 3bac2420626c25095b38584f6139abf454adb532 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Tue, 28 Oct 2025 14:24:07 +0000 Subject: [PATCH 36/67] Register dynamic test variants at runtime for more flexible testing paradigms (e.g. Property-based Testing) (#3556) * feat: implement dynamic test variant creation and management with relationships * feat: add display name and relationship parameters to test variant creation * feat: add documentation for test variants and their usage --- TUnit.Core/AbstractDynamicTest.cs | 14 +- TUnit.Core/Enums/TestRelationship.cs | 31 ++ .../Extensions/TestContextExtensions.cs | 24 + TUnit.Core/Interfaces/ITestRegistry.cs | 21 + TUnit.Core/TestContext.cs | 12 + .../Framework/TUnitServiceProvider.cs | 7 +- TUnit.Engine/Interfaces/IDynamicTestQueue.cs | 40 ++ TUnit.Engine/Scheduling/TestScheduler.cs | 62 ++- TUnit.Engine/Services/DynamicTestQueue.cs | 65 +++ TUnit.Engine/Services/TestRegistry.cs | 151 +++++- ...Has_No_API_Changes.DotNet10_0.verified.txt | 18 + ..._Has_No_API_Changes.DotNet8_0.verified.txt | 18 + ..._Has_No_API_Changes.DotNet9_0.verified.txt | 18 + ...ary_Has_No_API_Changes.Net4_7.verified.txt | 15 + TUnit.TestProject/TestVariantTests.cs | 61 +++ docs/docs/advanced/test-variants.md | 483 ++++++++++++++++++ docs/sidebars.ts | 1 + 17 files changed, 1030 insertions(+), 11 deletions(-) create mode 100644 TUnit.Core/Enums/TestRelationship.cs create mode 100644 TUnit.Engine/Interfaces/IDynamicTestQueue.cs create mode 100644 TUnit.Engine/Services/DynamicTestQueue.cs create mode 100644 TUnit.TestProject/TestVariantTests.cs create mode 100644 docs/docs/advanced/test-variants.md diff --git a/TUnit.Core/AbstractDynamicTest.cs b/TUnit.Core/AbstractDynamicTest.cs index afa495adcb..e086176bc6 100644 --- a/TUnit.Core/AbstractDynamicTest.cs +++ b/TUnit.Core/AbstractDynamicTest.cs @@ -36,15 +36,17 @@ public class DynamicDiscoveryResult : DiscoveryResult | DynamicallyAccessedMemberTypes.NonPublicFields)] public Type? TestClassType { get; set; } - /// - /// The file path where the dynamic test was created - /// public string? CreatorFilePath { get; set; } - /// - /// The line number where the dynamic test was created - /// public int? CreatorLineNumber { get; set; } + + public string? ParentTestId { get; set; } + + public Enums.TestRelationship? Relationship { get; set; } + + public Dictionary? Properties { get; set; } + + public string? DisplayName { get; set; } } public abstract class AbstractDynamicTest diff --git a/TUnit.Core/Enums/TestRelationship.cs b/TUnit.Core/Enums/TestRelationship.cs new file mode 100644 index 0000000000..e545f2af6f --- /dev/null +++ b/TUnit.Core/Enums/TestRelationship.cs @@ -0,0 +1,31 @@ +namespace TUnit.Core.Enums; + +/// +/// Defines the relationship between a test and its parent test, if any. +/// Used for tracking test hierarchies and informing the test runner about the category of relationship. +/// +public enum TestRelationship +{ + /// + /// This test is independent and has no parent. + /// + None, + + /// + /// An identical re-run of a test, typically following a failure. + /// + Retry, + + /// + /// A test case generated as part of an initial set to explore a solution space. + /// For example, the initial random inputs for a property-based test. + /// + Generated, + + /// + /// A test case derived during the execution of a parent test, often in response to its outcome. + /// This is the appropriate category for property-based testing shrink attempts, mutation testing variants, + /// and other analytical test variations created at runtime based on parent test results. + /// + Derived +} diff --git a/TUnit.Core/Extensions/TestContextExtensions.cs b/TUnit.Core/Extensions/TestContextExtensions.cs index 4361cf0f50..11f1b4b038 100644 --- a/TUnit.Core/Extensions/TestContextExtensions.cs +++ b/TUnit.Core/Extensions/TestContextExtensions.cs @@ -45,4 +45,28 @@ public static string GetClassTypeName(this TestContext context) { await context.GetService()!.AddDynamicTest(context, dynamicTest);; } + + /// + /// Creates a new test variant based on the current test's template. + /// The new test is queued for execution and will appear as a distinct test in the test explorer. + /// This is the primary mechanism for implementing property-based test shrinking and retry logic. + /// + /// The current test context + /// Method arguments for the variant (null to reuse current arguments) + /// Key-value pairs for user-defined metadata (e.g., attempt count, custom data) + /// The relationship category of this variant to its parent test (defaults to Derived) + /// Optional user-facing display name for the variant (e.g., "Shrink Attempt", "Mutant") + /// A task that completes when the variant has been queued + #if NET6_0_OR_GREATER + [RequiresUnreferencedCode("Creating test variants requires runtime compilation and reflection")] + #endif + public static async Task CreateTestVariant( + this TestContext context, + object?[]? arguments = null, + Dictionary? properties = null, + Enums.TestRelationship relationship = Enums.TestRelationship.Derived, + string? displayName = null) + { + await context.GetService()!.CreateTestVariant(context, arguments, properties, relationship, displayName); + } } diff --git a/TUnit.Core/Interfaces/ITestRegistry.cs b/TUnit.Core/Interfaces/ITestRegistry.cs index 9dbfcd5da0..931ada871e 100644 --- a/TUnit.Core/Interfaces/ITestRegistry.cs +++ b/TUnit.Core/Interfaces/ITestRegistry.cs @@ -26,4 +26,25 @@ public interface ITestRegistry | DynamicallyAccessedMemberTypes.PublicFields | DynamicallyAccessedMemberTypes.NonPublicFields)] T>(TestContext context, DynamicTest dynamicTest) where T : class; + + /// + /// Creates a new test variant based on the current test's template. + /// The new test is queued for execution and will appear as a distinct test in the test explorer. + /// This is the primary mechanism for implementing property-based test shrinking and retry logic. + /// + /// The current test context to base the variant on + /// Method arguments for the variant (null to reuse current arguments) + /// Key-value pairs for user-defined metadata (e.g., attempt count, custom data) + /// The relationship category of this variant to its parent test + /// Optional user-facing display name for the variant (e.g., "Shrink Attempt", "Mutant") + /// A task that completes when the variant has been queued + #if NET6_0_OR_GREATER + [RequiresUnreferencedCode("Creating test variants requires runtime compilation and reflection which are not supported in native AOT scenarios.")] + #endif + Task CreateTestVariant( + TestContext currentContext, + object?[]? arguments, + Dictionary? properties, + Enums.TestRelationship relationship, + string? displayName); } diff --git a/TUnit.Core/TestContext.cs b/TUnit.Core/TestContext.cs index b5acc046c2..4f3dabc706 100644 --- a/TUnit.Core/TestContext.cs +++ b/TUnit.Core/TestContext.cs @@ -153,6 +153,18 @@ public void AddParallelConstraint(IParallelConstraint constraint) public Priority ExecutionPriority { get; set; } = Priority.Normal; + /// + /// The test ID of the parent test, if this test is a variant or child of another test. + /// Used for tracking test hierarchies in property-based testing shrinking and retry scenarios. + /// + public string? ParentTestId { get; set; } + + /// + /// Defines the relationship between this test and its parent test (if ParentTestId is set). + /// Used by test explorers to display hierarchical relationships. + /// + public TestRelationship Relationship { get; set; } = TestRelationship.None; + /// /// Will be null until initialized by TestOrchestrator /// diff --git a/TUnit.Engine/Framework/TUnitServiceProvider.cs b/TUnit.Engine/Framework/TUnitServiceProvider.cs index 673e73c91a..11e0547cab 100644 --- a/TUnit.Engine/Framework/TUnitServiceProvider.cs +++ b/TUnit.Engine/Framework/TUnitServiceProvider.cs @@ -221,6 +221,8 @@ public TUnitServiceProvider(IExtension extension, var staticPropertyHandler = Register(new StaticPropertyHandler(Logger, objectTracker, trackableObjectGraphProvider, disposer)); + var dynamicTestQueue = Register(new DynamicTestQueue(MessageBus)); + var testScheduler = Register(new TestScheduler( Logger, testGroupingService, @@ -232,7 +234,8 @@ public TUnitServiceProvider(IExtension extension, circularDependencyDetector, constraintKeyScheduler, hookExecutor, - staticPropertyHandler)); + staticPropertyHandler, + dynamicTestQueue)); TestSessionCoordinator = Register(new TestSessionCoordinator(EventReceiverOrchestrator, Logger, @@ -243,7 +246,7 @@ public TUnitServiceProvider(IExtension extension, MessageBus, staticPropertyInitializer)); - Register(new TestRegistry(TestBuilderPipeline, testCoordinator, TestSessionId, CancellationToken.Token)); + Register(new TestRegistry(TestBuilderPipeline, testCoordinator, dynamicTestQueue, TestSessionId, CancellationToken.Token)); InitializeConsoleInterceptors(); } diff --git a/TUnit.Engine/Interfaces/IDynamicTestQueue.cs b/TUnit.Engine/Interfaces/IDynamicTestQueue.cs new file mode 100644 index 0000000000..56c8f4498d --- /dev/null +++ b/TUnit.Engine/Interfaces/IDynamicTestQueue.cs @@ -0,0 +1,40 @@ +using TUnit.Core; + +namespace TUnit.Engine.Interfaces; + +/// +/// Thread-safe queue for managing dynamically created tests during execution. +/// Ensures tests created at runtime (via CreateTestVariant or AddDynamicTest) are properly scheduled. +/// Handles discovery notification internally to keep all dynamic test logic in one place. +/// +internal interface IDynamicTestQueue +{ + /// + /// Enqueues a test for execution and notifies the message bus. Thread-safe. + /// + /// The test to enqueue + /// Task that completes when the test is enqueued and discovery is notified + Task EnqueueAsync(AbstractExecutableTest test); + + /// + /// Attempts to dequeue the next test. Thread-safe. + /// + /// The dequeued test, or null if queue is empty + /// True if a test was dequeued, false if queue is empty + bool TryDequeue(out AbstractExecutableTest? test); + + /// + /// Gets the number of pending tests in the queue. + /// + int PendingCount { get; } + + /// + /// Indicates whether the queue has been completed and no more tests will be added. + /// + bool IsCompleted { get; } + + /// + /// Marks the queue as complete, indicating no more tests will be added. + /// + void Complete(); +} diff --git a/TUnit.Engine/Scheduling/TestScheduler.cs b/TUnit.Engine/Scheduling/TestScheduler.cs index b009bea717..5fca724fd1 100644 --- a/TUnit.Engine/Scheduling/TestScheduler.cs +++ b/TUnit.Engine/Scheduling/TestScheduler.cs @@ -4,6 +4,7 @@ using TUnit.Core.Exceptions; using TUnit.Core.Logging; using TUnit.Engine.CommandLineProviders; +using TUnit.Engine.Interfaces; using TUnit.Engine.Logging; using TUnit.Engine.Models; using TUnit.Engine.Services; @@ -23,6 +24,7 @@ internal sealed class TestScheduler : ITestScheduler private readonly IConstraintKeyScheduler _constraintKeyScheduler; private readonly HookExecutor _hookExecutor; private readonly StaticPropertyHandler _staticPropertyHandler; + private readonly IDynamicTestQueue _dynamicTestQueue; private readonly int _maxParallelism; private readonly SemaphoreSlim? _maxParallelismSemaphore; @@ -37,7 +39,8 @@ public TestScheduler( CircularDependencyDetector circularDependencyDetector, IConstraintKeyScheduler constraintKeyScheduler, HookExecutor hookExecutor, - StaticPropertyHandler staticPropertyHandler) + StaticPropertyHandler staticPropertyHandler, + IDynamicTestQueue dynamicTestQueue) { _logger = logger; _groupingService = groupingService; @@ -49,6 +52,7 @@ public TestScheduler( _constraintKeyScheduler = constraintKeyScheduler; _hookExecutor = hookExecutor; _staticPropertyHandler = staticPropertyHandler; + _dynamicTestQueue = dynamicTestQueue; _maxParallelism = GetMaxParallelism(logger, commandLineOptions); @@ -155,6 +159,9 @@ private async Task ExecuteGroupedTestsAsync( GroupedTests groupedTests, CancellationToken cancellationToken) { + // Start dynamic test queue processing in background + var dynamicTestProcessingTask = ProcessDynamicTestQueueAsync(cancellationToken); + if (groupedTests.Parallel.Length > 0) { await _logger.LogDebugAsync($"Starting {groupedTests.Parallel.Length} parallel tests").ConfigureAwait(false); @@ -205,6 +212,59 @@ private async Task ExecuteGroupedTestsAsync( await _logger.LogDebugAsync($"Starting {groupedTests.NotInParallel.Length} global NotInParallel tests").ConfigureAwait(false); await ExecuteSequentiallyAsync(groupedTests.NotInParallel, cancellationToken).ConfigureAwait(false); } + + // Mark the queue as complete and wait for remaining dynamic tests to finish + _dynamicTestQueue.Complete(); + await dynamicTestProcessingTask.ConfigureAwait(false); + } + + #if NET6_0_OR_GREATER + [System.Diagnostics.CodeAnalysis.RequiresUnreferencedCode("Test execution involves reflection for hooks and initialization")] + #endif + private async Task ProcessDynamicTestQueueAsync(CancellationToken cancellationToken) + { + var dynamicTests = new List(); + + while (!_dynamicTestQueue.IsCompleted || _dynamicTestQueue.PendingCount > 0) + { + // Dequeue all currently pending tests + while (_dynamicTestQueue.TryDequeue(out var test)) + { + if (test != null) + { + dynamicTests.Add(test); + } + } + + // Execute the batch of dynamic tests if any were found + if (dynamicTests.Count > 0) + { + await _logger.LogDebugAsync($"Executing {dynamicTests.Count} dynamic test(s)").ConfigureAwait(false); + + // Group and execute just like regular tests + var dynamicTestsArray = dynamicTests.ToArray(); + var groupedDynamicTests = await _groupingService.GroupTestsByConstraintsAsync(dynamicTestsArray).ConfigureAwait(false); + + // Execute the grouped dynamic tests (recursive call handles sub-dynamics) + if (groupedDynamicTests.Parallel.Length > 0) + { + await ExecuteTestsAsync(groupedDynamicTests.Parallel, cancellationToken).ConfigureAwait(false); + } + + if (groupedDynamicTests.NotInParallel.Length > 0) + { + await ExecuteSequentiallyAsync(groupedDynamicTests.NotInParallel, cancellationToken).ConfigureAwait(false); + } + + dynamicTests.Clear(); + } + + // If queue is not complete, wait a short time before checking again + if (!_dynamicTestQueue.IsCompleted) + { + await Task.Delay(50, cancellationToken).ConfigureAwait(false); + } + } } #if NET6_0_OR_GREATER diff --git a/TUnit.Engine/Services/DynamicTestQueue.cs b/TUnit.Engine/Services/DynamicTestQueue.cs new file mode 100644 index 0000000000..8536119942 --- /dev/null +++ b/TUnit.Engine/Services/DynamicTestQueue.cs @@ -0,0 +1,65 @@ +using System.Threading.Channels; +using TUnit.Core; +using TUnit.Engine.Interfaces; + +namespace TUnit.Engine.Services; + +/// +/// Thread-safe queue implementation for managing dynamically created tests using System.Threading.Channels. +/// Provides efficient async support for queuing tests created at runtime. +/// Handles discovery notification internally to keep all dynamic test logic in one place. +/// +internal sealed class DynamicTestQueue : IDynamicTestQueue +{ + private readonly Channel _channel; + private readonly ITUnitMessageBus _messageBus; + private int _pendingCount; + private bool _isCompleted; + + public DynamicTestQueue(ITUnitMessageBus messageBus) + { + _messageBus = messageBus ?? throw new ArgumentNullException(nameof(messageBus)); + + // Unbounded channel for maximum flexibility + // Tests can be added at any time during execution + _channel = Channel.CreateUnbounded(new UnboundedChannelOptions + { + SingleReader = false, // Multiple test runners may dequeue + SingleWriter = false // Multiple sources may enqueue (AddDynamicTest, CreateTestVariant) + }); + } + + public async Task EnqueueAsync(AbstractExecutableTest test) + { + Interlocked.Increment(ref _pendingCount); + + if (!_channel.Writer.TryWrite(test)) + { + Interlocked.Decrement(ref _pendingCount); + throw new InvalidOperationException("Failed to enqueue test to dynamic test queue."); + } + + await _messageBus.Discovered(test.Context); + } + + public bool TryDequeue(out AbstractExecutableTest? test) + { + if (_channel.Reader.TryRead(out test)) + { + Interlocked.Decrement(ref _pendingCount); + return true; + } + + test = null; + return false; + } + + public int PendingCount => _pendingCount; + + public bool IsCompleted => _isCompleted; + + public void Complete() + { + _isCompleted = true; + } +} diff --git a/TUnit.Engine/Services/TestRegistry.cs b/TUnit.Engine/Services/TestRegistry.cs index 737998f8f5..79352774b3 100644 --- a/TUnit.Engine/Services/TestRegistry.cs +++ b/TUnit.Engine/Services/TestRegistry.cs @@ -1,11 +1,13 @@ using System.Collections.Concurrent; using System.Diagnostics.CodeAnalysis; +using System.Linq; using System.Linq.Expressions; using System.Reflection; using TUnit.Core; using TUnit.Core.Interfaces; using TUnit.Engine.Building; using TUnit.Engine.Interfaces; +using Expression = System.Linq.Expressions.Expression; namespace TUnit.Engine.Services; @@ -17,16 +19,19 @@ internal sealed class TestRegistry : ITestRegistry private readonly ConcurrentQueue _pendingTests = new(); private readonly TestBuilderPipeline? _testBuilderPipeline; private readonly ITestCoordinator _testCoordinator; + private readonly IDynamicTestQueue _dynamicTestQueue; private readonly CancellationToken _sessionCancellationToken; private readonly string? _sessionId; public TestRegistry(TestBuilderPipeline testBuilderPipeline, ITestCoordinator testCoordinator, + IDynamicTestQueue dynamicTestQueue, string sessionId, CancellationToken sessionCancellationToken) { _testBuilderPipeline = testBuilderPipeline; _testCoordinator = testCoordinator; + _dynamicTestQueue = dynamicTestQueue; _sessionId = sessionId; _sessionCancellationToken = sessionCancellationToken; } @@ -96,10 +101,134 @@ private async Task ProcessPendingDynamicTests() foreach (var test in builtTests) { - await _testCoordinator.ExecuteTestAsync(test, _sessionCancellationToken); + await _dynamicTestQueue.EnqueueAsync(test); } } + [RequiresUnreferencedCode("Creating test variants requires reflection which is not supported in native AOT scenarios.")] + [UnconditionalSuppressMessage("Trimming", + "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access", + Justification = "Dynamic test variants require reflection")] + [UnconditionalSuppressMessage("Trimming", + "IL2067:Target parameter argument does not satisfy 'DynamicallyAccessedMembersAttribute' in call", + Justification = "Dynamic test variants require reflection")] + [UnconditionalSuppressMessage("AOT", + "IL3050:Calling members annotated with 'RequiresDynamicCodeAttribute' may break functionality when AOT compiling", + Justification = "Dynamic test variants require runtime compilation")] + public async Task CreateTestVariant( + TestContext currentContext, + object?[]? arguments, + Dictionary? properties, + TUnit.Core.Enums.TestRelationship relationship, + string? displayName) + { + var testDetails = currentContext.TestDetails; + var testClassType = testDetails.ClassType; + var variantMethodArguments = arguments ?? testDetails.TestMethodArguments; + + var methodMetadata = testDetails.MethodMetadata; + var parameterTypes = methodMetadata.Parameters.Select(p => p.Type).ToArray(); + var methodInfo = methodMetadata.Type.GetMethod( + methodMetadata.Name, + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static, + null, + parameterTypes, + null); + + if (methodInfo == null) + { + throw new InvalidOperationException($"Cannot create test variant: method '{methodMetadata.Name}' not found"); + } + + var genericAddDynamicTestMethod = typeof(TestRegistry) + .GetMethod(nameof(CreateTestVariantInternal), BindingFlags.NonPublic | BindingFlags.Instance) + ?.MakeGenericMethod(testClassType); + + if (genericAddDynamicTestMethod == null) + { + throw new InvalidOperationException("Failed to resolve CreateTestVariantInternal method"); + } + + await ((Task)genericAddDynamicTestMethod.Invoke(this, + [currentContext, methodInfo, variantMethodArguments, testDetails.TestClassArguments, properties, relationship, displayName])!); + } + + [RequiresUnreferencedCode("Creating test variants requires reflection which is not supported in native AOT scenarios.")] + private async Task CreateTestVariantInternal<[DynamicallyAccessedMembers( + DynamicallyAccessedMemberTypes.PublicConstructors + | DynamicallyAccessedMemberTypes.NonPublicConstructors + | DynamicallyAccessedMemberTypes.PublicProperties + | DynamicallyAccessedMemberTypes.PublicMethods + | DynamicallyAccessedMemberTypes.NonPublicMethods + | DynamicallyAccessedMemberTypes.PublicFields + | DynamicallyAccessedMemberTypes.NonPublicFields)] T>( + TestContext currentContext, + MethodInfo methodInfo, + object?[] variantMethodArguments, + object?[] classArguments, + Dictionary? properties, + TUnit.Core.Enums.TestRelationship relationship, + string? displayName) where T : class + { + var parameter = Expression.Parameter(typeof(T), "instance"); + var methodParameters = methodInfo.GetParameters(); + var argumentExpressions = new Expression[methodParameters.Length]; + + for (int i = 0; i < methodParameters.Length; i++) + { + var argValue = i < variantMethodArguments.Length ? variantMethodArguments[i] : null; + argumentExpressions[i] = Expression.Constant(argValue, methodParameters[i].ParameterType); + } + + var methodCall = Expression.Call(parameter, methodInfo, argumentExpressions); + + Expression body; + if (methodInfo.ReturnType == typeof(Task)) + { + body = methodCall; + } + else if (methodInfo.ReturnType == typeof(void)) + { + body = Expression.Block(methodCall, Expression.Constant(Task.CompletedTask)); + } + else if (methodInfo.ReturnType.IsGenericType && + methodInfo.ReturnType.GetGenericTypeDefinition() == typeof(Task<>)) + { + body = Expression.Convert(methodCall, typeof(Task)); + } + else + { + body = Expression.Block(methodCall, Expression.Constant(Task.CompletedTask)); + } + + var lambda = Expression.Lambda>(body, parameter); + var attributes = new List(currentContext.TestDetails.Attributes); + + var discoveryResult = new DynamicDiscoveryResult + { + TestClassType = typeof(T), + TestClassArguments = classArguments, + TestMethodArguments = variantMethodArguments, + TestMethod = lambda, + Attributes = attributes, + CreatorFilePath = currentContext.TestDetails.TestFilePath, + CreatorLineNumber = currentContext.TestDetails.TestLineNumber, + ParentTestId = currentContext.TestDetails.TestId, + Relationship = relationship, + Properties = properties, + DisplayName = displayName + }; + + _pendingTests.Enqueue(new PendingDynamicTest + { + DiscoveryResult = discoveryResult, + SourceContext = currentContext, + TestClassType = typeof(T) + }); + + await ProcessPendingDynamicTests(); + } + [RequiresUnreferencedCode("Dynamic test metadata creation requires reflection which is not supported in native AOT scenarios.")] private async Task CreateMetadataFromDynamicDiscoveryResult(DynamicDiscoveryResult result) { @@ -231,12 +360,30 @@ public override Func { var instance = metadata.InstanceFactory(Type.EmptyTypes, modifiedContext.ClassArguments); 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 4352c6266d..4c6d041a8d 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 @@ -612,6 +612,10 @@ namespace public .<> Attributes { get; set; } public string? CreatorFilePath { get; set; } public int? CreatorLineNumber { get; set; } + public string? DisplayName { get; set; } + public string? ParentTestId { get; set; } + public .? Properties { get; set; } + public .? Relationship { get; set; } public object?[]? TestClassArguments { get; set; } [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicMethods | ..NonPublicMethods | ..PublicFields | ..NonPublicFields | ..PublicProperties)] public ? TestClassType { get; set; } @@ -1284,7 +1288,9 @@ namespace public .? ParallelConstraint { get; set; } public .<.> ParallelConstraints { get; } public .? ParallelLimiter { get; } + public string? ParentTestId { get; set; } public .TestPhase Phase { get; set; } + public . Relationship { get; set; } public bool ReportResult { get; set; } public .TestResult? Result { get; set; } public <.TestContext, , int, .>? RetryFunc { get; set; } @@ -1704,6 +1710,13 @@ namespace .Enums High = 4, Critical = 5, } + public enum TestRelationship + { + None = 0, + Retry = 1, + Generated = 2, + Derived = 3, + } } namespace .Events { @@ -1889,6 +1902,8 @@ namespace .Extensions [.("Dynamic test metadata creation uses reflection")] public static . AddDynamicTest<[.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicMethods | ..NonPublicMethods | ..PublicFields | ..NonPublicFields | ..PublicProperties)] T>(this .TestContext context, .DynamicTest dynamicTest) where T : class { } + [.("Creating test variants requires runtime compilation and reflection")] + public static . CreateTestVariant(this .TestContext context, object?[]? arguments = null, .? properties = null, . relationship = 3, string? displayName = null) { } public static string GetClassTypeName(this .TestContext context) { } public static T? GetService(this .TestContext context) where T : class { } @@ -2313,6 +2328,9 @@ namespace .Interfaces "pported in native AOT scenarios.")] . AddDynamicTest<[.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicMethods | ..NonPublicMethods | ..PublicFields | ..NonPublicFields | ..PublicProperties)] T>(.TestContext context, .DynamicTest dynamicTest) where T : class; + [.("Creating test variants requires runtime compilation and reflection which are not " + + "supported in native AOT scenarios.")] + . CreateTestVariant(.TestContext currentContext, object?[]? arguments, .? properties, . relationship, string? displayName); } public interface ITestRetryEventReceiver : . { 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 f64342699b..1ccc7971a8 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 @@ -612,6 +612,10 @@ namespace public .<> Attributes { get; set; } public string? CreatorFilePath { get; set; } public int? CreatorLineNumber { get; set; } + public string? DisplayName { get; set; } + public string? ParentTestId { get; set; } + public .? Properties { get; set; } + public .? Relationship { get; set; } public object?[]? TestClassArguments { get; set; } [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicMethods | ..NonPublicMethods | ..PublicFields | ..NonPublicFields | ..PublicProperties)] public ? TestClassType { get; set; } @@ -1284,7 +1288,9 @@ namespace public .? ParallelConstraint { get; set; } public .<.> ParallelConstraints { get; } public .? ParallelLimiter { get; } + public string? ParentTestId { get; set; } public .TestPhase Phase { get; set; } + public . Relationship { get; set; } public bool ReportResult { get; set; } public .TestResult? Result { get; set; } public <.TestContext, , int, .>? RetryFunc { get; set; } @@ -1704,6 +1710,13 @@ namespace .Enums High = 4, Critical = 5, } + public enum TestRelationship + { + None = 0, + Retry = 1, + Generated = 2, + Derived = 3, + } } namespace .Events { @@ -1889,6 +1902,8 @@ namespace .Extensions [.("Dynamic test metadata creation uses reflection")] public static . AddDynamicTest<[.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicMethods | ..NonPublicMethods | ..PublicFields | ..NonPublicFields | ..PublicProperties)] T>(this .TestContext context, .DynamicTest dynamicTest) where T : class { } + [.("Creating test variants requires runtime compilation and reflection")] + public static . CreateTestVariant(this .TestContext context, object?[]? arguments = null, .? properties = null, . relationship = 3, string? displayName = null) { } public static string GetClassTypeName(this .TestContext context) { } public static T? GetService(this .TestContext context) where T : class { } @@ -2313,6 +2328,9 @@ namespace .Interfaces "pported in native AOT scenarios.")] . AddDynamicTest<[.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicMethods | ..NonPublicMethods | ..PublicFields | ..NonPublicFields | ..PublicProperties)] T>(.TestContext context, .DynamicTest dynamicTest) where T : class; + [.("Creating test variants requires runtime compilation and reflection which are not " + + "supported in native AOT scenarios.")] + . CreateTestVariant(.TestContext currentContext, object?[]? arguments, .? properties, . relationship, string? displayName); } public interface ITestRetryEventReceiver : . { 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 443782f791..d1c3f9b3e9 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 @@ -612,6 +612,10 @@ namespace public .<> Attributes { get; set; } public string? CreatorFilePath { get; set; } public int? CreatorLineNumber { get; set; } + public string? DisplayName { get; set; } + public string? ParentTestId { get; set; } + public .? Properties { get; set; } + public .? Relationship { get; set; } public object?[]? TestClassArguments { get; set; } [.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicMethods | ..NonPublicMethods | ..PublicFields | ..NonPublicFields | ..PublicProperties)] public ? TestClassType { get; set; } @@ -1284,7 +1288,9 @@ namespace public .? ParallelConstraint { get; set; } public .<.> ParallelConstraints { get; } public .? ParallelLimiter { get; } + public string? ParentTestId { get; set; } public .TestPhase Phase { get; set; } + public . Relationship { get; set; } public bool ReportResult { get; set; } public .TestResult? Result { get; set; } public <.TestContext, , int, .>? RetryFunc { get; set; } @@ -1704,6 +1710,13 @@ namespace .Enums High = 4, Critical = 5, } + public enum TestRelationship + { + None = 0, + Retry = 1, + Generated = 2, + Derived = 3, + } } namespace .Events { @@ -1889,6 +1902,8 @@ namespace .Extensions [.("Dynamic test metadata creation uses reflection")] public static . AddDynamicTest<[.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicMethods | ..NonPublicMethods | ..PublicFields | ..NonPublicFields | ..PublicProperties)] T>(this .TestContext context, .DynamicTest dynamicTest) where T : class { } + [.("Creating test variants requires runtime compilation and reflection")] + public static . CreateTestVariant(this .TestContext context, object?[]? arguments = null, .? properties = null, . relationship = 3, string? displayName = null) { } public static string GetClassTypeName(this .TestContext context) { } public static T? GetService(this .TestContext context) where T : class { } @@ -2313,6 +2328,9 @@ namespace .Interfaces "pported in native AOT scenarios.")] . AddDynamicTest<[.(..None | ..PublicParameterlessConstructor | ..PublicConstructors | ..NonPublicConstructors | ..PublicMethods | ..NonPublicMethods | ..PublicFields | ..NonPublicFields | ..PublicProperties)] T>(.TestContext context, .DynamicTest dynamicTest) where T : class; + [.("Creating test variants requires runtime compilation and reflection which are not " + + "supported in native AOT scenarios.")] + . CreateTestVariant(.TestContext currentContext, object?[]? arguments, .? properties, . relationship, string? displayName); } public interface ITestRetryEventReceiver : . { 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 44febaf0b5..b6a0ad1953 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 @@ -592,6 +592,10 @@ namespace public .<> Attributes { get; set; } public string? CreatorFilePath { get; set; } public int? CreatorLineNumber { get; set; } + public string? DisplayName { get; set; } + public string? ParentTestId { get; set; } + public .? Properties { get; set; } + public .? Relationship { get; set; } public object?[]? TestClassArguments { get; set; } public ? TestClassType { get; set; } public .? TestMethod { get; set; } @@ -1238,7 +1242,9 @@ namespace public .? ParallelConstraint { get; set; } public .<.> ParallelConstraints { get; } public .? ParallelLimiter { get; } + public string? ParentTestId { get; set; } public .TestPhase Phase { get; set; } + public . Relationship { get; set; } public bool ReportResult { get; set; } public .TestResult? Result { get; set; } public <.TestContext, , int, .>? RetryFunc { get; set; } @@ -1656,6 +1662,13 @@ namespace .Enums High = 4, Critical = 5, } + public enum TestRelationship + { + None = 0, + Retry = 1, + Generated = 2, + Derived = 3, + } } namespace .Events { @@ -1839,6 +1852,7 @@ namespace .Extensions { public static . AddDynamicTest(this .TestContext context, .DynamicTest dynamicTest) where T : class { } + public static . CreateTestVariant(this .TestContext context, object?[]? arguments = null, .? properties = null, . relationship = 3, string? displayName = null) { } public static string GetClassTypeName(this .TestContext context) { } public static T? GetService(this .TestContext context) where T : class { } @@ -2243,6 +2257,7 @@ namespace .Interfaces { . AddDynamicTest(.TestContext context, .DynamicTest dynamicTest) where T : class; + . CreateTestVariant(.TestContext currentContext, object?[]? arguments, .? properties, . relationship, string? displayName); } public interface ITestRetryEventReceiver : . { diff --git a/TUnit.TestProject/TestVariantTests.cs b/TUnit.TestProject/TestVariantTests.cs new file mode 100644 index 0000000000..db1007848a --- /dev/null +++ b/TUnit.TestProject/TestVariantTests.cs @@ -0,0 +1,61 @@ +using TUnit.Core; +using TUnit.Core.Extensions; + +namespace TUnit.TestProject; + +public class TestVariantTests +{ + [Test] + public async Task CreateTestVariant_ShouldCreateVariantWithDifferentArguments() + { + var context = TestContext.Current; + + if (context == null) + { + throw new InvalidOperationException("TestContext.Current is null"); + } + + await context.CreateTestVariant( + arguments: new object?[] { 42 }, + properties: new Dictionary + { + { "AttemptNumber", 1 } + }, + relationship: TUnit.Core.Enums.TestRelationship.Derived, + displayName: "Shrink Attempt" + ); + } + + [Test] + [Arguments(10)] + public async Task VariantTarget_WithArguments(int value) + { + var context = TestContext.Current; + + if (context == null) + { + throw new InvalidOperationException("TestContext.Current is null"); + } + + if (value < 0) + { + throw new InvalidOperationException($"Expected non-negative value but got {value}"); + } + + if (context.ObjectBag.ContainsKey("AttemptNumber")) + { + var attemptNumber = context.ObjectBag["AttemptNumber"]; + context.WriteLine($"Shrink attempt {attemptNumber} with value {value}"); + + if (context.Relationship != TUnit.Core.Enums.TestRelationship.Derived) + { + throw new InvalidOperationException($"Expected Derived relationship but got {context.Relationship}"); + } + + if (context.ParentTestId == null) + { + throw new InvalidOperationException("Expected ParentTestId to be set for shrink attempt"); + } + } + } +} diff --git a/docs/docs/advanced/test-variants.md b/docs/docs/advanced/test-variants.md new file mode 100644 index 0000000000..7862a0327c --- /dev/null +++ b/docs/docs/advanced/test-variants.md @@ -0,0 +1,483 @@ +# Test Variants + +Test variants enable you to dynamically create additional test cases during test execution based on runtime results. This powerful feature unlocks advanced testing patterns like property-based testing shrinking, mutation testing, adaptive stress testing, and intelligent retry strategies. + +## What Are Test Variants? + +Test variants are tests that are created **during the execution** of a parent test, inheriting the parent's test method template but potentially using different arguments, properties, or display names. They appear as distinct tests in the test explorer and can have their own outcomes. + +### Test Variants vs Dynamic Tests + +| Feature | Test Variants (`CreateTestVariant`) | Dynamic Tests (`AddDynamicTest`) | +|---------|-------------------------------------|----------------------------------| +| **Created** | During test execution | During test discovery | +| **Parent** | Always has a parent test | Standalone tests | +| **Template** | Reuses parent's test method | Requires explicit method definition | +| **Use Case** | Runtime adaptation (shrinking, mutation, stress) | Pre-generation of test cases | +| **AOT Compatible** | No (requires reflection) | Yes (with source generators) | + +## Core Concepts + +### TestRelationship Enum + +The `TestRelationship` enum categorizes how a variant relates to its parent, informing the test runner about execution semantics: + +```csharp +public enum TestRelationship +{ + None, // Independent test (no parent) + Retry, // Identical re-run after failure + Generated, // Pre-execution exploration (e.g., initial PBT cases) + Derived // Post-execution analysis (e.g., shrinking, mutation) +} +``` + +**When to use each:** +- **`Retry`**: For identical re-runs, typically handled by `[Retry]` attribute +- **`Generated`**: For upfront test case generation before execution +- **`Derived`**: For runtime analysis based on parent results (most common for variants) + +### DisplayName Parameter + +The optional `displayName` parameter provides user-facing labels in test explorers and reports. While the `TestRelationship` informs the framework about execution semantics, `displayName` communicates intent to humans: + +```csharp +await context.CreateTestVariant( + arguments: new object[] { smallerInput }, + relationship: TestRelationship.Derived, + displayName: "Shrink Attempt #3" // Shows in test explorer +); +``` + +### Properties Dictionary + +Store metadata for filtering, reporting, or variant logic: + +```csharp +await context.CreateTestVariant( + arguments: new object[] { mutatedValue }, + properties: new Dictionary + { + { "AttemptNumber", 3 }, + { "ShrinkStrategy", "Binary" }, + { "OriginalValue", originalInput } + }, + relationship: TestRelationship.Derived, + displayName: "Shrink #3 (Binary)" +); +``` + +## Use Cases + +### 1. Property-Based Testing (PBT) - Shrinking + +When a property-based test fails with a complex input, create variants with progressively simpler inputs to find the minimal failing case. Use a custom attribute implementing `ITestEndEventReceiver` to automatically shrink on failure: + +```csharp +// Custom attribute that shrinks inputs on test failure +public class ShrinkOnFailureAttribute : Attribute, ITestEndEventReceiver +{ + private readonly int _maxAttempts; + + public ShrinkOnFailureAttribute(int maxAttempts = 5) + { + _maxAttempts = maxAttempts; + } + + public async ValueTask OnTestEnd(TestContext testContext) + { + // Only shrink if test failed and it's not already a shrink attempt + if (testContext.Result?.Status != TestStatus.Failed) + return; + + if (testContext.Relationship == TestRelationship.Derived) + return; // Don't shrink shrink attempts + + // Get the test's numeric argument to shrink + var args = testContext.TestDetails.TestMethodArguments; + if (args.Length == 0 || args[0] is not int size) + return; + + if (size <= 1) + return; // Can't shrink further + + // Create shrink variants + var shrinkSize = size / 2; + for (int attempt = 1; attempt <= _maxAttempts && shrinkSize > 0; attempt++) + { + await testContext.CreateTestVariant( + arguments: new object[] { shrinkSize }, + properties: new Dictionary + { + { "AttemptNumber", attempt }, + { "OriginalSize", size }, + { "ShrinkStrategy", "Binary" } + }, + relationship: TestRelationship.Derived, + displayName: $"Shrink #{attempt} (size={shrinkSize})" + ); + + shrinkSize /= 2; + } + } +} + +// Usage: Just add the attribute - shrinking happens automatically on failure +[Test] +[ShrinkOnFailure(maxAttempts: 5)] +[Arguments(1000)] +[Arguments(500)] +[Arguments(100)] +public async Task PropertyTest_ListReversal(int size) +{ + var list = Enumerable.Range(0, size).ToList(); + + // Property: reversing twice should return original + var reversed = list.Reverse().Reverse().ToList(); + await Assert.That(reversed).IsEquivalentTo(list); + + // If this fails, the attribute automatically creates shrink variants +} +``` + +**Why this pattern is better:** +- **Separation of concerns**: Test logic stays clean, shrinking is in the attribute +- **Reusable**: Apply `[ShrinkOnFailure]` to any test with numeric inputs +- **Declarative**: Intent is clear from the attribute +- **Automatic**: No try-catch or manual failure detection needed + +### 2. Mutation Testing + +Create variants that test your test's ability to catch bugs by introducing controlled mutations: + +```csharp +[Test] +[Arguments(5, 10)] +public async Task CalculatorTest_Addition(int a, int b) +{ + var context = TestContext.Current!; + var calculator = new Calculator(); + + var result = calculator.Add(a, b); + await Assert.That(result).IsEqualTo(a + b); + + // After test passes, create mutants to verify test quality + var mutations = new[] + { + (a + 1, b, "Mutant: Boundary +1 on first arg"), + (a, b + 1, "Mutant: Boundary +1 on second arg"), + (a - 1, b, "Mutant: Boundary -1 on first arg"), + (0, 0, "Mutant: Zero case") + }; + + foreach (var (mutA, mutB, name) in mutations) + { + await context.CreateTestVariant( + arguments: new object[] { mutA, mutB }, + relationship: TestRelationship.Derived, + displayName: name + ); + } +} +``` + +### 3. Adaptive Stress Testing + +Progressively increase load based on system performance: + +```csharp +[Test] +[Arguments(10)] // Start with low load +public async Task LoadTest_ApiEndpoint(int concurrentUsers) +{ + var context = TestContext.Current!; + var stopwatch = Stopwatch.StartNew(); + + // Simulate load + var tasks = Enumerable.Range(0, concurrentUsers) + .Select(_ => CallApiAsync()) + .ToArray(); + + await Task.WhenAll(tasks); + stopwatch.Stop(); + + var avgResponseTime = stopwatch.ElapsedMilliseconds / (double)concurrentUsers; + context.WriteLine($"Users: {concurrentUsers}, Avg response: {avgResponseTime}ms"); + + // If system handled load well, increase it + if (avgResponseTime < 200 && concurrentUsers < 1000) + { + var nextLoad = concurrentUsers * 2; + await context.CreateTestVariant( + arguments: new object[] { nextLoad }, + properties: new Dictionary + { + { "PreviousLoad", concurrentUsers }, + { "PreviousAvgResponseTime", avgResponseTime } + }, + relationship: TestRelationship.Derived, + displayName: $"Load Test ({nextLoad} users)" + ); + } + + await Assert.That(avgResponseTime).IsLessThan(500); +} +``` + +### 4. Exploratory Fuzzing + +Generate additional test cases when edge cases are discovered: + +```csharp +[Test] +[Arguments("normal text")] +public async Task InputValidation_SpecialCharacters(string input) +{ + var context = TestContext.Current!; + var validator = new InputValidator(); + + var result = validator.Validate(input); + await Assert.That(result.IsValid).IsTrue(); + + // If we haven't tested special characters yet, generate variants + if (!context.ObjectBag.ContainsKey("TestedSpecialChars")) + { + context.ObjectBag["TestedSpecialChars"] = true; + + var specialInputs = new[] + { + "", + "'; DROP TABLE users; --", + "../../../etc/passwd", + "\0\0\0null bytes\0", + new string('A', 10000) // Buffer overflow attempt + }; + + foreach (var specialInput in specialInputs) + { + await context.CreateTestVariant( + arguments: new object[] { specialInput }, + relationship: TestRelationship.Derived, + displayName: $"Fuzz: {specialInput.Substring(0, Math.Min(30, specialInput.Length))}" + ); + } + } +} +``` + +### 5. Smart Retry with Parameter Adjustment + +Retry failed tests with adjusted parameters to differentiate transient failures from persistent bugs: + +```csharp +[Test] +[Arguments(TimeSpan.FromSeconds(5))] +public async Task ExternalService_WithTimeout(TimeSpan timeout) +{ + var context = TestContext.Current!; + + try + { + using var cts = new CancellationTokenSource(timeout); + var result = await _externalService.FetchDataAsync(cts.Token); + await Assert.That(result).IsNotNull(); + } + catch (TimeoutException ex) + { + // If timeout, try with longer timeout to see if it's a transient issue + if (timeout < TimeSpan.FromSeconds(30)) + { + var longerTimeout = timeout.Add(TimeSpan.FromSeconds(5)); + + await context.CreateTestVariant( + arguments: new object[] { longerTimeout }, + properties: new Dictionary + { + { "OriginalTimeout", timeout }, + { "RetryReason", "Timeout" } + }, + relationship: TestRelationship.Derived, + displayName: $"Retry with {longerTimeout.TotalSeconds}s timeout" + ); + } + + throw; + } +} +``` + +### 6. Chaos Engineering + +Inject faults and verify system resilience: + +```csharp +[Test] +public async Task Resilience_DatabaseFailover() +{ + var context = TestContext.Current!; + var system = new DistributedSystem(); + + // Normal operation test + var result = await system.ProcessRequestAsync(); + await Assert.That(result.Success).IsTrue(); + + // Create chaos variants + var chaosScenarios = new[] + { + ("primary-db-down", "Primary DB Failure"), + ("network-latency-500ms", "High Network Latency"), + ("replica-lag-10s", "Replica Lag"), + ("cascading-failure", "Cascading Failure") + }; + + foreach (var (faultType, displayName) in chaosScenarios) + { + await context.CreateTestVariant( + arguments: new object[] { faultType }, + properties: new Dictionary + { + { "ChaosType", faultType }, + { "InjectionPoint", "AfterSuccess" } + }, + relationship: TestRelationship.Derived, + displayName: $"Chaos: {displayName}" + ); + } +} +``` + +## API Reference + +### Method Signature + +```csharp +public static async Task CreateTestVariant( + this TestContext context, + object?[]? arguments = null, + Dictionary? properties = null, + TestRelationship relationship = TestRelationship.Derived, + string? displayName = null) +``` + +### Parameters + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `context` | `TestContext` | Yes | - | The current test context | +| `arguments` | `object?[]?` | No | `null` | Method arguments for the variant. If `null`, reuses parent's arguments | +| `properties` | `Dictionary?` | No | `null` | Custom metadata stored in the variant's `TestContext.ObjectBag` | +| `relationship` | `TestRelationship` | No | `Derived` | Categorizes the variant's relationship to its parent | +| `displayName` | `string?` | No | `null` | User-facing label shown in test explorers. If `null`, uses default format | + +### Return Value + +Returns `Task` that completes when the variant has been queued for execution. + +### Exceptions + +- `InvalidOperationException`: Thrown if `TestContext.Current` is null +- `InvalidOperationException`: Thrown if the test method cannot be resolved + +## Best Practices + +### 1. Choose Appropriate TestRelationship + +```csharp +// ✅ Good: Derived for post-execution analysis +await context.CreateTestVariant( + arguments: [smallerInput], + relationship: TestRelationship.Derived, + displayName: "Shrink Attempt" +); + +// ❌ Bad: Using None loses parent relationship +await context.CreateTestVariant( + arguments: [smallerInput], + relationship: TestRelationship.None // Parent link lost! +); +``` + +### 2. Provide Descriptive Display Names + +```csharp +// ✅ Good: Clear, specific, actionable +displayName: "Shrink #3 (Binary Search, size=125)" + +// ⚠️ Okay: Somewhat clear +displayName: "Shrink Attempt 3" + +// ❌ Bad: Vague, unhelpful +displayName: "Variant" +``` + +### 3. Avoid Infinite Recursion + +```csharp +[Test] +public async Task RecursiveVariant() +{ + var context = TestContext.Current!; + + // ✅ Good: Check depth + var depth = context.ObjectBag.TryGetValue("Depth", out var d) ? (int)d : 0; + if (depth < 5) + { + await context.CreateTestVariant( + properties: new Dictionary { { "Depth", depth + 1 } }, + relationship: TestRelationship.Derived + ); + } + + // ❌ Bad: Infinite loop! + // await context.CreateTestVariant(relationship: TestRelationship.Derived); +} +``` + +### 4. Use Properties for Metadata + +```csharp +// ✅ Good: Structured metadata +properties: new Dictionary +{ + { "AttemptNumber", 3 }, + { "Strategy", "BinarySearch" }, + { "OriginalValue", largeInput }, + { "Timestamp", DateTime.UtcNow } +} + +// ❌ Bad: Encoding metadata in displayName +displayName: "Attempt=3,Strategy=Binary,Original=1000,Time=2024-01-01" +``` + +### 5. Consider Performance + +Creating many variants has overhead. Be strategic: + +```csharp +// ✅ Good: Limited, strategic variants +if (shouldShrink && attemptCount < 10) +{ + await context.CreateTestVariant(...); +} + +// ❌ Bad: Explosion of variants +for (int i = 0; i < 10000; i++) // Creates 10,000 tests! +{ + await context.CreateTestVariant(...); +} +``` + +## Limitations + +- **Not AOT Compatible**: Test variants require runtime reflection and expression compilation +- **Requires Reflection Mode**: Must run with reflection-based discovery (not source-generated) +- **Performance Overhead**: Each variant is a full test execution with its own lifecycle +- **No Source Generator Support**: Cannot be used in AOT-compiled scenarios + +## See Also + +- [Test Context](../test-lifecycle/test-context.md) - Understanding TestContext and ObjectBag +- [Dynamic Tests](../experimental/dynamic-tests.md) - Pre-execution test generation +- [Retrying](../execution/retrying.md) - Built-in retry mechanism comparison +- [Properties](../test-lifecycle/properties.md) - Test metadata and custom properties +- [Event Subscribing](../test-lifecycle/event-subscribing.md) - Test lifecycle event receivers diff --git a/docs/sidebars.ts b/docs/sidebars.ts index 04ae12559b..7133a2bd13 100644 --- a/docs/sidebars.ts +++ b/docs/sidebars.ts @@ -153,6 +153,7 @@ const sidebars: SidebarsConfig = { items: [ 'advanced/exception-handling', 'advanced/extension-points', + 'advanced/test-variants', 'advanced/performance-best-practices', ], }, From 48f1f0838d27155d9e0cdb69fd76f04f0636f134 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Tue, 28 Oct 2025 14:38:15 +0000 Subject: [PATCH 37/67] Fix various documentation snippets (#3555) * Fix various documentation snippets * fix(docs): correct method names and improve clarity in documentation * feat(docs): add new entries to sidebars for troubleshooting and migration guides --- docs/docs/advanced/extension-points.md | 158 +++++++++++++----- .../examples/instrumenting-global-test-ids.md | 7 +- docs/docs/execution/retrying.md | 2 +- docs/docs/migration/mstest.md | 2 +- docs/docs/migration/nunit.md | 4 +- docs/docs/test-authoring/skip.md | 2 +- docs/docs/test-lifecycle/cleanup.md | 2 +- .../test-lifecycle/dependency-injection.md | 2 +- docs/docs/test-lifecycle/event-subscribing.md | 7 +- .../docs/test-lifecycle/property-injection.md | 4 +- docs/sidebars.ts | 10 +- 11 files changed, 142 insertions(+), 58 deletions(-) diff --git a/docs/docs/advanced/extension-points.md b/docs/docs/advanced/extension-points.md index b319f0ec67..14e8c087a8 100644 --- a/docs/docs/advanced/extension-points.md +++ b/docs/docs/advanced/extension-points.md @@ -15,7 +15,7 @@ The `ITestExecutor` interface allows you to customize how tests are executed. Th ```csharp public interface ITestExecutor { - Task ExecuteAsync(TestContext context, Func testBody); + ValueTask ExecuteTest(TestContext context, Func action); } ``` @@ -24,13 +24,13 @@ public interface ITestExecutor ```csharp public class TimingTestExecutor : ITestExecutor { - public async Task ExecuteAsync(TestContext context, Func testBody) + public async ValueTask ExecuteTest(TestContext context, Func action) { var stopwatch = Stopwatch.StartNew(); try { - await testBody(); + await action(); } finally { @@ -87,48 +87,97 @@ The `IHookExecutor` interface allows you to customize how setup and cleanup hook ```csharp public interface IHookExecutor { - Task ExecuteAsync(HookContext context, Func hookBody); + ValueTask ExecuteBeforeTestDiscoveryHook(MethodMetadata hookMethodInfo, BeforeTestDiscoveryContext context, Func action); + ValueTask ExecuteBeforeTestSessionHook(MethodMetadata hookMethodInfo, TestSessionContext context, Func action); + ValueTask ExecuteBeforeAssemblyHook(MethodMetadata hookMethodInfo, AssemblyHookContext context, Func action); + ValueTask ExecuteBeforeClassHook(MethodMetadata hookMethodInfo, ClassHookContext context, Func action); + ValueTask ExecuteBeforeTestHook(MethodMetadata hookMethodInfo, TestContext context, Func action); + + ValueTask ExecuteAfterTestDiscoveryHook(MethodMetadata hookMethodInfo, TestDiscoveryContext context, Func action); + ValueTask ExecuteAfterTestSessionHook(MethodMetadata hookMethodInfo, TestSessionContext context, Func action); + ValueTask ExecuteAfterAssemblyHook(MethodMetadata hookMethodInfo, AssemblyHookContext context, Func action); + ValueTask ExecuteAfterClassHook(MethodMetadata hookMethodInfo, ClassHookContext context, Func action); + ValueTask ExecuteAfterTestHook(MethodMetadata hookMethodInfo, TestContext context, Func action); } ``` +**Note**: This interface has specific methods for each hook type (Before/After × TestDiscovery/TestSession/Assembly/Class/Test). Each method receives: +- `MethodMetadata hookMethodInfo`: Information about the hook method being executed +- A context object specific to the hook type +- The `action` to execute (the actual hook logic) + ### Example Implementation ```csharp -public class ResourceManagingHookExecutor : IHookExecutor +public class LoggingHookExecutor : IHookExecutor { - private static readonly Dictionary Resources = new(); + public async ValueTask ExecuteBeforeTestDiscoveryHook(MethodMetadata hookMethodInfo, BeforeTestDiscoveryContext context, Func action) + { + Console.WriteLine($"Before test discovery hook: {hookMethodInfo.MethodName}"); + await action(); + } - public async Task ExecuteAsync(HookContext context, Func hookBody) + public async ValueTask ExecuteBeforeTestSessionHook(MethodMetadata hookMethodInfo, TestSessionContext context, Func action) { - if (context.HookType == HookType.Before) - { - // Allocate resources before the hook - var resource = AllocateResource(context.TestContext.TestName); - Resources[context.TestContext.TestName] = resource; - } + Console.WriteLine($"Before test session hook: {hookMethodInfo.MethodName}"); + await action(); + } + + public async ValueTask ExecuteBeforeAssemblyHook(MethodMetadata hookMethodInfo, AssemblyHookContext context, Func action) + { + Console.WriteLine($"Before assembly hook: {hookMethodInfo.MethodName}"); + await action(); + } + + public async ValueTask ExecuteBeforeClassHook(MethodMetadata hookMethodInfo, ClassHookContext context, Func action) + { + Console.WriteLine($"Before class hook: {hookMethodInfo.MethodName} for class {context.TestClassType.Name}"); try { - await hookBody(); + await action(); } - finally + catch (Exception ex) { - if (context.HookType == HookType.After) - { - // Clean up resources after the hook - if (Resources.TryGetValue(context.TestContext.TestName, out var resource)) - { - resource.Dispose(); - Resources.Remove(context.TestContext.TestName); - } - } + Console.WriteLine($"Hook failed: {ex.Message}"); + throw; } } - private IDisposable AllocateResource(string testName) + public async ValueTask ExecuteBeforeTestHook(MethodMetadata hookMethodInfo, TestContext context, Func action) + { + Console.WriteLine($"Before test hook: {hookMethodInfo.MethodName} for test {context.TestDetails.TestName}"); + await action(); + } + + public async ValueTask ExecuteAfterTestDiscoveryHook(MethodMetadata hookMethodInfo, TestDiscoveryContext context, Func action) { - // Allocate some resource - return new SomeResource(testName); + await action(); + Console.WriteLine($"After test discovery hook: {hookMethodInfo.MethodName}"); + } + + public async ValueTask ExecuteAfterTestSessionHook(MethodMetadata hookMethodInfo, TestSessionContext context, Func action) + { + await action(); + Console.WriteLine($"After test session hook: {hookMethodInfo.MethodName}"); + } + + public async ValueTask ExecuteAfterAssemblyHook(MethodMetadata hookMethodInfo, AssemblyHookContext context, Func action) + { + await action(); + Console.WriteLine($"After assembly hook: {hookMethodInfo.MethodName}"); + } + + public async ValueTask ExecuteAfterClassHook(MethodMetadata hookMethodInfo, ClassHookContext context, Func action) + { + await action(); + Console.WriteLine($"After class hook: {hookMethodInfo.MethodName} for class {context.TestClassType.Name}"); + } + + public async ValueTask ExecuteAfterTestHook(MethodMetadata hookMethodInfo, TestContext context, Func action) + { + await action(); + Console.WriteLine($"After test hook: {hookMethodInfo.MethodName} for test {context.TestDetails.TestName}"); } } ``` @@ -292,34 +341,59 @@ public class DatabaseTests ### IParallelConstraint -Defines constraints for parallel execution. +Defines constraints for parallel execution. This is a marker interface with no members - it's used to identify types that represent parallel execution constraints. ```csharp public interface IParallelConstraint { - string ConstraintKey { get; } } ``` +**Note**: `IParallelConstraint` is a marker interface. The actual constraint logic is handled by TUnit's built-in constraint implementations like `NotInParallelConstraint` and `ParallelGroupConstraint`. + Example: ```csharp -public class SharedResourceConstraint : IParallelConstraint +public class FileAccessTests { - public string ConstraintKey => "SharedFile"; -} + [Test] + [NotInParallel("SharedFile")] + public async Task Test1() + { + // This test won't run in parallel with other tests + // that have the same constraint key "SharedFile" + await File.WriteAllTextAsync("shared.txt", "test1"); + } -[NotInParallel] -public async Task Test1() -{ - // This test won't run in parallel with other tests - // that have the same constraint + [Test] + [NotInParallel("SharedFile")] + public async Task Test2() + { + // This test won't run in parallel with Test1 + // because they share the same constraint key + await File.WriteAllTextAsync("shared.txt", "test2"); + } + + [Test] + [NotInParallel("Database")] + public async Task Test3() + { + // This test can run in parallel with Test1 and Test2 + // because it has a different constraint key + await Database.ExecuteAsync("UPDATE users SET status = 'active'"); + } } +``` + +You can also use the `NotInParallel` attribute without arguments to ensure tests don't run in parallel with any other tests: -[NotInParallel] -public async Task Test2() +```csharp +[Test] +[NotInParallel] +public async Task GloballySerializedTest() { - // This test won't run in parallel with Test1 + // This test won't run in parallel with any other tests + // marked with [NotInParallel] (no constraint key) } ``` @@ -397,7 +471,7 @@ Here's a complete example that wraps each test in a database transaction: ```csharp public class TransactionalTestExecutor : ITestExecutor { - public async Task ExecuteAsync(TestContext context, Func testBody) + public async ValueTask ExecuteTest(TestContext context, Func action) { // Get the database connection from DI var dbContext = context.GetService(); @@ -406,7 +480,7 @@ public class TransactionalTestExecutor : ITestExecutor try { - await testBody(); + await action(); // Rollback instead of commit to keep tests isolated await transaction.RollbackAsync(); diff --git a/docs/docs/examples/instrumenting-global-test-ids.md b/docs/docs/examples/instrumenting-global-test-ids.md index 63a9034428..847a4b0a33 100644 --- a/docs/docs/examples/instrumenting-global-test-ids.md +++ b/docs/docs/examples/instrumenting-global-test-ids.md @@ -4,7 +4,7 @@ There are plenty use cases for having a unique identifier for each test in your A straightforward way to ensure data isolation is to connect to a different data source for each test. To isolate, you need a unique identifier to differentiate between the tests. This identifier should be known before the test starts, so you can use it to provision the data source and build the connection string. For Redis, you would typically use a different database number for each test. For SQL databases, you would typically use a different table or database name for each test. -We can hook into TUnit's test discovery process and assign unique identifiers to tests by implementing `OnTestDiscovery(DiscoveredTestContext discoveredTestContext)` in `ITestDiscoveryEventReceiver`. Here's an example implementation that is tailored for contrived test cases involving Redis databases: +We can hook into TUnit's test discovery process and assign unique identifiers to tests by implementing `OnTestDiscovered(DiscoveredTestContext discoveredTestContext)` in `ITestDiscoveryEventReceiver`. Here's an example implementation that is tailored for contrived test cases involving Redis databases: ```csharp class AssignTestIdentifiersAttribute : Attribute, ITestDiscoveryEventReceiver @@ -13,9 +13,10 @@ class AssignTestIdentifiersAttribute : Attribute, ITestDiscoveryEventReceiver public static int TestId { get; private set; } = 0; - public void OnTestDiscovery(DiscoveredTestContext discoveredTestContext) + public ValueTask OnTestDiscovered(DiscoveredTestContext discoveredTestContext) { discoveredTestContext.TestContext.ObjectBag[TestIdObjectBagKey] = TestId++; + return ValueTask.CompletedTask; } } ``` @@ -24,7 +25,7 @@ class AssignTestIdentifiersAttribute : Attribute, ITestDiscoveryEventReceiver `TestId` is a static integer that we increment for each test. We use this to assign a unique identifier to each test. -In `OnTestDiscovery`, we assign the test identifier to the `ObjectBag` using the `TestIdObjectBagKey`. The use of `ObjectBag` exposes the test identifier to hooks and tests. +In `OnTestDiscovered`, we assign the test identifier to the `ObjectBag` using the `TestIdObjectBagKey`. The use of `ObjectBag` exposes the test identifier to hooks and tests. Before we demonstrate how to use this attribute, let's create a simple extension method for `TestContext` to retrieve the test identifier swiftly: diff --git a/docs/docs/execution/retrying.md b/docs/docs/execution/retrying.md index 5c22ecdf35..4738605254 100644 --- a/docs/docs/execution/retrying.md +++ b/docs/docs/execution/retrying.md @@ -43,7 +43,7 @@ public class RetryTransientHttpAttribute : RetryAttribute { } - public override Task ShouldRetry(TestInformation testInformation, Exception exception) + public override Task ShouldRetry(TestContext context, Exception exception, int currentRetryCount) { if (exception is HttpRequestException requestException) { diff --git a/docs/docs/migration/mstest.md b/docs/docs/migration/mstest.md index 57d8fe1a93..28340faf5a 100644 --- a/docs/docs/migration/mstest.md +++ b/docs/docs/migration/mstest.md @@ -246,7 +246,7 @@ public class MyTests [Test] public async Task MyTest(TestContext context) { - await context.OutputWriter.WriteLineAsync("Test output"); + context.OutputWriter.WriteLine("Test output"); } [Before(HookType.Class)] diff --git a/docs/docs/migration/nunit.md b/docs/docs/migration/nunit.md index 7b8d6658b6..5ccbb16c2e 100644 --- a/docs/docs/migration/nunit.md +++ b/docs/docs/migration/nunit.md @@ -205,8 +205,8 @@ TestContext.Out.WriteLine("More output"); // TUnit (inject TestContext) public async Task MyTest(TestContext context) { - await context.OutputWriter.WriteLineAsync("Test output"); - await context.OutputWriter.WriteLineAsync("More output"); + context.OutputWriter.WriteLine("Test output"); + context.OutputWriter.WriteLine("More output"); } ``` diff --git a/docs/docs/test-authoring/skip.md b/docs/docs/test-authoring/skip.md index 35e648cd1b..58d9ef2aa1 100644 --- a/docs/docs/test-authoring/skip.md +++ b/docs/docs/test-authoring/skip.md @@ -26,7 +26,7 @@ As an example, this could be used to skip tests on certain operating systems. ```csharp public class WindowsOnlyAttribute() : SkipAttribute("This test is only supported on Windows") { - public override Task ShouldSkip(BeforeTestContext context) + public override Task ShouldSkip(TestRegisteredContext context) { return Task.FromResult(!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)); } diff --git a/docs/docs/test-lifecycle/cleanup.md b/docs/docs/test-lifecycle/cleanup.md index f2b50bcec1..234424dbc2 100644 --- a/docs/docs/test-lifecycle/cleanup.md +++ b/docs/docs/test-lifecycle/cleanup.md @@ -69,7 +69,7 @@ public class MyTestClass [After(Test)] public async Task AfterEachTest() { - await new HttpClient().GetAsync($"https://localhost/test-finished-notifier?testName={TestContext.Current.TestInformation.TestName}"); + await new HttpClient().GetAsync($"https://localhost/test-finished-notifier?testName={TestContext.Current.TestDetails.TestName}"); } [Test] diff --git a/docs/docs/test-lifecycle/dependency-injection.md b/docs/docs/test-lifecycle/dependency-injection.md index 0c65796482..f46516a924 100644 --- a/docs/docs/test-lifecycle/dependency-injection.md +++ b/docs/docs/test-lifecycle/dependency-injection.md @@ -19,7 +19,7 @@ public class MicrosoftDependencyInjectionDataSourceAttribute : DependencyInjecti public override IServiceScope CreateScope(DataGeneratorMetadata dataGeneratorMetadata) { - return ServiceProvider.CreateAsyncScope(); + return ServiceProvider.CreateScope(); } public override object? Create(IServiceScope scope, Type type) diff --git a/docs/docs/test-lifecycle/event-subscribing.md b/docs/docs/test-lifecycle/event-subscribing.md index 99d4fcc6db..7ba135e099 100644 --- a/docs/docs/test-lifecycle/event-subscribing.md +++ b/docs/docs/test-lifecycle/event-subscribing.md @@ -37,11 +37,12 @@ public class DependencyInjectionClassConstructor : IClassConstructor, ITestEndEv private readonly IServiceProvider _serviceProvider = CreateServiceProvider(); private AsyncServiceScope _scope; - public T Create<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] T>() where T : class + public Task Create([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] Type type, ClassConstructorMetadata classConstructorMetadata) { _scope = _serviceProvider.CreateAsyncScope(); - - return ActivatorUtilities.GetServiceOrCreateInstance(_scope.ServiceProvider); + + var instance = ActivatorUtilities.GetServiceOrCreateInstance(_scope.ServiceProvider, type); + return Task.FromResult(instance); } public ValueTask OnTestEnd(TestContext testContext) diff --git a/docs/docs/test-lifecycle/property-injection.md b/docs/docs/test-lifecycle/property-injection.md index 3e8a146243..2780cf3f08 100644 --- a/docs/docs/test-lifecycle/property-injection.md +++ b/docs/docs/test-lifecycle/property-injection.md @@ -18,14 +18,14 @@ The AOT system generates strongly-typed property setters at compile time, elimin ## Async Property Initialization -Properties can implement `IAsyncInitializable` for complex setup scenarios with automatic lifecycle management: +Properties can implement `IAsyncInitializer` for complex setup scenarios with automatic lifecycle management: ```csharp using TUnit.Core; namespace MyTestProject; -public class AsyncPropertyExample : IAsyncInitializable, IAsyncDisposable +public class AsyncPropertyExample : IAsyncInitializer, IAsyncDisposable { public bool IsInitialized { get; private set; } public string? ConnectionString { get; private set; } diff --git a/docs/sidebars.ts b/docs/sidebars.ts index 7133a2bd13..338710f0d8 100644 --- a/docs/sidebars.ts +++ b/docs/sidebars.ts @@ -22,6 +22,7 @@ const sidebars: SidebarsConfig = { 'getting-started/running-your-tests', 'getting-started/congratulations', 'faq', + 'troubleshooting', ], }, { @@ -50,7 +51,9 @@ const sidebars: SidebarsConfig = { 'test-authoring/depends-on', 'test-authoring/order', 'test-authoring/mocking', - 'test-authoring/culture' + 'test-authoring/culture', + 'test-authoring/aot-compatibility', + 'test-authoring/generic-attributes' ], }, { @@ -63,6 +66,8 @@ const sidebars: SidebarsConfig = { 'assertions/scopes', 'assertions/assertion-groups', 'assertions/delegates', + 'assertions/member-assertions', + 'assertions/type-checking', { type: 'category', label: 'Extensibility', @@ -145,6 +150,7 @@ const sidebars: SidebarsConfig = { 'comparison/framework-differences', 'comparison/attributes', 'reference/test-configuration', + 'reference/command-line-flags', ], }, { @@ -162,6 +168,8 @@ const sidebars: SidebarsConfig = { label: 'Migration Guides', items: [ 'migration/xunit', + 'migration/nunit', + 'migration/mstest', ], }, { From 0a314cbec1c51bb4c4ebf60c2c02169f63d8f93a Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Tue, 28 Oct 2025 15:18:10 +0000 Subject: [PATCH 38/67] Add more assertions documentation (#3557) * Add more assertions documentation * Refactor sidebar structure to include additional assertion categories and improve organization --- docs/docs/assertions/boolean.md | 463 ++++++++++ docs/docs/assertions/collections.md | 788 ++++++++++++++++++ docs/docs/assertions/datetime.md | 591 +++++++++++++ docs/docs/assertions/dictionaries.md | 547 ++++++++++++ .../assertions/equality-and-comparison.md | 500 +++++++++++ docs/docs/assertions/exceptions.md | 625 ++++++++++++++ docs/docs/assertions/getting-started.md | 277 ++++++ docs/docs/assertions/null-and-default.md | 500 +++++++++++ docs/docs/assertions/numeric.md | 583 +++++++++++++ docs/docs/assertions/specialized-types.md | 765 +++++++++++++++++ docs/docs/assertions/string.md | 766 +++++++++++++++++ docs/docs/assertions/tasks-and-async.md | 537 ++++++++++++ docs/docs/assertions/types.md | 680 +++++++++++++++ docs/sidebars.ts | 19 +- 14 files changed, 7637 insertions(+), 4 deletions(-) create mode 100644 docs/docs/assertions/boolean.md create mode 100644 docs/docs/assertions/collections.md create mode 100644 docs/docs/assertions/datetime.md create mode 100644 docs/docs/assertions/dictionaries.md create mode 100644 docs/docs/assertions/equality-and-comparison.md create mode 100644 docs/docs/assertions/exceptions.md create mode 100644 docs/docs/assertions/getting-started.md create mode 100644 docs/docs/assertions/null-and-default.md create mode 100644 docs/docs/assertions/numeric.md create mode 100644 docs/docs/assertions/specialized-types.md create mode 100644 docs/docs/assertions/string.md create mode 100644 docs/docs/assertions/tasks-and-async.md create mode 100644 docs/docs/assertions/types.md diff --git a/docs/docs/assertions/boolean.md b/docs/docs/assertions/boolean.md new file mode 100644 index 0000000000..6773c02b2a --- /dev/null +++ b/docs/docs/assertions/boolean.md @@ -0,0 +1,463 @@ +--- +sidebar_position: 3.5 +--- + +# Boolean Assertions + +TUnit provides simple, expressive assertions for testing boolean values. These assertions work with both `bool` and `bool?` (nullable boolean) types. + +## Basic Boolean Assertions + +### IsTrue + +Tests that a boolean value is `true`: + +```csharp +[Test] +public async Task Value_Is_True() +{ + var isValid = ValidateInput("test@example.com"); + await Assert.That(isValid).IsTrue(); + + var hasPermission = user.HasPermission("write"); + await Assert.That(hasPermission).IsTrue(); +} +``` + +### IsFalse + +Tests that a boolean value is `false`: + +```csharp +[Test] +public async Task Value_Is_False() +{ + var isExpired = CheckIfExpired(futureDate); + await Assert.That(isExpired).IsFalse(); + + var isEmpty = list.Count == 0; + await Assert.That(isEmpty).IsFalse(); +} +``` + +## Alternative: Using IsEqualTo + +You can also use `IsEqualTo()` for boolean comparisons: + +```csharp +[Test] +public async Task Using_IsEqualTo() +{ + var result = PerformCheck(); + + await Assert.That(result).IsEqualTo(true); + // Same as: await Assert.That(result).IsTrue(); + + await Assert.That(result).IsEqualTo(false); + // Same as: await Assert.That(result).IsFalse(); +} +``` + +However, `IsTrue()` and `IsFalse()` are more expressive and recommended for boolean values. + +## Nullable Booleans + +Both assertions work with nullable booleans (`bool?`): + +```csharp +[Test] +public async Task Nullable_Boolean_True() +{ + bool? result = GetOptionalFlag(); + + await Assert.That(result).IsTrue(); + // This asserts both: + // 1. result is not null + // 2. result.Value is true +} + +[Test] +public async Task Nullable_Boolean_False() +{ + bool? result = GetOptionalFlag(); + + await Assert.That(result).IsFalse(); + // This asserts both: + // 1. result is not null + // 2. result.Value is false +} +``` + +### Null Nullable Booleans + +If a nullable boolean is `null`, both `IsTrue()` and `IsFalse()` will fail: + +```csharp +[Test] +public async Task Nullable_Boolean_Null() +{ + bool? result = null; + + // These will both fail: + // await Assert.That(result).IsTrue(); // ❌ Fails - null is not true + // await Assert.That(result).IsFalse(); // ❌ Fails - null is not false + + // Check for null first: + await Assert.That(result).IsNull(); +} +``` + +## Chaining Boolean Assertions + +Boolean assertions can be chained with other assertions: + +```csharp +[Test] +public async Task Chained_With_Other_Assertions() +{ + bool? flag = GetFlag(); + + await Assert.That(flag) + .IsNotNull() + .And.IsTrue(); +} +``` + +## Practical Examples + +### Validation Results + +```csharp +[Test] +public async Task Email_Validation() +{ + var isValid = EmailValidator.Validate("test@example.com"); + await Assert.That(isValid).IsTrue(); + + var isInvalid = EmailValidator.Validate("not-an-email"); + await Assert.That(isInvalid).IsFalse(); +} +``` + +### Permission Checks + +```csharp +[Test] +public async Task User_Permissions() +{ + var user = await GetUserAsync("alice"); + + await Assert.That(user.CanRead).IsTrue(); + await Assert.That(user.CanWrite).IsTrue(); + await Assert.That(user.CanDelete).IsFalse(); +} +``` + +### State Flags + +```csharp +[Test] +public async Task Service_State() +{ + var service = new BackgroundService(); + + await Assert.That(service.IsRunning).IsFalse(); + + await service.StartAsync(); + + await Assert.That(service.IsRunning).IsTrue(); +} +``` + +### Feature Flags + +```csharp +[Test] +public async Task Feature_Toggles() +{ + var config = LoadConfiguration(); + + await Assert.That(config.EnableNewFeature).IsTrue(); + await Assert.That(config.EnableBetaFeature).IsFalse(); +} +``` + +## Testing Conditional Logic + +### Logical AND + +```csharp +[Test] +public async Task Logical_AND() +{ + var isAdult = age >= 18; + var hasLicense = CheckLicense(userId); + var canDrive = isAdult && hasLicense; + + await Assert.That(canDrive).IsTrue(); +} +``` + +### Logical OR + +```csharp +[Test] +public async Task Logical_OR() +{ + var isWeekend = dayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday; + var isHoliday = CheckIfHoliday(date); + var isDayOff = isWeekend || isHoliday; + + await Assert.That(isDayOff).IsTrue(); +} +``` + +### Logical NOT + +```csharp +[Test] +public async Task Logical_NOT() +{ + var isExpired = CheckExpiration(token); + var isValid = !isExpired; + + await Assert.That(isValid).IsTrue(); +} +``` + +## Complex Boolean Expressions + +```csharp +[Test] +public async Task Complex_Expression() +{ + var user = GetUser(); + var canAccess = user.IsActive && + !user.IsBanned && + (user.IsPremium || user.HasFreeTrial); + + await Assert.That(canAccess).IsTrue(); +} +``` + +You can also break this down for clarity: + +```csharp +[Test] +public async Task Complex_Expression_Broken_Down() +{ + var user = GetUser(); + + await using (Assert.Multiple()) + { + await Assert.That(user.IsActive).IsTrue(); + await Assert.That(user.IsBanned).IsFalse(); + await Assert.That(user.IsPremium || user.HasFreeTrial).IsTrue(); + } +} +``` + +## Comparison with Other Values + +When testing boolean results of comparisons, you can often simplify: + +```csharp +[Test] +public async Task Comparison_Simplified() +{ + var count = GetCount(); + + // Less clear: + await Assert.That(count > 0).IsTrue(); + + // More clear and expressive: + await Assert.That(count).IsGreaterThan(0); +} +``` + +Similarly for equality: + +```csharp +[Test] +public async Task Equality_Simplified() +{ + var name = GetName(); + + // Less clear: + await Assert.That(name == "Alice").IsTrue(); + + // More clear: + await Assert.That(name).IsEqualTo("Alice"); +} +``` + +Use boolean assertions for actual boolean values and flags, not for comparisons. + +## Testing LINQ Queries + +```csharp +[Test] +public async Task LINQ_Any() +{ + var numbers = new[] { 1, 2, 3, 4, 5 }; + + var hasEven = numbers.Any(n => n % 2 == 0); + await Assert.That(hasEven).IsTrue(); + + var hasNegative = numbers.Any(n => n < 0); + await Assert.That(hasNegative).IsFalse(); +} + +[Test] +public async Task LINQ_All() +{ + var numbers = new[] { 2, 4, 6, 8 }; + + var allEven = numbers.All(n => n % 2 == 0); + await Assert.That(allEven).IsTrue(); + + var allPositive = numbers.All(n => n > 0); + await Assert.That(allPositive).IsTrue(); +} +``` + +Note: TUnit provides specialized collection assertions for these patterns: + +```csharp +[Test] +public async Task Using_Collection_Assertions() +{ + var numbers = new[] { 2, 4, 6, 8 }; + + // Instead of .All(n => n % 2 == 0): + await Assert.That(numbers).All(n => n % 2 == 0); + + // Instead of .Any(n => n > 5): + await Assert.That(numbers).Any(n => n > 5); +} +``` + +## String Boolean Methods + +Many string methods return booleans: + +```csharp +[Test] +public async Task String_Boolean_Methods() +{ + var text = "Hello World"; + + await Assert.That(text.StartsWith("Hello")).IsTrue(); + await Assert.That(text.EndsWith("World")).IsTrue(); + await Assert.That(text.Contains("lo Wo")).IsTrue(); + await Assert.That(string.IsNullOrEmpty(text)).IsFalse(); +} +``` + +But TUnit has more expressive string assertions: + +```csharp +[Test] +public async Task Using_String_Assertions() +{ + var text = "Hello World"; + + // More expressive: + await Assert.That(text).StartsWith("Hello"); + await Assert.That(text).EndsWith("World"); + await Assert.That(text).Contains("lo Wo"); + await Assert.That(text).IsNotEmpty(); +} +``` + +## Type Checking Booleans + +```csharp +[Test] +public async Task Type_Checking() +{ + var obj = GetObject(); + + await Assert.That(obj is string).IsTrue(); + await Assert.That(obj is not null).IsTrue(); +} +``` + +Or use type assertions: + +```csharp +[Test] +public async Task Using_Type_Assertions() +{ + var obj = GetObject(); + + await Assert.That(obj).IsTypeOf(); + await Assert.That(obj).IsNotNull(); +} +``` + +## Common Patterns + +### Toggle Testing + +```csharp +[Test] +public async Task Toggle_State() +{ + var toggle = new Toggle(); + + await Assert.That(toggle.IsOn).IsFalse(); + + toggle.TurnOn(); + await Assert.That(toggle.IsOn).IsTrue(); + + toggle.TurnOff(); + await Assert.That(toggle.IsOn).IsFalse(); +} +``` + +### Authentication State + +```csharp +[Test] +public async Task Authentication_State() +{ + var authService = new AuthenticationService(); + + await Assert.That(authService.IsAuthenticated).IsFalse(); + + await authService.LoginAsync("user", "password"); + + await Assert.That(authService.IsAuthenticated).IsTrue(); +} +``` + +### Validation Scenarios + +```csharp +[Test] +public async Task Multiple_Validations() +{ + var form = new RegistrationForm + { + Email = "test@example.com", + Password = "SecurePass123!", + Age = 25 + }; + + await using (Assert.Multiple()) + { + await Assert.That(form.IsEmailValid()).IsTrue(); + await Assert.That(form.IsPasswordStrong()).IsTrue(); + await Assert.That(form.IsAgeValid()).IsTrue(); + await Assert.That(form.IsComplete()).IsTrue(); + } +} +``` + +## See Also + +- [Equality & Comparison](equality-and-comparison.md) - General equality testing +- [Null & Default](null-and-default.md) - Testing for null values +- [Collections](collections.md) - Collection-specific boolean tests (All, Any) +- [Type Assertions](types.md) - Type checking instead of `is` checks diff --git a/docs/docs/assertions/collections.md b/docs/docs/assertions/collections.md new file mode 100644 index 0000000000..c11eec5f33 --- /dev/null +++ b/docs/docs/assertions/collections.md @@ -0,0 +1,788 @@ +--- +sidebar_position: 6.5 +--- + +# Collection Assertions + +TUnit provides comprehensive assertions for testing collections, including membership, count, ordering, and equivalency checks. These assertions work with any `IEnumerable`. + +## Membership Assertions + +### Contains (Item) + +Tests that a collection contains a specific item: + +```csharp +[Test] +public async Task Collection_Contains_Item() +{ + var numbers = new[] { 1, 2, 3, 4, 5 }; + + await Assert.That(numbers).Contains(3); + await Assert.That(numbers).Contains(1); +} +``` + +Works with any collection type: + +```csharp +[Test] +public async Task Various_Collection_Types() +{ + var list = new List { "apple", "banana", "cherry" }; + await Assert.That(list).Contains("banana"); + + var hashSet = new HashSet { 10, 20, 30 }; + await Assert.That(hashSet).Contains(20); + + var queue = new Queue(new[] { "first", "second" }); + await Assert.That(queue).Contains("first"); +} +``` + +### Contains (Predicate) + +Tests that a collection contains an item matching a predicate, and returns that item: + +```csharp +[Test] +public async Task Collection_Contains_Matching_Item() +{ + var users = new[] + { + new User { Name = "Alice", Age = 30 }, + new User { Name = "Bob", Age = 25 } + }; + + // Returns the found item + var user = await Assert.That(users).Contains(u => u.Name == "Alice"); + + // Can assert on the returned item + await Assert.That(user.Age).IsEqualTo(30); +} +``` + +### DoesNotContain (Item) + +Tests that a collection does not contain a specific item: + +```csharp +[Test] +public async Task Collection_Does_Not_Contain() +{ + var numbers = new[] { 1, 2, 3, 4, 5 }; + + await Assert.That(numbers).DoesNotContain(10); + await Assert.That(numbers).DoesNotContain(0); +} +``` + +### DoesNotContain (Predicate) + +Tests that no items match the predicate: + +```csharp +[Test] +public async Task Collection_Does_Not_Contain_Matching() +{ + var users = new[] + { + new User { Name = "Alice", Age = 30 }, + new User { Name = "Bob", Age = 25 } + }; + + await Assert.That(users).DoesNotContain(u => u.Age > 50); + await Assert.That(users).DoesNotContain(u => u.Name == "Charlie"); +} +``` + +## Count Assertions + +### HasCount + +Tests that a collection has an exact count: + +```csharp +[Test] +public async Task Collection_Has_Count() +{ + var numbers = new[] { 1, 2, 3, 4, 5 }; + + await Assert.That(numbers).HasCount(5); +} +``` + +### Count with Comparison + +Get the count for further assertions: + +```csharp +[Test] +public async Task Count_With_Comparison() +{ + var numbers = new[] { 1, 2, 3, 4, 5 }; + + await Assert.That(numbers) + .HasCount().EqualTo(5); + + await Assert.That(numbers) + .HasCount().GreaterThan(3) + .And.HasCount().LessThan(10); +} +``` + +### Count with Predicate + +Count items matching a predicate: + +```csharp +[Test] +public async Task Count_With_Predicate() +{ + var numbers = new[] { 1, 2, 3, 4, 5, 6 }; + + // Count even numbers + var evenCount = await Assert.That(numbers) + .Count(n => n % 2 == 0); + + await Assert.That(evenCount).IsEqualTo(3); +} +``` + +### IsEmpty + +Tests that a collection has no items: + +```csharp +[Test] +public async Task Collection_Is_Empty() +{ + var empty = new List(); + + await Assert.That(empty).IsEmpty(); + await Assert.That(empty).HasCount(0); +} +``` + +### IsNotEmpty + +Tests that a collection has at least one item: + +```csharp +[Test] +public async Task Collection_Is_Not_Empty() +{ + var numbers = new[] { 1 }; + + await Assert.That(numbers).IsNotEmpty(); +} +``` + +### HasSingleItem + +Tests that a collection has exactly one item, and returns that item: + +```csharp +[Test] +public async Task Collection_Has_Single_Item() +{ + var users = new[] { new User { Name = "Alice", Age = 30 } }; + + var user = await Assert.That(users).HasSingleItem(); + + await Assert.That(user.Name).IsEqualTo("Alice"); +} +``` + +## Ordering Assertions + +### IsInOrder + +Tests that a collection is sorted in ascending order: + +```csharp +[Test] +public async Task Collection_In_Ascending_Order() +{ + var numbers = new[] { 1, 2, 3, 4, 5 }; + + await Assert.That(numbers).IsInOrder(); +} +``` + +```csharp +[Test] +public async Task Strings_In_Order() +{ + var names = new[] { "Alice", "Bob", "Charlie" }; + + await Assert.That(names).IsInOrder(); +} +``` + +### IsInDescendingOrder + +Tests that a collection is sorted in descending order: + +```csharp +[Test] +public async Task Collection_In_Descending_Order() +{ + var numbers = new[] { 5, 4, 3, 2, 1 }; + + await Assert.That(numbers).IsInDescendingOrder(); +} +``` + +### IsOrderedBy + +Tests that a collection is ordered by a specific property: + +```csharp +[Test] +public async Task Ordered_By_Property() +{ + var users = new[] + { + new User { Name = "Alice", Age = 25 }, + new User { Name = "Bob", Age = 30 }, + new User { Name = "Charlie", Age = 35 } + }; + + await Assert.That(users).IsOrderedBy(u => u.Age); +} +``` + +### IsOrderedByDescending + +Tests that a collection is ordered by a property in descending order: + +```csharp +[Test] +public async Task Ordered_By_Descending() +{ + var users = new[] + { + new User { Name = "Charlie", Age = 35 }, + new User { Name = "Bob", Age = 30 }, + new User { Name = "Alice", Age = 25 } + }; + + await Assert.That(users).IsOrderedByDescending(u => u.Age); +} +``` + +## Predicate-Based Assertions + +### All + +Tests that all items satisfy a condition: + +```csharp +[Test] +public async Task All_Items_Match() +{ + var numbers = new[] { 2, 4, 6, 8 }; + + await Assert.That(numbers).All(n => n % 2 == 0); +} +``` + +#### With Satisfy + +Chain additional assertions on all items: + +```csharp +[Test] +public async Task All_Satisfy() +{ + var users = new[] + { + new User { Name = "Alice", Age = 25 }, + new User { Name = "Bob", Age = 30 } + }; + + await Assert.That(users) + .All() + .Satisfy(u => Assert.That(u.Age).IsGreaterThan(18)); +} +``` + +#### With Mapper + +Map items before asserting: + +```csharp +[Test] +public async Task All_Satisfy_With_Mapper() +{ + var users = new[] + { + new User { Name = "Alice", Age = 25 }, + new User { Name = "Bob", Age = 30 } + }; + + await Assert.That(users) + .All() + .Satisfy( + u => u.Name, + name => Assert.That(name).IsNotEmpty() + ); +} +``` + +### Any + +Tests that at least one item satisfies a condition: + +```csharp +[Test] +public async Task Any_Item_Matches() +{ + var numbers = new[] { 1, 3, 5, 6, 7 }; + + await Assert.That(numbers).Any(n => n % 2 == 0); +} +``` + +## Equivalency Assertions + +### IsEquivalentTo + +Tests that two collections contain the same items, regardless of order: + +```csharp +[Test] +public async Task Collections_Are_Equivalent() +{ + var actual = new[] { 1, 2, 3, 4, 5 }; + var expected = new[] { 5, 4, 3, 2, 1 }; + + await Assert.That(actual).IsEquivalentTo(expected); +} +``` + +Different collection types: + +```csharp +[Test] +public async Task Different_Collection_Types() +{ + var list = new List { 1, 2, 3 }; + var array = new[] { 3, 2, 1 }; + + await Assert.That(list).IsEquivalentTo(array); +} +``` + +#### With Custom Comparer + +```csharp +[Test] +public async Task Equivalent_With_Comparer() +{ + var actual = new[] { "apple", "banana", "cherry" }; + var expected = new[] { "APPLE", "BANANA", "CHERRY" }; + + await Assert.That(actual) + .IsEquivalentTo(expected) + .Using(StringComparer.OrdinalIgnoreCase); +} +``` + +#### With Custom Equality Predicate + +```csharp +[Test] +public async Task Equivalent_With_Predicate() +{ + var users1 = new[] + { + new User { Name = "Alice", Age = 30 }, + new User { Name = "Bob", Age = 25 } + }; + + var users2 = new[] + { + new User { Name = "Bob", Age = 25 }, + new User { Name = "Alice", Age = 30 } + }; + + await Assert.That(users1) + .IsEquivalentTo(users2) + .Using((u1, u2) => u1.Name == u2.Name && u1.Age == u2.Age); +} +``` + +#### Explicitly Ignoring Order + +```csharp +[Test] +public async Task Equivalent_Ignoring_Order() +{ + var actual = new[] { 1, 2, 3 }; + var expected = new[] { 3, 2, 1 }; + + await Assert.That(actual) + .IsEquivalentTo(expected) + .IgnoringOrder(); +} +``` + +### IsNotEquivalentTo + +Tests that collections are not equivalent: + +```csharp +[Test] +public async Task Collections_Not_Equivalent() +{ + var actual = new[] { 1, 2, 3 }; + var different = new[] { 4, 5, 6 }; + + await Assert.That(actual).IsNotEquivalentTo(different); +} +``` + +## Structural Equivalency + +### IsStructurallyEqualTo + +Deep comparison of collections including nested objects: + +```csharp +[Test] +public async Task Structurally_Equal() +{ + var actual = new[] + { + new { Name = "Alice", Address = new { City = "Seattle" } }, + new { Name = "Bob", Address = new { City = "Portland" } } + }; + + var expected = new[] + { + new { Name = "Alice", Address = new { City = "Seattle" } }, + new { Name = "Bob", Address = new { City = "Portland" } } + }; + + await Assert.That(actual).IsStructurallyEqualTo(expected); +} +``` + +### IsNotStructurallyEqualTo + +```csharp +[Test] +public async Task Not_Structurally_Equal() +{ + var actual = new[] + { + new { Name = "Alice", Age = 30 } + }; + + var different = new[] + { + new { Name = "Alice", Age = 31 } + }; + + await Assert.That(actual).IsNotStructurallyEqualTo(different); +} +``` + +## Distinctness + +### HasDistinctItems + +Tests that all items in a collection are unique: + +```csharp +[Test] +public async Task All_Items_Distinct() +{ + var numbers = new[] { 1, 2, 3, 4, 5 }; + + await Assert.That(numbers).HasDistinctItems(); +} +``` + +Fails if duplicates exist: + +```csharp +[Test] +public async Task Duplicates_Fail() +{ + var numbers = new[] { 1, 2, 2, 3 }; + + // This will fail + // await Assert.That(numbers).HasDistinctItems(); +} +``` + +## Practical Examples + +### Filtering Results + +```csharp +[Test] +public async Task Filter_And_Assert() +{ + var numbers = new[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }; + var evens = numbers.Where(n => n % 2 == 0).ToArray(); + + await Assert.That(evens) + .HasCount(5) + .And.All(n => n % 2 == 0); +} +``` + +### LINQ Query Results + +```csharp +[Test] +public async Task LINQ_Query_Results() +{ + var users = new[] + { + new User { Name = "Alice", Age = 25 }, + new User { Name = "Bob", Age = 30 }, + new User { Name = "Charlie", Age = 35 } + }; + + var adults = users.Where(u => u.Age >= 18).ToArray(); + + await Assert.That(adults) + .HasCount(3) + .And.All(u => u.Age >= 18); +} +``` + +### Sorting Validation + +```csharp +[Test] +public async Task Verify_Sorting() +{ + var unsorted = new[] { 5, 2, 8, 1, 9 }; + var sorted = unsorted.OrderBy(x => x).ToArray(); + + await Assert.That(sorted).IsInOrder(); + await Assert.That(sorted).IsEquivalentTo(unsorted); +} +``` + +### API Response Validation + +```csharp +[Test] +public async Task API_Returns_Expected_Items() +{ + var response = await _api.GetUsersAsync(); + + await Assert.That(response) + .IsNotEmpty() + .And.All(u => u.Id > 0) + .And.All(u => !string.IsNullOrEmpty(u.Name)); +} +``` + +### Collection Transformation + +```csharp +[Test] +public async Task Map_And_Verify() +{ + var users = new[] + { + new User { Name = "Alice", Age = 25 }, + new User { Name = "Bob", Age = 30 } + }; + + var names = users.Select(u => u.Name).ToArray(); + + await Assert.That(names) + .HasCount(2) + .And.Contains("Alice") + .And.Contains("Bob") + .And.All(name => !string.IsNullOrEmpty(name)); +} +``` + +## Empty vs Null Collections + +```csharp +[Test] +public async Task Empty_vs_Null() +{ + List? nullList = null; + List emptyList = new(); + List populated = new() { 1, 2, 3 }; + + await Assert.That(nullList).IsNull(); + await Assert.That(emptyList).IsNotNull(); + await Assert.That(emptyList).IsEmpty(); + await Assert.That(populated).IsNotEmpty(); +} +``` + +## Nested Collections + +```csharp +[Test] +public async Task Nested_Collections() +{ + var matrix = new[] + { + new[] { 1, 2, 3 }, + new[] { 4, 5, 6 }, + new[] { 7, 8, 9 } + }; + + await Assert.That(matrix).HasCount(3); + await Assert.That(matrix).All(row => row.Length == 3); + + // Flatten and assert + var flattened = matrix.SelectMany(x => x).ToArray(); + await Assert.That(flattened).HasCount(9); +} +``` + +## Collection of Collections + +```csharp +[Test] +public async Task Collection_Of_Collections() +{ + var groups = new List> + { + new() { 1, 2 }, + new() { 3, 4, 5 }, + new() { 6 } + }; + + await Assert.That(groups) + .HasCount(3) + .And.All(group => group.Count > 0); +} +``` + +## Chaining Collection Assertions + +```csharp +[Test] +public async Task Chained_Collection_Assertions() +{ + var numbers = new[] { 1, 2, 3, 4, 5 }; + + await Assert.That(numbers) + .IsNotEmpty() + .And.HasCount(5) + .And.Contains(3) + .And.DoesNotContain(10) + .And.IsInOrder() + .And.All(n => n > 0) + .And.Any(n => n == 5); +} +``` + +## Performance Considerations + +### Materialize IEnumerable + +```csharp +[Test] +public async Task Materialize_Before_Multiple_Assertions() +{ + // This query is deferred + IEnumerable query = Enumerable.Range(1, 1000000) + .Where(n => n % 2 == 0); + + // Materialize once to avoid re-execution + var materialized = query.ToArray(); + + await Assert.That(materialized).HasCount().GreaterThan(1000); + await Assert.That(materialized).Contains(100); + await Assert.That(materialized).All(n => n % 2 == 0); +} +``` + +## Working with HashSet and SortedSet + +```csharp +[Test] +public async Task HashSet_Assertions() +{ + var set = new HashSet { 1, 2, 3, 4, 5 }; + + await Assert.That(set) + .HasCount(5) + .And.Contains(3) + .And.HasDistinctItems(); +} + +[Test] +public async Task SortedSet_Assertions() +{ + var sorted = new SortedSet { 5, 2, 8, 1, 9 }; + + await Assert.That(sorted) + .IsInOrder() + .And.HasDistinctItems(); +} +``` + +## Common Patterns + +### Validate All Items + +```csharp +[Test] +public async Task Validate_Each_Item() +{ + var users = GetUsers(); + + await using (Assert.Multiple()) + { + foreach (var user in users) + { + await Assert.That(user.Name).IsNotEmpty(); + await Assert.That(user.Age).IsGreaterThan(0); + } + } +} +``` + +Or more elegantly: + +```csharp +[Test] +public async Task Validate_All_With_Assertion() +{ + var users = GetUsers(); + + await Assert.That(users).All(u => + !string.IsNullOrEmpty(u.Name) && u.Age > 0 + ); +} +``` + +### Find and Assert + +```csharp +[Test] +public async Task Find_And_Assert() +{ + var users = GetUsers(); + + var admin = await Assert.That(users) + .Contains(u => u.Role == "Admin"); + + await Assert.That(admin.Permissions).IsNotEmpty(); +} +``` + +## See Also + +- [Dictionaries](dictionaries.md) - Dictionary-specific assertions +- [Strings](string.md) - String collections +- [Equality & Comparison](equality-and-comparison.md) - Item comparison diff --git a/docs/docs/assertions/datetime.md b/docs/docs/assertions/datetime.md new file mode 100644 index 0000000000..accb815d46 --- /dev/null +++ b/docs/docs/assertions/datetime.md @@ -0,0 +1,591 @@ +--- +sidebar_position: 7.5 +--- + +# DateTime and Time Assertions + +TUnit provides comprehensive assertions for date and time types, including `DateTime`, `DateTimeOffset`, `DateOnly`, `TimeOnly`, and `TimeSpan`, with support for tolerance-based comparisons and specialized checks. + +## DateTime Equality with Tolerance + +DateTime comparisons often need tolerance to account for timing variations: + +```csharp +[Test] +public async Task DateTime_With_Tolerance() +{ + var now = DateTime.Now; + var almostNow = now.AddMilliseconds(50); + + // Without tolerance - might fail + // await Assert.That(almostNow).IsEqualTo(now); + + // With tolerance - passes + await Assert.That(almostNow).IsEqualTo(now, tolerance: TimeSpan.FromSeconds(1)); +} +``` + +### Tolerance Examples + +```csharp +[Test] +public async Task Various_Tolerance_Values() +{ + var baseTime = new DateTime(2024, 1, 15, 10, 30, 0); + + // Millisecond tolerance + var time1 = baseTime.AddMilliseconds(100); + await Assert.That(time1).IsEqualTo(baseTime, tolerance: TimeSpan.FromMilliseconds(500)); + + // Second tolerance + var time2 = baseTime.AddSeconds(5); + await Assert.That(time2).IsEqualTo(baseTime, tolerance: TimeSpan.FromSeconds(10)); + + // Minute tolerance + var time3 = baseTime.AddMinutes(2); + await Assert.That(time3).IsEqualTo(baseTime, tolerance: TimeSpan.FromMinutes(5)); +} +``` + +## DateTime Comparison + +Standard comparison operators work with DateTime: + +```csharp +[Test] +public async Task DateTime_Comparison() +{ + var past = DateTime.Now.AddDays(-1); + var now = DateTime.Now; + var future = DateTime.Now.AddDays(1); + + await Assert.That(now).IsGreaterThan(past); + await Assert.That(now).IsLessThan(future); + await Assert.That(past).IsLessThan(future); +} +``` + +## DateTime-Specific Assertions + +### IsToday / IsNotToday + +```csharp +[Test] +public async Task DateTime_Is_Today() +{ + var today = DateTime.Now; + await Assert.That(today).IsToday(); + + var yesterday = DateTime.Now.AddDays(-1); + await Assert.That(yesterday).IsNotToday(); + + var tomorrow = DateTime.Now.AddDays(1); + await Assert.That(tomorrow).IsNotToday(); +} +``` + +### IsUtc / IsNotUtc + +```csharp +[Test] +public async Task DateTime_Kind() +{ + var utc = DateTime.UtcNow; + await Assert.That(utc).IsUtc(); + + var local = DateTime.Now; + await Assert.That(local).IsNotUtc(); + + var unspecified = new DateTime(2024, 1, 15); + await Assert.That(unspecified).IsNotUtc(); +} +``` + +### IsLeapYear / IsNotLeapYear + +```csharp +[Test] +public async Task Leap_Year_Check() +{ + var leapYear = new DateTime(2024, 1, 1); + await Assert.That(leapYear).IsLeapYear(); + + var nonLeapYear = new DateTime(2023, 1, 1); + await Assert.That(nonLeapYear).IsNotLeapYear(); +} +``` + +### IsInFuture / IsInPast + +Compares against local time: + +```csharp +[Test] +public async Task Future_and_Past() +{ + var future = DateTime.Now.AddHours(1); + await Assert.That(future).IsInFuture(); + + var past = DateTime.Now.AddHours(-1); + await Assert.That(past).IsInPast(); +} +``` + +### IsInFutureUtc / IsInPastUtc + +Compares against UTC time: + +```csharp +[Test] +public async Task Future_and_Past_UTC() +{ + var futureUtc = DateTime.UtcNow.AddHours(1); + await Assert.That(futureUtc).IsInFutureUtc(); + + var pastUtc = DateTime.UtcNow.AddHours(-1); + await Assert.That(pastUtc).IsInPastUtc(); +} +``` + +### IsOnWeekend / IsOnWeekday + +```csharp +[Test] +public async Task Weekend_Check() +{ + var saturday = new DateTime(2024, 1, 6); // Saturday + await Assert.That(saturday).IsOnWeekend(); + + var monday = new DateTime(2024, 1, 8); // Monday + await Assert.That(monday).IsOnWeekday(); + await Assert.That(monday).IsNotOnWeekend(); +} +``` + +### IsDaylightSavingTime / IsNotDaylightSavingTime + +```csharp +[Test] +public async Task Daylight_Saving_Time() +{ + var summer = new DateTime(2024, 7, 1); // Summer in Northern Hemisphere + var winter = new DateTime(2024, 1, 1); // Winter + + // Results depend on timezone + if (TimeZoneInfo.Local.IsDaylightSavingTime(summer)) + { + await Assert.That(summer).IsDaylightSavingTime(); + } +} +``` + +## DateTimeOffset + +DateTimeOffset includes timezone information: + +```csharp +[Test] +public async Task DateTimeOffset_With_Tolerance() +{ + var now = DateTimeOffset.Now; + var almostNow = now.AddSeconds(1); + + await Assert.That(almostNow).IsEqualTo(now, tolerance: TimeSpan.FromSeconds(5)); +} +``` + +```csharp +[Test] +public async Task DateTimeOffset_Comparison() +{ + var earlier = new DateTimeOffset(2024, 1, 1, 12, 0, 0, TimeSpan.FromHours(-8)); + var later = new DateTimeOffset(2024, 1, 1, 12, 0, 0, TimeSpan.FromHours(0)); + + // Same local time, but different UTC times + await Assert.That(later).IsGreaterThan(earlier); +} +``` + +## DateOnly (.NET 6+) + +DateOnly represents just a date without time: + +```csharp +[Test] +public async Task DateOnly_Assertions() +{ + var date1 = new DateOnly(2024, 1, 15); + var date2 = new DateOnly(2024, 1, 15); + + await Assert.That(date1).IsEqualTo(date2); +} +``` + +### DateOnly with Days Tolerance + +```csharp +[Test] +public async Task DateOnly_With_Tolerance() +{ + var date1 = new DateOnly(2024, 1, 15); + var date2 = new DateOnly(2024, 1, 17); + + await Assert.That(date2).IsEqualTo(date1, daysTolerance: 5); +} +``` + +### DateOnly Comparison + +```csharp +[Test] +public async Task DateOnly_Comparison() +{ + var earlier = new DateOnly(2024, 1, 1); + var later = new DateOnly(2024, 12, 31); + + await Assert.That(later).IsGreaterThan(earlier); + await Assert.That(earlier).IsLessThan(later); +} +``` + +## TimeOnly (.NET 6+) + +TimeOnly represents just time without a date: + +```csharp +[Test] +public async Task TimeOnly_Assertions() +{ + var morning = new TimeOnly(9, 30, 0); + var evening = new TimeOnly(17, 45, 0); + + await Assert.That(evening).IsGreaterThan(morning); +} +``` + +### TimeOnly with Tolerance + +```csharp +[Test] +public async Task TimeOnly_With_Tolerance() +{ + var time1 = new TimeOnly(10, 30, 0); + var time2 = new TimeOnly(10, 30, 5); + + await Assert.That(time2).IsEqualTo(time1, tolerance: TimeSpan.FromSeconds(10)); +} +``` + +## TimeSpan + +TimeSpan represents a duration: + +```csharp +[Test] +public async Task TimeSpan_Assertions() +{ + var duration1 = TimeSpan.FromMinutes(30); + var duration2 = TimeSpan.FromMinutes(30); + + await Assert.That(duration1).IsEqualTo(duration2); +} +``` + +### TimeSpan Comparison + +```csharp +[Test] +public async Task TimeSpan_Comparison() +{ + var short_duration = TimeSpan.FromMinutes(5); + var long_duration = TimeSpan.FromHours(1); + + await Assert.That(long_duration).IsGreaterThan(short_duration); + await Assert.That(short_duration).IsLessThan(long_duration); +} +``` + +### TimeSpan Sign Checks + +```csharp +[Test] +public async Task TimeSpan_Sign() +{ + var positive = TimeSpan.FromHours(1); + await Assert.That(positive).IsPositive(); + + var negative = TimeSpan.FromHours(-1); + await Assert.That(negative).IsNegative(); +} +``` + +## Practical Examples + +### Expiration Checks + +```csharp +[Test] +public async Task Check_Token_Expiration() +{ + var token = CreateToken(); + var expiresAt = token.ExpiresAt; + + await Assert.That(expiresAt).IsInFuture(); + + // Or check if expired + var expiredToken = CreateExpiredToken(); + await Assert.That(expiredToken.ExpiresAt).IsInPast(); +} +``` + +### Age Calculation + +```csharp +[Test] +public async Task Calculate_Age() +{ + var birthDate = new DateTime(1990, 1, 1); + var age = DateTime.Now.Year - birthDate.Year; + + if (DateTime.Now.DayOfYear < birthDate.DayOfYear) + { + age--; + } + + await Assert.That(age).IsGreaterThanOrEqualTo(0); + await Assert.That(age).IsLessThan(150); // Reasonable max age +} +``` + +### Business Days + +```csharp +[Test] +public async Task Is_Business_Day() +{ + var monday = new DateTime(2024, 1, 8); + + await Assert.That(monday).IsOnWeekday(); + await Assert.That(monday.DayOfWeek).IsNotEqualTo(DayOfWeek.Saturday); + await Assert.That(monday.DayOfWeek).IsNotEqualTo(DayOfWeek.Sunday); +} +``` + +### Scheduling + +```csharp +[Test] +public async Task Scheduled_Time() +{ + var scheduledTime = new DateTime(2024, 12, 25, 9, 0, 0); + + await Assert.That(scheduledTime.Month).IsEqualTo(12); + await Assert.That(scheduledTime.Day).IsEqualTo(25); + await Assert.That(scheduledTime.Hour).IsEqualTo(9); +} +``` + +### Performance Timing + +```csharp +[Test] +public async Task Operation_Duration() +{ + var start = DateTime.Now; + await PerformOperationAsync(); + var end = DateTime.Now; + + var duration = end - start; + + await Assert.That(duration).IsLessThan(TimeSpan.FromSeconds(5)); + await Assert.That(duration).IsPositive(); +} +``` + +### Date Range Validation + +```csharp +[Test] +public async Task Date_Within_Range() +{ + var startDate = new DateTime(2024, 1, 1); + var endDate = new DateTime(2024, 12, 31); + var checkDate = new DateTime(2024, 6, 15); + + await Assert.That(checkDate).IsGreaterThan(startDate); + await Assert.That(checkDate).IsLessThan(endDate); +} +``` + +### Timestamp Validation + +```csharp +[Test] +public async Task Record_Created_Recently() +{ + var record = await CreateRecordAsync(); + var createdAt = record.CreatedAt; + var now = DateTime.UtcNow; + + // Created within last minute + await Assert.That(createdAt).IsEqualTo(now, tolerance: TimeSpan.FromMinutes(1)); + await Assert.That(createdAt).IsInPastUtc(); +} +``` + +### Time Zone Conversions + +```csharp +[Test] +public async Task Time_Zone_Conversion() +{ + var utcTime = DateTime.UtcNow; + var localTime = utcTime.ToLocalTime(); + + await Assert.That(utcTime).IsUtc(); + await Assert.That(localTime).IsNotUtc(); + + var offset = localTime - utcTime; + await Assert.That(Math.Abs(offset.TotalHours)).IsLessThan(24); +} +``` + +## Working with Date Components + +```csharp +[Test] +public async Task Date_Components() +{ + var date = new DateTime(2024, 7, 15, 14, 30, 45); + + await Assert.That(date.Year).IsEqualTo(2024); + await Assert.That(date.Month).IsEqualTo(7); + await Assert.That(date.Day).IsEqualTo(15); + await Assert.That(date.Hour).IsEqualTo(14); + await Assert.That(date.Minute).IsEqualTo(30); + await Assert.That(date.Second).IsEqualTo(45); +} +``` + +## First and Last Day of Month + +```csharp +[Test] +public async Task First_Day_Of_Month() +{ + var date = new DateTime(2024, 3, 15); + var firstDay = new DateTime(date.Year, date.Month, 1); + + await Assert.That(firstDay.Day).IsEqualTo(1); +} + +[Test] +public async Task Last_Day_Of_Month() +{ + var date = new DateTime(2024, 2, 15); + var daysInMonth = DateTime.DaysInMonth(date.Year, date.Month); + var lastDay = new DateTime(date.Year, date.Month, daysInMonth); + + await Assert.That(lastDay.Day).IsEqualTo(29); // 2024 is a leap year +} +``` + +## Quarter Calculation + +```csharp +[Test] +public async Task Date_Quarter() +{ + var q1 = new DateTime(2024, 2, 1); + var quarter1 = (q1.Month - 1) / 3 + 1; + await Assert.That(quarter1).IsEqualTo(1); + + var q3 = new DateTime(2024, 8, 1); + var quarter3 = (q3.Month - 1) / 3 + 1; + await Assert.That(quarter3).IsEqualTo(3); +} +``` + +## DayOfWeek Assertions + +DayOfWeek has its own assertions: + +```csharp +[Test] +public async Task Day_Of_Week_Checks() +{ + var dayOfWeek = DateTime.Now.DayOfWeek; + + if (dayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday) + { + await Assert.That(dayOfWeek).IsWeekend(); + } + else + { + await Assert.That(dayOfWeek).IsWeekday(); + } +} +``` + +## Chaining DateTime Assertions + +```csharp +[Test] +public async Task Chained_DateTime_Assertions() +{ + var date = DateTime.Now; + + await Assert.That(date) + .IsToday() + .And.IsGreaterThan(DateTime.MinValue) + .And.IsLessThan(DateTime.MaxValue); +} +``` + +## Common Patterns + +### Birthday Validation + +```csharp +[Test] +public async Task Validate_Birthday() +{ + var birthday = new DateTime(1990, 5, 15); + + await Assert.That(birthday).IsInPast(); + await Assert.That(birthday).IsGreaterThan(new DateTime(1900, 1, 1)); +} +``` + +### Meeting Scheduler + +```csharp +[Test] +public async Task Schedule_Meeting() +{ + var meetingTime = new DateTime(2024, 1, 15, 14, 0, 0); + + await Assert.That(meetingTime).IsInFuture(); + await Assert.That(meetingTime).IsOnWeekday(); + await Assert.That(meetingTime.Hour).IsBetween(9, 17); // Business hours +} +``` + +### Relative Time Checks + +```csharp +[Test] +public async Task Within_Last_Hour() +{ + var timestamp = DateTime.Now.AddMinutes(-30); + var hourAgo = DateTime.Now.AddHours(-1); + + await Assert.That(timestamp).IsGreaterThan(hourAgo); +} +``` + +## See Also + +- [Equality & Comparison](equality-and-comparison.md) - General comparison with tolerance +- [Numeric Assertions](numeric.md) - Numeric components of dates +- [Specialized Types](specialized-types.md) - Other time-related types diff --git a/docs/docs/assertions/dictionaries.md b/docs/docs/assertions/dictionaries.md new file mode 100644 index 0000000000..938a723f6e --- /dev/null +++ b/docs/docs/assertions/dictionaries.md @@ -0,0 +1,547 @@ +--- +sidebar_position: 6.8 +--- + +# Dictionary Assertions + +TUnit provides specialized assertions for testing dictionaries (`IReadOnlyDictionary`), including key and value membership checks. Dictionaries also inherit all collection assertions. + +## Key Assertions + +### ContainsKey + +Tests that a dictionary contains a specific key: + +```csharp +[Test] +public async Task Dictionary_Contains_Key() +{ + var dict = new Dictionary + { + ["apple"] = 1, + ["banana"] = 2, + ["cherry"] = 3 + }; + + await Assert.That(dict).ContainsKey("apple"); + await Assert.That(dict).ContainsKey("banana"); +} +``` + +#### With Custom Comparer + +```csharp +[Test] +public async Task Contains_Key_With_Comparer() +{ + var dict = new Dictionary + { + ["Apple"] = 1, + ["Banana"] = 2 + }; + + await Assert.That(dict) + .ContainsKey("apple") + .Using(StringComparer.OrdinalIgnoreCase); +} +``` + +### DoesNotContainKey + +Tests that a dictionary does not contain a specific key: + +```csharp +[Test] +public async Task Dictionary_Does_Not_Contain_Key() +{ + var dict = new Dictionary + { + ["apple"] = 1, + ["banana"] = 2 + }; + + await Assert.That(dict).DoesNotContainKey("cherry"); + await Assert.That(dict).DoesNotContainKey("orange"); +} +``` + +## Value Assertions + +### ContainsValue + +Tests that a dictionary contains a specific value: + +```csharp +[Test] +public async Task Dictionary_Contains_Value() +{ + var dict = new Dictionary + { + ["apple"] = 1, + ["banana"] = 2, + ["cherry"] = 3 + }; + + await Assert.That(dict).ContainsValue(2); + await Assert.That(dict).ContainsValue(3); +} +``` + +## Collection Assertions on Dictionaries + +Dictionaries inherit all collection assertions since they implement `IEnumerable>`: + +### Count + +```csharp +[Test] +public async Task Dictionary_Count() +{ + var dict = new Dictionary + { + ["a"] = 1, + ["b"] = 2, + ["c"] = 3 + }; + + await Assert.That(dict).HasCount(3); +} +``` + +### IsEmpty / IsNotEmpty + +```csharp +[Test] +public async Task Dictionary_Empty() +{ + var empty = new Dictionary(); + var populated = new Dictionary { ["key"] = 1 }; + + await Assert.That(empty).IsEmpty(); + await Assert.That(populated).IsNotEmpty(); +} +``` + +### Contains (KeyValuePair) + +```csharp +[Test] +public async Task Dictionary_Contains_Pair() +{ + var dict = new Dictionary + { + ["apple"] = 1, + ["banana"] = 2 + }; + + await Assert.That(dict).Contains(new KeyValuePair("apple", 1)); +} +``` + +### All Pairs Match Condition + +```csharp +[Test] +public async Task All_Values_Positive() +{ + var dict = new Dictionary + { + ["a"] = 1, + ["b"] = 2, + ["c"] = 3 + }; + + await Assert.That(dict).All(kvp => kvp.Value > 0); +} +``` + +### Any Pair Matches Condition + +```csharp +[Test] +public async Task Any_Key_Starts_With() +{ + var dict = new Dictionary + { + ["apple"] = 1, + ["banana"] = 2, + ["cherry"] = 3 + }; + + await Assert.That(dict).Any(kvp => kvp.Key.StartsWith("b")); +} +``` + +## Practical Examples + +### Configuration Validation + +```csharp +[Test] +public async Task Configuration_Has_Required_Keys() +{ + var config = LoadConfiguration(); + + await using (Assert.Multiple()) + { + await Assert.That(config).ContainsKey("DatabaseConnection"); + await Assert.That(config).ContainsKey("ApiKey"); + await Assert.That(config).ContainsKey("Environment"); + } +} +``` + +### HTTP Headers Validation + +```csharp +[Test] +public async Task Response_Headers() +{ + var headers = new Dictionary + { + ["Content-Type"] = "application/json", + ["Cache-Control"] = "no-cache" + }; + + await Assert.That(headers) + .ContainsKey("Content-Type") + .And.ContainsValue("application/json"); +} +``` + +### Lookup Table Validation + +```csharp +[Test] +public async Task Lookup_Table() +{ + var statusCodes = new Dictionary + { + [200] = "OK", + [404] = "Not Found", + [500] = "Internal Server Error" + }; + + await Assert.That(statusCodes) + .HasCount(3) + .And.ContainsKey(200) + .And.ContainsValue("OK"); +} +``` + +### Cache Validation + +```csharp +[Test] +public async Task Cache_Contains_Entry() +{ + var cache = new Dictionary + { + ["user:123"] = new User { Id = 123 }, + ["user:456"] = new User { Id = 456 } + }; + + await Assert.That(cache) + .ContainsKey("user:123") + .And.HasCount(2) + .And.IsNotEmpty(); +} +``` + +## Dictionary Key/Value Operations + +### Accessing Values After Key Check + +```csharp +[Test] +public async Task Get_Value_After_Key_Check() +{ + var dict = new Dictionary + { + ["alice"] = new User { Name = "Alice", Age = 30 } + }; + + // First verify key exists + await Assert.That(dict).ContainsKey("alice"); + + // Then safely access + var user = dict["alice"]; + await Assert.That(user.Age).IsEqualTo(30); +} +``` + +### TryGetValue Pattern + +```csharp +[Test] +public async Task TryGetValue_Pattern() +{ + var dict = new Dictionary + { + ["count"] = 42 + }; + + var found = dict.TryGetValue("count", out var value); + + await Assert.That(found).IsTrue(); + await Assert.That(value).IsEqualTo(42); +} +``` + +## Working with Dictionary Keys and Values + +### Keys Collection + +```csharp +[Test] +public async Task Dictionary_Keys() +{ + var dict = new Dictionary + { + ["a"] = 1, + ["b"] = 2, + ["c"] = 3 + }; + + var keys = dict.Keys; + + await Assert.That(keys) + .HasCount(3) + .And.Contains("a") + .And.Contains("b") + .And.Contains("c"); +} +``` + +### Values Collection + +```csharp +[Test] +public async Task Dictionary_Values() +{ + var dict = new Dictionary + { + ["a"] = 1, + ["b"] = 2, + ["c"] = 3 + }; + + var values = dict.Values; + + await Assert.That(values) + .HasCount(3) + .And.Contains(1) + .And.Contains(2) + .And.All(v => v > 0); +} +``` + +## Equivalency Checks + +### Same Key-Value Pairs + +```csharp +[Test] +public async Task Dictionaries_Are_Equivalent() +{ + var dict1 = new Dictionary + { + ["a"] = 1, + ["b"] = 2 + }; + + var dict2 = new Dictionary + { + ["b"] = 2, + ["a"] = 1 + }; + + // Dictionaries are equivalent (same pairs, order doesn't matter) + await Assert.That(dict1).IsEquivalentTo(dict2); +} +``` + +## Chaining Dictionary Assertions + +```csharp +[Test] +public async Task Chained_Dictionary_Assertions() +{ + var dict = new Dictionary + { + ["apple"] = 1, + ["banana"] = 2, + ["cherry"] = 3 + }; + + await Assert.That(dict) + .IsNotEmpty() + .And.HasCount(3) + .And.ContainsKey("apple") + .And.ContainsKey("banana") + .And.ContainsValue(2) + .And.All(kvp => kvp.Value > 0); +} +``` + +## Specialized Dictionary Types + +### ConcurrentDictionary + +```csharp +[Test] +public async Task Concurrent_Dictionary() +{ + var concurrent = new ConcurrentDictionary(); + concurrent.TryAdd("a", 1); + concurrent.TryAdd("b", 2); + + await Assert.That(concurrent) + .HasCount(2) + .And.ContainsKey("a"); +} +``` + +### ReadOnlyDictionary + +```csharp +[Test] +public async Task ReadOnly_Dictionary() +{ + var dict = new Dictionary { ["a"] = 1 }; + var readOnly = new ReadOnlyDictionary(dict); + + await Assert.That(readOnly) + .HasCount(1) + .And.ContainsKey("a"); +} +``` + +### SortedDictionary + +```csharp +[Test] +public async Task Sorted_Dictionary() +{ + var sorted = new SortedDictionary + { + [3] = "three", + [1] = "one", + [2] = "two" + }; + + var keys = sorted.Keys.ToArray(); + + await Assert.That(keys).IsInOrder(); +} +``` + +## Null Checks + +### Null Dictionary + +```csharp +[Test] +public async Task Null_Dictionary() +{ + Dictionary? dict = null; + + await Assert.That(dict).IsNull(); +} +``` + +### Empty vs Null + +```csharp +[Test] +public async Task Empty_vs_Null_Dictionary() +{ + Dictionary? nullDict = null; + var emptyDict = new Dictionary(); + + await Assert.That(nullDict).IsNull(); + await Assert.That(emptyDict).IsNotNull(); + await Assert.That(emptyDict).IsEmpty(); +} +``` + +## Common Patterns + +### Required Configuration Keys + +```csharp +[Test] +public async Task All_Required_Keys_Present() +{ + var config = LoadConfiguration(); + var requiredKeys = new[] { "ApiKey", "Database", "Environment" }; + + foreach (var key in requiredKeys) + { + await Assert.That(config).ContainsKey(key); + } +} +``` + +Or with `Assert.Multiple`: + +```csharp +[Test] +public async Task All_Required_Keys_Present_Multiple() +{ + var config = LoadConfiguration(); + var requiredKeys = new[] { "ApiKey", "Database", "Environment" }; + + await using (Assert.Multiple()) + { + foreach (var key in requiredKeys) + { + await Assert.That(config).ContainsKey(key); + } + } +} +``` + +### Metadata Validation + +```csharp +[Test] +public async Task Validate_Metadata() +{ + var metadata = GetFileMetadata(); + + await Assert.That(metadata) + .ContainsKey("ContentType") + .And.ContainsKey("Size") + .And.ContainsKey("LastModified") + .And.All(kvp => kvp.Value != null); +} +``` + +### Feature Flags + +```csharp +[Test] +public async Task Feature_Flags() +{ + var features = new Dictionary + { + ["NewUI"] = true, + ["BetaFeature"] = false, + ["ExperimentalApi"] = true + }; + + await Assert.That(features) + .ContainsKey("NewUI") + .And.ContainsValue(true); + + var newUiEnabled = features["NewUI"]; + await Assert.That(newUiEnabled).IsTrue(); +} +``` + +## See Also + +- [Collections](collections.md) - General collection assertions +- [Equality & Comparison](equality-and-comparison.md) - Comparing dictionary values +- [Strings](string.md) - String key comparisons diff --git a/docs/docs/assertions/equality-and-comparison.md b/docs/docs/assertions/equality-and-comparison.md new file mode 100644 index 0000000000..78920a4624 --- /dev/null +++ b/docs/docs/assertions/equality-and-comparison.md @@ -0,0 +1,500 @@ +--- +sidebar_position: 2 +--- + +# Equality and Comparison Assertions + +TUnit provides comprehensive assertions for testing equality and comparing values. These assertions work with any type that implements the appropriate comparison interfaces. + +## Basic Equality + +### IsEqualTo + +Tests that two values are equal using the type's `Equals()` method or `==` operator: + +```csharp +[Test] +public async Task Basic_Equality() +{ + var result = 5 + 5; + await Assert.That(result).IsEqualTo(10); + + var name = "Alice"; + await Assert.That(name).IsEqualTo("Alice"); + + var isValid = true; + await Assert.That(isValid).IsEqualTo(true); +} +``` + +### IsNotEqualTo + +Tests that two values are not equal: + +```csharp +[Test] +public async Task Not_Equal() +{ + var actual = CalculateResult(); + await Assert.That(actual).IsNotEqualTo(0); + + var username = GetUsername(); + await Assert.That(username).IsNotEqualTo("admin"); +} +``` + +### EqualTo (Alias) + +`EqualTo()` is an alias for `IsEqualTo()` for more natural chaining: + +```csharp +[Test] +public async Task Using_EqualTo_Alias() +{ + var numbers = new[] { 1, 2, 3 }; + + await Assert.That(numbers) + .HasCount().EqualTo(3) + .And.Contains(2); +} +``` + +## Reference Equality + +### IsSameReferenceAs + +Tests that two references point to the exact same object instance: + +```csharp +[Test] +public async Task Same_Reference() +{ + var original = new Person { Name = "Alice" }; + var reference = original; + + await Assert.That(reference).IsSameReferenceAs(original); +} +``` + +### IsNotSameReferenceAs + +Tests that two references point to different object instances: + +```csharp +[Test] +public async Task Different_References() +{ + var person1 = new Person { Name = "Alice" }; + var person2 = new Person { Name = "Alice" }; + + // Same values, different instances + await Assert.That(person1).IsNotSameReferenceAs(person2); + await Assert.That(person1).IsEqualTo(person2); // If equality is overridden +} +``` + +## Comparison Assertions + +All comparison assertions work with types that implement `IComparable` or `IComparable`. + +### IsGreaterThan + +```csharp +[Test] +public async Task Greater_Than() +{ + var score = 85; + await Assert.That(score).IsGreaterThan(70); + + var temperature = 25.5; + await Assert.That(temperature).IsGreaterThan(20.0); + + var date = DateTime.Now; + await Assert.That(date).IsGreaterThan(DateTime.Now.AddDays(-1)); +} +``` + +### IsGreaterThanOrEqualTo + +```csharp +[Test] +public async Task Greater_Than_Or_Equal() +{ + var passingGrade = 60; + await Assert.That(passingGrade).IsGreaterThanOrEqualTo(60); + + var age = 18; + await Assert.That(age).IsGreaterThanOrEqualTo(18); // Exactly 18 passes +} +``` + +### IsLessThan + +```csharp +[Test] +public async Task Less_Than() +{ + var response_time = 150; // milliseconds + await Assert.That(response_time).IsLessThan(200); + + var price = 49.99m; + await Assert.That(price).IsLessThan(50.00m); +} +``` + +### IsLessThanOrEqualTo + +```csharp +[Test] +public async Task Less_Than_Or_Equal() +{ + var maxRetries = 3; + var actualRetries = 3; + await Assert.That(actualRetries).IsLessThanOrEqualTo(maxRetries); +} +``` + +## Range Assertions + +### IsBetween + +Tests that a value falls within a range (inclusive): + +```csharp +[Test] +public async Task Between_Values() +{ + var percentage = 75; + await Assert.That(percentage).IsBetween(0, 100); + + var temperature = 22.5; + await Assert.That(temperature).IsBetween(20.0, 25.0); + + var age = 30; + await Assert.That(age).IsBetween(18, 65); +} +``` + +Boundary values are included: + +```csharp +[Test] +public async Task Between_Includes_Boundaries() +{ + await Assert.That(0).IsBetween(0, 10); // ✅ Passes + await Assert.That(10).IsBetween(0, 10); // ✅ Passes + await Assert.That(5).IsBetween(0, 10); // ✅ Passes +} +``` + +## Numeric-Specific Assertions + +### IsPositive + +Tests that a numeric value is greater than zero: + +```csharp +[Test] +public async Task Positive_Numbers() +{ + var profit = 1500.50m; + await Assert.That(profit).IsPositive(); + + var count = 5; + await Assert.That(count).IsPositive(); + + // Works with all numeric types + await Assert.That(1.5).IsPositive(); // double + await Assert.That(1.5f).IsPositive(); // float + await Assert.That(1.5m).IsPositive(); // decimal + await Assert.That((byte)1).IsPositive(); // byte + await Assert.That((short)1).IsPositive(); // short + await Assert.That(1L).IsPositive(); // long +} +``` + +### IsNegative + +Tests that a numeric value is less than zero: + +```csharp +[Test] +public async Task Negative_Numbers() +{ + var loss = -500.25m; + await Assert.That(loss).IsNegative(); + + var temperature = -5; + await Assert.That(temperature).IsNegative(); +} +``` + +## Tolerance for Floating-Point Numbers + +When comparing floating-point numbers, you can specify a tolerance to account for rounding errors: + +### Double Tolerance + +```csharp +[Test] +public async Task Double_With_Tolerance() +{ + var actual = 1.0 / 3.0; // 0.333333... + var expected = 0.333; + + // Without tolerance - might fail due to precision + // await Assert.That(actual).IsEqualTo(expected); + + // With tolerance - passes + await Assert.That(actual).IsEqualTo(expected, tolerance: 0.001); +} +``` + +### Float Tolerance + +```csharp +[Test] +public async Task Float_With_Tolerance() +{ + float actual = 3.14159f; + float expected = 3.14f; + + await Assert.That(actual).IsEqualTo(expected, tolerance: 0.01f); +} +``` + +### Decimal Tolerance + +```csharp +[Test] +public async Task Decimal_With_Tolerance() +{ + decimal price = 19.995m; + decimal expected = 20.00m; + + await Assert.That(price).IsEqualTo(expected, tolerance: 0.01m); +} +``` + +### Long Tolerance + +```csharp +[Test] +public async Task Long_With_Tolerance() +{ + long timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + long expected = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + + // Allow 100ms difference + await Assert.That(timestamp).IsEqualTo(expected, tolerance: 100L); +} +``` + +## Chaining Comparisons + +Combine multiple comparison assertions: + +```csharp +[Test] +public async Task Chained_Comparisons() +{ + var score = 85; + + await Assert.That(score) + .IsGreaterThan(0) + .And.IsLessThan(100) + .And.IsGreaterThanOrEqualTo(80); +} +``` + +Or use `IsBetween` for simpler range checks: + +```csharp +[Test] +public async Task Range_Check_Simplified() +{ + var score = 85; + + // Instead of chaining IsGreaterThan and IsLessThan: + await Assert.That(score).IsBetween(0, 100); +} +``` + +## Custom Equality Comparers + +You can provide custom equality comparers for collections and complex types: + +```csharp +[Test] +public async Task Custom_Comparer() +{ + var people1 = new[] { new Person("Alice"), new Person("Bob") }; + var people2 = new[] { new Person("ALICE"), new Person("BOB") }; + + // Case-insensitive name comparison + var comparer = new PersonNameComparer(); + + await Assert.That(people1) + .IsEquivalentTo(people2) + .Using(comparer); +} + +public class PersonNameComparer : IEqualityComparer +{ + public bool Equals(Person? x, Person? y) => + string.Equals(x?.Name, y?.Name, StringComparison.OrdinalIgnoreCase); + + public int GetHashCode(Person obj) => + obj.Name?.ToLowerInvariant().GetHashCode() ?? 0; +} +``` + +Or use a predicate: + +```csharp +[Test] +public async Task Custom_Equality_Predicate() +{ + var people1 = new[] { new Person("Alice"), new Person("Bob") }; + var people2 = new[] { new Person("ALICE"), new Person("BOB") }; + + await Assert.That(people1) + .IsEquivalentTo(people2) + .Using((p1, p2) => string.Equals(p1.Name, p2.Name, + StringComparison.OrdinalIgnoreCase)); +} +``` + +## Working with Value Types and Records + +Equality works naturally with value types and records: + +```csharp +public record Point(int X, int Y); + +[Test] +public async Task Record_Equality() +{ + var point1 = new Point(10, 20); + var point2 = new Point(10, 20); + + // Records have built-in value equality + await Assert.That(point1).IsEqualTo(point2); + await Assert.That(point1).IsNotSameReferenceAs(point2); +} +``` + +```csharp +public struct Coordinate +{ + public double Latitude { get; init; } + public double Longitude { get; init; } +} + +[Test] +public async Task Struct_Equality() +{ + var coord1 = new Coordinate { Latitude = 47.6, Longitude = -122.3 }; + var coord2 = new Coordinate { Latitude = 47.6, Longitude = -122.3 }; + + await Assert.That(coord1).IsEqualTo(coord2); +} +``` + +## Practical Examples + +### Validating Calculation Results + +```csharp +[Test] +public async Task Calculate_Discount() +{ + var originalPrice = 100m; + var discount = 0.20m; // 20% + + var finalPrice = originalPrice * (1 - discount); + + await Assert.That(finalPrice).IsEqualTo(80m); + await Assert.That(finalPrice).IsLessThan(originalPrice); + await Assert.That(finalPrice).IsGreaterThan(0); +} +``` + +### Validating Ranges + +```csharp +[Test] +public async Task Temperature_In_Valid_Range() +{ + var roomTemperature = GetRoomTemperature(); + + await Assert.That(roomTemperature) + .IsBetween(18, 26) // Comfortable range in Celsius + .And.IsPositive(); +} +``` + +### Comparing with Mathematical Constants + +```csharp +[Test] +public async Task Mathematical_Constants() +{ + var calculatedPi = CalculatePiUsingLeibniz(10000); + + await Assert.That(calculatedPi).IsEqualTo(Math.PI, tolerance: 0.0001); +} +``` + +### API Response Validation + +```csharp +[Test] +public async Task API_Response_Time() +{ + var stopwatch = Stopwatch.StartNew(); + await CallApiEndpoint(); + stopwatch.Stop(); + + await Assert.That(stopwatch.ElapsedMilliseconds) + .IsLessThan(500) // Must respond within 500ms + .And.IsGreaterThan(0); +} +``` + +## Common Patterns + +### Validating User Input + +```csharp +[Test] +public async Task Username_Length() +{ + var username = GetUserInput(); + + await Assert.That(username.Length) + .IsBetween(3, 20) + .And.IsGreaterThan(0); +} +``` + +### Percentage Validation + +```csharp +[Test] +public async Task Percentage_Valid() +{ + var successRate = CalculateSuccessRate(); + + await Assert.That(successRate) + .IsBetween(0, 100) + .And.IsGreaterThanOrEqualTo(0); +} +``` + +## See Also + +- [Numeric Assertions](numeric.md) - Additional numeric-specific assertions +- [DateTime Assertions](datetime.md) - Time-based comparisons with tolerance +- [Collections](collections.md) - Comparing collections +- [Strings](string.md) - String equality with options diff --git a/docs/docs/assertions/exceptions.md b/docs/docs/assertions/exceptions.md new file mode 100644 index 0000000000..5eb03251c5 --- /dev/null +++ b/docs/docs/assertions/exceptions.md @@ -0,0 +1,625 @@ +--- +sidebar_position: 8 +--- + +# Exception Assertions + +TUnit provides comprehensive assertions for testing that code throws (or doesn't throw) exceptions, with rich support for validating exception types, messages, and properties. + +## Basic Exception Assertions + +### Throws<TException> + +Tests that a delegate throws a specific exception type (or a subclass): + +```csharp +[Test] +public async Task Code_Throws_Exception() +{ + await Assert.That(() => int.Parse("not a number")) + .Throws(); +} +``` + +Works with any exception type: + +```csharp +[Test] +public async Task Various_Exception_Types() +{ + await Assert.That(() => throw new InvalidOperationException()) + .Throws(); + + await Assert.That(() => throw new ArgumentNullException()) + .Throws(); + + await Assert.That(() => File.ReadAllText("nonexistent.txt")) + .Throws(); +} +``` + +### ThrowsExactly<TException> + +Tests that a delegate throws the exact exception type (not a subclass): + +```csharp +[Test] +public async Task Throws_Exact_Type() +{ + await Assert.That(() => throw new ArgumentNullException()) + .ThrowsExactly(); + + // This would fail - ArgumentNullException is a subclass of ArgumentException + // await Assert.That(() => throw new ArgumentNullException()) + // .ThrowsExactly(); +} +``` + +### Throws (Runtime Type) + +Use when the exception type is only known at runtime: + +```csharp +[Test] +public async Task Throws_Runtime_Type() +{ + Type exceptionType = typeof(InvalidOperationException); + + await Assert.That(() => throw new InvalidOperationException()) + .Throws(exceptionType); +} +``` + +### ThrowsNothing + +Tests that code does not throw any exception: + +```csharp +[Test] +public async Task Code_Does_Not_Throw() +{ + await Assert.That(() => int.Parse("42")) + .ThrowsNothing(); + + await Assert.That(() => ValidateInput("valid")) + .ThrowsNothing(); +} +``` + +## Async Exception Assertions + +For async operations, use async delegates: + +```csharp +[Test] +public async Task Async_Throws_Exception() +{ + await Assert.That(async () => await FailingOperationAsync()) + .Throws(); +} +``` + +```csharp +[Test] +public async Task Async_Does_Not_Throw() +{ + await Assert.That(async () => await SuccessfulOperationAsync()) + .ThrowsNothing(); +} +``` + +## Exception Message Assertions + +### WithMessage + +Tests that the exception has an exact message: + +```csharp +[Test] +public async Task Exception_With_Exact_Message() +{ + await Assert.That(() => throw new InvalidOperationException("Operation failed")) + .Throws() + .WithMessage("Operation failed"); +} +``` + +### WithMessageContaining + +Tests that the exception message contains a substring: + +```csharp +[Test] +public async Task Exception_Message_Contains() +{ + await Assert.That(() => throw new ArgumentException("The parameter 'userId' is invalid")) + .Throws() + .WithMessageContaining("userId"); +} +``` + +#### Case-Insensitive + +```csharp +[Test] +public async Task Message_Contains_Ignoring_Case() +{ + await Assert.That(() => throw new Exception("ERROR: Failed")) + .Throws() + .WithMessageContaining("error") + .IgnoringCase(); +} +``` + +### WithMessageNotContaining + +Tests that the exception message does not contain a substring: + +```csharp +[Test] +public async Task Message_Does_Not_Contain() +{ + await Assert.That(() => throw new Exception("User error")) + .Throws() + .WithMessageNotContaining("system"); +} +``` + +### WithMessageMatching + +Tests that the exception message matches a pattern: + +```csharp +[Test] +public async Task Message_Matches_Pattern() +{ + await Assert.That(() => throw new Exception("Error code: 12345")) + .Throws() + .WithMessageMatching("Error code: *"); +} +``` + +Or with a `StringMatcher`: + +```csharp +[Test] +public async Task Message_Matches_With_Matcher() +{ + var matcher = new StringMatcher("Error * occurred", caseSensitive: false); + + await Assert.That(() => throw new Exception("Error 500 occurred")) + .Throws() + .WithMessageMatching(matcher); +} +``` + +## ArgumentException Specific + +### WithParameterName + +For `ArgumentException` and its subclasses, you can assert on the parameter name: + +```csharp +[Test] +public async Task ArgumentException_With_Parameter_Name() +{ + await Assert.That(() => ValidateUser(null!)) + .Throws() + .WithParameterName("user"); +} + +void ValidateUser(User user) +{ + if (user == null) + throw new ArgumentNullException(nameof(user)); +} +``` + +Combine with message assertions: + +```csharp +[Test] +public async Task ArgumentException_Parameter_And_Message() +{ + await Assert.That(() => SetAge(-1)) + .Throws() + .WithParameterName("age") + .WithMessageContaining("must be positive"); +} + +void SetAge(int age) +{ + if (age < 0) + throw new ArgumentOutOfRangeException(nameof(age), "Age must be positive"); +} +``` + +## Inner Exception Assertions + +### WithInnerException + +Assert on the inner exception: + +```csharp +[Test] +public async Task Exception_With_Inner_Exception() +{ + await Assert.That(() => { + try + { + int.Parse("not a number"); + } + catch (Exception ex) + { + throw new InvalidOperationException("Processing failed", ex); + } + }) + .Throws() + .WithInnerException(); +} +``` + +Chain to assert on the inner exception type: + +```csharp +[Test] +public async Task Inner_Exception_Type() +{ + await Assert.That(() => ThrowWithInner()) + .Throws() + .WithInnerException() + .Throws(); +} + +void ThrowWithInner() +{ + try + { + int.Parse("abc"); + } + catch (Exception ex) + { + throw new InvalidOperationException("Outer", ex); + } +} +``` + +## Practical Examples + +### Validation Exceptions + +```csharp +[Test] +public async Task Validate_Email_Throws() +{ + await Assert.That(() => ValidateEmail("invalid-email")) + .Throws() + .WithParameterName("email") + .WithMessageContaining("valid email"); +} +``` + +### Null Argument Checks + +```csharp +[Test] +public async Task Null_Argument_Throws() +{ + await Assert.That(() => ProcessData(null!)) + .Throws() + .WithParameterName("data"); +} +``` + +### File Operations + +```csharp +[Test] +public async Task File_Not_Found() +{ + await Assert.That(() => File.ReadAllText("nonexistent.txt")) + .Throws() + .WithMessageContaining("nonexistent.txt"); +} +``` + +### Network Operations + +```csharp +[Test] +public async Task HTTP_Request_Fails() +{ + await Assert.That(async () => await _client.GetAsync("http://invalid-url")) + .Throws(); +} +``` + +### Database Operations + +```csharp +[Test] +public async Task Duplicate_Key_Violation() +{ + await Assert.That(async () => await InsertDuplicateAsync()) + .Throws() + .WithMessageContaining("duplicate key"); +} +``` + +### Division by Zero + +```csharp +[Test] +public async Task Division_By_Zero() +{ + await Assert.That(() => { + int a = 10; + int b = 0; + return a / b; + }) + .Throws(); +} +``` + +### Index Out of Range + +```csharp +[Test] +public async Task Array_Index_Out_Of_Range() +{ + var array = new[] { 1, 2, 3 }; + + await Assert.That(() => array[10]) + .Throws(); +} +``` + +### Invalid Cast + +```csharp +[Test] +public async Task Invalid_Cast() +{ + object obj = "string"; + + await Assert.That(() => (int)obj) + .Throws(); +} +``` + +### Custom Exceptions + +```csharp +public class BusinessRuleException : Exception +{ + public string RuleCode { get; } + + public BusinessRuleException(string ruleCode, string message) + : base(message) + { + RuleCode = ruleCode; + } +} + +[Test] +public async Task Custom_Exception_With_Properties() +{ + var exception = await Assert.That(() => + throw new BusinessRuleException("BR001", "Business rule violated")) + .Throws(); + + // Can't directly assert on exception properties yet, but you can access them + await Assert.That(exception.RuleCode).IsEqualTo("BR001"); + await Assert.That(exception.Message).Contains("Business rule"); +} +``` + +## Testing Multiple Operations + +### Using Assert.Multiple + +```csharp +[Test] +public async Task Multiple_Exception_Scenarios() +{ + await using (Assert.Multiple()) + { + await Assert.That(() => int.Parse("abc")) + .Throws(); + + await Assert.That(() => int.Parse("999999999999999999999")) + .Throws(); + + await Assert.That(() => int.Parse("42")) + .ThrowsNothing(); + } +} +``` + +## Exception Inheritance + +When using `Throws()`, subclasses are accepted: + +```csharp +[Test] +public async Task Exception_Inheritance() +{ + // ArgumentNullException inherits from ArgumentException + await Assert.That(() => throw new ArgumentNullException()) + .Throws(); // ✅ Passes + + await Assert.That(() => throw new ArgumentNullException()) + .Throws(); // ✅ Also passes +} +``` + +Use `ThrowsExactly()` if you need the exact type: + +```csharp +[Test] +public async Task Exact_Exception_Type() +{ + // This fails - ArgumentNullException is not exactly ArgumentException + // await Assert.That(() => throw new ArgumentNullException()) + // .ThrowsExactly(); + + await Assert.That(() => throw new ArgumentException()) + .ThrowsExactly(); // ✅ Passes +} +``` + +## Aggregate Exceptions + +```csharp +[Test] +public async Task Aggregate_Exception() +{ + await Assert.That(() => { + var task1 = Task.Run(() => throw new InvalidOperationException()); + var task2 = Task.Run(() => throw new ArgumentException()); + Task.WaitAll(task1, task2); + }) + .Throws(); +} +``` + +## Chaining Exception Assertions + +```csharp +[Test] +public async Task Chained_Exception_Assertions() +{ + await Assert.That(() => ValidateInput("")) + .Throws() + .WithParameterName("input") + .WithMessageContaining("cannot be empty") + .WithMessageNotContaining("null"); +} +``` + +## Testing that No Exception is Thrown + +### ThrowsNothing vs Try-Catch + +```csharp +[Test] +public async Task Explicit_No_Exception() +{ + // Using ThrowsNothing + await Assert.That(() => SafeOperation()) + .ThrowsNothing(); + + // Alternative: just call it + SafeOperation(); // If it throws, the test fails +} +``` + +## Common Patterns + +### Expected Failures + +```csharp +[Test] +public async Task Expected_Validation_Failure() +{ + var invalidUser = new User { Age = -1 }; + + await Assert.That(() => ValidateUser(invalidUser)) + .Throws() + .WithMessageContaining("Age"); +} +``` + +### Defensive Programming + +```csharp +[Test] +public async Task Guard_Clause_Validation() +{ + await Assert.That(() => new Service(null!)) + .Throws() + .WithParameterName("dependency"); +} +``` + +### State Validation + +```csharp +[Test] +public async Task Invalid_State_Operation() +{ + var connection = new Connection(); + // Don't connect + + await Assert.That(() => connection.SendData("test")) + .Throws() + .WithMessageContaining("not connected"); +} +``` + +### Configuration Errors + +```csharp +[Test] +public async Task Missing_Configuration() +{ + await Assert.That(() => LoadConfiguration("invalid.json")) + .Throws() + .WithMessageContaining("invalid.json"); +} +``` + +## Timeout Exceptions + +```csharp +[Test] +public async Task Operation_Timeout() +{ + using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(100)); + + await Assert.That(async () => await LongRunningOperationAsync(cts.Token)) + .Throws(); +} +``` + +## Re-throwing Exceptions + +```csharp +[Test] +public async Task Wrapper_Exception() +{ + await Assert.That(() => { + try + { + RiskyOperation(); + } + catch (Exception ex) + { + throw new ApplicationException("Operation failed", ex); + } + }) + .Throws() + .WithInnerException(); +} +``` + +## Exception Assertions with Async/Await + +```csharp +[Test] +public async Task Async_Exception_Handling() +{ + await Assert.That(async () => { + await Task.Delay(10); + throw new InvalidOperationException("Async failure"); + }) + .Throws() + .WithMessageContaining("Async failure"); +} +``` + +## See Also + +- [Tasks & Async](tasks-and-async.md) - Testing async operations and task state +- [Types](types.md) - Type checking for exception types +- [Strings](string.md) - String assertions for exception messages diff --git a/docs/docs/assertions/getting-started.md b/docs/docs/assertions/getting-started.md new file mode 100644 index 0000000000..e4d5d1fa3b --- /dev/null +++ b/docs/docs/assertions/getting-started.md @@ -0,0 +1,277 @@ +--- +sidebar_position: 1 +--- + +# Getting Started with Assertions + +TUnit provides a comprehensive, fluent assertion library that makes your tests readable and expressive. This guide introduces the core concepts and gets you started with writing assertions. + +## Basic Syntax + +All assertions in TUnit follow a consistent pattern using the `Assert.That()` method: + +```csharp +await Assert.That(actualValue).IsEqualTo(expectedValue); +``` + +The basic flow is: +1. Start with `Assert.That(value)` +2. Chain assertion methods (e.g., `.IsEqualTo()`, `.Contains()`, `.IsGreaterThan()`) +3. Always `await` the assertion (TUnit's assertions are async) + +## Why Await? + +TUnit assertions must be awaited. This design enables: +- **Async support**: Seamlessly test async operations +- **Rich error messages**: Build detailed failure messages during execution +- **Extensibility**: Create custom assertions that can perform async operations + +```csharp +// ✅ Correct - awaited +await Assert.That(result).IsEqualTo(42); + +// ❌ Wrong - will cause compiler warning +Assert.That(result).IsEqualTo(42); +``` + +TUnit includes a built-in analyzer that warns you if you forget to `await` an assertion. + +## Assertion Categories + +TUnit provides assertions for all common scenarios: + +### Equality & Comparison + +```csharp +await Assert.That(actual).IsEqualTo(expected); +await Assert.That(value).IsNotEqualTo(other); +await Assert.That(score).IsGreaterThan(70); +await Assert.That(age).IsLessThanOrEqualTo(100); +await Assert.That(temperature).IsBetween(20, 30); +``` + +### Strings + +```csharp +await Assert.That(message).Contains("Hello"); +await Assert.That(filename).StartsWith("test_"); +await Assert.That(email).Matches(@"^[\w\.-]+@[\w\.-]+\.\w+$"); +await Assert.That(input).IsNotEmpty(); +``` + +### Collections + +```csharp +await Assert.That(numbers).Contains(42); +await Assert.That(items).HasCount(5); +await Assert.That(list).IsNotEmpty(); +await Assert.That(values).All(x => x > 0); +``` + +### Booleans & Null + +```csharp +await Assert.That(isValid).IsTrue(); +await Assert.That(result).IsNotNull(); +await Assert.That(optional).IsDefault(); +``` + +### Exceptions + +```csharp +await Assert.That(() => DivideByZero()) + .Throws() + .WithMessage("Attempted to divide by zero."); +``` + +### Type Checking + +```csharp +await Assert.That(obj).IsTypeOf(); +await Assert.That(typeof(Dog)).IsAssignableTo(); +``` + +## Chaining Assertions + +Combine multiple assertions on the same value using `.And`: + +```csharp +await Assert.That(username) + .IsNotNull() + .And.IsNotEmpty() + .And.HasLength().GreaterThan(3) + .And.HasLength().LessThan(20); +``` + +Use `.Or` when any condition can be true: + +```csharp +await Assert.That(statusCode) + .IsEqualTo(200) + .Or.IsEqualTo(201) + .Or.IsEqualTo(204); +``` + +## Multiple Assertions with Assert.Multiple() + +Group related assertions together so all failures are reported: + +```csharp +await using (Assert.Multiple()) +{ + await Assert.That(user.FirstName).IsEqualTo("John"); + await Assert.That(user.LastName).IsEqualTo("Doe"); + await Assert.That(user.Age).IsGreaterThan(18); + await Assert.That(user.Email).IsNotNull(); +} +``` + +Instead of stopping at the first failure, `Assert.Multiple()` runs all assertions and reports every failure together. + +## Member Assertions + +Assert on object properties using `.Member()`: + +```csharp +await Assert.That(person) + .Member(p => p.Name, name => name.IsEqualTo("Alice")) + .And.Member(p => p.Age, age => age.IsGreaterThan(18)); +``` + +This works with nested properties too: + +```csharp +await Assert.That(order) + .Member(o => o.Customer.Address.City, city => city.IsEqualTo("Seattle"); +``` + +## Working with Collections + +Collections have rich assertion support: + +```csharp +var numbers = new[] { 1, 2, 3, 4, 5 }; + +// Count and emptiness +await Assert.That(numbers).HasCount(5); +await Assert.That(numbers).IsNotEmpty(); + +// Membership +await Assert.That(numbers).Contains(3); +await Assert.That(numbers).DoesNotContain(10); + +// Predicates +await Assert.That(numbers).All(n => n > 0); +await Assert.That(numbers).Any(n => n == 3); + +// Ordering +await Assert.That(numbers).IsInOrder(); + +// Equivalence (same items, any order) +await Assert.That(numbers).IsEquivalentTo(new[] { 5, 4, 3, 2, 1 }); +``` + +## Returning Values from Assertions + +Some assertions return the value being tested, allowing you to continue working with it: + +```csharp +// HasSingleItem returns the single item +var user = await Assert.That(users).HasSingleItem(); +await Assert.That(user.Name).IsEqualTo("Alice"); + +// Contains with predicate returns the found item +var admin = await Assert.That(users).Contains(u => u.Role == "Admin"); +await Assert.That(admin.Permissions).IsNotEmpty(); +``` + +## Custom Expectations + +Use `.Satisfies()` for custom conditions: + +```csharp +await Assert.That(value).Satisfies(v => v % 2 == 0, "Value must be even"); +``` + +Or map to a different value before asserting: + +```csharp +await Assert.That(order) + .Satisfies(o => o.Total, total => total > 100); +``` + +## Common Patterns + +### Testing Numeric Ranges + +```csharp +await Assert.That(score).IsBetween(0, 100); +await Assert.That(temperature).IsGreaterThanOrEqualTo(32); +``` + +### Testing with Tolerance + +For floating-point comparisons: + +```csharp +await Assert.That(3.14159).IsEqualTo(Math.PI, tolerance: 0.001); +``` + +### Testing Async Operations + +```csharp +await Assert.That(async () => await FetchDataAsync()) + .Throws(); + +await Assert.That(longRunningTask).CompletesWithin(TimeSpan.FromSeconds(5)); +``` + +### Testing Multiple Conditions + +```csharp +await Assert.That(username) + .IsNotNull() + .And.Satisfies(name => name.Length >= 3 && name.Length <= 20, + "Username must be 3-20 characters"); +``` + +## Type Safety + +TUnit's assertions are strongly typed and catch type mismatches at compile time: + +```csharp +int number = 42; +string text = "42"; + +// ✅ This works - both are ints +await Assert.That(number).IsEqualTo(42); + +// ❌ This won't compile - can't compare int to string +// await Assert.That(number).IsEqualTo("42"); +``` + +## Next Steps + +Now that you understand the basics, explore specific assertion types: + +- **[Equality & Comparison](equality-and-comparison.md)** - Detailed equality and comparison assertions +- **[Strings](string.md)** - Comprehensive string testing +- **[Collections](collections.md)** - Advanced collection assertions +- **[Exceptions](exceptions.md)** - Testing thrown exceptions +- **[Custom Assertions](extensibility/custom-assertions.md)** - Create your own assertions + +## Quick Reference + +| Assertion Category | Example | +|-------------------|---------| +| Equality | `IsEqualTo()`, `IsNotEqualTo()` | +| Comparison | `IsGreaterThan()`, `IsLessThan()`, `IsBetween()` | +| Null/Default | `IsNull()`, `IsNotNull()`, `IsDefault()` | +| Boolean | `IsTrue()`, `IsFalse()` | +| Strings | `Contains()`, `StartsWith()`, `Matches()` | +| Collections | `Contains()`, `HasCount()`, `All()`, `Any()` | +| Exceptions | `Throws()`, `ThrowsNothing()` | +| Types | `IsTypeOf()`, `IsAssignableTo()` | +| Async | `CompletesWithin()`, async exception testing | + +For a complete list of all assertions, see the specific category pages in the sidebar. diff --git a/docs/docs/assertions/null-and-default.md b/docs/docs/assertions/null-and-default.md new file mode 100644 index 0000000000..c0570f6fbd --- /dev/null +++ b/docs/docs/assertions/null-and-default.md @@ -0,0 +1,500 @@ +--- +sidebar_position: 2.5 +--- + +# Null and Default Value Assertions + +TUnit provides assertions for testing null values and default values. These assertions integrate with C#'s nullability annotations to provide better compile-time safety. + +## Null Assertions + +### IsNull + +Tests that a value is `null`: + +```csharp +[Test] +public async Task Null_Value() +{ + string? result = GetOptionalValue(); + await Assert.That(result).IsNull(); + + Person? person = FindPerson("unknown-id"); + await Assert.That(person).IsNull(); +} +``` + +### IsNotNull + +Tests that a value is not `null`: + +```csharp +[Test] +public async Task Not_Null_Value() +{ + string? result = GetRequiredValue(); + await Assert.That(result).IsNotNull(); + + var user = GetCurrentUser(); + await Assert.That(user).IsNotNull(); +} +``` + +## Nullability Flow Analysis + +When you use `IsNotNull()`, C#'s nullability analysis understands that the value is non-null afterward: + +```csharp +[Test] +public async Task Nullability_Flow() +{ + string? maybeNull = GetValue(); + + // After this assertion, compiler knows it's not null + await Assert.That(maybeNull).IsNotNull(); + + // No warning - compiler knows it's safe + int length = maybeNull.Length; +} +``` + +This works with chaining too: + +```csharp +[Test] +public async Task Chained_After_Null_Check() +{ + string? input = GetInput(); + + await Assert.That(input) + .IsNotNull() + .And.IsNotEmpty() // Compiler knows input is not null + .And.HasLength().GreaterThan(5); +} +``` + +## Default Value Assertions + +### IsDefault + +Tests that a value equals the default value for its type: + +```csharp +[Test] +public async Task Default_Values() +{ + // Reference types - default is null + string? text = default; + await Assert.That(text).IsDefault(); + + // Value types - default is zero/false/empty struct + int number = default; + await Assert.That(number).IsDefault(); // 0 + + bool flag = default; + await Assert.That(flag).IsDefault(); // false + + DateTime date = default; + await Assert.That(date).IsDefault(); // DateTime.MinValue + + Guid id = default; + await Assert.That(id).IsDefault(); // Guid.Empty +} +``` + +### IsNotDefault + +Tests that a value is not the default value for its type: + +```csharp +[Test] +public async Task Not_Default_Values() +{ + var name = "Alice"; + await Assert.That(name).IsNotDefault(); + + var count = 42; + await Assert.That(count).IsNotDefault(); + + var isValid = true; + await Assert.That(isValid).IsNotDefault(); + + var id = Guid.NewGuid(); + await Assert.That(id).IsNotDefault(); +} +``` + +## Reference Types vs Value Types + +### Reference Type Defaults + +For reference types, default equals `null`: + +```csharp +[Test] +public async Task Reference_Type_Defaults() +{ + string? text = default; + object? obj = default; + Person? person = default; + + await Assert.That(text).IsDefault(); // Same as IsNull() + await Assert.That(obj).IsDefault(); // Same as IsNull() + await Assert.That(person).IsDefault(); // Same as IsNull() +} +``` + +### Value Type Defaults + +For value types, default is the zero-initialized value: + +```csharp +[Test] +public async Task Value_Type_Defaults() +{ + // Numeric types default to 0 + int intVal = default; + await Assert.That(intVal).IsDefault(); + await Assert.That(intVal).IsEqualTo(0); + + double doubleVal = default; + await Assert.That(doubleVal).IsDefault(); + await Assert.That(doubleVal).IsEqualTo(0.0); + + // Boolean defaults to false + bool boolVal = default; + await Assert.That(boolVal).IsDefault(); + await Assert.That(boolVal).IsFalse(); + + // Struct defaults to all fields/properties at their defaults + Point point = default; + await Assert.That(point).IsDefault(); + await Assert.That(point).IsEqualTo(new Point(0, 0)); +} +``` + +### Nullable Value Types + +Nullable value types (`T?`) are reference types, so their default is `null`: + +```csharp +[Test] +public async Task Nullable_Value_Type_Defaults() +{ + int? nullableInt = default; + await Assert.That(nullableInt).IsDefault(); // Same as IsNull() + await Assert.That(nullableInt).IsNull(); // Also works + + DateTime? nullableDate = default; + await Assert.That(nullableDate).IsDefault(); + await Assert.That(nullableDate).IsNull(); +} +``` + +## Practical Examples + +### Optional Parameters and Returns + +```csharp +[Test] +public async Task Optional_Return_Value() +{ + // API might return null if item not found + var item = await _repository.FindByIdAsync("unknown-id"); + await Assert.That(item).IsNull(); + + // API should return value if item exists + var existing = await _repository.FindByIdAsync("valid-id"); + await Assert.That(existing).IsNotNull(); +} +``` + +### Initialization Checks + +```csharp +[Test] +public async Task Uninitialized_Field() +{ + var service = new MyService(); + + // Before initialization + await Assert.That(service.Connection).IsNull(); + + await service.InitializeAsync(); + + // After initialization + await Assert.That(service.Connection).IsNotNull(); +} +``` + +### Dependency Injection Validation + +```csharp +[Test] +public async Task Constructor_Injection() +{ + var logger = new Mock(); + var service = new UserService(logger.Object); + + // Verify dependency was injected + await Assert.That(service.Logger).IsNotNull(); +} +``` + +### Lazy Initialization + +```csharp +[Test] +public async Task Lazy_Property() +{ + var calculator = new ExpensiveCalculator(); + + // Before first access + await Assert.That(calculator.CachedResult).IsNull(); + + var result = calculator.GetResult(); + + // After first access - cached + await Assert.That(calculator.CachedResult).IsNotNull(); +} +``` + +## Checking Multiple Properties + +Use `Assert.Multiple()` to check multiple null conditions: + +```csharp +[Test] +public async Task Validate_All_Required_Fields() +{ + var user = CreateUser(); + + await using (Assert.Multiple()) + { + await Assert.That(user).IsNotNull(); + await Assert.That(user.FirstName).IsNotNull(); + await Assert.That(user.LastName).IsNotNull(); + await Assert.That(user.Email).IsNotNull(); + await Assert.That(user.CreatedDate).IsNotDefault(); + } +} +``` + +Or chain them: + +```csharp +[Test] +public async Task Required_Fields_With_Chaining() +{ + var config = LoadConfiguration(); + + await Assert.That(config.DatabaseConnection) + .IsNotNull() + .And.Member(c => c.Server).IsNotNull() + .And.Member(c => c.Database).IsNotNull(); +} +``` + +## Default Values for Custom Types + +### Structs + +```csharp +public struct Rectangle +{ + public int Width { get; init; } + public int Height { get; init; } +} + +[Test] +public async Task Struct_Default() +{ + Rectangle rect = default; + + await Assert.That(rect).IsDefault(); + await Assert.That(rect.Width).IsEqualTo(0); + await Assert.That(rect.Height).IsEqualTo(0); +} +``` + +### Records + +```csharp +public record Person(string Name, int Age); + +[Test] +public async Task Record_Default() +{ + Person? person = default; + await Assert.That(person).IsDefault(); // null for reference types + await Assert.That(person).IsNull(); +} + +public record struct Point(int X, int Y); + +[Test] +public async Task Record_Struct_Default() +{ + Point point = default; + await Assert.That(point).IsDefault(); + await Assert.That(point.X).IsEqualTo(0); + await Assert.That(point.Y).IsEqualTo(0); +} +``` + +## Special Cases + +### Empty Collections vs Null + +```csharp +[Test] +public async Task Empty_vs_Null() +{ + List? nullList = null; + List emptyList = new(); + + await Assert.That(nullList).IsNull(); + await Assert.That(emptyList).IsNotNull(); + await Assert.That(emptyList).IsEmpty(); // Not null, but empty +} +``` + +### Empty Strings vs Null + +```csharp +[Test] +public async Task Empty_String_vs_Null() +{ + string? nullString = null; + string emptyString = ""; + + await Assert.That(nullString).IsNull(); + await Assert.That(emptyString).IsNotNull(); + await Assert.That(emptyString).IsEmpty(); // Not null, but empty +} +``` + +### Default GUID + +```csharp +[Test] +public async Task GUID_Default() +{ + Guid id = default; + + await Assert.That(id).IsDefault(); + await Assert.That(id).IsEqualTo(Guid.Empty); + await Assert.That(id).IsEmptyGuid(); // TUnit specific assertion +} +``` + +### Default DateTime + +```csharp +[Test] +public async Task DateTime_Default() +{ + DateTime date = default; + + await Assert.That(date).IsDefault(); + await Assert.That(date).IsEqualTo(DateTime.MinValue); +} +``` + +## Combining with Other Assertions + +### Null Coalescing Validation + +```csharp +[Test] +public async Task Null_Coalescing_Default() +{ + string? input = GetOptionalInput(); + string result = input ?? "default"; + + if (input == null) + { + await Assert.That(result).IsEqualTo("default"); + } + else + { + await Assert.That(result).IsEqualTo(input); + } +} +``` + +### Null Conditional Operator + +```csharp +[Test] +public async Task Null_Conditional() +{ + Person? person = FindPerson("id"); + string? name = person?.Name; + + if (person == null) + { + await Assert.That(name).IsNull(); + } + else + { + await Assert.That(name).IsNotNull(); + } +} +``` + +## Common Patterns + +### Validate Required Dependencies + +```csharp +[Test] +public async Task All_Dependencies_Provided() +{ + var service = CreateService(); + + await Assert.That(service.Logger).IsNotNull(); + await Assert.That(service.Repository).IsNotNull(); + await Assert.That(service.Cache).IsNotNull(); +} +``` + +### Validate Optional Features + +```csharp +[Test] +public async Task Optional_Feature_Not_Enabled() +{ + var config = LoadConfiguration(); + + if (!config.EnableAdvancedFeatures) + { + await Assert.That(config.AdvancedSettings).IsNull(); + } +} +``` + +### State Machine Validation + +```csharp +[Test] +public async Task State_Transitions() +{ + var workflow = new Workflow(); + + // Initial state + await Assert.That(workflow.CurrentState).IsDefault(); + + await workflow.StartAsync(); + + // After start + await Assert.That(workflow.CurrentState).IsNotDefault(); +} +``` + +## See Also + +- [Equality & Comparison](equality-and-comparison.md) - Comparing values including defaults +- [Boolean Assertions](boolean.md) - Testing true/false values +- [String Assertions](string.md) - String-specific null and empty checks +- [Collections](collections.md) - Collection null and empty checks diff --git a/docs/docs/assertions/numeric.md b/docs/docs/assertions/numeric.md new file mode 100644 index 0000000000..6b8cbc8f07 --- /dev/null +++ b/docs/docs/assertions/numeric.md @@ -0,0 +1,583 @@ +--- +sidebar_position: 4.5 +--- + +# Numeric Assertions + +TUnit provides comprehensive assertions for testing numeric values, including specialized assertions for positive/negative values and comparison assertions with tolerance support. + +## Sign Assertions + +### IsPositive + +Tests that a numeric value is greater than zero: + +```csharp +[Test] +public async Task Positive_Values() +{ + var profit = 1500m; + await Assert.That(profit).IsPositive(); + + var count = 5; + await Assert.That(count).IsPositive(); + + var rating = 4.5; + await Assert.That(rating).IsPositive(); +} +``` + +Works with all numeric types: + +```csharp +[Test] +public async Task All_Numeric_Types() +{ + // Integers + await Assert.That(1).IsPositive(); // int + await Assert.That(1L).IsPositive(); // long + await Assert.That((short)1).IsPositive(); // short + await Assert.That((byte)1).IsPositive(); // byte + + // Floating point + await Assert.That(1.5).IsPositive(); // double + await Assert.That(1.5f).IsPositive(); // float + await Assert.That(1.5m).IsPositive(); // decimal +} +``` + +### IsNegative + +Tests that a numeric value is less than zero: + +```csharp +[Test] +public async Task Negative_Values() +{ + var loss = -250.50m; + await Assert.That(loss).IsNegative(); + + var temperature = -5; + await Assert.That(temperature).IsNegative(); + + var delta = -0.001; + await Assert.That(delta).IsNegative(); +} +``` + +### Zero is Neither Positive Nor Negative + +```csharp +[Test] +public async Task Zero_Checks() +{ + var zero = 0; + + // These will both fail: + // await Assert.That(zero).IsPositive(); // ❌ Fails + // await Assert.That(zero).IsNegative(); // ❌ Fails + + // Instead, check for zero explicitly: + await Assert.That(zero).IsEqualTo(0); +} +``` + +## Comparison Assertions + +All comparison operators work with numeric types. See [Equality and Comparison](equality-and-comparison.md) for full details. + +### Quick Reference + +```csharp +[Test] +public async Task Numeric_Comparisons() +{ + var value = 42; + + await Assert.That(value).IsGreaterThan(40); + await Assert.That(value).IsGreaterThanOrEqualTo(42); + await Assert.That(value).IsLessThan(50); + await Assert.That(value).IsLessThanOrEqualTo(42); + await Assert.That(value).IsBetween(0, 100); +} +``` + +## Tolerance for Floating-Point Numbers + +Floating-point arithmetic can introduce rounding errors. Use tolerance for safe comparisons: + +### Double Tolerance + +```csharp +[Test] +public async Task Double_Tolerance() +{ + double result = 1.0 / 3.0; // 0.33333333... + double expected = 0.333; + + // Without tolerance - might fail + // await Assert.That(result).IsEqualTo(expected); + + // With tolerance - safe + await Assert.That(result).IsEqualTo(expected, tolerance: 0.001); +} +``` + +### Float Tolerance + +```csharp +[Test] +public async Task Float_Tolerance() +{ + float pi = 3.14159f; + float approximation = 3.14f; + + await Assert.That(pi).IsEqualTo(approximation, tolerance: 0.01f); +} +``` + +### Decimal Tolerance + +Useful for monetary calculations: + +```csharp +[Test] +public async Task Decimal_Tolerance() +{ + decimal price = 19.995m; + decimal rounded = 20.00m; + + await Assert.That(price).IsEqualTo(rounded, tolerance: 0.01m); +} +``` + +### Long Tolerance + +For timestamp or large number comparisons: + +```csharp +[Test] +public async Task Long_Tolerance() +{ + long timestamp1 = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + await Task.Delay(50); + long timestamp2 = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + + // Allow 100ms difference + await Assert.That(timestamp1).IsEqualTo(timestamp2, tolerance: 100L); +} +``` + +## Practical Examples + +### Financial Calculations + +```csharp +[Test] +public async Task Calculate_Total_Price() +{ + decimal unitPrice = 9.99m; + int quantity = 3; + decimal tax = 0.08m; // 8% + + decimal subtotal = unitPrice * quantity; + decimal total = subtotal * (1 + tax); + + await Assert.That(total).IsPositive(); + await Assert.That(total).IsGreaterThan(subtotal); + await Assert.That(total).IsEqualTo(32.37m, tolerance: 0.01m); +} +``` + +### Temperature Conversions + +```csharp +[Test] +public async Task Celsius_To_Fahrenheit() +{ + double celsius = 20.0; + double fahrenheit = celsius * 9.0 / 5.0 + 32.0; + + await Assert.That(fahrenheit).IsEqualTo(68.0, tolerance: 0.1); + await Assert.That(fahrenheit).IsGreaterThan(celsius); +} +``` + +### Percentage Calculations + +```csharp +[Test] +public async Task Calculate_Percentage() +{ + int total = 200; + int passed = 175; + double percentage = (double)passed / total * 100; + + await Assert.That(percentage).IsPositive(); + await Assert.That(percentage).IsBetween(0, 100); + await Assert.That(percentage).IsEqualTo(87.5, tolerance: 0.1); +} +``` + +### Statistical Calculations + +```csharp +[Test] +public async Task Calculate_Average() +{ + var numbers = new[] { 10, 20, 30, 40, 50 }; + double average = numbers.Average(); + + await Assert.That(average).IsEqualTo(30.0, tolerance: 0.01); + await Assert.That(average).IsGreaterThan(numbers.Min()); + await Assert.That(average).IsLessThan(numbers.Max()); +} +``` + +## Range Validation + +### Valid Range Checks + +```csharp +[Test] +public async Task Validate_Age() +{ + int age = 25; + + await Assert.That(age).IsBetween(0, 120); + await Assert.That(age).IsGreaterThanOrEqualTo(0); +} +``` + +### Percentage Range + +```csharp +[Test] +public async Task Validate_Percentage() +{ + double successRate = 87.5; + + await Assert.That(successRate).IsBetween(0, 100); + await Assert.That(successRate).IsPositive(); +} +``` + +### Score Validation + +```csharp +[Test] +public async Task Validate_Score() +{ + int score = 85; + int minPassing = 60; + int maxScore = 100; + + await Assert.That(score).IsBetween(minPassing, maxScore); + await Assert.That(score).IsGreaterThanOrEqualTo(minPassing); +} +``` + +## Mathematical Operations + +### Addition + +```csharp +[Test] +public async Task Addition() +{ + var result = 5 + 3; + + await Assert.That(result).IsEqualTo(8); + await Assert.That(result).IsPositive(); + await Assert.That(result).IsGreaterThan(5); +} +``` + +### Subtraction + +```csharp +[Test] +public async Task Subtraction() +{ + var result = 10 - 3; + + await Assert.That(result).IsEqualTo(7); + await Assert.That(result).IsPositive(); +} +``` + +### Multiplication + +```csharp +[Test] +public async Task Multiplication() +{ + var result = 4 * 5; + + await Assert.That(result).IsEqualTo(20); + await Assert.That(result).IsPositive(); +} +``` + +### Division + +```csharp +[Test] +public async Task Division() +{ + double result = 10.0 / 4.0; + + await Assert.That(result).IsEqualTo(2.5, tolerance: 0.001); + await Assert.That(result).IsPositive(); +} +``` + +### Modulo + +```csharp +[Test] +public async Task Modulo() +{ + var result = 17 % 5; + + await Assert.That(result).IsEqualTo(2); + await Assert.That(result).IsGreaterThanOrEqualTo(0); + await Assert.That(result).IsLessThan(5); +} +``` + +## Working with Math Library + +### Rounding + +```csharp +[Test] +public async Task Math_Round() +{ + double value = 3.7; + double rounded = Math.Round(value); + + await Assert.That(rounded).IsEqualTo(4.0, tolerance: 0.001); +} +``` + +### Ceiling and Floor + +```csharp +[Test] +public async Task Math_Ceiling_Floor() +{ + double value = 3.2; + + double ceiling = Math.Ceiling(value); + await Assert.That(ceiling).IsEqualTo(4.0); + + double floor = Math.Floor(value); + await Assert.That(floor).IsEqualTo(3.0); +} +``` + +### Absolute Value + +```csharp +[Test] +public async Task Math_Abs() +{ + int negative = -42; + int positive = Math.Abs(negative); + + await Assert.That(positive).IsPositive(); + await Assert.That(positive).IsEqualTo(42); +} +``` + +### Power and Square Root + +```csharp +[Test] +public async Task Math_Power_Sqrt() +{ + double squared = Math.Pow(5, 2); + await Assert.That(squared).IsEqualTo(25.0, tolerance: 0.001); + + double root = Math.Sqrt(25); + await Assert.That(root).IsEqualTo(5.0, tolerance: 0.001); +} +``` + +### Trigonometry + +```csharp +[Test] +public async Task Math_Trigonometry() +{ + double angle = Math.PI / 4; // 45 degrees + double sine = Math.Sin(angle); + + await Assert.That(sine).IsEqualTo(Math.Sqrt(2) / 2, tolerance: 0.0001); + await Assert.That(sine).IsPositive(); + await Assert.That(sine).IsBetween(0, 1); +} +``` + +## Increment and Decrement + +```csharp +[Test] +public async Task Increment_Decrement() +{ + int counter = 0; + + counter++; + await Assert.That(counter).IsEqualTo(1); + await Assert.That(counter).IsPositive(); + + counter--; + await Assert.That(counter).IsEqualTo(0); + + counter--; + await Assert.That(counter).IsEqualTo(-1); + await Assert.That(counter).IsNegative(); +} +``` + +## Chaining Numeric Assertions + +```csharp +[Test] +public async Task Chained_Numeric_Assertions() +{ + int score = 85; + + await Assert.That(score) + .IsPositive() + .And.IsGreaterThan(70) + .And.IsLessThan(100) + .And.IsBetween(80, 90); +} +``` + +## Nullable Numeric Types + +```csharp +[Test] +public async Task Nullable_Numerics() +{ + int? value = 42; + + await Assert.That(value).IsNotNull(); + await Assert.That(value).IsEqualTo(42); + await Assert.That(value).IsPositive(); +} + +[Test] +public async Task Nullable_Null() +{ + int? value = null; + + await Assert.That(value).IsNull(); +} +``` + +## Special Floating-Point Values + +### Infinity + +```csharp +[Test] +public async Task Infinity_Checks() +{ + double positiveInfinity = double.PositiveInfinity; + double negativeInfinity = double.NegativeInfinity; + + await Assert.That(positiveInfinity).IsEqualTo(double.PositiveInfinity); + await Assert.That(negativeInfinity).IsEqualTo(double.NegativeInfinity); +} +``` + +### NaN (Not a Number) + +```csharp +[Test] +public async Task NaN_Checks() +{ + double nan = double.NaN; + + // NaN is never equal to itself + await Assert.That(double.IsNaN(nan)).IsTrue(); + + // Can't use IsEqualTo with NaN + // await Assert.That(nan).IsEqualTo(double.NaN); // ❌ Won't work +} +``` + +## Performance Metrics + +```csharp +[Test] +public async Task Response_Time_Check() +{ + var stopwatch = Stopwatch.StartNew(); + await PerformOperationAsync(); + stopwatch.Stop(); + + long milliseconds = stopwatch.ElapsedMilliseconds; + + await Assert.That(milliseconds).IsPositive(); + await Assert.That(milliseconds).IsLessThan(1000); // Under 1 second +} +``` + +## Common Patterns + +### Boundary Testing + +```csharp +[Test] +public async Task Boundary_Values() +{ + int min = int.MinValue; + int max = int.MaxValue; + + await Assert.That(min).IsNegative(); + await Assert.That(max).IsPositive(); + await Assert.That(min).IsLessThan(max); +} +``` + +### Growth Rate Validation + +```csharp +[Test] +public async Task Growth_Rate() +{ + decimal previousValue = 100m; + decimal currentValue = 125m; + decimal growthRate = (currentValue - previousValue) / previousValue * 100; + + await Assert.That(growthRate).IsPositive(); + await Assert.That(growthRate).IsEqualTo(25m, tolerance: 0.1m); +} +``` + +### Ratio Calculations + +```csharp +[Test] +public async Task Success_Ratio() +{ + int successful = 85; + int total = 100; + double ratio = (double)successful / total; + + await Assert.That(ratio).IsPositive(); + await Assert.That(ratio).IsBetween(0, 1); + await Assert.That(ratio).IsGreaterThan(0.8); // 80% threshold +} +``` + +## See Also + +- [Equality & Comparison](equality-and-comparison.md) - General comparison assertions +- [DateTime Assertions](datetime.md) - Time-based numeric values with tolerance +- [Collections](collections.md) - Numeric operations on collections (Count, Sum, Average) diff --git a/docs/docs/assertions/specialized-types.md b/docs/docs/assertions/specialized-types.md new file mode 100644 index 0000000000..6cefde9117 --- /dev/null +++ b/docs/docs/assertions/specialized-types.md @@ -0,0 +1,765 @@ +--- +sidebar_position: 12 +--- + +# Specialized Type Assertions + +TUnit provides assertions for many specialized .NET types beyond the common primitives. This page covers GUID, HTTP, file system, networking, and other specialized assertions. + +## GUID Assertions + +### IsEmptyGuid / IsNotEmptyGuid + +Tests whether a GUID is empty (`Guid.Empty`): + +```csharp +[Test] +public async Task GUID_Is_Empty() +{ + var emptyGuid = Guid.Empty; + await Assert.That(emptyGuid).IsEmptyGuid(); + + var newGuid = Guid.NewGuid(); + await Assert.That(newGuid).IsNotEmptyGuid(); +} +``` + +Practical usage: + +```csharp +[Test] +public async Task Entity_Has_Valid_ID() +{ + var entity = new Entity { Id = Guid.NewGuid() }; + + await Assert.That(entity.Id).IsNotEmptyGuid(); + await Assert.That(entity.Id).IsNotEqualTo(Guid.Empty); +} +``` + +## HTTP Status Code Assertions + +### IsSuccess + +Tests for 2xx success status codes: + +```csharp +[Test] +public async Task HTTP_Success_Status() +{ + var response = await _client.GetAsync("/api/users"); + + await Assert.That(response.StatusCode).IsSuccess(); +} +``` + +Works with all 2xx codes: + +```csharp +[Test] +public async Task Various_Success_Codes() +{ + await Assert.That(HttpStatusCode.OK).IsSuccess(); // 200 + await Assert.That(HttpStatusCode.Created).IsSuccess(); // 201 + await Assert.That(HttpStatusCode.Accepted).IsSuccess(); // 202 + await Assert.That(HttpStatusCode.NoContent).IsSuccess(); // 204 +} +``` + +### IsNotSuccess + +```csharp +[Test] +public async Task HTTP_Not_Success() +{ + await Assert.That(HttpStatusCode.NotFound).IsNotSuccess(); // 404 + await Assert.That(HttpStatusCode.InternalServerError).IsNotSuccess(); // 500 +} +``` + +### IsClientError + +Tests for 4xx client error status codes: + +```csharp +[Test] +public async Task HTTP_Client_Error() +{ + await Assert.That(HttpStatusCode.BadRequest).IsClientError(); // 400 + await Assert.That(HttpStatusCode.Unauthorized).IsClientError(); // 401 + await Assert.That(HttpStatusCode.Forbidden).IsClientError(); // 403 + await Assert.That(HttpStatusCode.NotFound).IsClientError(); // 404 +} +``` + +### IsServerError + +Tests for 5xx server error status codes: + +```csharp +[Test] +public async Task HTTP_Server_Error() +{ + await Assert.That(HttpStatusCode.InternalServerError).IsServerError(); // 500 + await Assert.That(HttpStatusCode.BadGateway).IsServerError(); // 502 + await Assert.That(HttpStatusCode.ServiceUnavailable).IsServerError(); // 503 +} +``` + +### IsRedirection + +Tests for 3xx redirection status codes: + +```csharp +[Test] +public async Task HTTP_Redirection() +{ + await Assert.That(HttpStatusCode.MovedPermanently).IsRedirection(); // 301 + await Assert.That(HttpStatusCode.Found).IsRedirection(); // 302 + await Assert.That(HttpStatusCode.TemporaryRedirect).IsRedirection(); // 307 +} +``` + +## CancellationToken Assertions + +### IsCancellationRequested / IsNotCancellationRequested + +```csharp +[Test] +public async Task CancellationToken_Is_Requested() +{ + var cts = new CancellationTokenSource(); + cts.Cancel(); + + await Assert.That(cts.Token).IsCancellationRequested(); +} + +[Test] +public async Task CancellationToken_Not_Requested() +{ + var cts = new CancellationTokenSource(); + + await Assert.That(cts.Token).IsNotCancellationRequested(); +} +``` + +### CanBeCanceled / CannotBeCanceled + +```csharp +[Test] +public async Task Token_Can_Be_Canceled() +{ + var cts = new CancellationTokenSource(); + + await Assert.That(cts.Token).CanBeCanceled(); +} + +[Test] +public async Task Default_Token_Cannot_Be_Canceled() +{ + var token = CancellationToken.None; + + await Assert.That(token).CannotBeCanceled(); +} +``` + +## Character Assertions + +### IsLetter / IsNotLetter + +```csharp +[Test] +public async Task Char_Is_Letter() +{ + await Assert.That('A').IsLetter(); + await Assert.That('z').IsLetter(); + + await Assert.That('5').IsNotLetter(); + await Assert.That('!').IsNotLetter(); +} +``` + +### IsDigit / IsNotDigit + +```csharp +[Test] +public async Task Char_Is_Digit() +{ + await Assert.That('0').IsDigit(); + await Assert.That('9').IsDigit(); + + await Assert.That('A').IsNotDigit(); +} +``` + +### IsWhiteSpace / IsNotWhiteSpace + +```csharp +[Test] +public async Task Char_Is_WhiteSpace() +{ + await Assert.That(' ').IsWhiteSpace(); + await Assert.That('\t').IsWhiteSpace(); + await Assert.That('\n').IsWhiteSpace(); + + await Assert.That('A').IsNotWhiteSpace(); +} +``` + +### IsUpper / IsNotUpper + +```csharp +[Test] +public async Task Char_Is_Upper() +{ + await Assert.That('A').IsUpper(); + await Assert.That('Z').IsUpper(); + + await Assert.That('a').IsNotUpper(); +} +``` + +### IsLower / IsNotLower + +```csharp +[Test] +public async Task Char_Is_Lower() +{ + await Assert.That('a').IsLower(); + await Assert.That('z').IsLower(); + + await Assert.That('A').IsNotLower(); +} +``` + +### IsPunctuation / IsNotPunctuation + +```csharp +[Test] +public async Task Char_Is_Punctuation() +{ + await Assert.That('.').IsPunctuation(); + await Assert.That(',').IsPunctuation(); + await Assert.That('!').IsPunctuation(); + + await Assert.That('A').IsNotPunctuation(); +} +``` + +## File System Assertions + +### DirectoryInfo + +#### Exists / DoesNotExist + +```csharp +[Test] +public async Task Directory_Exists() +{ + var tempDir = new DirectoryInfo(Path.GetTempPath()); + + await Assert.That(tempDir).Exists(); +} + +[Test] +public async Task Directory_Does_Not_Exist() +{ + var nonExistent = new DirectoryInfo(@"C:\NonExistentFolder"); + + await Assert.That(nonExistent).DoesNotExist(); +} +``` + +#### HasFiles / IsEmpty + +```csharp +[Test] +public async Task Directory_Has_Files() +{ + var tempDir = new DirectoryInfo(Path.GetTempPath()); + + // Likely has files + await Assert.That(tempDir).HasFiles(); +} + +[Test] +public async Task Directory_Is_Empty() +{ + var emptyDir = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString())); + + await Assert.That(emptyDir).IsEmpty(); + + // Cleanup + emptyDir.Delete(); +} +``` + +#### HasSubdirectories / HasNoSubdirectories + +```csharp +[Test] +public async Task Directory_Has_Subdirectories() +{ + var windowsDir = new DirectoryInfo(@"C:\Windows"); + + await Assert.That(windowsDir).HasSubdirectories(); +} +``` + +### FileInfo + +#### Exists / DoesNotExist + +```csharp +[Test] +public async Task File_Exists() +{ + var tempFile = Path.GetTempFileName(); + var fileInfo = new FileInfo(tempFile); + + await Assert.That(fileInfo).Exists(); + + // Cleanup + File.Delete(tempFile); +} + +[Test] +public async Task File_Does_Not_Exist() +{ + var nonExistent = new FileInfo(@"C:\nonexistent.txt"); + + await Assert.That(nonExistent).DoesNotExist(); +} +``` + +#### IsReadOnly / IsNotReadOnly + +```csharp +[Test] +public async Task File_Is_ReadOnly() +{ + var tempFile = Path.GetTempFileName(); + var fileInfo = new FileInfo(tempFile); + + fileInfo.IsReadOnly = true; + await Assert.That(fileInfo).IsReadOnly(); + + fileInfo.IsReadOnly = false; + await Assert.That(fileInfo).IsNotReadOnly(); + + // Cleanup + File.Delete(tempFile); +} +``` + +#### IsHidden / IsNotHidden + +```csharp +[Test] +public async Task File_Is_Hidden() +{ + var tempFile = Path.GetTempFileName(); + var fileInfo = new FileInfo(tempFile); + + fileInfo.Attributes |= FileAttributes.Hidden; + await Assert.That(fileInfo).IsHidden(); + + // Cleanup + fileInfo.Attributes &= ~FileAttributes.Hidden; + File.Delete(tempFile); +} +``` + +#### IsSystem / IsNotSystem + +```csharp +[Test] +public async Task File_Is_System() +{ + // System files are typically in System32 + var systemFile = new FileInfo(@"C:\Windows\System32\kernel32.dll"); + + if (systemFile.Exists) + { + await Assert.That(systemFile).IsSystem(); + } +} +``` + +#### IsExecutable / IsNotExecutable + +```csharp +[Test] +public async Task File_Is_Executable() +{ + var exeFile = new FileInfo(@"C:\Windows\notepad.exe"); + + if (exeFile.Exists) + { + await Assert.That(exeFile).IsExecutable(); + } +} +``` + +## IP Address Assertions + +### IsIPv4 / IsNotIPv4 + +```csharp +[Test] +public async Task IP_Is_IPv4() +{ + var ipv4 = IPAddress.Parse("192.168.1.1"); + + await Assert.That(ipv4).IsIPv4(); +} + +[Test] +public async Task IP_Not_IPv4() +{ + var ipv6 = IPAddress.Parse("::1"); + + await Assert.That(ipv6).IsNotIPv4(); +} +``` + +### IsIPv6 / IsNotIPv6 + +```csharp +[Test] +public async Task IP_Is_IPv6() +{ + var ipv6 = IPAddress.Parse("2001:0db8:85a3:0000:0000:8a2e:0370:7334"); + + await Assert.That(ipv6).IsIPv6(); +} + +[Test] +public async Task IP_Not_IPv6() +{ + var ipv4 = IPAddress.Parse("127.0.0.1"); + + await Assert.That(ipv4).IsNotIPv6(); +} +``` + +## Lazy\ Assertions + +### IsValueCreated / IsNotValueCreated + +```csharp +[Test] +public async Task Lazy_Value_Not_Created() +{ + var lazy = new Lazy(() => 42); + + await Assert.That(lazy).IsNotValueCreated(); + + var value = lazy.Value; + + await Assert.That(lazy).IsValueCreated(); + await Assert.That(value).IsEqualTo(42); +} +``` + +## Stream Assertions + +### CanRead / CannotRead + +```csharp +[Test] +public async Task Stream_Can_Read() +{ + using var stream = new MemoryStream(); + + await Assert.That(stream).CanRead(); +} +``` + +### CanWrite / CannotWrite + +```csharp +[Test] +public async Task Stream_Can_Write() +{ + using var stream = new MemoryStream(); + + await Assert.That(stream).CanWrite(); +} + +[Test] +public async Task Stream_Cannot_Write() +{ + var readOnlyStream = new MemoryStream(new byte[10], writable: false); + + await Assert.That(readOnlyStream).CannotWrite(); +} +``` + +### CanSeek / CannotSeek + +```csharp +[Test] +public async Task Stream_Can_Seek() +{ + using var stream = new MemoryStream(); + + await Assert.That(stream).CanSeek(); +} +``` + +### CanTimeout / CannotTimeout + +```csharp +[Test] +public async Task Network_Stream_Can_Timeout() +{ + using var client = new TcpClient(); + // Note: stream only available after connection + // await Assert.That(stream).CanTimeout(); +} +``` + +## Process Assertions + +### HasExited / HasNotExited + +```csharp +[Test] +public async Task Process_Has_Not_Exited() +{ + var process = Process.Start("notepad.exe"); + + await Assert.That(process).HasNotExited(); + + process.Kill(); + process.WaitForExit(); + + await Assert.That(process).HasExited(); +} +``` + +### IsResponding / IsNotResponding + +```csharp +[Test] +public async Task Process_Is_Responding() +{ + var process = Process.GetCurrentProcess(); + + await Assert.That(process).IsResponding(); +} +``` + +## Thread Assertions + +### IsAlive / IsNotAlive + +```csharp +[Test] +public async Task Thread_Is_Alive() +{ + var thread = new Thread(() => Thread.Sleep(1000)); + thread.Start(); + + await Assert.That(thread).IsAlive(); + + thread.Join(); + await Assert.That(thread).IsNotAlive(); +} +``` + +### IsBackground / IsNotBackground + +```csharp +[Test] +public async Task Thread_Is_Background() +{ + var thread = new Thread(() => { }); + thread.IsBackground = true; + + await Assert.That(thread).IsBackground(); +} +``` + +### IsThreadPoolThread / IsNotThreadPoolThread + +```csharp +[Test] +public async Task Check_ThreadPool_Thread() +{ + var currentThread = Thread.CurrentThread; + + // Test thread is typically not a thread pool thread + await Assert.That(currentThread).IsNotThreadPoolThread(); +} +``` + +## WeakReference Assertions + +### IsAlive / IsNotAlive + +```csharp +[Test] +public async Task WeakReference_Is_Alive() +{ + var obj = new object(); + var weakRef = new WeakReference(obj); + + await Assert.That(weakRef).IsAlive(); + + obj = null!; + GC.Collect(); + GC.WaitForPendingFinalizers(); + + await Assert.That(weakRef).IsNotAlive(); +} +``` + +## URI Assertions + +### IsAbsoluteUri / IsNotAbsoluteUri + +```csharp +[Test] +public async Task URI_Is_Absolute() +{ + var absolute = new Uri("https://example.com/path"); + + await Assert.That(absolute).IsAbsoluteUri(); +} + +[Test] +public async Task URI_Is_Relative() +{ + var relative = new Uri("/path/to/resource", UriKind.Relative); + + await Assert.That(relative).IsNotAbsoluteUri(); +} +``` + +## Encoding Assertions + +### IsUtf8 / IsNotUtf8 + +```csharp +[Test] +public async Task Encoding_Is_UTF8() +{ + var encoding = Encoding.UTF8; + + await Assert.That(encoding).IsUtf8(); +} + +[Test] +public async Task Encoding_Not_UTF8() +{ + var encoding = Encoding.ASCII; + + await Assert.That(encoding).IsNotUtf8(); +} +``` + +## Version Assertions + +Version comparisons using standard comparison operators: + +```csharp +[Test] +public async Task Version_Comparison() +{ + var v1 = new Version(1, 0, 0); + var v2 = new Version(2, 0, 0); + + await Assert.That(v2).IsGreaterThan(v1); + await Assert.That(v1).IsLessThan(v2); +} +``` + +## DayOfWeek Assertions + +### IsWeekend / IsNotWeekend + +```csharp +[Test] +public async Task Day_Is_Weekend() +{ + await Assert.That(DayOfWeek.Saturday).IsWeekend(); + await Assert.That(DayOfWeek.Sunday).IsWeekend(); +} +``` + +### IsWeekday / IsNotWeekday + +```csharp +[Test] +public async Task Day_Is_Weekday() +{ + await Assert.That(DayOfWeek.Monday).IsWeekday(); + await Assert.That(DayOfWeek.Tuesday).IsWeekday(); + await Assert.That(DayOfWeek.Wednesday).IsWeekday(); + await Assert.That(DayOfWeek.Thursday).IsWeekday(); + await Assert.That(DayOfWeek.Friday).IsWeekday(); +} +``` + +## Practical Examples + +### API Testing + +```csharp +[Test] +public async Task API_Returns_Success() +{ + var response = await _client.GetAsync("/api/health"); + + await Assert.That(response.StatusCode).IsSuccess(); + await Assert.That(response.StatusCode).IsNotEqualTo(HttpStatusCode.InternalServerError); +} +``` + +### File Upload Validation + +```csharp +[Test] +public async Task Uploaded_File_Validation() +{ + var uploadedFile = new FileInfo("upload.txt"); + + await Assert.That(uploadedFile).Exists(); + await Assert.That(uploadedFile).IsNotReadOnly(); + await Assert.That(uploadedFile.Length).IsGreaterThan(0); +} +``` + +### Configuration Directory Check + +```csharp +[Test] +public async Task Config_Directory_Setup() +{ + var configDir = new DirectoryInfo(@"C:\ProgramData\MyApp"); + + await Assert.That(configDir).Exists(); + await Assert.That(configDir).HasFiles(); +} +``` + +### Network Validation + +```csharp +[Test] +public async Task Server_IP_Is_Valid() +{ + var serverIp = IPAddress.Parse(Configuration["ServerIP"]); + + await Assert.That(serverIp).IsIPv4(); +} +``` + +## See Also + +- [Boolean](boolean.md) - For boolean properties of specialized types +- [String](string.md) - For string conversions and properties +- [Collections](collections.md) - For collections of specialized types +- [Types](types.md) - For type checking specialized types diff --git a/docs/docs/assertions/string.md b/docs/docs/assertions/string.md new file mode 100644 index 0000000000..7ffadc6bd6 --- /dev/null +++ b/docs/docs/assertions/string.md @@ -0,0 +1,766 @@ +--- +sidebar_position: 5.5 +--- + +# String Assertions + +TUnit provides rich assertions for testing strings, including substring matching, pattern matching, length checks, and various string comparison options. + +## Content Assertions + +### Contains + +Tests that a string contains a substring: + +```csharp +[Test] +public async Task String_Contains() +{ + var message = "Hello, World!"; + + await Assert.That(message).Contains("World"); + await Assert.That(message).Contains("Hello"); + await Assert.That(message).Contains(", "); +} +``` + +#### Case-Insensitive Contains + +```csharp +[Test] +public async Task Contains_Ignoring_Case() +{ + var message = "Hello, World!"; + + await Assert.That(message) + .Contains("world") + .IgnoringCase(); + + await Assert.That(message) + .Contains("HELLO") + .IgnoringCase(); +} +``` + +#### With String Comparison + +```csharp +[Test] +public async Task Contains_With_Comparison() +{ + var message = "Hello, World!"; + + await Assert.That(message) + .Contains("world") + .WithComparison(StringComparison.OrdinalIgnoreCase); +} +``` + +#### With Trimming + +```csharp +[Test] +public async Task Contains_With_Trimming() +{ + var message = " Hello, World! "; + + await Assert.That(message) + .Contains("Hello, World!") + .WithTrimming(); +} +``` + +#### Ignoring Whitespace + +```csharp +[Test] +public async Task Contains_Ignoring_Whitespace() +{ + var message = "Hello, World!"; + + await Assert.That(message) + .Contains("Hello, World!") + .IgnoringWhitespace(); +} +``` + +### DoesNotContain + +Tests that a string does not contain a substring: + +```csharp +[Test] +public async Task String_Does_Not_Contain() +{ + var message = "Hello, World!"; + + await Assert.That(message).DoesNotContain("Goodbye"); + await Assert.That(message).DoesNotContain("xyz"); +} +``` + +All modifiers work with `DoesNotContain`: + +```csharp +[Test] +public async Task Does_Not_Contain_Ignoring_Case() +{ + var message = "Hello, World!"; + + await Assert.That(message) + .DoesNotContain("goodbye") + .IgnoringCase(); +} +``` + +### StartsWith + +Tests that a string starts with a specific prefix: + +```csharp +[Test] +public async Task String_Starts_With() +{ + var filename = "report_2024.pdf"; + + await Assert.That(filename).StartsWith("report"); + + var url = "https://example.com"; + await Assert.That(url).StartsWith("https://"); +} +``` + +With case-insensitive comparison: + +```csharp +[Test] +public async Task Starts_With_Ignoring_Case() +{ + var filename = "Report_2024.pdf"; + + await Assert.That(filename) + .StartsWith("report") + .IgnoringCase(); +} +``` + +### EndsWith + +Tests that a string ends with a specific suffix: + +```csharp +[Test] +public async Task String_Ends_With() +{ + var filename = "document.pdf"; + + await Assert.That(filename).EndsWith(".pdf"); + + var email = "user@example.com"; + await Assert.That(email).EndsWith("example.com"); +} +``` + +With case-insensitive comparison: + +```csharp +[Test] +public async Task Ends_With_Ignoring_Case() +{ + var filename = "document.PDF"; + + await Assert.That(filename) + .EndsWith(".pdf") + .IgnoringCase(); +} +``` + +## Pattern Matching + +### Matches (Regex) + +Tests that a string matches a regular expression pattern: + +```csharp +[Test] +public async Task String_Matches_Pattern() +{ + var email = "test@example.com"; + + await Assert.That(email).Matches(@"^[\w\.-]+@[\w\.-]+\.\w+$"); +} +``` + +With a compiled `Regex`: + +```csharp +[Test] +public async Task Matches_With_Regex() +{ + var phoneNumber = "(123) 456-7890"; + var pattern = new Regex(@"^\(\d{3}\) \d{3}-\d{4}$"); + + await Assert.That(phoneNumber).Matches(pattern); +} +``` + +#### Case-Insensitive Matching + +```csharp +[Test] +public async Task Matches_Ignoring_Case() +{ + var text = "Hello World"; + + await Assert.That(text) + .Matches("hello.*world") + .IgnoringCase(); +} +``` + +#### With Regex Options + +```csharp +[Test] +public async Task Matches_With_Options() +{ + var multiline = "Line 1\nLine 2\nLine 3"; + + await Assert.That(multiline) + .Matches("^Line 2$") + .WithOptions(RegexOptions.Multiline); +} +``` + +### DoesNotMatch + +Tests that a string does not match a pattern: + +```csharp +[Test] +public async Task String_Does_Not_Match() +{ + var text = "abc123"; + + await Assert.That(text).DoesNotMatch(@"^\d+$"); // Not all digits +} +``` + +## Length Assertions + +### IsEmpty + +Tests that a string is empty (`""`): + +```csharp +[Test] +public async Task String_Is_Empty() +{ + var emptyString = ""; + + await Assert.That(emptyString).IsEmpty(); +} +``` + +Note: This checks for an empty string, not `null`: + +```csharp +[Test] +public async Task Empty_vs_Null() +{ + string? nullString = null; + string emptyString = ""; + + await Assert.That(nullString).IsNull(); // null + await Assert.That(emptyString).IsEmpty(); // not null, but empty + await Assert.That(emptyString).IsNotNull(); // also passes +} +``` + +### IsNotEmpty + +Tests that a string is not empty: + +```csharp +[Test] +public async Task String_Is_Not_Empty() +{ + var text = "Hello"; + + await Assert.That(text).IsNotEmpty(); +} +``` + +### HasLength + +Tests that a string has a specific length: + +```csharp +[Test] +public async Task String_Has_Length() +{ + var code = "ABC123"; + + await Assert.That(code).HasLength(6); +} +``` + +With chained comparison: + +```csharp +[Test] +public async Task Length_With_Comparison() +{ + var username = "alice"; + + await Assert.That(username) + .HasLength().GreaterThan(3) + .And.HasLength().LessThan(20); +} +``` + +Or more concisely: + +```csharp +[Test] +public async Task Length_Range() +{ + var username = "alice"; + + await Assert.That(username.Length).IsBetween(3, 20); +} +``` + +## Equality with Options + +### IsEqualTo + +String equality with various comparison options: + +```csharp +[Test] +public async Task String_Equality() +{ + var actual = "Hello"; + var expected = "Hello"; + + await Assert.That(actual).IsEqualTo(expected); +} +``` + +#### Ignoring Case + +```csharp +[Test] +public async Task Equality_Ignoring_Case() +{ + var actual = "Hello"; + var expected = "HELLO"; + + await Assert.That(actual) + .IsEqualTo(expected) + .IgnoringCase(); +} +``` + +#### With String Comparison + +```csharp +[Test] +public async Task Equality_With_Comparison() +{ + var actual = "café"; + var expected = "CAFÉ"; + + await Assert.That(actual) + .IsEqualTo(expected) + .WithComparison(StringComparison.CurrentCultureIgnoreCase); +} +``` + +## String Parsing + +You can parse strings to other types and assert on the result: + +```csharp +[Test] +public async Task Parse_String_To_Int() +{ + var text = "42"; + + var number = await Assert.That(text).WhenParsedInto(); + await Assert.That(number).IsEqualTo(42); +} +``` + +```csharp +[Test] +public async Task Parse_String_To_DateTime() +{ + var text = "2024-01-15"; + + var date = await Assert.That(text).WhenParsedInto(); + await Assert.That(date.Year).IsEqualTo(2024); +} +``` + +## Practical Examples + +### Email Validation + +```csharp +[Test] +public async Task Validate_Email() +{ + var email = "user@example.com"; + + await Assert.That(email) + .Contains("@") + .And.Matches(@"^[\w\.-]+@[\w\.-]+\.\w+$") + .And.DoesNotContain(" "); +} +``` + +### URL Validation + +```csharp +[Test] +public async Task Validate_URL() +{ + var url = "https://www.example.com/path"; + + await Assert.That(url) + .StartsWith("https://") + .And.Contains("example.com") + .And.Matches(@"^https?://[\w\.-]+"); +} +``` + +### File Extension Check + +```csharp +[Test] +public async Task Check_File_Extension() +{ + var filename = "document.pdf"; + + await Assert.That(filename) + .EndsWith(".pdf") + .IgnoringCase(); +} +``` + +### Username Validation + +```csharp +[Test] +public async Task Validate_Username() +{ + var username = "alice_123"; + + await Assert.That(username) + .HasLength().GreaterThanOrEqualTo(3) + .And.HasLength().LessThanOrEqualTo(20) + .And.Matches(@"^[a-zA-Z0-9_]+$") + .And.DoesNotContain(" "); +} +``` + +### Password Requirements + +```csharp +[Test] +public async Task Validate_Password() +{ + var password = "SecureP@ss123"; + + await Assert.That(password) + .HasLength().GreaterThanOrEqualTo(8) + .And.Matches(@"[A-Z]") // Has uppercase + .And.Matches(@"[a-z]") // Has lowercase + .And.Matches(@"\d") // Has digit + .And.Matches(@"[@$!%*?&]"); // Has special char +} +``` + +### JSON String Content + +```csharp +[Test] +public async Task Check_JSON_Content() +{ + var json = """{"name":"Alice","age":30}"""; + + await Assert.That(json) + .Contains("\"name\"") + .And.Contains("\"Alice\"") + .And.StartsWith("{") + .And.EndsWith("}"); +} +``` + +### SQL Query Validation + +```csharp +[Test] +public async Task Validate_SQL_Query() +{ + var query = "SELECT * FROM Users WHERE Age > 18"; + + await Assert.That(query) + .StartsWith("SELECT") + .And.Contains("FROM Users") + .And.Matches(@"WHERE\s+\w+\s*[><=]"); +} +``` + +## Null and Empty Checks Combined + +### IsNullOrEmpty Equivalent + +```csharp +[Test] +public async Task Check_Null_Or_Empty() +{ + string? text = GetOptionalString(); + + // Option 1: Check both conditions + if (string.IsNullOrEmpty(text)) + { + // Handle null or empty + } + else + { + await Assert.That(text).IsNotNull(); + await Assert.That(text).IsNotEmpty(); + } +} +``` + +### IsNullOrWhiteSpace Equivalent + +```csharp +[Test] +public async Task Check_Null_Or_Whitespace() +{ + string? text = " "; + + await Assert.That(string.IsNullOrWhiteSpace(text)).IsTrue(); +} +``` + +Better with trimming: + +```csharp +[Test] +public async Task Require_Non_Whitespace() +{ + string? text = GetInput(); + + await Assert.That(text) + .IsNotNull() + .And.IsNotEmpty(); + + var trimmed = text.Trim(); + await Assert.That(trimmed).IsNotEmpty(); +} +``` + +## StringBuilder Assertions + +TUnit also supports assertions on `StringBuilder`: + +```csharp +[Test] +public async Task StringBuilder_Tests() +{ + var builder = new StringBuilder(); + builder.Append("Hello"); + builder.Append(" "); + builder.Append("World"); + + var result = builder.ToString(); + + await Assert.That(result).IsEqualTo("Hello World"); + await Assert.That(result).Contains("Hello"); +} +``` + +## Chaining String Assertions + +```csharp +[Test] +public async Task Chained_String_Assertions() +{ + var input = "Hello, World!"; + + await Assert.That(input) + .IsNotNull() + .And.IsNotEmpty() + .And.Contains("World") + .And.StartsWith("Hello") + .And.EndsWith("!") + .And.HasLength(13); +} +``` + +## Case Sensitivity Patterns + +```csharp +[Test] +public async Task Case_Sensitivity() +{ + var text = "TUnit Framework"; + + // Case-sensitive (default) + await Assert.That(text).Contains("TUnit"); + + // Case-insensitive + await Assert.That(text) + .Contains("tunit") + .IgnoringCase(); + + // Using StringComparison + await Assert.That(text) + .Contains("framework") + .WithComparison(StringComparison.OrdinalIgnoreCase); +} +``` + +## String Formatting Validation + +```csharp +[Test] +public async Task Formatted_String() +{ + var name = "Alice"; + var age = 30; + var message = $"User {name} is {age} years old"; + + await Assert.That(message) + .IsEqualTo("User Alice is 30 years old") + .And.Contains(name) + .And.Contains(age.ToString()); +} +``` + +## Multi-line Strings + +```csharp +[Test] +public async Task Multiline_String() +{ + var multiline = """ + Line 1 + Line 2 + Line 3 + """; + + await Assert.That(multiline) + .Contains("Line 1") + .And.Contains("Line 2") + .And.Matches("Line.*Line.*Line"); +} +``` + +## Common String Comparison Options + +```csharp +[Test] +public async Task String_Comparison_Options() +{ + var text = "Hello"; + + // Ordinal (binary comparison) + await Assert.That(text) + .IsEqualTo("hello") + .WithComparison(StringComparison.OrdinalIgnoreCase); + + // Current culture + await Assert.That(text) + .IsEqualTo("hello") + .WithComparison(StringComparison.CurrentCultureIgnoreCase); + + // Invariant culture + await Assert.That(text) + .IsEqualTo("hello") + .WithComparison(StringComparison.InvariantCultureIgnoreCase); +} +``` + +## Path Validation + +```csharp +[Test] +public async Task File_Path_Validation() +{ + var path = @"C:\Users\Alice\Documents\file.txt"; + + await Assert.That(path) + .Contains(@"\") + .And.EndsWith(".txt") + .And.Matches(@"[A-Z]:\\"); +} +``` + +Unix path: + +```csharp +[Test] +public async Task Unix_Path_Validation() +{ + var path = "/home/alice/documents/file.txt"; + + await Assert.That(path) + .StartsWith("/") + .And.Contains("/") + .And.EndsWith(".txt"); +} +``` + +## Common Patterns + +### Trim and Assert + +```csharp +[Test] +public async Task Trim_Before_Assert() +{ + var input = " Hello "; + var trimmed = input.Trim(); + + await Assert.That(trimmed).IsEqualTo("Hello"); +} +``` + +### Case Normalization + +```csharp +[Test] +public async Task Normalize_Case() +{ + var input = "Hello World"; + var lower = input.ToLowerInvariant(); + + await Assert.That(lower).IsEqualTo("hello world"); +} +``` + +### Substring Extraction + +```csharp +[Test] +public async Task Extract_Substring() +{ + var text = "Hello, World!"; + var greeting = text.Substring(0, 5); + + await Assert.That(greeting).IsEqualTo("Hello"); +} +``` + +## See Also + +- [Equality & Comparison](equality-and-comparison.md) - String equality assertions +- [Collections](collections.md) - Working with collections of strings +- [Null & Default](null-and-default.md) - Null string checks diff --git a/docs/docs/assertions/tasks-and-async.md b/docs/docs/assertions/tasks-and-async.md new file mode 100644 index 0000000000..2050ef62c9 --- /dev/null +++ b/docs/docs/assertions/tasks-and-async.md @@ -0,0 +1,537 @@ +--- +sidebar_position: 11 +--- + +# Task and Async Assertions + +TUnit provides specialized assertions for testing `Task` and `Task` objects, including state checking, completion timeouts, and async exception handling. + +## Task State Assertions + +### IsCompleted / IsNotCompleted + +Tests whether a task has completed (successfully, faulted, or canceled): + +```csharp +[Test] +public async Task Task_Is_Completed() +{ + var completedTask = Task.CompletedTask; + await Assert.That(completedTask).IsCompleted(); + + var runningTask = Task.Delay(10000); + await Assert.That(runningTask).IsNotCompleted(); +} +``` + +### IsCanceled / IsNotCanceled + +Tests whether a task was canceled: + +```csharp +[Test] +public async Task Task_Is_Canceled() +{ + var cts = new CancellationTokenSource(); + cts.Cancel(); + + var task = Task.Run(() => { }, cts.Token); + + try + { + await task; + } + catch (TaskCanceledException) + { + // Expected + } + + await Assert.That(task).IsCanceled(); +} +``` + +```csharp +[Test] +public async Task Task_Not_Canceled() +{ + var task = Task.CompletedTask; + + await Assert.That(task).IsNotCanceled(); +} +``` + +### IsFaulted / IsNotFaulted + +Tests whether a task ended in a faulted state (threw an exception): + +```csharp +[Test] +public async Task Task_Is_Faulted() +{ + var faultedTask = Task.Run(() => throw new InvalidOperationException()); + + try + { + await faultedTask; + } + catch + { + // Expected + } + + await Assert.That(faultedTask).IsFaulted(); +} +``` + +```csharp +[Test] +public async Task Task_Not_Faulted() +{ + var successfulTask = Task.CompletedTask; + + await Assert.That(successfulTask).IsNotFaulted(); +} +``` + +### IsCompletedSuccessfully / IsNotCompletedSuccessfully (.NET 6+) + +Tests whether a task completed successfully (not faulted or canceled): + +```csharp +[Test] +public async Task Task_Completed_Successfully() +{ + var task = Task.CompletedTask; + + await Assert.That(task).IsCompletedSuccessfully(); +} +``` + +```csharp +[Test] +public async Task Task_Not_Completed_Successfully() +{ + var cts = new CancellationTokenSource(); + cts.Cancel(); + var canceledTask = Task.FromCanceled(cts.Token); + + await Assert.That(canceledTask).IsNotCompletedSuccessfully(); +} +``` + +## Timeout Assertions + +### CompletesWithin + +Tests that a task completes within a specified time: + +```csharp +[Test] +public async Task Task_Completes_Within_Timeout() +{ + var fastTask = Task.Delay(100); + + await Assert.That(fastTask).CompletesWithin(TimeSpan.FromSeconds(1)); +} +``` + +Fails if timeout exceeded: + +```csharp +[Test] +public async Task Task_Exceeds_Timeout() +{ + var slowTask = Task.Delay(5000); + + // This will fail - task takes longer than timeout + // await Assert.That(slowTask).CompletesWithin(TimeSpan.FromMilliseconds(100)); +} +``` + +### WaitsFor + +Waits for a condition to become true within a timeout: + +```csharp +[Test] +public async Task Wait_For_Condition() +{ + bool condition = false; + + _ = Task.Run(async () => + { + await Task.Delay(500); + condition = true; + }); + + await Assert.That(() => condition) + .WaitsFor(c => c == true, timeout: TimeSpan.FromSeconds(2)); +} +``` + +## Practical Examples + +### API Call Timeout + +```csharp +[Test] +public async Task API_Call_Completes_In_Time() +{ + var apiTask = _httpClient.GetAsync("https://api.example.com/data"); + + await Assert.That(apiTask).CompletesWithin(TimeSpan.FromSeconds(5)); + + var response = await apiTask; + await Assert.That(response.IsSuccessStatusCode).IsTrue(); +} +``` + +### Background Task Completion + +```csharp +[Test] +public async Task Background_Processing_Completes() +{ + var processingTask = ProcessDataInBackgroundAsync(); + + await Assert.That(processingTask).CompletesWithin(TimeSpan.FromMinutes(1)); + await Assert.That(processingTask).IsCompletedSuccessfully(); +} +``` + +### Cancellation Token Handling + +```csharp +[Test] +public async Task Operation_Respects_Cancellation() +{ + using var cts = new CancellationTokenSource(); + cts.CancelAfter(TimeSpan.FromMilliseconds(100)); + + var task = LongRunningOperationAsync(cts.Token); + + try + { + await task; + } + catch (OperationCanceledException) + { + // Expected + } + + await Assert.That(task).IsCanceled(); +} +``` + +### Async Exception Handling + +For testing exceptions in async code, use exception assertions: + +```csharp +[Test] +public async Task Async_Method_Throws_Exception() +{ + await Assert.That(async () => await FailingOperationAsync()) + .Throws(); +} +``` + +### Task Result Assertions + +For `Task`, await the task first, then assert on the result: + +```csharp +[Test] +public async Task Task_Returns_Expected_Result() +{ + var task = GetValueAsync(); + + // Ensure it completes in time + await Assert.That(task).CompletesWithin(TimeSpan.FromSeconds(1)); + + // Get the result + var result = await task; + + // Assert on the result + await Assert.That(result).IsEqualTo(42); +} +``` + +### Parallel Task Execution + +```csharp +[Test] +public async Task Parallel_Tasks_Complete() +{ + var task1 = Task.Delay(100); + var task2 = Task.Delay(100); + var task3 = Task.Delay(100); + + var allTasks = Task.WhenAll(task1, task2, task3); + + await Assert.That(allTasks).CompletesWithin(TimeSpan.FromSeconds(1)); + await Assert.That(allTasks).IsCompletedSuccessfully(); +} +``` + +### Task State Transitions + +```csharp +[Test] +public async Task Task_State_Progression() +{ + var tcs = new TaskCompletionSource(); + var task = tcs.Task; + + // Initially not completed + await Assert.That(task).IsNotCompleted(); + + // Complete the task + tcs.SetResult(42); + + // Now completed + await Assert.That(task).IsCompleted(); + await Assert.That(task).IsCompletedSuccessfully(); + + var result = await task; + await Assert.That(result).IsEqualTo(42); +} +``` + +### Failed Task + +```csharp +[Test] +public async Task Task_Fails_With_Exception() +{ + var tcs = new TaskCompletionSource(); + var task = tcs.Task; + + tcs.SetException(new InvalidOperationException("Operation failed")); + + await Assert.That(task).IsFaulted(); + await Assert.That(task).IsNotCompletedSuccessfully(); +} +``` + +### Canceled Task + +```csharp +[Test] +public async Task Task_Can_Be_Canceled() +{ + var tcs = new TaskCompletionSource(); + var task = tcs.Task; + + tcs.SetCanceled(); + + await Assert.That(task).IsCanceled(); + await Assert.That(task).IsNotCompletedSuccessfully(); +} +``` + +## WhenAll and WhenAny + +### WhenAll Completion + +```csharp +[Test] +public async Task All_Tasks_Complete() +{ + var tasks = Enumerable.Range(1, 5) + .Select(i => Task.Delay(i * 100)) + .ToArray(); + + var allCompleted = Task.WhenAll(tasks); + + await Assert.That(allCompleted).CompletesWithin(TimeSpan.FromSeconds(1)); +} +``` + +### WhenAny Completion + +```csharp +[Test] +public async Task Any_Task_Completes() +{ + var fastTask = Task.Delay(100); + var slowTask = Task.Delay(5000); + + var firstCompleted = Task.WhenAny(fastTask, slowTask); + + await Assert.That(firstCompleted).CompletesWithin(TimeSpan.FromMilliseconds(500)); + + var completed = await firstCompleted; + await Assert.That(completed).IsSameReferenceAs(fastTask); +} +``` + +## ValueTask Assertions + +`ValueTask` and `ValueTask` work similarly: + +```csharp +[Test] +public async Task ValueTask_Completion() +{ + var valueTask = GetValueTaskAsync(); + + var result = await valueTask; + await Assert.That(result).IsGreaterThan(0); +} + +async ValueTask GetValueTaskAsync() +{ + await Task.Delay(10); + return 42; +} +``` + +## Chaining Task Assertions + +```csharp +[Test] +public async Task Chained_Task_Assertions() +{ + var task = GetDataAsync(); + + await Assert.That(task) + .CompletesWithin(TimeSpan.FromSeconds(5)); + + await Assert.That(task) + .IsCompleted() + .And.IsCompletedSuccessfully() + .And.IsNotCanceled() + .And.IsNotFaulted(); +} +``` + +## Common Patterns + +### Retry Logic Testing + +```csharp +[Test] +public async Task Retry_Eventually_Succeeds() +{ + int attempts = 0; + + var task = RetryAsync(async () => + { + attempts++; + if (attempts < 3) + throw new Exception("Temporary failure"); + return "Success"; + }, maxRetries: 5); + + await Assert.That(task).CompletesWithin(TimeSpan.FromSeconds(10)); + var result = await task; + await Assert.That(result).IsEqualTo("Success"); +} +``` + +### Debounce Testing + +```csharp +[Test] +public async Task Debounced_Operation() +{ + var trigger = new Subject(); + var debouncedTask = trigger + .Throttle(TimeSpan.FromMilliseconds(500)) + .FirstAsync() + .ToTask(); + + trigger.OnNext("value"); + + await Assert.That(debouncedTask) + .CompletesWithin(TimeSpan.FromSeconds(1)); +} +``` + +### Circuit Breaker Testing + +```csharp +[Test] +public async Task Circuit_Breaker_Opens() +{ + var circuitBreaker = new CircuitBreaker(); + + // Fail enough times to open circuit + for (int i = 0; i < 5; i++) + { + try + { + await circuitBreaker.ExecuteAsync(() => throw new Exception()); + } + catch { } + } + + // Circuit should be open + var task = circuitBreaker.ExecuteAsync(() => Task.CompletedTask); + + await Assert.That(async () => await task) + .Throws(); +} +``` + +### Producer-Consumer Testing + +```csharp +[Test] +public async Task Producer_Consumer_Processes_Items() +{ + var channel = Channel.CreateUnbounded(); + + var producer = ProduceItemsAsync(channel.Writer); + var consumer = ConsumeItemsAsync(channel.Reader); + + await Assert.That(producer).CompletesWithin(TimeSpan.FromSeconds(1)); + await Assert.That(consumer).CompletesWithin(TimeSpan.FromSeconds(2)); +} +``` + +### Rate Limiting + +```csharp +[Test] +public async Task Rate_Limiter_Delays_Requests() +{ + var rateLimiter = new RateLimiter(maxRequests: 5, perTimeSpan: TimeSpan.FromSeconds(1)); + + var stopwatch = Stopwatch.StartNew(); + + // Make 10 requests (should take ~2 seconds due to rate limiting) + var tasks = Enumerable.Range(0, 10) + .Select(_ => rateLimiter.ExecuteAsync(() => Task.CompletedTask)); + + await Task.WhenAll(tasks); + stopwatch.Stop(); + + await Assert.That(stopwatch.Elapsed).IsGreaterThan(TimeSpan.FromSeconds(1.5)); +} +``` + +## Testing Async Disposal + +```csharp +[Test] +public async Task Async_Disposable_Cleanup() +{ + var resource = new AsyncResource(); + + await using (resource) + { + // Use resource + } + + // After disposal + await Assert.That(resource.IsDisposed).IsTrue(); +} +``` + +## See Also + +- [Exceptions](exceptions.md) - Testing async exceptions +- [DateTime](datetime.md) - Timeout and duration testing +- [Boolean](boolean.md) - Testing task state booleans diff --git a/docs/docs/assertions/types.md b/docs/docs/assertions/types.md new file mode 100644 index 0000000000..e831c7ebf2 --- /dev/null +++ b/docs/docs/assertions/types.md @@ -0,0 +1,680 @@ +--- +sidebar_position: 9 +--- + +# Type Assertions + +TUnit provides comprehensive assertions for testing types and type properties. These assertions work with both runtime values and `Type` objects themselves. + +## Value Type Assertions + +### IsTypeOf<T> + +Tests that a value is exactly of a specific type: + +```csharp +[Test] +public async Task Value_Is_Type() +{ + object value = "Hello"; + + await Assert.That(value).IsTypeOf(); +} +``` + +Works with all types: + +```csharp +[Test] +public async Task Various_Types() +{ + await Assert.That(42).IsTypeOf(); + await Assert.That(3.14).IsTypeOf(); + await Assert.That(true).IsTypeOf(); + await Assert.That(new List()).IsTypeOf>(); +} +``` + +### IsAssignableTo<T> + +Tests that a type can be assigned to a target type (inheritance/interface): + +```csharp +[Test] +public async Task Type_Is_Assignable() +{ + var list = new List(); + + await Assert.That(list).IsAssignableTo>(); + await Assert.That(list).IsAssignableTo>(); + await Assert.That(list).IsAssignableTo(); +} +``` + +With inheritance: + +```csharp +public class Animal { } +public class Dog : Animal { } + +[Test] +public async Task Inheritance_Assignability() +{ + var dog = new Dog(); + + await Assert.That(dog).IsAssignableTo(); + await Assert.That(dog).IsAssignableTo(); + await Assert.That(dog).IsAssignableTo(); +} +``` + +### IsNotAssignableTo<T> + +Tests that a type cannot be assigned to a target type: + +```csharp +[Test] +public async Task Type_Not_Assignable() +{ + var value = 42; + + await Assert.That(value).IsNotAssignableTo(); + await Assert.That(value).IsNotAssignableTo(); +} +``` + +## Type Object Assertions + +All the following assertions work on `Type` objects directly: + +```csharp +[Test] +public async Task Type_Object_Assertions() +{ + var type = typeof(string); + + await Assert.That(type).IsClass(); + await Assert.That(type).IsNotInterface(); +} +``` + +### Class and Interface + +#### IsClass / IsNotClass + +```csharp +[Test] +public async Task Is_Class() +{ + await Assert.That(typeof(string)).IsClass(); + await Assert.That(typeof(List)).IsClass(); + await Assert.That(typeof(object)).IsClass(); + + await Assert.That(typeof(IEnumerable)).IsNotClass(); + await Assert.That(typeof(int)).IsNotClass(); // Value type +} +``` + +#### IsInterface / IsNotInterface + +```csharp +[Test] +public async Task Is_Interface() +{ + await Assert.That(typeof(IEnumerable)).IsInterface(); + await Assert.That(typeof(IDisposable)).IsInterface(); + + await Assert.That(typeof(string)).IsNotInterface(); +} +``` + +### Modifiers + +#### IsAbstract / IsNotAbstract + +```csharp +public abstract class AbstractBase { } +public class Concrete : AbstractBase { } + +[Test] +public async Task Is_Abstract() +{ + await Assert.That(typeof(AbstractBase)).IsAbstract(); + await Assert.That(typeof(Concrete)).IsNotAbstract(); +} +``` + +#### IsSealed / IsNotSealed + +```csharp +public sealed class SealedClass { } +public class OpenClass { } + +[Test] +public async Task Is_Sealed() +{ + await Assert.That(typeof(SealedClass)).IsSealed(); + await Assert.That(typeof(string)).IsSealed(); // string is sealed + await Assert.That(typeof(OpenClass)).IsNotSealed(); +} +``` + +### Value Types and Enums + +#### IsValueType / IsNotValueType + +```csharp +[Test] +public async Task Is_Value_Type() +{ + await Assert.That(typeof(int)).IsValueType(); + await Assert.That(typeof(DateTime)).IsValueType(); + await Assert.That(typeof(Guid)).IsValueType(); + + await Assert.That(typeof(string)).IsNotValueType(); + await Assert.That(typeof(object)).IsNotValueType(); +} +``` + +#### IsEnum / IsNotEnum + +```csharp +public enum Color { Red, Green, Blue } + +[Test] +public async Task Is_Enum() +{ + await Assert.That(typeof(Color)).IsEnum(); + await Assert.That(typeof(DayOfWeek)).IsEnum(); + + await Assert.That(typeof(int)).IsNotEnum(); +} +``` + +#### IsPrimitive / IsNotPrimitive + +```csharp +[Test] +public async Task Is_Primitive() +{ + // Primitives: bool, byte, sbyte, short, ushort, int, uint, + // long, ulong, char, double, float, IntPtr, UIntPtr + await Assert.That(typeof(int)).IsPrimitive(); + await Assert.That(typeof(bool)).IsPrimitive(); + await Assert.That(typeof(char)).IsPrimitive(); + + await Assert.That(typeof(string)).IsNotPrimitive(); + await Assert.That(typeof(decimal)).IsNotPrimitive(); +} +``` + +### Visibility + +#### IsPublic / IsNotPublic + +```csharp +public class PublicClass { } +internal class InternalClass { } + +[Test] +public async Task Is_Public() +{ + await Assert.That(typeof(PublicClass)).IsPublic(); + await Assert.That(typeof(string)).IsPublic(); + + await Assert.That(typeof(InternalClass)).IsNotPublic(); +} +``` + +### Generics + +#### IsGenericType / IsNotGenericType + +```csharp +[Test] +public async Task Is_Generic_Type() +{ + await Assert.That(typeof(List)).IsGenericType(); + await Assert.That(typeof(Dictionary)).IsGenericType(); + + await Assert.That(typeof(string)).IsNotGenericType(); +} +``` + +#### IsGenericTypeDefinition / IsNotGenericTypeDefinition + +```csharp +[Test] +public async Task Is_Generic_Type_Definition() +{ + // Generic type definition (unbound) + await Assert.That(typeof(List<>)).IsGenericTypeDefinition(); + await Assert.That(typeof(Dictionary<,>)).IsGenericTypeDefinition(); + + // Constructed generic type (bound) + await Assert.That(typeof(List)).IsNotGenericTypeDefinition(); +} +``` + +#### IsConstructedGenericType / IsNotConstructedGenericType + +```csharp +[Test] +public async Task Is_Constructed_Generic_Type() +{ + await Assert.That(typeof(List)).IsConstructedGenericType(); + await Assert.That(typeof(Dictionary)).IsConstructedGenericType(); + + await Assert.That(typeof(List<>)).IsNotConstructedGenericType(); + await Assert.That(typeof(string)).IsNotConstructedGenericType(); +} +``` + +#### ContainsGenericParameters / DoesNotContainGenericParameters + +```csharp +[Test] +public async Task Contains_Generic_Parameters() +{ + await Assert.That(typeof(List<>)).ContainsGenericParameters(); + + await Assert.That(typeof(List)).DoesNotContainGenericParameters(); + await Assert.That(typeof(string)).DoesNotContainGenericParameters(); +} +``` + +### Arrays and Pointers + +#### IsArray / IsNotArray + +```csharp +[Test] +public async Task Is_Array() +{ + await Assert.That(typeof(int[])).IsArray(); + await Assert.That(typeof(string[])).IsArray(); + await Assert.That(typeof(int[,])).IsArray(); // Multi-dimensional + + await Assert.That(typeof(List)).IsNotArray(); +} +``` + +#### IsByRef / IsNotByRef + +```csharp +[Test] +public async Task Is_By_Ref() +{ + var method = typeof(string).GetMethod(nameof(int.TryParse)); + var parameters = method!.GetParameters(); + var outParam = parameters.First(p => p.IsOut); + + await Assert.That(outParam.ParameterType).IsByRef(); +} +``` + +#### IsByRefLike / IsNotByRefLike (.NET 5+) + +```csharp +[Test] +public async Task Is_By_Ref_Like() +{ + await Assert.That(typeof(Span)).IsByRefLike(); + await Assert.That(typeof(ReadOnlySpan)).IsByRefLike(); + + await Assert.That(typeof(string)).IsNotByRefLike(); +} +``` + +#### IsPointer / IsNotPointer + +```csharp +[Test] +public async Task Is_Pointer() +{ + unsafe + { + var intPtr = typeof(int*); + await Assert.That(intPtr).IsPointer(); + } + + await Assert.That(typeof(int)).IsNotPointer(); +} +``` + +### Nested Types + +#### IsNested / IsNotNested + +```csharp +public class Outer +{ + public class Inner { } +} + +[Test] +public async Task Is_Nested() +{ + await Assert.That(typeof(Outer.Inner)).IsNested(); + await Assert.That(typeof(Outer)).IsNotNested(); +} +``` + +#### IsNestedPublic / IsNotNestedPublic + +```csharp +public class Container +{ + public class PublicNested { } + private class PrivateNested { } +} + +[Test] +public async Task Is_Nested_Public() +{ + await Assert.That(typeof(Container.PublicNested)).IsNestedPublic(); +} +``` + +#### IsNestedPrivate / IsNotNestedPrivate + +```csharp +[Test] +public async Task Is_Nested_Private() +{ + var privateType = typeof(Container) + .GetNestedType("PrivateNested", BindingFlags.NonPublic); + + await Assert.That(privateType).IsNestedPrivate(); +} +``` + +#### IsNestedAssembly / IsNotNestedAssembly + +For internal nested types. + +#### IsNestedFamily / IsNotNestedFamily + +For protected nested types. + +### Visibility Checks + +#### IsVisible / IsNotVisible + +```csharp +[Test] +public async Task Is_Visible() +{ + await Assert.That(typeof(string)).IsVisible(); + await Assert.That(typeof(List)).IsVisible(); + + // Internal types are not visible + var internalType = Assembly.GetExecutingAssembly() + .GetTypes() + .FirstOrDefault(t => !t.IsPublic && !t.IsNested); + + if (internalType != null) + { + await Assert.That(internalType).IsNotVisible(); + } +} +``` + +### COM Interop + +#### IsCOMObject / IsNotCOMObject + +```csharp +[Test] +public async Task Is_COM_Object() +{ + await Assert.That(typeof(string)).IsNotCOMObject(); + // COM types would return true +} +``` + +## Practical Examples + +### Dependency Injection Validation + +```csharp +[Test] +public async Task Service_Implements_Interface() +{ + var service = GetService(); + + await Assert.That(service).IsAssignableTo(); + await Assert.That(service).IsNotNull(); +} +``` + +### Plugin System + +```csharp +public interface IPlugin { } + +[Test] +public async Task Plugin_Implements_Interface() +{ + var pluginType = LoadPluginType(); + + await Assert.That(pluginType).IsAssignableTo(); + await Assert.That(pluginType).IsClass(); + await Assert.That(pluginType).IsNotAbstract(); +} +``` + +### Reflection Testing + +```csharp +[Test] +public async Task Type_Has_Expected_Properties() +{ + var type = typeof(User); + + await Assert.That(type).IsClass(); + await Assert.That(type).IsPublic(); + await Assert.That(type).IsNotAbstract(); + await Assert.That(type).IsNotSealed(); +} +``` + +### Generic Constraints + +```csharp +[Test] +public async Task Validate_Generic_Constraints() +{ + var listType = typeof(List); + + await Assert.That(listType).IsGenericType(); + await Assert.That(listType).IsAssignableTo>(); +} +``` + +### Enum Validation + +```csharp +[Test] +public async Task Type_Is_Enum() +{ + var statusType = typeof(OrderStatus); + + await Assert.That(statusType).IsEnum(); + await Assert.That(statusType).IsValueType(); +} +``` + +### Abstract Class Validation + +```csharp +[Test] +public async Task Base_Class_Is_Abstract() +{ + var baseType = typeof(BaseRepository); + + await Assert.That(baseType).IsClass(); + await Assert.That(baseType).IsAbstract(); +} +``` + +## Chaining Type Assertions + +```csharp +[Test] +public async Task Chained_Type_Assertions() +{ + var type = typeof(MyService); + + await Assert.That(type) + .IsClass() + .And.IsPublic() + .And.IsNotAbstract() + .And.IsNotSealed(); +} +``` + +## Type Comparison + +```csharp +[Test] +public async Task Compare_Types() +{ + var type1 = typeof(List); + var type2 = typeof(List); + var type3 = typeof(List); + + await Assert.That(type1).IsEqualTo(type2); + await Assert.That(type1).IsNotEqualTo(type3); +} +``` + +## Working with Base Types + +```csharp +[Test] +public async Task Check_Base_Type() +{ + var type = typeof(ArgumentNullException); + var baseType = type.BaseType; + + await Assert.That(baseType).IsEqualTo(typeof(ArgumentException)); +} +``` + +## Interface Implementation + +```csharp +[Test] +public async Task Implements_Multiple_Interfaces() +{ + var type = typeof(List); + + await Assert.That(type).IsAssignableTo>(); + await Assert.That(type).IsAssignableTo>(); + await Assert.That(type).IsAssignableTo>(); +} +``` + +## Common Patterns + +### Factory Pattern Validation + +```csharp +[Test] +public async Task Factory_Returns_Correct_Type() +{ + var instance = Factory.Create("user-service"); + + await Assert.That(instance).IsTypeOf(); + await Assert.That(instance).IsAssignableTo(); +} +``` + +### ORM Entity Validation + +```csharp +[Test] +public async Task Entity_Is_Properly_Configured() +{ + var entityType = typeof(Order); + + await Assert.That(entityType).IsClass(); + await Assert.That(entityType).IsPublic(); + await Assert.That(entityType).IsNotAbstract(); + + // Check for required interfaces + await Assert.That(entityType).IsAssignableTo(); +} +``` + +### Serialization Requirements + +```csharp +[Test] +public async Task Type_Is_Serializable() +{ + var type = typeof(DataTransferObject); + + await Assert.That(type).IsClass(); + await Assert.That(type).IsPublic(); + + // All properties should be public + var properties = type.GetProperties(); + await Assert.That(properties).All(p => p.GetMethod?.IsPublic ?? false); +} +``` + +### Test Double Validation + +```csharp +[Test] +public async Task Mock_Implements_Interface() +{ + var mock = new Mock(); + var instance = mock.Object; + + await Assert.That(instance).IsAssignableTo(); +} +``` + +## Struct Validation + +```csharp +public struct Point +{ + public int X { get; set; } + public int Y { get; set; } +} + +[Test] +public async Task Struct_Properties() +{ + var type = typeof(Point); + + await Assert.That(type).IsValueType(); + await Assert.That(type).IsNotClass(); + await Assert.That(type).IsNotEnum(); +} +``` + +## Record Validation + +```csharp +public record Person(string Name, int Age); + +[Test] +public async Task Record_Properties() +{ + var type = typeof(Person); + + await Assert.That(type).IsClass(); + // Records are classes with special properties +} +``` + +## See Also + +- [Exceptions](exceptions.md) - Type checking for exceptions +- [Equality & Comparison](equality-and-comparison.md) - Comparing type objects +- [Collections](collections.md) - Type checking collection elements diff --git a/docs/sidebars.ts b/docs/sidebars.ts index 338710f0d8..ad59e66dd4 100644 --- a/docs/sidebars.ts +++ b/docs/sidebars.ts @@ -60,14 +60,26 @@ const sidebars: SidebarsConfig = { type: 'category', label: 'Assertions', items: [ + 'assertions/getting-started', + 'assertions/equality-and-comparison', + 'assertions/null-and-default', 'assertions/awaiting', 'assertions/and-conditions', 'assertions/or-conditions', + 'assertions/boolean', + 'assertions/numeric', + 'assertions/string', + 'assertions/collections', + 'assertions/dictionaries', + 'assertions/datetime', + 'assertions/exceptions', + 'assertions/types', + 'assertions/tasks-and-async', + 'assertions/specialized-types', + 'assertions/member-assertions', 'assertions/scopes', 'assertions/assertion-groups', - 'assertions/delegates', - 'assertions/member-assertions', - 'assertions/type-checking', + 'assertions/fsharp', { type: 'category', label: 'Extensibility', @@ -78,7 +90,6 @@ const sidebars: SidebarsConfig = { 'assertions/extensibility/extensibility-returning-items-from-await', ], }, - 'assertions/fsharp', ], }, { From 9fa92b8d02b7e520fa85cc8a53cb0948dbae3758 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Tue, 28 Oct 2025 17:04:13 +0000 Subject: [PATCH 39/67] Add test for negative category filter behavior with explicit tests (#3190) (#3559) --- TUnit.Engine.Tests/ExplicitTests.cs | 4 +- TUnit.Engine/Services/TestFilterService.cs | 4 +- .../Bugs/3190/NegativeCategoryFilterTests.cs | 112 ++++++++++++++++++ 3 files changed, 115 insertions(+), 5 deletions(-) create mode 100644 TUnit.TestProject/Bugs/3190/NegativeCategoryFilterTests.cs diff --git a/TUnit.Engine.Tests/ExplicitTests.cs b/TUnit.Engine.Tests/ExplicitTests.cs index 497cd90c54..4010602baa 100644 --- a/TUnit.Engine.Tests/ExplicitTests.cs +++ b/TUnit.Engine.Tests/ExplicitTests.cs @@ -68,6 +68,4 @@ await RunTestsWithFilter( ]); } - - -} \ No newline at end of file +} diff --git a/TUnit.Engine/Services/TestFilterService.cs b/TUnit.Engine/Services/TestFilterService.cs index ddc8998467..aa9b8f5b63 100644 --- a/TUnit.Engine/Services/TestFilterService.cs +++ b/TUnit.Engine/Services/TestFilterService.cs @@ -41,9 +41,9 @@ public IReadOnlyCollection FilterTests(ITestExecutionFil } } - if (filteredTests.Count > 0 && filteredExplicitTests.Count > 0) + if (filteredTests.Count > 0) { - logger.LogTrace($"Filter matched both explicit and non-explicit tests. Excluding {filteredExplicitTests.Count} explicit tests."); + logger.LogTrace($"Filter matched {filteredTests.Count} non-explicit tests. Excluding {filteredExplicitTests.Count} explicit tests."); return filteredTests; } diff --git a/TUnit.TestProject/Bugs/3190/NegativeCategoryFilterTests.cs b/TUnit.TestProject/Bugs/3190/NegativeCategoryFilterTests.cs new file mode 100644 index 0000000000..80327f6b8a --- /dev/null +++ b/TUnit.TestProject/Bugs/3190/NegativeCategoryFilterTests.cs @@ -0,0 +1,112 @@ +using System; +using System.Threading.Tasks; + +namespace TUnit.TestProject.Bugs._3190; + +// This file replicates the issue from GitHub issue #3190: +// When ANY test has [Explicit], negative category filters stop working correctly. +// Expected: /*/*/*/*[Category!=Performance] should exclude all Performance tests +// Actual bug: It runs all non-explicit tests INCLUDING those with Performance category +// +// ROOT CAUSE ANALYSIS: +// The filter evaluation logic is incorrectly handling [Explicit] tests. The presence of +// explicit tests is somehow interfering with negative category filter evaluation. +// +// CORRECT DESIGN PRINCIPLE (Two-Stage Filtering): +// +// Stage 1: Pre-Filter for [Explicit] +// Create initial candidate list: +// - START WITH: All non-explicit tests +// - ADD explicit tests ONLY IF: They are positively and specifically selected +// ✓ Specific name match: /*/MyExplicitTest +// ✓ Positive property: /*/*/*/*[Category=Nightly] +// ✗ Wildcard: /*/*/*/* (too broad - not a specific selection) +// ✗ Negative filter: /*/*/*/*[Category!=Performance] (not a positive selection) +// +// Stage 2: Main Filter +// Apply the full filter logic (including negations) to the candidate list from Stage 1. +// +// WHY THIS IS CORRECT: +// - [Explicit] means "opt-in only" - never run unless specifically requested +// - Test behavior should be local to the test itself, not dependent on sibling tests +// - Aligns with industry standards (NUnit, etc.) +// - Prevents "last non-explicit test" disaster scenario where deleting one test +// changes the behavior of 99 unrelated explicit tests +// +// EXPECTED BEHAVIOR FOR THIS TEST: +// Filter: /*/*/*/*[Category!=Performance] +// +// Stage 1 Result (candidate list): +// - TestClass1.TestMethod1 ✓ (not explicit) +// - TestClass1.TestMethod2 ✓ (not explicit) +// - TestClass2.TestMethod1 ✓ (not explicit) +// - TestClass2.TestMethod2 ✗ (explicit - wildcard doesn't positively select it) +// - TestClass3.RegularTestWithoutCategory ✓ (not explicit) +// +// Stage 2 Result (after applying [Category!=Performance]): +// - TestClass1.TestMethod1 ✗ (has Performance category) +// - TestClass1.TestMethod2 ✓ (no Performance category) ← SHOULD RUN +// - TestClass2.TestMethod1 ✗ (has Performance category) +// - TestClass3.RegularTestWithoutCategory ✓ (no Performance category) ← SHOULD RUN +// +// FINAL: 2 tests should run + +public class TestClass1 +{ + [Test] + [Category("Performance")] + public Task TestMethod1() + { + // This test has Performance category + // With filter [Category!=Performance], this should be EXCLUDED + Console.WriteLine("TestClass1.TestMethod1 executed (has Performance category)"); + return Task.CompletedTask; + } + + [Test] + [Property("CI", "false")] + public Task TestMethod2() + { + // This test has CI property but NOT Performance category + // With filter [Category!=Performance], this should be INCLUDED + Console.WriteLine("TestClass1.TestMethod2 executed (no Performance category)"); + return Task.CompletedTask; + } +} + +public class TestClass2 +{ + [Test] + [Category("Performance")] + [Property("CI", "true")] + public Task TestMethod1() + { + // This test has BOTH Performance category and CI property + // With filter [Category!=Performance], this should be EXCLUDED + Console.WriteLine("TestClass2.TestMethod1 executed (has Performance category)"); + return Task.CompletedTask; + } + + [Test] + [Explicit] + public Task TestMethod2() + { + // This test is marked Explicit - the trigger for the bug + // With any wildcard filter, this should NOT run unless explicitly requested + // But its presence causes negative category filters to malfunction + Console.WriteLine("TestClass2.TestMethod2 executed (Explicit test - should not run with wildcard filter!)"); + throw new NotImplementedException("Explicit test should not run with wildcard filter!"); + } +} + +public class TestClass3 +{ + [Test] + public Task RegularTestWithoutCategory() + { + // This test has no Performance category and is not Explicit + // With filter [Category!=Performance], this should be INCLUDED + Console.WriteLine("TestClass3.RegularTestWithoutCategory executed (no Performance category)"); + return Task.CompletedTask; + } +} From b2621c849c2f49f86ed6aa6ef8681361ad343513 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Tue, 28 Oct 2025 17:18:44 +0000 Subject: [PATCH 40/67] feat: add more regex assertions (#3558) * feat: add more regex assertions * feat: add tests for non-existent group name handling and reintroduce RegexMatchResult class * fix: update links in regex assertions documentation for consistency * feat: Enhance regex assertions with group and match capabilities --- StringMatchesAssertion.patch | 88 +++++ TUnit.Assertions.Tests/RegexAPITests.cs | 72 ++++ .../Assertions/Regex/GroupAssertion.cs | 156 +++++++++ .../Assertions/Regex/MatchAssertion.cs | 68 ++++ .../Assertions/Regex/MatchIndexAssertion.cs | 59 ++++ .../Assertions/Regex/RegexMatch.cs | 77 +++++ .../Assertions/Regex/RegexMatchCollection.cs | 57 ++++ .../Regex/StringMatchesAssertionExtensions.cs | 153 +++++++++ .../Conditions/StringAssertions.cs | 90 +++-- ...Has_No_API_Changes.DotNet10_0.verified.txt | 61 +++- ..._Has_No_API_Changes.DotNet8_0.verified.txt | 61 +++- ..._Has_No_API_Changes.DotNet9_0.verified.txt | 61 +++- ...ary_Has_No_API_Changes.Net4_7.verified.txt | 61 +++- docs/docs/assertions/regex-assertions.md | 315 ++++++++++++++++++ 14 files changed, 1318 insertions(+), 61 deletions(-) create mode 100644 StringMatchesAssertion.patch create mode 100644 TUnit.Assertions.Tests/RegexAPITests.cs create mode 100644 TUnit.Assertions/Assertions/Regex/GroupAssertion.cs create mode 100644 TUnit.Assertions/Assertions/Regex/MatchAssertion.cs create mode 100644 TUnit.Assertions/Assertions/Regex/MatchIndexAssertion.cs create mode 100644 TUnit.Assertions/Assertions/Regex/RegexMatch.cs create mode 100644 TUnit.Assertions/Assertions/Regex/RegexMatchCollection.cs create mode 100644 TUnit.Assertions/Assertions/Regex/StringMatchesAssertionExtensions.cs create mode 100644 docs/docs/assertions/regex-assertions.md diff --git a/StringMatchesAssertion.patch b/StringMatchesAssertion.patch new file mode 100644 index 0000000000..a5dac85a28 --- /dev/null +++ b/StringMatchesAssertion.patch @@ -0,0 +1,88 @@ +REQUIRED CHANGES TO: TUnit.Assertions/Conditions/StringAssertions.cs + +This patch adds Match caching to StringMatchesAssertion class to enable clean, reflection-free access to regex match results. + +================================================================================ +CHANGE 1: Add _cachedMatch field +================================================================================ +Location: Line 432 (after "private RegexOptions _options = RegexOptions.None;") + +ADD THIS LINE: + private Match? _cachedMatch; + +So it becomes: + private readonly string _pattern; + private readonly Regex? _regex; + private RegexOptions _options = RegexOptions.None; + private Match? _cachedMatch; // <-- ADD THIS LINE + +================================================================================ +CHANGE 2: Add GetMatch() method +================================================================================ +Location: After line 464 (after the WithOptions() method, before CheckAsync()) + +ADD THIS METHOD: + /// + /// Gets the cached regex match result after the assertion has been executed. + /// Returns null if the assertion hasn't been executed yet or if the match failed. + /// + public Match? GetMatch() => _cachedMatch; + +So it becomes: + public StringMatchesAssertion WithOptions(RegexOptions options) + { + _options = options; + Context.ExpressionBuilder.Append($".WithOptions({options})"); + return this; + } + + /// + /// Gets the cached regex match result after the assertion has been executed. + /// Returns null if the assertion hasn't been executed yet or if the match failed. + /// + public Match? GetMatch() => _cachedMatch; // <-- ADD THIS METHOD + + protected override Task CheckAsync(EvaluationMetadata metadata) + { + +================================================================================ +CHANGE 3: Modify CheckAsync() to cache the Match +================================================================================ +Location: Lines 486-492 in CheckAsync() method + +REPLACE THIS: + // Use the validated regex to check the match + bool isMatch = regex.IsMatch(value); + + if (isMatch) + { + return Task.FromResult(AssertionResult.Passed); + } + +WITH THIS: + // Use the validated regex to check the match and cache it + var match = regex.Match(value); + _cachedMatch = match; + + if (match.Success) + { + return Task.FromResult(AssertionResult.Passed); + } + +EXPLANATION: +- Changed from regex.IsMatch(value) to regex.Match(value) to get the Match object +- Store the Match in _cachedMatch field +- Changed from checking isMatch to checking match.Success + +================================================================================ +SUMMARY +================================================================================ +These changes enable StringMatchesAssertionExtensions.GetMatchAsync() to work +without reflection by calling the new GetMatch() method. + +The implementation follows SOLID, KISS, DRY, and SRP principles: +- Single Responsibility: StringMatchesAssertion now caches its match +- Open/Closed: API extended without modifying existing behavior +- No reflection: Clean, AOT-compatible code +- DRY: Match is performed once and cached +- KISS: Simple, straightforward implementation diff --git a/TUnit.Assertions.Tests/RegexAPITests.cs b/TUnit.Assertions.Tests/RegexAPITests.cs new file mode 100644 index 0000000000..a213cc0db2 --- /dev/null +++ b/TUnit.Assertions.Tests/RegexAPITests.cs @@ -0,0 +1,72 @@ +using TUnit.Core; + +namespace TUnit.Assertions.Tests; + +public class RegexAPITests +{ + [Test] + public async Task Test_Matches_WithGroup_DirectCall() + { + var email = "john.doe@example.com"; + var pattern = @"(?[\w.]+)@(?[\w.]+)"; + + // Test the new API - requires .And before .Group() for readability + await Assert.That(email) + .Matches(pattern) + .And.Group("username", user => user.IsEqualTo("john.doe")) + .And.Group("domain", domain => domain.IsEqualTo("example.com")); + } + + [Test] + public async Task Test_Matches_WithAnd_ThenGroup() + { + var email = "john.doe@example.com"; + var pattern = @"(?[\w.]+)@(?[\w.]+)"; + + // Test with .And before first .Group() + await Assert.That(email) + .Matches(pattern) + .And.Group("username", user => user.IsEqualTo("john.doe")) + .And.Group("domain", domain => domain.IsEqualTo("example.com")); + } + + [Test] + public async Task Test_Matches_WithMatchAt() + { + var text = "test123 hello456"; + var pattern = @"\w+\d+"; + + // Test accessing multiple matches - requires .And before .Match() + await Assert.That(text) + .Matches(pattern) + .And.Match(0) + .And.Group(0, match => match.IsEqualTo("test123")); + } + + [Test] + public async Task Test_Matches_IndexedGroups() + { + var date = "2025-10-28"; + var pattern = @"(\d{4})-(\d{2})-(\d{2})"; + + // Test indexed groups - all require .And for consistency + await Assert.That(date) + .Matches(pattern) + .And.Group(0, full => full.IsEqualTo("2025-10-28")) + .And.Group(1, year => year.IsEqualTo("2025")) + .And.Group(2, month => month.IsEqualTo("10")) + .And.Group(3, day => day.IsEqualTo("28")); + } + + [Test] + public async Task Test_Match_WithLambda() + { + var text = "test123 hello456"; + var pattern = @"\w+\d+"; + + // Test lambda pattern for match assertions + await Assert.That(text) + .Matches(pattern) + .And.Match(0, match => match.Group(0, g => g.IsEqualTo("test123"))); + } +} diff --git a/TUnit.Assertions/Assertions/Regex/GroupAssertion.cs b/TUnit.Assertions/Assertions/Regex/GroupAssertion.cs new file mode 100644 index 0000000000..b2b998ea6e --- /dev/null +++ b/TUnit.Assertions/Assertions/Regex/GroupAssertion.cs @@ -0,0 +1,156 @@ +using TUnit.Assertions.Core; +using TUnit.Assertions.Sources; + +namespace TUnit.Assertions.Assertions.Regex; + +/// +/// Assertion for a regex capture group on a match collection (operates on first match). +/// +public class GroupAssertion : Assertion +{ + private readonly Func, Assertion?> _groupAssertion; + private readonly string? _groupName; + private readonly int? _groupIndex; + + internal GroupAssertion( + AssertionContext context, + string groupName, + Func, Assertion?> assertion, + bool _) + : base(context) + { + _groupName = groupName; + _groupAssertion = assertion; + } + + internal GroupAssertion( + AssertionContext context, + int groupIndex, + Func, Assertion?> assertion, + bool _) + : base(context) + { + _groupIndex = groupIndex; + _groupAssertion = assertion; + } + + protected override async Task CheckAsync(EvaluationMetadata metadata) + { + var collection = metadata.Value; + var exception = metadata.Exception; + + if (exception != null) + { + return AssertionResult.Failed($"threw {exception.GetType().Name}: {exception.Message}"); + } + + if (collection == null || collection.Count == 0) + { + return AssertionResult.Failed("No match results available"); + } + + string groupValue; + try + { + groupValue = _groupName != null + ? collection.First.GetGroup(_groupName) + : collection.First.GetGroup(_groupIndex!.Value); + } + catch (Exception ex) + { + return AssertionResult.Failed(ex.Message); + } + + var groupAssertion = new ValueAssertion(groupValue, _groupName ?? $"group {_groupIndex}"); + var resultingAssertion = _groupAssertion(groupAssertion); + if (resultingAssertion != null) + { + await resultingAssertion.AssertAsync(); + } + return AssertionResult.Passed; + } + + protected override string GetExpectation() + { + if (_groupName != null) + { + return $"group '{_groupName}' to satisfy assertion"; + } + return $"group {_groupIndex} to satisfy assertion"; + } +} + +/// +/// Assertion for a regex capture group on a specific match. +/// +public class MatchGroupAssertion : Assertion +{ + private readonly Func, Assertion?> _groupAssertion; + private readonly string? _groupName; + private readonly int? _groupIndex; + + internal MatchGroupAssertion( + AssertionContext context, + string groupName, + Func, Assertion?> assertion) + : base(context) + { + _groupName = groupName; + _groupAssertion = assertion; + } + + internal MatchGroupAssertion( + AssertionContext context, + int groupIndex, + Func, Assertion?> assertion) + : base(context) + { + _groupIndex = groupIndex; + _groupAssertion = assertion; + } + + protected override async Task CheckAsync(EvaluationMetadata metadata) + { + var match = metadata.Value; + var exception = metadata.Exception; + + if (exception != null) + { + return AssertionResult.Failed($"threw {exception.GetType().Name}: {exception.Message}"); + } + + if (match == null) + { + return AssertionResult.Failed("No match available"); + } + + string groupValue; + try + { + groupValue = _groupName != null + ? match.GetGroup(_groupName) + : match.GetGroup(_groupIndex!.Value); + } + catch (Exception ex) + { + return AssertionResult.Failed(ex.Message); + } + + var groupAssertion = new ValueAssertion(groupValue, _groupName ?? $"group {_groupIndex}"); + var resultingAssertion = _groupAssertion(groupAssertion); + if (resultingAssertion != null) + { + await resultingAssertion.AssertAsync(); + } + return AssertionResult.Passed; + } + + protected override string GetExpectation() + { + if (_groupName != null) + { + return $"group '{_groupName}' to satisfy assertion"; + } + return $"group {_groupIndex} to satisfy assertion"; + } +} diff --git a/TUnit.Assertions/Assertions/Regex/MatchAssertion.cs b/TUnit.Assertions/Assertions/Regex/MatchAssertion.cs new file mode 100644 index 0000000000..41aef69833 --- /dev/null +++ b/TUnit.Assertions/Assertions/Regex/MatchAssertion.cs @@ -0,0 +1,68 @@ +using TUnit.Assertions.Core; +using TUnit.Assertions.Sources; + +namespace TUnit.Assertions.Assertions.Regex; + +/// +/// Assertion for a specific regex match with lambda support. +/// +public class MatchAssertion : Assertion +{ + private readonly int _index; + private readonly Func, Assertion?> _matchAssertion; + + internal MatchAssertion( + AssertionContext context, + int index, + Func, Assertion?> assertion) + : base(context) + { + _index = index; + _matchAssertion = assertion; + } + + protected override async Task CheckAsync(EvaluationMetadata metadata) + { + var collection = metadata.Value; + var exception = metadata.Exception; + + if (exception != null) + { + return AssertionResult.Failed($"threw {exception.GetType().Name}: {exception.Message}"); + } + + if (collection == null) + { + return AssertionResult.Failed("No match collection available"); + } + + if (_index < 0 || _index >= collection.Count) + { + return AssertionResult.Failed( + $"Match index {_index} is out of range. Collection has {collection.Count} matches."); + } + + RegexMatch match; + try + { + match = collection[_index]; + } + catch (Exception ex) + { + return AssertionResult.Failed($"Failed to get match at index {_index}: {ex.Message}"); + } + + var matchAssertion = new ValueAssertion(match, $"match at index {_index}"); + var resultingAssertion = _matchAssertion(matchAssertion); + if (resultingAssertion != null) + { + await resultingAssertion.AssertAsync(); + } + return AssertionResult.Passed; + } + + protected override string GetExpectation() + { + return $"match at index {_index} to satisfy assertion"; + } +} diff --git a/TUnit.Assertions/Assertions/Regex/MatchIndexAssertion.cs b/TUnit.Assertions/Assertions/Regex/MatchIndexAssertion.cs new file mode 100644 index 0000000000..65a19913d5 --- /dev/null +++ b/TUnit.Assertions/Assertions/Regex/MatchIndexAssertion.cs @@ -0,0 +1,59 @@ +using TUnit.Assertions.Core; + +namespace TUnit.Assertions.Assertions.Regex; + +/// +/// Assertion that maps from RegexMatchCollection to a specific RegexMatch at an index. +/// Mirrors .NET's MatchCollection[index] indexer. +/// +public class MatchIndexAssertion : Assertion +{ + private readonly int _index; + + internal MatchIndexAssertion( + AssertionContext context, + int index) + : base(context.Map(collection => + { + if (collection == null) + { + throw new InvalidOperationException("No match collection available"); + } + + if (index < 0) + { + throw new ArgumentOutOfRangeException(nameof(index), "Match index must be >= 0"); + } + + if (index >= collection.Count) + { + throw new ArgumentOutOfRangeException(nameof(index), + $"Match index {index} is out of range. Collection has {collection.Count} matches."); + } + + return collection[index]; + })) + { + _index = index; + } + + protected override Task CheckAsync(EvaluationMetadata metadata) + { + var match = metadata.Value; + var exception = metadata.Exception; + + if (exception != null) + { + if (exception is ArgumentOutOfRangeException || exception is InvalidOperationException) + { + return Task.FromResult(AssertionResult.Failed(exception.Message)); + } + return Task.FromResult(AssertionResult.Failed($"threw {exception.GetType().Name}")); + } + + // If we have a match, the assertion passed + return Task.FromResult(AssertionResult.Passed); + } + + protected override string GetExpectation() => $"match at index {_index} to exist"; +} diff --git a/TUnit.Assertions/Assertions/Regex/RegexMatch.cs b/TUnit.Assertions/Assertions/Regex/RegexMatch.cs new file mode 100644 index 0000000000..df55ff34ba --- /dev/null +++ b/TUnit.Assertions/Assertions/Regex/RegexMatch.cs @@ -0,0 +1,77 @@ +using System.Text.RegularExpressions; + +namespace TUnit.Assertions.Assertions.Regex; + +/// +/// Represents a single regex match with access to capture groups, match position, and length. +/// Mirrors the .NET System.Text.RegularExpressions.Match type. +/// +public class RegexMatch +{ + internal Match InternalMatch { get; } + + internal RegexMatch(Match match) + { + InternalMatch = match ?? throw new ArgumentNullException(nameof(match)); + + if (!match.Success) + { + throw new InvalidOperationException("Cannot create RegexMatch from an unsuccessful match"); + } + } + + /// + /// Gets the captured string for a named group. + /// + /// Thrown when the group name is null, empty, or does not exist in the match. + public string GetGroup(string groupName) + { + if (string.IsNullOrEmpty(groupName)) + { + throw new ArgumentException("Group name cannot be null or empty", nameof(groupName)); + } + + var group = InternalMatch.Groups[groupName]; + if (!group.Success) + { + throw new ArgumentException( + $"Group '{groupName}' does not exist in the match", + nameof(groupName)); + } + + return group.Value; + } + + /// + /// Gets the captured string for an indexed group (0 is full match, 1+ are capture groups). + /// + public string GetGroup(int groupIndex) + { + if (groupIndex < 0) + { + throw new ArgumentOutOfRangeException(nameof(groupIndex), "Group index must be >= 0"); + } + + if (groupIndex >= InternalMatch.Groups.Count) + { + throw new ArgumentOutOfRangeException(nameof(groupIndex), $"Group index {groupIndex} is out of range. Match has {InternalMatch.Groups.Count} groups."); + } + + return InternalMatch.Groups[groupIndex].Value; + } + + /// + /// Gets the index where the match starts in the input string. + /// + public int Index => InternalMatch.Index; + + /// + /// Gets the length of the matched string. + /// + public int Length => InternalMatch.Length; + + /// + /// Gets the matched string value. + /// + public string Value => InternalMatch.Value; +} diff --git a/TUnit.Assertions/Assertions/Regex/RegexMatchCollection.cs b/TUnit.Assertions/Assertions/Regex/RegexMatchCollection.cs new file mode 100644 index 0000000000..30fe4eb824 --- /dev/null +++ b/TUnit.Assertions/Assertions/Regex/RegexMatchCollection.cs @@ -0,0 +1,57 @@ +using System.Collections; +using System.Text.RegularExpressions; + +namespace TUnit.Assertions.Assertions.Regex; + +/// +/// Collection of all regex matches from a string. +/// Mirrors the .NET System.Text.RegularExpressions.MatchCollection type. +/// +public class RegexMatchCollection : IReadOnlyList +{ + private readonly List _matches; + + internal RegexMatchCollection(MatchCollection matches) + { + if (matches == null) + { + throw new ArgumentNullException(nameof(matches)); + } + + _matches = []; + foreach (Match match in matches) + { + if (match.Success) + { + _matches.Add(new RegexMatch(match)); + } + } + + if (_matches.Count == 0) + { + throw new InvalidOperationException("No successful matches found"); + } + } + + /// + /// Gets the first match in the collection. + /// + public RegexMatch First => _matches[0]; + + /// + /// Gets the match at the specified index. + /// + public RegexMatch this[int index] => _matches[index]; + + /// + /// Gets the number of matches in the collection. + /// + public int Count => _matches.Count; + + /// + /// Returns an enumerator that iterates through the matches. + /// + public IEnumerator GetEnumerator() => _matches.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); +} diff --git a/TUnit.Assertions/Assertions/Regex/StringMatchesAssertionExtensions.cs b/TUnit.Assertions/Assertions/Regex/StringMatchesAssertionExtensions.cs new file mode 100644 index 0000000000..7f58e32445 --- /dev/null +++ b/TUnit.Assertions/Assertions/Regex/StringMatchesAssertionExtensions.cs @@ -0,0 +1,153 @@ +using System.Runtime.CompilerServices; +using System.Text.RegularExpressions; +using TUnit.Assertions.Assertions.Regex; +using TUnit.Assertions.Conditions; +using TUnit.Assertions.Core; +using TUnit.Assertions.Sources; + +namespace TUnit.Assertions.Extensions; + +/// +/// Extension methods for regex match assertions to access capture groups and individual matches. +/// +public static class RegexAssertionExtensions +{ + // =============================================================== + // .Matches() - Maps string → RegexMatchCollection + // =============================================================== + + /// + /// Asserts that a string matches a regular expression pattern. + /// Returns a collection of all matches for further assertions. + /// + public static StringMatchesAssertion Matches( + this IAssertionSource source, + string pattern, + [CallerArgumentExpression(nameof(pattern))] string? patternExpression = null) + { + source.Context.ExpressionBuilder.Append(".Matches(" + (patternExpression ?? "?") + ")"); + return new StringMatchesAssertion(source.Context, pattern); + } + + /// + /// Asserts that a string matches a regular expression. + /// Returns a collection of all matches for further assertions. + /// + public static StringMatchesAssertion Matches( + this IAssertionSource source, + System.Text.RegularExpressions.Regex regex, + [CallerArgumentExpression(nameof(regex))] string? regexExpression = null) + { + source.Context.ExpressionBuilder.Append(".Matches(" + (regexExpression ?? "regex") + ")"); + return new StringMatchesAssertion(source.Context, regex); + } + + // =============================================================== + // .Match(index) - Maps RegexMatchCollection → RegexMatch + // =============================================================== + + /// + /// Gets a specific match by index from the match collection. + /// Requires .And before .Match() for better readability. + /// Mirrors .NET's MatchCollection[index] indexer. + /// + public static MatchIndexAssertion Match( + this AndContinuation continuation, + int index) + { + return new MatchIndexAssertion(continuation.Context, index); + } + + /// + /// Asserts on a specific match by index from the match collection using a lambda. + /// Requires .And before .Match() for better readability. + /// + public static MatchAssertion Match( + this AndContinuation continuation, + int index, + Func, Assertion?> assertion) + { + return new MatchAssertion(continuation.Context, index, assertion); + } + + // =============================================================== + // .Group() - Operates on first match in collection + // =============================================================== + + /// + /// Asserts on a named capture group from the first regex match. + /// Requires .And before .Group() for better readability. + /// + public static GroupAssertion Group( + this AndContinuation continuation, + string groupName, + Func, Assertion?> assertion) + { + return new GroupAssertion(continuation.Context, groupName, assertion, true); + } + + /// + /// Asserts on an indexed capture group from the first regex match. + /// Requires .And before .Group() for better readability. + /// + public static GroupAssertion Group( + this AndContinuation continuation, + int groupIndex, + Func, Assertion?> assertion) + { + return new GroupAssertion(continuation.Context, groupIndex, assertion, true); + } + + // =============================================================== + // .Group() - Operates on specific match (after .Match(index)) + // =============================================================== + + /// + /// Asserts on a named capture group from a specific regex match. + /// Can be used inside lambda expressions or after .And for chaining. + /// + public static MatchGroupAssertion Group( + this ValueAssertion source, + string groupName, + Func, Assertion?> assertion) + { + return new MatchGroupAssertion(source.Context, groupName, assertion); + } + + /// + /// Asserts on an indexed capture group from a specific regex match. + /// Can be used inside lambda expressions or after .And for chaining. + /// + public static MatchGroupAssertion Group( + this ValueAssertion source, + int groupIndex, + Func, Assertion?> assertion) + { + return new MatchGroupAssertion(source.Context, groupIndex, assertion); + } + + /// + /// Asserts on a named capture group from a specific regex match. + /// Requires .And before .Group() for better readability. + /// + public static MatchGroupAssertion Group( + this AndContinuation continuation, + string groupName, + Func, Assertion?> assertion) + { + return new MatchGroupAssertion(continuation.Context, groupName, assertion); + } + + /// + /// Asserts on an indexed capture group from a specific regex match. + /// Requires .And before .Group() for better readability. + /// + public static MatchGroupAssertion Group( + this AndContinuation continuation, + int groupIndex, + Func, Assertion?> assertion) + { + return new MatchGroupAssertion(continuation.Context, groupIndex, assertion); + } +} + diff --git a/TUnit.Assertions/Conditions/StringAssertions.cs b/TUnit.Assertions/Conditions/StringAssertions.cs index 8d49f69e17..1b6bdc4baf 100644 --- a/TUnit.Assertions/Conditions/StringAssertions.cs +++ b/TUnit.Assertions/Conditions/StringAssertions.cs @@ -1,5 +1,6 @@ using System.Text; using System.Text.RegularExpressions; +using TUnit.Assertions.Assertions.Regex; using TUnit.Assertions.Attributes; using TUnit.Assertions.Core; @@ -422,10 +423,9 @@ protected override Task CheckAsync(EvaluationMetadata m } /// -/// Asserts that a string matches a regular expression pattern. +/// Asserts that a string matches a regular expression pattern and returns a collection of all matches. /// -[AssertionExtension("Matches")] -public class StringMatchesAssertion : Assertion +public class StringMatchesAssertion : Assertion { private readonly string _pattern; private readonly Regex? _regex; @@ -434,7 +434,7 @@ public class StringMatchesAssertion : Assertion public StringMatchesAssertion( AssertionContext context, string pattern) - : base(context) + : base(CreateMappedContext(context, pattern, null, RegexOptions.None)) { _pattern = pattern; _regex = null; @@ -443,55 +443,87 @@ public StringMatchesAssertion( public StringMatchesAssertion( AssertionContext context, Regex regex) - : base(context) + : base(CreateMappedContext(context, regex.ToString(), regex, RegexOptions.None)) { _pattern = regex.ToString(); _regex = regex; } + // Private constructor for chaining methods like IgnoringCase + private StringMatchesAssertion( + AssertionContext mappedContext, + string pattern, + Regex? regex, + RegexOptions options) + : base(mappedContext) + { + _pattern = pattern; + _regex = regex; + _options = options; + } + public StringMatchesAssertion IgnoringCase() { - _options |= RegexOptions.IgnoreCase; + var newOptions = _options | RegexOptions.IgnoreCase; Context.ExpressionBuilder.Append(".IgnoringCase()"); - return this; + return new StringMatchesAssertion(Context, _pattern, _regex, newOptions); } public StringMatchesAssertion WithOptions(RegexOptions options) { - _options = options; Context.ExpressionBuilder.Append($".WithOptions({options})"); - return this; + return new StringMatchesAssertion(Context, _pattern, _regex, options); } - protected override Task CheckAsync(EvaluationMetadata metadata) + private static AssertionContext CreateMappedContext( + AssertionContext context, + string pattern, + Regex? regex, + RegexOptions options) { - var value = metadata.Value; - var exception = metadata.Exception; - - if (exception != null) + return context.Map(stringValue => { - return Task.FromResult(AssertionResult.Failed($"threw {exception.GetType().Name}")); - } + // Validate the regex pattern first (by creating a Regex object if we don't have one) + // This ensures RegexParseException is thrown before ArgumentNullException for invalid patterns + var regexObj = regex ?? new Regex(pattern, options); - // Validate the regex pattern first (by creating a Regex object if we don't have one) - // This ensures RegexParseException is thrown before ArgumentNullException for invalid patterns - var regex = _regex ?? new Regex(_pattern, _options); + // Now check if value is null and throw ArgumentNullException + if (stringValue == null) + { + throw new ArgumentNullException(nameof(stringValue), "value was null"); + } - // Now check if value is null and throw ArgumentNullException - if (value == null) - { - throw new ArgumentNullException(nameof(value), "value was null"); - } + // Perform the matches + var matches = regexObj.Matches(stringValue); - // Use the validated regex to check the match - bool isMatch = regex.IsMatch(value); + if (matches.Count == 0) + { + throw new InvalidOperationException($"The regex \"{pattern}\" does not match with \"{stringValue}\""); + } + + return new RegexMatchCollection(matches); + }); + } - if (isMatch) + protected override Task CheckAsync(EvaluationMetadata metadata) + { + var value = metadata.Value; + var exception = metadata.Exception; + + if (exception != null) { - return Task.FromResult(AssertionResult.Passed); + // Check what type of exception it is + if (exception is InvalidOperationException) + { + return Task.FromResult(AssertionResult.Failed(exception.Message)); + } + // Rethrow native exceptions (ArgumentNullException, RegexParseException, RegexMatchTimeoutException) + // so they can be tested with .Throws() + throw exception; } - return Task.FromResult(AssertionResult.Failed($"The regex \"{_pattern}\" does not match with \"{value}\"")); + // If we have a RegexMatchCollection, at least one match succeeded + return Task.FromResult(AssertionResult.Passed); } protected override string GetExpectation() 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 32d6a99f6e..c8951f304e 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 @@ -147,6 +147,44 @@ namespace .Assertions } } namespace . +{ + public class GroupAssertion : .<..RegexMatchCollection> + { + protected override .<.> CheckAsync(.<..RegexMatchCollection> metadata) { } + protected override string GetExpectation() { } + } + public class MatchAssertion : .<..RegexMatchCollection> + { + protected override .<.> CheckAsync(.<..RegexMatchCollection> metadata) { } + protected override string GetExpectation() { } + } + public class MatchGroupAssertion : .<..RegexMatch> + { + protected override .<.> CheckAsync(.<..RegexMatch> metadata) { } + protected override string GetExpectation() { } + } + public class MatchIndexAssertion : .<..RegexMatch> + { + protected override .<.> CheckAsync(.<..RegexMatch> metadata) { } + protected override string GetExpectation() { } + } + public class RegexMatch + { + public int Index { get; } + public int Length { get; } + public string Value { get; } + public string GetGroup(int groupIndex) { } + public string GetGroup(string groupName) { } + } + public class RegexMatchCollection : .<..RegexMatch>, .<..RegexMatch>, .<..RegexMatch>, .IEnumerable + { + public int Count { get; } + public ..RegexMatch First { get; } + public ..RegexMatch this[int index] { get; } + public .<..RegexMatch> GetEnumerator() { } + } +} +namespace . { public class IsNotParsableIntoAssertion<[.(..None | ..PublicMethods | ..Interfaces)] T> : . { @@ -1351,12 +1389,11 @@ namespace .Conditions protected override .<.> CheckAsync(. metadata) { } protected override string GetExpectation() { } } - [.("Matches")] - public class StringMatchesAssertion : . + public class StringMatchesAssertion : .<..RegexMatchCollection> { public StringMatchesAssertion(. context, . regex) { } public StringMatchesAssertion(. context, string pattern) { } - protected override .<.> CheckAsync(. metadata) { } + protected override .<.> CheckAsync(.<..RegexMatchCollection> metadata) { } protected override string GetExpectation() { } public . IgnoringCase() { } public . WithOptions(. options) { } @@ -3232,6 +3269,19 @@ namespace .Extensions protected override .<.> CheckAsync(.<> metadata) { } protected override string GetExpectation() { } } + public static class RegexAssertionExtensions + { + public static ..MatchGroupAssertion Group(this .<..RegexMatch> continuation, int groupIndex, <., .?> assertion) { } + public static ..MatchGroupAssertion Group(this .<..RegexMatch> continuation, string groupName, <., .?> assertion) { } + public static ..GroupAssertion Group(this .<..RegexMatchCollection> continuation, int groupIndex, <., .?> assertion) { } + public static ..GroupAssertion Group(this .<..RegexMatchCollection> continuation, string groupName, <., .?> assertion) { } + public static ..MatchGroupAssertion Group(this .<..RegexMatch> source, int groupIndex, <., .?> assertion) { } + public static ..MatchGroupAssertion Group(this .<..RegexMatch> source, string groupName, <., .?> assertion) { } + public static ..MatchIndexAssertion Match(this .<..RegexMatchCollection> continuation, int index) { } + public static ..MatchAssertion Match(this .<..RegexMatchCollection> continuation, int index, <.<..RegexMatch>, .<..RegexMatch>?> assertion) { } + public static . Matches(this . source, . regex, [.("regex")] string? regexExpression = null) { } + public static . Matches(this . source, string pattern, [.("pattern")] string? patternExpression = null) { } + } public static class SameReferenceAssertionExtensions { public static . IsSameReferenceAs(this . source, object? expected, [.("expected")] string? expectedExpression = null) { } @@ -3368,11 +3418,6 @@ namespace .Extensions protected override .<.> CheckAsync(. metadata) { } protected override string GetExpectation() { } } - public static class StringMatchesAssertionExtensions - { - public static . Matches(this . source, . regex, [.("regex")] string? regexExpression = null) { } - public static . Matches(this . source, string pattern, [.("pattern")] string? patternExpression = null) { } - } public static class StringStartsWithAssertionExtensions { public static . StartsWith(this . source, string expected, [.("expected")] string? expectedExpression = 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 d0e272c035..096b6cc60f 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 @@ -144,6 +144,44 @@ namespace .Assertions } } namespace . +{ + public class GroupAssertion : .<..RegexMatchCollection> + { + protected override .<.> CheckAsync(.<..RegexMatchCollection> metadata) { } + protected override string GetExpectation() { } + } + public class MatchAssertion : .<..RegexMatchCollection> + { + protected override .<.> CheckAsync(.<..RegexMatchCollection> metadata) { } + protected override string GetExpectation() { } + } + public class MatchGroupAssertion : .<..RegexMatch> + { + protected override .<.> CheckAsync(.<..RegexMatch> metadata) { } + protected override string GetExpectation() { } + } + public class MatchIndexAssertion : .<..RegexMatch> + { + protected override .<.> CheckAsync(.<..RegexMatch> metadata) { } + protected override string GetExpectation() { } + } + public class RegexMatch + { + public int Index { get; } + public int Length { get; } + public string Value { get; } + public string GetGroup(int groupIndex) { } + public string GetGroup(string groupName) { } + } + public class RegexMatchCollection : .<..RegexMatch>, .<..RegexMatch>, .<..RegexMatch>, .IEnumerable + { + public int Count { get; } + public ..RegexMatch First { get; } + public ..RegexMatch this[int index] { get; } + public .<..RegexMatch> GetEnumerator() { } + } +} +namespace . { public class IsNotParsableIntoAssertion<[.(..None | ..PublicMethods | ..Interfaces)] T> : . { @@ -1348,12 +1386,11 @@ namespace .Conditions protected override .<.> CheckAsync(. metadata) { } protected override string GetExpectation() { } } - [.("Matches")] - public class StringMatchesAssertion : . + public class StringMatchesAssertion : .<..RegexMatchCollection> { public StringMatchesAssertion(. context, . regex) { } public StringMatchesAssertion(. context, string pattern) { } - protected override .<.> CheckAsync(. metadata) { } + protected override .<.> CheckAsync(.<..RegexMatchCollection> metadata) { } protected override string GetExpectation() { } public . IgnoringCase() { } public . WithOptions(. options) { } @@ -3214,6 +3251,19 @@ namespace .Extensions protected override .<.> CheckAsync(.<> metadata) { } protected override string GetExpectation() { } } + public static class RegexAssertionExtensions + { + public static ..MatchGroupAssertion Group(this .<..RegexMatch> continuation, int groupIndex, <., .?> assertion) { } + public static ..MatchGroupAssertion Group(this .<..RegexMatch> continuation, string groupName, <., .?> assertion) { } + public static ..GroupAssertion Group(this .<..RegexMatchCollection> continuation, int groupIndex, <., .?> assertion) { } + public static ..GroupAssertion Group(this .<..RegexMatchCollection> continuation, string groupName, <., .?> assertion) { } + public static ..MatchGroupAssertion Group(this .<..RegexMatch> source, int groupIndex, <., .?> assertion) { } + public static ..MatchGroupAssertion Group(this .<..RegexMatch> source, string groupName, <., .?> assertion) { } + public static ..MatchIndexAssertion Match(this .<..RegexMatchCollection> continuation, int index) { } + public static ..MatchAssertion Match(this .<..RegexMatchCollection> continuation, int index, <.<..RegexMatch>, .<..RegexMatch>?> assertion) { } + public static . Matches(this . source, . regex, [.("regex")] string? regexExpression = null) { } + public static . Matches(this . source, string pattern, [.("pattern")] string? patternExpression = null) { } + } public static class SameReferenceAssertionExtensions { public static . IsSameReferenceAs(this . source, object? expected, [.("expected")] string? expectedExpression = null) { } @@ -3349,11 +3399,6 @@ namespace .Extensions protected override .<.> CheckAsync(. metadata) { } protected override string GetExpectation() { } } - public static class StringMatchesAssertionExtensions - { - public static . Matches(this . source, . regex, [.("regex")] string? regexExpression = null) { } - public static . Matches(this . source, string pattern, [.("pattern")] string? patternExpression = null) { } - } public static class StringStartsWithAssertionExtensions { public static . StartsWith(this . source, string expected, [.("expected")] string? expectedExpression = 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 dc4a6d144e..2f1de5c02f 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 @@ -147,6 +147,44 @@ namespace .Assertions } } namespace . +{ + public class GroupAssertion : .<..RegexMatchCollection> + { + protected override .<.> CheckAsync(.<..RegexMatchCollection> metadata) { } + protected override string GetExpectation() { } + } + public class MatchAssertion : .<..RegexMatchCollection> + { + protected override .<.> CheckAsync(.<..RegexMatchCollection> metadata) { } + protected override string GetExpectation() { } + } + public class MatchGroupAssertion : .<..RegexMatch> + { + protected override .<.> CheckAsync(.<..RegexMatch> metadata) { } + protected override string GetExpectation() { } + } + public class MatchIndexAssertion : .<..RegexMatch> + { + protected override .<.> CheckAsync(.<..RegexMatch> metadata) { } + protected override string GetExpectation() { } + } + public class RegexMatch + { + public int Index { get; } + public int Length { get; } + public string Value { get; } + public string GetGroup(int groupIndex) { } + public string GetGroup(string groupName) { } + } + public class RegexMatchCollection : .<..RegexMatch>, .<..RegexMatch>, .<..RegexMatch>, .IEnumerable + { + public int Count { get; } + public ..RegexMatch First { get; } + public ..RegexMatch this[int index] { get; } + public .<..RegexMatch> GetEnumerator() { } + } +} +namespace . { public class IsNotParsableIntoAssertion<[.(..None | ..PublicMethods | ..Interfaces)] T> : . { @@ -1351,12 +1389,11 @@ namespace .Conditions protected override .<.> CheckAsync(. metadata) { } protected override string GetExpectation() { } } - [.("Matches")] - public class StringMatchesAssertion : . + public class StringMatchesAssertion : .<..RegexMatchCollection> { public StringMatchesAssertion(. context, . regex) { } public StringMatchesAssertion(. context, string pattern) { } - protected override .<.> CheckAsync(. metadata) { } + protected override .<.> CheckAsync(.<..RegexMatchCollection> metadata) { } protected override string GetExpectation() { } public . IgnoringCase() { } public . WithOptions(. options) { } @@ -3232,6 +3269,19 @@ namespace .Extensions protected override .<.> CheckAsync(.<> metadata) { } protected override string GetExpectation() { } } + public static class RegexAssertionExtensions + { + public static ..MatchGroupAssertion Group(this .<..RegexMatch> continuation, int groupIndex, <., .?> assertion) { } + public static ..MatchGroupAssertion Group(this .<..RegexMatch> continuation, string groupName, <., .?> assertion) { } + public static ..GroupAssertion Group(this .<..RegexMatchCollection> continuation, int groupIndex, <., .?> assertion) { } + public static ..GroupAssertion Group(this .<..RegexMatchCollection> continuation, string groupName, <., .?> assertion) { } + public static ..MatchGroupAssertion Group(this .<..RegexMatch> source, int groupIndex, <., .?> assertion) { } + public static ..MatchGroupAssertion Group(this .<..RegexMatch> source, string groupName, <., .?> assertion) { } + public static ..MatchIndexAssertion Match(this .<..RegexMatchCollection> continuation, int index) { } + public static ..MatchAssertion Match(this .<..RegexMatchCollection> continuation, int index, <.<..RegexMatch>, .<..RegexMatch>?> assertion) { } + public static . Matches(this . source, . regex, [.("regex")] string? regexExpression = null) { } + public static . Matches(this . source, string pattern, [.("pattern")] string? patternExpression = null) { } + } public static class SameReferenceAssertionExtensions { public static . IsSameReferenceAs(this . source, object? expected, [.("expected")] string? expectedExpression = null) { } @@ -3368,11 +3418,6 @@ namespace .Extensions protected override .<.> CheckAsync(. metadata) { } protected override string GetExpectation() { } } - public static class StringMatchesAssertionExtensions - { - public static . Matches(this . source, . regex, [.("regex")] string? regexExpression = null) { } - public static . Matches(this . source, string pattern, [.("pattern")] string? patternExpression = null) { } - } public static class StringStartsWithAssertionExtensions { public static . StartsWith(this . source, string expected, [.("expected")] string? expectedExpression = 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 d875beeb7d..ebe955425f 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 @@ -144,6 +144,44 @@ namespace .Assertions } } namespace . +{ + public class GroupAssertion : .<..RegexMatchCollection> + { + protected override .<.> CheckAsync(.<..RegexMatchCollection> metadata) { } + protected override string GetExpectation() { } + } + public class MatchAssertion : .<..RegexMatchCollection> + { + protected override .<.> CheckAsync(.<..RegexMatchCollection> metadata) { } + protected override string GetExpectation() { } + } + public class MatchGroupAssertion : .<..RegexMatch> + { + protected override .<.> CheckAsync(.<..RegexMatch> metadata) { } + protected override string GetExpectation() { } + } + public class MatchIndexAssertion : .<..RegexMatch> + { + protected override .<.> CheckAsync(.<..RegexMatch> metadata) { } + protected override string GetExpectation() { } + } + public class RegexMatch + { + public int Index { get; } + public int Length { get; } + public string Value { get; } + public string GetGroup(int groupIndex) { } + public string GetGroup(string groupName) { } + } + public class RegexMatchCollection : .<..RegexMatch>, .<..RegexMatch>, .<..RegexMatch>, .IEnumerable + { + public int Count { get; } + public ..RegexMatch First { get; } + public ..RegexMatch this[int index] { get; } + public .<..RegexMatch> GetEnumerator() { } + } +} +namespace . { public class IsNotParsableIntoAssertion : . { @@ -1284,12 +1322,11 @@ namespace .Conditions protected override .<.> CheckAsync(. metadata) { } protected override string GetExpectation() { } } - [.("Matches")] - public class StringMatchesAssertion : . + public class StringMatchesAssertion : .<..RegexMatchCollection> { public StringMatchesAssertion(. context, . regex) { } public StringMatchesAssertion(. context, string pattern) { } - protected override .<.> CheckAsync(. metadata) { } + protected override .<.> CheckAsync(.<..RegexMatchCollection> metadata) { } protected override string GetExpectation() { } public . IgnoringCase() { } public . WithOptions(. options) { } @@ -2955,6 +2992,19 @@ namespace .Extensions public static . HasProperty(this . source, .<> propertySelector) { } public static . HasProperty(this . source, .<> propertySelector, TProperty expectedValue, [.("expectedValue")] string? expression = null) { } } + public static class RegexAssertionExtensions + { + public static ..MatchGroupAssertion Group(this .<..RegexMatch> continuation, int groupIndex, <., .?> assertion) { } + public static ..MatchGroupAssertion Group(this .<..RegexMatch> continuation, string groupName, <., .?> assertion) { } + public static ..GroupAssertion Group(this .<..RegexMatchCollection> continuation, int groupIndex, <., .?> assertion) { } + public static ..GroupAssertion Group(this .<..RegexMatchCollection> continuation, string groupName, <., .?> assertion) { } + public static ..MatchGroupAssertion Group(this .<..RegexMatch> source, int groupIndex, <., .?> assertion) { } + public static ..MatchGroupAssertion Group(this .<..RegexMatch> source, string groupName, <., .?> assertion) { } + public static ..MatchIndexAssertion Match(this .<..RegexMatchCollection> continuation, int index) { } + public static ..MatchAssertion Match(this .<..RegexMatchCollection> continuation, int index, <.<..RegexMatch>, .<..RegexMatch>?> assertion) { } + public static . Matches(this . source, . regex, [.("regex")] string? regexExpression = null) { } + public static . Matches(this . source, string pattern, [.("pattern")] string? patternExpression = null) { } + } public static class SameReferenceAssertionExtensions { public static . IsSameReferenceAs(this . source, object? expected, [.("expected")] string? expectedExpression = null) { } @@ -3090,11 +3140,6 @@ namespace .Extensions protected override .<.> CheckAsync(. metadata) { } protected override string GetExpectation() { } } - public static class StringMatchesAssertionExtensions - { - public static . Matches(this . source, . regex, [.("regex")] string? regexExpression = null) { } - public static . Matches(this . source, string pattern, [.("pattern")] string? patternExpression = null) { } - } public static class StringStartsWithAssertionExtensions { public static . StartsWith(this . source, string expected, [.("expected")] string? expectedExpression = null) { } diff --git a/docs/docs/assertions/regex-assertions.md b/docs/docs/assertions/regex-assertions.md new file mode 100644 index 0000000000..6b2d9b1261 --- /dev/null +++ b/docs/docs/assertions/regex-assertions.md @@ -0,0 +1,315 @@ +# Regex Assertions + +The `.Matches()` method allows you to validate strings against regular expressions and assert on capture groups, match positions, and match lengths. This is useful when you need to validate structured text like emails, phone numbers, dates, or extract specific parts of a string. + +## Basic Usage + +```csharp +[Test] +public async Task BasicRegexAssertions() +{ + var email = "john.doe@example.com"; + + // Assert that string matches a pattern + await Assert.That(email).Matches(@"^[\w.]+@[\w.]+$"); + + // Use a compiled Regex object + var emailRegex = new Regex(@"^[\w.]+@[\w.]+$"); + await Assert.That(email).Matches(emailRegex); + + // Use source-generated regex (C# 11+) + [GeneratedRegex(@"^[\w.]+@[\w.]+$")] + static partial Regex EmailRegex(); + + await Assert.That(email).Matches(EmailRegex()); +} +``` + +## Group Assertions + +The key advantage of regex assertions is the ability to assert on capture groups using `.Group()`: + +### Named Groups + +```csharp +[Test] +public async Task NamedGroupAssertions() +{ + var email = "john.doe@example.com"; + var pattern = @"(?[\w.]+)@(?[\w.]+)"; + + // Assert on named capture groups (requires .And before .Group()) + await Assert.That(email) + .Matches(pattern) + .And.Group("username", user => user.IsEqualTo("john.doe")) + .And.Group("domain", domain => domain.IsEqualTo("example.com")); +} +``` + +### Indexed Groups + +```csharp +[Test] +public async Task IndexedGroupAssertions() +{ + var date = "2025-10-28"; + var pattern = @"(\d{4})-(\d{2})-(\d{2})"; + + // Assert on indexed capture groups (1-based, 0 is full match) + await Assert.That(date) + .Matches(pattern) + .And.Group(0, full => full.IsEqualTo("2025-10-28")) + .And.Group(1, year => year.IsEqualTo("2025")) + .And.Group(2, month => month.IsEqualTo("10")) + .And.Group(3, day => day.IsEqualTo("28")); +} +``` + +## Multiple Matches + +When a regex matches multiple times in a string, you can access specific matches using `.Match(index)`: + +```csharp +[Test] +public async Task MultipleMatchAssertions() +{ + var text = "test123 hello456 world789"; + var pattern = @"\w+\d+"; + + // Assert on first match + await Assert.That(text) + .Matches(pattern) + .And.Match(0) + .And.Group(0, match => match.IsEqualTo("test123")); + + // Use lambda pattern to assert on a specific match + await Assert.That(text) + .Matches(pattern) + .And.Match(1, match => match.Group(0, g => g.IsEqualTo("hello456"))); +} +``` + +## Match Position and Length + +You can assert on where a match occurs and its length: + +```csharp +[Test] +public async Task PositionAndLengthAssertions() +{ + var text = "Hello World 123"; + var pattern = @"\d+"; + + // Assert that match is at specific index + await Assert.That(text) + .Matches(pattern) + .AtIndex(12); + + // Assert that match has specific length + await Assert.That(text) + .Matches(pattern) + .HasLength(3); + + // Combine with group assertions + await Assert.That(text) + .Matches(pattern) + .AtIndex(12) + .And.HasLength(3); +} +``` + +## Complex Patterns with Multiple Groups + +```csharp +[Test] +public async Task ComplexPatternAssertions() +{ + var logEntry = "[2025-10-28 14:30:45] ERROR: Connection timeout"; + var pattern = @"\[(?\d{4}-\d{2}-\d{2}) (?