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
5 changes: 4 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
- automatic nuget updates by dependabot, since we want to test against the lowest supported nuget version and most of the time dependabot does not choose the right package.
### Added
- Support for .NET 9.0
- support for .NET 10.0
- Support for .NET 10.0
### Changed
- The TestableHttpMessageHandler now makes a clone of the original request, so that the original request can be disposed.
This change also makes it possible to assert the content on .NET Framework.

## [0.11] - 2024-06-15
### Removed
Expand Down
8 changes: 0 additions & 8 deletions src/TestableHttpClient/HttpRequestMessagesCheckExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,6 @@ private static IHttpRequestMessagesCheck WithHeader(this IHttpRequestMessagesChe
/// <param name="check">The implementation that hold all the request messages.</param>
/// <param name="pattern">The expected content, supports wildcards.</param>
/// <returns>The <seealso cref="IHttpRequestMessagesCheck"/> for further assertions.</returns>
/// <remarks>Note that on .NET Framework, the HttpClient might dispose the content after sending the request.</remarks>
public static IHttpRequestMessagesCheck WithContent(this IHttpRequestMessagesCheck check, string pattern) => WithContent(check, pattern, null);

/// <summary>
Expand All @@ -268,7 +267,6 @@ private static IHttpRequestMessagesCheck WithHeader(this IHttpRequestMessagesChe
/// <param name="pattern">The expected content, supports wildcards.</param>
/// <param name="expectedNumberOfRequests">The expected number of requests.</param>
/// <returns>The <seealso cref="IHttpRequestMessagesCheck"/> for further assertions.</returns>
/// <remarks>Note that on .NET Framework, the HttpClient might dispose the content after sending the request.</remarks>
public static IHttpRequestMessagesCheck WithContent(this IHttpRequestMessagesCheck check, string pattern, int expectedNumberOfRequests) => WithContent(check, pattern, (int?)expectedNumberOfRequests);

private static IHttpRequestMessagesCheck WithContent(this IHttpRequestMessagesCheck check, string pattern, int? expectedNumberOfRequests)
Expand All @@ -285,7 +283,6 @@ private static IHttpRequestMessagesCheck WithContent(this IHttpRequestMessagesCh
/// <param name="check">The implementation that hold all the request messages.</param>
/// <param name="jsonObject">The object representation of the expected request content.</param>
/// <returns>The <seealso cref="IHttpRequestMessagesCheck"/> for further assertions.</returns>
/// <remarks>Note that on .NET Framework, the HttpClient might dispose the content after sending the request.</remarks>
public static IHttpRequestMessagesCheck WithJsonContent(this IHttpRequestMessagesCheck check, object? jsonObject) => WithJsonContent(check, jsonObject, null, null);

/// <summary>
Expand All @@ -295,7 +292,6 @@ private static IHttpRequestMessagesCheck WithContent(this IHttpRequestMessagesCh
/// <param name="jsonObject">The object representation of the expected request content.</param>
/// <param name="jsonSerializerOptions">The serializer options that should be used for serializing te content.</param>
/// <returns>The <seealso cref="IHttpRequestMessagesCheck"/> for further assertions.</returns>
/// <remarks>Note that on .NET Framework, the HttpClient might dispose the content after sending the request.</remarks>
public static IHttpRequestMessagesCheck WithJsonContent(this IHttpRequestMessagesCheck check, object? jsonObject, JsonSerializerOptions jsonSerializerOptions) => WithJsonContent(check, jsonObject, jsonSerializerOptions, null);

/// <summary>
Expand All @@ -305,7 +301,6 @@ private static IHttpRequestMessagesCheck WithContent(this IHttpRequestMessagesCh
/// <param name="jsonObject">The object representation of the expected request content.</param>
/// <param name="expectedNumberOfRequests">The expected number of requests.</param>
/// <returns>The <seealso cref="IHttpRequestMessagesCheck"/> for further assertions.</returns>
/// <remarks>Note that on .NET Framework, the HttpClient might dispose the content after sending the request.</remarks>
public static IHttpRequestMessagesCheck WithJsonContent(this IHttpRequestMessagesCheck check, object? jsonObject, int expectedNumberOfRequests) => WithJsonContent(check, jsonObject, null, (int?)expectedNumberOfRequests);

/// <summary>
Expand All @@ -316,7 +311,6 @@ private static IHttpRequestMessagesCheck WithContent(this IHttpRequestMessagesCh
/// <param name="jsonSerializerOptions">The serializer options that should be used for serializing the content.</param>
/// <param name="expectedNumberOfRequests">The expected number of requests.</param>
/// <returns>The <seealso cref="IHttpRequestMessagesCheck"/> for further assertions.</returns>
/// <remarks>Note that on .NET Framework, the HttpClient might dispose the content after sending the request.</remarks>
public static IHttpRequestMessagesCheck WithJsonContent(this IHttpRequestMessagesCheck check, object? jsonObject, JsonSerializerOptions jsonSerializerOptions, int expectedNumberOfRequests) => WithJsonContent(check, jsonObject, jsonSerializerOptions, (int?)expectedNumberOfRequests);

private static IHttpRequestMessagesCheck WithJsonContent(this IHttpRequestMessagesCheck check, object? jsonObject, JsonSerializerOptions? jsonSerializerOptions, int? expectedNumberOfRequests)
Expand All @@ -334,7 +328,6 @@ private static IHttpRequestMessagesCheck WithJsonContent(this IHttpRequestMessag
/// <param name="check">The implementation that hold all the request messages.</param>
/// <param name="nameValueCollection">The collection of key/value pairs that should be url encoded.</param>
/// <returns>The <seealso cref="IHttpRequestMessagesCheck"/> for further assertions.</returns>
/// <remarks>Note that on .NET Framework, the HttpClient might dispose the content after sending the request.</remarks>
public static IHttpRequestMessagesCheck WithFormUrlEncodedContent(this IHttpRequestMessagesCheck check, IEnumerable<KeyValuePair<string?, string?>> nameValueCollection) => WithFormUrlEncodedContent(check, nameValueCollection, null);

/// <summary>
Expand All @@ -344,7 +337,6 @@ private static IHttpRequestMessagesCheck WithJsonContent(this IHttpRequestMessag
/// <param name="nameValueCollection">The collection of key/value pairs that should be url encoded.</param>
/// <param name="expectedNumberOfRequests">The expected number of requests.</param>
/// <returns>The <seealso cref="IHttpRequestMessagesCheck"/> for further assertions.</returns>
/// <remarks>Note that on .NET Framework, the HttpClient might dispose the content after sending the request.</remarks>
public static IHttpRequestMessagesCheck WithFormUrlEncodedContent(this IHttpRequestMessagesCheck check, IEnumerable<KeyValuePair<string?, string?>> nameValueCollection, int expectedNumberOfRequests) => WithFormUrlEncodedContent(check, nameValueCollection, (int?)expectedNumberOfRequests);

private static IHttpRequestMessagesCheck WithFormUrlEncodedContent(this IHttpRequestMessagesCheck check, IEnumerable<KeyValuePair<string?, string?>> nameValueCollection, int? expectedNumberOfRequests)
Expand Down
2 changes: 1 addition & 1 deletion src/TestableHttpClient/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
@@ -1 +1 @@

override TestableHttpClient.TestableHttpMessageHandler.Dispose(bool disposing) -> void
24 changes: 21 additions & 3 deletions src/TestableHttpClient/TestableHttpMessageHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,19 +17,28 @@ public class TestableHttpMessageHandler : HttpMessageHandler
/// </summary>
public IEnumerable<HttpRequestMessage> Requests => httpRequestMessages;

protected override void Dispose(bool disposing)
{
DisposeRequestMessages();
base.Dispose(disposing);
}

[System.Diagnostics.CodeAnalysis.SuppressMessage("Reliability", "CA2000:Dispose objects before losing scope", Justification = "It gets disposed in the dispose method")]
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
httpRequestMessages.Enqueue(request);
Guard.ThrowIfNull(request);

httpRequestMessages.Enqueue(await HttpRequestMessageCloner.ClonaAsync(request, cancellationToken).ConfigureAwait(false));

HttpResponseMessage responseMessage = new();
HttpResponseContext context = new(request, httpRequestMessages, responseMessage, Options);
await response.ExecuteAsync(context, cancellationToken).ConfigureAwait(false);

responseMessage.RequestMessage ??= request;

#if !NET6_0_OR_GREATER
// In .NET Standard, a response message can be null, but we need it to be at least empty like in the newer versions.
// Newer versions will always have a content, even if it's empty.
responseMessage.Content ??= new StringContent("");
#endif

return responseMessage;
}
Expand All @@ -55,6 +64,15 @@ public void RespondWith(IResponse response)
/// <remarks>The configuration itself (Options and the configured IResponse) will not be cleared or reset.</remarks>
public void ClearRequests()
{
DisposeRequestMessages();
httpRequestMessages.Clear();
}

private void DisposeRequestMessages()
{
foreach (HttpRequestMessage request in httpRequestMessages)
{
request.Dispose();
}
}
}
2 changes: 1 addition & 1 deletion src/TestableHttpClient/Utils/Guard.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ internal static void ThrowIfNullOrEmpty([NotNull] string? argument, [CallerArgum
throw new ArgumentException("String should not be empty", paramName);
}
#else
ArgumentNullException.ThrowIfNullOrEmpty(argument, paramName);
ArgumentException.ThrowIfNullOrEmpty(argument, paramName);
#endif
}
}
37 changes: 37 additions & 0 deletions src/TestableHttpClient/Utils/HttpRequestMessageCloner.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
namespace TestableHttpClient.Utils;

internal static class HttpRequestMessageCloner
{
internal static async Task<HttpRequestMessage> ClonaAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
HttpRequestMessage clone = new()
{
Method = request.Method,
RequestUri = request.RequestUri,
Version = request.Version,
};

foreach (var item in request.Headers)
{
clone.Headers.TryAddWithoutValidation(item.Key, item.Value);
}

if (request.Content is not null)
{
var bytes = await request.Content
.ReadAsByteArrayAsync(cancellationToken)
.ConfigureAwait(false);
var contentClone = new ByteArrayContent(bytes);
contentClone.Headers.Clear();
// copy content headers
foreach (var header in request.Content.Headers)
{
contentClone.Headers.TryAddWithoutValidation(header.Key, header.Value);
}

clone.Content = contentClone;
}

return clone;
}
}
18 changes: 18 additions & 0 deletions src/TestableHttpClient/Utils/NetStandardPollyFill.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
#if NETSTANDARD

namespace TestableHttpClient.Utils;

internal static class NetStandardPollyFill
{
public static Task<byte[]> ReadAsByteArrayAsync(this HttpContent content, CancellationToken cancellationToken = default)
{
return content.ReadAsByteArrayAsync();
}

public static string Replace(this string input, string oldValue, string newValue, StringComparison comparisonType)
{
return input.Replace(oldValue, newValue);
}
}

#endif
5 changes: 1 addition & 4 deletions src/TestableHttpClient/Utils/StringMatcher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,8 @@ internal static class StringMatcher
internal static bool Matches(string value, string pattern, bool ignoreCase = false)
{
var escapedPattern = Regex.Escape(pattern);
#if NETSTANDARD2_0
var regex = escapedPattern.Replace("\\*", "(.*)");
#else

var regex = escapedPattern.Replace("\\*", "(.*)", StringComparison.InvariantCultureIgnoreCase);
#endif
RegexOptions options = ignoreCase ? RegexOptions.IgnoreCase : RegexOptions.None;
return Regex.IsMatch(value, $"^{regex}$", options);
}
Expand Down
29 changes: 19 additions & 10 deletions test/TestableHttpClient.IntegrationTests/AssertingRequests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -162,17 +162,31 @@ public async Task AssertingContent()
using StringContent content = new("my special content");
_ = await client.PostAsync("https://httpbin.org/post", content, TestContext.Current.CancellationToken);

#if NETFRAMEWORK
// On .NET Framework the HttpClient disposes the content automatically. So we can't perform the same test.
testHandler.ShouldHaveMadeRequests();
#else
testHandler.ShouldHaveMadeRequests().WithContent("my special content");
testHandler.ShouldHaveMadeRequests().WithContent("my*content");
testHandler.ShouldHaveMadeRequests().WithContent("*");

Assert.Throws<HttpRequestMessageAssertionException>(() => testHandler.ShouldHaveMadeRequests().WithContent(""));
Assert.Throws<HttpRequestMessageAssertionException>(() => testHandler.ShouldHaveMadeRequests().WithContent("my"));
#endif
}

[Fact]
public async Task AssertingContent_WhenOriginalContentIsDisposed()
{
using TestableHttpMessageHandler testHandler = new();
using HttpClient client = new(testHandler);

using (StringContent content = new("my special content"))
{
_ = await client.PostAsync("https://httpbin.org/post", content, TestContext.Current.CancellationToken);
}

testHandler.ShouldHaveMadeRequests().WithContent("my special content");
testHandler.ShouldHaveMadeRequests().WithContent("my*content");
testHandler.ShouldHaveMadeRequests().WithContent("*");

Assert.Throws<HttpRequestMessageAssertionException>(() => testHandler.ShouldHaveMadeRequests().WithContent(""));
Assert.Throws<HttpRequestMessageAssertionException>(() => testHandler.ShouldHaveMadeRequests().WithContent("my"));
}

[Fact]
Expand All @@ -184,12 +198,7 @@ public async Task AssertJsonContent()
using StringContent content = new("{}", Encoding.UTF8, "application/json");
_ = await client.PostAsync("https://httpbin.org/post", content, TestContext.Current.CancellationToken);

#if NETFRAMEWORK
// On .NET Framework the HttpClient disposes the content automatically. So we can't perform the same test.
testHandler.ShouldHaveMadeRequests();
#else
testHandler.ShouldHaveMadeRequests().WithJsonContent(new { });
#endif
}

[Fact]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,7 @@ public async Task ByDefault_CamelCasing_is_used_for_json_serialization()
sut.RespondWith(Json(new { Name = "Charlie" }));
using HttpClient client = sut.CreateClient();

#if NETFRAMEWORK
string json = await client.GetStringAsync("http://localhost/myjson");
#else
string json = await client.GetStringAsync("http://localhost/myjson", TestContext.Current.CancellationToken);
#endif

Assert.Equal("{\"name\":\"Charlie\"}", json);
}
Expand All @@ -31,11 +27,7 @@ public async Task But_this_can_be_changed()
sut.RespondWith(Json(new { Name = "Charlie" }));
using HttpClient client = sut.CreateClient();

#if NETFRAMEWORK
string json = await client.GetStringAsync("http://localhost/myjson");
#else
string json = await client.GetStringAsync("http://localhost/myjson", TestContext.Current.CancellationToken);
#endif

Assert.Equal("{\"Name\":\"Charlie\"}", json);
}
Expand All @@ -47,11 +39,7 @@ public async Task But_Also_directly_on_the_response()
sut.RespondWith(Json(new { Name = "Charlie" }, jsonSerializerOptions: new JsonSerializerOptions()));
using HttpClient client = sut.CreateClient();

