From 8876c1323a5055b51f6cd0a8dbab143d75e05272 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Tue, 12 May 2026 00:03:56 +0100 Subject: [PATCH 1/3] feat(mocks): add Mock.HttpClientFactory() helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds MockHttpClientFactory : IHttpClientFactory backed by one or more MockHttpHandler instances. Each CreateClient call returns a fresh non-disposing HttpClient sharing a handler, so captured requests survive across `using var client = factory.CreateClient()` blocks. Supports named clients via WithHandler/HandlerFor for typed-client DI scenarios; unregistered names fall back to the default handler. Resolves the UX pain raised in #5885 — no more `Returns(() => new HttpClient(_handler, disposeHandler: false))` boilerplate or ObjectDisposedException from shared mock client reuse. --- Directory.Packages.props | 1 + .../MockHttpClientFactoryTests.cs | 118 ++++++++++++++++++ TUnit.Mocks.Http/MockExtensions.cs | 12 ++ TUnit.Mocks.Http/MockHttpClientFactory.cs | 46 +++++++ TUnit.Mocks.Http/TUnit.Mocks.Http.csproj | 1 + docs/docs/writing-tests/mocking/http.md | 27 ++++ 6 files changed, 205 insertions(+) create mode 100644 TUnit.Mocks.Http.Tests/MockHttpClientFactoryTests.cs create mode 100644 TUnit.Mocks.Http/MockHttpClientFactory.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index a314f231cb..333f826166 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -38,6 +38,7 @@ + diff --git a/TUnit.Mocks.Http.Tests/MockHttpClientFactoryTests.cs b/TUnit.Mocks.Http.Tests/MockHttpClientFactoryTests.cs new file mode 100644 index 0000000000..8c52b9e6c7 --- /dev/null +++ b/TUnit.Mocks.Http.Tests/MockHttpClientFactoryTests.cs @@ -0,0 +1,118 @@ +using System.Net; +using TUnit.Mocks; +using TUnit.Mocks.Http; + +namespace TUnit.Mocks.Http.Tests; + +public class MockHttpClientFactoryTests +{ + [Test] + public async Task CreateClient_ReturnsConfiguredResponseFromDefaultHandler() + { + var factory = Mock.HttpClientFactory(); + factory.Handler.OnGet("/api/users").RespondWithJson("""[{"id":1}]"""); + + using var client = factory.CreateClient("any"); + client.BaseAddress = new Uri("http://localhost"); + + var response = await client.GetAsync("/api/users"); + var body = await response.Content.ReadAsStringAsync(); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + await Assert.That(body).Contains("id"); + } + + [Test] + public async Task CreateClient_SurvivesUsingBlockDisposal() + { + var factory = Mock.HttpClientFactory(); + factory.Handler.OnAnyRequest().Respond(HttpStatusCode.OK); + + for (var i = 0; i < 3; i++) + { + using var client = factory.CreateClient("default"); + client.BaseAddress = new Uri("http://localhost"); + var response = await client.GetAsync("/ping"); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + } + + await Assert.That(factory.Handler.Requests).Count().IsEqualTo(3); + } + + [Test] + public async Task CreateClient_ReturnsFreshInstanceEachCall() + { + var factory = Mock.HttpClientFactory(); + + var a = factory.CreateClient("x"); + var b = factory.CreateClient("x"); + + await Assert.That(a).IsNotSameReferenceAs(b); + } + + [Test] + public async Task WithHandler_UsesNamedHandlerForMatchingName() + { + var usersHandler = Mock.HttpHandler(); + usersHandler.OnGet("/").RespondWithJson("""{"who":"users"}"""); + + var ordersHandler = Mock.HttpHandler(); + ordersHandler.OnGet("/").RespondWithJson("""{"who":"orders"}"""); + + var factory = Mock.HttpClientFactory() + .WithHandler("users", usersHandler) + .WithHandler("orders", ordersHandler); + + using var usersClient = factory.CreateClient("users"); + usersClient.BaseAddress = new Uri("http://localhost"); + var usersBody = await (await usersClient.GetAsync("/")).Content.ReadAsStringAsync(); + + using var ordersClient = factory.CreateClient("orders"); + ordersClient.BaseAddress = new Uri("http://localhost"); + var ordersBody = await (await ordersClient.GetAsync("/")).Content.ReadAsStringAsync(); + + await Assert.That(usersBody).Contains("users"); + await Assert.That(ordersBody).Contains("orders"); + await Assert.That(usersHandler.Requests).Count().IsEqualTo(1); + await Assert.That(ordersHandler.Requests).Count().IsEqualTo(1); + } + + [Test] + public async Task HandlerFor_FallsBackToDefaultWhenNameNotRegistered() + { + var factory = Mock.HttpClientFactory(); + + var handler = factory.HandlerFor("unregistered"); + + await Assert.That(handler).IsSameReferenceAs(factory.Handler); + } + + [Test] + public async Task Verify_TracksRequestsAcrossMultipleClientLifetimes() + { + var factory = Mock.HttpClientFactory(); + factory.Handler.OnGet("/api/data").Respond(HttpStatusCode.OK); + + using (var c1 = factory.CreateClient("default")) + { + c1.BaseAddress = new Uri("http://localhost"); + await c1.GetAsync("/api/data"); + } + using (var c2 = factory.CreateClient("default")) + { + c2.BaseAddress = new Uri("http://localhost"); + await c2.GetAsync("/api/data"); + } + + factory.Handler.Verify(r => r.Method(HttpMethod.Get).Path("/api/data"), Times.Exactly(2)); + } + + [Test] + public async Task Constructor_UsesSuppliedDefaultHandler() + { + var handler = Mock.HttpHandler(); + var factory = Mock.HttpClientFactory(handler); + + await Assert.That(factory.Handler).IsSameReferenceAs(handler); + } +} diff --git a/TUnit.Mocks.Http/MockExtensions.cs b/TUnit.Mocks.Http/MockExtensions.cs index 3e85b77fb7..ad3cf437c4 100644 --- a/TUnit.Mocks.Http/MockExtensions.cs +++ b/TUnit.Mocks.Http/MockExtensions.cs @@ -24,5 +24,17 @@ public static class MockExtensions /// Use .Handler on the returned client to configure setups and verify calls. /// public static Http.MockHttpClient HttpClient(string baseAddress) => new(baseAddress); + + /// + /// Creates a mock backed by a fresh . + /// Each CreateClient call returns a new sharing the + /// same handler, with handler disposal disabled — safe to use in using blocks. + /// + public static Http.MockHttpClientFactory HttpClientFactory() => new(); + + /// + /// Creates a mock backed by the supplied default handler. + /// + public static Http.MockHttpClientFactory HttpClientFactory(Http.MockHttpHandler handler) => new(handler); } } diff --git a/TUnit.Mocks.Http/MockHttpClientFactory.cs b/TUnit.Mocks.Http/MockHttpClientFactory.cs new file mode 100644 index 0000000000..480828e4df --- /dev/null +++ b/TUnit.Mocks.Http/MockHttpClientFactory.cs @@ -0,0 +1,46 @@ +namespace TUnit.Mocks.Http; + +/// +/// A mock backed by one or more s. +/// Each call to returns a fresh that +/// does not dispose the underlying handler, so captured requests survive across using blocks. +/// +public sealed class MockHttpClientFactory : IHttpClientFactory +{ + private readonly Dictionary _named = new(StringComparer.Ordinal); + + /// The default handler used for clients with no name-specific handler configured. + public MockHttpHandler Handler { get; } + + /// Creates a factory with a fresh default handler. + public MockHttpClientFactory() : this(new MockHttpHandler()) + { + } + + /// Creates a factory using the supplied default handler. + public MockHttpClientFactory(MockHttpHandler handler) + { + Handler = handler; + } + + /// + /// Register a dedicated handler for a named client. Subsequent + /// calls with this name will use the supplied handler. + /// + public MockHttpClientFactory WithHandler(string name, MockHttpHandler handler) + { + _named[name] = handler; + return this; + } + + /// + /// Gets the handler associated with the named client, falling back to + /// if no name-specific handler has been registered. + /// + public MockHttpHandler HandlerFor(string name) + => _named.TryGetValue(name, out var h) ? h : Handler; + + /// + public HttpClient CreateClient(string name) + => new(HandlerFor(name), disposeHandler: false); +} diff --git a/TUnit.Mocks.Http/TUnit.Mocks.Http.csproj b/TUnit.Mocks.Http/TUnit.Mocks.Http.csproj index acc90930b1..2bd38e5589 100644 --- a/TUnit.Mocks.Http/TUnit.Mocks.Http.csproj +++ b/TUnit.Mocks.Http/TUnit.Mocks.Http.csproj @@ -9,6 +9,7 @@ + diff --git a/docs/docs/writing-tests/mocking/http.md b/docs/docs/writing-tests/mocking/http.md index 98aafe00ae..9a7a9432bd 100644 --- a/docs/docs/writing-tests/mocking/http.md +++ b/docs/docs/writing-tests/mocking/http.md @@ -237,6 +237,33 @@ Each `CapturedRequest` provides: | `Matched` | Whether a setup matched this request | | `Timestamp` | When the request was captured | +## Mocking `IHttpClientFactory` + +When the system under test consumes `IHttpClientFactory` and uses `using var client = factory.CreateClient()`, returning a single shared `HttpClient` will fail on the second call with `ObjectDisposedException`. `Mock.HttpClientFactory()` solves this by returning a fresh non-disposing `HttpClient` per call, all sharing one `MockHttpHandler` so captured requests survive across `using` blocks. + +```csharp +var factory = Mock.HttpClientFactory(); +factory.Handler.OnGet("/api/users").RespondWithJson("""[{"id":1}]"""); + +var sut = new Sut(factory); +await sut.DoWork(); // SUT may call CreateClient() any number of times + +factory.Handler.Verify(r => r.Method(HttpMethod.Get).Path("/api/users"), Times.Once); +``` + +### Named clients + +For typed/named clients registered via `services.AddHttpClient("users")`, assign a dedicated handler per name. Unregistered names fall back to `factory.Handler`. + +```csharp +var factory = Mock.HttpClientFactory() + .WithHandler("users", Mock.HttpHandler()) + .WithHandler("orders", Mock.HttpHandler()); + +factory.HandlerFor("users").OnGet("/").RespondWithJson("""[]"""); +factory.HandlerFor("orders").OnPost("/").Respond(HttpStatusCode.Created); +``` + ## Reset ```csharp From 62173e5f9bf36cbbad07bdaf19cc7c0f80b76402 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Tue, 12 May 2026 00:11:28 +0100 Subject: [PATCH 2/3] refactor(mocks): apply simplify review on MockHttpClientFactory - Switch named-handler dictionary to OrdinalIgnoreCase to match IHttpClientFactory semantics (CreateClient("API") and "api" resolve the same handler). - Add WithBaseAddress(string) and WithBaseAddress(name, string) so factory consumers (and tests) don't have to set BaseAddress on each produced HttpClient by hand. - Trim noisy XML docs that narrated WHAT; keep WHY on the class summary. - Drop low-signal constructor-assignment test; add a name-case-insensitive test that proves the comparer change. --- .../MockHttpClientFactoryTests.cs | 50 ++++++++----------- TUnit.Mocks.Http/MockExtensions.cs | 9 ++-- TUnit.Mocks.Http/MockHttpClientFactory.cs | 47 +++++++++++------ docs/docs/writing-tests/mocking/http.md | 10 ++-- 4 files changed, 62 insertions(+), 54 deletions(-) diff --git a/TUnit.Mocks.Http.Tests/MockHttpClientFactoryTests.cs b/TUnit.Mocks.Http.Tests/MockHttpClientFactoryTests.cs index 8c52b9e6c7..8757a8b37a 100644 --- a/TUnit.Mocks.Http.Tests/MockHttpClientFactoryTests.cs +++ b/TUnit.Mocks.Http.Tests/MockHttpClientFactoryTests.cs @@ -1,20 +1,19 @@ using System.Net; using TUnit.Mocks; -using TUnit.Mocks.Http; namespace TUnit.Mocks.Http.Tests; public class MockHttpClientFactoryTests { + private const string ClientName = "test-client"; + [Test] public async Task CreateClient_ReturnsConfiguredResponseFromDefaultHandler() { - var factory = Mock.HttpClientFactory(); + var factory = Mock.HttpClientFactory().WithBaseAddress("http://localhost"); factory.Handler.OnGet("/api/users").RespondWithJson("""[{"id":1}]"""); - using var client = factory.CreateClient("any"); - client.BaseAddress = new Uri("http://localhost"); - + using var client = factory.CreateClient(ClientName); var response = await client.GetAsync("/api/users"); var body = await response.Content.ReadAsStringAsync(); @@ -25,13 +24,12 @@ public async Task CreateClient_ReturnsConfiguredResponseFromDefaultHandler() [Test] public async Task CreateClient_SurvivesUsingBlockDisposal() { - var factory = Mock.HttpClientFactory(); + var factory = Mock.HttpClientFactory().WithBaseAddress("http://localhost"); factory.Handler.OnAnyRequest().Respond(HttpStatusCode.OK); for (var i = 0; i < 3; i++) { - using var client = factory.CreateClient("default"); - client.BaseAddress = new Uri("http://localhost"); + using var client = factory.CreateClient(ClientName); var response = await client.GetAsync("/ping"); await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); } @@ -44,8 +42,8 @@ public async Task CreateClient_ReturnsFreshInstanceEachCall() { var factory = Mock.HttpClientFactory(); - var a = factory.CreateClient("x"); - var b = factory.CreateClient("x"); + var a = factory.CreateClient(ClientName); + var b = factory.CreateClient(ClientName); await Assert.That(a).IsNotSameReferenceAs(b); } @@ -60,15 +58,14 @@ public async Task WithHandler_UsesNamedHandlerForMatchingName() ordersHandler.OnGet("/").RespondWithJson("""{"who":"orders"}"""); var factory = Mock.HttpClientFactory() + .WithBaseAddress("http://localhost") .WithHandler("users", usersHandler) .WithHandler("orders", ordersHandler); using var usersClient = factory.CreateClient("users"); - usersClient.BaseAddress = new Uri("http://localhost"); var usersBody = await (await usersClient.GetAsync("/")).Content.ReadAsStringAsync(); using var ordersClient = factory.CreateClient("orders"); - ordersClient.BaseAddress = new Uri("http://localhost"); var ordersBody = await (await ordersClient.GetAsync("/")).Content.ReadAsStringAsync(); await Assert.That(usersBody).Contains("users"); @@ -82,37 +79,34 @@ public async Task HandlerFor_FallsBackToDefaultWhenNameNotRegistered() { var factory = Mock.HttpClientFactory(); - var handler = factory.HandlerFor("unregistered"); + await Assert.That(factory.HandlerFor("unregistered")).IsSameReferenceAs(factory.Handler); + } + + [Test] + public async Task CreateClient_NameIsCaseInsensitive() + { + var namedHandler = Mock.HttpHandler(); + var factory = Mock.HttpClientFactory().WithHandler("Users", namedHandler); - await Assert.That(handler).IsSameReferenceAs(factory.Handler); + await Assert.That(factory.HandlerFor("USERS")).IsSameReferenceAs(namedHandler); + await Assert.That(factory.HandlerFor("users")).IsSameReferenceAs(namedHandler); } [Test] public async Task Verify_TracksRequestsAcrossMultipleClientLifetimes() { - var factory = Mock.HttpClientFactory(); + var factory = Mock.HttpClientFactory().WithBaseAddress("http://localhost"); factory.Handler.OnGet("/api/data").Respond(HttpStatusCode.OK); - using (var c1 = factory.CreateClient("default")) + using (var c1 = factory.CreateClient(ClientName)) { - c1.BaseAddress = new Uri("http://localhost"); await c1.GetAsync("/api/data"); } - using (var c2 = factory.CreateClient("default")) + using (var c2 = factory.CreateClient(ClientName)) { - c2.BaseAddress = new Uri("http://localhost"); await c2.GetAsync("/api/data"); } factory.Handler.Verify(r => r.Method(HttpMethod.Get).Path("/api/data"), Times.Exactly(2)); } - - [Test] - public async Task Constructor_UsesSuppliedDefaultHandler() - { - var handler = Mock.HttpHandler(); - var factory = Mock.HttpClientFactory(handler); - - await Assert.That(factory.Handler).IsSameReferenceAs(handler); - } } diff --git a/TUnit.Mocks.Http/MockExtensions.cs b/TUnit.Mocks.Http/MockExtensions.cs index ad3cf437c4..5d3e37e828 100644 --- a/TUnit.Mocks.Http/MockExtensions.cs +++ b/TUnit.Mocks.Http/MockExtensions.cs @@ -26,15 +26,12 @@ public static class MockExtensions public static Http.MockHttpClient HttpClient(string baseAddress) => new(baseAddress); /// - /// Creates a mock backed by a fresh . - /// Each CreateClient call returns a new sharing the - /// same handler, with handler disposal disabled — safe to use in using blocks. + /// Creates a mock producing non-disposing clients + /// that share a , so requests survive using blocks. /// public static Http.MockHttpClientFactory HttpClientFactory() => new(); - /// - /// Creates a mock backed by the supplied default handler. - /// + /// public static Http.MockHttpClientFactory HttpClientFactory(Http.MockHttpHandler handler) => new(handler); } } diff --git a/TUnit.Mocks.Http/MockHttpClientFactory.cs b/TUnit.Mocks.Http/MockHttpClientFactory.cs index 480828e4df..1cbf94ee7c 100644 --- a/TUnit.Mocks.Http/MockHttpClientFactory.cs +++ b/TUnit.Mocks.Http/MockHttpClientFactory.cs @@ -1,46 +1,61 @@ namespace TUnit.Mocks.Http; /// -/// A mock backed by one or more s. -/// Each call to returns a fresh that -/// does not dispose the underlying handler, so captured requests survive across using blocks. +/// A mock . Each call returns a +/// fresh with handler disposal disabled, so captured requests on the +/// shared survive across using blocks. /// public sealed class MockHttpClientFactory : IHttpClientFactory { - private readonly Dictionary _named = new(StringComparer.Ordinal); + private readonly Dictionary _named = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _baseAddresses = new(StringComparer.OrdinalIgnoreCase); + private Uri? _defaultBaseAddress; - /// The default handler used for clients with no name-specific handler configured. public MockHttpHandler Handler { get; } - /// Creates a factory with a fresh default handler. public MockHttpClientFactory() : this(new MockHttpHandler()) { } - /// Creates a factory using the supplied default handler. public MockHttpClientFactory(MockHttpHandler handler) { Handler = handler; } - /// - /// Register a dedicated handler for a named client. Subsequent - /// calls with this name will use the supplied handler. - /// + /// Register a dedicated handler for a named client. public MockHttpClientFactory WithHandler(string name, MockHttpHandler handler) { _named[name] = handler; return this; } - /// - /// Gets the handler associated with the named client, falling back to - /// if no name-specific handler has been registered. - /// + /// Set the base address applied to every result that has no name-specific override. + public MockHttpClientFactory WithBaseAddress(string baseAddress) + { + _defaultBaseAddress = new Uri(baseAddress); + return this; + } + + /// Set the base address applied to clients created with the specified name. + public MockHttpClientFactory WithBaseAddress(string name, string baseAddress) + { + _baseAddresses[name] = new Uri(baseAddress); + return this; + } + + /// Gets the handler for the named client, falling back to . public MockHttpHandler HandlerFor(string name) => _named.TryGetValue(name, out var h) ? h : Handler; /// public HttpClient CreateClient(string name) - => new(HandlerFor(name), disposeHandler: false); + { + var client = new HttpClient(HandlerFor(name), disposeHandler: false); + var baseAddress = _baseAddresses.TryGetValue(name, out var named) ? named : _defaultBaseAddress; + if (baseAddress is not null) + { + client.BaseAddress = baseAddress; + } + return client; + } } diff --git a/docs/docs/writing-tests/mocking/http.md b/docs/docs/writing-tests/mocking/http.md index 9a7a9432bd..c2cc9bf42e 100644 --- a/docs/docs/writing-tests/mocking/http.md +++ b/docs/docs/writing-tests/mocking/http.md @@ -239,10 +239,10 @@ Each `CapturedRequest` provides: ## Mocking `IHttpClientFactory` -When the system under test consumes `IHttpClientFactory` and uses `using var client = factory.CreateClient()`, returning a single shared `HttpClient` will fail on the second call with `ObjectDisposedException`. `Mock.HttpClientFactory()` solves this by returning a fresh non-disposing `HttpClient` per call, all sharing one `MockHttpHandler` so captured requests survive across `using` blocks. +`Mock.HttpClientFactory()` returns a factory whose `CreateClient` produces non-disposing `HttpClient`s sharing one `MockHttpHandler`, so captured requests survive `using` blocks in the system under test. ```csharp -var factory = Mock.HttpClientFactory(); +var factory = Mock.HttpClientFactory().WithBaseAddress("https://api.example.com"); factory.Handler.OnGet("/api/users").RespondWithJson("""[{"id":1}]"""); var sut = new Sut(factory); @@ -253,12 +253,14 @@ factory.Handler.Verify(r => r.Method(HttpMethod.Get).Path("/api/users"), Times.O ### Named clients -For typed/named clients registered via `services.AddHttpClient("users")`, assign a dedicated handler per name. Unregistered names fall back to `factory.Handler`. +For typed/named clients registered via `services.AddHttpClient("users")`, assign a dedicated handler (and optionally base address) per name. Name lookups are case-insensitive, matching `IHttpClientFactory` semantics. Unregistered names fall back to `factory.Handler`. ```csharp var factory = Mock.HttpClientFactory() .WithHandler("users", Mock.HttpHandler()) - .WithHandler("orders", Mock.HttpHandler()); + .WithHandler("orders", Mock.HttpHandler()) + .WithBaseAddress("users", "https://users.example.com") + .WithBaseAddress("orders", "https://orders.example.com"); factory.HandlerFor("users").OnGet("/").RespondWithJson("""[]"""); factory.HandlerFor("orders").OnPost("/").Respond(HttpStatusCode.Created); From dc7079178fefcd2455040ce3e9bff0acbf455886 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Tue, 12 May 2026 00:22:04 +0100 Subject: [PATCH 3/3] chore(snapshots): accept reordered ClassTimelineAttribute in Core net472 public API snapshot --- ...rary_Has_No_API_Changes.Net4_7.verified.txt | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt index f02e58b1e3..64ca32b5e5 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt @@ -228,15 +228,6 @@ namespace public . OnTestDiscovered(.DiscoveredTestContext context) { } } [(.Assembly | .Class)] - public sealed class ClassTimelineAttribute : .TUnitAttribute, .IScopedAttribute, ., . - { - public ClassTimelineAttribute(. mode) { } - public . Mode { get; } - public int Order { get; } - public ScopeType { get; } - public . OnTestDiscovered(.DiscoveredTestContext context) { } - } - [(.Assembly | .Class)] public class ClassConstructorAttribute : .TUnitAttribute { public ClassConstructorAttribute( classConstructorType) { } @@ -370,6 +361,15 @@ namespace public static .ClassMetadata GetOrAdd(string name, <.ClassMetadata> factory) { } public static .ClassMetadata GetOrAdd(string name, .ClassMetadata value) { } } + [(.Assembly | .Class)] + public sealed class ClassTimelineAttribute : .TUnitAttribute, .IScopedAttribute, ., . + { + public ClassTimelineAttribute(. mode) { } + public . Mode { get; } + public int Order { get; } + public ScopeType { get; } + public . OnTestDiscovered(.DiscoveredTestContext context) { } + } [(.Class | .Method)] public sealed class CombinedDataSourcesAttribute : .AsyncUntypedDataSourceGeneratorAttribute {