From a416d8ac599251976ee3a22ea936c5a7928b573c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Thu, 11 Jun 2026 00:39:52 +0200 Subject: [PATCH 1/4] perf: single-pass PropertyBag walk in JUnitReport TestResultCapture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace 5 × SingleOrDefault() + 1 × foreach/GetEnumerator() in TestResultCapture.TryCapture() with a single GetStructEnumerator() pass, saving 5 linked-list traversals and 1 IEnumerator heap allocation per terminal test result when --report-junit is enabled. Also update GetClassAndMethodName() to accept the already-collected TestMethodIdentifierProperty directly, removing the final redundant walk. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../TestResultCapture.cs | 72 ++++++++++++------- 1 file changed, 45 insertions(+), 27 deletions(-) diff --git a/src/Platform/Microsoft.Testing.Extensions.JUnitReport/TestResultCapture.cs b/src/Platform/Microsoft.Testing.Extensions.JUnitReport/TestResultCapture.cs index ee7d42710a..6e44d1f680 100644 --- a/src/Platform/Microsoft.Testing.Extensions.JUnitReport/TestResultCapture.cs +++ b/src/Platform/Microsoft.Testing.Extensions.JUnitReport/TestResultCapture.cs @@ -38,18 +38,55 @@ public static ParentChainEntry GetParentChainEntry(TestNodeUpdateMessage update) public static CapturedTestResult? TryCapture(TestNodeUpdateMessage update) { TestNode node = update.TestNode; - TestNodeStateProperty? state = node.Properties.SingleOrDefault(); + + // Single-pass collection of all required properties — replaces 5 × SingleOrDefault() + // + 1 × foreach/GetEnumerator() with one zero-allocation GetStructEnumerator() pass, + // saving 5 linked-list traversals and 1 IEnumerator heap allocation per + // terminal test result. Singleton-typed properties use the local GetSingleOrDefaultValue + // helper to preserve the throw-on-duplicate invariant that SingleOrDefault() provided; + // TestMetadataProperty is intentionally multi-valued and accumulates into a list. + TestNodeStateProperty? state = null; + TimingProperty? timing = null; + TestMethodIdentifierProperty? identifier = null; + StandardOutputProperty? standardOutput = null; + StandardErrorProperty? standardError = null; + List>? traits = null; + + PropertyBag.PropertyBagEnumerator enumerator = node.Properties.GetStructEnumerator(); + while (enumerator.MoveNext()) + { + switch (enumerator.Current) + { + case TestNodeStateProperty s: state = GetSingleOrDefaultValue(state, s); break; + case TimingProperty t: timing = GetSingleOrDefaultValue(timing, t); break; + case TestMethodIdentifierProperty m: identifier = GetSingleOrDefaultValue(identifier, m); break; + case StandardOutputProperty so: standardOutput = GetSingleOrDefaultValue(standardOutput, so); break; + case StandardErrorProperty se: standardError = GetSingleOrDefaultValue(standardError, se); break; + case TestMetadataProperty meta: + // Trait keys and values are test-controlled so we truncate them to + // bound the size of the in-memory result list and generated XML. + traits ??= []; + traits.Add(new KeyValuePair( + Truncate(meta.Key, MaxTraitFieldLength)!, + Truncate(meta.Value, MaxTraitFieldLength)!)); + break; + } + } + + static TProperty GetSingleOrDefaultValue(TProperty? existingProperty, TProperty property) + where TProperty : IProperty + => existingProperty is not null + ? throw new InvalidOperationException($"Found multiple properties of type '{typeof(TProperty)}'.") + : property; + if (state is null or DiscoveredTestNodeStateProperty or InProgressTestNodeStateProperty) { return null; } string outcome = ClassifyOutcome(state); - - TimingProperty? timing = node.Properties.SingleOrDefault(); TimeSpan duration = timing?.GlobalTiming.Duration ?? TimeSpan.Zero; - - (string? className, string? methodName) = GetClassAndMethodName(node); + (string? className, string? methodName) = GetClassAndMethodName(identifier); string? errorMessage = state.Explanation; string? stackTrace = null; @@ -72,24 +109,6 @@ public static ParentChainEntry GetParentChainEntry(TestNodeUpdateMessage update) exceptionType = exception.GetType().FullName; } - string? stdout = node.Properties.SingleOrDefault()?.StandardOutput; - string? stderr = node.Properties.SingleOrDefault()?.StandardError; - - // Collect traits without using LINQ to avoid an enumerator allocation per node. - // Trait keys and values are also test-controlled so we truncate them as well to - // bound the size of the in-memory result list and generated XML. - List>? traits = null; - foreach (IProperty p in node.Properties) - { - if (p is TestMetadataProperty meta) - { - traits ??= []; - traits.Add(new KeyValuePair( - Truncate(meta.Key, MaxTraitFieldLength)!, - Truncate(meta.Value, MaxTraitFieldLength)!)); - } - } - return new CapturedTestResult { // Identity fields are test-controlled and can be unbounded (e.g. very long @@ -111,8 +130,8 @@ public static ParentChainEntry GetParentChainEntry(TestNodeUpdateMessage update) ErrorMessage = Truncate(errorMessage, MaxMessageLength), ExceptionType = exceptionType, StackTrace = Truncate(stackTrace, MaxStackTraceLength), - StandardOutput = Truncate(stdout, MaxStandardStreamLength), - StandardError = Truncate(stderr, MaxStandardStreamLength), + StandardOutput = Truncate(standardOutput?.StandardOutput, MaxStandardStreamLength), + StandardError = Truncate(standardError?.StandardError, MaxStandardStreamLength), Traits = traits, }; } @@ -137,9 +156,8 @@ _ when Array.IndexOf(TestNodePropertiesCategories.WellKnownTestNodeTestRunOutcom _ => throw ApplicationStateGuard.Unreachable(), }; - private static (string? ClassName, string? MethodName) GetClassAndMethodName(TestNode node) + private static (string? ClassName, string? MethodName) GetClassAndMethodName(TestMethodIdentifierProperty? identifier) { - TestMethodIdentifierProperty? identifier = node.Properties.SingleOrDefault(); if (identifier is null) { return (null, null); From 28790fb646f016a9d8bd6933bb7e3b661c93675d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Thu, 11 Jun 2026 09:43:40 +0200 Subject: [PATCH 2/4] perf: keep JUnit capture non-terminal fast path Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../TestResultCapture.cs | 20 +++++++++---------- .../HtmlReportEngineTests.cs | 12 +++++++++++ 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/src/Platform/Microsoft.Testing.Extensions.JUnitReport/TestResultCapture.cs b/src/Platform/Microsoft.Testing.Extensions.JUnitReport/TestResultCapture.cs index 6e44d1f680..a522587c22 100644 --- a/src/Platform/Microsoft.Testing.Extensions.JUnitReport/TestResultCapture.cs +++ b/src/Platform/Microsoft.Testing.Extensions.JUnitReport/TestResultCapture.cs @@ -38,14 +38,18 @@ public static ParentChainEntry GetParentChainEntry(TestNodeUpdateMessage update) public static CapturedTestResult? TryCapture(TestNodeUpdateMessage update) { TestNode node = update.TestNode; + TestNodeStateProperty? state = node.Properties.SingleOrDefault(); + if (state is null or DiscoveredTestNodeStateProperty or InProgressTestNodeStateProperty) + { + return null; + } - // Single-pass collection of all required properties — replaces 5 × SingleOrDefault() - // + 1 × foreach/GetEnumerator() with one zero-allocation GetStructEnumerator() pass, - // saving 5 linked-list traversals and 1 IEnumerator heap allocation per - // terminal test result. Singleton-typed properties use the local GetSingleOrDefaultValue + // Keep the O(1) state lookup above as a fast path for non-terminal messages. Terminal + // results collect all remaining required properties in one pass — replacing + // 4 × SingleOrDefault() + 1 × foreach/GetEnumerator() with one zero-allocation + // GetStructEnumerator() pass. Singleton-typed properties use the local GetSingleOrDefaultValue // helper to preserve the throw-on-duplicate invariant that SingleOrDefault() provided; // TestMetadataProperty is intentionally multi-valued and accumulates into a list. - TestNodeStateProperty? state = null; TimingProperty? timing = null; TestMethodIdentifierProperty? identifier = null; StandardOutputProperty? standardOutput = null; @@ -57,7 +61,6 @@ public static ParentChainEntry GetParentChainEntry(TestNodeUpdateMessage update) { switch (enumerator.Current) { - case TestNodeStateProperty s: state = GetSingleOrDefaultValue(state, s); break; case TimingProperty t: timing = GetSingleOrDefaultValue(timing, t); break; case TestMethodIdentifierProperty m: identifier = GetSingleOrDefaultValue(identifier, m); break; case StandardOutputProperty so: standardOutput = GetSingleOrDefaultValue(standardOutput, so); break; @@ -79,11 +82,6 @@ static TProperty GetSingleOrDefaultValue(TProperty? existingProperty, ? throw new InvalidOperationException($"Found multiple properties of type '{typeof(TProperty)}'.") : property; - if (state is null or DiscoveredTestNodeStateProperty or InProgressTestNodeStateProperty) - { - return null; - } - string outcome = ClassifyOutcome(state); TimeSpan duration = timing?.GlobalTiming.Duration ?? TimeSpan.Zero; (string? className, string? methodName) = GetClassAndMethodName(identifier); diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/HtmlReportEngineTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/HtmlReportEngineTests.cs index 18e14bb21e..cca3f8ece4 100644 --- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/HtmlReportEngineTests.cs +++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/HtmlReportEngineTests.cs @@ -185,6 +185,18 @@ public void TestResultCapture_Returns_Null_For_NonTerminalStates() Assert.IsNull(TestResultCapture.TryCapture(inProgress)); } + [TestMethod] + public void TestResultCapture_DoesNotWalkTerminalProperties_ForNonTerminalStates() + { + var bag = new PropertyBag( + DiscoveredTestNodeStateProperty.CachedInstance, + new TimingProperty(new TimingInfo(DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, TimeSpan.Zero)), + new TimingProperty(new TimingInfo(DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, TimeSpan.Zero))); + TestNode node = new() { Uid = "a", DisplayName = "x", Properties = bag }; + + Assert.IsNull(TestResultCapture.TryCapture(node)); + } + [TestMethod] [DataRow("passed", typeof(PassedTestNodeStateProperty))] [DataRow("skipped", typeof(SkippedTestNodeStateProperty))] From 206cebbae77435c00c8fa518e60e433eb8f6d6d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Thu, 11 Jun 2026 09:50:07 +0200 Subject: [PATCH 3/4] test: cover JUnit capture singleton duplicates Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../HtmlReportEngineTests.cs | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/HtmlReportEngineTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/HtmlReportEngineTests.cs index cca3f8ece4..a42ab1c2c8 100644 --- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/HtmlReportEngineTests.cs +++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/HtmlReportEngineTests.cs @@ -8,9 +8,12 @@ using Microsoft.Testing.Platform.Extensions.TestFramework; using Microsoft.Testing.Platform.Helpers; using Microsoft.Testing.Platform.Services; +using Microsoft.Testing.Platform.TestHost; using Moq; +using JUnitTestResultCapture = Microsoft.Testing.Extensions.JUnitReport.TestResultCapture; + namespace Microsoft.Testing.Extensions.UnitTests; [TestClass] @@ -186,7 +189,7 @@ public void TestResultCapture_Returns_Null_For_NonTerminalStates() } [TestMethod] - public void TestResultCapture_DoesNotWalkTerminalProperties_ForNonTerminalStates() + public void JUnitTestResultCapture_DoesNotWalkTerminalProperties_ForNonTerminalStates() { var bag = new PropertyBag( DiscoveredTestNodeStateProperty.CachedInstance, @@ -194,7 +197,22 @@ public void TestResultCapture_DoesNotWalkTerminalProperties_ForNonTerminalStates new TimingProperty(new TimingInfo(DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, TimeSpan.Zero))); TestNode node = new() { Uid = "a", DisplayName = "x", Properties = bag }; - Assert.IsNull(TestResultCapture.TryCapture(node)); + Assert.IsNull(JUnitTestResultCapture.TryCapture(new TestNodeUpdateMessage(new SessionUid("1"), node))); + } + + [TestMethod] + public void JUnitTestResultCapture_DuplicateSingletonProperty_Throws() + { + var bag = new PropertyBag( + PassedTestNodeStateProperty.CachedInstance, + new TimingProperty(new TimingInfo(DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, TimeSpan.Zero)), + new TimingProperty(new TimingInfo(DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, TimeSpan.Zero))); + TestNode node = new() { Uid = "a", DisplayName = "x", Properties = bag }; + + InvalidOperationException ex = Assert.ThrowsExactly(() => + JUnitTestResultCapture.TryCapture(new TestNodeUpdateMessage(new SessionUid("1"), node))); + + Assert.Contains(nameof(TimingProperty), ex.Message); } [TestMethod] From 562424a4324b157433faa209022a92ba992074be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Thu, 11 Jun 2026 10:00:35 +0200 Subject: [PATCH 4/4] test: extract JUnit TestResultCapture tests, tighten GetSingleOrDefaultValue constraint - Move JUnitTestResultCapture_* tests out of HtmlReportEngineTests.cs into a dedicated JUnitReportTestResultCaptureTests.cs so the file/class name matches the system under test. - Add 'class' constraint to TestResultCapture.GetSingleOrDefaultValue so the TProperty? annotation is unambiguously a nullable-reference annotation (all IProperty implementations are reference types in practice). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../TestResultCapture.cs | 2 +- .../HtmlReportEngineTests.cs | 30 -------------- .../JUnitReportTestResultCaptureTests.cs | 39 +++++++++++++++++++ 3 files changed, 40 insertions(+), 31 deletions(-) create mode 100644 test/UnitTests/Microsoft.Testing.Extensions.UnitTests/JUnitReportTestResultCaptureTests.cs diff --git a/src/Platform/Microsoft.Testing.Extensions.JUnitReport/TestResultCapture.cs b/src/Platform/Microsoft.Testing.Extensions.JUnitReport/TestResultCapture.cs index a522587c22..2d223ae467 100644 --- a/src/Platform/Microsoft.Testing.Extensions.JUnitReport/TestResultCapture.cs +++ b/src/Platform/Microsoft.Testing.Extensions.JUnitReport/TestResultCapture.cs @@ -77,7 +77,7 @@ public static ParentChainEntry GetParentChainEntry(TestNodeUpdateMessage update) } static TProperty GetSingleOrDefaultValue(TProperty? existingProperty, TProperty property) - where TProperty : IProperty + where TProperty : class, IProperty => existingProperty is not null ? throw new InvalidOperationException($"Found multiple properties of type '{typeof(TProperty)}'.") : property; diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/HtmlReportEngineTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/HtmlReportEngineTests.cs index a42ab1c2c8..18e14bb21e 100644 --- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/HtmlReportEngineTests.cs +++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/HtmlReportEngineTests.cs @@ -8,12 +8,9 @@ using Microsoft.Testing.Platform.Extensions.TestFramework; using Microsoft.Testing.Platform.Helpers; using Microsoft.Testing.Platform.Services; -using Microsoft.Testing.Platform.TestHost; using Moq; -using JUnitTestResultCapture = Microsoft.Testing.Extensions.JUnitReport.TestResultCapture; - namespace Microsoft.Testing.Extensions.UnitTests; [TestClass] @@ -188,33 +185,6 @@ public void TestResultCapture_Returns_Null_For_NonTerminalStates() Assert.IsNull(TestResultCapture.TryCapture(inProgress)); } - [TestMethod] - public void JUnitTestResultCapture_DoesNotWalkTerminalProperties_ForNonTerminalStates() - { - var bag = new PropertyBag( - DiscoveredTestNodeStateProperty.CachedInstance, - new TimingProperty(new TimingInfo(DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, TimeSpan.Zero)), - new TimingProperty(new TimingInfo(DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, TimeSpan.Zero))); - TestNode node = new() { Uid = "a", DisplayName = "x", Properties = bag }; - - Assert.IsNull(JUnitTestResultCapture.TryCapture(new TestNodeUpdateMessage(new SessionUid("1"), node))); - } - - [TestMethod] - public void JUnitTestResultCapture_DuplicateSingletonProperty_Throws() - { - var bag = new PropertyBag( - PassedTestNodeStateProperty.CachedInstance, - new TimingProperty(new TimingInfo(DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, TimeSpan.Zero)), - new TimingProperty(new TimingInfo(DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, TimeSpan.Zero))); - TestNode node = new() { Uid = "a", DisplayName = "x", Properties = bag }; - - InvalidOperationException ex = Assert.ThrowsExactly(() => - JUnitTestResultCapture.TryCapture(new TestNodeUpdateMessage(new SessionUid("1"), node))); - - Assert.Contains(nameof(TimingProperty), ex.Message); - } - [TestMethod] [DataRow("passed", typeof(PassedTestNodeStateProperty))] [DataRow("skipped", typeof(SkippedTestNodeStateProperty))] diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/JUnitReportTestResultCaptureTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/JUnitReportTestResultCaptureTests.cs new file mode 100644 index 0000000000..e1b5d0a880 --- /dev/null +++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/JUnitReportTestResultCaptureTests.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.Testing.Extensions.JUnitReport; +using Microsoft.Testing.Platform.Extensions.Messages; +using Microsoft.Testing.Platform.TestHost; + +namespace Microsoft.Testing.Extensions.UnitTests; + +[TestClass] +public class JUnitReportTestResultCaptureTests +{ + [TestMethod] + public void TryCapture_DoesNotWalkTerminalProperties_ForNonTerminalStates() + { + var bag = new PropertyBag( + DiscoveredTestNodeStateProperty.CachedInstance, + new TimingProperty(new TimingInfo(DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, TimeSpan.Zero)), + new TimingProperty(new TimingInfo(DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, TimeSpan.Zero))); + TestNode node = new() { Uid = "a", DisplayName = "x", Properties = bag }; + + Assert.IsNull(TestResultCapture.TryCapture(new TestNodeUpdateMessage(new SessionUid("1"), node))); + } + + [TestMethod] + public void TryCapture_DuplicateSingletonProperty_Throws() + { + var bag = new PropertyBag( + PassedTestNodeStateProperty.CachedInstance, + new TimingProperty(new TimingInfo(DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, TimeSpan.Zero)), + new TimingProperty(new TimingInfo(DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, TimeSpan.Zero))); + TestNode node = new() { Uid = "a", DisplayName = "x", Properties = bag }; + + InvalidOperationException ex = Assert.ThrowsExactly(() => + TestResultCapture.TryCapture(new TestNodeUpdateMessage(new SessionUid("1"), node))); + + Assert.Contains(nameof(TimingProperty), ex.Message); + } +}