Поиск  
Always will be ready notify the world about expectations as easy as possible: job change page
Dec 21, 2023

Testing an ASP.NET Core service with xUnit

Автор:
Источник:
Просмотров:
1488

Testing an ASP.NET Core Service With xUnit

Introduction and prerequisites

This post is part of an ongoing series where we build a “walking skeleton” application using ASP.NET Core and Angular as well as other technologies for deployment and testing. By now, our application is a minimally functional web API that organizes and returns weather data from a location. In this post, we will use xUnit to test how a service handles calls to a third-party API, then how a controller responds to a successful response. In the next post, we’ll then use those tests to scaffold some exception handling that’s missing from our classes right now. As in other posts, the aim of this article is to go through the steps with detailed explanations.

If you would like to start from the beginning, this post will introduce the project and walk you up to this point. If you’re just here for a walkthrough of testing with xUnit, then you can:

1. Begin by cloning the project up to this point and cd-ing into it:

$ git clone -b 2_adding-async --single-branch git@github.com:jsheridanwells/WeatherWalkingSkeleton.git
$ cd WeatherWalkingSkeleton

2. Register for an OpenWeatherMap API key,

3. And add the API key to the secrets store for this project:

$ dotnet user-secrets set "OpenWeather:ApiKey" "<YOUR-API-KEY>" --project ./Api/WeatherWalkingSkeleton.csproj

4. Test that the web API is working properly up to now:

$ dotnet restore
$ dotnet run --project Api/WeatherWalkingSkeleton.csproj

… and in another terminal, make a request to make sure a result comes out:

$ curl -k https://localhost:5001/weatherforecast?location=detroit

If you get what looks like an array of weather forecasts, then you are good to go.

Testing goals

For this stage of the project, we will add some tests for two of the classes that we’ve built so far: the OpenWeatherService and the WeatherForecastController. This is to establish a pattern of tests that describe the code, and as the application grows in complexity, we'll be sure new changes won't break prior functionality.

I’ve read lots of opinions on software testing from engineers much more experienced than me — from strictly adhering to test-driven development to strategies that might be more pragmatic. The strategy I’ve decided on is to test how the OpenWeatherService responds to different possible responses from the third-party API. Since we do not want to call the actual OpenWeatherMap API, we will set up a substitute class where we can simulate the responses. After writing tests for the service, we'll then set up the service with the WeatherForecastController to test that data is returned properly from the API. From there, we'll have a small set of tests that describe the classes and can run in the future to keep any new work from breaking the prior work.

To the steps to get there are:

  1. Set up our test project with the xUnit and Moq libraries.
  2. Write tests to describe the classes’ current functionality.

Creating a test project

The dotnet CLI contains a template for adding a xUnit test project, as well as templates for the nUnit and MSTest libraries. Let’s create that project:

$ dotnet new xunit -o Tests -n WeatherWalkingSkeleton.Tests

Next, we add the test project to the WeatherWalkingSkeleton solution:

$ dotnet sln WeatherWalkingSkeleton.sln add ./Tests/WeatherWalkingSkeleton.Tests.csproj

Then, to see that the tests are discoverable, run:

$ dotnet test

If everything is working alright, you’ll see the results of one passing fake test.

If you are using Visual Studio, there is a built-in test explorer that will provide a UI for running and debugging tests. You can get a similar set of functionality with VS Code using the .NET Core Test Explorer plugin. Otherwise, running $ dotnet test from the command line will suffice for this tutorial.

We need to add a reference to our test project so that it can access the classes from the API library under test:

$ dotnet add Tests/WeatherWalkingSkeleton.Tests.csproj reference Api/WeatherWalkingSkeleton.csproj

Lastly, we’ll add a few directories and test classes to the testing library. It will take on a similar structure to the API project so that it will be easier to compare a class to its tests. Let’s add directories for any controller and service classes:

$ mkdir ./Tests/Controllers_Tests ./Tests/Services_Tests

Then we’ll add the test classes. You can either add the files via command line or scaffold a class with the IDE you’re using:

$ touch ./Tests/{/Controllers_Tests/WeatherForecastController_Tests.cs,/Services_Tests/OpenWeatherService_Tests.cs}

We’ll also create an Infrastructure directory for any fixtures or utilities needed to support the test classes:

$ mkdir ./Tests/Infrastructure

Lastly, the fake example test can be removed:

$ rm ./Tests/UnitTest1.cs

Setting up test fixtures

The OpenWeatherService will be the trickier class to test because it creates an HttpClient object to make calls to a third-party API. We want to test how it handles different kinds of responses from the API, but we don't want to actually make those requests. We'll have to simulate the kinds of responses the API might return. Also, our service class uses an IOptions object in order to extract a secret API key to use. We don't want any API keys to appear in our code, and in fact it's not really important whether we have a real API key or not, so we'll have to create a service to test with an alternate IOptions object.

