Unless a test controls time, testing time dependent behavior becomes tricky. Code under test must query a test specific, machine independent provider of time. One such provider is outlined in this post. Specifically, one that avoids the dependency injection container, doesn't require being passed down the call stack to objects not created by container, and doesn't require a mocking library to reduce boilerplate in testing.
The goal is to write tests like the one below. Inside the using block time is January 1, 2018 at noon, regardless of actual system time. Of course, in real code under test the call to TimeProvider.Now wouldn't be in the unit test but down the call stack:
using System; using Xunit; using MyProject.Seedwork; using MyProject.UnitTests.Seedwork; namespace MyProject.UnitTests { [Fact] public void SingleThreadedTimeProviderTestScope() { var testNow = DateTime.Parse("2018-01-01T12:00:00"); using (new TimeProviderTestScope(() => testNow)) { Assert.Equal(testNow, TimeProvider.Now); } // Test the passing of 15 minutes. testNow = DateTime.Parse("2018-01-01T12:15:00"); using (new TimeProviderTestScope(() => testNow)) { Assert.Equal(testNow, TimeProvider.Now); } } }
To arrive at the unit test above, the first step is add to a project's seedwork the (potentially) system time independent TimeProvider:
using System; namespace MyProject.Seedwork { public static class TimeProvider { private static readonly Func<DateTime> DefaultProvider = () => DateTime.UtcNow; private static Func<DateTime> Provider = DefaultProvider; public static DateTime Now => Provider(); public static void SetTimeProvider(Func<DateTime> provider) => Provider = provider ?? throw new ArgumentNullException(nameof(provider)); public static void ResetTimeProvider() => Provider = DefaultProvider; } }
While the provider's default time is DateTime.UtcNow, other providers of time may be substituted in. A unit test would typically provide a hardcoded time read by code under test.
To make easy use of TimeProvider in unit tests, we wrap it such that its source of time is constrained to a using block and such that the wrapper is thread-safe (more on thread-safety later):
using System; using System.Threading; using System.Runtime.CompilerServices; using MyProject.Seedwork; namespace MyProject.UnitTests.Seedwork { public class TimeProviderTestScope : IDisposable { private static Mutex Mutex = new Mutex(); private static string MemberName; private static string FilePath; private static int LineNumber; public TimeProviderTestScope(Func<DateTime> provider, int timeoutMilliseconds = 10000, [CallerMemberName] string memberName = "", [CallerFilePath] string filePath = "", [CallerLineNumber] int lineNumber = 0) { if (Mutex.WaitOne(timeoutMilliseconds)) { TimeProvider.SetTimeProvider(provider); MemberName = memberName; FilePath = filePath; LineNumber = lineNumber; } else throw new Exception( "Forgot to call Dispose method or was Dispose method called too late? " + $"Lock is held by member '{MemberName}' at '{FilePath}:{LineNumber}"); } public static TimeProviderTestScope SetTimeTo(DateTime time) => new TimeProviderTestScope(() => time); public static TimeProviderTestScope SetTimeTo(string time) => SetTimeTo(DateTime.Parse(time)); public void Dispose() { TimeProvider.ResetTimeProvider(); MemberName = ""; FilePath = ""; LineNumber = 0; Mutex.ReleaseMutex(); } } }
TimeProviderTestScope serializes execution of code in using blocks within which time is controlled by the provider. Parallel execution of two using blocks, on different threads, will cause one to get queued for up to 10 seconds after which an exception is thrown. The exception reports which method is currently executing.
The main reason for serializing using blocks is the xUnit test runner. It groups tests into collections: each class with tests becomes a test collection whose tests execute one after the other. Across test collections, tests execute in parallel and without proper synchronization time in one collection would leak into another, causing test failures. All because the source of time inside TimeProvider is a static.
The contrived tests below illustrate the issue by simulating two tests executing in parallel. We make sure t2 waits for t1 so that without thread synchronization undesired behavior is guaranteed on each run:
using System; using System.Threading; using System.Threading.Tasks; using Xunit; using MyProject.Seedwork; using MyProject.UnitTests.Seedwork; namespace MyProject.UnitTests { private async Task TwoParallelTestsAsync(int delayMilliseconds, int timeoutMilliseconds) { var mutex = new AutoResetEvent(false); var t1 = Task.Factory.StartNew(() => { var testNow = DateTime.Parse("2018-01-01T12:00:00"); using (new TimeProviderTestScope(() => testNow)) { Assert.Equal(testNow, TimeProvider.Now); mutex.Set(); Thread.Sleep(delayMilliseconds); Assert.Equal(testNow, TimeProvider.Now); } }); var t2 = Task.Factory.StartNew(() => { mutex.WaitOne(); var testNow = DateTime.Parse("2018-02-01T12:00:00"); using (new TimeProviderTestScope(() => testNow, timeoutMilliseconds)) { Assert.Equal(testNow, TimeProvider.Now); } }); await t1; await t2; } [Fact] public async Task MultiThreadedTimeProviderTestScopeSuccess() { await TwoParallelTestsAsync(5000, 10000); } [Fact] public async Task MultiThreadedTimeProviderTestScopeFail() { // Set t2's timeout less than t1's wait period to trigger "locked" exception. var e = await Assert.ThrowsAsync<Exception>(() => TwoParallelTestsAsync(5000, 1000)); Assert.Contains("Lock is held", e.Message); } }
Without synchronization, in MultiThreadedTimeProviderTestScopeSuccess t1 would halt for five seconds while t2 completes. As t2 exits its using block, the shared provider is reset to current time. When t1 resumes, time would've changed underneath it, causing it to fail (assuming current time is August 26):
Assert.Equal() Failure Expected: 2018-01-01T12:00:00.0000000+00:00 Actual: 2019-08-26T09:52:24.7845343+00:00
Using blocks in unit tests are generally CPU bound and execute quickly. MultiThreadedTimeProviderTestScopeFail shows what happens when it's not the case. Here t1 blocks for longer than t2 is prepared to wait, causing an exception with call site information (line 28 is t1's using statement):
System.Exception: 'Forgot to call Dispose method or was Dispose method called too late? Lock is held by member 'TwoParallelTestsAsync' at '...\TimeProviderTestScopeTests.cs:28'
Both TimeProvider and traditional dependency injection provide external control of time. But used with domain driven design, for instance, the former has its advantages: it's common for a domain object, which isn't constructed by a dependency injection container, to query time. With dependency injection, an ITimeProvider instance would have to be passed down the call stack from an object created by the container, polluting signatures. With TimeProvider, because it's effectively global, it's readily available everywhere, and the threading issues are isolated to tests.