Background and motivation
FakeTimeProvider (in Microsoft.Extensions.TimeProvider.Testing) is the deterministic clock that test code uses to drive timer-based production logic. Production code calls timeProvider.CreateTimer(callback, state, dueTime, period); the timer registers with the fake provider; when tests call Advance(...), registered callbacks fire if their due-time falls within the advanced window.
The package gives consumers full control over time, but no way to observe what's registered. The internal field that holds active timer entries has no public accessor, which forces every test of timer-related behaviour to assert indirectly via the side-effects of timer-fire callbacks.
That indirection conflates four distinct things into a single assertion:
- Was a timer registered at all?
- With the right period and due-time?
- Without a duplicate registration?
- Did the callback execute correctly when the timer fired?
When a side-effect-based assertion fails, the test cannot distinguish which of the four broke. Several real-world bug shapes are structurally invisible to side-effect tests:
| Bug shape |
Today's side-effect test |
Why it fails to catch the bug |
Duplicate timer registration after a botched cleanup (e.g., StopAsync didn't dispose, StartAsync adds a second instance) |
Advance time, count callback fires (or log entries) |
Test sees "at least N expected fires, pass." Two timers fire instead of one and log volume doubles, but the assertion is satisfied because the lower bound met. |
| Timer not registered at all because of a configuration / feature-flag misconfiguration |
Advance to expected fire-time, assert that the side-effect-of-the-fire happens |
Test sees "no fire, fail" but the failure is identical to "fire happened, side-effect masked by an unrelated bug." Cannot localise. |
| Per-instance timer leaked because the per-instance scope was disposed but its timer wasn't |
Advance, observe consumer-visible state, assertion passes |
Memory growth from leaked timer entries is invisible to functional tests; only surfaces under load or long-running scenarios. |
| Restart cleanliness (start → stop → start) double-registering |
Functional tests on the second start pass; the timer registry has 2× entries |
No way to assert "after restart, timer count equals fresh-start count." |
| Conflating "registered correctly" with "callback ran correctly" (e.g., wrong period passed, callback runs at the wrong cadence) |
Wall-clock-and-count assertions |
Test sees "callback ran N times" but cannot directly verify "registered with period 10s." The cadence has to be reverse-engineered from the side-effects. |
The common pattern: these are all state assertions, not behaviour assertions. They need to verify what the timer registry is, not what it does. A read-only API on FakeTimeProvider exposing a snapshot of currently-registered timers would let test-tooling libraries (and consumer test code directly) assert on registration state at the precise moment of registration, before any time has been advanced.
The existing internal data model holds everything that's needed; the proposal is to expose a controlled view of it as a minimal additive surface.
Background context for the design: an open-source assertion-helper library in the .NET ecosystem currently has planned assertions blocked on the lack of this API. The proposal originates from that real-world adoption pressure rather than from theoretical observability needs.
API Proposal
namespace Microsoft.Extensions.Time.Testing;
public sealed class FakeTimeProvider : TimeProvider
{
/// <summary>
/// Returns a snapshot of timers currently scheduled but not yet fired.
/// </summary>
/// <remarks>
/// The returned collection is a point-in-time snapshot. Subsequent calls to
/// <see cref="Advance"/>, <see cref="SetUtcNow"/>, or any consumer-side timer
/// disposal may invalidate it. Repeated calls allocate a fresh snapshot.
/// </remarks>
public IReadOnlyCollection<FakeTimerInfo> GetActiveTimers();
}
/// <summary>
/// Read-only information about a timer currently registered with a <see cref="FakeTimeProvider"/>.
/// </summary>
public readonly struct FakeTimerInfo : IEquatable<FakeTimerInfo>
{
/// <summary>
/// Time until the timer's next scheduled fire, relative to the
/// <see cref="FakeTimeProvider"/>'s current UTC time at the moment
/// <see cref="FakeTimeProvider.GetActiveTimers"/> was called.
/// </summary>
public TimeSpan DueTime { get; }
/// <summary>
/// Period between subsequent fires for periodic timers;
/// <see cref="Timeout.InfiniteTimeSpan"/> for one-shot timers.
/// </summary>
public TimeSpan Period { get; }
/// <summary>
/// Whether the timer fires repeatedly.
/// </summary>
/// <value>
/// <see langword="true"/> when <see cref="Period"/> is not
/// <see cref="Timeout.InfiniteTimeSpan"/>.
/// </value>
public bool IsPeriodic { get; }
public bool Equals(FakeTimerInfo other);
public override bool Equals(object? obj);
public override int GetHashCode();
public static bool operator ==(FakeTimerInfo left, FakeTimerInfo right);
public static bool operator !=(FakeTimerInfo left, FakeTimerInfo right);
}
The struct is readonly to avoid mutation surface and IEquatable<FakeTimerInfo> so consumers can compose with IEnumerable<T> LINQ helpers (Distinct, GroupBy, etc.) without boxing.
The method form (rather than a property) signals to readers that the call allocates a fresh snapshot. Every invocation walks the internal registry under whatever locking the implementation already uses, materialises a snapshot, and returns it; consumer-visible state is unchanged.
API Usage
The proposal enables direct registration-state assertions in test code. Examples below use xUnit-style assertions; equivalent patterns work in any test framework.
// At construction time, before any Advance: assert the expected timer count.
// Catches both "no timer registered" and "duplicate timer registered" in one
// assertion, before any side-effects can mask the issue.
var fakeTime = new FakeTimeProvider();
var service = new MyHostedService(fakeTime);
await service.StartAsync(ct);
Assert.Equal(3, fakeTime.GetActiveTimers().Count);
// e.g., 3 = ping loop + session timeout + heartbeat
// After StopAsync: clean shutdown contract.
// Today this can only be inferred indirectly (e.g., by observing that callbacks
// no longer run after Advance). With this API, it becomes a direct state assertion.
await service.StopAsync(ct);
Assert.Empty(fakeTime.GetActiveTimers());
// Restart cleanliness: start, stop, start again should not leak.
// Currently impossible to verify directly; this catches the "didn't dispose
// timers in StopAsync, second StartAsync added a duplicate set" class of bug.
await service.StartAsync(ct);
await service.StopAsync(ct);
await service.StartAsync(ct);
Assert.Equal(3, fakeTime.GetActiveTimers().Count); // not 6
// Predicate-based identification: assert a specific timer's period without
// brittle index-based access into the snapshot collection.
Assert.Contains(
fakeTime.GetActiveTimers(),
timer => timer.Period == TimeSpan.FromSeconds(10));
// Feature-flag negative case: when a flag is off, the corresponding timer
// should NOT be registered. Today this can only be inferred from the absence
// of side-effects, which conflates "no timer" with "timer fired but consumer
// chose not to react." The negative case becomes a direct assertion.
service.UseFeatureX = false;
await service.StartAsync(ct);
Assert.DoesNotContain(
fakeTime.GetActiveTimers(),
timer => timer.Period == TimeSpan.FromMinutes(30));
// Test-library composition: a higher-level assertion library can wrap the
// snapshot in idiomatic chains (e.g. for fluent test frameworks). Without
// this API, the library author has no observable surface to wrap.
These are state assertions: they pass or fail at the moment of registration, before any time has been advanced, and they distinguish "registered with the right shape" from "callback executed correctly." That distinction catches the entire class of bugs in the table above.
Alternative Designs
Property form IReadOnlyCollection<FakeTimerInfo> ActiveTimers { get; }
Considered. Method form preferred because:
- The implementation has to walk a synchronisation-protected internal collection and allocate a fresh snapshot on each call. Properties imply cheap reads; the
Get* prefix signals the allocation cost.
- A property would suggest the snapshot has lifetime semantics (cached / stable until invalidated). Method form makes "this is a snapshot at call time" obvious.
Either form satisfies the use cases; happy to switch if the API review prefers property semantics with documented allocation behaviour.
Expose the internal Waiter-shaped type directly
Considered and rejected. The internal type carries:
- The user-supplied callback delegate (security-sensitive; should not flow back through a public API)
- The user-supplied state object (potentially holds references the test author doesn't want exposed)
- Implementation-internal scheduling fields
A new public read-only struct (FakeTimerInfo) gives the API designer control over what's exposed. The internal storage type stays internal.
Include the user-supplied state object on FakeTimerInfo
Considered. Rejected from this initial proposal because:
- It exposes consumer-passed objects through a test-observability API. Consumers may pass references that hold sensitive data or large object graphs they don't expect to flow through the test surface.
- The use cases documented above are all satisfied by
DueTime + Period (count, period match, predicate over period).
- If real consumer demand for state introspection emerges later, adding a
State property to FakeTimerInfo is a clean additive follow-up that doesn't break anything in this initial proposal.
The Alternative Designs section can be revisited if the API review wants State in this initial proposal; flagging it explicitly so reviewers see the trade-off.
Add an Identifier field for distinguishing timers without relying on Period
Considered. Rejected from this initial proposal because:
- The
CreateTimer API doesn't accept a name; there's no upstream-supplied identifier to surface.
- Predicate-based identification on
Period (and any future State) covers the documented patterns.
- Adding an
Identifier field would require a corresponding CreateTimer overload that accepts a name, expanding the surface significantly. Not in scope for this proposal.
Risks
| Risk |
Mitigation |
Allocation on every call. Each GetActiveTimers() invocation allocates a snapshot collection. Tests that call it in tight loops would allocate. |
The use case is test code asserting on registry state, typically once or a few times per test, not in a hot loop. The allocation cost is negligible relative to a typical test runtime. Documenting that the method allocates a fresh snapshot makes the trade-off explicit. |
Thread-safety with concurrent Advance. If GetActiveTimers() is called concurrently with Advance, the snapshot might miss a timer that's about to fire (or include one that's about to be removed). |
Document the snapshot semantics: "point-in-time view of the registry at the moment of the call; subsequent operations may invalidate the contents." Tests rarely race GetActiveTimers against Advance; both are typically sequential test-control operations. |
Surface-area expansion. Two new public types (one method, one struct) added to FakeTimeProvider. |
The new types are minimal, documented, and don't create generic-type or reflection surface. Existing consumers see no change. |
Encourages over-testing of registration internals. Test authors might write tests that pin specific DueTime values that aren't really part of the production contract. |
Same risk as any state-assertion API; mitigated by documentation. The cost of some over-testing is acceptable in exchange for closing the entire bug-shape category that side-effect tests can't catch. |
Disagreement about whether State should be in the initial proposal. |
Documented under Alternative Designs as an explicit deferred decision. The proposed surface (without State) covers all documented use cases; adding State later is a clean additive change. |
Happy to PR the implementation once the API design is approved. The internal data is already collected; the implementation surface is modest (snapshot allocation + one struct), and the test surface covers registration-count, period-predicate, and snapshot-after-advance scenarios.
Background and motivation
FakeTimeProvider(inMicrosoft.Extensions.TimeProvider.Testing) is the deterministic clock that test code uses to drive timer-based production logic. Production code callstimeProvider.CreateTimer(callback, state, dueTime, period); the timer registers with the fake provider; when tests callAdvance(...), registered callbacks fire if their due-time falls within the advanced window.The package gives consumers full control over time, but no way to observe what's registered. The internal field that holds active timer entries has no public accessor, which forces every test of timer-related behaviour to assert indirectly via the side-effects of timer-fire callbacks.
That indirection conflates four distinct things into a single assertion:
When a side-effect-based assertion fails, the test cannot distinguish which of the four broke. Several real-world bug shapes are structurally invisible to side-effect tests:
StopAsyncdidn't dispose,StartAsyncadds a second instance)The common pattern: these are all state assertions, not behaviour assertions. They need to verify what the timer registry is, not what it does. A read-only API on
FakeTimeProviderexposing a snapshot of currently-registered timers would let test-tooling libraries (and consumer test code directly) assert on registration state at the precise moment of registration, before any time has been advanced.The existing internal data model holds everything that's needed; the proposal is to expose a controlled view of it as a minimal additive surface.
Background context for the design: an open-source assertion-helper library in the .NET ecosystem currently has planned assertions blocked on the lack of this API. The proposal originates from that real-world adoption pressure rather than from theoretical observability needs.
API Proposal
The struct is
readonlyto avoid mutation surface andIEquatable<FakeTimerInfo>so consumers can compose withIEnumerable<T>LINQ helpers (Distinct,GroupBy, etc.) without boxing.The method form (rather than a property) signals to readers that the call allocates a fresh snapshot. Every invocation walks the internal registry under whatever locking the implementation already uses, materialises a snapshot, and returns it; consumer-visible state is unchanged.
API Usage
The proposal enables direct registration-state assertions in test code. Examples below use xUnit-style assertions; equivalent patterns work in any test framework.
These are state assertions: they pass or fail at the moment of registration, before any time has been advanced, and they distinguish "registered with the right shape" from "callback executed correctly." That distinction catches the entire class of bugs in the table above.
Alternative Designs
Property form
IReadOnlyCollection<FakeTimerInfo> ActiveTimers { get; }Considered. Method form preferred because:
Get*prefix signals the allocation cost.Either form satisfies the use cases; happy to switch if the API review prefers property semantics with documented allocation behaviour.
Expose the internal
Waiter-shaped type directlyConsidered and rejected. The internal type carries:
A new public read-only struct (
FakeTimerInfo) gives the API designer control over what's exposed. The internal storage type stays internal.Include the user-supplied
stateobject onFakeTimerInfoConsidered. Rejected from this initial proposal because:
DueTime+Period(count, period match, predicate over period).Stateproperty toFakeTimerInfois a clean additive follow-up that doesn't break anything in this initial proposal.The Alternative Designs section can be revisited if the API review wants
Statein this initial proposal; flagging it explicitly so reviewers see the trade-off.Add an
Identifierfield for distinguishing timers without relying onPeriodConsidered. Rejected from this initial proposal because:
CreateTimerAPI doesn't accept a name; there's no upstream-supplied identifier to surface.Period(and any futureState) covers the documented patterns.Identifierfield would require a correspondingCreateTimeroverload that accepts a name, expanding the surface significantly. Not in scope for this proposal.Risks
GetActiveTimers()invocation allocates a snapshot collection. Tests that call it in tight loops would allocate.Advance. IfGetActiveTimers()is called concurrently withAdvance, the snapshot might miss a timer that's about to fire (or include one that's about to be removed).GetActiveTimersagainstAdvance; both are typically sequential test-control operations.FakeTimeProvider.DueTimevalues that aren't really part of the production contract.Stateshould be in the initial proposal.State) covers all documented use cases; addingStatelater is a clean additive change.Happy to PR the implementation once the API design is approved. The internal data is already collected; the implementation surface is modest (snapshot allocation + one struct), and the test surface covers registration-count, period-predicate, and snapshot-after-advance scenarios.