So here’s a strategy for describing and testing the OpenWeatherService:

  1. Create an empty IOptions<OpenWeather> object to inject into the service.
  2. Create a mock HttpClient that can return simulated responses.
  3. Handle the “happy path” scenario — how does the service return a successful response from the API?

1. Setting up an options builder

In the Infrastructure directory, add a class called OptionsBuilder.cs. This will be a static class, and so far all we need it to do is to return an Options object with one of the OpenWeatherMap configuration objects as its value:

using Microsoft.Extensions.Options;
using WeatherWalkingSkeleton.Config;

namespace WeatherWalkingSkeleton.Tests.Infrastructure
{
    public static class OptionsBuilder
    {
        public static IOptions<OpenWeather> OpenWeatherConfig()
        {
            return Options.Create<OpenWeather>(new OpenWeather { ApiKey = "00000"});
        }
    }
}

Not much happening here, but we’ve got a passable object to build a test OpenWeatherService

2. Setting up an HttpClient builder

This one is going to be more involved. Our service gets instantiated with an IHttpClientFactory and we call a CreateClient method from the object to get an HttpClient. This strategy is a workaround because we cannot mock an HttpClient directly.

To assist in mocking the objects, we’ll add a very common Nuget package called Moq:

$ dotnet add Tests/WeatherWalkingSkeleton.Tests.csproj package Moq

In Infrastructure, create a ClientBuilder.cs class, also a static class. It will have a static method called OpenWeatherClientFactory. In order to make the method more versatile, we'll give it two arguments: StringContent content will be the simulated response from the API and HttpStatusCode statusCode will be HTTP response code, e.g. 200, 400, 401. I'll paste the entire class directly below, then explain each part:

using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Moq;
using Moq.Protected;

namespace WeatherWalkingSkeleton.Tests.Infrastructure
{
    public static class ClientBuilder
    {
        public static IHttpClientFactory OpenWeatherClientFactory(StringContent content, HttpStatusCode statusCode = HttpStatusCode.OK)
        {
            var handler = new Mock<HttpMessageHandler>();
            handler.Protected()
                .Setup<Task<HttpResponseMessage>>(
                    "SendAsync",
                    ItExpr.IsAny<HttpRequestMessage>(),
                    ItExpr.IsAny<CancellationToken>()
                )
                .ReturnsAsync(new HttpResponseMessage
                {
                    StatusCode = statusCode,
                    Content = content
                });
            var client = new HttpClient(handler.Object);
            var clientFactory = new Mock<IHttpClientFactory>();
            clientFactory.Setup(_ => _.CreateClient(It.IsAny<string>()))
                .Returns(client);

            return clientFactory.Object;
        }
    }
}

HttpClient uses an object internally called HttpMessageHandler to make the actual HTTP request. We will mock it using the Moq library:

var handler = new Mock<HttpMessageHandler>();

The handler has a method called SendAsync that is called to send the request, so we will use Moq to set up the response that we want:

handler.Protected()
.Setup<Task<HttpResponseMessage>>(
    "SendAsync",
    ItExpr.IsAny<HttpRequestMessage>(),
    ItExpr.IsAny<CancellationToken>()
)
.ReturnsAsync(new HttpResponseMessage
{
    StatusCode = statusCode,
    Content = content
});

With our fake message handler, we’ll create a real HttpClient object:

var client = new HttpClient(handler.Object);

And then we’ll create a mock IHttpClientFactory that returns our HttpClient

var client = new HttpClient(handler.Object);
var clientFactory = new Mock<IHttpClientFactory>();
clientFactory.Setup(_ => _.CreateClient(It.IsAny<string>()))
    .Returns(client);

return clientFactory.Object;

3. Generating controlled third-party API responses

The last piece of infrastructure we’ll need is a static class that can return some canned responses that sort of look like the responses that would come back from the OpenWeatherMap API. From testing the API in Postman, we can see that a successful response returns an array of objects that resemble the project’s WeatherForecast class with an HTTP status of 200.

We can also predict a few other scenarios:

  1. If the resource is called with a missing or invalid API key, we get a 401 status with “Invalid Api key”.
  2. If the resource is called without a valid city name, we get a 404 status with “city not found”.
  3. And, I can’t be sure from testing what an error from the third-party server looks like, based on convention we’ll guess that it’s a 500 HTTP status with “Internal Error.” as the message.

Add a file to the ./Tests/Infrastructure directory called OpenWeatherResponses.cs. Then, paste the following code which will create the canned responses for our mock HTTP factory to return:

using System.Collections.Generic;
using System.Net.Http;
using System.Text.Json;
using WeatherWalkingSkeleton.Models;

namespace WeatherWalkingSkeleton.Tests.Infrastructure
{
    public static class OpenWeatherResponses
    {
        public static StringContent OkResponse => BuildOkResponse();
        public static StringContent UnauthorizedResponse => BuildUnauthorizedResponse();
        public static StringContent NotFoundResponse => BuildNotFoundResponse();
        public static StringContent InternalErrorResponse => BuildInternalErrorResponse();

        private static StringContent BuildOkResponse()
        {
            var response = new OpenWeatherResponse
            {
                Forecasts = new List<Forecast>
                {
                    new Forecast{ Dt = 1594155600, Temps = new Temps { Temp = (decimal)32.93 } }
                }
            };
            var json = JsonSerializer.Serialize(response);

            return new StringContent(json);
        }

        private static StringContent BuildUnauthorizedResponse()
        {
            var json = JsonSerializer.Serialize(new { Cod = 401, Message = "Invalid Api key." });

            return new StringContent(json);
        }

        private static StringContent BuildNotFoundResponse()
        {
            var json = JsonSerializer.Serialize(new { Cod = 404, Message = "city not found" });

            return new StringContent(json);
        }

        private static StringContent BuildInternalErrorResponse()
        {
            var json = JsonSerializer.Serialize(new {Cod = 500, Message = "Internal Error."});

            return new StringContent(json);
        }
    }
}

Testing the service

Now that we can control the response we get when pretending to call the OpenWeatherMap API, we’ll set up some tests to describe the OpenWeatherService.

So far, the class contains one method: GetFiveDayForecastAsync. We expect it to return a list of WeatherForecast objects. In the future, we'll need to update this method to handle any errors that get returned from the API, but for now the test will just describe what the method is supposed to do. Also, note that the class contains a private method called BuildOpenWeatherUrl. Since it's a private method, we can't test it in isolation, but since we know that GetFiveDayForecastAsync depends on it, any failures from the private method will be indicated when testing the public method.

Here are a couple of responses that we can expect from GetFiveDayForecastAsync:

  1. It will return a list of WeatherForecast objects, no other objects.
  2. The Date and the Temp properties that come from the API response will be the same when the WeatherForecast list is created.

Add a test file in the .Tests/Services_Tests directory:

$ touch ./Tests/Services_Tests/OpenWeatherService_Tests.cs

The class, with all of the using statements should start like this:

using System;
using System.Collections.Generic;
using System.Net;
using System.Threading.Tasks;
using WeatherWalkingSkeleton.Infrastructure;
using WeatherWalkingSkeleton.Models;
using WeatherWalkingSkeleton.Tests.Infrastructure;
using Xunit;

namespace WeatherWalkingSkeleton.Services
{
    public class OpenWeatherService_Tests
    {

    }
}

Inside the service, let’s add two methods for each of the descriptions we want to provide. Here are the methods:

[Fact]
public async Task Returns_A_WeatherForecast()
{

}

[Fact]
public async Task Returns_Expected_Values_From_the_Api()
{

}

Note that the [Fact] annotation allows a test explorer to find and run any test methods.

Now we’ll add code to the first method. Since in each test, we’ll need to create a OpenWeatherService, we'll generate IOptions<OpenWeather> and IHttpClientFactory objects using the fixtures created above, then create an OpenWeatherService named sut for "system under test". With the service instantiated, we'll call GetFiveDayForecastAsync. On the last line, the Assert class from xUnit is used to test that the method is returning the type of object that we expect:

[Fact]
public async Task Returns_A_WeatherForecast()
{
    var opts = OptionsBuilder.OpenWeatherConfig();
    var clientFactory = ClientBuilder.OpenWeatherClientFactory(OpenWeatherResponses.OkResponse);
    var sut = new OpenWeatherService(opts, clientFactory);
    var result = await sut.GetFiveDayForecastAsync("Chicago");

    Assert.IsType<List<WeatherForecast>>(result);
}

The second test is set up exactly the same way, but in this test we’re seeing if we find the same Date and Temp values that we loaded OpenWeatherResponses.BuildOkResponse() with earlier:

[Fact]
public async Task Returns_Expected_Values_From_the_Api()
{
    var opts = OptionsBuilder.OpenWeatherConfig();
    var clientFactory = ClientBuilder.OpenWeatherClientFactory(OpenWeatherResponses.OkResponse);
    var sut = new OpenWeatherService(opts, clientFactory);
    var result = await sut.GetFiveDayForecastAsync("Chicago");

    Assert.Equal(new DateTime(1594155600), result[0].Date);
    Assert.Equal((decimal)32.93, result[0].Temp);
}

