Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

#115 - Adding placeholders for default services #223

Merged
merged 15 commits into from
Oct 5, 2020
Merged
Show file tree
Hide file tree
Changes from 12 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
47 changes: 47 additions & 0 deletions docs/samples/tests/xunit/MockHttpClientBunitHelpers.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
namespace Bunit.Docs.Samples
{
using Bunit;
using Microsoft.Extensions.DependencyInjection;
using RichardSzalay.MockHttp;
using System;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text.Json;

public static class MockHttpClientBunitHelpers
{
public static MockHttpMessageHandler AddMockHttpClient(this TestServiceProvider services)
{
var mockHttpHandler = new MockHttpMessageHandler();
var httpClient = mockHttpHandler.ToHttpClient();
httpClient.BaseAddress = new Uri("http://localhost");
services.AddSingleton<HttpClient>(httpClient);
return mockHttpHandler;
}

public static MockedRequest RespondJson<T>(this MockedRequest request, T content)
{
request.Respond(req =>
{
var response = new HttpResponseMessage(HttpStatusCode.OK);
response.Content = new StringContent(JsonSerializer.Serialize(content));
response.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json");
return response;
});
return request;
}

public static MockedRequest RespondJson<T>(this MockedRequest request, Func<T> contentProvider)
{
request.Respond(req =>
{
var response = new HttpResponseMessage(HttpStatusCode.OK);
response.Content = new StringContent(JsonSerializer.Serialize(contentProvider()));
response.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json");
return response;
});
return request;
}
}
}
1 change: 1 addition & 0 deletions docs/samples/tests/xunit/bunit.docs.xunit.samples.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="RichardSzalay.MockHttp" Version="6.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.6.1" />
<PackageReference Include="xunit" Version="2.4.1" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.1">
Expand Down
30 changes: 29 additions & 1 deletion docs/site/docs/test-doubles/mocking-httpclient.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,32 @@ title: Mocking HttpClient

# Mocking `HttpClient`

