Chaos Control - Making Random Testable

December 09, 2024#Software Development
Article
Author image.

Scott DePouw, Senior Consultant

Yes, it is in fact possible to test randomness within your application! Like with so many other facets of your code, it’s important too. Whether you’re making a complex game or a simple random featured item selector on a web page, verifying that your code behaves properly given random input is essential. Let’s see what we can do with some of what C# has to offer in terms of random features.

Random.Shared

A lot of C# developers will see the System.Random class on a cursory search for “how do I get a random outcome” and get straight to using it. Out of the box it’ll get what you want easily! For a random number, one merely provides a range of values to myRandomInstance.Next() and it’s yours. Many will then go on to ask “how do I manage my instances of the Random class efficiently?” Should you maintain a single static instance? New one up every time you need it?

Well, as of .NET 6, the framework provides the answer for you. The Random class has a static, thread-safe instance of itself, accessible via a property called Shared on the Random class. Anywhere in your code you can call System.Random.Shared.Next() and be off to the races with whatever random numbers you need. No more managing instances of Random on your own!

As of .NET 8, if you’re needing random elements out of an array or other ReadOnlySpan<T>, GetItems() has you covered. Prior to this, you’d have to write code akin to var randomItem = items[random.next(0, items.Length)]; to pull a random element out of an array. I strongly suspect this is why Random.Next(int minValue, int maxValue) treats maxValue as an exclusive upper bound, as otherwise you’d have to write items.Length - 1.

So, we have our instance of Random. How do we test it?

Static Cling

Accessing a global static instance of anything, Random or otherwise, makes code harder to test. This is a code smell known as Static Cling. Consider the following.

using System;

namespace SuperCoolRPG;

public class BattleCalculator
{
  public int CalculateDamage(int weaponStrength, int characterStrength)
  {
    return weaponStrength + characterStrength + Random.Shared.Next(5, 31);
  }
}

Getting the amount of damage dealt by a character’s attack here is a simple formula: Add the strengths up alongside a random value between 5 and 30 (recall the 2nd parameter is an exclusive upper bound). This is a pretty simple calculation, but real games have far more complex math going on. For example, the damage calculation in Pokémon has far more inputs and operations happening. We’d like to unit test our calculations for various random results to make sure our game is properly balanced and doesn’t give off the wall results.

To accomplish this, we’re going to take our shared instance of Random and Wrap It Up!

Controlling Chaos with an Abstraction for Random

Random has a myriad of methods available (including the static Shared property even). When one creates an interface to be used for dependency injection, one only needs to provide what’s being used. It can be daunting and exhaustive to try and wrap and mirror everything. For our class, we only use one overload of Next(), so that’s all we’ll do for now. We can always add additional implementations as needed later.

public interface IRandom
{
  /// <inheritdoc cref="System.Random.Next(int,int)" />
  int Next(int minValue, int maxValue);
}

public class SystemRandom : IRandom
{
  /// <inheritdoc />
  public int Next(int minValue, int maxValue) => System.Random.Shared.Next(minValue, maxValue);
}

Using “inheritdoc” to Spill the Beans

Our wrapper class SystemRandom just calls to the Shared instance to do real randomization. Our interface exposes the documentation available to the real Next() method using inheritdoc, with cref pointing to the original method. This will maintain the documentation when wanting to discover details about the method when you’re working in an IDE like Visual Studio or JetBrains Rider. No need to copy and paste! We do this because we know there’s just one implementation of IRandom (a wrapping class of System.Random). If there existed multiple IRandom implementations it would be inaccurate to use System.Random.Next, but because we know it always will be, we can allow our interface to “know about the implementation details” for the sake of making the documentation easier to read when using IRandom.

Using the Abstraction

Let’s modify our BattleCalculator with our new interface.

public class BattleCalculator(IRandom random)
{
  public int CalculateDamage(int weaponStrength, int characterStrength)
  {
    return weaponStrength + characterStrength + random.Next(5, 31);
  }
}

Nothing changes about the real behavior. In fact, you could write integration tests for various scenarios by simply providing a new instance of SystemRandom to the BattleCalculator constructor. But of course we’re here to control the chaos of randomization! Now that we have our IRandom interface, it’s trivial to mock it using your mocking tool of choice, and test to your heart’s content.

Here’s an example using NSubstitute.

using FluentAssertions;
using NSubstitute;
using SuperCoolRPG;

namespace SuperCoolRPGTests.BattleCalculatorTests;

public class CalculateDamage
{
  private readonly IRandom _mockRandom = Substitute.For<IRandom>();
  private readonly BattleCalculator _calculator;

  public CalculateDamage()
  {
    _calculator = new(_mockRandom);
  }

  [Fact]
  public void AddsRandomValueToDamage()
  {
    const int randomValue = 50;
    const int weaponStrength = 95;
    const int characterStrength = 5;
    _mockRandom.Next(Arg.Any<int>(), Arg.Any<int>()).Returns(randomValue);

    int damage = _calculator.CalculateDamage(95, 5);

    damage.Should().Be(weaponStrength + characterStrength + randomValue);
  }
}

Had we been stuck with real randomization, there’s no way we could have written the above test. Well, to be precise, we could have written it, but it would have failed most of the time! But since we controlled randomization in our application via interface, we now have the capability of controlling randomization to validate any assertions we want to make about our code’s behavior.

Happy Holidays!

That’ll do it for my blog posts this year. Happy holidays, everyone!


Copyright © 2024 NimblePros - All Rights Reserved