From 01259f5576609d1e5d143c0dc68ba8993eb3087b Mon Sep 17 00:00:00 2001
From: Piotr Nawrot
Date: Wed, 1 Apr 2026 17:24:21 +0200
Subject: [PATCH 1/9] refactor(MTP): extract CaptureCoverageInOneGo and add
routing in CaptureCoverage
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../MicrosoftTestPlatformRunnerPool.cs | 32 +++++++++++++------
1 file changed, 22 insertions(+), 10 deletions(-)
diff --git a/src/Stryker.TestRunner.MicrosoftTestPlatform/MicrosoftTestPlatformRunnerPool.cs b/src/Stryker.TestRunner.MicrosoftTestPlatform/MicrosoftTestPlatformRunnerPool.cs
index 12c44f48ea..18c29e60d5 100644
--- a/src/Stryker.TestRunner.MicrosoftTestPlatform/MicrosoftTestPlatformRunnerPool.cs
+++ b/src/Stryker.TestRunner.MicrosoftTestPlatform/MicrosoftTestPlatformRunnerPool.cs
@@ -97,9 +97,21 @@ public async Task InitialTestAsync(IProjectAndTests project)
public IEnumerable CaptureCoverage(IProjectAndTests project)
{
- _logger.LogInformation("Starting coverage capture for MTP runner");
+ if (_options.OptimizationMode.HasFlag(OptimizationModes.CoverageBasedTest))
+ {
+ var confidence = _options.OptimizationMode.HasFlag(OptimizationModes.CaptureCoveragePerTest)
+ ? CoverageConfidence.Exact
+ : CoverageConfidence.Normal;
+ return CaptureCoverageTestByTest(project, confidence);
+ }
+
+ return CaptureCoverageInOneGo(project);
+ }
+
+ private IEnumerable CaptureCoverageInOneGo(IProjectAndTests project)
+ {
+ _logger.LogInformation("Starting aggregate coverage capture for MTP runner");
- // Enable coverage mode on all runners
foreach (var runner in _availableRunners)
{
runner.SetCoverageMode(true);
@@ -107,7 +119,6 @@ public IEnumerable CaptureCoverage(IProjectAndTests project)
try
{
- // Run all tests with coverage tracking enabled
var testResult = RunThisAsync(runner => runner.InitialTestAsync(project)).GetAwaiter().GetResult();
if (testResult.FailingTests.IsEveryTest)
@@ -115,10 +126,8 @@ public IEnumerable CaptureCoverage(IProjectAndTests project)
_logger.LogWarning("Coverage test run failed: {Message}", testResult.ResultMessage);
}
- // Reset test processes to trigger coverage file flush (process exit writes coverage)
ResetTestProcesses();
- // Aggregate coverage data from all runners
var allCoveredMutants = new HashSet();
var allStaticMutants = new HashSet();
@@ -135,12 +144,9 @@ public IEnumerable CaptureCoverage(IProjectAndTests project)
}
}
- _logger.LogInformation("Coverage capture complete: {CoveredCount} mutations covered, {StaticCount} static mutations",
+ _logger.LogInformation("Aggregate coverage capture complete: {CoveredCount} mutations covered, {StaticCount} static mutations",
allCoveredMutants.Count, allStaticMutants.Count);
- // For cumulative coverage, we return a single coverage result that applies to all tests
- // Each test is assumed to cover all the mutations that were covered during the full test run
- // Static mutants are marked as such for proper handling during mutation testing
return _testDescriptions.Values.Select(testDescription =>
CoverageRunResult.Create(
testDescription.Id,
@@ -151,7 +157,6 @@ public IEnumerable CaptureCoverage(IProjectAndTests project)
}
finally
{
- // Disable coverage mode on all runners for subsequent mutation testing
foreach (var runner in _availableRunners)
{
runner.SetCoverageMode(false);
@@ -159,6 +164,13 @@ public IEnumerable CaptureCoverage(IProjectAndTests project)
}
}
+ // Stub: real implementation added in Task 6
+ private IEnumerable CaptureCoverageTestByTest(
+ IProjectAndTests project, CoverageConfidence confidence)
+ {
+ throw new NotImplementedException("CaptureCoverageTestByTest will be implemented in Task 6");
+ }
+
public async Task TestMultipleMutantsAsync(
IProjectAndTests project,
ITimeoutValueCalculator? timeoutCalc,
From df69e009be1e2e75a591e66969325b5dc4c565fa Mon Sep 17 00:00:00 2001
From: Piotr Nawrot
Date: Wed, 1 Apr 2026 17:24:41 +0200
Subject: [PATCH 2/9] test(MTP): extend TestableRunner to support per-test
coverage handler
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../TestableRunner.cs | 38 ++++++++++++++++---
1 file changed, 33 insertions(+), 5 deletions(-)
diff --git a/src/Stryker.TestRunner.MicrosoftTestPlatform.UnitTest/TestableRunner.cs b/src/Stryker.TestRunner.MicrosoftTestPlatform.UnitTest/TestableRunner.cs
index 5f425decd4..032f566fcf 100644
--- a/src/Stryker.TestRunner.MicrosoftTestPlatform.UnitTest/TestableRunner.cs
+++ b/src/Stryker.TestRunner.MicrosoftTestPlatform.UnitTest/TestableRunner.cs
@@ -1,5 +1,7 @@
using Microsoft.Extensions.Logging.Abstractions;
+using Stryker.Abstractions.Testing;
using Stryker.TestRunner.MicrosoftTestPlatform.Models;
+using Stryker.TestRunner.Results;
using Stryker.TestRunner.Tests;
namespace Stryker.TestRunner.MicrosoftTestPlatform.UnitTest;
@@ -7,17 +9,43 @@ namespace Stryker.TestRunner.MicrosoftTestPlatform.UnitTest;
internal class TestableRunner : SingleMicrosoftTestPlatformRunner
{
private readonly Action _onDispose;
+ private readonly Func>? _coverageHandler;
- public TestableRunner(int id, Action onDispose)
- : base(id, new Dictionary>(),
- new Dictionary(),
- new TestSet(),
- new object(),
+ public TestableRunner(int id, Action onDispose)
+ : base(id, new Dictionary>(),
+ new Dictionary(),
+ new TestSet(),
+ new object(),
NullLogger.Instance)
{
_onDispose = onDispose;
}
+ public TestableRunner(
+ int id,
+ Dictionary> testsByAssembly,
+ Dictionary testDescriptions,
+ TestSet testSet,
+ object discoveryLock,
+ Action onDispose,
+ Func>? coverageHandler = null)
+ : base(id, testsByAssembly, testDescriptions, testSet, discoveryLock, NullLogger.Instance)
+ {
+ _onDispose = onDispose;
+ _coverageHandler = coverageHandler;
+ }
+
+ internal override async Task RunSingleTestForCoverageAsync(
+ string assembly, TestNode test, string testId, CoverageConfidence confidence)
+ {
+ if (_coverageHandler is not null)
+ {
+ return await _coverageHandler(assembly, test, testId, confidence).ConfigureAwait(false);
+ }
+
+ return CoverageRunResult.Create(testId, confidence, Array.Empty(), Array.Empty(), Array.Empty());
+ }
+
public override void Dispose(bool disposing)
{
_onDispose?.Invoke();
From f55bb61e641e4ccef3b1dd0a3d949fda786d6a39 Mon Sep 17 00:00:00 2001
From: Piotr Nawrot
Date: Wed, 1 Apr 2026 17:24:55 +0200
Subject: [PATCH 3/9] feat(MTP): add StopAndRemoveServerAsync helper for
per-test coverage
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
...icrosoftTestPlatformRunnerCoverageTests.cs | 83 +++++++++++++++++++
.../SingleMicrosoftTestPlatformRunner.cs | 65 +++++++++++++++
2 files changed, 148 insertions(+)
diff --git a/src/Stryker.TestRunner.MicrosoftTestPlatform.UnitTest/SingleMicrosoftTestPlatformRunnerCoverageTests.cs b/src/Stryker.TestRunner.MicrosoftTestPlatform.UnitTest/SingleMicrosoftTestPlatformRunnerCoverageTests.cs
index cc9e11ff5f..4a9343173d 100644
--- a/src/Stryker.TestRunner.MicrosoftTestPlatform.UnitTest/SingleMicrosoftTestPlatformRunnerCoverageTests.cs
+++ b/src/Stryker.TestRunner.MicrosoftTestPlatform.UnitTest/SingleMicrosoftTestPlatformRunnerCoverageTests.cs
@@ -485,4 +485,87 @@ public async Task ResetServerAsync_ShouldDisposeAndClearAllServers()
var serversAfter = (Dictionary)serversField.GetValue(runner)!;
serversAfter.ShouldBeEmpty("all servers should be disposed and removed after reset");
}
+
+ [TestMethod]
+ public async Task StopAndRemoveServerAsync_ShouldRemoveServerFromDictionary()
+ {
+ var runnerId = 610;
+ using var runner = new SingleMicrosoftTestPlatformRunner(
+ runnerId,
+ _testsByAssembly,
+ _testDescriptions,
+ _testSet,
+ _discoveryLock,
+ NullLogger.Instance);
+
+ var testAssembly = typeof(SingleMicrosoftTestPlatformRunnerCoverageTests).Assembly.Location;
+ await runner.DiscoverTestsAsync(testAssembly);
+
+ var serversField = typeof(SingleMicrosoftTestPlatformRunner)
+ .GetField("_assemblyServers", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!;
+
+ var serversBefore = (Dictionary)serversField.GetValue(runner)!;
+ serversBefore.ShouldNotBeEmpty("servers should exist after discovery");
+
+ var method = typeof(SingleMicrosoftTestPlatformRunner)
+ .GetMethod("StopAndRemoveServerAsync", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!;
+ await (Task)method.Invoke(runner, new object[] { testAssembly })!;
+
+ var serversAfter = (Dictionary)serversField.GetValue(runner)!;
+ serversAfter.ContainsKey(testAssembly).ShouldBeFalse("server should be removed after stop");
+ }
+
+ [TestMethod]
+ public async Task RunSingleTestForCoverageAsync_ShouldReturnCoverageFromFile()
+ {
+ var runnerId = 620;
+ var coverageFilePath = Path.Combine(Path.GetTempPath(), $"stryker-coverage-{runnerId}.txt");
+
+ try
+ {
+ using var runner = new SingleMicrosoftTestPlatformRunner(
+ runnerId,
+ _testsByAssembly,
+ _testDescriptions,
+ _testSet,
+ _discoveryLock,
+ NullLogger.Instance);
+
+ File.WriteAllText(coverageFilePath, "1,2,3;10");
+
+ var result = runner.ReadCoverageData();
+
+ result.CoveredMutants.Count.ShouldBe(3);
+ result.CoveredMutants.ShouldContain(1);
+ result.CoveredMutants.ShouldContain(2);
+ result.CoveredMutants.ShouldContain(3);
+ result.StaticMutants.Count.ShouldBe(1);
+ result.StaticMutants.ShouldContain(10);
+ }
+ finally
+ {
+ if (File.Exists(coverageFilePath))
+ {
+ File.Delete(coverageFilePath);
+ }
+ }
+ }
+
+ [TestMethod]
+ public void RunSingleTestForCoverageAsync_ShouldReturnDubious_WhenNoCoverageFile()
+ {
+ var runnerId = 621;
+ using var runner = new SingleMicrosoftTestPlatformRunner(
+ runnerId,
+ _testsByAssembly,
+ _testDescriptions,
+ _testSet,
+ _discoveryLock,
+ NullLogger.Instance);
+
+ var result = runner.ReadCoverageData();
+
+ result.CoveredMutants.ShouldBeEmpty();
+ result.StaticMutants.ShouldBeEmpty();
+ }
}
diff --git a/src/Stryker.TestRunner.MicrosoftTestPlatform/SingleMicrosoftTestPlatformRunner.cs b/src/Stryker.TestRunner.MicrosoftTestPlatform/SingleMicrosoftTestPlatformRunner.cs
index 61edf42003..94462f8d39 100644
--- a/src/Stryker.TestRunner.MicrosoftTestPlatform/SingleMicrosoftTestPlatformRunner.cs
+++ b/src/Stryker.TestRunner.MicrosoftTestPlatform/SingleMicrosoftTestPlatformRunner.cs
@@ -106,6 +106,71 @@ public async Task ResetServerAsync()
await Task.CompletedTask;
}
+ ///
+ /// Stops and removes the server for a specific assembly. This triggers ProcessExit
+ /// in the test process, causing MutantControl.FlushCoverageToFile() to be called.
+ /// The server is removed from the cache so a fresh one is created on next use.
+ ///
+ internal async Task StopAndRemoveServerAsync(string assembly)
+ {
+ AssemblyTestServer? server;
+ lock (_serverLock)
+ {
+ _assemblyServers.TryGetValue(assembly, out server);
+ _assemblyServers.Remove(assembly);
+ }
+
+ if (server is not null)
+ {
+ await server.StopAsync().ConfigureAwait(false);
+ }
+ }
+
+ ///
+ /// Runs a single test in isolation to capture its per-test coverage data.
+ /// The flow is: start server → run one test → stop server (triggers coverage flush) → read coverage file.
+ /// This is used by the pool's CaptureCoverageTestByTest method.
+ ///
+ internal virtual async Task RunSingleTestForCoverageAsync(
+ string assembly, TestNode test, string testId, CoverageConfidence confidence)
+ {
+ try
+ {
+ DeleteCoverageFile();
+
+ var server = await GetOrCreateServerAsync(assembly).ConfigureAwait(false);
+ await server.RunTestsAsync(new[] { test }).ConfigureAwait(false);
+ await StopAndRemoveServerAsync(assembly).ConfigureAwait(false);
+
+ var (coveredMutants, staticMutants) = ReadCoverageData();
+
+ _logger.LogDebug(
+ "{RunnerId}: Test {TestId} covers {CoveredCount} mutants ({StaticCount} static)",
+ RunnerId, testId, coveredMutants.Count, staticMutants.Count);
+
+ DeleteCoverageFile();
+
+ return CoverageRunResult.Create(
+ testId,
+ confidence,
+ coveredMutants,
+ staticMutants,
+ Array.Empty());
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex,
+ "{RunnerId}: Failed to capture coverage for test {TestId}", RunnerId, testId);
+
+ return CoverageRunResult.Create(
+ testId,
+ CoverageConfidence.Dubious,
+ Array.Empty(),
+ Array.Empty(),
+ Array.Empty());
+ }
+ }
+
private void WriteMutantIdToFile(int mutantId)
{
try
From d275345513d884b9386252d0c33491bcee49fe2c Mon Sep 17 00:00:00 2001
From: Piotr Nawrot
Date: Wed, 1 Apr 2026 17:34:42 +0200
Subject: [PATCH 4/9] feat(MTP): implement per-test coverage capture via
isolated test execution
MTP runner now captures per-test coverage by running each test in an isolated
process. When coverage-analysis is set to perTest or perTestInIsolation,
each test gets its own MTP server process. The server is stopped after each
test, triggering MutantControl.FlushCoverageToFile() via ProcessExit, and
the resulting coverage file is read to build per-test coverage results.
This enables Stryker to determine which tests cover which mutants for MTP-based
frameworks (xUnit v3, TUnit, MSTest with MTP, NUnit with MTP), unlocking
coverage-based test optimization that was previously only available with VsTest.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../MicrosoftTestPlatformRunnerPoolTests.cs | 276 ++++++++++++++++++
.../MicrosoftTestPlatformRunnerPool.cs | 54 +++-
2 files changed, 328 insertions(+), 2 deletions(-)
diff --git a/src/Stryker.TestRunner.MicrosoftTestPlatform.UnitTest/MicrosoftTestPlatformRunnerPoolTests.cs b/src/Stryker.TestRunner.MicrosoftTestPlatform.UnitTest/MicrosoftTestPlatformRunnerPoolTests.cs
index 7789a7e084..41f1c2fd24 100644
--- a/src/Stryker.TestRunner.MicrosoftTestPlatform.UnitTest/MicrosoftTestPlatformRunnerPoolTests.cs
+++ b/src/Stryker.TestRunner.MicrosoftTestPlatform.UnitTest/MicrosoftTestPlatformRunnerPoolTests.cs
@@ -5,7 +5,9 @@
using Shouldly;
using Stryker.Abstractions;
using Stryker.Abstractions.Options;
+using Stryker.Abstractions.Testing;
using Stryker.TestRunner.MicrosoftTestPlatform.Models;
+using Stryker.TestRunner.Results;
using Stryker.TestRunner.Tests;
namespace Stryker.TestRunner.MicrosoftTestPlatform.UnitTest;
@@ -314,6 +316,280 @@ public void Constructor_ShouldUseDefaultLogger_WhenLoggerIsNull()
// Assert
pool.ShouldNotBeNull();
}
+
+ [TestMethod]
+ public void CaptureCoverage_ShouldUsePerTestCapture_WhenCoverageBasedTestEnabled()
+ {
+ // Arrange
+ var options = new Mock();
+ options.Setup(x => x.Concurrency).Returns(1);
+ options.Setup(x => x.OptimizationMode).Returns(OptimizationModes.CoverageBasedTest);
+
+ var testsByAssembly = new Dictionary>();
+ var testDescriptions = new Dictionary();
+ var testSet = new TestSet();
+
+ var testNode1 = new TestNode("test-1", "Test1", "test", "discovered");
+ var testNode2 = new TestNode("test-2", "Test2", "test", "discovered");
+ testsByAssembly["assembly.dll"] = new List { testNode1, testNode2 };
+
+ var desc1 = new MtpTestDescription(testNode1);
+ var desc2 = new MtpTestDescription(testNode2);
+ testDescriptions["test-1"] = desc1;
+ testDescriptions["test-2"] = desc2;
+ testSet.RegisterTest(desc1.Description);
+ testSet.RegisterTest(desc2.Description);
+
+ var capturedTests = new System.Collections.Concurrent.ConcurrentBag();
+
+ var runnerFactory = new Mock();
+ runnerFactory.Setup(x => x.CreateRunner(
+ It.IsAny(),
+ It.IsAny>>(),
+ It.IsAny>(),
+ It.IsAny(),
+ It.IsAny