Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
<PackageVersion Include="Microsoft.CSharp" Version="4.7.0" />
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.8" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.8" />
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.8" />
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.8" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.8" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.5.1" />
Expand Down
134 changes: 134 additions & 0 deletions TUnit.Mocks.Http.Tests/MockHttpClientFactoryTests.cs
Original file line number Diff line number Diff line change
@@ -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<ObjectDisposedException>(() => 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));
}
}
9 changes: 9 additions & 0 deletions TUnit.Mocks.Http/MockExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,14 @@ public static class MockExtensions
/// Use <c>.Handler</c> on the returned client to configure setups and verify calls.
/// </summary>
public static Http.MockHttpClient HttpClient(string baseAddress) => new(baseAddress);

/// <summary>
/// Creates a mock <see cref="IHttpClientFactory"/> producing non-disposing clients
/// that share a <see cref="Http.MockHttpHandler"/>, so requests survive <c>using</c> blocks.
/// </summary>
public static Http.MockHttpClientFactory HttpClientFactory() => new();

/// <inheritdoc cref="HttpClientFactory()" />
public static Http.MockHttpClientFactory HttpClientFactory(Http.MockHttpHandler handler) => new(handler);
}
}
98 changes: 98 additions & 0 deletions TUnit.Mocks.Http/MockHttpClientFactory.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
using System.Collections.Concurrent;

namespace TUnit.Mocks.Http;

/// <summary>
/// A mock <see cref="IHttpClientFactory"/>. Each <see cref="CreateClient(string)"/> call returns a
/// fresh <see cref="HttpClient"/> with handler disposal disabled, so captured requests on the
/// shared <see cref="MockHttpHandler"/> survive across <c>using</c> blocks.
/// </summary>
public sealed class MockHttpClientFactory : IHttpClientFactory, IDisposable
{
private readonly ConcurrentDictionary<string, MockHttpHandler> _named = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, Uri> _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;
}

/// <summary>Register a dedicated handler for a named client.</summary>
public MockHttpClientFactory WithHandler(string name, MockHttpHandler handler)
{
ThrowIfDisposed();
_named[name] = handler;
return this;
}

/// <summary>Set the base address applied to every <see cref="CreateClient(string)"/> result that has no name-specific override.</summary>
public MockHttpClientFactory WithBaseAddress(string baseAddress)
{
ThrowIfDisposed();
_defaultBaseAddress = new Uri(baseAddress);
return this;
}

/// <summary>Set the base address applied to clients created with the specified name.</summary>
public MockHttpClientFactory WithBaseAddress(string name, string baseAddress)
{
ThrowIfDisposed();
_baseAddresses[name] = new Uri(baseAddress);
return this;
}

/// <summary>Gets the handler for the named client, falling back to <see cref="Handler"/>.</summary>
public MockHttpHandler HandlerFor(string name)
{
ThrowIfDisposed();
return _named.TryGetValue(name, out var h) ? h : Handler;
}

/// <inheritdoc />
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;
}

/// <inheritdoc />
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) != 0)
{
return;
}

Handler.Dispose();
var disposedHandlers = new HashSet<MockHttpHandler> { Handler };
foreach (var handler in _named.Values)
{
if (disposedHandlers.Add(handler))
{
handler.Dispose();
}
}
}

private void ThrowIfDisposed()
{
if (_disposed != 0)
{
throw new ObjectDisposedException(nameof(MockHttpClientFactory));
}
}
}
1 change: 1 addition & 0 deletions TUnit.Mocks.Http/TUnit.Mocks.Http.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

<ItemGroup>
<ProjectReference Include="..\TUnit.Mocks\TUnit.Mocks.csproj" />
<PackageReference Include="Microsoft.Extensions.Http" />
</ItemGroup>

<Import Project="..\Library.targets" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) { }
Expand Down Expand Up @@ -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
{
Expand Down
29 changes: 29 additions & 0 deletions docs/docs/writing-tests/mocking/http.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading