Believe it or not, I spent 3 years of my career without knowing what testability really means, in fact, I never really heard that term until lately. I have been working on small-scale applications, writing tests for every feature I had implemented, but it took 3 years to realize that the tests I have been writing are integration tests, not unit tests. Let me show you how my code used to look like a few years back
Snippets in this blog post are written in C# but the concept can be applied to any OOP language. Also please note that I left null checks and error handling to keep things simple and focused.
public class WeatherApp {
private readonly WeatherAPIClient _weatherApiClient;
public WeatherApp(string apiKey)
{
_weatherApiClient = new WeatherAPIClient(apiKey);
}
public double GetAvgTempBetween(DateTime startDate, DateTime endDate)
{
var weatherDataResponse = _weatherApiClient.GetWeatherDataBetween(startDate, endDate);
var readings = weatherDataResponse["forecast"]["forecastday"];
var numOfReadings = readings.AsArray().Count;
double sumOfTemps = 0;
for(var i=0; i < numOfReadings; i++){
sumOfTemps += (double)readings[i]["day"]["avgtemp_c"];
}
return Math.Round(sumOfTemps/numOfReadings, 2);
}
}
Consider, the above WeatherApp
class, which is going to be our SUT (Subject Under Test), we are basically calculating the average temperature between two dates. Our SUT depends on the WeatherAPIClient
class to get weather data from an external API. WeatherAPIClient
just calls the API by sending the required parameters and returns the response. You can find the API client code below.
public class WeatherAPIClient
{
private readonly string _apiKey;
public WeatherAPIClient(string apiKey)
{
_apiKey = apiKey;
}
public JsonNode GetWeatherDataBetween(DateTime startDate, DateTime endDate)
{
var formattedStartDate = startDate.ToString("yyyy-MM-dd");
var formattedEndDate = endDate.ToString("yyyy-MM-dd");
var url = $"https://api.weatherapi.com/v1/history.json?key={_apiKey}&q=Hyderabad&dt={formattedStartDate}&end_dt={formattedEndDate}";
var requestMessage = new HttpRequestMessage(HttpMethod.Get, url);
var response = new HttpClient().Send(requestMessage);
using var reader = new StreamReader(response.Content.ReadAsStream());
return JsonNode.Parse(reader.ReadToEnd())!;
}
}
Please note that the city name is hardcoded to Hyderabad
( maybe I was feeling lazy to modify the code :p )
Now let me show you how I used to test code like above
public class TestWeatherApp
{
[Fact]
public void TestAverageTemperatureBetweenTwoDates(){
// Arrange
var startDate = DateTime.Parse("2022-05-02");
var endDate = DateTime.Parse("2022-05-03");
var weatherApp = new WeatherApp("some-api-key");
// Act
var result = weatherApp.GetAvgTempBetween(startDate, endDate);
//Assert
Assert.Equal(37, result);
}
}
Everything looks good, right? I wrote some code that works, and I am able to test the code I wrote, so does that mean my code is testable? Before answering that let's revisit the definition of unit test. I liked the following definition from the book Unit Testing Principles, Practices, and Patterns
A unit test is a test that
Verifies a single unit of behavior,
Does it quickly,
And does it in isolation from other tests.
A test that doesn’t meet at least one of these three requirements falls into the category of integration tests
Our test is verifying a single unit of behavior , there is no shared state between other tests so it is running in isolation from other tests, but what about the second characteristic? does our test run quickly?
The answer is, definitely a NO, as we discussed earlier, our SUT depends on WeatherAPIClient
which actually makes a network call to get weather data which makes our tests run really slow. Imagine you have so many tests which exercise the code that depends on network, databases, filesystems, caches...etc to get some data, your entire test suite takes several minutes to hours making your feedback loops longer, affecting the time that takes for you to develop, go live.
Code is said to be testable, If it does not cross system boundaries, i.e does not reach out to network, databases, filesystems, caches..etc during unit testing
Since our code is talking to the network, it is not testable. Now let's see where the problem is, what is making our code untestable? .
.
.
Did you spot it?
.
.
.
Alright, let me explain.
It is the new
keyword that is making our code untestable, to be precise it is the instantiation of WeatherAPIClient
in the constructor. The moment you do that, WeatherApp
becomes tightly coupled with WeatherAPIClient
. Let's fix it.
Refactoring towards testable code
Let's start by removing the instantiation inside the constructor, instead, let's take the WeatherAPIClient
instance as the argument to the constructor. It is called constructor injection.
public class WeatherApp
{
private readonly WeatherAPIClient _weatherApiClient;
public WeatherApp(WeatherAPIClient weatherAPIClient)
{
_weatherApiClient = weatherAPIClient;
}
public double GetAvgTempBetween(DateTime startDate, DateTime endDate)
{
// ... rest of the code
}
}
It is just a part of the solution, WeatherApp
is still tightly coupled with WeatherAPIClient
. We can make them loosely coupled by following Dependency Inversion Principle
Dependency Inversion Principle: High-level modules should not depend on low-level modules, both should depend on abstractions. Abstractions should not depend on details, details should depend upon abstractions
public interface IWeatherAPIClient
{
JsonNode GetWeatherDataBetween(DateTime startDate, DateTime endDate);
}
Let's make our WeatherAPIClient
implement this interface, and modify WeatherApp
class's constructor such that it depends on the interface rather than the concrete class
public class WeatherAPIClient: IWeatherAPIClient
{
private read-only string _apiKey;
public WeatherAPIClient(string apiKey)
{
_apiKey = apiKey;
}
public JsonNode GetWeatherDataBetween(DateTime startDate, DateTime endDate)
{
// ... rest of the code
}
}
public class WeatherApp
{
private readonly IWeatherAPIClient _weatherApiClient;
public WeatherApp(IWeatherAPIClient weatherAPIClient)
{
_weatherApiClient = weatherAPIClient;
}
public double GetAvgTempBetween(DateTime startDate, DateTime endDate)
{
// ... rest of the code
}
}
The advantage of depending on abstraction rather than concrete class is that you can swap out the implementation with some test doubles during unit testing. Let me show you what I mean
public class TestWeatherApp
{
[Fact]
public void TestAverageTemperatureBetweenTwoDates()
{
var stubApiClient = new Mock<IWeatherAPIClient>();
var startDate = DateTime.Parse("2022-05-02");
var endDate = DateTime.Parse("2022-05-02");
stubApiClient.Setup(apiClient => apiClient.GetWeatherDataBetween(startDate, endDate))
.Returns(JsonNode.Parse(@"{
""forecast"": {
""forecastday"": [
{
""day"": {
""avgtemp_c"": 34
}
},
{
""day"": {
""avgtemp_c"": 38
}
}
]}}"
)
);
var weatherapp = new WeatherApp(stubApiClient.Object);
var result = weatherapp.GetAvgTempBetween(startDate, endDate);
Assert.Equal(36, result);
}
}
}
In our unit test, instead of passing the actual instance of WeatherAPIClient
, we passed a stub that returns some canned response. Using stub, we avoided the problem of reaching out to the network to get the data during testing, which proves that we made our code testable.
Whenever I find some piece of code that is tightly coupled with infrastructure concerns like database, network, filesystem..etc, I usually separate the code that is taking with infrastructure from SUT into a separate class ( Single Responsibility Principle), inject it via constructor following dependency injection, dependency inversion principles to make it decoupled and testable.
Link to the source code: https://github.com/SriNandan33/weatherapp
If I learn more about testability, and object-oriented design, I will keep you updated here on this blog. Stay tuned. Thanks for reading, happy coding.