In the previous article, we tested our WeatherForecastController
using a Dummy logger and a Stub service. While this was sufficient for basic unit testing, in this article, we'll take our testing to the next level by introducing Spies and Mocks.
As an exercise, we want to evolve our unit tests to use Spy and Mock. These kinds of Test Doubles let us have more control over what the code does since we can assert how many times a method is called, which values are passed to a method, and much more.
Let's see a basic implementation of each.
Spy
As you may remember from the first article of the series, a Spy is like a Stub but with the added ability to record information about how methods are called—such as which parameters are passed and how many times the method was invoked. This makes it useful for assertions and verifications at the end of the test.
With this in mind, evolving the Stub service into a Spy is straightforward and may look like this:
internal class SpyWeatherService : IWeatherService
{
private readonly List<WeatherForecast> _weatherForecast =
[
new WeatherForecast
{
Date = DateOnly.FromDateTime(DateTime.Now),
TemperatureC = 20,
Summary = "Test Summary"
}
];
public int NumberOfCalls = 0;
public string LastRequestedCity = string.Empty;
public IEnumerable<WeatherForecast> GetByCity(string city)
{
NumberOfCalls++;
LastRequestedCity = city;
return string.Equals(city, "Rome") ? _weatherForecast : Enumerable.Empty<WeatherForecast>();
}
}
We have simply added two properties:
-
NumberOfCalls
, to store the number of timesGetByCity
is called -
LastRequestedCity
, to store the last input passed toGetByCity
Tests with Spy
The constructor for our test class looks like this:
public class TestWithSpy
{
private readonly WeatherForecastController _sut;
private readonly SpyWeatherService _weatherService;
private readonly string _cityWithData = "Rome";
public TestWithSpy()
{
_weatherService = new SpyWeatherService();
_sut = new WeatherForecastController(_weatherService, new DummyLogger<WeatherForecastController>());
}
}
Using the XUnit Framework and initializing the WeatherForecastController
in the constructor of the test class is very important when working with Test Doubles because XUnit calls the test class constructor before each test. This way, we ensure that an instance of _sut (System Under Test) is not shared between tests. This allows us to use the properties we introduced in the Spy to make assertions across multiple tests, knowing they reset for each test!
At this point, we can change our tests to look like these:
[Fact]
public void Get_ReturnOk_When_Data_Exists()
{
IActionResult actual = _sut.Get(_cityWithData);
var okResult = Assert.IsType<OkObjectResult>(actual);
var forecasts = Assert.IsAssignableFrom<IEnumerable<WeatherForecast>>(okResult.Value);
Assert.Equal(1, _weatherService.NumberOfCall);
Assert.Equal(_cityWithData, _weatherService.LastRequestedCity);
Assert.NotEmpty(forecasts);
}
[Fact]
public void Get_ReturnNoContent_When_Data_NotExists()
{
IActionResult actual = _sut.Get("Paris");
Assert.IsType<NoContentResult>(actual);
Assert.Equal(1, _weatherService.NumberOfCall);
Assert.Equal("Paris", _weatherService.LastRequestedCity);
}
While checking the number of method calls or the last requested city might seem trivial in this case, these assertions become invaluable as our logic grows more complex. They allow us to verify not just the final outcome but also the correctness of interactions within our system.
Mock
Now that we've seen how a Spy can help us track method calls and parameters, let's take it a step further with Mocks, which allow us to define stricter expectations and enforce them during the test.
Mocks are particularly useful when testing interactions with dependencies that should follow strict contracts, such as ensuring an API is called a specific number of times or with exact parameters.
From the first article of the series, a Mock takes the concept of a Spy one step further: it can be pre-programmed with expectations about method calls, such as which parameters should be passed and how many times the methods should be invoked. If these expectations are not met, the Mock can throw an exception. Mocks usually include a Verify
method to assert that the expectations were satisfied.
An implementation for the IWeatherService
that matches this definition could be the following:
internal class MockWeatherService : IWeatherService
{
private readonly Dictionary<string, List<WeatherForecast>> _expectedResponses = [];
private readonly List<string> _calledWithCities = [];
public void SetUpGetByCity(string city, List<WeatherForecast> weatherForecasts)
=> _expectedResponses[city] = weatherForecasts;
public void VerifyGetByCity(string cityToVerify, int times = 1)
{
var actualCount = _calledWithCities.Count(x => x == cityToVerify);
if (actualCount != times)
throw new Exception($"GetByCity to be called {times} time(s) with city {cityToVerify}, but was called {actualCount} time(s).");
}
public IEnumerable<WeatherForecast> GetByCity(string city)
{
_calledWithCities.Add(city);
return _expectedResponses.TryGetValue(city, out List<WeatherForecast>? value) ? value : Enumerable.Empty<WeatherForecast>();
}
}
We have defined two fields:
-
_expectedResponses
is a dictionary used to set up the expected response returned by theGetByCity
method for a given city. TheSetUpGetByCity
method will be used for the set up and is a utility method not part of theIWeatherService
interface. -
_calledWithCities
is a list that will store strings passed to theGetByCity
method during a test and used by another utility method,VerifyGetByCity
, to verify the expectations on the mock.
Tests with Mock
Using the MockWeatherService
, our test class looks like this:
public class TestWithMock
{
private readonly WeatherForecastController _sut;
private readonly MockWeatherService _weatherService;
private readonly string _cityWithData = "Rome";
public TestWithMock()
{
_weatherService = new MockWeatherService();
_sut = new WeatherForecastController(_weatherService, new DummyLogger<WeatherForecastController>());
}
[Fact]
public void Get_ReturnOk_When_Data_Exists()
{
List<WeatherForecast> expectedForecast = [
new WeatherForecast()
];
_weatherService.SetUpGetByCity(_cityWithData, expectedForecast);
IActionResult actual = _sut.Get(_cityWithData);
var okResult = Assert.IsType<OkObjectResult>(actual);
var forecasts = Assert.IsAssignableFrom<IEnumerable<WeatherForecast>>(okResult.Value);
Assert.Same(expectedForecast, forecasts);
_weatherService.VerifyGetByCity(_cityWithData);
}
[Fact]
public void Get_ReturnNoContent_When_Data_NotExists()
{
IActionResult actual = _sut.Get("Paris");
Assert.IsType<NoContentResult>(actual);
_weatherService.VerifyGetByCity("Paris");
}
}
The code is self-explaining but I want you to focus your attention to
List<WeatherForecast> expectedForecast = [
new WeatherForecast()
];
and this line
Assert.Same(expectedForecast, forecasts);
As you can see, we are initializing a non-empty list with an empty WeatherForecast
. Since its properties are not relevant for our tests, we only care that the instance returned by GetByCity
is the same one returned by the Controller’s Get
method without further data manipulation. This is where junior developers tend to overcomplicate things—keep it simple!
What’s Next?
That's all for a basic implementation of Test Doubles in scope for the series.
In the next article, we'll explore how Moq and AutoFixture can further simplify our test setup, making our tests both faster to write and easier to maintain.
Stay tuned!
Top comments (0)