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