Read the TimeProvider article

TimeProvider lab

By · .NET 10 · xUnit · FakeTimeProvider

Run the invitation expiry test from the article, then try delays and cancellation with the same fake clock. I included the system-clock versions too, so you can see what happens when a dependency ignores the time you're controlling.

1. Download the files

You need the .NET 10 SDK. Download and extract the ZIP, then open the timeprovider folder inside. The first run restores the packages listed in mnw_215.csproj.

Download all files (.zip)

Includes all 6 files below. You can also preview or download each file individually.

  • ClockTests.cs

    Five tests covering expiry, delays, and cancellation

    Download
    Preview contents of ClockTests.cs
    using Microsoft.Extensions.Time.Testing;
    using Xunit.Abstractions;
    
    namespace Newsletter.Clock;
    
    public sealed class ClockTests(ITestOutputHelper output)
    {
        [Fact]
        public void Invitation_expires_at_the_boundary()
        {
            var start = new DateTimeOffset(2026, 9, 26, 10, 0, 0, TimeSpan.Zero);
            var clock = new FakeTimeProvider(start);
            var policy = new InvitationPolicy(clock);
            var expiresAt = start.AddMinutes(5);
    
            clock.Advance(TimeSpan.FromMinutes(5) - TimeSpan.FromTicks(1));
            Assert.True(policy.CanAccept(expiresAt));
            output.WriteLine("One tick before expiry: accepted");
    
            clock.Advance(TimeSpan.FromTicks(1));
            Assert.False(policy.CanAccept(expiresAt));
            output.WriteLine("Exactly at expiry: rejected");
    
            clock.Advance(TimeSpan.FromTicks(1));
            Assert.False(policy.CanAccept(expiresAt));
            output.WriteLine("One tick after expiry: rejected");
        }
    
        [Fact]
        public async Task Advance_completes_a_provider_backed_delay()
        {
            var clock = new FakeTimeProvider();
            Task delay = Task.Delay(TimeSpan.FromMinutes(5), clock);
            Assert.False(delay.IsCompleted);
    
            clock.Advance(TimeSpan.FromMinutes(5));
            await delay.WaitAsync(TimeSpan.FromSeconds(5));
            Assert.True(delay.IsCompletedSuccessfully);
            output.WriteLine("Five-minute delay: completed after Advance");
        }
    
        [Fact]
        public async Task Advance_does_not_complete_a_system_delay()
        {
            var clock = new FakeTimeProvider();
            using var cancellation = new CancellationTokenSource();
            Task delay = Task.Delay(TimeSpan.FromMinutes(5), cancellation.Token);
    
            clock.Advance(TimeSpan.FromMinutes(5));
            Assert.False(delay.IsCompleted);
            output.WriteLine("System delay: still waiting after Advance");
    
            cancellation.Cancel();
            await Assert.ThrowsAnyAsync<OperationCanceledException>(() => delay);
        }
    
        [Fact]
        public async Task Provider_backed_timeout_cancels_work_at_its_deadline()
        {
            var clock = new FakeTimeProvider();
            using var timeout = new CancellationTokenSource(TimeSpan.FromMinutes(1), clock);
            Task work = Task.Delay(TimeSpan.FromHours(1), clock, timeout.Token);
    
            clock.Advance(TimeSpan.FromMinutes(1) - TimeSpan.FromTicks(1));
            Assert.False(timeout.IsCancellationRequested);
            Assert.False(work.IsCompleted);
            output.WriteLine("One tick before timeout: work pending");
    
            clock.Advance(TimeSpan.FromTicks(1));
            await Assert.ThrowsAnyAsync<OperationCanceledException>(
                () => work.WaitAsync(TimeSpan.FromSeconds(5)));
            Assert.True(timeout.IsCancellationRequested);
            output.WriteLine("Exactly at timeout: work canceled");
        }
    
        [Fact]
        public void Advance_does_not_trigger_a_system_timeout()
        {
            var clock = new FakeTimeProvider();
            using var timeout = new CancellationTokenSource(TimeSpan.FromMinutes(1));
    
            clock.Advance(TimeSpan.FromMinutes(1));
            Assert.False(timeout.IsCancellationRequested);
            output.WriteLine("System timeout: still pending after Advance");
        }
    }
    
  • InvitationPolicy.cs

    The invitation expiry rule used in the article

    Download
    Preview contents of InvitationPolicy.cs
    namespace Newsletter.Clock;
    
    public sealed class InvitationPolicy(TimeProvider clock)
    {
        public bool CanAccept(DateTimeOffset expiresAt) =>
            clock.GetUtcNow() < expiresAt;
    }
    
  • mnw_215.csproj

    .NET 10 test project with pinned package versions

    Download
    Preview contents of mnw_215.csproj
    <Project Sdk="Microsoft.NET.Sdk">
    
      <PropertyGroup>
        <TargetFramework>net10.0</TargetFramework>
        <ImplicitUsings>enable</ImplicitUsings>
        <Nullable>enable</Nullable>
        <IsPackable>false</IsPackable>
      </PropertyGroup>
    
      <ItemGroup>
        <PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" Version="10.8.0" />
        <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
        <PackageReference Include="xunit" Version="2.9.3" />
        <PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
      </ItemGroup>
    
      <ItemGroup>
        <Using Include="Xunit" />
      </ItemGroup>
    
    </Project>
    
  • global.json

    SDK selection for the .NET 10 project

    Download
    Preview contents of global.json
    {
      "sdk": {
        "version": "10.0.100",
        "rollForward": "latestFeature",
        "allowPrerelease": false
      }
    }
    
  • README.md

    Setup, expected results, and changes to try

    Download
    Preview contents of README.md
    # TimeProvider lab
    
    Companion to [Test Time-Dependent Code Without Waiting](https://milanjovanovic.tech/blog/test-time-dependent-code-without-waiting).
    The [lab page](https://milanjovanovic.tech/labs/timeprovider) includes file previews and individual downloads.
    
    ## Run the tests
    
    Install the .NET 10 SDK, download and extract `timeprovider.zip`, then open a terminal in the `timeprovider` folder inside it.
    If you are using the repository, run from `samples/mnw_215` instead.
    The first run restores the packages pinned in `mnw_215.csproj`:
    
    ```sh
    dotnet test --logger "console;verbosity=detailed"
    ```
    
    All five tests should pass. Compare the messages with `output.txt`; paths, test order, and elapsed times can differ.
    The APIs in the article are available from .NET 8, but this project targets .NET 10.
    
    ## What to look for
    
    - An invitation is accepted one tick before expiry, rejected exactly at expiry, and rejected one tick after it.
    - A provider-backed five-minute delay completes after advancing fake time by five minutes.
    - A system-clock delay remains pending after the same advance. The test cancels it explicitly to clean up.
    - A provider-backed one-minute timeout cancels the pending work at its deadline.
    - A system-clock timeout remains pending after advancing the fake clock.
    
    Every test owns a fresh `FakeTimeProvider`.
    The five-second `WaitAsync` calls use real time to fail a broken test instead of leaving it waiting indefinitely.
    They do not control the invitation expiry or the operation's timeout.
    
    ## Change one thing at a time
    
    1. In `InvitationPolicy.cs`, change `< expiresAt` to `<= expiresAt` and run the tests. The assertion exactly at expiry fails with `Expected: False, Actual: True`. Restore `<`.
    2. In `Advance_completes_a_provider_backed_delay`, remove `clock` from `Task.Delay(TimeSpan.FromMinutes(5), clock)`. The delay now uses real time and the test fails with a `TimeoutException` after its five-second watchdog. Restore the argument.
    3. In `Provider_backed_timeout_cancels_work_at_its_deadline`, remove `clock` from the `CancellationTokenSource` constructor. Advancing fake time no longer triggers the timeout, so the test fails through the watchdog. Restore the provider, then remove `timeout.Token` from the work's `Task.Delay` instead. Cancellation no longer reaches the work, and the same test fails. Restore the token.
    
    After restoring each edit, all five tests should pass again.
    To run just the timeout test:
    
    ```sh
    dotnet test --filter FullyQualifiedName~Provider_backed_timeout_cancels_work_at_its_deadline
    ```
    
    ## What the fake clock does not control
    
    Create each timer before calling `Advance`.
    For an asynchronous retry loop, wait until the next delay has been scheduled before advancing again.
    Direct reads of `DateTime.UtcNow`, ordinary delays, and clocks in other processes are unaffected.
    Cancellation is cooperative; an HTTP client or database driver still has to observe its token.
    
    References: [TimeProvider overview](https://learn.microsoft.com/en-us/dotnet/standard/datetime/timeprovider-overview), [FakeTimeProvider testing](https://learn.microsoft.com/en-us/dotnet/core/extensions/timeprovider-testing), [CancellationTokenSource constructors](https://learn.microsoft.com/en-us/dotnet/api/system.threading.cancellationtokensource.-ctor?view=net-10.0).
    
  • output.txt

    Captured output from a local run of all five tests

    Download
    Preview contents of output.txt
      Determining projects to restore...
      Restored C:\Users\milan\source\repos\business\milanjovanovic.tech\.worktrees\mnw-213-clock\samples\mnw_215\mnw_215.csproj (in 156 ms).
      mnw_215 -> C:\Users\milan\source\repos\business\milanjovanovic.tech\.worktrees\mnw-213-clock\samples\mnw_215\bin\Debug\net10.0\mnw_215.dll
    Test run for C:\Users\milan\source\repos\business\milanjovanovic.tech\.worktrees\mnw-213-clock\samples\mnw_215\bin\Debug\net10.0\mnw_215.dll (.NETCoreApp,Version=v10.0)
    A total of 1 test files matched the specified pattern.
    C:\Users\milan\source\repos\business\milanjovanovic.tech\.worktrees\mnw-213-clock\samples\mnw_215\bin\Debug\net10.0\mnw_215.dll
    [xUnit.net 00:00:00.00] xUnit.net VSTest Adapter v3.1.4+50e68bbb8b (64-bit .NET 10.0.9)
    [xUnit.net 00:00:00.05]   Discovering: mnw_215
    [xUnit.net 00:00:00.07]   Discovered:  mnw_215
    [xUnit.net 00:00:00.08]   Starting:    mnw_215
    [xUnit.net 00:00:00.11]   Finished:    mnw_215
      Passed Newsletter.Clock.ClockTests.Invitation_expires_at_the_boundary [3 ms]
      Standard Output Messages:
     One tick before expiry: accepted
     Exactly at expiry: rejected
     One tick after expiry: rejected
    
    
      Passed Newsletter.Clock.ClockTests.Advance_does_not_complete_a_system_delay [1 ms]
      Standard Output Messages:
     System delay: still waiting after Advance
    
    
      Passed Newsletter.Clock.ClockTests.Provider_backed_timeout_cancels_work_at_its_deadline [1 ms]
      Standard Output Messages:
     One tick before timeout: work pending
     Exactly at timeout: work canceled
    
    
      Passed Newsletter.Clock.ClockTests.Advance_does_not_trigger_a_system_timeout [< 1 ms]
      Standard Output Messages:
     System timeout: still pending after Advance
    
    
      Passed Newsletter.Clock.ClockTests.Advance_completes_a_provider_backed_delay [< 1 ms]
      Standard Output Messages:
     Five-minute delay: completed after Advance
    
    
    
    Test Run Successful.
    Total tests: 5
         Passed: 5
     Total time: 0.4761 Seconds
    

2. Run it

Open a terminal in that folder and run:

dotnet test --logger "console;verbosity=detailed"

All five tests should pass. The detailed logger prints what each test observes; compare those messages with output.txt. Your paths and timings will differ.

Each test creates its own FakeTimeProvider. The five-second WaitAsync calls use real time as a watchdog: if a change leaves an operation pending, the test fails instead of hanging. Passing tests do not wait for those five seconds.

3. What the tests show

  1. Invitation expiry. CanAccept returns true one tick before expiry, false exactly at expiry, and false one tick after it. The test advances the clock to each instant without waiting five minutes.
  2. A delay using the fake clock. The five-minute delay starts pending. Advancing the provider by five minutes completes it.
  3. A delay using the system clock. Advancing the fake clock leaves this delay pending. The test cancels it afterward so no work is left waiting.
  4. Cancellation at a deadline. Work is still pending one tick before the one-minute timeout. At the deadline, the token source cancels and the delay observes that cancellation. Both use the same fake clock.
  5. A timeout using the system clock. Advancing the fake clock does not cancel a token source created without the provider. This catches the same clock mismatch as the system-delay test.

4. Change something

Make one change at a time, rerun the tests, and restore the original code before trying the next one:

  1. In InvitationPolicy.cs, change < expiresAt to <= expiresAt. The assertion exactly at expiry should fail because the invitation is now accepted at its deadline.
  2. In ClockTests.cs, find Provider_backed_timeout_cancels_work_at_its_deadline and remove clock from the CancellationTokenSource constructor. Advancing the fake clock no longer triggers cancellation. The five-second watchdog should fail the test with a TimeoutException instead of the expected cancellation.
  3. In that same test, keep clock in the constructor but remove timeout.Token from Task.Delay. The token source cancels at the deadline, but the delay cannot observe it. The watchdog should report the same failure.

Restore all edits and run the command once more. You should have five passing tests again. These small changes are a useful check that your tests detect the boundary and cancellation bugs you care about.