#if NETFRAMEWORK
string json = await client.GetStringAsync("http://localhost/myjson");
#else
string json = await client.GetStringAsync("http://localhost/myjson", TestContext.Current.CancellationToken);
#endif

Assert.Equal("{\"Name\":\"Charlie\"}", json);
}
Expand All @@ -63,12 +51,7 @@ public async Task Asserting_also_works_this_way()
using HttpClient client = sut.CreateClient();
await client.PostAsJsonAsync("http://localhost", new { Name = "Charlie" }, cancellationToken: TestContext.Current.CancellationToken);

#if NETFRAMEWORK
// Well this doesn't really work on .NET Framework.
sut.ShouldHaveMadeRequests();
#else
sut.ShouldHaveMadeRequests().WithJsonContent(new { Name = "Charlie" });
#endif
}

[Fact]
Expand All @@ -84,11 +67,6 @@ public async Task And_we_can_go_crazy_with_it()

await client.PostAsJsonAsync("http://localhost", new { Name = "Charlie" }, options, cancellationToken: TestContext.Current.CancellationToken);

#if NETFRAMEWORK
// Well this doesn't really work on .NET Framework.
sut.ShouldHaveMadeRequests();
#else
sut.ShouldHaveMadeRequests().WithJsonContent(new { Name = "Charlie" }, options);
#endif
}
}
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
using System.Threading;
#if NETFRAMEWORK

