Skip to content

Commit

Permalink
Merge pull request #13 from maxkagamine/json
Browse files Browse the repository at this point in the history
System.Text.Json support via new ReturnsJsonResponse methods
  • Loading branch information
maxkagamine authored Jul 9, 2022
2 parents 3090b6f + a7afa99 commit 669672b
Show file tree
Hide file tree
Showing 10 changed files with 403 additions and 88 deletions.
16 changes: 16 additions & 0 deletions Moq.Contrib.HttpClient.Test/Models.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
namespace Moq.Contrib.HttpClient.Test
{
/// <summary>
/// Model used by a fictitious music API in a couple examples.
/// </summary>
public class Song
{
public string Title { get; set; }

public string Artist { get; set; }

public string Album { get; set; }

public string Url { get; set; }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
<PackageReference Include="FluentAssertions.Analyzers" Version="0.11.4" />
<PackageReference Include="Flurl" Version="2.8.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="15.9.0" />
<PackageReference Include="Newtonsoft.Json" Version="11.0.2" />
<PackageReference Include="Polly" Version="6.1.1" />
<PackageReference Include="System.Text.Encoding.CodePages" Version="4.5.0" />
<PackageReference Include="xunit" Version="2.4.1" />
Expand Down
103 changes: 77 additions & 26 deletions Moq.Contrib.HttpClient.Test/RequestExtensionsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,12 @@
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Net.Http.Json;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Flurl;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Newtonsoft.Json.Serialization;
using Xunit;

namespace Moq.Contrib.HttpClient.Test
Expand Down Expand Up @@ -122,10 +120,11 @@ public async Task MatchesCustomPredicate()
// Let's simulate posting a song to a music API
var url = new Uri("https://example.com/api/songs");
var token = "auth token obtained somehow";
var model = new

var expected = new Song()
{
Artist = "Neru feat. Kagamine Rin, Kagamine Len",
Title = "The Disease Called Love",
Artist = "Neru feat. Kagamine Rin, Kagamine Len",
Album = "CYNICISM",
Url = "https://youtu.be/2IH-toUoq3w"
};
Expand All @@ -134,43 +133,46 @@ public async Task MatchesCustomPredicate()
handler
.SetupRequest(HttpMethod.Post, url, async request =>
{
// Here we can parse the request json. For this test we'll just check `title`, but if you imagine
// this as a service method mock, anything you would check with It.Is() should go here.
var json = JObject.Parse(await request.Content.ReadAsStringAsync());
return json.Value<string>("title") == model.Title;
// Here we can parse the request json. Anything you would check with It.Is() should go here. Tip: If
// Song were a record type, we could compare the entire object at once with `return json == expected`
var json = await request.Content.ReadFromJsonAsync<Song>();
return json.Title == expected.Title /* ... */;
})
.ReturnsResponse(HttpStatusCode.Created);
// Or, if you know the code under test is using the System.Text.Json extensions, you can skip
// deserialization and access the original class directly:
//
// .SetupRequest(HttpMethod.Post, url, async r => ((JsonContent)r.Content).Value == expected)
//
// Alternatively, to do asserts on the sent model (like a db insert), use Callback to save the model,
// and then Verify it was only called once. (You can also just put asserts in the match predicate!)
//
// .Callback((HttpRequestMessage request, CancellationToken _) =>
// {
// actual = request.Content.ReadFromJsonAsync<Song>().Result;
// });

// A request without a valid auth token should fail (the last setup takes precedence)
handler.SetupRequest(r => r.Headers.Authorization?.Parameter != token)
.ReturnsResponse(HttpStatusCode.Unauthorized);

// Imaginary service method that calls the API we're mocking
async Task CreateSong(object song, string authToken)
async Task CreateSong(Song song, string authToken)
{
var json = JsonConvert.SerializeObject(song, new JsonSerializerSettings()
{
ContractResolver = new CamelCasePropertyNamesContractResolver()
});

var request = new HttpRequestMessage(HttpMethod.Post, url)
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
};
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", authToken);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", authToken);

var response = await client.SendAsync(request);
var response = await client.PostAsJsonAsync(url, song);
response.EnsureSuccessStatusCode();
}

// Create the song
await CreateSong(model, token);
await CreateSong(expected, token);

// The setup won't match if the request json contains a different song
Func<Task> wrongSongAttempt = () => CreateSong(new
Func<Task> wrongSongAttempt = () => CreateSong(new Song()
{
Artist = "鼻そうめんP feat. 初音ミク",
Title = "Plug Out (HSP 2012 Remix)",
Artist = "鼻そうめんP feat. 初音ミク",
Album = "Hiroyuki ODA pres. HSP WORKS 11-14",
Url = "https://vocadb.net/S/21567"
}, token);
Expand All @@ -179,7 +181,7 @@ async Task CreateSong(object song, string authToken)

// Attempt to create the song again, this time without a valid token (if we were actually testing a service,
// this would probably be a separate unit test)
Func<Task> unauthorizedAttempt = () => CreateSong(model, "expired token");
Func<Task> unauthorizedAttempt = () => CreateSong(expected, "expired token");
await unauthorizedAttempt.Should().ThrowAsync<HttpRequestException>("this should 400, causing EnsureSuccessStatusCode() to throw");
}

Expand Down Expand Up @@ -216,6 +218,55 @@ public async Task MatchesQueryParameters()
handler.VerifyAll();
}

