diff --git a/Directory.Packages.props b/Directory.Packages.props index d0b1a05e21..0d6faa72be 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..beacd169b7 --- /dev/null +++ b/TUnit.Mocks.Http.Tests/MockHttpClientFactoryTests.cs @@ -0,0 +1,134 @@ +using System.Net; +using TUnit.Mocks; + +namespace TUnit.Mocks.Http.Tests; + +public class MockHttpClientFactoryTests +{ + private const string ClientName = "test-client"; + + [Test] + public async Task CreateClient_ReturnsConfiguredResponseFromDefaultHandler() + { + using var factory = Mock.HttpClientFactory().WithBaseAddress("http://localhost"); + factory.Handler.OnGet("/api/users").RespondWithJson("""[{"id":1}]"""); + + using var client = factory.CreateClient(ClientName); + 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_EmptyNameFallsBackToDefaultHandler() + { + using var factory = Mock.HttpClientFactory().WithBaseAddress("http://localhost"); + factory.Handler.OnAnyRequest().Respond(HttpStatusCode.OK); + + using var client = factory.CreateClient(string.Empty); + var response = await client.GetAsync("/"); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + } + + [Test] + public async Task CreateClient_SurvivesUsingBlockDisposal() + { + using 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(ClientName); + 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() + { + using var factory = Mock.HttpClientFactory(); + + using var a = factory.CreateClient(ClientName); + using var b = factory.CreateClient(ClientName); + + 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"}"""); + + using var factory = Mock.HttpClientFactory() + .WithBaseAddress("http://localhost") + .WithHandler("users", usersHandler) + .WithHandler("orders", ordersHandler); + + using var usersClient = factory.CreateClient("users"); + var usersBody = await (await usersClient.GetAsync("/")).Content.ReadAsStringAsync(); + + using var ordersClient = factory.CreateClient("orders"); + 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() + { + using var factory = Mock.HttpClientFactory(); + + await Assert.That(factory.HandlerFor("unregistered")).IsSameReferenceAs(factory.Handler); + } + + [Test] + public async Task CreateClient_NameIsCaseInsensitive() + { + var namedHandler = Mock.HttpHandler(); + using var factory = Mock.HttpClientFactory().WithHandler("Users", namedHandler); + + await Assert.That(factory.HandlerFor("USERS")).IsSameReferenceAs(namedHandler); + await Assert.That(factory.HandlerFor("users")).IsSameReferenceAs(namedHandler); + } + + [Test] + public void Dispose_PreventsFurtherUse() + { + var factory = Mock.HttpClientFactory(); + + factory.Dispose(); + + Assert.Throws(() => factory.CreateClient(ClientName)); + } + + [Test] + public async Task Verify_TracksRequestsAcrossMultipleClientLifetimes() + { + using var factory = Mock.HttpClientFactory().WithBaseAddress("http://localhost"); + factory.Handler.OnGet("/api/data").Respond(HttpStatusCode.OK); + + using (var c1 = factory.CreateClient(ClientName)) + { + await c1.GetAsync("/api/data"); + } + using (var c2 = factory.CreateClient(ClientName)) + { + await c2.GetAsync("/api/data"); + } + + factory.Handler.Verify(r => r.Method(HttpMethod.Get).Path("/api/data"), Times.Exactly(2)); + } +} diff --git a/TUnit.Mocks.Http/MockExtensions.cs b/TUnit.Mocks.Http/MockExtensions.cs index 3e85b77fb7..5d3e37e828 100644 --- a/TUnit.Mocks.Http/MockExtensions.cs +++ b/TUnit.Mocks.Http/MockExtensions.cs @@ -24,5 +24,14 @@ 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 producing non-disposing clients + /// that share a , so requests survive using blocks. + /// + public static Http.MockHttpClientFactory HttpClientFactory() => new(); + + /// + 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..0e7ad80f19 --- /dev/null +++ b/TUnit.Mocks.Http/MockHttpClientFactory.cs @@ -0,0 +1,98 @@ +using System.Collections.Concurrent; + +namespace TUnit.Mocks.Http; + +/// +/// 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, IDisposable +{ + private readonly ConcurrentDictionary _named = new(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary _baseAddresses = new(StringComparer.OrdinalIgnoreCase); + private Uri? _defaultBaseAddress; + private int _disposed; + + public MockHttpHandler Handler { get; } + + public MockHttpClientFactory() : this(new MockHttpHandler()) + { + } + + public MockHttpClientFactory(MockHttpHandler handler) + { + Handler = handler; + } + + /// Register a dedicated handler for a named client. + public MockHttpClientFactory WithHandler(string name, MockHttpHandler handler) + { + ThrowIfDisposed(); + _named[name] = handler; + return this; + } + + /// Set the base address applied to every result that has no name-specific override. + public MockHttpClientFactory WithBaseAddress(string baseAddress) + { + ThrowIfDisposed(); + _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) + { + ThrowIfDisposed(); + _baseAddresses[name] = new Uri(baseAddress); + return this; + } + + /// Gets the handler for the named client, falling back to . + public MockHttpHandler HandlerFor(string name) + { + ThrowIfDisposed(); + return _named.TryGetValue(name, out var h) ? h : Handler; + } + + /// + public HttpClient CreateClient(string name) + { + ThrowIfDisposed(); + 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; + } + + /// + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + Handler.Dispose(); + var disposedHandlers = new HashSet { Handler }; + foreach (var handler in _named.Values) + { + if (disposedHandlers.Add(handler)) + { + handler.Dispose(); + } + } + } + + private void ThrowIfDisposed() + { + if (_disposed != 0) + { + throw new ObjectDisposedException(nameof(MockHttpClientFactory)); + } + } +} 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/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 { diff --git a/docs/docs/writing-tests/mocking/http.md b/docs/docs/writing-tests/mocking/http.md index 98aafe00ae..c2cc9bf42e 100644 --- a/docs/docs/writing-tests/mocking/http.md +++ b/docs/docs/writing-tests/mocking/http.md @@ -237,6 +237,35 @@ Each `CapturedRequest` provides: | `Matched` | Whether a setup matched this request | | `Timestamp` | When the request was captured | +## Mocking `IHttpClientFactory` + +`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().WithBaseAddress("https://api.example.com"); +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 (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()) + .WithBaseAddress("users", "https://users.example.com") + .WithBaseAddress("orders", "https://orders.example.com"); + +factory.HandlerFor("users").OnGet("/").RespondWithJson("""[]"""); +factory.HandlerFor("orders").OnPost("/").Respond(HttpStatusCode.Created); +``` + ## Reset ```csharp