using System.Threading;

namespace TestableHttpClient.IntegrationTests;

#if NETFRAMEWORK

internal static class NetFrameworkPollyFill
{
public static Task<string> ReadAsStringAsync(this HttpContent content, CancellationToken cancellationToken = default)
{
return content.ReadAsStringAsync();
}

public static Task<string> GetStringAsync(this HttpClient client, string requestUri, CancellationToken cancellationToken = default)
{
return client.GetStringAsync(requestUri);
}
}

#endif
24 changes: 22 additions & 2 deletions test/TestableHttpClient.Tests/TestableHttpMessageHandlerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ public async Task SendAsync_WhenRequestsAreMade_LogsRequests()

_ = await client.SendAsync(request, TestContext.Current.CancellationToken);

Assert.Contains(request, sut.Requests);
Assert.Contains(request, sut.Requests, new SimpleHttpRequestMessageComparer());
}

[Fact]
Expand All @@ -34,7 +34,7 @@ public async Task SendAsync_WhenMultipleRequestsAreMade_AllRequestsAreLogged()
_ = await client.SendAsync(request3, TestContext.Current.CancellationToken);
_ = await client.SendAsync(request4, TestContext.Current.CancellationToken);

Assert.Equal([request1, request2, request3, request4], sut.Requests);
Assert.Equal([request1, request2, request3, request4], sut.Requests, new SimpleHttpRequestMessageComparer());
}

[Fact]
Expand Down Expand Up @@ -210,4 +210,24 @@ public void Dispose()
GC.SuppressFinalize(this);
}
}

private sealed class SimpleHttpRequestMessageComparer : IEqualityComparer<HttpRequestMessage>
{
public bool Equals(HttpRequestMessage? x, HttpRequestMessage? y)
{
if (x is null && y is null)
{
return true;
}

if (x is null || y is null)
{
return false;
}

return x.Method == y.Method && x.RequestUri == y.RequestUri && x.Version == y.Version;
}

public int GetHashCode([DisallowNull] HttpRequestMessage obj) => HashCode.Combine(obj.Method, obj.RequestUri, obj.Version);
}
}