Photo by Joyce McCown on Unsplash
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:
- 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
- Register for an OpenWeatherMap API key,
- 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
- 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:
- Set up our test project with the xUnit and Moq libraries.
- 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
:
- Create an empty
IOptions<OpenWeather>
object to inject into the service. - Create a mock
HttpClient
that can return simulated responses. - 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:
- If the resource is called with a missing or invalid API key, we get a 401 status with "Invalid Api key".
- If the resource is called without a valid city name, we get a 404 status with "city not found".
- 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
:
- It will return a list of
WeatherForecast
objects, no other objects. - The
Date
and theTemp
properties that come from the API response will be the same when theWeatherForecast
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.
Top comments (0)