In this chapter on unit testing, I’m going to discuss Moq and how it can effectively be used to ensure you are unit testing correctly without having to concern yourself (too much) with dependencies further down the chain.

In this final (for now) chapter on unit testing, I’ll be showing you how to generate randomised data for our unit tests using GenFu as well as how we can use ChatGPT to write unit tests for us.

Sample Project

I’ve used the base ASP.NET Code Web API project to start things off and made a few modifications to it, all of which can be found in the repo below:

https://github.com/Wallyza/EasyTests/tree/v3.0

Refactoring the service

We’re going to do quite a bit of refactoring to the sample project this time around. The first thing we want to do is have a service that fetches the temperature and precipitation prediction for a specific date.

Let’s create a WeatherPrediction.cs for that first and foremost:

namespace EasyTests.Models;

public class WeatherPrediction
{
    public int TemperatureC { get; set; }
    public int TemperatureF => 32 + (int) (TemperatureC / 0.5556);
    public int Precipitation { get; set; }
    public DateTime Date { get; set; }
}

Simple enough, we have a Date and a corresponding Temperature and Precipitation (rain or chance thereof). Next, let’s move the forecast portion of WeatherForecastService to its own method:

public class WeatherForecastService : IWeatherForecastService
{
    private readonly IWeatherSummariesService _weatherSummariesService;

    public WeatherForecastService(IWeatherSummariesService weatherSummariesService)
    {
        _weatherSummariesService = weatherSummariesService;
    }
    
    public IEnumerable<WeatherSummary> GetWeatherForecast()
    {
        var weatherSummaries = _weatherSummariesService.GetWeatherSummaries();

        if (!weatherSummaries.Any())
            throw new IndexOutOfRangeException();
        
        return Enumerable.Range(1, 5).Select(index => new WeatherSummary
            {
                Date = DateTime.Now.AddDays(index),
                WeatherPrediction = GetWeatherPredictionForDate(DateTime.Now.AddDays(index)),
                Summary = weatherSummaries[Random.Shared.Next(weatherSummaries.Length)]
            })
            .ToArray();
    }

    private WeatherPrediction GetWeatherPredictionForDate(DateTime date)
    {
        return new WeatherPrediction
        {
            TemperatureC = Random.Shared.Next(-20, 55),
            Precipitation = Random.Shared.Next(0, 100),
            Date = date
        };
    }
}

Notice that I changed WeatherForecast to WeatherSummary and to include WeatherPrediction. The new WeatherSummary looks as follows:

public class WeatherSummary
{
    public DateTime Date { get; set; }
    public WeatherPrediction WeatherPrediction { get; set; }
    public string? Summary { get; set; }
}

We’re going to have to make some changes to our unit tests to get things back on track. If our application was already in use, we’d be breaking the contract right now and the fact that our unit tests are failing means we need to rethink our approach (perhaps a new version of the API).

Here’s our latest GetWeatherForecast_None_RespectLimits in WeatherForecastServiceTests.cs:

[Fact]
public void GetWeatherForecast_None_RespectLimits()
{
	// arrange
	
	// act
	var response = _testClass.GetWeatherForecast();
	// assert
	response.Max(x => x.WeatherPrediction.TemperatureC).Should().BeInRange(-20, 55);
}

So far, so good. Everything is passing again, things look a little bit different. We’re getting a random temperature with random precipitation. Now, let’s change the WeatherSummariesService to have a bit more logic to it. We want to have corresponding temperature and precipitation descriptions:

public interface IWeatherSummariesService
{
    string GetWeatherSummaryDescription(WeatherPrediction prediction);
}

