Welcome to the second post in the .Net Unit Testing series: escaping the sticky trap of infinite or C# CancellationToken based loops! In this article we’ll dive head first into solving the first problem highlighted in our .Net sloth rocket launching application.
Seemingly endless loops aren’t merely a product of flawed logic. Some applications rely heavily on them to facilitate ongoing functions such as polling resources, or executing periodic operations.
While they may make sense for the logic of your application, they may also prove prohibitive to writing unit tests around the looped functionality. Once your unit test enters an infinite loop there may be very few ways that it will make it out alive. Even if it does break free, what can you assert against to understand what actually happened?
Much like an agent on a top secret mission, a wise Code Sloth always plans an exit strategy. Today’s article will cover how we can identify a way out, or fall back to the ultimate failsafe strategy if none are available.
Remember to check out the Code Sloth Code Samples page for GitHub links to the repository of sample code!
Getting back into .Net? Check out our post on getting started with .Net 6 here.
The Problem: an Endless Loop
Let’s take a look at the flow diagram of our application once more
As we can see, this flow diagram has no terminating outcomes. Both happy and failure flows result in the application returning to the beginning to poll the queue for more messages.
While the intention here is absolutely what we want, it causes us a major problem.
Even when the application fails and exceptions are thrown they are discarded. This allows the app to continue polling the queue.
This means that there is literally no way that we can escape the loop once we enter it, other than shutting the application down forcefully.
Types of Loops
Infinite Loops
In traditional Code Slothian style we presented our problem code with a simplicity first mindset. The first form of our bad code demonstrated the most basic infinite loop. This loop has no terminating conditions and continues to run until the application terminates, or unhandled exceptions are propagated.
while (true) { ... some code here }
Sticky Loops: C# CancellationToken
While it was useful to provide a simple concept that demonstrated a point, this type of loop may not be realistic for a lot of readers. You’re more likely to come across sticky loops in commercial production code.
Sticky loops, unlike infinite loops, have a way out. However, when or even how we get out of the loop may be tricky to orchestrate from our unit tests. Therefore, we may get stuck in them at inopportune times.
Modern .Net applications tend to leverage the framework’s hosted services, such as BackgroundService to facilitate a long running process that is based around continuous looping.
BackgroundServices
are useful because they enable you to run many concurrent processes in your executable application’s process space. Each service might be polling different queues, or running periodic tasks etc, the choice is up to you!
Now that we understand the basic case, let’s level up our solution by implementing the BackgroundService
in our RocketLauncher
.
using BadCodeToBeJudged.BusinessLogic; using BadCodeToBeJudged.Database; using BadCodeToBeJudged.Infrastructure; using BadCodeToBeJudged.WebApi; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using System.Text.Json; namespace BadCodeToBeJudged { public class RocketLauncher : BackgroundService { ... member variables and constructor ... protected override async Task ExecuteAsync(CancellationToken stoppingToken) { while (!stoppingToken.IsCancellationRequested) { ... do our rocket launching goodness ... } } } }
That’s My Queue to Leave
The best strategy for breaking out of a loop is to provide a way for your application to signal that the loop should finish.
When we implemented the BackgroundService
we didn’t have an option. The signature of the function override demands that we accept a CancellationToken
, which does exactly that. At some point in the future, the CancellationToken
will tell us that it has been signaled. It is then up to us to respond to that change in state.
Problem solved! Or is it … ?
Let’s take a look at how this works in reality, through a unit test against a simplified version of the RocketLauncher
: MiniRocketLauncher
!
using Microsoft.Extensions.Hosting; namespace BadCodeToBeJudged { public class MiniRocketLauncher : BackgroundService { public int LaunchCount { get; set; } = 0; public MiniRocketLauncher() { } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { while (!stoppingToken.IsCancellationRequested) { LaunchCount++; } } } }
The MiniRocketLauncher
blasts rockets off into space as fast as it can, all while keeping track of its LaunchCount
in a public property. It will continue to blast rockets off into space until the stoppingToken
is signaled, at which point it will exit the while
loop.
Let’s jump into the unit test.
using BadCodeToBeJudged; using FluentAssertions; namespace FixingTheInfiniteLoopUnitTests { public class MiniRocketLauncherTests { [Fact] public async Task RocketLauncher_IsHardToTest_AsBackgroundService() { var rocketLauncher = new MiniRocketLauncher(); var tokenSource = new CancellationTokenSource(); tokenSource.CancelAfter(1000); var token = tokenSource.Token; // Provide our token to the start method await rocketLauncher.StartAsync(token); rocketLauncher.LaunchCount.Should().Be(1); // Run 1: 167164137 // Run 2: 161524603 // Run 3: 169504777 } } }
Our unit test does the following:
- Creates a
MiniRocketLauncher
- Creates a
CancellationToken
from aCancellationTokenSource
, which will mark the token for cancellation after 1 second - Runs the mini rocket launcher
- Asserts how many times we launch a mini rocket
Side note: while using Assert.True(...)
might work for some, it doesn’t always clearly describe the intention of what is being asserted against.
To avoid setting confusing expectations we are using the Fluent Assertions NuGet package. This package allows us to write sentence-like assertions that clearly describe what we are hoping to see. In the test above, we are asserting that the LaunchCount
should represent 1 launch.
FixingTheInfiniteLoopUnitTests.MiniRocketLauncherTests.RocketLauncher_IsHardToTest_AsBackgroundService
Source: MiniRocketLauncherTests.cs line 9
Duration: 1.1 sec
Message:
Expected rocketLauncher.LaunchCount to be 1, but found 169504777 (difference of 169504776).
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)
MiniRocketLauncherTests.RocketLauncher_IsHardToTest_AsBackgroundService() line 19
--- End of stack trace from previous location ---
The test was run three times. The final run produced the above message on assertion thanks to Fluent Assertions.
Here we can see that we actually launched a whopping 169504777
rockets during that one second! The second run launched 161524603
and the initial run managed 167164137
.
Congratulations!
Our test has confirmed that we’ve been able to break free from the sticky loop. Much like Ariana, now we’re stronger than we’ve been before!
Can you spot the lingering problem with the unit test, though?
A Broken C# CancellationToken Loop with Shattered Determinism
In computer programming determinism
means that when a function is invoked multiple times with the same inputs, it will always provide the same outputs.
Our unit test has shown an abhorrent lack of determinism. Each time we ran the test we received a different LaunchCount
due to being stuck in the loop for differing periods of time. This makes writing unit tests for our code extremely difficult and brittle.
The CancellationToken
provides a unique problem for our unit tests. If the state of the cancellation token is observed at other points during the call stack, a given invocation of the RocketLauncher
may not even complete, whereas another invocation might. As our code evolves over time more or less of these checks may be added that result in unit tests starting to unknowingly break.
The complexity and fragility of unit testing in this way compounds as we then start to explore mocking our dependencies, which might provide specific values based on the number of times they have been invoked. Puppeteering these count based invocations against a time based constraint may be near to impossible.
One SOLID Way Out
We’ve conceptually learned how to break out of a loop and observed a solution in action. Unfortunately, it didn’t place us in much better stead from a unit testing perspective.
The only fool proof way of writing unit testable code, is to focus on SOLID programming principles by first determining the single responsibility of our class(es).
Understanding what a “responsibility” is can be quite subjective. However, we can rationalise in stupidly simple sloth terms that the RocketLauncher
is fundamentally taking care of two different concerns in its current shape.
Firstly it is handling an infrastructure concern: how the application stays alive indefinitely. This is facilitated by implementing the BackgroundService
and our slightly less sticky loop.
Secondly it is performing its business function: launching sloths into space. This is executed through the code (business logic) that sits within the loop.
Rather than trying to identify each single responsibility of business logic within the loop, our simplest path forward is to lift and shift
our second responsibility, take a step back and re-evaluate.
Lift and Shift
A lift and shift is a basic exercise of cut and paste. You take some code from one function/file/class and paste it elsewhere.
For this exercise we will take our business logic from the RocketLauncher
and give it a new home in RockerLaunchingLogic.cs
. A small revision is needed to return
if we don’t have a rocket to launch, rather than calling continue
, as we are no longer in the context of the while loop.
Other than these types of small revisions, lift and shift should be very safe and have a low risk of introducing regressions to your business logic.
using BadCodeToBeJudged.BusinessLogic; using BadCodeToBeJudged.Database; using BadCodeToBeJudged.Infrastructure; using BadCodeToBeJudged.WebApi; using BadCodeToBeJudged; using Microsoft.Extensions.Logging; using System.Text.Json; namespace FixingTheInfiniteLoop.BusinessLogic { public class RocketLaunchingLogic { private ThrustCalculator thrustCalculator; private RocketDatabaseRetriever rocketDatabaseRetriever; private RocketQueuePoller rocketQueuePoller; private RocketLaunchingService rocketLaunchingService; private ILogger<RocketLauncher> logger; public RocketLaunchingLogic(ThrustCalculator thrustCalculator, RocketDatabaseRetriever rocketDatabaseRetriever, RocketQueuePoller rocketQueuePoller, RocketLaunchingService rocketLaunchingService, ILogger<RocketLauncher> logger) { 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)); } 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} "); } } } }
It is worth noting that we could have lifted and shifted this code into a public function on the RocketLauncher
class and unit tested it directly. However, as we have already identified, the class was bloated with too much responsbility, so removing the logic simplifies what it is responsible for.
Our RocketLauncher
is then simplified to:
using FixingTheInfiniteLoop.BusinessLogic; using Microsoft.Extensions.Hosting; namespace BadCodeToBeJudged { public class RocketLauncher : BackgroundService { private RocketLaunchingLogic rocketLaunchingLogic; public RocketLauncher(RocketLaunchingLogic rocketLaunchingLogic) { this.rocketLaunchingLogic = rocketLaunchingLogic ?? throw new ArgumentNullException(nameof(rocketLaunchingLogic)); } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { while (!stoppingToken.IsCancellationRequested) { await rocketLaunchingLogic.TryToLaunchARocket(); } } } }
Take Two at Testing our C# Cancellation Token
We can now start to write tests for the RocketLauncher.
using BadCodeToBeJudged; using FixingTheInfiniteLoop.BusinessLogic; using FluentAssertions; namespace FixingTheInfiniteLoopUnitTests { public class RocketLauncherTests { [Fact] public async Task RocketLauncher_StopsLaunchingRockets_WhenCancellationTokenIsSignaled() { var rocketLaunchingLogic = new RocketLaunchingLogic(null,null,null,null,null); var rocketLauncher = new RocketLauncher(rocketLaunchingLogic); var cancellationTokenSource = new CancellationTokenSource(); var cancellationToken = cancellationTokenSource.Token; cancellationTokenSource.Cancel(); await rocketLauncher.StartAsync(cancellationToken); // A very silly assertion to confirm that we exited from StartAsync true.Should().BeTrue(); } } }
We still have much code refactoring to do, but we have a starting point for our tests. This test:
- Creates a
RocketLauncher
with a bogus-initialisedrocketLaunchingLogic
- Creates a
CancellationToken
from aCancellationTokenSource
and cancels the token immediately - Invokes the
RocketLauncher
- Asserts that we return from our
async
call
This test does not create a real, usable RocketLaunchingLogic
. This is because it is not the responsibility of the RocketLauncher
to test this logic.
Due to the fact that we would get NullReferenceExceptions
if we invoked the RocketLaunchingLogic
with its given parameters, our test technically never enters the loop.
A coming article will dive into the problem of programming against concrete dependencies which will see us simplify a future iteration of this test to cover more specific test cases. For the moment however, we can celebrate some incremental progress, in that we have been able to write some semblance of a test around our previously untestable class!
Now let’s take a crack at finally writing some unit tests for our core business logic:
using BadCodeToBeJudged; using BadCodeToBeJudged.BusinessLogic; using BadCodeToBeJudged.Database; using BadCodeToBeJudged.Infrastructure; using BadCodeToBeJudged.WebApi; using FixingTheInfiniteLoop.BusinessLogic; using Microsoft.Extensions.Logging; namespace FixingTheInfiniteLoopUnitTests { public class RocketLaunchingLogicTests { /// <summary> /// Now we can focus on writing tests for the rocket launching logic...or can we? /// </summary> [Fact] public async Task RocketLaunchingLogic_DoesSomeStuff() { var thrustCalculator = new ThrustCalculator(); var pretendDatabaseClient = new PretendDatabaseClient( "connection string", TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(60), 3, 5 ); var loggerFactory = LoggerFactory.Create((loggingBuilder) => { }); var rocketDatabaseRetrieverLogger = LoggerFactoryExtensions.CreateLogger<RocketDatabaseRetriever>(loggerFactory); var rocketDatabaseRetriever = new RocketDatabaseRetriever(pretendDatabaseClient, rocketDatabaseRetrieverLogger); var rocketQueuePoller = new RocketQueuePoller(1, 2, "dependency info"); var httpClient = new HttpClient(); var rocketLaunchingService = new RocketLaunchingService(httpClient); var rocketLauncherLogger = LoggerFactoryExtensions.CreateLogger<RocketLauncher>(loggerFactory); var rocketLaunchingLogic = new RocketLaunchingLogic(thrustCalculator, rocketDatabaseRetriever, rocketQueuePoller, rocketLaunchingService, rocketLauncherLogger); // Oh my... This much test setup does not even deserve a pretend call to our function! // Arrange (in Arrange, Act, Assert) should be concise. This much dependency construction is a code smell } } }
Oh my. That’s quite the start to writing a test.
If you ever find yourself writing a unit test which requires a complex amount of dependency setup, pause and take note. This is your unit test telling you that something’s off with the code under test.
Lucky for us it is the subject of the next entry in this series.
Sloth Summary
Today we learned that loops come in different shapes and sizes. The key takeaway is that we need to be able to provide a deterministic way to exit a loop if we ever hope to write a suite of unit tests around its functionality.
Takeaway: (Some) C# CancellationToken Logic Can be Tested
CancellationTokens
may be a convenient way of exiting a loop at runtime but destroy the determinism of our unit tests that use them. Given that BackgroundService
forces us to use them however, we are powerless to change the function signature to provide another way to break free.
Takeaway: Lift and Shift Helps Increase Unit Testability
When all else fails the lift and shift
strategy can allow us to take difficult to test code and put it somewhere else (like a new class) to make it easier to test. Typically a lift and shift
will help to simplify the original location of the code, hopefully aligning it closer to SOLID principles’ Single Responsibility.
Today’s tips form the start of many strategies that can be used to Code Slothify code. While not the complete picture, they can be delivered as a low risk incremental step forward to a simpler solution. The way code is lifted and shifted may also unlock some unit testability along the way, so that you can start building confidence around future refactors.
As we have seen though, writing unit tests for our RocketLaunchingLogic
is still not stupidly simple. Hopefully we’ll make better progress in the next post!
Other Posts in This Series
Check out the other posts in this series below!
- [Series] Your .Net Unit Tests Are Judging You: Why You Should Listen to them
- [Test Refactoring Part 1] Introducing The Problem Code
- [Test Refactoring Part 2] Sticky Loops
- [Test Refactoring Part 3] Concrete Dependencies
- [Test Refactoring Part 4] Hidden Concrete Types
- [Test Refactoring Part 5] Dependency Simplification