A TUnit-native fluent time-assertion DSL on top of Microsoft.Extensions.Time.Testing.FakeTimeProvider. Built using TUnit's [AssertionExtension] source generator, so the assertion entry points integrate directly into TUnit's Assert.That(...) pipeline. Adds TimeProvider-aware DateTimeOffset checks plus a cross-cutting .And.WithinTimeBudget(TimeSpan) chain extension that composes with any behavioural assertion.
Scope: Test projects only. Not intended for production code.
- Why this package
- Install
- Package layout
- Namespaces (and a
GlobalUsings.csrecommendation) - Quick start
- Why TimeProvider in tests
- Entry points
- Failure diagnostics
- Cookbook: common patterns
- Modern .NET 10+ practices on display
- Design notes
- Stability intent (pre-1.0)
- Limitations and future work
- Family compatibility
- Pair with
- Contributing
- License
Asserting on time-dependent behaviour during tests typically devolves into either:
- Manual
Assert.True(fakeTime.GetUtcNow() == expected, ...)plumbing in every test, or - Real-clock waits (
Thread.Sleep,Task.Delay) with arbitrary tolerances that produce flaky CI when the runner is loaded.
This library replaces both with a fluent DSL on top of Microsoft's recommended TimeProvider testability pattern, plus an assertion-level timing-budget extension that composes with any behavioural chain.
dotnet add package TimeAssertions.TUnitRequirements: TUnit 1.45.8 or later, .NET 10. TimeAssertions (the framework-agnostic core) and Microsoft.Extensions.TimeProvider.Testing come transitively. The package is AOT-compatible, trimmable, and uses no runtime reflection in the assertion path.
This repo ships two NuGet packages:
| Package | Purpose | Depends on |
|---|---|---|
TimeAssertions |
Framework-agnostic core: TimeRenderingHelpers for elapsed-duration / budget-overrun formatting |
BCL only |
TimeAssertions.TUnit |
TUnit-specific entry points: HasAdvancedExactly(), HasAdvancedApproximately(), HasUtcNow(), HasUtcNowApproximately(), IsRecent(), IsBeforeNow(), IsAfterNow(), WithinTimeBudget(), WithinTimeBudgetCapturing(), WasInvokedAtMostOncePer() |
TimeAssertions + TUnit.Assertions + TUnit.Core + Microsoft.Extensions.TimeProvider.Testing |
You install TimeAssertions.TUnit; TimeAssertions and Microsoft.Extensions.TimeProvider.Testing come transitively. Adapters for other test frameworks (NUnit, xUnit, MSTest) are not shipped today: they would reuse the TimeAssertions core. Open a feature request if you need one.
The two packages place types in two namespaces with deliberately-different scopes:
| Type / member | Namespace | Auto-imported? |
|---|---|---|
HasAdvancedExactly(), HasAdvancedApproximately(), HasUtcNow(), HasUtcNowApproximately(), IsRecent(), IsBeforeNow(), IsAfterNow(), WithinTimeBudget(), WithinTimeBudgetCapturing(), WasInvokedAtMostOncePer() (source-generated entries) |
TUnit.Assertions.Extensions |
Yes: TUnit auto-imports |
FakeTimeProvider (the testable-clock type) |
Microsoft.Extensions.Time.Testing |
No: needed at the call site; recommended for GlobalUsings.cs |
TimeRenderingHelpers (formatting utilities for failure messages) |
TimeAssertions |
No: needed at the call site; recommended for GlobalUsings.cs |
WithinTimeBudgetAssertion<T>, WithinTimeBudgetCapturingAssertion<T> (the assertion classes behind WithinTimeBudget() and WithinTimeBudgetCapturing()) |
TimeAssertions.TUnit |
No: needed at the call site; recommended for GlobalUsings.cs |
Recommended: put the three non-auto-imported namespaces into a single GlobalUsings.cs in your test project so every test file sees them without ceremony:
// tests/MyApp.Tests/GlobalUsings.cs
global using Microsoft.Extensions.Time.Testing;
global using TimeAssertions;
global using TimeAssertions.TUnit;using Microsoft.Extensions.Time.Testing;
[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 service.ProcessAsync(CancellationToken.None);
using (Assert.Multiple())
{
await Assert.That(fakeTime).HasAdvancedExactly(TimeSpan.FromMinutes(31));
await Assert.That(service.LastProcessedAt).IsRecent(TimeSpan.FromSeconds(1), fakeTime);
await Assert.That(service.NextRunAt).IsAfterNow(fakeTime);
}
}The Microsoft-recommended pattern for testable time in modern .NET (since .NET 8):
- Production code accepts an optional
TimeProviderparameter (defaults toTimeProvider.System). - Tests construct a
FakeTimeProviderand inject it instead. - Tests call
fakeTime.Advance(TimeSpan)orfakeTime.SetUtcNow(...)to drive time forward deterministically: noThread.Sleep, no flaky timing, no waiting for real wall-clock seconds to pass. - Tests assert that production code reacted correctly to the simulated time.
This package supplies the assertion side of step 4. Without it, you write boilerplate (Assert.True(fakeTime.GetUtcNow() == expected, ...)) for every time-dependent test. With it:
await Assert.That(fakeTime).HasUtcNow(expected);
await Assert.That(fakeTime).HasAdvancedExactly(TimeSpan.FromMinutes(5));
await Assert.That(timestamp).IsRecent(TimeSpan.FromSeconds(1), fakeTime);
await Assert.That(timestamp).IsBeforeNow(fakeTime);
await Assert.That(timestamp).IsAfterNow(fakeTime);For projects standardising on this pattern, TimeAssertions.TUnit is the TUnit-side test infrastructure that pays for itself test-by-test.
Four groups of entry points cover four distinct testing concerns: fake-clock state, TimeProvider-aware DateTimeOffset checks, assertion-level timing budgets, and rate-limit assertions on invocation timestamps.
| Entry point | Behaviour |
|---|---|
HasAdvancedExactly(TimeSpan total) |
Asserts fakeTime.GetUtcNow() - construction-time equals total exactly. Sanity check for Advance / SetUtcNow calls in test setup. |
HasAdvancedApproximately(TimeSpan total, TimeSpan tolerance) |
Same, with absolute tolerance. Useful when production code performs additional internal Advance calls. |
HasUtcNow(DateTimeOffset expected) |
Asserts fakeTime.GetUtcNow() equals expected exactly. |
HasUtcNowApproximately(DateTimeOffset expected, TimeSpan tolerance) |
Same, with absolute tolerance. Useful when the expected moment is computed from integer-truncated minute math or chained Advance calls with rounding rather than a literal. |
var fakeTime = new FakeTimeProvider();
fakeTime.Advance(TimeSpan.FromHours(2));
await Assert.That(fakeTime).HasAdvancedExactly(TimeSpan.FromHours(2));Renamed in v0.2.0. The previous names
HasAdvanced/HasAdvancedByare kept as[Obsolete]aliases through v0.3.x and removed in v0.4.0. The rename gives both names an explicit "Exactly" vs "Approximately" suffix for symmetry withHasUtcNow/HasUtcNowApproximately. Migrate via search-and-replace by name across the test suite.
Distinct from TUnit core's IsInPast() / IsInFuture() (which always use the system clock):
| Entry point | Behaviour |
|---|---|
IsRecent(TimeSpan window, TimeProvider? timeProvider = null) |
Asserts the timestamp is within the last window relative to the supplied TimeProvider's notion of "now". When timeProvider is null or omitted, falls back to TimeProvider.System (useful for end-to-end tests not running under a fake clock). |
IsBeforeNow(TimeProvider timeProvider) |
Strict-before-now check against the supplied time provider. |
IsAfterNow(TimeProvider timeProvider) |
Strict-after-now check. |
await Assert.That(service.LastProcessedAt).IsRecent(TimeSpan.FromSeconds(1), fakeTime);
await Assert.That(record.ExpiresAt).IsBeforeNow(fakeTime);
await Assert.That(service.NextRunAt).IsAfterNow(fakeTime);.And.WithinTimeBudget(TimeSpan) composes with any behavioural assertion. The wall-clock duration captured by TUnit's EvaluationMetadata<T>.Duration is compared against the budget; the chain fails if exceeded.
// Canonical pattern: .And.WithinTimeBudget(...) after any behavioural assertion
await Assert.That(asyncOp)
.IsEqualTo(expectedResult)
.And.WithinTimeBudget(TimeSpan.FromMilliseconds(500));
// Composes with sibling-family chains (LogAssertions, SnapshotAssertions, ...)
await Assert.That(collector)
.HasLoggedOnce()
.AtLevel(LogLevel.Error)
.And.WithinTimeBudget(TimeSpan.FromSeconds(2));.And.WithinTimeBudget() is post-facto, not cancellation. The wall-clock duration is captured around the assertion's evaluation; the chain fails if the budget is exceeded but does NOT abort the assertion mid-flight. For polling / streaming workloads, use the relevant sibling package's domain-specific timeout API.
When you need the measured elapsed value (e.g. to log it, or to feed it into a follow-up assertion), use WithinTimeBudgetCapturing(TimeSpan budget, Action<TimeSpan> capture). Same wall-clock-budget behaviour as WithinTimeBudget, plus an Action<TimeSpan> callback that receives the measured elapsed on every evaluation path EXCEPT external cancellation (since v0.5.0): whether the budget passed, was exceeded, or the source threw a non-OperationCanceledException. See the paragraph after the example for the cancellation contract.
var elapsed = TimeSpan.Zero;
await Assert.That(asyncOp)
.IsEqualTo(expectedResult)
.And.WithinTimeBudgetCapturing(TimeSpan.FromMilliseconds(500), e => elapsed = e);
// 'elapsed' now holds the wall-clock duration of the asyncOp evaluator.
// Use it for diagnostic logging, or feed into HasAdvancedApproximately for
// a follow-up assertion against a fake clock advanced by the same amount.
TestContext.Current.OutputWriter.WriteLine($"asyncOp took {elapsed.TotalMilliseconds:F1}ms");The capture callback runs on every evaluation path EXCEPT external cancellation (since v0.5.0), so failed-budget tests can still surface the observed timing in their failure diagnostic before the budget-overrun AssertionException propagates. If the source itself threw a non-OperationCanceledException, the callback receives the partial elapsed reported by TUnit's EvaluationMetadata<T>.Duration. When the source threw OperationCanceledException (parent [Timeout], test-class CT, runner cancel), the assertion propagates the OCE to the test runner and the capture callback is deliberately not invoked: a partial elapsed from a cancelled operation would mislead consumers about the operation's real cost.
WasInvokedAtMostOncePer(TimeSpan interval) asserts that consecutive timestamps in a recorded invocation log maintain at least the specified minimum interval. The classic use case is a periodic-probe contract: "the failure handler must fire at most once per 30 seconds; subsequent failures inside that window are suppressed".
| Entry point | Behaviour |
|---|---|
WasInvokedAtMostOncePer(this IReadOnlyList<DateTimeOffset> timestamps, TimeSpan interval) |
Asserts every consecutive pair (timestamps[i-1], timestamps[i]) is at least interval apart. The first violating pair fails the assertion with a message naming the violating index, observed gap, and required minimum. Empty / single-element sequences pass trivially; the boundary case gap == interval passes (minimum is inclusive). |
// Production code records invocation timestamps somewhere observable; the test
// extracts the timestamp list from that recording and asserts the rate-limit.
List<DateTimeOffset> failureLogs = collector.Collected
.Where(r => r.Message.Contains("PingFailed", StringComparison.Ordinal))
.Select(r => r.Timestamp)
.ToList();
await Assert.That(failureLogs).WasInvokedAtMostOncePer(TimeSpan.FromSeconds(30));The receiver is the recorded log itself, NOT the action being invoked: the consumer's production code calls the rate-limited operation, the test records each invocation's timestamp, and the assertion examines the recording. Caller is responsible for chronological order; the assertion preserves input order verbatim.
Failures render the actual measurement against the expected value, with no extra Console.WriteLine calls needed.
HasAdvancedExactly mismatch:
Expected:
fakeTime to have advanced 31m
Actual:
advanced 30m (differs by 1m)
WithinTimeBudget budget exceeded (assertion behavioural check passed but slow):
Expected:
to be equal to 42
and completion within timing budget of 500ms
Actual:
Value: 42 (matches)
Timing: completed in 1.2s: exceeded budget of 500ms by 747ms
Source threw (timing surface is additive; a thrown source is the dominant failure mode):
Expected:
to be equal to 42
and completion within timing budget of 500ms
Actual:
Source threw InvalidOperationException: connection refused
The budget-overrun rendering carries a grep-friendly fixed-unit suffix (elapsed=Xms, budget=Yms, overrun=Zms) in addition to the human-readable prose, so CI log scrapers and triage tooling can extract the three numbers without parsing the prose.
For tests that need to surface the measured elapsed even on the success path, the capturing variant WithinTimeBudgetCapturing invokes its callback on every evaluation path (pass / fail / throw). Use it to write the observed elapsed to test output before the assertion exception propagates: see Capturing the elapsed time.
A complete production-code + test pair showing how TimeProvider injection, FakeTimeProvider, and these assertions compose. The production code never reads the system clock directly: every time-dependent decision goes through the injected TimeProvider. Tests inject FakeTimeProvider, drive time deterministically, and assert against fake-clock state.
// === Production code ===
public sealed class ExpirationService
{
private readonly TimeProvider _timeProvider;
private readonly TimeSpan _ttl;
public DateTimeOffset? LastProcessedAt { get; private set; }
public DateTimeOffset NextRunAt { get; private set; }
public ExpirationService(TimeProvider? timeProvider = null, TimeSpan? ttl = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
_ttl = ttl ?? TimeSpan.FromMinutes(30);
NextRunAt = _timeProvider.GetUtcNow() + _ttl;
}
public Task ProcessAsync(CancellationToken ct)
{
LastProcessedAt = _timeProvider.GetUtcNow();
NextRunAt = LastProcessedAt.Value + _ttl;
return Task.CompletedTask;
}
}
// === Test ===
[Test]
public async Task PreReleaseExpiration_advances_state_after_clock_moves_forward(CancellationToken ct)
{
var startedAt = new DateTimeOffset(2026, 5, 6, 18, 0, 0, TimeSpan.Zero);
var fakeTime = new FakeTimeProvider();
fakeTime.SetUtcNow(startedAt);
var service = new ExpirationService(fakeTime, ttl: TimeSpan.FromMinutes(30));
fakeTime.Advance(TimeSpan.FromMinutes(31));
await service.ProcessAsync(ct);
using (Assert.Multiple())
{
await Assert.That(fakeTime).HasUtcNow(startedAt.AddMinutes(31));
await Assert.That(service.LastProcessedAt!.Value).IsRecent(TimeSpan.FromSeconds(1), fakeTime);
await Assert.That(service.NextRunAt).IsAfterNow(fakeTime);
}
}What this pattern buys you:
- Deterministic timing. No
Thread.Sleep, no flaky CI from real-clock drift. The test runs in milliseconds even though it simulates 31 minutes of elapsed time. - Both sides assertable.
HasUtcNow/HasAdvancedExactly/HasAdvancedApproximatelyconfirm the fake clock's state;IsRecent/IsBeforeNow/IsAfterNowconfirm production state relative to that fake clock. - No system-clock leakage. Because production code accepts
TimeProviderand the test injectsFakeTimeProvider, there's no path whereDateTimeOffset.UtcNowcould sneak in.
IsRecent's TimeProvider parameter is optional: when omitted, TimeProvider.System is used. Useful for end-to-end tests that don't run under a fake clock:
await Assert.That(DateTimeOffset.UtcNow).IsRecent(TimeSpan.FromSeconds(5));fakeTime.SetUtcNow(new DateTimeOffset(2026, 5, 6, 18, 0, 0, TimeSpan.Zero));
await Assert.That(fakeTime).HasUtcNow(new DateTimeOffset(2026, 5, 6, 18, 0, 0, TimeSpan.Zero));await Assert.That(fakeTime).HasAdvancedApproximately(
total: TimeSpan.FromMinutes(30),
tolerance: TimeSpan.FromSeconds(1));After advancing FakeTimeProvider, timer callbacks and any continuations they trigger run on real wall-clock thread-pool threads; they do not complete synchronously when Advance returns. The naïve pattern is a fixed Task.Delay between the advance and the assert:
// Pattern to avoid: brittle and slow.
fakeTime.Advance(TimeSpan.FromMinutes(2));
await Task.Delay(TimeSpan.FromMilliseconds(50), ct); // hope continuations have drained
await Assert.That(collector.HasLogged("[Heartbeat]")).IsTrue();The 50ms guess is paid on every test run regardless of whether continuations drained in 5ms or 500ms. Replace it with TUnit's built-in Eventually polling assertion, which re-evaluates the source until the inner assertion passes or the timeout elapses:
fakeTime.Advance(TimeSpan.FromMinutes(2));
await Assert.That(() => collector.HasLogged("[Heartbeat]"))
.Eventually(a => a.IsTrue(), TimeSpan.FromSeconds(1));Eventually uses a 10ms default polling interval, so the median case completes in 10-20ms instead of the worst-case 50ms. The TimeAssertions.TUnit package deliberately does not ship its own polling assertion: Eventually and its alias WaitsFor cover the use case directly, and a sibling implementation would only fragment the surface.
For polling sources updated externally (e.g. a counter incremented by a different thread), the same pattern applies with an int-typed source and a value predicate:
await Assert.That(() => observable.ActiveTimerCount)
.Eventually(a => a.IsGreaterThanOrEqualTo(1),
timeout: TimeSpan.FromSeconds(5),
pollingInterval: TimeSpan.FromMilliseconds(25));Since TUnit 1.45.0, both Eventually and its alias WaitsFor accept a trailing CancellationToken. Plumb the test's own token so that an external cancel (parent [Timeout], test-class CT, runner cancel) aborts the polling loop instead of waiting for the configured timeout argument:
[Test]
public async Task Heartbeat_fires_before_parent_timeout(CancellationToken cancellationToken)
{
fakeTime.Advance(TimeSpan.FromMinutes(2));
await Assert.That(() => collector.HasLogged("[Heartbeat]"))
.Eventually(a => a.IsTrue(),
timeout: TimeSpan.FromSeconds(10),
cancellationToken: cancellationToken);
}The CT short-circuits the polling loop on external cancellation; the timeout argument remains the upper bound for the no-cancel case. The WithinTimeBudget / WithinTimeBudgetCapturing chains in this package also propagate OperationCanceledException intact since v0.5.0, so a chain like Eventually(...).And.WithinTimeBudget(...) surfaces cancellation as a cancelled test rather than an assertion failure.
When a test produces a sequence of named events at known fake-time moments, snapshot the whole graph rather than asserting on each event individually. TimelineRenderer produces a deterministic byte-stable string from a list of (Timestamp, Label) pairs; pair it with MatchesSnapshot() from SnapshotAssertions.TUnit to pin the graph against a committed baseline.
using TimeAssertions.Render;
[Test]
public async Task HeartbeatService_emits_at_expected_cadence(CancellationToken ct)
{
var epoch = new DateTimeOffset(2026, 5, 13, 0, 0, 0, TimeSpan.Zero);
var fakeTime = new FakeTimeProvider();
fakeTime.SetUtcNow(epoch);
var events = new List<TimelineEvent>();
var service = new HeartbeatService(fakeTime, ev => events.Add(new TimelineEvent(fakeTime.GetUtcNow(), ev)));
await service.StartAsync(ct);
fakeTime.Advance(TimeSpan.FromMinutes(3));
var rendered = TimelineRenderer.Render(epoch, events);
// rendered =
// +60000ms Heartbeat
// +120000ms Heartbeat
// +180000ms Heartbeat
await Assert.That(rendered).MatchesSnapshot();
}MatchesSnapshot() lives in the sibling SnapshotAssertions.TUnit package; this package does not take a hard dependency on it. The two-line composition (Render, then assert) lets consumers reach for the renderer without committing to a specific snapshot framework, and lets SnapshotAssertions.TUnit stay an opt-in pairing.
The renderer preserves input order verbatim, including ties on Timestamp. If the snapshot needs a specific ordering (chronological, by-category, etc.) the caller sorts the input list before rendering.
var elapsed = TimeSpan.Zero;
await Assert.That(httpClient.GetAsync("/health"))
.CompletesSuccessfully()
.And.WithinTimeBudgetCapturing(TimeSpan.FromSeconds(2), e => elapsed = e);
// Surface the measured latency in test output, even when the assertion passes.
TestContext.Current.OutputWriter.WriteLine($"GET /health: {elapsed.TotalMilliseconds:F1}ms");A common production pattern: a background component periodically probes some external resource (HTTP health endpoint, message broker, database connectivity). When the probe fails, the first failure is logged; subsequent failures inside a suppression window are deliberately silenced so a sustained outage does not flood the log. The contract to verify is "the failure handler logs at most once per <window> seconds".
WasInvokedAtMostOncePer asserts the consecutive-pair gap against a minimum interval. Extract the failure timestamps from whatever recording mechanism the test uses (a FakeLogCollector, a captured event probe, a list populated in a wrapped callback) and assert against the recording.
[Test]
public async Task PingHandler_suppresses_repeated_failures_within_30s_window(CancellationToken ct)
{
var fakeTime = new FakeTimeProvider();
var collector = new FakeLogCollector();
var handler = new PingHandler(fakeTime, collector.GetLogger<PingHandler>());
// Simulate ten consecutive ping failures spaced 5s apart of fake time.
for (var i = 0; i < 10; i++)
{
await handler.HandleFailureAsync(ct);
fakeTime.Advance(TimeSpan.FromSeconds(5));
}
// Production code logs every failure as "[PingFailed]" but suppresses the second-and-later
// occurrence within any 30-second window. After ten failures spaced 5s apart, we expect at
// most one log entry per 30-second window: two failures total (at t=0 and t=30).
var failureTimestamps = collector.Collected
.Where(r => r.Message.Contains("[PingFailed]", StringComparison.Ordinal))
.Select(r => r.Timestamp)
.ToList();
await Assert.That(failureTimestamps).WasInvokedAtMostOncePer(TimeSpan.FromSeconds(30));
}The assertion preserves input order. If the underlying mechanism does not guarantee chronological order (rare; log collectors usually do), sort before asserting. The failure message names the first violating index, the observed gap, and the required minimum so a regression in the suppression-window code path is immediately legible:
Expected:
to have at most one invocation per 30s
Actual:
interval violation at index 4: gap was 5.0s (minimum 30s)
timestamps[3]: 2026-01-01T00:00:15.000+00:00
timestamps[4]: 2026-01-01T00:00:20.000+00:00
(gap=5000ms, minimum=30000ms)
WasInvokedAtMostOncePer is added in v0.5.0; consumers on earlier versions can hand-roll the equivalent gap check inline.
WithinTimeBudget measures wall-clock time including JIT compilation, DI container construction, hosted-service startup, and any one-shot lazy initialization that happens during the first call in a freshly-created fixture. The cold-start tax varies by workload and runner; for hosted-service-backed pipelines on shared CI it can be several multiples of the steady-state cost. Measure locally to calibrate before setting tight budgets.
Two patterns address this:
Pattern A: budget with margin (simple). Set the budget at 5-10x the local steady-state measurement on paths that exercise hosted-service startup, DI container build, or first-time JIT. The goal of WithinTimeBudget is to catch order-of-magnitude regressions, not micro-benchmark drift.
// Local steady-state: ~1s. Cold-start: up to 5s. Budget 10s for order-of-magnitude regression detection.
await Assert.That(action)
.ThrowsNothing()
.And.WithinTimeBudget(TimeSpan.FromSeconds(10));Pattern B: warm-up call (precise). Factor a single warm-up invocation before the measured call. The warm-up amortises JIT and one-shot init; the measured call sees steady-state cost only.
// Warm up: pays the cold-start tax once, discarded.
await action();
// Measured: now reflects steady-state cost only.
await Assert.That(action)
.ThrowsNothing()
.And.WithinTimeBudget(TimeSpan.FromMilliseconds(500));Pair Pattern B with WithinTimeBudgetCapturing to log the actual measured elapsed and confirm the steady-state assumption holds:
await action(); // warm up
var elapsed = TimeSpan.Zero;
await Assert.That(action)
.ThrowsNothing()
.And.WithinTimeBudgetCapturing(TimeSpan.FromMilliseconds(500), e => elapsed = e);
TestContext.Current.OutputWriter.WriteLine($"steady-state: {elapsed.TotalMilliseconds:F1}ms");The package is a deliberate showcase of modern .NET conventions:
- AOT-compatible (
IsAotCompatible=true), trimmable (IsTrimmable=true), no runtime reflection in the assertion path. TimeProvider-first. All time-dependent assertions accept an optionalTimeProviderparameter; defaults toTimeProvider.Systemonly when no fake clock is needed.- Source-generated assertion entries via TUnit's
[AssertionExtension]. No interface implementation required, no reflection at runtime. CallerArgumentExpressionon tolerance / budget parameters surfaces the caller's expression in failure messages without manual string passing.- Allocation-conscious failure rendering (
string.Createwith culture-invariant interpolation; struct-based budget overrun renderer).
TUnit core already uses .Within(...) on tolerance assertions (TimeSpanEqualsAssertion.Within(TimeSpan tolerance), IntEqualsAssertion.Within(int days), etc.). Reusing .Within for timing budgets would collide with the existing tolerance API and confuse overload resolution. .WithinTimeBudget reads naturally with .And ("and within time budget of 500ms") and is unambiguous about timing-budget intent.
.WithinTimeBudget() is generic in the source's value type. Two patterns work; the first infers types automatically, the second requires an explicit type argument:
// Canonical: infers cleanly via .And continuation
await Assert.That(asyncTask).IsEqualTo(42).And.WithinTimeBudget(TimeSpan.FromSeconds(5));
// Direct-on-source: requires explicit type argument
await Assert.That(asyncTask).WithinTimeBudget<int>(TimeSpan.FromSeconds(5));Use .And.WithinTimeBudget whenever you have a behavioural assertion to chain after; the explicit-type-argument form is a fallback for source-only timing.
TUnit core ships DateTimeOffset.IsInPast() / IsInFuture() against DateTimeOffset.Now (system clock, no TimeProvider). Our IsBeforeNow(TimeProvider) / IsAfterNow(TimeProvider) add the TimeProvider-aware variants: distinct names so the reader sees at a glance which mechanism the test relies on. For system-clock tests, prefer TUnit's existing methods; for FakeTimeProvider-driven tests, use ours.
Consumers writing Assert.That(fakeTime).HasUtcNow(...) need FakeTimeProvider itself in scope. Making the dependency transitive avoids an extra explicit reference in every test project that consumes us.
This is a 0.x release and the public API may evolve. Specifically:
- Additive changes (new entry points, new shorthand wrappers, new tolerance overloads) ship in any patch / minor without breaking ApiCompat.
- Breaking changes to existing signatures bump the minor version (0.X.0) and are called out in the CHANGELOG.
PackageValidationBaselineVersionis pinned to the previous shipped version starting from 0.1.1, so ApiCompat breakage is caught at pack time.
The 1.0 milestone signals API stability: see Limitations and future work for what's still being designed.
- ✅ Capturing the elapsed time of an assertion chain. Originally planned as
.Elapsed(out TimeSpan)(unimplementable:outparameters are assigned synchronously before any await). Shipped asWithinTimeBudgetCapturing(TimeSpan budget, Action<TimeSpan> capture). See Cross-cutting timing budget: Capturing the elapsed time. - ✅
HasAdvanced/HasAdvancedBynaming asymmetry. Renamed toHasAdvancedExactly/HasAdvancedApproximatelyin v0.2.0 to mirrorHasUtcNow/HasUtcNowApproximately. The old names lived as[Obsolete]aliases through v0.3.x; removed in v0.4.0. - ✅ External-consumer smoke test + AOT-publish CI gate.
tests/TimeAssertions.TUnit.SmokeTest/consumesTimeAssertions.TUnitONLY viaPackageReferenceagainst the just-packed local feed; CI publishes that consumer withPublishAot=trueonlinux-x64so any future reflection / DynamicCode regression fails the build before the package can ship. TheIsAotCompatible=truebuild-time analyzer remains the first gate; the smoke + AOT-publish steps add end-to-end parity with the rest of the family as a defensive backup. - ✅ Recursive public-API self-test project.
tests/TimeAssertions.TUnit.SnapshotTests/pins the public surface usingSnapshotAssertions.TUnit.MatchesSnapshot()againstPublicApiGeneratoroutput: pure dogfooding for the family, no Verify dependency.
Stopwatch.GetTimestamp()-based monotonic-clock variant ofWithinTimeBudget: candidate if benchmark-class precision is needed. Today,WithinTimeBudgetuses TUnit'sEvaluationMetadata<T>.Duration(DateTimeOffset.Now-based); system-clock jumps during a test method are vanishingly rare.HasActiveTimers(tracked upstream): filed as dotnet/extensions#7515 ([API Proposal] FakeTimeProvider: expose active-timer snapshot for testability).FakeTimeProvider.ActiveTimersis not part of the publicMicrosoft.Extensions.Time.TestingAPI surface today, so the assertion cannot be implemented without runtime reflection, which the family forbids (see CONVENTIONS.md no-reflection policy). The shape is already settled (a boolean predicate plus an exact-count chain, mirroringHasAdvancedExactly/HasAdvancedApproximately); the assertion ships the day upstream lands. Consumers who need the check today can wrapFakeTimeProviderin a smallObservableTimeProviderthat records timer creation via the timer factory hook and assert directly against that wrapper.
The six assertion-family packages: LogAssertions.TUnit, TimeAssertions.TUnit, SnapshotAssertions.TUnit, MathAssertions.TUnit, JsonAssertions.TUnit, and SseAssertions.TUnit: release independently and target the same .NET TFM at any moment (LTS-anchored, multi-target during STS support windows; see the TFM policy in CONVENTIONS.md for the rotation schedule). Mix versions freely. Each package ships under SemVer with EnablePackageValidation strict-mode ApiCompat against its previous baseline, so binary breaks within a version line are caught at pack time.
For per-package release notes:
- LogAssertions.TUnit CHANGELOG
- TimeAssertions.TUnit CHANGELOG
- SnapshotAssertions.TUnit CHANGELOG
- MathAssertions.TUnit CHANGELOG
- JsonAssertions.TUnit CHANGELOG
- SseAssertions.TUnit CHANGELOG
LogAssertions.TUnit: fluent log assertions overMicrosoft.Extensions.Logging.Testing.FakeLogCollector. Use.And.WithinTimeBudget(...)to add a timing budget to anyHasLogged()chain.SnapshotAssertions.TUnit: text-snapshot assertions for API-surface tests and similar deterministic-string scenarios. Coexists with Verify; covers the 80% case without coverage friction.MathAssertions.TUnit: tolerance-aware fluent assertions over numeric and geometric types (vectors, quaternions, matrices, planes, complex numbers, arrays).JsonAssertions.TUnit: fluent JSON assertions overSystem.Text.Json, HTTP response bodies (including RFC 7807 ProblemDetails), and source-generatedJsonSerializerContextregistration.SseAssertions.TUnit: Server-Sent Events (SSE) wire-format assertions: event-count, field shape (event:,data:,id:,retry:), and stream content validation.
Issues and pull requests welcome. Before opening a PR:
- Run
dotnet buildanddotnet testlocally; the CI pipeline enforces the same quality bar (zero warnings as errors, 90% line / 90% branch coverage minimum). - Match the existing code style (
.editorconfigis authoritative;dotnet formatcovers formatting). - For new assertions, include a test for both the happy path and a representative failure case so the failure-message rendering is verified.
For larger ideas (new entry points, breaking changes, cross-cutting refactors), open a Discussion first to align on direction before investing implementation time.
See CONTRIBUTING.md for the full PR review checklist and API design principles, and CONVENTIONS.md for the family-wide code conventions shared across LogAssertions.TUnit, SnapshotAssertions.TUnit, TimeAssertions.TUnit, MathAssertions.TUnit, JsonAssertions.TUnit, and SseAssertions.TUnit.
MIT. Copyright (c) 2026 John Verheij.