public class WeatherSummariesService : IWeatherSummariesService
{
    public string GetWeatherSummaryDescription(WeatherPrediction prediction)
    {
        var temperatureDescription = prediction.TemperatureC switch
        {
            <= 0 => "Freezing",
            > 0 and <= 20 => "Cold",
            > 20 and <= 25 => "Warm",
            > 25 and <= 35 => "Hot",
            > 35 => "Extremely hot"
        };

        var precipitationDescription = prediction.Precipitation switch
        {
            < 0 => throw new IndexOutOfRangeException(),
            0 => "Dry",
            > 0 and <= 20 => "Misting",
            > 20 and <= 40 => "Drizzling",
            > 40 and <= 60 => "Raining",
            > 60 and <= 80 => "Storming",
            > 80 => "Risk of floods"
        };

        return $"{temperatureDescription} and {precipitationDescription}";
    }
}

By the way, if you haven’t seen these forms of switch statements yet, they were introduced in C# 8 and expanded on with the new conditionals in C# 9.

Next up we’ll change our GetWeatherForecast method to reflect these new changes:

    public IEnumerable<WeatherSummary> GetWeatherForecast()
    {
        var forecast = Enumerable.Range(1, 5).Select(index => new WeatherSummary
            {
                Date = DateTime.Now.AddDays(index),
                WeatherPrediction = GetWeatherPredictionForDate(DateTime.Now.AddDays(index))
            })
            .ToArray();

        foreach (var weatherSummary in forecast)
        {
            weatherSummary.Summary = _weatherSummariesService.GetWeatherSummaryDescription(weatherSummary.WeatherPrediction);
        }

        return forecast;
    }

Right now our unit tests won’t even build so we’re going to have to make a few changes and drop one test (as we’re no longer throwing an exception in this method).

Our updated WeatherForecastServiceTests will look like this:

public class WeatherForecastServiceTests
{
    private readonly Mock<IWeatherSummariesService> _weatherSummariesServiceMock = new Mock<IWeatherSummariesService>();
    private readonly WeatherForecastService _testClass;

    public WeatherForecastServiceTests()
    {
        _weatherSummariesServiceMock
            .Setup(x => x.GetWeatherSummaryDescription(It.IsAny<WeatherPrediction>()))
            .Returns("A string describing the weather")
            .Verifiable();
        _testClass = new WeatherForecastService(_weatherSummariesServiceMock.Object);
    }
    
    [Fact]
    public void GetWeatherForecast_None_Returns5()
    {
        // arrange
        
        // act
        var response = _testClass.GetWeatherForecast();
        // assert
        response.Should().HaveCount(5);
    }

    [Fact]
    public void GetWeatherForecast_None_NotNull()
    {
        // arrange
        
        // act
        var response = _testClass.GetWeatherForecast();
        // assert
        response.Should().NotBeNull();
    }
    
    [Fact]
    public void GetWeatherForecast_None_RespectLimits()
    {
        // arrange
        
        // act
        var response = _testClass.GetWeatherForecast();
        // assert
        response.Max(x => x.WeatherPrediction.TemperatureC).Should().BeInRange(-20, 55);
    }
    
    [Fact]
    public void GetWeatherForecast_None_GetSummariesCalledOnce()
    {
        // arrange
        
        // act
        var response = _testClass.GetWeatherForecast();
        
        // assert
        _weatherSummariesServiceMock.Verify(x => x.GetWeatherSummaryDescription(It.IsAny<WeatherPrediction>()), Times.Exactly(5));
    }
}

So far, so good! I’ve changed the setup a bit and added a funky new bit of a parameter there It.IsAny<int>() which tells Moq to be somewhat parameter-aware without worrying about the exact value. This way we can create setups for specific parameters or general setups like we have there.

Adding tests to our updated class

So far we’ve refactored things quite a bit and updated our tests to reflect those changes. If our application was in production we’d have broken a contract or two, to say the least, but fortunately for us, this service is not quite production-ready yet and we can pretend these were last-minute changes brought in by scope creep.

We did extensively change WeatherSummariesService though and it’s time we added some tests for it. First, we create a new class in our EasyTests.Tests project, and we creatively call it WeatherSummariesServiceTests. Let’s add the basic test class and some basic tests:

