MetricsAssertions.TUnit
TUnit-native assertions over System.Diagnostics.Metrics instruments for .NET tests. Fluent entry points over TUnit's Assert.That(...) pipeline for asserting on captured measurements, with a framework-agnostic core (MetricsAssertions) that a future xUnit, NUnit, or MSTest adapter can reuse. AOT-compatible, trimmable, no runtime reflection in the assertion path. Capture is the first-party MetricCollector testing primitive over a System.Diagnostics.Metrics instrument: no OpenTelemetry SDK or exporter pipeline.
Scope: Test projects only. Not intended for production code.
Part of the DotNetAssertions family of assertion extensions for TUnit.
Table of contents
- Why this package
- Install
- Package layout
- Namespaces
- Quick start
- Entry points
- Failure diagnostics
- Design notes
- Stability intent (pre-1.0)
- Roadmap
- Family compatibility
- Pair with
- Contributing
- License
Why this package
System.Diagnostics.Metrics (Counter<T>, Histogram<T>, Gauge<T>, and friends) is how modern .NET code emits metrics. A test wants to assert "the pipeline recorded two orders.placed measurements", "the counter total is 5", "a measurement carried route = /orders". The BCL ships the capture machinery (MetricCollector<T> in Microsoft.Extensions.Diagnostics.Testing), but asserting on it is manual: construct a collector, pull a measurement snapshot, then hand-roll the count-and-compare against Assert. Every project that does this re-invents the same capture-and-query helper.
MetricsAssertions.TUnit absorbs that boilerplate into a reusable capture type and a fluent assertion surface:
- A disposable, per-test
InstrumentCapturethat wrapsMetricCollector<T>over an instrument and projects its measurements to a uniformCapturedMeasurementshape, exposed as a queryableMeasurementSet. - A full TUnit
Assert.That(...).Has*vocabulary over captures, measurement sets, and multi-instrumentMeterCapturebundles, source-generated via[GenerateAssertion].
The framework-agnostic MetricsAssertions core ships separately so non-TUnit consumers can reuse the capture and query surface.
Install
# TUnit consumers install the adapter; the core is pulled transitively:
dotnet add package MetricsAssertions.TUnit
# Framework-agnostic consumers (rare in test projects) can pull the core directly:
dotnet add package MetricsAssertions
Requirements: TUnit 1.63.0 or later, .NET 10. AOT-compatible, trimmable.
Package layout
| Package | Purpose | Depends on |
|---|---|---|
MetricsAssertions |
Framework-agnostic core: the InstrumentCapture / MeterCapture capture types, the queryable MeasurementSet, MeterInspector, and the CapturedMeasurement record |
Microsoft.Extensions.Diagnostics.Testing |
MetricsAssertions.TUnit |
TUnit adapter: fluent Assert.That(...) assertions over captures, measurement sets, and meter bundles |
MetricsAssertions + TUnit.Assertions + TUnit.Core |
Install MetricsAssertions.TUnit for TUnit test projects; MetricsAssertions comes transitively. Adapters for other test frameworks (NUnit, xUnit, MSTest) are not shipped; they would reuse the MetricsAssertions core. Open a feature request if you need one.
Namespaces
| Type / member | Namespace | Auto-imported? |
|---|---|---|
Fluent entry points (HasCounterTotal, HasMeasurementCount, ...) |
TUnit.Assertions.Extensions |
Yes (TUnit auto-imports) |
Core types (InstrumentCapture, MeasurementSet, MeterCapture, MeterInspector, CapturedMeasurement) |
MetricsAssertions |
No - needs using MetricsAssertions; |
A GlobalUsings.cs in your test project:
global using MetricsAssertions;
makes the core namespace available everywhere. The fluent entry points are auto-imported via TUnit.Assertions.Extensions, so they need no using of their own.
Quick start
using System.Diagnostics.Metrics;
using MetricsAssertions;
using var meter = new Meter("MyCompany.Orders");
var placed = meter.CreateCounter<long>("orders.placed");
// Capture the instrument's measurements for the duration of a test.
using var capture = InstrumentCapture.Of(placed);
placed.Add(2);
placed.Add(3);
await Assert.That(capture).HasCounterTotal(5);
await Assert.That(capture).HasMeasurementCount(2);
InstrumentCapture.Of(instrument) wraps a MetricCollector<T> over the instrument and collects every measurement it records. Create one per test with using for isolation; disposing it releases the collector.
Entry points
Assertions on Assert.That(capture) where capture is an InstrumentCapture:
| Assertion | Description |
|---|---|
HasCounterTotal(n) |
The net total of the captured values equals n. |
HasCounterTotalAtLeast(n) |
The net total is at least n. |
HasUpDownCounterValue(n) |
The net up-down-counter value equals n. |
HasMeasurementCount(n) |
Exactly n measurements were captured. |
HasNoMeasurements() |
No measurements were captured. |
HasLastValue(v) / HasLastValue(v, tolerance) |
The most recently captured value equals v (exactly, or within an absolute tolerance for computed doubles). |
HasTaggedMeasurement(key, value) |
At least one measurement carries the tag key = value. |
Assertions on Assert.That(set) where set is a MeasurementSet (e.g. capture.Measurements, capture.Tagged(...), capture.Since(...)):
| Assertion | Description |
|---|---|
HasCounterTotal(n) / HasCounterTotalAtLeast(n) |
The net total equals / is at least n. |
HasMeasurementCount(n) / HasNoMeasurements() |
The set holds exactly n measurements / is empty. |
HasSampleSum(v, tolerance?) / HasSampleAverage(v, tolerance?) |
The histogram sample sum / mean equals v within an optional tolerance. |
HasAllSamplesInRange(min, max) |
Every sample lies within the inclusive range. |
HasSamples(...) / HasSamplesInAnyOrder(...) |
The samples equal the expected values, in order / in any order. A (IReadOnlyList<double> expected, double tolerance) overload of each compares within an absolute per-sample tolerance. |
HasEveryMeasurementTagged(key) |
Every measurement carries a tag with the given key. |
HasTaggedMeasurement(key, value) |
At least one measurement carries the tag key = value. |
Assertions on Assert.That(meter) where meter is a multi-instrument MeterCapture, addressing one bundled instrument by name:
| Assertion | Description |
|---|---|
HasCounterTotal(name, n) / HasUpDownCounterValue(name, n) |
The named counter's net total / value equals n. |
HasMeasurementCount(name, n) |
The named instrument captured exactly n measurements. |
HasTaggedMeasurement(name, key, value) |
The named instrument has a measurement tagged key = value. |
The MetricsAssertions core exposes the capture and query surface for reading measurements without an assertion:
| Member | Description |
|---|---|
InstrumentCapture.Of<T> / OfObservable<T> / OfName<T> |
Captures a referenceable, observable, or by-name instrument via MetricCollector<T>. |
InstrumentCapture.OfName<T>(meterScope, ...) / MeterCapture.For(name, meterScope) (v0.2.0+) |
Capture a meter created by an IMeterFactory (the ASP.NET Core DI metrics path): pass the factory as meterScope. The no-scope overloads match only a directly-created meter (new Meter(name)) and would capture nothing from a factory-created one. |
InstrumentCapture.Measurements |
The captured measurements as a queryable MeasurementSet. |
InstrumentCapture.Total / Count / LastValue / Tagged / HasMeasurementTagged |
Convenience reads over the capture. |
InstrumentCapture.Snapshot / Since |
Take a MeasurementBaseline and query only the measurements recorded after it (an action's delta). |
InstrumentCapture.RecordObservable / WaitForAsync |
Pull observable-gauge values; await a measurement count. |
MeasurementSet |
An immutable, queryable set: Total/Sum/Min/Max/Average/Values, Tagged/ForInstrument, sample comparisons, and ToSnapshotString. |
MeterCapture.For + Add |
Build a meter-wide bundle and query per instrument or across all via Measurements. |
MeterInspector |
Discover a meter's published instruments (PublishedInstrumentNames / IsPublished / PublishesAll). |
CapturedMeasurement |
A captured measurement: instrument name, value projected to double, tags, and timestamp. |
Failure diagnostics
Every assertion dumps the captured measurements (instrument, value, tags, timestamp) under the failure, so a mismatch shows what was actually recorded rather than only the mismatched scalar:
Expected the capture to have captured 2 measurement(s)
but it had 1
captured:
orders.placed = 3 {route=/orders} @ 2026-06-07T10:15:00.0000000+00:00
Every assertion also accepts .Because(reason) to attach a domain explanation to the failure, the same as any other TUnit assertion (it is inherited from the base assertion type):
await Assert.That(capture).HasMeasurementCount(2).Because("each retry records one measurement");
Design notes
Why MetricCollector<T>, not a raw MeterListener
Capture is built on the first-party MetricCollector<T> from Microsoft.Extensions.Diagnostics.Testing, not a hand-rolled MeterListener. MetricCollector<T> is the BCL's purpose-built measurement-capture primitive: strongly typed, with built-in measurement snapshots and an injectable TimeProvider. Building on it keeps the capture small and correct, at the cost of one NuGet runtime dependency (Microsoft.Extensions.Diagnostics.Testing, a Microsoft-shipped package). No OpenTelemetry SDK or exporter pipeline is involved.
Why per-test capture, disposed via using
InstrumentCapture is a using-scoped handle that attaches its collector on creation and releases it on Dispose, with a fresh measurement stream per instance. This keeps capture isolated per test (no process-wide collector leaking measurements across parallel or sequential tests) and bounds the collector's lifetime to the test that needs it.
Values are projected to double
Every measurement's value is projected to double (via Convert.ToDouble) so heterogeneous instrument types share one query and assertion surface. double represents integers exactly only up to 2^53 and cannot represent every decimal exactly, so an exact-equality assertion on a very large long counter, or on a decimal (for example money-as-metric) histogram, can surprise. For counts and durations this is a non-issue; for non-integer values, prefer the tolerance overloads, which take an explicit absolute tolerance: HasSampleSum / HasSampleAverage, and (since 0.3.0) HasLastValue(v, tolerance), HasSamples(expected, tolerance), and HasSamplesInAnyOrder(expected, tolerance).
Counter totals are evaluated as integers: MeasurementSet.Total (and the HasCounterTotal assertions over it) round the sum to a long with banker's rounding, so a Counter<double> with a fractional total is compared as an integer. For an exact fractional total, compare Sum directly or use HasSampleSum(expected, tolerance).
Assertions are kind-named, not kind-checked
The capture is typed by the instrument's value type (T), not its kind, and every value is projected to double, so the assertion surface is uniform across counters, histograms, and gauges. The kind-named terminators (HasCounterTotal, HasSampleSum, HasLastValue) signal intent but do not enforce it: calling HasSampleSum on a counter capture, or HasCounterTotal on a histogram, computes a defined but meaningless number rather than refusing to apply. Metric tests are normally written by whoever created the instrument, so this is a deliberate simplicity-over-safety tradeoff. As a partial guard, HasCounterTotal rejects negative deltas, since a true counter never decrements. Enforcing kind end to end (a capture that remembers its instrument kind) is a post-1.0 consideration.
Stability intent (pre-1.0)
This is a 0.x release and the public API may evolve.
- Additive changes (new entry points, new chain methods) ship in any patch without breaking ApiCompat.
- Breaking changes to existing signatures bump the minor version (0.X.0) and are called out in the CHANGELOG.
- From 0.1.0,
PackageValidationBaselineVersionpins to the previous shipped version so ApiCompat catches binary breaks at pack time;CompatibilitySuppressions.xmlrecords accepted differences.
The 1.0 milestone signals API stability.
Roadmap
Shipped in 0.1.0 (full surface): the queryable MeasurementSet, multi-instrument MeterCapture, meter-introspection MeterInspector, OfObservable / OfName and baseline-delta capture, WaitForAsync, and the full Assert.That assertion vocabulary across captures, sets, and meters.
Further assertions and capture conveniences are demand-driven; open a feature request or Discussion if you hit a gap. No fixed timeline.
Family compatibility
The nine assertion-family packages: LogAssertions.TUnit, TimeAssertions.TUnit, SnapshotAssertions.TUnit, MathAssertions.TUnit, JsonAssertions.TUnit, SseAssertions.TUnit, GrpcAssertions.TUnit, TracingAssertions.TUnit, and MetricsAssertions.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.
Pair with
TracingAssertions.TUnit: fluent assertions overSystem.Diagnostics.Activityspans, the other half of OpenTelemetry signals.LogAssertions.TUnit: fluent log assertions overMicrosoft.Extensions.Logging.Testing.FakeLogCollector. Completes the logs / metrics / traces trio.TimeAssertions.TUnit:TimeProvider-aware time assertions and cross-cutting.WithinTimeBudget(...)chain methods.SnapshotAssertions.TUnit: text-snapshot assertions for API-surface tests and similar deterministic-string scenarios.MathAssertions.TUnit: tolerance-aware fluent assertions over numeric and geometric types.JsonAssertions.TUnit: fluent JSON assertions overSystem.Text.Jsonand HTTP response bodies.SseAssertions.TUnit: fluent Server-Sent Events wire-format assertions.GrpcAssertions.TUnit: fluent gRPC outcome assertions plus theGrpcCallBuildertest-double helper.
Contributing
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.
For larger ideas, open a Discussion first to align on direction before investing implementation time.
See CONTRIBUTING.md for the full PR review checklist, and CONVENTIONS.md for the family-wide code conventions shared across the assertion family.
License
MIT. Copyright (c) 2026 John Verheij.