Expanding on Unit Tests with GenFu and ChatGPT
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.