public class WeatherSummariesServiceTests
{
    private readonly WeatherSummariesService _testClass = new();

    [Fact]
    public void GetWeatherSummaryDescription_Valid_ReturnsString()
    {
        // arrange
        var prediction = new WeatherPrediction
        {
            TemperatureC = 1,
            Precipitation = 2
        };
        
        // act
        var response = _testClass.GetWeatherSummaryDescription(prediction);
        
        // assert
        response.Should().BeOfType<string>();
    }
    
    [Fact]
    public void GetWeatherSummaryDescription_InvalidPrecipitation_ThrowsException()
    {
        // arrange
        var prediction = new WeatherPrediction
        {
            TemperatureC = 1,
            Precipitation = -10
        };
        
        // act
        var act = () => _testClass.GetWeatherSummaryDescription(prediction);
        
        // assert
        act.Should().Throw<IndexOutOfRangeException>();
    }
    
    [Fact]
    public void GetWeatherSummaryDescription_Valid_ReturnsSpecificString()
    {
        // arrange
        var prediction = new WeatherPrediction
        {
            TemperatureC = 1,
            Precipitation = 2
        };
        
        // act
        var response = _testClass.GetWeatherSummaryDescription(prediction);
        
        // assert
        response.Should().Be("Cold and Misting");
    }
}

Considering WeatherSummariesService has no dependencies, we don’t have to worry about mocking anything up. It is however a pain to manually instantiate WeatherPrediction every time. Imagine if it was more complex and you had to scope out random data for each test. You could refactor it but even then it’s a hassle.

GenFu

This is where GenFu comes in. You can find more info on GenFu and it’s creators here: https://github.com/MisterJames/GenFu but let me demonstrate the gist of it by adding it to our Tests project and using it to sculpt out some randomised (but controlled data).

The simplest example of GenFu would be replacing

        var prediction = new WeatherPrediction
        {
            TemperatureC = 1,
            Precipitation = 2
        };

with

        var prediction = A.New<WeatherPrediction>();

GenFu will check the types within the model and create randomised values for those properties. The problem is that in our case we have the potential to hit that IndexOutOfRangeException if the precipitation is less than 0. This is where we can configure GenFu to be more specific.

Let’s update our first test to look like the below:

    [Fact]
    public void GetWeatherSummaryDescription_Valid_ReturnsString()
    {
        // arrange
        GenFu.GenFu.Configure<WeatherPrediction>()
            .Fill(x => x.TemperatureC)
            .WithinRange(-20, 55)
            .Fill(x => x.Precipitation)
            .WithinRange(0, 100);
        
        var prediction = A.New<WeatherPrediction>();
        
        // act
        var response = _testClass.GetWeatherSummaryDescription(prediction);
        
        // assert
        response.Should().BeOfType<string>();
    }

We’re now giving GenFu constraints to work with. In this example it seems like much of muchness, I won’t pretend otherwise. But if we needed a list of data we could change A.New<WeatherPrediction>() to A.ListOf<WeatherPrediction>() which would generate a list of test data for us, within the constraints specified.

There’s more to GenFu than meets the eye and I find it very valuable when trying to create simple and quick tests. If your models change, you don’t have to be concerned with updating every new field in your test data either. However, it should be noted that this is really only effective when you’re being agnostic of validation in your testing. If your data matters, then GenFu will only be useful if you configure it correctly.

Conclusion and bonus

Unit testing can be arduous but when working with a larger team they become invaluable. If you want to, you can write your tests beforehand, as you’ll often see in coding challenges, and move towards test-driven-development. Keep coding until your tests pass without changing the tests!

I played it a bit fast and loose in updating our tests to accommodate code changes. In real-world scenarios your tests failing should indicate your code requiring changes, not your tests, though it’s obviously not as cut-and-dry as that.

The bonus

