Optimize TestMethodRunner: fix double enumeration, reduce allocations#7541
Optimize TestMethodRunner: fix double enumeration, reduce allocations#7541Evangelink wants to merge 2 commits intomainfrom
Conversation
Evangelink
commented
Mar 12, 2026
- Fix double enumeration in TryExecuteFoldedDataDrivenTestsAsync by materializing GetData() into an IReadOnlyList once instead of calling .Any() then re-enumerating
- Remove unnecessary List allocation in UpdateResultsWithParentInfo by mutating in place and returning void
- Cache result[result.Length - 1] into a local in ExecuteAsync catch block
- Skip redundant first-element self-comparison in GetAggregateOutcome
- Use Stopwatch.StartNew() in ExecuteTestFromDataSourceAttributeAsync
- Fix double enumeration in TryExecuteFoldedDataDrivenTestsAsync by materializing GetData() into an IReadOnlyList once instead of calling .Any() then re-enumerating - Remove unnecessary List allocation in UpdateResultsWithParentInfo by mutating in place and returning void - Cache result[result.Length - 1] into a local in ExecuteAsync catch block - Skip redundant first-element self-comparison in GetAggregateOutcome - Use Stopwatch.StartNew() in ExecuteTestFromDataSourceAttributeAsync
- Hoist loop-invariant 'testContext as TestContextImplementation' cast above the foreach loop in RunAssemblyCleanupAsync - Remove unnecessary nested scope in IsTestMethodRunnable - Use 'is null' pattern instead of '== null' - Simplify ignore message logic by inlining into the conditional block
There was a problem hiding this comment.
Pull request overview
This PR optimizes TestMethodRunner execution paths in the MSTest adapter by reducing redundant work (enumerations/allocations) during data-driven execution and result aggregation.
Changes:
- Materialize folded data-driven
GetData()results once to avoid double enumeration and enableCountchecks. - Remove an unnecessary list allocation by mutating results in place in
UpdateResultsWithParentInfo. - Minor micro-optimizations (cache last result in exception path, skip redundant first element in aggregate outcome, use
Stopwatch.StartNew()).
You can also share your feedback on Copilot code review. Take the survey.
| IReadOnlyList<object?[]> dataSource = testDataSource.GetData(_testMethodInfo.MethodInfo) is IReadOnlyList<object?[]> dataList | ||
| ? dataList | ||
| : testDataSource.GetData(_testMethodInfo.MethodInfo).ToList(); |
There was a problem hiding this comment.
testDataSource.GetData(...) is invoked twice here (once for the is IReadOnlyList check and again for .ToList()), which can re-run expensive logic and can change behavior if GetData is not idempotent. Store the GetData(...) result in a local once, then either cast to IReadOnlyList<object?[]> or materialize it.
| IReadOnlyList<object?[]> dataSource = testDataSource.GetData(_testMethodInfo.MethodInfo) is IReadOnlyList<object?[]> dataList | |
| ? dataList | |
| : testDataSource.GetData(_testMethodInfo.MethodInfo).ToList(); | |
| var data = testDataSource.GetData(_testMethodInfo.MethodInfo); | |
| IReadOnlyList<object?[]> dataSource = data is IReadOnlyList<object?[]> dataList | |
| ? dataList | |
| : data.ToList(); |
| /// <summary> | ||
| /// Updates given results with parent info if results are greater than 1. | ||
| /// Add parent results as first result in updated result. | ||
| /// </summary> |
There was a problem hiding this comment.
The XML doc summary for UpdateResultsWithParentInfo is now misleading: it says it only updates results when count > 1 and that it "Add parent results as first result", but the implementation always iterates all results and does not add/reorder anything. Please update the summary to match the current behavior (or adjust the implementation if the comment describes intended behavior).
See below for a potential fix:
/// Updates each given result with new execution and parent execution identifiers.
| } | ||
| catch (Exception ex) | ||
| { | ||
| if (result == null || result.Length == 0) |
There was a problem hiding this comment.
In case of exception, I would expect result to always be null here. Looks like we have some dead code here and we can simplify to just create a single TestResult right away, unless I'm missing something very obvious. Cleaning up in #7546
| // This code is to execute tests. To discover the tests code is in AssemblyEnumerator.ProcessTestDataSourceTests. | ||
| // Any change made here should be reflected in AssemblyEnumerator.ProcessTestDataSourceTests as well. | ||
| dataSource = testDataSource.GetData(_testMethodInfo.MethodInfo); | ||
| IReadOnlyList<object?[]> dataSource = testDataSource.GetData(_testMethodInfo.MethodInfo) is IReadOnlyList<object?[]> dataList |
There was a problem hiding this comment.
Aside from double calling GetData, I'm not really sure if this is more optimized or if it's worse.
There was a problem hiding this comment.
What I probably would do is call GetData normally, without materializing to a list, but still enumerate once by keeping a bool like dataSourceHasData = false before the loop, and set it to true inside the loop. After the loop, if it remained false, we know we didn't have data. That way, we didn't need to materialize to a list, and we didn't double enumerate.
| // Get aggregate outcome. | ||
| UnitTestOutcome aggregateOutcome = results[0].Outcome; | ||
| foreach (TestResult result in results) | ||
| for (int i = 1; i < results.Count; i++) |
There was a problem hiding this comment.
This should have roughly the same perf as before IMO, but I'm not opposed to the change.
| var testContextImpl = testContext as TestContextImplementation; | ||
| IEnumerable<TestAssemblyInfo> assemblyInfoCache = typeCache.AssemblyInfoListWithExecutableCleanupMethods; |
There was a problem hiding this comment.
assemblyInfoCache is always a single-element today AFAIK. This is not saving anything IMO, but I don't mind the change.
|
|
||
| if (shouldIgnoreClass || shouldIgnoreMethod) | ||
| { | ||
| string? ignoreMessage = StringEx.IsNullOrEmpty(ignoreMessageOnClass) ? ignoreMessageOnMethod : ignoreMessageOnClass; |
There was a problem hiding this comment.
There is a slight behavior change here.
If class is ignored with empty message, and method is not ignored.
- Previous behavior: we create ignore result with empty message.
- New behavior: we create ignore result with null message.
I have no idea if this will have any user-visible impact or not, but it's probably safer to keep the old behavior.
| string? ignoreMessage = StringEx.IsNullOrEmpty(ignoreMessageOnClass) ? ignoreMessageOnMethod : ignoreMessageOnClass; | |
| string? ignoreMessage = shouldIgnoreMethod && StringEx.IsNullOrEmpty(ignoreMessageOnClass) ? ignoreMessageOnMethod : ignoreMessageOnClass; |
| } | ||
|
|
||
| #pragma warning disable IDE0056 // Use index operator | ||
| TestResult lastResult = result[result.Length - 1]; |