From e61d440a9f48ab3a0d6fad8d3593a3385e179602 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 12 Apr 2026 20:41:28 +0100 Subject: [PATCH 1/7] perf: reduce allocations and improve hot-path performance across engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Key optimizations: - Lazy-init Context output state (StringBuilder, RWLS, ConsoleLineBuffer) with thread-safe LazyInitializer — most test contexts never capture output - Volatile cached array for log sinks (eliminates lock + ToArray on every Console.Write) - Replace ConcurrentBag with List for sequential-write collections (Timings, Artifacts) - O(1) duplicate detection in ClassHookContext via generic ReferenceEqualityComparer - Parallel test registration with Parallel.ForEachAsync for 8+ tests - HashSet-based UID filter lookup instead of O(N) list scan - CTS allocation fast-path in HookTimeoutHelper when no timeout configured - Targeted EventReceiverRegistry cache invalidation instead of blanket Clear() - Deferred StateBag/Events allocation in TestBuilderContext - Eliminate LINQ allocations in hot paths (TestBuilder, TestExtensions, TestFilterService) - Conditional List allocation in test teardown --- TUnit.Core/Context.cs | 55 +++++++++++++----- TUnit.Core/DataGeneratorMetadataCreator.cs | 37 +++++++----- .../Helpers/ReferenceEqualityComparer.cs | 14 +++++ TUnit.Core/Logging/LogSinkRouter.cs | 4 +- TUnit.Core/Logging/TUnitLoggerFactory.cs | 14 ++--- TUnit.Core/Models/ClassHookContext.cs | 4 +- TUnit.Core/TestContext.Output.cs | 13 +++-- TUnit.Engine/Building/TestBuilder.cs | 32 +++++++++-- TUnit.Engine/Building/TestBuilderPipeline.cs | 15 +++-- TUnit.Engine/Events/EventReceiverRegistry.cs | 5 +- TUnit.Engine/Extensions/TestExtensions.cs | 27 +++++---- TUnit.Engine/Helpers/HookTimeoutHelper.cs | 20 +++++++ TUnit.Engine/Reporters/GitHubReporter.cs | 4 +- TUnit.Engine/Services/TestFilterService.cs | 56 +++++++++++++++++-- TUnit.Engine/TestExecutor.cs | 17 ++++-- 15 files changed, 233 insertions(+), 84 deletions(-) diff --git a/TUnit.Core/Context.cs b/TUnit.Core/Context.cs index dd5b916d00..6a4ed8d03a 100644 --- a/TUnit.Core/Context.cs +++ b/TUnit.Core/Context.cs @@ -21,28 +21,43 @@ TestContext.Current as Context ?? BeforeTestDiscoveryContext.Current as Context ?? GlobalContext.Current; - private readonly StringBuilder _outputBuilder = new(); - private readonly StringBuilder _errorOutputBuilder = new(); - private readonly ReaderWriterLockSlim _outputLock = new(LockRecursionPolicy.NoRecursion); - private readonly ReaderWriterLockSlim _errorOutputLock = new(LockRecursionPolicy.NoRecursion); + // Lazy output state: avoid allocating StringBuilder + RWLS + ConsoleLineBuffer + // for contexts that never receive output (the common case for most tests) + private StringBuilder? _outputBuilder; + private StringBuilder? _errorOutputBuilder; + private ReaderWriterLockSlim? _outputLock; + private ReaderWriterLockSlim? _errorOutputLock; private DefaultLogger? _defaultLogger; // Console interceptor line buffers for partial writes (Console.Write without newline) // These are stored per-context to prevent output mixing between parallel tests // ConsoleLineBuffer uses Lock internally for efficient synchronization - private readonly ConsoleLineBuffer _consoleStdOutLineBuffer = new(); - private readonly ConsoleLineBuffer _consoleStdErrLineBuffer = new(); + private ConsoleLineBuffer? _consoleStdOutLineBuffer; + private ConsoleLineBuffer? _consoleStdErrLineBuffer; + + // Thread-safe lazy init via Interlocked.CompareExchange — console interceptors + // may access these from multiple threads for the same context. + private StringBuilder GetOutputBuilder() => + LazyInitializer.EnsureInitialized(ref _outputBuilder)!; + private StringBuilder GetErrorOutputBuilder() => + LazyInitializer.EnsureInitialized(ref _errorOutputBuilder)!; + private ReaderWriterLockSlim GetOutputLock() => + LazyInitializer.EnsureInitialized(ref _outputLock, static () => new ReaderWriterLockSlim(LockRecursionPolicy.NoRecursion))!; + private ReaderWriterLockSlim GetErrorOutputLock() => + LazyInitializer.EnsureInitialized(ref _errorOutputLock, static () => new ReaderWriterLockSlim(LockRecursionPolicy.NoRecursion))!; [field: AllowNull, MaybeNull] - public TextWriter OutputWriter => field ??= new ConcurrentStringWriter(_outputBuilder, _outputLock); + public TextWriter OutputWriter => field ??= new ConcurrentStringWriter(GetOutputBuilder(), GetOutputLock()); [field: AllowNull, MaybeNull] - public TextWriter ErrorOutputWriter => field ??= new ConcurrentStringWriter(_errorOutputBuilder, _errorOutputLock); + public TextWriter ErrorOutputWriter => field ??= new ConcurrentStringWriter(GetErrorOutputBuilder(), GetErrorOutputLock()); // Internal accessors for console interceptor line buffers - internal ConsoleLineBuffer ConsoleStdOutLineBuffer => _consoleStdOutLineBuffer; + internal ConsoleLineBuffer ConsoleStdOutLineBuffer => + LazyInitializer.EnsureInitialized(ref _consoleStdOutLineBuffer)!; - internal ConsoleLineBuffer ConsoleStdErrLineBuffer => _consoleStdErrLineBuffer; + internal ConsoleLineBuffer ConsoleStdErrLineBuffer => + LazyInitializer.EnsureInitialized(ref _consoleStdErrLineBuffer)!; internal Context(Context? parent) { @@ -82,7 +97,13 @@ public void AddAsyncLocalValues() public virtual string GetStandardOutput() { - _outputLock.EnterReadLock(); + if (_outputBuilder == null) + { + return string.Empty; + } + + var outputLock = GetOutputLock(); + outputLock.EnterReadLock(); try { @@ -92,13 +113,19 @@ public virtual string GetStandardOutput() } finally { - _outputLock.ExitReadLock(); + outputLock.ExitReadLock(); } } public virtual string GetErrorOutput() { - _errorOutputLock.EnterReadLock(); + if (_errorOutputBuilder == null) + { + return string.Empty; + } + + var errorOutputLock = GetErrorOutputLock(); + errorOutputLock.EnterReadLock(); try { @@ -108,7 +135,7 @@ public virtual string GetErrorOutput() } finally { - _errorOutputLock.ExitReadLock(); + errorOutputLock.ExitReadLock(); } } diff --git a/TUnit.Core/DataGeneratorMetadataCreator.cs b/TUnit.Core/DataGeneratorMetadataCreator.cs index 82090005b0..70d8767efc 100644 --- a/TUnit.Core/DataGeneratorMetadataCreator.cs +++ b/TUnit.Core/DataGeneratorMetadataCreator.cs @@ -98,8 +98,6 @@ public static DataGeneratorMetadata CreateForReflectionDiscovery( TestBuilderContext = new TestBuilderContextAccessor(new TestBuilderContext { TestMetadata = null!, // Not available during discovery - Events = new TestContextEvents(), - StateBag = new ConcurrentDictionary() }), MembersToGenerate = membersToGenerate, TestInformation = methodMetadata, @@ -158,8 +156,6 @@ public static DataGeneratorMetadata CreateForGenericTypeDiscovery( { TestMetadata = discoveryMethodMetadata, DataSourceAttribute = dataSource, - Events = new TestContextEvents(), - StateBag = new ConcurrentDictionary() }), MembersToGenerate = [dummyParameter], TestInformation = discoveryMethodMetadata, @@ -184,17 +180,28 @@ public static DataGeneratorMetadata CreateForPropertyInjection( TestContextEvents? events = null, ConcurrentDictionary? objectBag = null) { - var testBuilderContext = testContext != null - ? TestBuilderContext.FromTestContext(testContext, dataSource) - : methodMetadata != null - ? new TestBuilderContext - { - Events = events ?? new TestContextEvents(), - TestMetadata = methodMetadata, - DataSourceAttribute = dataSource, - StateBag = objectBag ?? new ConcurrentDictionary() - } - : TestSessionContext.GlobalStaticPropertyContext; + TestBuilderContext testBuilderContext; + if (testContext != null) + { + testBuilderContext = TestBuilderContext.FromTestContext(testContext, dataSource); + } + else if (methodMetadata != null) + { + testBuilderContext = new TestBuilderContext + { + Events = events ?? new TestContextEvents(), + TestMetadata = methodMetadata, + DataSourceAttribute = dataSource, + }; + if (objectBag != null) + { + testBuilderContext.StateBag = objectBag; + } + } + else + { + testBuilderContext = TestSessionContext.GlobalStaticPropertyContext; + } return new DataGeneratorMetadata { diff --git a/TUnit.Core/Helpers/ReferenceEqualityComparer.cs b/TUnit.Core/Helpers/ReferenceEqualityComparer.cs index 16da75d77b..2bed2ae489 100644 --- a/TUnit.Core/Helpers/ReferenceEqualityComparer.cs +++ b/TUnit.Core/Helpers/ReferenceEqualityComparer.cs @@ -41,3 +41,17 @@ public int GetHashCode(object obj) return RuntimeHelpers.GetHashCode(obj); } } + +/// +/// Generic version of for strongly-typed collections. +/// +public sealed class ReferenceEqualityComparer : IEqualityComparer where T : class +{ + public static readonly ReferenceEqualityComparer Instance = new(); + + private ReferenceEqualityComparer() { } + + public bool Equals(T? x, T? y) => ReferenceEquals(x, y); + + public int GetHashCode(T obj) => RuntimeHelpers.GetHashCode(obj); +} diff --git a/TUnit.Core/Logging/LogSinkRouter.cs b/TUnit.Core/Logging/LogSinkRouter.cs index e10dac3b48..7de4dc2c76 100644 --- a/TUnit.Core/Logging/LogSinkRouter.cs +++ b/TUnit.Core/Logging/LogSinkRouter.cs @@ -13,7 +13,7 @@ internal static class LogSinkRouter public static void RouteToSinks(LogLevel level, string message, Exception? exception, Context? context) { var sinks = TUnitLoggerFactory.GetSinks(); - if (sinks.Count == 0) + if (sinks.Length == 0) { return; } @@ -41,7 +41,7 @@ public static void RouteToSinks(LogLevel level, string message, Exception? excep public static async ValueTask RouteToSinksAsync(LogLevel level, string message, Exception? exception, Context? context) { var sinks = TUnitLoggerFactory.GetSinks(); - if (sinks.Count == 0) + if (sinks.Length == 0) { return; } diff --git a/TUnit.Core/Logging/TUnitLoggerFactory.cs b/TUnit.Core/Logging/TUnitLoggerFactory.cs index 1eaab9adda..643e2da629 100644 --- a/TUnit.Core/Logging/TUnitLoggerFactory.cs +++ b/TUnit.Core/Logging/TUnitLoggerFactory.cs @@ -61,6 +61,7 @@ public static class TUnitLoggerFactory { private static readonly List _sinks = []; private static readonly Lock _lock = new(); + private static volatile ILogSink[] _sinksCache = []; /// /// Registers a log sink instance to receive log messages from all tests. @@ -90,6 +91,7 @@ public static void AddSink(ILogSink sink) lock (_lock) { _sinks.Add(sink); + _sinksCache = [.. _sinks]; } } @@ -120,13 +122,7 @@ public static void AddSink(ILogSink sink) /// /// Gets all registered sinks. For internal use. /// - internal static IReadOnlyList GetSinks() - { - lock (_lock) - { - return _sinks.ToArray(); - } - } + internal static ILogSink[] GetSinks() => _sinksCache; /// /// Disposes all sinks that implement IAsyncDisposable or IDisposable. @@ -137,8 +133,9 @@ internal static async ValueTask DisposeAllAsync() ILogSink[] sinksToDispose; lock (_lock) { - sinksToDispose = _sinks.ToArray(); + sinksToDispose = _sinksCache; _sinks.Clear(); + _sinksCache = []; } foreach (var sink in sinksToDispose) @@ -169,6 +166,7 @@ internal static void Clear() lock (_lock) { _sinks.Clear(); + _sinksCache = []; } } } diff --git a/TUnit.Core/Models/ClassHookContext.cs b/TUnit.Core/Models/ClassHookContext.cs index 14e96c35b1..2df78d7c88 100644 --- a/TUnit.Core/Models/ClassHookContext.cs +++ b/TUnit.Core/Models/ClassHookContext.cs @@ -1,5 +1,6 @@ using System.Diagnostics; using System.Diagnostics.CodeAnalysis; +using TUnit.Core.Helpers; namespace TUnit.Core; @@ -27,11 +28,12 @@ internal ClassHookContext(AssemblyHookContext assemblyHookContext) : base(assemb [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.PublicMethods)] public required Type ClassType { get; init; } + private readonly HashSet _testSet = new(ReferenceEqualityComparer.Instance); private readonly List _tests = []; public void AddTest(TestContext testContext) { - if (_tests.Contains(testContext)) + if (!_testSet.Add(testContext)) { return; // Prevent duplicates } diff --git a/TUnit.Core/TestContext.Output.cs b/TUnit.Core/TestContext.Output.cs index ac94e3bdc2..6614b737b1 100644 --- a/TUnit.Core/TestContext.Output.cs +++ b/TUnit.Core/TestContext.Output.cs @@ -1,4 +1,3 @@ -using System.Collections.Concurrent; using TUnit.Core.Helpers; using TUnit.Core.Interfaces; @@ -16,10 +15,12 @@ internal record TimingEntry(string StepName, DateTimeOffset Start, DateTimeOffse public partial class TestContext { // Internal backing fields and properties - internal ConcurrentBag Timings { get; } = []; - private readonly ConcurrentBag _artifactsBag = new(); + // List instead of ConcurrentBag: Timings are written sequentially during test + // execution and read once after completion. Artifacts are added at known points. + internal List Timings { get; } = []; + private readonly List _artifacts = []; - internal IReadOnlyList Artifacts => _artifactsBag.ToArray(); + internal IReadOnlyList Artifacts => _artifacts; // Explicit interface implementations for ITestOutput TextWriter ITestOutput.StandardOutput => OutputWriter; @@ -28,13 +29,13 @@ public partial class TestContext void ITestOutput.AttachArtifact(Artifact artifact) { - _artifactsBag.Add(artifact); + _artifacts.Add(artifact); } void ITestOutput.AttachArtifact(string filePath, string? displayName, string? description) { var fileInfo = new FileInfo(filePath); - _artifactsBag.Add(new Artifact + _artifacts.Add(new Artifact { File = fileInfo, DisplayName = displayName ?? fileInfo.Name, diff --git a/TUnit.Engine/Building/TestBuilder.cs b/TUnit.Engine/Building/TestBuilder.cs index 2b972170db..207d8a24e6 100644 --- a/TUnit.Engine/Building/TestBuilder.cs +++ b/TUnit.Engine/Building/TestBuilder.cs @@ -156,7 +156,7 @@ public async Task> BuildTestsFromMetadataAsy // Create and initialize attributes ONCE var attributes = await InitializeAttributesAsync(metadata.GetOrCreateAttributes(), cancellationToken); - if (metadata.ClassDataSources.Any(ds => ds is IAccessesInstanceData)) + if (HasInstanceDataAccessor(metadata.ClassDataSources)) { var failedTest = CreateFailedTestForClassDataSourceCircularDependency(metadata); tests.Add(failedTest); @@ -176,7 +176,15 @@ public async Task> BuildTestsFromMetadataAsy TestBuilderContext.Current = testBuilderContext; // Check for ClassConstructor attribute and set it early if present (reuse already created attributes) - var classConstructorAttribute = attributes.OfType().FirstOrDefault(); + ClassConstructorAttribute? classConstructorAttribute = null; + foreach (var attr in attributes) + { + if (attr is ClassConstructorAttribute cca) + { + classConstructorAttribute = cca; + break; + } + } if (classConstructorAttribute != null) { testBuilderContext.ClassConstructor = (IClassConstructor)Activator.CreateInstance(classConstructorAttribute.ClassConstructorType)!; @@ -220,7 +228,7 @@ public async Task> BuildTestsFromMetadataAsy // ObjectInitializer is phase-aware and will only initialize IAsyncDiscoveryInitializer during Discovery. await InitializeClassDataAsync(classData); - var needsInstanceForMethodDataSources = metadata.DataSources.Any(ds => ds is IAccessesInstanceData); + var needsInstanceForMethodDataSources = HasInstanceDataAccessor(metadata.DataSources); object? instanceForMethodDataSources = null; var discoveryInstanceUsed = false; @@ -701,7 +709,7 @@ private static Type[] TryInferClassGenericsFromDataSources(TestMetadata metadata } } - if (metadata.DataSources.Any(ds => ds is IAccessesInstanceData)) + if (HasInstanceDataAccessor(metadata.DataSources)) { // Look at the test method parameters to find attributes that can help with generic type inference foreach (var param in metadata.MethodMetadata.Parameters) @@ -1570,7 +1578,7 @@ public async IAsyncEnumerable BuildTestsStreamingAsync( var contextAccessor = new TestBuilderContextAccessor(baseContext); // Check for circular dependency - if (metadata.ClassDataSources.Any(ds => ds is IAccessesInstanceData)) + if (HasInstanceDataAccessor(metadata.ClassDataSources)) { yield return CreateFailedTestForClassDataSourceCircularDependency(metadata); yield break; @@ -1603,7 +1611,7 @@ public async IAsyncEnumerable BuildTestsStreamingAsync( await InitializeClassDataAsync(classData); // Handle instance creation for method data sources - var needsInstanceForMethodDataSources = metadata.DataSources.Any(ds => ds is IAccessesInstanceData); + var needsInstanceForMethodDataSources = HasInstanceDataAccessor(metadata.DataSources); object? instanceForMethodDataSources = null; if (needsInstanceForMethodDataSources) @@ -1900,4 +1908,16 @@ internal bool CouldTestMatchFilter(ITestExecutionFilter filter, TestMetadata met return _filterMatcher.CouldMatchFilter(metadata, filter); } + private static bool HasInstanceDataAccessor(IDataSourceAttribute[] dataSources) + { + foreach (var ds in dataSources) + { + if (ds is IAccessesInstanceData) + { + return true; + } + } + return false; + } + } diff --git a/TUnit.Engine/Building/TestBuilderPipeline.cs b/TUnit.Engine/Building/TestBuilderPipeline.cs index 4b44a3c206..3d1d0d11de 100644 --- a/TUnit.Engine/Building/TestBuilderPipeline.cs +++ b/TUnit.Engine/Building/TestBuilderPipeline.cs @@ -49,8 +49,6 @@ private TestBuilderContext CreateTestBuilderContext(TestMetadata metadata) var testBuilderContext = new TestBuilderContext { TestMetadata = metadata.MethodMetadata, - Events = new TestContextEvents(), - StateBag = new ConcurrentDictionary() }; // Check for ClassConstructor attribute and set it early if present @@ -58,10 +56,15 @@ private TestBuilderContext CreateTestBuilderContext(TestMetadata metadata) // Look for any attribute that inherits from ClassConstructorAttribute // This handles both ClassConstructorAttribute and ClassConstructorAttribute - var classConstructorAttribute = attributes - .Where(a => a is ClassConstructorAttribute) - .Cast() - .FirstOrDefault(); + ClassConstructorAttribute? classConstructorAttribute = null; + foreach (var attr in attributes) + { + if (attr is ClassConstructorAttribute cca) + { + classConstructorAttribute = cca; + break; + } + } if (classConstructorAttribute != null) { diff --git a/TUnit.Engine/Events/EventReceiverRegistry.cs b/TUnit.Engine/Events/EventReceiverRegistry.cs index dd7bfcaa88..f2517a6916 100644 --- a/TUnit.Engine/Events/EventReceiverRegistry.cs +++ b/TUnit.Engine/Events/EventReceiverRegistry.cs @@ -71,8 +71,6 @@ private void RegisterReceiverInternal(object receiver) RegisterIfImplements(receiver); RegisterIfImplements(receiver); RegisterIfImplements(receiver); - - _cachedTypedReceivers.Clear(); } private void RegisterIfImplements(object receiver) where T : class @@ -91,6 +89,9 @@ private void RegisterIfImplements(object receiver) where T : class { _receiversByType[interfaceType] = [receiver]; } + + // Invalidate only the changed type instead of clearing the entire cache + _cachedTypedReceivers.TryRemove(interfaceType, out _); } } diff --git a/TUnit.Engine/Extensions/TestExtensions.cs b/TUnit.Engine/Extensions/TestExtensions.cs index f7a75b01b8..59e9cdd801 100644 --- a/TUnit.Engine/Extensions/TestExtensions.cs +++ b/TUnit.Engine/Extensions/TestExtensions.cs @@ -179,7 +179,8 @@ internal static TestNode ToTestNode(this TestContext testContext, TestNodeStateP propertyBag.Add(cachedProps.TrxCategories); } - if (GetTrxMessages(testContext, output, error).ToArray() is { Length: > 0 } trxMessages) + var trxMessages = GetTrxMessages(testContext, output, error); + if (trxMessages.Length > 0) { propertyBag.Add(new TrxMessagesProperty(trxMessages)); } @@ -310,22 +311,24 @@ private static TimingProperty GetTimingProperty(TestContext testContext, DateTim return new TimingProperty(new TimingInfo(overallStart, end, end - overallStart), stepTimings); } - private static IEnumerable GetTrxMessages(TestContext testContext, string? standardOutput, string? standardError) + private static TrxMessage[] GetTrxMessages(TestContext testContext, string? standardOutput, string? standardError) { - if (!string.IsNullOrEmpty(standardOutput)) - { - yield return new StandardOutputTrxMessage(standardOutput); - } + var hasOutput = !string.IsNullOrEmpty(standardOutput); + var hasError = !string.IsNullOrEmpty(standardError); + var hasSkip = !string.IsNullOrEmpty(testContext.SkipReason); - if (!string.IsNullOrEmpty(standardError)) + var count = (hasOutput ? 1 : 0) + (hasError ? 1 : 0) + (hasSkip ? 1 : 0); + if (count == 0) { - yield return new StandardErrorTrxMessage(standardError); + return []; } - if (!string.IsNullOrEmpty(testContext.SkipReason)) - { - yield return new DebugOrTraceTrxMessage($"Skipped: {testContext.SkipReason}"); - } + var result = new TrxMessage[count]; + var i = 0; + if (hasOutput) result[i++] = new StandardOutputTrxMessage(standardOutput!); + if (hasError) result[i++] = new StandardErrorTrxMessage(standardError!); + if (hasSkip) result[i++] = new DebugOrTraceTrxMessage($"Skipped: {testContext.SkipReason}"); + return result; } private static string[] CreateParameterTypeArray(ParameterMetadata[] parameters) diff --git a/TUnit.Engine/Helpers/HookTimeoutHelper.cs b/TUnit.Engine/Helpers/HookTimeoutHelper.cs index 45677966cd..270c806404 100644 --- a/TUnit.Engine/Helpers/HookTimeoutHelper.cs +++ b/TUnit.Engine/Helpers/HookTimeoutHelper.cs @@ -20,6 +20,12 @@ public static Task CreateTimeoutHookAction( { var timeout = hook.Timeout ?? TUnitSettings.Default.Timeouts.DefaultHookTimeout; + // Fast path: skip CTS allocation when timeout is effectively infinite + if (timeout == Timeout.InfiniteTimeSpan || timeout >= TimeSpan.FromDays(1)) + { + return hook.ExecuteAsync(context, cancellationToken).AsTask(); + } + var timeoutMs = (int)timeout.TotalMilliseconds; return CreateTimeoutHookActionAsync(hook, context, timeoutMs, cancellationToken); @@ -56,6 +62,13 @@ public static Func CreateTimeoutHookAction( CancellationToken cancellationToken) { var effectiveTimeout = timeout ?? TUnitSettings.Default.Timeouts.DefaultHookTimeout; + + // Fast path: skip CTS allocation when timeout is effectively infinite + if (effectiveTimeout == Timeout.InfiniteTimeSpan || effectiveTimeout >= TimeSpan.FromDays(1)) + { + return () => hookDelegate(context, cancellationToken); + } + var timeoutMs = (int)effectiveTimeout.TotalMilliseconds; return async () => @@ -88,6 +101,13 @@ public static Func CreateTimeoutHookAction( CancellationToken cancellationToken) { var effectiveTimeout = timeout ?? TUnitSettings.Default.Timeouts.DefaultHookTimeout; + + // Fast path: skip CTS allocation when timeout is effectively infinite + if (effectiveTimeout == Timeout.InfiniteTimeSpan || effectiveTimeout >= TimeSpan.FromDays(1)) + { + return () => hookDelegate(context, cancellationToken).AsTask(); + } + var timeoutMs = (int)effectiveTimeout.TotalMilliseconds; return async () => diff --git a/TUnit.Engine/Reporters/GitHubReporter.cs b/TUnit.Engine/Reporters/GitHubReporter.cs index 3f590a6ffb..eae60fae59 100644 --- a/TUnit.Engine/Reporters/GitHubReporter.cs +++ b/TUnit.Engine/Reporters/GitHubReporter.cs @@ -82,7 +82,7 @@ public Task ConsumeAsync(IDataProducer dataProducer, IData value, CancellationTo var uid = testNodeUpdateMessage.TestNode.Uid.Value; - var state = testNodeUpdateMessage.TestNode.Properties.OfType().FirstOrDefault(); + var state = testNodeUpdateMessage.TestNode.Properties.SingleOrDefault(); if (state is not null and not InProgressTestNodeStateProperty and not DiscoveredTestNodeStateProperty) { _terminalStateCounts.AddOrUpdate(uid, 1, static (_, count) => count + 1); @@ -460,7 +460,7 @@ private async Task WriteFile(string contents) } const int maxAttempts = EngineDefaults.FileWriteMaxAttempts; - var random = new Random(); + var random = Random.Shared; for (int attempt = 1; attempt <= maxAttempts; attempt++) { diff --git a/TUnit.Engine/Services/TestFilterService.cs b/TUnit.Engine/Services/TestFilterService.cs index 7605838236..e0acfe5572 100644 --- a/TUnit.Engine/Services/TestFilterService.cs +++ b/TUnit.Engine/Services/TestFilterService.cs @@ -14,6 +14,7 @@ namespace TUnit.Engine.Services; internal class TestFilterService(TUnitFrameworkLogger logger, TestArgumentRegistrationService testArgumentRegistrationService) { private static readonly ConcurrentDictionary _explicitClassCache = new(); + private HashSet? _uidFilterSet; public IReadOnlyCollection FilterTests(ITestExecutionFilter? testExecutionFilter, IReadOnlyCollection testNodes) { if (testExecutionFilter is null or NopFilter) @@ -119,10 +120,22 @@ private async Task RegisterTest(AbstractExecutableTest test) public async Task RegisterTestsAsync(IEnumerable tests) { - foreach (var test in tests) + var testList = tests as IReadOnlyList ?? tests.ToList(); + + if (testList.Count < 8) { - await RegisterTest(test); + foreach (var test in testList) + { + await RegisterTest(test); + } + return; } + + await Parallel.ForEachAsync( + testList, + new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount }, + async (test, _) => await RegisterTest(test).ConfigureAwait(false) + ).ConfigureAwait(false); } public bool MatchesTest(ITestExecutionFilter? testExecutionFilter, AbstractExecutableTest executableTest) @@ -132,7 +145,7 @@ public bool MatchesTest(ITestExecutionFilter? testExecutionFilter, AbstractExecu { null => true, NopFilter => true, - TestNodeUidListFilter testNodeUidListFilter => testNodeUidListFilter.TestNodeUids.Contains(new TestNodeUid(executableTest.TestId)), + TestNodeUidListFilter testNodeUidListFilter => GetOrCreateUidFilterSet(testNodeUidListFilter).Contains(executableTest.TestId), TreeNodeFilter treeNodeFilter => CheckTreeNodeFilter(treeNodeFilter, executableTest), _ => UnhandledFilter(testExecutionFilter) }; @@ -141,6 +154,27 @@ public bool MatchesTest(ITestExecutionFilter? testExecutionFilter, AbstractExecu #pragma warning restore TPEXP } + private HashSet GetOrCreateUidFilterSet( +#pragma warning disable TPEXP + TestNodeUidListFilter filter +#pragma warning restore TPEXP + ) + { + if (_uidFilterSet != null) + { + return _uidFilterSet; + } + + var set = new HashSet(StringComparer.Ordinal); + foreach (var uid in filter.TestNodeUids) + { + set.Add(uid.Value); + } + + _uidFilterSet = set; + return set; + } + private string BuildPath(AbstractExecutableTest test) { // Return cached path if available @@ -257,6 +291,7 @@ private IReadOnlyCollection FilterOutExplicitTests(IRead /// /// Builds the nested class name from ClassMetadata by walking the Parent chain. /// Returns names joined with '+' (e.g., "OuterClass+InnerClass"). + /// Fills array in root-to-leaf order to avoid List + Reverse allocation. /// internal static string GetNestedClassName(ClassMetadata classMetadata) { @@ -265,15 +300,24 @@ internal static string GetNestedClassName(ClassMetadata classMetadata) return classMetadata.Name; } - var hierarchy = new List(); + // Count depth first + var depth = 0; var current = classMetadata; while (current != null) { - hierarchy.Add(current.Name); + depth++; + current = current.Parent; + } + + // Fill array in root-to-leaf order (avoids Reverse) + var hierarchy = new string[depth]; + current = classMetadata; + for (var i = depth - 1; i >= 0; i--) + { + hierarchy[i] = current!.Name; current = current.Parent; } - hierarchy.Reverse(); return string.Join('+', hierarchy); } } diff --git a/TUnit.Engine/TestExecutor.cs b/TUnit.Engine/TestExecutor.cs index 5a6d8da8ab..3030f9514a 100644 --- a/TUnit.Engine/TestExecutor.cs +++ b/TUnit.Engine/TestExecutor.cs @@ -235,10 +235,19 @@ await TimeoutHelper.ExecuteWithTimeoutAsync( // Late stage test end receivers run after instance-level hooks (default behavior) var lateStageExceptions = await _eventReceiverOrchestrator.InvokeTestEndEventReceiversAsync(executableTest.Context, CancellationToken.None, EventReceiverStage.Late).ConfigureAwait(false); - // Combine all exceptions from event receivers - var eventReceiverExceptions = new List(earlyStageExceptions.Count + lateStageExceptions.Count); - eventReceiverExceptions.AddRange(earlyStageExceptions); - eventReceiverExceptions.AddRange(lateStageExceptions); + // Combine all exceptions from event receivers - defer allocation until needed + IReadOnlyList eventReceiverExceptions; + if (earlyStageExceptions.Count > 0 || lateStageExceptions.Count > 0) + { + var combined = new List(earlyStageExceptions.Count + lateStageExceptions.Count); + combined.AddRange(earlyStageExceptions); + combined.AddRange(lateStageExceptions); + eventReceiverExceptions = combined; + } + else + { + eventReceiverExceptions = []; + } if (hookExceptions.Count > 0 || eventReceiverExceptions.Count > 0) { From 9f0bda403390ea498f0d28ed9e72bffdf6e366d6 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 12 Apr 2026 20:51:49 +0100 Subject: [PATCH 2/7] fix: address PR review feedback - Revert SingleOrDefault to OfType().FirstOrDefault() in GitHubReporter (SingleOrDefault throws on >1 match, breaking defensive resilience) - Use LazyInitializer.EnsureInitialized for _uidFilterSet in TestFilterService (consistent thread-safe lazy init pattern across the PR) - Extract TimeSpan.FromDays(1) to static readonly field in HookTimeoutHelper (avoid recomputation on every call) --- TUnit.Engine/Helpers/HookTimeoutHelper.cs | 7 ++++--- TUnit.Engine/Reporters/GitHubReporter.cs | 2 +- TUnit.Engine/Services/TestFilterService.cs | 11 ++--------- 3 files changed, 7 insertions(+), 13 deletions(-) diff --git a/TUnit.Engine/Helpers/HookTimeoutHelper.cs b/TUnit.Engine/Helpers/HookTimeoutHelper.cs index 270c806404..201acb3f4c 100644 --- a/TUnit.Engine/Helpers/HookTimeoutHelper.cs +++ b/TUnit.Engine/Helpers/HookTimeoutHelper.cs @@ -10,6 +10,7 @@ namespace TUnit.Engine.Helpers; /// internal static class HookTimeoutHelper { + private static readonly TimeSpan EffectivelyInfiniteTimeout = TimeSpan.FromDays(1); /// /// Creates a timeout-aware action wrapper for a hook /// @@ -21,7 +22,7 @@ public static Task CreateTimeoutHookAction( var timeout = hook.Timeout ?? TUnitSettings.Default.Timeouts.DefaultHookTimeout; // Fast path: skip CTS allocation when timeout is effectively infinite - if (timeout == Timeout.InfiniteTimeSpan || timeout >= TimeSpan.FromDays(1)) + if (timeout == Timeout.InfiniteTimeSpan || timeout >= EffectivelyInfiniteTimeout) { return hook.ExecuteAsync(context, cancellationToken).AsTask(); } @@ -64,7 +65,7 @@ public static Func CreateTimeoutHookAction( var effectiveTimeout = timeout ?? TUnitSettings.Default.Timeouts.DefaultHookTimeout; // Fast path: skip CTS allocation when timeout is effectively infinite - if (effectiveTimeout == Timeout.InfiniteTimeSpan || effectiveTimeout >= TimeSpan.FromDays(1)) + if (effectiveTimeout == Timeout.InfiniteTimeSpan || effectiveTimeout >= EffectivelyInfiniteTimeout) { return () => hookDelegate(context, cancellationToken); } @@ -103,7 +104,7 @@ public static Func CreateTimeoutHookAction( var effectiveTimeout = timeout ?? TUnitSettings.Default.Timeouts.DefaultHookTimeout; // Fast path: skip CTS allocation when timeout is effectively infinite - if (effectiveTimeout == Timeout.InfiniteTimeSpan || effectiveTimeout >= TimeSpan.FromDays(1)) + if (effectiveTimeout == Timeout.InfiniteTimeSpan || effectiveTimeout >= EffectivelyInfiniteTimeout) { return () => hookDelegate(context, cancellationToken).AsTask(); } diff --git a/TUnit.Engine/Reporters/GitHubReporter.cs b/TUnit.Engine/Reporters/GitHubReporter.cs index eae60fae59..0ed6afd379 100644 --- a/TUnit.Engine/Reporters/GitHubReporter.cs +++ b/TUnit.Engine/Reporters/GitHubReporter.cs @@ -82,7 +82,7 @@ public Task ConsumeAsync(IDataProducer dataProducer, IData value, CancellationTo var uid = testNodeUpdateMessage.TestNode.Uid.Value; - var state = testNodeUpdateMessage.TestNode.Properties.SingleOrDefault(); + var state = testNodeUpdateMessage.TestNode.Properties.OfType().FirstOrDefault(); if (state is not null and not InProgressTestNodeStateProperty and not DiscoveredTestNodeStateProperty) { _terminalStateCounts.AddOrUpdate(uid, 1, static (_, count) => count + 1); diff --git a/TUnit.Engine/Services/TestFilterService.cs b/TUnit.Engine/Services/TestFilterService.cs index e0acfe5572..4cfb43c7c8 100644 --- a/TUnit.Engine/Services/TestFilterService.cs +++ b/TUnit.Engine/Services/TestFilterService.cs @@ -158,22 +158,15 @@ private HashSet GetOrCreateUidFilterSet( #pragma warning disable TPEXP TestNodeUidListFilter filter #pragma warning restore TPEXP - ) + ) => LazyInitializer.EnsureInitialized(ref _uidFilterSet, () => { - if (_uidFilterSet != null) - { - return _uidFilterSet; - } - var set = new HashSet(StringComparer.Ordinal); foreach (var uid in filter.TestNodeUids) { set.Add(uid.Value); } - - _uidFilterSet = set; return set; - } + })!; private string BuildPath(AbstractExecutableTest test) { From 53cecf0c3b13fc5cdf3a2d68c9448738d3a1768c Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 12 Apr 2026 20:59:40 +0100 Subject: [PATCH 3/7] =?UTF-8?q?fix:=20address=20second=20review=20?= =?UTF-8?q?=E2=80=94=20thread-safe=20artifacts=20and=20documented=20parall?= =?UTF-8?q?elism?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Revert _artifacts to thread-safe collection (Lock + List instead of ConcurrentBag) since AttachArtifact is user-facing and can be called from parallel Task.WhenAll branches within a single test - Document concurrency contract on RegisterTestsAsync: per-test state is isolated, shared services use ConcurrentDictionary, and ITestRegisteredEventReceiver implementations must be thread-safe --- TUnit.Core/TestContext.Output.cs | 15 +++++++++------ TUnit.Engine/Services/TestFilterService.cs | 7 +++++++ 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/TUnit.Core/TestContext.Output.cs b/TUnit.Core/TestContext.Output.cs index 6614b737b1..328693ff91 100644 --- a/TUnit.Core/TestContext.Output.cs +++ b/TUnit.Core/TestContext.Output.cs @@ -15,12 +15,14 @@ internal record TimingEntry(string StepName, DateTimeOffset Start, DateTimeOffse public partial class TestContext { // Internal backing fields and properties - // List instead of ConcurrentBag: Timings are written sequentially during test - // execution and read once after completion. Artifacts are added at known points. + // Timings are written sequentially by the framework during test execution, never by user code. internal List Timings { get; } = []; + // Artifacts use a lock because AttachArtifact is user-facing and can be called + // from parallel Task.WhenAll branches within a single test. + private readonly Lock _artifactsLock = new(); private readonly List _artifacts = []; - internal IReadOnlyList Artifacts => _artifacts; + internal IReadOnlyList Artifacts { get { lock (_artifactsLock) return [.. _artifacts]; } } // Explicit interface implementations for ITestOutput TextWriter ITestOutput.StandardOutput => OutputWriter; @@ -29,18 +31,19 @@ public partial class TestContext void ITestOutput.AttachArtifact(Artifact artifact) { - _artifacts.Add(artifact); + lock (_artifactsLock) _artifacts.Add(artifact); } void ITestOutput.AttachArtifact(string filePath, string? displayName, string? description) { var fileInfo = new FileInfo(filePath); - _artifacts.Add(new Artifact + var artifact = new Artifact { File = fileInfo, DisplayName = displayName ?? fileInfo.Name, Description = description - }); + }; + lock (_artifactsLock) _artifacts.Add(artifact); } string ITestOutput.GetStandardOutput() => GetOutput(); diff --git a/TUnit.Engine/Services/TestFilterService.cs b/TUnit.Engine/Services/TestFilterService.cs index 4cfb43c7c8..8abbd1e444 100644 --- a/TUnit.Engine/Services/TestFilterService.cs +++ b/TUnit.Engine/Services/TestFilterService.cs @@ -118,6 +118,13 @@ private async Task RegisterTest(AbstractExecutableTest test) test.Context.InvalidateDisplayNameCache(); } + /// + /// Registers tests by invoking event receivers and argument registration. + /// Parallelized for 8+ tests. Concurrency contract: RegisterTest operates on + /// per-test state (TestContext), and shared services (EventReceiverRegistry, + /// TestArgumentRegistrationService) use ConcurrentDictionary internally. + /// ITestRegisteredEventReceiver implementations must be thread-safe. + /// public async Task RegisterTestsAsync(IEnumerable tests) { var testList = tests as IReadOnlyList ?? tests.ToList(); From 1bb39fe7d4efcf49a3aec536e2875fa3a135b1c9 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 12 Apr 2026 21:05:43 +0100 Subject: [PATCH 4/7] =?UTF-8?q?fix:=20simplification=20review=20=E2=80=94?= =?UTF-8?q?=20bug=20fix,=20efficiency,=20and=20cleanup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix ClassHookContext.RemoveTest not removing from _testSet (would silently drop re-added tests after removal) - Add null-check guard in GetOrCreateUidFilterSet to avoid closure allocation on every MatchesTest call after initialization - Capture testContext.Artifacts once in TestExtensions to eliminate double lock acquisition and double array copy - Trim narrating comment in Context.cs --- TUnit.Core/Context.cs | 3 +-- TUnit.Core/Models/ClassHookContext.cs | 1 + TUnit.Engine/Extensions/TestExtensions.cs | 5 +++-- TUnit.Engine/Services/TestFilterService.cs | 21 +++++++++++++++------ 4 files changed, 20 insertions(+), 10 deletions(-) diff --git a/TUnit.Core/Context.cs b/TUnit.Core/Context.cs index 6a4ed8d03a..cd5e64f5a7 100644 --- a/TUnit.Core/Context.cs +++ b/TUnit.Core/Context.cs @@ -35,8 +35,7 @@ TestContext.Current as Context private ConsoleLineBuffer? _consoleStdOutLineBuffer; private ConsoleLineBuffer? _consoleStdErrLineBuffer; - // Thread-safe lazy init via Interlocked.CompareExchange — console interceptors - // may access these from multiple threads for the same context. + // Thread-safe: console interceptors may access from multiple threads. private StringBuilder GetOutputBuilder() => LazyInitializer.EnsureInitialized(ref _outputBuilder)!; private StringBuilder GetErrorOutputBuilder() => diff --git a/TUnit.Core/Models/ClassHookContext.cs b/TUnit.Core/Models/ClassHookContext.cs index 2df78d7c88..e400427fc6 100644 --- a/TUnit.Core/Models/ClassHookContext.cs +++ b/TUnit.Core/Models/ClassHookContext.cs @@ -77,6 +77,7 @@ public override int GetHashCode() internal void RemoveTest(TestContext test) { + _testSet.Remove(test); _tests.Remove(test); if (_tests.Count is 0) diff --git a/TUnit.Engine/Extensions/TestExtensions.cs b/TUnit.Engine/Extensions/TestExtensions.cs index 59e9cdd801..841e8cc70d 100644 --- a/TUnit.Engine/Extensions/TestExtensions.cs +++ b/TUnit.Engine/Extensions/TestExtensions.cs @@ -143,9 +143,10 @@ internal static TestNode ToTestNode(this TestContext testContext, TestNodeStateP } } - if (isFinalState && testContext.Output.Artifacts.Count > 0) + var artifacts = testContext.Artifacts; + if (isFinalState && artifacts.Count > 0) { - foreach (var artifact in testContext.Artifacts) + foreach (var artifact in artifacts) { propertyBag.Add(new FileArtifactProperty(artifact.File, artifact.DisplayName, artifact.Description)); } diff --git a/TUnit.Engine/Services/TestFilterService.cs b/TUnit.Engine/Services/TestFilterService.cs index 8abbd1e444..24391f450d 100644 --- a/TUnit.Engine/Services/TestFilterService.cs +++ b/TUnit.Engine/Services/TestFilterService.cs @@ -165,15 +165,24 @@ private HashSet GetOrCreateUidFilterSet( #pragma warning disable TPEXP TestNodeUidListFilter filter #pragma warning restore TPEXP - ) => LazyInitializer.EnsureInitialized(ref _uidFilterSet, () => + ) { - var set = new HashSet(StringComparer.Ordinal); - foreach (var uid in filter.TestNodeUids) + // Fast path: avoid closure allocation when already initialized + if (_uidFilterSet is { } cached) { - set.Add(uid.Value); + return cached; } - return set; - })!; + + return LazyInitializer.EnsureInitialized(ref _uidFilterSet, () => + { + var set = new HashSet(StringComparer.Ordinal); + foreach (var uid in filter.TestNodeUids) + { + set.Add(uid.Value); + } + return set; + })!; + } private string BuildPath(AbstractExecutableTest test) { From 1ede29cae052463647c01b02b85d99863588c64f Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 12 Apr 2026 21:07:38 +0100 Subject: [PATCH 5/7] docs: add thread-safety note to ITestRegisteredEventReceiver OnTestRegistered may be called concurrently when many tests are registered. Document this contract at the interface level so implementors are aware. --- TUnit.Core/Interfaces/ITestRegisteredEventReceiver.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/TUnit.Core/Interfaces/ITestRegisteredEventReceiver.cs b/TUnit.Core/Interfaces/ITestRegisteredEventReceiver.cs index e5bce0f27f..9b9b76776b 100644 --- a/TUnit.Core/Interfaces/ITestRegisteredEventReceiver.cs +++ b/TUnit.Core/Interfaces/ITestRegisteredEventReceiver.cs @@ -14,6 +14,10 @@ namespace TUnit.Core.Interfaces; /// identified but before it goes through the discovery pipeline. /// /// +/// Thread safety: When many tests are registered, +/// may be called concurrently from multiple threads. Implementations must be thread-safe. +/// +/// /// The property can be used to control the execution order /// when multiple implementations of this interface exist. /// From 50add5c78d24aeee72042d9ba8370658948767dc Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 12 Apr 2026 21:26:31 +0100 Subject: [PATCH 6/7] chore: accept public API snapshots for ReferenceEqualityComparer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New additive-only public API: ReferenceEqualityComparer in TUnit.Core.Helpers. No breaking changes — existing APIs unchanged. --- ...Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt | 7 +++++++ ....Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt | 7 +++++++ ....Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt | 7 +++++++ 3 files changed, 21 insertions(+) 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 609fdc029d..ddf62e4860 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 @@ -2235,6 +2235,13 @@ namespace .Helpers public bool Equals(object? x, object? y) { } public int GetHashCode(object obj) { } } + public sealed class ReferenceEqualityComparer : . + where T : class + { + public static readonly . Instance; + public bool Equals(T? x, T? y) { } + public int GetHashCode(T obj) { } + } public static class TestClassTypeHelper { public static GetTestClassType(.DataGeneratorMetadata dataGeneratorMetadata) { } 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 f6b9f26f4e..e836ff3fc0 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 @@ -2235,6 +2235,13 @@ namespace .Helpers public bool Equals(object? x, object? y) { } public int GetHashCode(object obj) { } } + public sealed class ReferenceEqualityComparer : . + where T : class + { + public static readonly . Instance; + public bool Equals(T? x, T? y) { } + public int GetHashCode(T obj) { } + } public static class TestClassTypeHelper { public static GetTestClassType(.DataGeneratorMetadata dataGeneratorMetadata) { } 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 7eb932e7c6..79780d0e47 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 @@ -2235,6 +2235,13 @@ namespace .Helpers public bool Equals(object? x, object? y) { } public int GetHashCode(object obj) { } } + public sealed class ReferenceEqualityComparer : . + where T : class + { + public static readonly . Instance; + public bool Equals(T? x, T? y) { } + public int GetHashCode(T obj) { } + } public static class TestClassTypeHelper { public static GetTestClassType(.DataGeneratorMetadata dataGeneratorMetadata) { } From 4ae1f271ef08cfa5649d9ce3cea97cd809d04a1b Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sun, 12 Apr 2026 21:28:06 +0100 Subject: [PATCH 7/7] fix: make ReferenceEqualityComparer internal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only used internally by ClassHookContext — no need to expose as public API. Reverts the snapshot changes since the type no longer appears in the public API surface. --- TUnit.Core/Helpers/ReferenceEqualityComparer.cs | 2 +- ...Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt | 7 ------- ....Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt | 7 ------- ....Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt | 7 ------- 4 files changed, 1 insertion(+), 22 deletions(-) diff --git a/TUnit.Core/Helpers/ReferenceEqualityComparer.cs b/TUnit.Core/Helpers/ReferenceEqualityComparer.cs index 2bed2ae489..12b02a7c08 100644 --- a/TUnit.Core/Helpers/ReferenceEqualityComparer.cs +++ b/TUnit.Core/Helpers/ReferenceEqualityComparer.cs @@ -45,7 +45,7 @@ public int GetHashCode(object obj) /// /// Generic version of for strongly-typed collections. /// -public sealed class ReferenceEqualityComparer : IEqualityComparer where T : class +internal sealed class ReferenceEqualityComparer : IEqualityComparer where T : class { public static readonly ReferenceEqualityComparer Instance = new(); 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 ddf62e4860..609fdc029d 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 @@ -2235,13 +2235,6 @@ namespace .Helpers public bool Equals(object? x, object? y) { } public int GetHashCode(object obj) { } } - public sealed class ReferenceEqualityComparer : . - where T : class - { - public static readonly . Instance; - public bool Equals(T? x, T? y) { } - public int GetHashCode(T obj) { } - } public static class TestClassTypeHelper { public static GetTestClassType(.DataGeneratorMetadata dataGeneratorMetadata) { } 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 e836ff3fc0..f6b9f26f4e 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 @@ -2235,13 +2235,6 @@ namespace .Helpers public bool Equals(object? x, object? y) { } public int GetHashCode(object obj) { } } - public sealed class ReferenceEqualityComparer : . - where T : class - { - public static readonly . Instance; - public bool Equals(T? x, T? y) { } - public int GetHashCode(T obj) { } - } public static class TestClassTypeHelper { public static GetTestClassType(.DataGeneratorMetadata dataGeneratorMetadata) { } 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 79780d0e47..7eb932e7c6 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 @@ -2235,13 +2235,6 @@ namespace .Helpers public bool Equals(object? x, object? y) { } public int GetHashCode(object obj) { } } - public sealed class ReferenceEqualityComparer : . - where T : class - { - public static readonly . Instance; - public bool Equals(T? x, T? y) { } - public int GetHashCode(T obj) { } - } public static class TestClassTypeHelper { public static GetTestClassType(.DataGeneratorMetadata dataGeneratorMetadata) { }