[API Proposal] FakeTimeProvider: expose active-timer snapshot for testability
- Dominant language
- C#
- Stars
- 3.2k
- Forks
- 894
- Avg merge
- 1d 12h
- Merged PRs (30d)
- 23
Description
### 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:
1. Was a timer registered at all?
2. With the right period and due-time?
3. Without a duplicate registration?
4. 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
```csharp
namespace Microsoft.Extensions.Time.Testing;
public sealed class FakeTimeProvider : TimeProvider
{
///
/// Returns a snapshot of timers currently scheduled but not yet fired.
///
///
/// The returned collection is a point-in-time snapshot. Subsequent calls to
/// , , or any consumer-side timer
/// disposal may invalidate it. Repeated calls allocate a fresh snapshot.
///
public IReadOnlyCollection GetActiveTimers();
}
///
/// Read-only information about a timer currently registered with a .
///
public readonly struct FakeTimerInfo : IEquatable
{
///
/// Time until the timer's next scheduled fire, relative to the
/// 's current UTC time at the moment
/// was called.
///
public TimeSpan DueTime { get; }
///
/// Period between subsequent fires for periodic timers;
/// for one-shot timers.
///
public TimeSpan Period { get; }
///
/// Whether the timer fires repeatedly.
///
///
/// when is not
/// .
///
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` so consumers can compose with `IEnumerable` 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.
```csharp
// 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 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.
Contributor guide
Assessment
This issue has not been assessed yet.