Friday, 7 July 2023

Mocking Http Client used for Blazor apps using bUnit

This article will look at running http client calls used by Blazor apps using bUnit. First off, bUnit is a library to perform unit tests for Blazor apps. We will look at mocking http client calls in this article. I have added a Github repo with the sample code in this article here :

https://github.com/toreaurstadboss/BlazorHttpClientMocking
Setting up the project Nuget package references of the test project - BlazorHttpClientMocking.Test
 

    <PackageReference Include="bunit" Version="1.21.9" />
    <PackageReference Include="FluentAssertions" Version="6.11.0" />
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.5.0" />
    <PackageReference Include="Moq" Version="4.18.4" />
    <PackageReference Include="RichardSzalay.MockHttp" Version="6.0.0" />
    <PackageReference Include="xunit" Version="2.4.2" />
    <PackageReference Include="xunit.runner.visualstudio" Version="2.4.5">
      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
      <PrivateAssets>all</PrivateAssets>
    </PackageReference>
    <PackageReference Include="coverlet.collector" Version="3.2.0">
      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
      <PrivateAssets>all</PrivateAssets>
    </PackageReference>
 
 
We will use the Nuget package RichardSzalay.MockHttp to do much of the mocking of http client. The following helper extension methods allow us to easier add mocking of http client calls.
 
 
Helper extension methods for http client using bUnit - MockHttpClientBunitHelpers.cs
 
using Bunit;
using Microsoft.Extensions.DependencyInjection;
using RichardSzalay.MockHttp;
using System.Net;
using System.Net.Http.Headers;
using System.Text.Json;

namespace BlazorHttpClientMocking.Test.Helpers
{
    public static class MockHttpClientBunitHelpers
    {

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

        public static T? FromResponse<T>(this HttpResponseMessage? response, JsonSerializerOptions? options = null)
        {
            if (response == null)
            {
                return default(T);
            }
            if (options == null)
            {
                options = new JsonSerializerOptions
                {
                    PropertyNameCaseInsensitive = true
                };
            }
            string responseString = response.Content.ReadAsStringAsync().Result;
            var result = JsonSerializer.Deserialize<T>(responseString, options);
            return result;
        }

        public static async Task<T?> FromResponseAsync<T>(this HttpResponseMessage? response, JsonSerializerOptions? options = null)
        {
            if (response == null)
            {
                return await Task.FromResult(default(T));
            }
            if (options == null)
            {
                options = new JsonSerializerOptions
                {
                    PropertyNameCaseInsensitive = true
                };
            }
            string responseString = await response.Content.ReadAsStringAsync();
            var result = JsonSerializer.Deserialize<T>(responseString, options);
            return result;
        }

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


    }
}

 
 
The method AddMockHttpClient, which is an extension method on TestServiceProvider adds the mocked client. In the code above we read the response into a string and deserialize with System.Text.Json, defaulting to case insensitive property naming, since this is default System.Text.Json on web, but not elsewhere, such as in test projects.


Helper methods for serialization - SerializationHelpers.cs
  
using System.Text.Json;

namespace BlazorHttpClientMocking.Test.Helpers
{
    public static class SerializationHelpers
    {

        public static async Task<T?> DeserializeJsonAsync<T>(string path, JsonSerializerOptions? options = null)
        {
            if (options == null)
            {
                options = new JsonSerializerOptions
                {
                    WriteIndented = true,
                    IncludeFields = true,
                    PropertyNameCaseInsensitive = true
                };
            }

            using (Stream stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read))
            {
                if (File.Exists(path) && stream.Length > 0)
                {
                    T? obj = await JsonSerializer.DeserializeAsync<T>(stream, options);
                    return obj;
                }
                return default(T);
            }

        }

    }
}

  

Let's look at a unit test which then sets up a mocked http client response that is used in the Blazor sample app on the FetchData page.


using BlazorHttpClientMocking.Test.Helpers;
using Bunit;
using FluentAssertions;
using Microsoft.Extensions.DependencyInjection;
using RichardSzalay.MockHttp;
using static BlazorHttpClientMocking.Pages.FetchData;

