TimeProvider lab
By Milan Jovanović · .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.
Includes all 6 files below. You can also preview or download each file individually.
- Download
ClockTests.cs
Five tests covering expiry, delays, and cancellation
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"); } } - Download
InvitationPolicy.cs
The invitation expiry rule used in the article
Preview contents of InvitationPolicy.cs
namespace Newsletter.Clock; public sealed class InvitationPolicy(TimeProvider clock) { public bool CanAccept(DateTimeOffset expiresAt) => clock.GetUtcNow() < expiresAt; } - Download
mnw_215.csproj
.NET 10 test project with pinned package versions
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> - Download
global.json
SDK selection for the .NET 10 project
Preview contents of global.json
{ "sdk": { "version": "10.0.100", "rollForward": "latestFeature", "allowPrerelease": false } } - Download
README.md
Setup, expected results, and changes to try
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). - Download
output.txt
Captured output from a local run of all five tests
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
- 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.
- A delay using the fake clock. The five-minute delay starts pending. Advancing the provider by five minutes completes it.
- 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.
- 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.
- 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:
- In
InvitationPolicy.cs, change< expiresAtto<= expiresAt. The assertion exactly at expiry should fail because the invitation is now accepted at its deadline. - In
ClockTests.cs, findProvider_backed_timeout_cancels_work_at_its_deadlineand removeclockfrom theCancellationTokenSourceconstructor. Advancing the fake clock no longer triggers cancellation. The five-second watchdog should fail the test with aTimeoutExceptioninstead of the expected cancellation. - In that same test, keep
clockin the constructor but removetimeout.TokenfromTask.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.