Now run the tests in the IDE test explorer, or in the command line terminal ($ dotnet test) to make sure they pass.

Testing the Controller

Now we’ll add a file for running some controller tests:

$ touch ./Tests/Services_Tests/WeatherForecastController_Tests.cs

Just like the service, so far our WeatherForecastController that consumes the OpenWeatherService just has one method called Get for returning the result of the service. While our previous tests were strictly isolated unit tests - the API and the HTTP client were mocked - I'd like the controller tests to be more of an integration test: I'd like to know how it responds to different results from the OpenWeatherService that it depends on. If we make a change to the OpenWeatherService that could break the WeatherForecastController, we wouldn't know it if we were mocking the service in these tests. So in our tests, we'll build an OpenWeatherService with the API response that we expect, then build the controller with that.

Our WeatherForecastController requires an ILogger<WeatherForecastController> in the constructor. Luckily, the Microsoft.Extensions.Logging library that it the interface comes from also has a class called NullLogger<T> that lets us just pass in an empty logger since logging has nothing to do with the functionality that we're testing. The setup for creating the controller as our system under test is as follows (Note, I'll copy in the full method further down):

var opts = OptionsBuilder.OpenWeatherConfig();
var clientFactory = ClientBuilder.OpenWeatherClientFactory(OpenWeatherResponses.OkResponse);
var service = new OpenWeatherService(opts, clientFactory);
var sut = new WeatherForecastController(new NullLogger<WeatherForecastController>(), service);

After the controller is created we will await the result and convert it to an OkObjectResult that will contain the API response to evaluate:

var result = await sut.Get("Chicago") as OkObjectResult;

Lastly, we’ll make sure that a successful response from the OpenWeatherService results in a "success" response from the controller, along with the List<WeatherForecast> object that we're expecting:

Assert.IsType<List<WeatherForecast>>(result.Value);
Assert.Equal(200, result.StatusCode);

Altogether, the WeatherForecastController_Tests class should look like this:

using System.Collections.Generic;
using System.Net;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging.Abstractions;
using WeatherWalkingSkeleton.Controllers;
using WeatherWalkingSkeleton.Models;
using WeatherWalkingSkeleton.Services;
using WeatherWalkingSkeleton.Tests.Infrastructure;
using Xunit;

namespace WeatherWalkingSkeleton.Tests.Controllers_Tests
{
    public class WeatherForecastController_Tests
    {
        [Fact]
        public async Task Returns_OkResult_With_WeatherForecast()
        {
            var opts = OptionsBuilder.OpenWeatherConfig();
            var clientFactory = ClientBuilder.OpenWeatherClientFactory(OpenWeatherResponses.OkResponse);
            var service = new OpenWeatherService(opts, clientFactory);
            var sut = new WeatherForecastController(new NullLogger<WeatherForecastController>(), service);
            var result = await sut.Get("Chicago") as OkObjectResult;

            Assert.IsType<List<WeatherForecast>>(result.Value);
            Assert.Equal(200, result.StatusCode);
        }
    }
}

Run the tests again, and we should have three total successful tests.

Conclusion

That’s far enough for now. From this tutorial, we were able to install a test library for an ASP.NET Core WebApi project. We also created some initial infrastructure to control the dependencies that we are not testing as well as create a mock version of a third-party API so that we can control the different responses it might give. We used this to evaluate successful responses from our service, then to evaluate the same responses in the controller that consumes the service.

In the next tutorial, we’ll start a TDD process for adding exception handling functionality to the controller and service methods.

References

These posts proved especially helpful in figuring out how to use HttpClient in tests.

Похожее
Jan 7
Author: Sebastian Stupak
Few days ago I stopped myself while writing code. I wrote my LINQ filtering wrong. items.Where(x => x > 0).Any(); (Obviously, it’s a pseudo code) I realized my mistake immediately, changed the code, and went on with my day. Then...
Mar 21
Author: Mohammad Hussain
Introduction A common table expression (CTE) is a temporary named result set that you can reference within a SELECT, INSERT, UPDATE, or DELETE statement in SQL Server. CTEs were introduced in SQL Server 2005 and are similar to derived tables...
17 апреля
Рассмотрим интересную задачу по разработке игры «Крестики Нолики» на языке C#. Наш проект будет запускаться в консоли и потребует креативное мышление для решения задачи.  Ваша задача — реализовать консольную игру "крестики-нолики" с использованием языка программирования C#. Вам нужно создать игровое...
Jul 29
Author: Rick Strahl
Over the last few years, Markdown has become a ubiquitous text-entry model for HTML text. It's creeping up in more and more places and has become a standard for documents that are shared and edited for documentation purposes on the...
Написать сообщение
Тип
Почта
Имя
*Сообщение