We made some substantial progress on the testability of our code during the last article when we removed our dependency on concrete types.
This unlocked some new test cases, however, we were still blocked by a few lingering issues.
While the concrete dependencies of our classes have been removed from their constructors, would you believe that we are still battling against the drawbacks of concrete dependencies in the code?
During today’s article we will focus on testing code with static dependencies; exploring how common short term shortcuts can have long term consequences for the testability of a solution. A world of pain can be avoided with just a couple of extra steps.
Remember to check out the Code Sloth Code Samples page for GitHub links to the repository of sample code!
Today’s article contains assumed knowledge about programming concepts such as classes, interfaces, polymorphism and mocking. Getting back into .Net? Check out our post on getting started with .Net 6 here to resume your .Net journey!
Not all Dependencies are Injected
Most dependencies that you require will be injected into a class via a constructor or through public property assignment. The end result of this design is that you have structured visibility of how your dependencies get into your class. This design is called the explicit dependencies principle.
This is great because it gives us a starting point to track down dependencies that need to be abstracted and we can easily use the find references
feature of Visual Studio to help us modify them as required.
Fun fact – did you know that you can filter references by their kind
in Visual studio? read
and write
filtering can speed up your navigation exponentially when dealing with heavily used variables!
Unfortunately however, there is another way that you can find yourself depending on concrete implementations, which is much less structured to identify…
Static Code: the Not-So-Short Shortcut
Static code has a time and a place. Unfortunately however, the time that it is usually introduced into a codebase is when a software engineer is feeling too lazy to write an interface and inject it into their consuming class.
Perhaps this is because adding another dependency into the constructor would introduce a code smell that the class is becoming bloated with responsibility and requires some refactoring and consolidation. Or maybe it is because they are working to a deadline and those extra few seconds of work would slow them down too much; we know that this common excuse guarantees that the thought of writing unit tests would have long since been tossed out the window.
If you’re consuming static code, it is likely that there was no objective reason for it to be there in the first place. This is because static code introduces the same problems as programming against concrete types and requires careful consideration before being written. Specifically problem 3 from our last article regarding overlapping test cases.
Let’s take a look at the different ways that static code can find itself in your codebase.
Static Shortcut 1: Self Imposed
The most obvious form of static code dependency is that which is self imposed.
In our solution, this takes the shape of the StaticCoordinateCalculator
using FixingConcreteDependencies.Database; namespace FixingConcreteDependencies.BusinessLogic { /// <summary> /// This simple class does not have any dependencies, it just takes inputs to perform a calculation /// It has been defined as a static class, because someone thought it would be easier than having to inject it into another class through /// dependency injection /// Refactor candidate: consolidation with <seealso cref="ThrustCalculator"/> /// </summary> public static class StaticCoordinateCalculator { private record CoordinateInputs(int numberOfSloths, string foodForJourney); private static (int latitude, int longitude) CalculateCoordinates(CoordinateInputs inputs) => inputs switch { { numberOfSloths: < 10, foodForJourney: "only slightly toxic leaves" } => (123, 456), { numberOfSloths: < 10, foodForJourney: "sushi" } => (456, 789), { numberOfSloths: > 10, foodForJourney: "sushi" } => (111, 222), { numberOfSloths: > 10, foodForJourney: "only slightly toxic leaves" } => (444, 444), { numberOfSloths: < 10 } => (000, 111), { numberOfSloths: > 10 } => (111, 000) }; /// <summary> /// We need to calculate the best place to land on the moon to accommodate the given number of sloths and their allocated cuisine /// </summary> public static (int latitude, int longitude) CalculateCoordinatesToLand(int numberOfSloths, FoodForJourney foodForJourney) { var coordinates = new CoordinateInputs(numberOfSloths, foodForJourney.Foods.First()); return CalculateCoordinates(coordinates); } } }
This class is used during our call to TryToLaunchARocket
in the RocketLaunchingLogic
class.
public async Task TryToLaunchARocket() { var rocketLaunchMessage = await rocketQueuePoller.PollForRocketNeedingLaunch(); if (rocketLaunchMessage == null) { await Task.Delay(TimeSpan.FromSeconds(5)); return; } try { var databaseResult = await rocketDatabaseRetriever.FindRocketStatistics(rocketLaunchMessage.RocketModelId); var requiredThrust = thrustCalculator.CalculateThrust( databaseResult.ThrustValue1, databaseResult.ThrustValue2, databaseResult.ThrustValue3, databaseResult.ThrustValue4, databaseResult.ThrustValue5, rocketLaunchMessage.numberOfSlothsToLaunch ); var foodForJourney = await rocketDatabaseRetriever.GetFoodToFeedSlothsOnTheirJourney(rocketLaunchMessage.RocketModelId, rocketLaunchMessage.numberOfSlothsToLaunch); if (foodForJourney == null) { if (rocketLaunchMessage.numberOfSlothsToLaunch < 10) { foodForJourney = new FoodForJourney { NumberOfCourses = 1, Foods = new[] { "only slightly toxic leaves" } }; } else { throw new Exception("We can't cater a trip for 10 or more sloths without carefully planned food for space travel!"); } } var coordinatesToLandOnMoon = StaticCoordinateCalculator.CalculateCoordinatesToLand(rocketLaunchMessage.numberOfSlothsToLaunch, foodForJourney); var rocketLaunchResult = await rocketLaunchingService.LaunchRocket(rocketLaunchMessage.RocketModelId, rocketLaunchMessage.numberOfSlothsToLaunch, requiredThrust, foodForJourney); if (rocketLaunchResult.launchWasSuccessful) { await rocketQueuePoller.RemoveMessageFromQueue(rocketLaunchMessage.messageId); } else { throw new Exception($"Failed to launch rocket {rocketLaunchMessage.RocketModelId} with {rocketLaunchMessage.numberOfSlothsToLaunch} on board. Request id: {rocketLaunchMessage.messageId}. Retrying shortly."); } } catch (Exception ex) { logger.LogError($"Error caught while processing message {JsonSerializer.Serialize(rocketLaunchMessage)}. Error: {ex.Message} "); } }
Having trouble tracking it down? Let’s zoom in so you can take a better look
var coordinatesToLandOnMoon = StaticCoordinateCalculator.CalculateCoordinatesToLand(rocketLaunchMessage.numberOfSlothsToLaunch, foodForJourney);
This is the problem with static dependencies; visibility. They can creep into your solution and be very difficult to track down.
You’ll likely not notice their existence until you begin writing unit tests for the impacted class. It is at this point that you will quickly come to realise that tests weren’t accidentally forgotten when the class was authored. Rather, the original author likely abandoned all hope of writing tests because they didn’t understand the complexity of overlapping test cases that they introduced into the codebase when they typed the magic word static
.
Static Solution 1: Dependency Inversion and Injection
Luckily for us we have already covered the solution to this type of problem in the last article by using the vertical lift and shift to introduce a new strategy pattern.
Performing these steps will prune the dependency tree of your static class and remove the overlapping test cases from your suite. It will simplify your ability to write tests in the consuming class, by allow you to mock the outputs of your once-static class!
If you had already written unit tests for the static class, they will only need to be updated to instantiate a new object of the class before calling the method under test. It may even be possible to make this adjustment using find/replace or a regex.
Let’s take a look at the refactored class:
using FixingConcreteDependencies.Database; namespace FixingConcreteDependencies.BusinessLogic { /// <summary> /// A calcualtor of landing coordinates /// </summary> public class CoordinateCalculator : ICoordinateCalculator { private record CoordinateInputs(int numberOfSloths, string foodForJourney); private (int latitude, int longitude) CalculateCoordinates(CoordinateInputs inputs) => inputs switch { { numberOfSloths: < 10, foodForJourney: "only slightly toxic leaves" } => (123, 456), { numberOfSloths: < 10, foodForJourney: "sushi" } => (456, 789), { numberOfSloths: > 10, foodForJourney: "sushi" } => (111, 222), { numberOfSloths: > 10, foodForJourney: "only slightly toxic leaves" } => (444, 444), { numberOfSloths: < 10 } => (000, 111), { numberOfSloths: > 10 } => (111, 000) }; /// <summary> /// We need to calculate the best place to land on the moon to accommodate the given number of sloths and their allocated cuisine /// </summary> public (int latitude, int longitude) CalculateCoordinatesToLand(int numberOfSloths, FoodForJourney foodForJourney) { var coordinates = new CoordinateInputs(numberOfSloths, foodForJourney.Foods.First()); return CalculateCoordinates(coordinates); } } }
For this class we simply:
- Renamed the class to
CoordinateCalculator
- Removed all references to the word
static
from the class, including the switch expression and method - Used Visual Studio’s extract interface feature to make an interface from the existing class, which also implemented the interface on the class itself for us
Let’s take a look at the extracted interface
using FixingConcreteDependencies.Database; namespace FixingConcreteDependencies.BusinessLogic { public interface ICoordinateCalculator { (int latitude, int longitude) CalculateCoordinatesToLand(int numberOfSloths, FoodForJourney foodForJourney); } }
How simple is that?
Finally let’s see how this can be used in the RocketLaunchingLogic
class:
using FixingConcreteDependencies.Database; using FixingConcreteDependencies.Infrastructure; using FixingConcreteDependencies.WebApi; using Microsoft.Extensions.Logging; using System.Text.Json; namespace FixingConcreteDependencies.BusinessLogic { public class RocketLaunchingLogic : IRocketLaunchingLogic { private IThrustCalculator thrustCalculator; private IRocketDatabaseRetriever rocketDatabaseRetriever; private IRocketQueuePoller rocketQueuePoller; private IRocketLaunchingService rocketLaunchingService; private ILogger<RocketLaunchingLogic> logger; private ICoordinateCalculator coordinateCalculator; public RocketLaunchingLogic(IThrustCalculator thrustCalculator, IRocketDatabaseRetriever rocketDatabaseRetriever, IRocketQueuePoller rocketQueuePoller, IRocketLaunchingService rocketLaunchingService, ILogger<RocketLaunchingLogic> logger, ICoordinateCalculator coordinateCalculator) { this.thrustCalculator = thrustCalculator ?? throw new ArgumentNullException(nameof(thrustCalculator)); this.rocketDatabaseRetriever = rocketDatabaseRetriever ?? throw new ArgumentNullException(nameof(rocketDatabaseRetriever)); this.rocketQueuePoller = rocketQueuePoller ?? throw new ArgumentNullException(nameof(rocketQueuePoller)); this.rocketLaunchingService = rocketLaunchingService ?? throw new ArgumentNullException(nameof(rocketLaunchingService)); this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); this.coordinateCalculator = coordinateCalculator ?? throw new ArgumentNullException(nameof(coordinateCalculator)); } public async Task TryToLaunchARocket() { var rocketLaunchMessage = await rocketQueuePoller.PollForRocketNeedingLaunch(); if (rocketLaunchMessage == null) { await Task.Delay(TimeSpan.FromSeconds(5)); return; } try { var databaseResult = await rocketDatabaseRetriever.FindRocketStatistics(rocketLaunchMessage.RocketModelId); var requiredThrust = thrustCalculator.CalculateThrust( databaseResult.ThrustValue1, databaseResult.ThrustValue2, databaseResult.ThrustValue3, databaseResult.ThrustValue4, databaseResult.ThrustValue5, rocketLaunchMessage.numberOfSlothsToLaunch ); var foodForJourney = await rocketDatabaseRetriever.GetFoodToFeedSlothsOnTheirJourney(rocketLaunchMessage.RocketModelId, rocketLaunchMessage.numberOfSlothsToLaunch); if (foodForJourney == null) { if (rocketLaunchMessage.numberOfSlothsToLaunch < 10) { foodForJourney = new FoodForJourney { NumberOfCourses = 1, Foods = new[] { "only slightly toxic leaves" } }; } else { throw new Exception("We can't cater a trip for 10 or more sloths without carefully planned food for space travel!"); } } var coordinatesToLandOnMoon = coordinateCalculator.CalculateCoordinatesToLand(rocketLaunchMessage.numberOfSlothsToLaunch, foodForJourney); var rocketLaunchResult = await rocketLaunchingService.LaunchRocket(rocketLaunchMessage.RocketModelId, rocketLaunchMessage.numberOfSlothsToLaunch, requiredThrust, foodForJourney); if (rocketLaunchResult.launchWasSuccessful) { await rocketQueuePoller.RemoveMessageFromQueue(rocketLaunchMessage.messageId); } else { throw new Exception($"Failed to launch rocket {rocketLaunchMessage.RocketModelId} with {rocketLaunchMessage.numberOfSlothsToLaunch} on board. Request id: {rocketLaunchMessage.messageId}. Retrying shortly."); } } catch (Exception ex) { logger.LogError($"Error caught while processing message {JsonSerializer.Serialize(rocketLaunchMessage)}. Error: {ex.Message} "); } } } }
Let’s review the changes:
- Firstly we added the
ICoordinateCalculator
member variable - We then used Visual Studio’s generate constructor feature to make us a new constructor that included our new interface
- This feature makes refactoring a breeze, so long as your constructor does not contain any logic (other than null checks)!
- We then swapped our static reference over to the member variable
var coordinatesToLandOnMoon = coordinateCalculator.CalculateCoordinatesToLand(rocketLaunchMessage.numberOfSlothsToLaunch, foodForJourney);
Static Shortcut 2: Concrete Time Providers Part 1
You might have been programming against concrete time providers for years and not even know it!
The ThrustCalculator
is where our first concrete time provider issue can be found:
namespace FixingConcreteDependencies.BusinessLogic { /// <summary> /// This simple class does not have any dependencies, it just takes inputs to perform a calculation /// Refactor candidate: consolidation with <seealso cref="StaticCoordinateCalculator"/> /// </summary> public class ThrustCalculator : IThrustCalculator { /// <summary> /// Calculate the thrust that is required to get the given number of sloths to the moon for the chosen rocket /// After lunch time, we need a little extra thrust to get through the night sky /// </summary> public int CalculateThrust(int input1, int input2, int input3, int input4, int input5, int numberOfSloths) { var result = (input1 + input2 + input3 + input4 + input5) * numberOfSloths; if (DateTime.Now.Hour <= 12) { return result; } else { return result + 10; } } } }
Calling DateTime.Now
can be catastrophic for your code’s testability. This is because it removes determinism from your test cases. At one time of the day the function returns result
but at another time of the day it returns result + 10
!
If an engineer authored passing unit tests before midday, the problem wouldn’t be visible. However after midday those same tests would start to fail, because the output of the function would change. This is how flaky tests
are born.
It may take some time for the test suit to be run after midday and for the problem to arise; perhaps days or even weeks. It is likely then that no one remembers having written this code in the first place.
This means an engineer will need to spend time debugging what is going wrong. Then they will need to spend additional time to not only rewrite the code to re-introduce determinism, but they will then need to author new test cases to cover all of the required logic!
On top of this problem, we have also introduced an even more subtle issue into the codebase.
Programming against DateTime.Now
produces a date and time that are defined by the local
time zone. This poses a risk that the code behaves in an unexpected way if the application is deployed into an environment that has a different time zone to the machine that the code was engineered on.
This isn’t even something that can be unit tested to mitigate, because the unit tests will use the same implied local time zone as the code that they are testing. Programming explicitly against UTC as much as possible will ensure that you have stability in your application’s understanding of time regardless of the environment in which it is running.
Static Solution 2: ISystemClock.UtcNow
Let’s look at the refactored code below:
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication; namespace FixingConcreteDependencies.BusinessLogic { /// <summary> /// This simple class does not have any dependencies, it just takes inputs to perform a calculation /// Refactor candidate: consolidation with <seealso cref="CoordinateCalculator"/> /// </summary> public class ThrustCalculator : IThrustCalculator { private ISystemClock systemClock; public ThrustCalculator(ISystemClock systemClock) { this.systemClock = systemClock ?? throw new ArgumentNullException(nameof(systemClock)); } /// <summary> /// Calculate the thrust that is required to get the given number of sloths to the moon for the chosen rocket /// After lunch time, we need a little extra thrust to get through the night sky /// </summary> public int CalculateThrust(int input1, int input2, int input3, int input4, int input5, int numberOfSloths) { var result = (input1 + input2 + input3 + input4 + input5) * numberOfSloths; // We have converted from a dependency on local time to a dependency on standardised UTC time if (systemClock.UtcNow.Hour <= 2) { return result; } else { return result + 10; } } } }
The changes that we made were:
- Inject an
ISystemClock
into the class and replace our usage of the staticDateTime.Now
method. This restores determinism to our code by allowing us to mock the current time during testing - We have migrated to use
UtcNow
. This is the only method on the ISystemClock interface and forces us into best practice.- This required us to change the hour that we are programming against. Did you even realise that the original code assumed that it was running in local Sydney time? How could you! There’s no way to tell what the implied time zone was!
Static Shortcut 3: Concrete Time Providers Part 2
The final concrete dependency that will stop you from testing your code is delay
s.
Our RocketLaunchingLogic
contains an example of this:
using FixingConcreteDependencies.Database; using FixingConcreteDependencies.Infrastructure; using FixingConcreteDependencies.WebApi; using Microsoft.Extensions.Logging; using System.Text.Json; namespace FixingConcreteDependencies.BusinessLogic { public class RocketLaunchingLogic : IRocketLaunchingLogic { private IThrustCalculator thrustCalculator; private IRocketDatabaseRetriever rocketDatabaseRetriever; private IRocketQueuePoller rocketQueuePoller; private IRocketLaunchingService rocketLaunchingService; private ILogger<RocketLaunchingLogic> logger; private ICoordinateCalculator coordinateCalculator; public RocketLaunchingLogic(IThrustCalculator thrustCalculator, IRocketDatabaseRetriever rocketDatabaseRetriever, IRocketQueuePoller rocketQueuePoller, IRocketLaunchingService rocketLaunchingService, ILogger<RocketLaunchingLogic> logger, ICoordinateCalculator coordinateCalculator) { this.thrustCalculator = thrustCalculator ?? throw new ArgumentNullException(nameof(thrustCalculator)); this.rocketDatabaseRetriever = rocketDatabaseRetriever ?? throw new ArgumentNullException(nameof(rocketDatabaseRetriever)); this.rocketQueuePoller = rocketQueuePoller ?? throw new ArgumentNullException(nameof(rocketQueuePoller)); this.rocketLaunchingService = rocketLaunchingService ?? throw new ArgumentNullException(nameof(rocketLaunchingService)); this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); this.coordinateCalculator = coordinateCalculator ?? throw new ArgumentNullException(nameof(coordinateCalculator)); } public async Task TryToLaunchARocket() { var rocketLaunchMessage = await rocketQueuePoller.PollForRocketNeedingLaunch(); if (rocketLaunchMessage == null) { await Task.Delay(TimeSpan.FromSeconds(5)); return; } try { var databaseResult = await rocketDatabaseRetriever.FindRocketStatistics(rocketLaunchMessage.RocketModelId); var requiredThrust = thrustCalculator.CalculateThrust( databaseResult.ThrustValue1, databaseResult.ThrustValue2, databaseResult.ThrustValue3, databaseResult.ThrustValue4, databaseResult.ThrustValue5, rocketLaunchMessage.numberOfSlothsToLaunch ); var foodForJourney = await rocketDatabaseRetriever.GetFoodToFeedSlothsOnTheirJourney(rocketLaunchMessage.RocketModelId, rocketLaunchMessage.numberOfSlothsToLaunch); if (foodForJourney == null) { if (rocketLaunchMessage.numberOfSlothsToLaunch < 10) { foodForJourney = new FoodForJourney { NumberOfCourses = 1, Foods = new[] { "only slightly toxic leaves" } }; } else { throw new Exception("We can't cater a trip for 10 or more sloths without carefully planned food for space travel!"); } } var coordinatesToLandOnMoon = coordinateCalculator.CalculateCoordinatesToLand(rocketLaunchMessage.numberOfSlothsToLaunch, foodForJourney); var rocketLaunchResult = await rocketLaunchingService.LaunchRocket(rocketLaunchMessage.RocketModelId, rocketLaunchMessage.numberOfSlothsToLaunch, requiredThrust, foodForJourney); if (rocketLaunchResult.launchWasSuccessful) { await rocketQueuePoller.RemoveMessageFromQueue(rocketLaunchMessage.messageId); } else { throw new Exception($"Failed to launch rocket {rocketLaunchMessage.RocketModelId} with {rocketLaunchMessage.numberOfSlothsToLaunch} on board. Request id: {rocketLaunchMessage.messageId}. Retrying shortly."); } } catch (Exception ex) { logger.LogError($"Error caught while processing message {JsonSerializer.Serialize(rocketLaunchMessage)}. Error: {ex.Message} "); } } } }
The culprit line of code is
await Task.Delay(TimeSpan.FromSeconds(5));
If we write unit tests for this code, each test case that explores a failure to poll messages from the queue will literally have to wait 5 seconds before it can commence. This is a huge problem, given that unit tests are by nature supposed to be fast to run.
Static Solution 3: Levelled Up Lift and Shift Round 2
Unfortunately, unlike the ISystemClock
there is no readily available interface for asynchronous delays. This means that we will need to craft our own… By hand.
That’s right Rupaul. It is shocking! This is one of the few times that we will not be able to leverage Visual Studio’s refactoring tools to help us do our work.
A wise Code Sloth will just copy past the code from below, though ?.
The interface:
namespace FixingStaticCode.Infrastructure { public interface IAsyncDelay { Task DelayAsync(TimeSpan duration); } }
Similarly to the signature of Task.Delay, this method takes a TimeSpan parameter. These are incredibly flexible structures that can allow a consumer to express their duration in whichever form they would like (seconds, minutes etc).
The implementation:
namespace FixingStaticCode.Infrastructure { public class AsyncDelay : IAsyncDelay { public async Task DelayAsync(TimeSpan duration) { await Task.Delay(duration); } } }
How basic is that?
This simple interface provides us with the ability to completely circumvent the real delay during unit testing.
Let’s take a look at how we consume it in RocketLaunchingLogic
using FixingConcreteDependencies.Database; using FixingConcreteDependencies.Infrastructure; using FixingConcreteDependencies.WebApi; using FixingStaticCode.Infrastructure; using Microsoft.Extensions.Logging; using System.Text.Json; namespace FixingConcreteDependencies.BusinessLogic { public class RocketLaunchingLogic : IRocketLaunchingLogic { private IThrustCalculator thrustCalculator; private IRocketDatabaseRetriever rocketDatabaseRetriever; private IRocketQueuePoller rocketQueuePoller; private IRocketLaunchingService rocketLaunchingService; private ILogger<RocketLaunchingLogic> logger; private ICoordinateCalculator coordinateCalculator; private IAsyncDelay asyncDelay; public RocketLaunchingLogic(IThrustCalculator thrustCalculator, IRocketDatabaseRetriever rocketDatabaseRetriever, IRocketQueuePoller rocketQueuePoller, IRocketLaunchingService rocketLaunchingService, ILogger<RocketLaunchingLogic> logger, ICoordinateCalculator coordinateCalculator, IAsyncDelay asyncDelay) { this.thrustCalculator = thrustCalculator ?? throw new ArgumentNullException(nameof(thrustCalculator)); this.rocketDatabaseRetriever = rocketDatabaseRetriever ?? throw new ArgumentNullException(nameof(rocketDatabaseRetriever)); this.rocketQueuePoller = rocketQueuePoller ?? throw new ArgumentNullException(nameof(rocketQueuePoller)); this.rocketLaunchingService = rocketLaunchingService ?? throw new ArgumentNullException(nameof(rocketLaunchingService)); this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); this.coordinateCalculator = coordinateCalculator ?? throw new ArgumentNullException(nameof(coordinateCalculator)); this.asyncDelay = asyncDelay ?? throw new ArgumentNullException(nameof(asyncDelay)); } public async Task TryToLaunchARocket() { var rocketLaunchMessage = await rocketQueuePoller.PollForRocketNeedingLaunch(); if (rocketLaunchMessage == null) { await asyncDelay.DelayAsync(TimeSpan.FromSeconds(5)); return; } try { var databaseResult = await rocketDatabaseRetriever.FindRocketStatistics(rocketLaunchMessage.RocketModelId); var requiredThrust = thrustCalculator.CalculateThrust( databaseResult.ThrustValue1, databaseResult.ThrustValue2, databaseResult.ThrustValue3, databaseResult.ThrustValue4, databaseResult.ThrustValue5, rocketLaunchMessage.numberOfSlothsToLaunch ); var foodForJourney = await rocketDatabaseRetriever.GetFoodToFeedSlothsOnTheirJourney(rocketLaunchMessage.RocketModelId, rocketLaunchMessage.numberOfSlothsToLaunch); if (foodForJourney == null) { if (rocketLaunchMessage.numberOfSlothsToLaunch < 10) { foodForJourney = new FoodForJourney { NumberOfCourses = 1, Foods = new[] { "only slightly toxic leaves" } }; } else { throw new Exception("We can't cater a trip for 10 or more sloths without carefully planned food for space travel!"); } } var coordinatesToLandOnMoon = coordinateCalculator.CalculateCoordinatesToLand(rocketLaunchMessage.numberOfSlothsToLaunch, foodForJourney); var rocketLaunchResult = await rocketLaunchingService.LaunchRocket(rocketLaunchMessage.RocketModelId, rocketLaunchMessage.numberOfSlothsToLaunch, requiredThrust, foodForJourney); if (rocketLaunchResult.launchWasSuccessful) { await rocketQueuePoller.RemoveMessageFromQueue(rocketLaunchMessage.messageId); } else { throw new Exception($"Failed to launch rocket {rocketLaunchMessage.RocketModelId} with {rocketLaunchMessage.numberOfSlothsToLaunch} on board. Request id: {rocketLaunchMessage.messageId}. Retrying shortly."); } } catch (Exception ex) { logger.LogError($"Error caught while processing message {JsonSerializer.Serialize(rocketLaunchMessage)}. Error: {ex.Message} "); } } } }
Let’s review the changes:
- Added
IAsyncDelay
as a member variable and injected it via the constructor- This was done using Visual Studio’s generate constructor feature after deleting the old constructor
- Replaced the call to
Task.Delay
with our new member variableasyncDelay.DelayAsync(...)
and passed through the existing 5 second value
Testing Thrust
Now that we have time on our side (lol) we can go back to testing our ThrustCalculator
using FixingStaticCode.BusinessLogic; using FluentAssertions; using Microsoft.AspNetCore.Authentication; using Moq; namespace FixingStaticCodeUnitTests { /// <summary> /// We can now test the thrust calculator with determinism, because we are using an abstracted time provider via ISystemClock /// </summary> public class ThrustCalculatorTests { [Theory] // Case 1 [InlineData(1, 1, 1, 1, 1, 1, "2023-01-01T01:59:59Z", 5, "Hour before 2am UTC has no modifier")] [InlineData(1, 1, 1, 1, 1, 1, "2023-01-01T02:00:00Z", 5, "Hour at 2am UTC has no modifier")] [InlineData(1, 1, 1, 1, 1, 1, "2023-01-01T03:00:00Z", 15, "Hour after 2am UTC has +10 modifier")] // Case 2 - ensuring that we don't always return the above values [InlineData(1, 2, 5, 3, 9, 7, "2023-01-01T01:59:59Z", 140, "Hour before 2am UTC has no modifier")] [InlineData(1, 2, 5, 3, 9, 7, "2023-01-01T02:00:00Z", 140, "Hour at 2am UTC has no modifier")] [InlineData(1, 2, 5, 3, 9, 7, "2023-01-01T03:00:00Z", 150, "Hour after 2am UTC has +10 modifier")] public void ThrustCalculator_ReturnsExpectedValues( int thrustValue1, int thrustValue2, int thrustValue3, int thrustValue4, int thrustValue5, int numberOfSloths, string timeString, int expectedThrustValue, string explanation ) { var mockClock = new Mock<ISystemClock>(); // Parse the time (which will make it a local time, and then convert back to UTC) var parsedTime = DateTime.Parse(timeString).ToUniversalTime(); // Return a fake output from our mocked ISystemClock to bring determinism back into our test mockClock.Setup(method => method.UtcNow).Returns(parsedTime); // Provide the mock clock to the class var thrustCalculator = new ThrustCalculator(mockClock.Object); var result = thrustCalculator.CalculateThrust( thrustValue1, thrustValue2, thrustValue3, thrustValue4, thrustValue5, numberOfSloths ); result.Should().Be(expectedThrustValue, explanation); } } }
In this test we now:
- Create a
Mock<ISystemClock>
and configure it to return a deterministic time. This time is based off a string variable from theInlineData
of ourTheory
- XUnit Theory data needs to be a compile time constant, so we can’t create new DateTime objects directly in the
InlineData
- XUnit Theory data needs to be a compile time constant, so we can’t create new DateTime objects directly in the
- Inject the mock into the Thrust calculator
- Calculate the thrust
- Assert our expectations
This test will always pass, because we are now simulating the time of day, and asserting our expectations of the function’s behaviour at that time of the day.
Sloth Side Quest: Forming Test Cases
The ThrustCalculator
code has one logical decision – the if
statement that observes the current hour of the system clock. All other logic is common to all flows through the function.
public int CalculateThrust(int input1, int input2, int input3, int input4, int input5, int numberOfSloths) { var result = (input1 + input2 + input3 + input4 + input5) * numberOfSloths; // We have converted from a dependency on local time to a dependency on standardised UTC time if (systemClock.UtcNow.Hour <= 2) { return result; } else { return result + 10; } }
This is a big hint for how we can structure our unit tests.
[InlineData(1, 1, 1, 1, 1, 1, "2023-01-01T01:59:59Z", 5, "Hour before 2am UTC has no modifier")] [InlineData(1, 1, 1, 1, 1, 1, "2023-01-01T02:00:00Z", 5, "Hour at 2am UTC has no modifier")] [InlineData(1, 1, 1, 1, 1, 1, "2023-01-01T03:00:00Z", 15, "Hour after 2am UTC has +10 modifier")]
Here we can see that each test case is laser focused on that if statement:
- What do I expect if the hour is less than 2?
- The if statement says that we return the raw result when the hour is less than 2
- What do I expect if the hour is equal to 2?
- The if statement says that we return the raw result when the hour is equal to 2
- What do I expect if the hour is greater than 2?
- The else statement says that we return the result + 10 when the hour is greater than 2
Basing test cases around if
statements is a strategy that can be used to start forming test cases for any function. All you need to do is make test cases that navigate the less than, equal to and greater than conditions of the if
statement and assert your expected output. It’s that simple!
Did you notice that it was only the time string that changed between each of InlineData
for Case 1 of this unit test? This is because we use the conditions of the if
statement to drive our test through different logical flows. By keeping everything else constant we can increase confidence that the function behaves as we expect, because all other parts of the function should be equivalent (at least for this function!).
The value 1 was chosen for the static inputs of Case 1, as it is a very simple number that produces a simple to understand output. The same number was repeated for all inputs, because this also keeps the perceived complexity of the test case to a minimum. If we jumped straight into case 2, it might seem as though there was some significance to the numbers; which there is not. If you don’t have to use specific data for your test case, keep your inputs as simple as possible – even if they don’t make sense to the overall business logic of your application.
We could stop testing there. However, there is a small risk that the function itself returns the results that we expect when we give it all 1 values for the calculation, but fails given more diversity of data. Therefore, we added another grouping of test data that uses the same less than, equal to and greater than strategy based on the time, but with a different set of values that feed into the common calculation. These values were chosen randomly and do not hold any significance whatsoever.
Testing Thrust Continued
Now that we’ve got some good coverage using our if
statement strategy as a starting point, we can add some more tests.
As we are writing tests for a numerical calculation, we need to be aware that our arithmetic operations might produce very large or very small numbers.
The CalculateThrust
method takes 32 bit integers as an input, and returns a 32 bit integer output. A 32 bit integer supports a range of values from -2,147,483,648 to 2,147,483,647.
What would happen if all of our inputs were the maximum positive value for an integer? This would produce:
(2,147,483,647 + 2,147,483,647 + 2,147,483,647 + 2,147,483,647 + 2,147,483,647) * 2,147,483,647 = 23,058,430,070,662,103,045
23,058,430,070,662,103,045 is much larger than the max value for an integer, so what would happen here?
We can easily check this by adding another test case onto the unit test:
[InlineData(2147483647, 2147483647, 2147483647, 2147483647, 2147483647, 2147483647, "2023-01-01T01:59:59Z", 23058430070662103045, "Testing max values before 2am UTC")]
The compiler will complain to us that integral constant is too large
, giving us our answer. But what does our function do? If we put an arbitrary value for the expected result (we’ve just used the max int value here) the test will run and we can see what the result is:
[InlineData(2147483647, 2147483647, 2147483647, 2147483647, 2147483647, 2147483647, "2023-01-01T01:59:59Z", 2147483647, "Testing max values before 2am UTC")]
We get this result from FluentAssertions
FixingStaticCodeUnitTests.ThrustCalculatorTests.ThrustCalculator_ReturnsExpectedValues(thrustValue1: 2147483647, thrustValue2: 2147483647, thrustValue3: 2147483647, thrustValue4: 2147483647, thrustValue5: 2147483647, numberOfSloths: 2147483647, timeString: "2023-01-01T01:59:59Z", expectedThrustValue: 2147483647, explanation: "Testing max values before 2am UTC")
Source: ThrustCalculatorTests.cs line 24
Duration: 186 ms
Message:
Expected result to be 2147483647 because Testing max values before 2am UTC, but found 5 (difference of -2147483642).
Stack Trace:
XUnit2TestFramework.Throw(String message)
TestFrameworkProvider.Throw(String message)
DefaultAssertionStrategy.HandleFailure(String message)
AssertionScope.FailWith(Func1 failReasonFunc)
AssertionScope.FailWith(Func
1 failReasonFunc)
AssertionScope.FailWith(String message, Object[] args)
NumericAssertions`2.Be(T expected, String because, Object[] becauseArgs)
ThrustCalculatorTests.ThrustCalculator_ReturnsExpectedValues(Int32 thrustValue1, Int32 thrustValue2, Int32 thrustValue3, Int32 thrustValue4, Int32 thrustValue5, Int32 numberOfSloths, String timeString, Int32 expectedThrustValue, String explanation) line 50
Our test failed, because the function returned the value 5! Why did we get such a small number??
This is an arithmetic overflow in action. When this happens the output may not be well understood by a consumer, or appropriate for consumption in the subsequent flow of the application.
We clearly don’t want to launch sloths into space with an arbitrary thrust!!! This test case has prompted a refactor and new test case.
/// <summary> /// Calculate the thrust that is required to get the given number of sloths to the moon for the chosen rocket /// After lunch time, we need a little extra thrust to get through the night sky. /// If the thrust calculation overflows, this function will propagate the <see cref="OverflowException"/> /// </summary> public int CalculateThrust(int input1, int input2, int input3, int input4, int input5, int numberOfSloths) { try { checked { var result = (input1 + input2 + input3 + input4 + input5) * numberOfSloths; // We have converted from a dependency on local time to a dependency on standardised UTC time if (systemClock.UtcNow.Hour <= 2) { return result; } else { return result + 10; } } } catch(OverflowException ex) { // This is redundant but demonstrates that we propagate the OverFlowException throw; } }
Our CalculateThrust
method now uses a checked statement which will throw an OverflowException
if an arithmetic overflow happens within its body. This allows us to write a new unit test to assert that this happens:
[Theory] // Case 1 - guarantee overflow [InlineData(int.MaxValue, int.MaxValue, int.MaxValue, int.MaxValue, int.MaxValue, int.MaxValue, "2023-01-01T01:59:59Z", "Arithmetic overflow with max values before 2am UTC")] [InlineData(int.MaxValue, int.MaxValue, int.MaxValue, int.MaxValue, int.MaxValue, int.MaxValue, "2023-01-01T02:00:00Z", "Arithmetic overflow with max values at 2am UTC")] [InlineData(int.MaxValue, int.MaxValue, int.MaxValue, int.MaxValue, int.MaxValue, int.MaxValue, "2023-01-01T03:00:00Z", "Arithmetic overflow with max values after 2am UTC")] // Case 2 - a different variation of overflow [InlineData(999999999, 500000000, 1, 10, 3, 999999999, "2023-01-01T01:59:59Z", "Arithmetic overflow with max values before 2am UTC")] [InlineData(999999999, 500000000, 1, 10, 3, 999999999, "2023-01-01T02:00:00Z", "Arithmetic overflow with max values at 2am UTC")] [InlineData(999999999, 500000000, 1, 10, 3, 999999999, "2023-01-01T03:00:00Z", "Arithmetic overflow with max values after 2am UTC")] // Case 3 - testing negative overflow [InlineData(int.MaxValue, int.MaxValue, int.MaxValue, int.MaxValue, int.MaxValue, -1 * int.MaxValue, "2023-01-01T01:59:59Z", "Arithmetic overflow with max values before 2am UTC")] [InlineData(int.MaxValue, int.MaxValue, int.MaxValue, int.MaxValue, int.MaxValue, -1 * int.MaxValue, "2023-01-01T02:00:00Z", "Arithmetic overflow with max values at 2am UTC")] [InlineData(int.MaxValue, int.MaxValue, int.MaxValue, int.MaxValue, int.MaxValue, -1 * int.MaxValue, "2023-01-01T03:00:00Z", "Arithmetic overflow with max values after 2am UTC")] public void CalculateThrust_ThrowsOverflowException_WhenArithmeticOverflowHappens( int thrustValue1, int thrustValue2, int thrustValue3, int thrustValue4, int thrustValue5, int numberOfSloths, string timeString, string explanation ) { var mockClock = new Mock<ISystemClock>(); // Parse the time (which will make it a local time, and then convert back to UTC) var parsedTime = DateTime.Parse(timeString).ToUniversalTime(); // Return a fake output from our mocked ISystemClock to bring determinism back into our test mockClock.Setup(method => method.UtcNow).Returns(parsedTime); // Provide the mock clock to the class var thrustCalculator = new ThrustCalculator(mockClock.Object); Func<int> act = () => thrustCalculator.CalculateThrust( thrustValue1, thrustValue2, thrustValue3, thrustValue4, thrustValue5, numberOfSloths ); // Fluent assertions catches the exception which bubbles out of the RocketLauncher act.Should().Throw<OverflowException>(explanation); }
This test method asserts that CalculateThrust
will throw an OverflowException
when there is an arithmetic overflow. Once again these tests focus on different times of the day and are grouped into sets of test cases with invariant thrust and sloth numbers.
The first case is the most obvious one which will produce a positive overflow (we simply put in the biggest numbers that the compiler would allow us to) and the second case uses some arbitrary inputs to assert that we also see an overflow with a different data set.
Finally the third case tests for negative overflow in a simple way. It is up to you whether or not you feel that you need to add diverse data for the negative case, given that we have already asserted it for the positive case and would perhaps start to see a diminishing return on the coverage.
That’s some great coverage and improvements for the ThrustCalculator
. Let’s get back to trying to test our larger RocketLaunchingLogic
class.
Returning to Testing our Rocket Launcher
Let’s take a look at the test setup now:
using FixingStaticCode.BusinessLogic; using FixingStaticCode.Database; using FixingStaticCode.Infrastructure; using FixingStaticCode.WebApi; using Microsoft.Extensions.Logging; using Moq; namespace FixingStaticCodeUnitTests { /// <summary> /// Oh my lordy wordy! We are now drowning in dependencies for this class. /// </summary> public class RocketLaunchingLogicTests { [Fact] public async Task RocketLaunchingLogic_TriesToLaunchARocketAgain_IfItDoesNotInitiallyFindOne() { var thrustCalculatorMock = new Mock<IThrustCalculator>(); var rocketDatabaseRetrieverMock = new Mock<IRocketDatabaseRetriever>(); var rocketQueuePollerMock = new Mock<IRocketQueuePoller>(); var rocketLaunchingServiceMock = new Mock<IRocketLaunchingService>(); var loggerMock = new Mock<ILogger<RocketLaunchingLogic>>(); var coordinateCalculatorMock = new Mock<ICoordinateCalculator>(); var asyncDelayMock = new Mock<IAsyncDelay>(); var rocketLaunchingLogic = new RocketLaunchingLogic( thrustCalculatorMock.Object, rocketDatabaseRetrieverMock.Object, rocketQueuePollerMock.Object, rocketLaunchingServiceMock.Object, loggerMock.Object, coordinateCalculatorMock.Object, asyncDelayMock.Object ); // Wow! We now have 7 different dependencies that we need to orchestrate in order to test this class. // This is a code smell that we should fix before we try to write some tests here. } } }
Oh no! Our refactoring work has actually made our test even more difficult to orchestrate. We now have 7 dependencies to deal with.
Luckily for us dealing with the bloated dependencies of a class is the topic of our next article!
Sloth Summary
In today’s article we have learned that static classes are an easy way of adding code into your solution. This is because you do not need to modify the setup of the consuming class.
Despite the ease with which they allow us to add code to the solution they inevitably make testing more difficult, because they suffer from the same drawbacks as concrete types.
If your solution has self-authored static code, you can use the levelled up lift and shift to abstract the underlying implementation and prune the dependency tree to make testing easier.
If you solution has static time providers, you may be able to swap over to ISystemClock
or another time provider interface to bring determinism into your unit tests.
If your code uses manual delays, feel free to copy the IAsyncDelay
interface and implementation to regain control of the duration of your unit tests.
See you in the next article! ?