From 7448c298277be21ea23aedd6e8874246f0c89671 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Thu, 28 May 2026 19:31:04 +0100 Subject: [PATCH 1/2] perf: collapse Replace chain in TestNameFormatter.BuildTestId Replace the four chained string.Replace calls (each allocating a new string) with a single pooled StringBuilder pass. Only the final ToString allocates; output is byte-identical since StringBuilder.Replace shares ordinal, case-sensitive, all-occurrences semantics with string.Replace. Closes #6032 --- TUnit.Core/Services/TestNameFormatter.cs | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/TUnit.Core/Services/TestNameFormatter.cs b/TUnit.Core/Services/TestNameFormatter.cs index e9f582ab69..5427cb85c6 100644 --- a/TUnit.Core/Services/TestNameFormatter.cs +++ b/TUnit.Core/Services/TestNameFormatter.cs @@ -1,5 +1,6 @@ using System.Collections; using System.Text; +using TUnit.Core.Helpers; using TUnit.Core.Interfaces; namespace TUnit.Core.Services; @@ -68,11 +69,23 @@ public string BuildTestId( int classDataIndex = 0, int methodDataIndex = 0) { - return template - .Replace("{TestIndex}", testIndex.ToString()) - .Replace("{RepeatIndex}", repeatIndex.ToString()) - .Replace("{ClassDataIndex}", classDataIndex.ToString()) - .Replace("{MethodDataIndex}", methodDataIndex.ToString()); + // Mutate a pooled StringBuilder in place then materialize once, instead of + // allocating a new string per Replace call. Only the final string allocates. + var builder = StringBuilderPool.Get(); + try + { + return builder + .Append(template) + .Replace("{TestIndex}", testIndex.ToString()) + .Replace("{RepeatIndex}", repeatIndex.ToString()) + .Replace("{ClassDataIndex}", classDataIndex.ToString()) + .Replace("{MethodDataIndex}", methodDataIndex.ToString()) + .ToString(); + } + finally + { + StringBuilderPool.Return(builder); + } } private string FormatArguments(object?[] args) From f04b14102fe46afec5e1046dfc53b432349d7e2a Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Thu, 28 May 2026 20:30:34 +0100 Subject: [PATCH 2/2] perf: pool StringBuilder in TestNameFormatter.FormatEnumerable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review on PR #6083: FormatEnumerable allocated a fresh StringBuilder on every call on the discovery hot path. Use StringBuilderPool like BuildTestId already does. Reentrant-safe for nested enumerables. (The remaining int.ToString() allocations the reviewer noted are left as-is per their guidance — the stackalloc workaround adds complexity for a marginal win.) --- TUnit.Core/Services/TestNameFormatter.cs | 32 ++++++++++++++++-------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/TUnit.Core/Services/TestNameFormatter.cs b/TUnit.Core/Services/TestNameFormatter.cs index 5427cb85c6..3c6f108090 100644 --- a/TUnit.Core/Services/TestNameFormatter.cs +++ b/TUnit.Core/Services/TestNameFormatter.cs @@ -100,20 +100,30 @@ private string FormatArguments(object?[] args) private string FormatEnumerable(IEnumerable enumerable) { - var sb = new StringBuilder("["); - var first = true; - - foreach (var item in enumerable) + // Pool the builder like BuildTestId. Reentrant-safe: a nested enumerable draws a + // distinct instance from the pool, and each Get is balanced by a Return. + var sb = StringBuilderPool.Get(); + try { - if (!first) + sb.Append('['); + var first = true; + + foreach (var item in enumerable) { - sb.Append(", "); + if (!first) + { + sb.Append(", "); + } + first = false; + sb.Append(FormatArgumentValue(item)); } - first = false; - sb.Append(FormatArgumentValue(item)); - } - sb.Append(']'); - return sb.ToString(); + sb.Append(']'); + return sb.ToString(); + } + finally + { + StringBuilderPool.Return(sb); + } } }