DEV Community

Cesare De Sanctis
Cesare De Sanctis

Posted on

Test with Spy and Mock

In the previous article, we tested our WeatherForecastControllerusing 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>();
    }
}
Enter fullscreen mode Exit fullscreen mode

We have simply added two properties:

  • NumberOfCalls, to store the number of times GetByCity is called
  • LastRequestedCity, to store the last input passed to GetByCity

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>());
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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>();
    }
}
Enter fullscreen mode Exit fullscreen mode

We have defined two fields:

  • _expectedResponses is a dictionary used to set up the expected response returned by the GetByCity method for a given city. The SetUpGetByCity method will be used for the set up and is a utility method not part of the IWeatherService interface.
  • _calledWithCities is a list that will store strings passed to the GetByCity 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");
    }
}
Enter fullscreen mode Exit fullscreen mode

The code is self-explaining but I want you to focus your attention to

List<WeatherForecast> expectedForecast = [
            new WeatherForecast()
        ];
Enter fullscreen mode Exit fullscreen mode

and this line

Assert.Same(expectedForecast, forecasts);
Enter fullscreen mode Exit fullscreen mode

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)