TODO - https://github.com/egil/bunit/issues/61
Mocking the `HttpClient` service in .NET Core is a bit more clumbersome than interface-based services like `IJSRuntime`.
There is currently no built-in mock for `HttpClient` in bUnit, but with the use of
[RichardSzalay.MockHttp](https://www.nuget.org/packages/RichardSzalay.MockHttp/) we can easily add one that works
with bUnit.

To use RichardSzalay.MockHttp, add the following package reference to your test project's .csproj file:

```xml
<PackageReference Include="RichardSzalay.MockHttp" Version="6.0.0" />
```

To make it easier to work with [RichardSzalay.MockHttp](https://www.nuget.org/packages/RichardSzalay.MockHttp/), add
the following extension class to your test project. It makes it easier to add the `HttpClient` mock to
bUnit's test context's `Services` collection, and configure responses to requests:

[!code-csharp[MockHttpClientBunitHelpers.cs](../../../samples/tests/xunit/MockHttpClientBunitHelpers.cs?start=3&end=46)]

With the helper methods in place, you can do the following in your tests:

```csharp
using var ctx = new TestContext();
var mock = ctx.Services.AddMockHttpClient();
mock.When("/getData").RespondJson(new List<Data>{ ... });
```

This registers the mock `HttpClient` in bUnit's test context's `Services` collection, and then tells the mock that when a request is received for `/getData`, it should respond with the `new List<Data>{ ... }`, serialized as JSON.

> [!TIP]
> You can add addtional `RespondXXX` methods to the `MockHttpClientBunitHelpers` class to fit your testing needs.
1 change: 1 addition & 0 deletions docs/site/docs/toc.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@

# [Test Doubles](xref:test-doubles)
## [Faking Authorization](xref:faking-auth)
## [Mocking HttpClient](xref:mocking-httpclient)
## [Mocking IJSRuntime](xref:mocking-ijsruntime)

# [Miscellaneous testing tips](xref:misc-test-tips)
Expand Down
9 changes: 9 additions & 0 deletions src/bunit.web/Extensions/TestServiceProviderExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
using System.Net.Http;
using Bunit.Diffing;
using Bunit.Rendering;
using Bunit.TestDoubles.Authorization;
using Bunit.TestDoubles.HttpClient;
using Bunit.TestDoubles.JSInterop;
using Bunit.TestDoubles.Localization;
using Bunit.TestDoubles.NavigationManagement;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Localization;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.JSInterop;
Expand All @@ -25,6 +31,9 @@ public static IServiceCollection AddDefaultTestContextServices(this IServiceColl
services.AddSingleton<AuthenticationStateProvider, PlaceholderAuthenticationStateProvider>();
services.AddSingleton<IAuthorizationService, PlaceholderAuthorizationService>();
services.AddSingleton<IJSRuntime, PlaceholderJSRuntime>();
services.AddSingleton<NavigationManager, PlaceholderNavigationManager>();
services.AddSingleton<HttpClient, PlaceholderHttpClient>();
services.AddSingleton<IStringLocalizer, PlaceholderStringLocalization>();
services.AddSingleton<HtmlComparer>();
services.AddSingleton<BunitHtmlParser>();
services.AddSingleton<IRenderedComponentActivator, RenderedComponentActivator>();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
using System;
using System.Net.Http;

namespace Bunit.TestDoubles.HttpClient
{
/// <summary>
/// Exception use to indicate that a mock HttpClient is required by a test
/// but was not provided.
/// </summary>
public sealed class MissingMockHttpClientException : Exception
{
/// <summary>
/// The request that was sent via the http client
/// </summary>
public HttpRequestMessage Request { get; }

/// <summary>
/// Creates a new instance of the <see cref="MissingMockHttpClientException"/>
/// with the request that would have been handled
/// </summary>
/// <param name="request">The request being handled by the client</param>
public MissingMockHttpClientException(HttpRequestMessage request)
: base($"This test requires a HttpClient to be supplied, because the component under test invokes the HttpClient during the test. The request that was sent is contained within the '{nameof(Request)}' attribute of this exception. Guidance on mocking the HttpClient is available on bUnit's website.")
{
Request = request;
HelpLink = "https://bunit.egilhansen.com/docs/test-doubles/mocking-httpclient";
}
}
}
40 changes: 40 additions & 0 deletions src/bunit.web/TestDoubles/HttpClient/PlaceholderHttpClient.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
using System;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;

namespace Bunit.TestDoubles.HttpClient
{
public class PlaceholderHttpClient : System.Net.Http.HttpClient
{
/// <summary>
/// Creates an instance of <see cref="PlaceholderHttpClient"/>
/// with a <see cref="PlaceholderHttpMessageHandler"/> message handler
/// </summary>
[System.Diagnostics.CodeAnalysis.SuppressMessage("Reliability", "CA2000:Dispose objects before losing scope", Justification = "<Pending>")]
public PlaceholderHttpClient()
: base(new PlaceholderHttpMessageHandler())
{
BaseAddress = new Uri("http://localhost");
}

/// <summary>
/// This MessageHandler for HttpClient is used to provide users with helpful
/// exceptions if they fail to provide a mock when required.
/// </summary>
private class PlaceholderHttpMessageHandler : HttpMessageHandler
{
/// <summary>
///
/// </summary>
/// <param name="request"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
/// <exception cref="MissingMockHttpClientException"></exception>
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
throw new MissingMockHttpClientException(request);
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;

namespace Bunit.TestDoubles.HttpClient
{

}
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,11 @@ public class MissingMockJSRuntimeException : Exception
/// <param name="identifier">The identifer used in the invocation.</param>
/// <param name="arguments">The args used in the invocation, if any</param>
public MissingMockJSRuntimeException(string identifier, object?[]? arguments)
: base($"This test requires a IJSRuntime to be supplied, because the component under test invokes the IJSRuntime during the test. The invoked method is '{identifier}' and the invocation arguments are stored in the {nameof(Arguments)} property of this exception. Guidance on mocking the IJSRuntime is available in the testing library's Wiki.")
: base($"This test requires a IJSRuntime to be supplied, because the component under test invokes the IJSRuntime during the test. The invoked method is '{identifier}' and the invocation arguments are stored in the {nameof(Arguments)} property of this exception. Guidance on mocking the IJSRuntime is available on bUnit's website.")
{
Identifier = identifier;
Arguments = arguments ?? Array.Empty<object?>();
HelpLink = "https://github.com/egil/razor-components-testing-library/wiki/Mocking-JsRuntime";
HelpLink = "https://bunit.egilhansen.com/docs/test-doubles/mocking-ijsruntime";
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ public class MockJSRuntimeInvokeHandler
public IReadOnlyDictionary<string, List<JSRuntimeInvocation>> Invocations => _invocations;

/// <summary>
/// Gets whether the mock is running in <see cref="JSRuntimeMockMode.Loose"/> or
/// Gets whether the mock is running in <see cref="JSRuntimeMockMode.Loose"/> or
/// <see cref="JSRuntimeMockMode.Strict"/>.
/// </summary>
public JSRuntimeMockMode Mode { get; }
Expand Down Expand Up @@ -50,7 +50,7 @@ public IJSRuntime ToJSRuntime()
/// </summary>
/// <typeparam name="TResult">The result type of the invocation</typeparam>
/// <param name="identifier">The identifier to setup a response for</param>
/// <param name="argumentsMatcher">A matcher that is passed arguments received in invocations to <paramref name="identifier"/>. If it returns true the invocation is matched.</param>
/// <param name="argumentsMatcher">A matcher that is passed arguments received in invocTestServiceProviderExtensions.cs ations to <paramref name="identifier"/>. If it returns true the invocation is matched.</param>
/// <returns>A <see cref="JSRuntimePlannedInvocation{TResult}"/>.</returns>
public JSRuntimePlannedInvocation<TResult> Setup<TResult>(string identifier, Func<IReadOnlyList<object?>, bool> argumentsMatcher)
{
Expand Down Expand Up @@ -90,7 +90,7 @@ public JSRuntimePlannedInvocation SetupVoid(string identifier, Func<IReadOnlyLis
}

/// <summary>
/// Configure a planned JSInterop invocation with the <paramref name="identifier"/>
/// Configure a planned JSInterop invocation with the <paramref name="identifier"/>
/// and <paramref name="arguments"/>, that should not receive any result.
/// </summary>
/// <param name="identifier">The identifier to setup a response for</param>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
using System;
using System.Collections.Generic;

namespace Bunit.TestDoubles.Localization
{
/// <summary>
/// Exception use to indicate that a IStringLocalizer is required by a test
/// but was not provided.
/// </summary>
public class MissingMockStringLocalizationException : Exception
{

/// <summary>
/// The arguments that were passed into the localizer
/// </summary>
public IReadOnlyList<object?> Arguments { get; }

/// <summary>
/// Creates a new instance of the <see cref="MissingMockStringLocalizationException"/>
/// with the method name and arguments used in the invocation
/// </summary>
/// <param name="methodName">The method that was called on the localizer</param>
/// <param name="arguments">The arguments that were passed in</param>
public MissingMockStringLocalizationException(string methodName, params object?[]? arguments)
:base($"This test requires a IStringLocalizer to be supplied, because the component under test invokes the IStringLocalizer during the test. The method that was called was '{methodName}', the parameters are container within the '{nameof(Arguments)}' property of this exception.")
{
Arguments = arguments ?? Array.Empty<object?>();;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
using System.Globalization;
using System.Collections.Generic;
using Microsoft.Extensions.Localization;

namespace Bunit.TestDoubles.Localization
{
/// <summary>
/// This IStringLocalizer is used to provide users with helpful exceptions if they fail to provide a mock when required.
/// </summary>
internal class PlaceholderStringLocalization : IStringLocalizer
{
/// <summary>
/// Will throw exception to prompt the user
/// </summary>
/// <param name="includeParentCultures"></param>
/// <returns></returns>
/// <exception cref="MissingMockStringLocalizationException"></exception>
public IEnumerable<LocalizedString> GetAllStrings(bool includeParentCultures)
=> throw new MissingMockStringLocalizationException(nameof(GetAllStrings), includeParentCultures);

/// <summary>
/// Will throw exception to prompt the user
/// </summary>
/// <param name="culture"></param>
/// <returns></returns>
/// <exception cref="MissingMockStringLocalizationException"></exception>
public IStringLocalizer WithCulture(CultureInfo culture)
=> throw new MissingMockStringLocalizationException(nameof(WithCulture), culture);

/// <summary>
/// Will throw exception to prompt the user
/// </summary>
/// <param name="name"></param>
public LocalizedString this[string name]
=> Throw(name);

/// <summary>
/// Will throw exception to prompt the user
/// </summary>
/// <param name="name"></param>
/// <param name="arguments"></param>
public LocalizedString this[string name, params object[] arguments]
=> Throw(name, arguments);

private static LocalizedString Throw(string name, params object?[]? args)
=> throw new MissingMockStringLocalizationException("GetByIndex", name, args);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
using System;

namespace Bunit.TestDoubles.NavigationManagement
{
/// <summary>
/// Exception use to indicate that a NavigationManager is required by a test
/// but was not provided.
/// </summary>
public class MissingMockNavigationManagerException : Exception
{
private static string ExceptionMessage(string url, bool forceLoad) =>
$"This test requires a NavigationManager to be supplied, because the component under test invokes the NavigationManager during the test. The url that was requested was '{url}' with a force reload value of '{forceLoad.ToString()}'.";

/// <summary>
/// Creates a new instance of the <see cref="MissingMockNavigationManagerException"/>
/// with the arguments used in the invocation.
/// </summary>
/// <param name="url">Uri to navigate to</param>
/// <param name="forceLoad">Whether to force load</param>
public MissingMockNavigationManagerException(string url, bool forceLoad)
: base (ExceptionMessage(url, forceLoad))
{

}

/// <summary>
/// Creates a new instance of the <see cref="MissingMockNavigationManagerException"/>
/// with the arguments used in the invocation.
/// </summary>
/// <param name="url">Uri to navigate to</param>
/// <param name="forceLoad">Whether to force load</param>
public MissingMockNavigationManagerException(Uri url, bool forceLoad)
: base (ExceptionMessage(url?.ToString() ?? string.Empty, forceLoad))
{

}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
using Microsoft.AspNetCore.Components;

namespace Bunit.TestDoubles.NavigationManagement
{
/// <summary>
/// This NavigationManager is used to provide users with helpful exceptions if they fail to provide a mock when required.
/// </summary>
internal class PlaceholderNavigationManager : NavigationManager
{
/// <summary>
/// Will throw exception to prompt user
/// </summary>
/// <param name="uri"></param>
/// <param name="forceLoad"></param>
/// <exception cref="MissingMockNavigationManagerException"></exception>
protected override void NavigateToCore(string uri, bool forceLoad)
{
throw new MissingMockNavigationManagerException(uri, forceLoad);
}

/// <summary>
/// Will initialize the navigation manager with a hard coded
/// value of http://localhost:5000/
/// </summary>
protected override void EnsureInitialized()
{
Initialize("http://localhost/", "http://localhost/");
}
}
}
Loading