As you move towards .NET 6, you also get new opportunities to upgrade legacy codebases to the latest and greatest. Unfortunately, updating a legacy codebase can be nerve-racking. Luckily, in this post, I wanted to share a technique I recently discovered that allows you to alter the behavior of static classes without changing the surface API of a legacy codebase. Of course, this approach has its limits, but I think it’s a valuable technique to have in your toolkit.

Let’s get started.

The Problem

You’ve likely inherited codebases with iron-clad surface APIs, with twisting and turning dependency graphs that leave even those with the most vigorous constitution feeling uneasy. That said, you may still want to add tests to the legacy codebase but realize much of its implementation depends on static class access. So let’s take a look at a contrived example, but one you’ve likely seen in many applications.

public static class ConcreteSurfaceApi
{
    public static string CurrentInvariantMonthName()
    {
        var month = DateTimeOffset.UtcNow.Month;
        return CultureInfo
            .InvariantCulture
            .DateTimeFormat
            .GetMonthName(month);
    }
}

The previous code uses DateTimeOffset.UtcNow, which isn’t exactly easy to access, but you still want to test the validity of our method. How would you do that without changing the surface API or the class implementation?

Using Static Globals and Preprocessor Directives

.NET 6 introduces the concept of global static using, allowing us to define global imports to our files. Note, this technique only works if you can compile the codebase. If you’re interested in changing a precompiled dependencies behavior, check out my post on Monkey Patching in .NET.

Let’s get into implementing this solution.

Step 1. Create a Global Usings File

The file’s name isn’t essential, but you need to create an empty .cs file with the following code.

#if MOCK_TIME
global using DateTimeOffset = StubDateTimeOffset; 
#else
global using DateTimeOffset = System.DateTimeOffset;
#endif

public static class StubDateTimeOffset
{
    private static System.DateTimeOffset? value;
    
    public static System.DateTimeOffset UtcNow 
        => value ?? System.DateTimeOffset.UtcNow ;
    
    public static void Set(System.DateTimeOffset dateTimeOffset) {
        value = dateTimeOffset;
    }

    public static void Reset() => value = null;
}

Here you’re creating a static override for the type of DateTimeOffset throughout your entire project. The trick is to implement all the properties and methods you’re already using in the existing project.

It would help if you also had a preprocessor directive to turn this feature on and off. In this case, we’ve chosen the preprocessor directive of MOCK_TIME, but it could be anything you want.

Note: You may need to remove any existing using statements to namespaces in the files using the static class.

Step 2. Define a Project Constant

In the previous step, you added a preprocessor directive. Now you need to enable it. You can define the directive in two places:

  1. The C# file containing the StubDateTimeOffset class using the #define preprocessor.
  2. In our .csproj using the DefineConstants element under a PropertyGroup.

I recommend the second option, as you can trigger the constant using MSBuild properties like the Configuration value.

<PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
  <DefineConstants>MOCK_TIME</DefineConstants>
</PropertyGroup>

If you’re unfamiliar with preprocessor directives, you might want to read my previous blog post on C# preprocessor directive.

Step 3. Write Some Unit Tests

Global usings are limited to the assembly they are defined within, so all projects utilizing this technique must implement the abovementioned steps. You can now modify our static class value by starting a new unit test project. For example, in the following code, you’ll see accessing the StubDateTimeOffset static class to set the new DateTime value.

using System;
using System.Globalization;
using Xunit;
using Xunit.Abstractions;

namespace ConsoleApp18.Tests;

public class UnitTest1
{
    private readonly ITestOutputHelper helper;

    public UnitTest1(ITestOutputHelper helper)
    {
        this.helper = helper;
    }

    [Fact]
    public void GetCurrentMonth()
    {
        const int currentMonthValue = 7;
        // set time 
        StubDateTimeOffset.Set(new(new(2022, currentMonthValue, 1)));

        var expected = CultureInfo.InvariantCulture.DateTimeFormat.GetMonthName(7);
        var fromApi = ConcreteSurfaceApi.CurrentInvariantMonthName();

        helper.WriteLine($"GetMonthName: {expected}");
        helper.WriteLine($"Actual: {fromApi}");
        helper.WriteLine($"Real Month: {DateTimeOffset.UtcNow:MMMM}");

        Assert.Equal(expected, fromApi);
    }
}

You can see the test pass and the following output when you run the test.

GetMonthName: July
Actual: July
Real Month: January

Great! You were able to change internal implementation with a few .NET 6 tricks.

Conclusion

Yes, this approach takes a bit of work to get going, but it’s better than having to change a legacy code base that may have thousands of API calls and layers of dependency. If you need to test behavior dependent on static calls, this may be a viable approach for you. I hope you enjoyed this post, and let me know on Twitter what you think. Thank you for reading.