Let’s try something quick, I logged into https://chat.openai.com/ and prompted it to write xunit tests for the GetWeatherSummaryDescription method (I copied the entire method into the input).

This is what ChatGPT responded with:

using Xunit;

public class WeatherPredictionTests
{
    [Theory]
    [InlineData(-5, -1, "Freezing and Dry")]
    [InlineData(10, 0, "Cold and Dry")]
    [InlineData(25, 30, "Warm and Drizzling")]
    [InlineData(30, 70, "Hot and Storming")]
    [InlineData(40, 90, "Extremely hot and Risk of floods")]
    public void GetWeatherSummaryDescription_ReturnsCorrectDescription(
        double temperatureC, double precipitation, string expectedDescription)
    {
        // Arrange
        var prediction = new WeatherPrediction
        {
            TemperatureC = temperatureC,
            Precipitation = precipitation
        };
        var weatherSummary = new WeatherSummary();

        // Act
        string result = weatherSummary.GetWeatherSummaryDescription(prediction);

        // Assert
        Assert.Equal(expectedDescription, result);
    }

    [Theory]
    [InlineData(-10, -1)]
    [InlineData(15, -5)]
    public void GetWeatherSummaryDescription_ThrowsExceptionForInvalidPrecipitation(
        double temperatureC, double precipitation)
    {
        // Arrange
        var prediction = new WeatherPrediction
        {
            TemperatureC = temperatureC,
            Precipitation = precipitation
        };
        var weatherSummary = new WeatherSummary();

        // Act & Assert
        Assert.Throws<IndexOutOfRangeException>(() => weatherSummary.GetWeatherSummaryDescription(prediction));
    }
}

Clearly, it doesn’t have all the context required to know that instead of double we’re looking at int but it’s not far off from what we need. Given more context and perhaps a bit more prompting it can quickly help you get a great start in your testing suite.

I further prompted it to use FluentAssertions and it banged those out. I then told it that temperature and precipitation are of type int and it responded with the below:

using FluentAssertions;
using Xunit;

public class WeatherPredictionTests
{
    [Theory]
    [InlineData(-5, -1, "Freezing and Dry")]
    [InlineData(10, 0, "Cold and Dry")]
    [InlineData(25, 30, "Warm and Drizzling")]
    [InlineData(30, 70, "Hot and Storming")]
    [InlineData(40, 90, "Extremely hot and Risk of floods")]
    public void GetWeatherSummaryDescription_ReturnsCorrectDescription(
        int temperatureC, int precipitation, string expectedDescription)
    {
        // Arrange
        var prediction = new WeatherPrediction
        {
            TemperatureC = temperatureC,
            Precipitation = precipitation
        };
        var weatherSummary = new WeatherSummary();

        // Act
        string result = weatherSummary.GetWeatherSummaryDescription(prediction);

        // Assert
        result.Should().Be(expectedDescription);
    }

    [Theory]
    [InlineData(-10, -1)]
    [InlineData(15, -5)]
    public void GetWeatherSummaryDescription_ThrowsExceptionForInvalidPrecipitation(
        int temperatureC, int precipitation)
    {
        // Arrange
        var prediction = new WeatherPrediction
        {
            TemperatureC = temperatureC,
            Precipitation = precipitation
        };
        var weatherSummary = new WeatherSummary();

        // Act & Assert
        weatherSummary.Invoking(x => x.GetWeatherSummaryDescription(prediction))
            .Should().Throw<IndexOutOfRangeException>();
    }
}

It’s not spot on with failings like var weatherSummary = new WeatherSummary(); and [InlineData(-5, -1, "Freezing and Dry")] would actually throw an exception. But all-in-all it can save you a lot of effort in getting things kick-started. If you have access to GitHub Co-Pilot or Rider’s AI assistant, you can take it even further by giving it more context.

I hope you’ve learned something while reading through these past few posts of mine, I’ve certainly enjoyed writing them.