namespace BlazorHttpClientMocking.Test
{
    public class FetchDataTests
    {
        [Fact]
        public async Task FetchData_HttpClient_Request_SuccessResponse()
        {
            //Arrange 
            using var ctx = new TestContext();
            var httpMock = ctx.Services.AddMockHttpClient();
            string knownUrl = @"/sample-data/weather.json";
            var sampleData = await SerializationHelpers.DeserializeJsonAsync<WeatherForecast[]>(knownUrl.TrimStart('/')); //trimming start of url since we need a physical path
            httpMock.When(knownUrl).RespondJson(sampleData);

            //Act
            var httpClient = ctx.Services.BuildServiceProvider().GetService<HttpClient>();
            var httpClientResponse = await httpClient!.GetAsync(knownUrl);
            httpClientResponse.EnsureSuccessStatusCode();
            var forecasts = await httpClientResponse.FromResponseAsync<WeatherForecast[]>();

            //Assert 
            forecasts.Should().NotBeNull();
            forecasts.Should().HaveCount(5);
        }

    }
}


In the arrange part of the unit test above, we create a TestContext and add a mocked http client using the extension method shown earlier. We read out the sample json data and set up using the When method and remember to add "/" to the path as this is expected since we have a baseAddress specified on the http client, set to @"http://localhost" default.

We retrieve http client via the Services collection on the TestContext and call BuildServiceProvider and GetService method to get the http client with the mocking. The mocking must be done via the When method and then we get the client. The mocked http client is a singleton service here.

We can also do parameters in the mocking of http client calls.

Using parameters in http client calls

Lets first add parameter support for the Fetchdata razor page. Fetchdata.razor

 @page "/fetchdata/"
 @page "/fetchdata/{id:int}"
 
  
 
 @code {
    internal WeatherForecast[]? forecasts;

    [Parameter]
    public int? Id { get; set; }

    protected override async Task OnInitializedAsync()
    {
        forecasts = await Http.GetFromJsonAsync<WeatherForecast[]>($"sample-data/weather.json");
        if (forecasts != null && Id >= 0 && Id < 5)
        {
            forecasts = forecasts.Skip(Id.Value).Take(1).ToArray();
        }
    }

    public class WeatherForecast
    {
        public DateOnly Date { get; set; }

        public int TemperatureC { get; set; }

        public string? Summary { get; set; }

        public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
    }
}
 



Let's now look at using parameters in mocked http client calls in another unit test.
 
 
         [Fact]
        public async Task FetchData_HttpClient_With_Parameter_Request_SuccessResponse()
        {
            //Arrange 
            using var ctx = new TestContext();
            var httpMock = ctx.Services.AddMockHttpClient();
            string knownUrl = @"/sample-data/weather.json/0";
            string fileUrl = @"sample-data/weather.json";

            var sampleData = await SerializationHelpers.DeserializeJsonAsync<WeatherForecast[]>(fileUrl); //trimming start of url since we need a physical path
            httpMock.When(knownUrl).RespondJson(sampleData);

            //Act
            var renderComponent = ctx.RenderComponent<FetchData>(p => p
                .Add(fd => fd.Id, 0));

            //Assert 
            renderComponent.Instance.forecasts.Should().NotBeNull();

            renderComponent.Instance.forecasts.Should().HaveCount(1);    
        }
    
 
 
Here we use bUnit's capabilities in rendering Blazor components using the RenderComponent method and we also set the Id parameter here to the value 0 which now will prepare our component with the right forecasts, here only one forecast will be shown. We use the Instance property to look at the forecasts field of the component. internal WeatherForecast[]? forecasts; So bUnit can be used both the mock http client calls and also render Blazor components and also support parametrized calls of mocked http client calls.


Finally a tip concerning letting your internal fields to be available for test project. In the csproj file of the application we can set it up like in this example :

  <ItemGroup>
		<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
			<_Parameter1>BlazorHttpClientMocking.Test</_Parameter1>
		</AssemblyAttribute>
  </ItemGroup>

Here we set up that the test project can see the internals of the blazor app project. This allows the test project to see internal methods and internal fields, internal classes and so on. This allows you to avoid changing parameters or fields in your components from private to public for example and instead change access modifier to internal so the tests can access those members.

No comments:

Post a Comment