TimeAssertions.TUnit
0.7.0
Prefix Reserved
See the version list below for details.
dotnet add package TimeAssertions.TUnit --version 0.7.0
NuGet\Install-Package TimeAssertions.TUnit -Version 0.7.0
<PackageReference Include="TimeAssertions.TUnit" Version="0.7.0" />
<PackageVersion Include="TimeAssertions.TUnit" Version="0.7.0" />
<PackageReference Include="TimeAssertions.TUnit" />
paket add TimeAssertions.TUnit --version 0.7.0
#r "nuget: TimeAssertions.TUnit, 0.7.0"
#:package TimeAssertions.TUnit@0.7.0
#addin nuget:?package=TimeAssertions.TUnit&version=0.7.0
#tool nuget:?package=TimeAssertions.TUnit&version=0.7.0
TimeAssertions.TUnit
Scope: Test projects only. Not intended for production code.
TUnit-native fluent time-assertion DSL on top of Microsoft.Extensions.Time.Testing.FakeTimeProvider. Adds FakeTimeProvider state assertions, TimeProvider-aware DateTimeOffset recency / past / future checks, plus the cross-cutting .WithinTimeBudget(TimeSpan) chain extension. AOT-compatible, trimmable, no reflection.
Full documentation, "Why TimeProvider in tests", cookbook, design notes, and roadmap: github.com/JohnVerheij/TimeAssertions.TUnit
Install
dotnet add package TimeAssertions.TUnit
TimeAssertions (the framework-agnostic core) and Microsoft.Extensions.TimeProvider.Testing come transitively. Requirements: TUnit 1.49.0 or later, .NET 10.
The source-generated entry points (HasAdvancedExactly, HasAdvancedApproximately, HasUtcNow, HasUtcNowApproximately, IsRecent, IsBeforeNow, IsAfterNow, WithinTimeBudget, WithinTimeBudgetCapturing, WasInvokedAtMostOncePer, HasNoActiveTimers, HasActiveTimerCount, HasActiveTimers, HasAtLeastActiveTimerCount, HasNoActiveTimersEventually, HasActiveTimerCountEventually, HasNextTimerDueApproximately, HasPendingTimerDueWithin) auto-import via TUnit.Assertions.Extensions. Add the following to a GlobalUsings.cs in your test project for the call-site and FakeTimeProvider namespaces:
global using Microsoft.Extensions.Time.Testing;
global using TimeAssertions;
global using TimeAssertions.TUnit;
Quick start
[Test]
public async Task PreReleaseExpiration_advances_state_after_clock_moves_forward()
{
var fakeTime = new FakeTimeProvider();
var service = new ExpirationService(fakeTime);
fakeTime.Advance(TimeSpan.FromMinutes(31));
await Assert.That(fakeTime).HasAdvancedExactly(TimeSpan.FromMinutes(31));
await Assert.That(service.LastRefresh).IsRecent(TimeSpan.FromSeconds(1), fakeTime);
// Cross-cutting timing budget on any behavioural assertion chain
await Assert.That(service.IsExpiredAsync())
.IsTrue()
.And.WithinTimeBudget(TimeSpan.FromMilliseconds(500));
}
Entry points
| Method | Purpose |
|---|---|
HasAdvancedExactly(TimeSpan) / HasAdvancedApproximately(total, tolerance) |
FakeTimeProvider advanced by exact / approximate amount (renamed from HasAdvanced / HasAdvancedBy in v0.2.0; old names [Obsolete] until v0.4.0) |
HasUtcNow(DateTimeOffset) / HasUtcNowApproximately(expected, tolerance) |
FakeTimeProvider is at exact / approximate moment |
IsRecent(TimeSpan, TimeProvider?) |
DateTimeOffset is within window before "now" of supplied (or system) clock |
IsBeforeNow(TimeProvider) / IsAfterNow(TimeProvider) |
DateTimeOffset ordering relative to supplied clock |
WithinTimeBudget(TimeSpan) |
Cross-cutting timing budget; chains via .And after any behavioural assertion |
WithinTimeBudgetCapturing(TimeSpan, Action<TimeSpan>) |
Same as WithinTimeBudget plus a callback that receives the measured elapsed on every evaluation path except external cancellation (added in v0.2.0; cancellation-skip behaviour added in v0.5.0) |
WasInvokedAtMostOncePer(this IReadOnlyList<DateTimeOffset>, TimeSpan interval) |
Rate-limit assertion on a recorded invocation log: every consecutive gap is at least interval (added in v0.5.0) |
HasNoActiveTimers() / HasActiveTimerCount(int) on ObservableTimeProvider |
Timer-leak assertions: no undisposed timers / exact active-timer count, naming each survivor by its schedule on failure (added in v0.6.0) |
HasActiveTimers() / HasAtLeastActiveTimerCount(int) on ObservableTimeProvider |
Positive-count assertions: at least one / at least count active timers, for a lower bound rather than an exact count (added in v0.7.0) |
HasNoActiveTimersEventually(TimeSpan, ...) / HasActiveTimerCountEventually(int, TimeSpan, ...) on ObservableTimeProvider |
Real-time poll until the active count reaches zero / a target count, for the asynchronous disposal race a synchronous check cannot see (added in v0.7.0) |
HasNextTimerDueApproximately(TimeSpan, TimeSpan) / HasPendingTimerDueWithin(TimeSpan, TimeSpan) on ObservableTimeProvider |
Pending-timer due-time assertions: inspect the next scheduled timer's due time without advancing the clock, within a tolerance or an inclusive range (added in v0.6.0) |
Failure diagnostics
On a failed assertion, the exception message includes the elapsed / expected duration, the absolute drift, and (for budget overruns) the overshoot plus a grep-friendly (elapsed=Xms, budget=Yms, overrun=Zms) suffix for log scrapers. No Console.WriteLine debugging needed: every dimension you can assert on is also rendered in the failure message.
Full failure-diagnostics example, design notes, stability intent, and roadmap on GitHub.
Family
Part of an assertion family for TUnit:
- LogAssertions.TUnit
- SnapshotAssertions.TUnit
- MathAssertions.TUnit
- JsonAssertions.TUnit
- SseAssertions.TUnit
- GrpcAssertions.TUnit
License
MIT. Copyright (c) 2026 John Verheij.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Microsoft.Extensions.TimeProvider.Testing (>= 10.6.0)
- TimeAssertions (>= 0.7.0)
- TUnit.Assertions (>= 1.48.6)
- TUnit.Core (>= 1.48.6)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
View the rendered release notes: https://github.com/JohnVerheij/TimeAssertions.TUnit/releases/tag/v0.7.0
Minor release. Adds real-time "eventually" active-timer assertions for the asynchronous timer-disposal race a synchronous leak check cannot see, plus synchronous positive-count assertions to express a lower bound on the active set.
### Added
- **`Assert.That(provider).HasNoActiveTimersEventually(TimeSpan timeout, TimeSpan? pollingInterval = null, CancellationToken ct = default)`** (TUnit adapter) polls the live `ActiveTimerCount` on the real wall clock until it reaches zero, or the timeout elapses. A `BackgroundService` / `IHostedService` commonly disposes its timer on a continuation that runs after `StopAsync` returns to the caller, so a synchronous `HasNoActiveTimers()` check just after stop can still see the timer; the poll gives that continuation time to run. The condition is checked once before the first delay, so an already-clean provider passes without waiting. On timeout the failure names each surviving timer by its schedule with a grep-friendly `(count=N)` trailer.
- **`Assert.That(provider).HasActiveTimerCountEventually(int count, TimeSpan timeout, TimeSpan? pollingInterval = null, CancellationToken ct = default)`** (TUnit adapter) is the count-targeted sibling: polls until `ActiveTimerCount` equals `count`, for an active set that settles to a steady state on a background continuation. On timeout it renders the expected and actual counts with an `(expected=N, actual=M)` trailer plus the active timers' schedules.
- **`Assert.That(provider).HasActiveTimers()`** (TUnit adapter) passes when at least one timer is active: the positive-presence counterpart of `HasNoActiveTimers()` for the registration half of a leak test, without pinning the exact count. Source-generated via `[GenerateAssertion]`.
- **`Assert.That(provider).HasAtLeastActiveTimerCount(int count)`** (TUnit adapter) passes when the active count is at least `count`, for when a lower bound rather than an exact count is the natural expectation. On a shortfall it renders the required minimum and the actual count with a `(minimum=N, actual=M)` trailer plus the active timers' schedules. Source-generated via `[GenerateAssertion]`.
- **Positional-`CancellationToken` overloads** of `HasNoActiveTimersEventually` and `HasActiveTimerCountEventually` (TUnit adapter) let a token follow the timeout positionally while keeping the default poll interval, so the common case reads as `(timeout, ct)` / `(count, timeout, ct)` instead of the named `ct:` form. They forward to the canonical chain with `pollingInterval: null`; the token parameter has no default, so a bare `(timeout)` call stays unambiguous, matching TUnit's `WaitsFor` convention.
- **`TimeAssertions.TimeRenderingHelpers.FormatActiveTimerSurvivors(...)` and `FormatActiveTimerAtLeastShortfall(...)`** (framework-agnostic core) render the survivor list for the eventually-timeout messages and the at-least shortfall message, ordering survivors deterministically (by due time, then period) so the messages are snapshot-stable.
The poll uses a real `Task.Delay` loop against a wall-clock deadline rather than a fake-time advance: hosted-service timer disposal happens on a real asynchronous continuation, which a fake clock cannot drive. The default poll interval is 10 ms; supply your own via the optional `pollingInterval`. A canceled `CancellationToken` throws `OperationCanceledException` so the test is recorded as canceled rather than failed.