fix: re-enable RPC tests and modernize harness (#5540)#5632
Conversation
Restore the previously-disabled RPC test suite and fix the underlying failures. The MTP server was silently ignoring the client's test filter because the RunTestsRequest wire field was serialized as `testCases`, but Microsoft.Testing.Platform reads it from `tests`. This caused every `RunTests` call to execute the full catalogue instead of the selected nodes. Renamed the JSON property to match the protocol. Pipeline was also targeting `net8.0` while the csproj only targets `net10.0`, so the suite never ran. Aligned the testable framework list. While re-enabling, modernized the harness: extracted TestHostSession (IAsyncDisposable) and TestProjectBuilds (shared per-framework build cache), parameterized tests across net8.0/net10.0 via MethodDataSource, dropped dead abstractions (IProcessHandle/ProcessHandle, custom LogLevel, ConsoleRpcListener, RunRequest), and switched to Microsoft.Extensions.Logging.LogLevel with a string-enum converter.
Not up to standards ⛔🔴 Issues
|
| Category | Results |
|---|---|
| ErrorProne | 1 medium |
🟢 Metrics 9 complexity
Metric Results Complexity 9
TIP This summary will be updated as you push new changes. Give us feedback
There was a problem hiding this comment.
Code Review
This PR is a solid fix — the root cause ("testCases" → "tests" wire-format mismatch) is clear and correct, and the refactoring around it is well-motivated.
Core Fix ✅
RunTestsRequest.cs — The JSON property rename from testCases to tests is the crux of the fix. The protocol was silently ignoring the filter because the server read a field the client wasn't sending under that name. Correct.
RunRpcTestsModule.cs — Re-enabling net10.0 to match the project target is correct. No concerns here.
Architecture: TestHostSession ✅
Extracting session lifecycle into an IAsyncDisposable is the right abstraction. The private constructor + static factory (StartAsync) pattern is idiomatic for types that require async initialization. Good.
One issue in DisposeAsync: Client.Dispose() almost certainly disposes _tcpClient internally (since TestingPlatformClient still holds a reference to it), and then _tcpClient.Dispose() is called a second time. TcpClient tolerates double-dispose, but it is a code smell. Consider having TestingPlatformClient.Dispose() only dispose the JsonRpc/stream, and let TestHostSession own _tcpClient disposal exclusively. Similarly, _rpc.Dispose() is called after Client.Dispose() — if TestingPlatformClient disposes the JsonRpc it receives, that is another double-dispose.
TestProjectBuilds.EnsureBuiltAsync — Stale-failure risk
public static Task EnsureBuiltAsync(string framework, CancellationToken cancellationToken)
=> _builds.GetOrAdd(framework, tfm => BuildAsync(tfm, cancellationToken));If the first caller is cancelled (or the build fails transiently), the faulted/cancelled Task is permanently cached in _builds. Every subsequent caller for that TFM will immediately receive the same bad task instead of retrying. In a flaky CI environment this could permanently poison the test run. A safer pattern:
public static Task EnsureBuiltAsync(string framework, CancellationToken cancellationToken)
{
if (_builds.TryGetValue(framework, out var existing) && !existing.IsFaulted && !existing.IsCanceled)
return existing;
var fresh = BuildAsync(framework, cancellationToken);
_builds[framework] = fresh;
return fresh;
}There is also a subtle race: GetOrAdd is not atomic — two threads may both call BuildAsync for the same TFM simultaneously. For a test helper this is probably harmless (both builds succeed), but worth noting.
Frameworks() Return Type
public static IEnumerable<Func<string>> Frameworks()
{
yield return () => "net8.0";
yield return () => "net10.0";
}The test methods accept string framework, not Func<string>. If TUnit's MethodDataSource transparently invokes the Func<T> wrapper to materialize the argument, this works — but it is not obvious from reading the code why wrapping in Func<T> is needed here. If TUnit supports IEnumerable<string> directly from a MethodDataSource, prefer that for clarity. If the Func<string> wrapper is load-bearing (e.g., deferred evaluation), a comment explaining why would help future readers.
ExitAsync Silent Swallow
catch
{
// Best effort — connection may already be gone
}Swallowing all exceptions unconditionally can hide bugs during development. Since this is called in DisposeAsync, consider at least logging to Console.Error or Trace so it is visible in test output without being fatal.
TestNode.JsonExtensionData ✅
Using [JsonExtensionData] to round-trip unknown fields back to the server when constructing RunTestsRequest is exactly right. This avoids the common trap of stripping server-generated fields (traits, location info, etc.) that the server expects to see back on runTests.
Dead Code Removal ✅
Removing IProcessHandle/ProcessHandle (which had a NotImplementedException in StopAsync and no-ops everywhere else), ConsoleRpcListener, the duplicate LogLevel enum, and RunRequest are all correct.
Minor: Magic number
await Assert.That(discovered).Count().IsGreaterThanOrEqualTo(4000);This will fail if the test project's test count drops below 4000. Consider a named constant or a comment explaining where this threshold comes from.
Summary: The fix is correct and the refactoring is a clear improvement. The two issues worth addressing before merge are the stale-task caching in TestProjectBuilds (can permanently break a CI run after a transient failure) and the potential double-dispose in TestHostSession. Everything else is minor.
Updated [TUnit](https://github.com/thomhurst/TUnit) from 1.35.2 to 1.37.10. <details> <summary>Release notes</summary> _Sourced from [TUnit's releases](https://github.com/thomhurst/TUnit/releases)._ ## 1.37.10 <!-- Release notes generated using configuration in .github/release.yml at v1.37.10 --> ## What's Changed ### Other Changes * docs(test-filters): add migration callout for --filter → --treenode-filter by @johnkattenhorn in thomhurst/TUnit#5628 * fix: re-enable RPC tests and modernize harness (#5540) by @thomhurst in thomhurst/TUnit#5632 * fix(mocks): propagate [Obsolete] and null-forgiving raise dispatch (#5626) by @JohnVerheij in thomhurst/TUnit#5631 * ci: use setup-dotnet built-in NuGet cache by @thomhurst in thomhurst/TUnit#5635 * feat(playwright): propagate W3C trace context into browser contexts by @thomhurst in thomhurst/TUnit#5636 ### Dependencies * chore(deps): update tunit to 1.37.0 by @thomhurst in thomhurst/TUnit#5625 ## New Contributors * @johnkattenhorn made their first contribution in thomhurst/TUnit#5628 * @JohnVerheij made their first contribution in thomhurst/TUnit#5631 **Full Changelog**: thomhurst/TUnit@v1.37.0...v1.37.10 ## 1.37.0 <!-- Release notes generated using configuration in .github/release.yml at v1.37.0 --> ## What's Changed ### Other Changes * fix: stabilize flaky tests across analyzer, OTel, and engine suites by @thomhurst in thomhurst/TUnit#5609 * perf: engine hot-path allocation wins (#5528 B) by @thomhurst in thomhurst/TUnit#5610 * feat(analyzers): detect collection IsEqualTo reference equality (TUnitAssertions0016) by @thomhurst in thomhurst/TUnit#5615 * perf: consolidate test dedup + hook register guards (#5528 A) by @thomhurst in thomhurst/TUnit#5612 * perf: engine discovery/init path cleanup (#5528 C) by @thomhurst in thomhurst/TUnit#5611 * fix(assertions): render collection contents in IsEqualTo failure messages (#5613 B) by @thomhurst in thomhurst/TUnit#5619 * feat(analyzers): code-fix for TUnit0015 to insert CancellationToken (#5613 D) by @thomhurst in thomhurst/TUnit#5621 * fix(assertions): add Task reference forwarders on AsyncDelegateAssertion by @thomhurst in thomhurst/TUnit#5618 * test(asp-net): fix race in FactoryMethodOrderTests by @thomhurst in thomhurst/TUnit#5623 * feat(analyzers): code-fix for TUnit0049 to insert [MatrixDataSource] (#5613 C) by @thomhurst in thomhurst/TUnit#5620 * fix(pipeline): isolate AOT publish outputs to stop clobbering pack DLLs (#5622) by @thomhurst in thomhurst/TUnit#5624 ### Dependencies * chore(deps): update tunit to 1.36.0 by @thomhurst in thomhurst/TUnit#5608 * chore(deps): update modularpipelines to 3.2.8 by @thomhurst in thomhurst/TUnit#5614 **Full Changelog**: thomhurst/TUnit@v1.36.0...v1.37.0 ## 1.36.0 <!-- Release notes generated using configuration in .github/release.yml at v1.36.0 --> ## What's Changed ### Other Changes * fix: don't render test's own trace as Linked Trace in HTML report by @thomhurst in thomhurst/TUnit#5580 * fix(docs): benchmark index links 404 by @thomhurst in thomhurst/TUnit#5587 * docs: replace repeated benchmark link suffix with per-test descriptions by @thomhurst in thomhurst/TUnit#5588 * docs: clearer distributed tracing setup and troubleshooting by @thomhurst in thomhurst/TUnit#5597 * fix: auto-suppress ExecutionContext flow for hosted services (#5589) by @thomhurst in thomhurst/TUnit#5598 * feat: auto-align DistributedContextPropagator to W3C by @thomhurst in thomhurst/TUnit#5599 * feat: TUnit0064 analyzer + code fix for direct WebApplicationFactory inheritance by @thomhurst in thomhurst/TUnit#5601 * feat: auto-propagate test trace context through IHttpClientFactory by @thomhurst in thomhurst/TUnit#5603 * feat: TUnit.OpenTelemetry zero-config tracing package by @thomhurst in thomhurst/TUnit#5602 * fix: restore [Obsolete] members removed in v1.27 (#5539) by @thomhurst in thomhurst/TUnit#5605 * feat: generalize OTLP receiver for use outside TUnit.Aspire by @thomhurst in thomhurst/TUnit#5606 * feat: auto-configure OpenTelemetry in TestWebApplicationFactory SUT by @thomhurst in thomhurst/TUnit#5607 ### Dependencies * chore(deps): update tunit to 1.35.2 by @thomhurst in thomhurst/TUnit#5581 * chore(deps): update dependency typescript to ~6.0.3 by @thomhurst in thomhurst/TUnit#5582 * chore(deps): update dependency coverlet.collector to v10 by @thomhurst in thomhurst/TUnit#5600 **Full Changelog**: thomhurst/TUnit@v1.35.2...v1.36.0 Commits viewable in [compare view](thomhurst/TUnit@v1.35.2...v1.37.10). </details> [](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) --- <details> <summary>Dependabot commands and options</summary> <br /> You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show <dependency name> ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) </details> Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Summary
RunTestsRequestwas serializing the node filter astestCases, but Microsoft.Testing.Platform reads it fromtests— so the server silently ignored the filter and ran the full catalogue on everyRunTestscall. Renamed the JSON property to match the protocol wire format.RunRpcTestsModuletargetednet8.0whileTUnit.RpcTests.csprojonly targetsnet10.0, leaving the suite skipped. Aligned the testable framework list tonet10.0.TestHostSession(IAsyncDisposable) andTestProjectBuilds(per-framework shared build cache), parameterized the three scenarios acrossnet8.0/net10.0viaMethodDataSource, removed dead abstractions (IProcessHandle/ProcessHandle, customLogLevelenum,ConsoleRpcListener,RunRequest), switched toMicrosoft.Extensions.Logging.LogLevelwith a string-enum converter, and tightenedTestNodedeserialization viaJsonExtensionData.Closes #5540.
Test plan
Discovery_ReturnsFullTestCataloguepasses on net8.0 and net10.0RunTests_WithUidFilter_ExecutesOnlySelectedTestspasses on net8.0 and net10.0 — confirms server honors the filterRunTests_WithSkippedTest_ReportsSkippedStatepasses on net8.0 and net10.0dotnet build TUnit.RpcTestsclean (0 warnings, 0 errors)