// This next test ensures that methods that close the request stream, such as ReadFromJsonAsync, can safely be
// used in the match predicate. Moq did not intend for matchers to have side effects; its code sometimes calls
// the matcher a second time, which would cause these methods to throw as the content had already been consumed:
//
// https://github.com/moq/moq4/blob/v4.18.1/src/Moq/Match.cs#L182 (when Matches returns true)
// https://github.com/moq/moq4/blob/v4.8.0/Source/SetupCollection.cs#L100-L112 (when Matches returns false)
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task MatchPredicateRunsOnlyOnce(bool shouldMatch)
{
var handler = new Mock<HttpMessageHandler>(MockBehavior.Strict);
var client = handler.CreateClient();
var predicate = new Mock<Func<HttpRequestMessage, Task<bool>>>();

// Have the match predicate consume the request content before returning true. This will close the stream,
// which means a second invocation with the same request will throw. Having this be a mock allows us to
// directly Verify how many times it was called as a second line of defense in case ReadFromJsonAsync's
// behavior changes.
predicate.Setup(x => x(It.IsAny<HttpRequestMessage>()))
.Returns(async (HttpRequestMessage request) =>
{
await request.Content.ReadFromJsonAsync<string>();
return shouldMatch;
});

// We'll send two requests, to ensure that the predicate is called once _per request_, not once per setup
handler.SetupRequest(predicate.Object)
.ReturnsResponse(HttpStatusCode.OK);

var first = new Uri("https://example.com/first");
try
{
await client.PostAsJsonAsync(first, "");
} catch (MockException) { }

var second = new Uri("https://example.com/second");
try
{
await client.PostAsJsonAsync(second, "");
}
catch (MockException) { }

// Verify
predicate.Verify(x => x(It.IsAny<HttpRequestMessage>()), Times.Exactly(2));
predicate.Verify(x => x(It.Is<HttpRequestMessage>(r => r.RequestUri == first)), Times.Once());
predicate.Verify(x => x(It.Is<HttpRequestMessage>(r => r.RequestUri == second)), Times.Once());
}

[Fact]
public async Task VerifyHelpersThrowAsExpected() // This one is mainly for code coverage
{
Expand Down
72 changes: 72 additions & 0 deletions Moq.Contrib.HttpClient.Test/ResponseExtensionsTests.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using FluentAssertions;
using Xunit;
Expand Down Expand Up @@ -74,6 +77,75 @@ public async Task RespondsWithString(HttpStatusCode? statusCode, string content,
contentType.CharSet.Should().Be((encoding ?? Encoding.UTF8).WebName);
}

[Fact]
public async Task RespondsWithJson()
{
// Once again using our music API from MatchesCustomPredicate, this time fetching songs
var expected = new List<Song>()
{
new Song()
{
Title = "Lost One's Weeping",
Artist = "Neru feat. Kagamine Rin",
Album = "世界征服",
Url = "https://youtu.be/mF4KTG4c-Ic"
},
new Song()
{
Title = "Gimme×Gimme",
Artist = "八王子P, Giga feat. Hatsune Miku, Kagamine Rin",
Album = "Hatsune Miku Magical Mirai 2020",
Url = "https://youtu.be/IfEAtKW2qSI"
}
};

handler.SetupRequest(HttpMethod.Get, "https://example.com/api/songs")
.ReturnsJsonResponse(expected);

var actual = await client.GetFromJsonAsync<List<Song>>("api/songs");

actual.Should().BeEquivalentTo(expected);
}

[Fact]
public async Task RespondsWithJsonUsingCustomSerializerOptions()
{
var model = new Song()
{
Title = "Onegai Sekai",
Artist = "HitoshizukuP, yama△ feat. Kagamine Rin",
Album = "Mistletoe ~Kamigami no Yadorigi~",
Url = "https://youtu.be/CKvtM4DFkI0"
};

// By default, JsonContent uses JsonSerializerDefaults.Web which camel-cases property names
handler.SetupAnyRequest()
.ReturnsJsonResponse(HttpStatusCode.Created, model);

var json = await client.GetStringAsync("");
json.Should().Be(
@"{""title"":""Onegai Sekai"",""artist"":""HitoshizukuP, yama\u25B3 feat. Kagamine Rin"",""album"":""Mistletoe ~Kamigami no Yadorigi~"",""url"":""https://youtu.be/CKvtM4DFkI0""}");

// We can pass custom serializer options the same way as JsonContent, PostAsJsonAsync(), etc.
// See https://docs.microsoft.com/en-us/dotnet/standard/serialization/system-text-json-how-to#serialization-behavior
var options = new JsonSerializerOptions() // Not using the Web defaults
{
WriteIndented = true
};

handler.SetupAnyRequest()
.ReturnsJsonResponse(HttpStatusCode.Created, model, options);

var pretty = await client.GetStringAsync("");
var expected = @"{
""Title"": ""Onegai Sekai"",
""Artist"": ""HitoshizukuP, yama\u25B3 feat. Kagamine Rin"",
""Album"": ""Mistletoe ~Kamigami no Yadorigi~"",
""Url"": ""https://youtu.be/CKvtM4DFkI0""
}";
Assert.Equal(expected, pretty, ignoreLineEndingDifferences: true);
}

[Theory]
[InlineData(null, new byte[] { 39, 39, 39, 39 }, "image/png")]
[InlineData(HttpStatusCode.BadRequest, new byte[] { }, null)]
Expand Down
1 change: 1 addition & 0 deletions Moq.Contrib.HttpClient/Moq.Contrib.HttpClient.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Http" Version="2.1.1" />
<PackageReference Include="Moq" Version="[4.8.0,5)" />
<PackageReference Include="System.Net.Http.Json" Version="6.0.0" />
</ItemGroup>

<ItemGroup>
Expand Down
Loading

0 comments on commit 669672b

Please sign in to comment.