A TUnit-native fluent log-assertion DSL on top of Microsoft.Extensions.Logging.Testing.FakeLogCollector. Built using TUnit's [AssertionExtension] source generator, so the assertion entry points integrate directly into TUnit's Assert.That(...) pipeline with rich failure diagnostics.
Scope: Test projects only. Not intended for production code.
- Why this package
- Install
- Package layout
- Namespaces (and a
GlobalUsings.csrecommendation) - Quick start
- Migrating from manual assertions
- Entry points
- Filter reference
- Terminators (
HasLoggedonly) - Sequence assertions:
HasLoggedSequence - Combining assertions with
.And/.Or - Batch assertions:
AssertAllAsyncandAssert.Multipleinterop - TUnit-native conveniences (
Because, parallelism, Should) - Non-asserting inspection
- Failure diagnostics
- Cookbook: common patterns
- Troubleshooting
- Design notes
- Stability intent (pre-1.0)
- Limitations and future work
- Family compatibility
- Pair with
- Background
- Contributing
- License
Asserting on log output during tests typically devolves into either:
- Manual
collector.GetSnapshot().Where(...).Count()plumbing in every test, or - Adding temporary
Console.WriteLinecalls during debugging because the assertion failure says "expected 1, got 3" without showing what was actually logged.
This library replaces both with a fluent DSL that integrates with TUnit's assertion pipeline and shows every captured record (including structured properties and scope content) in failure messages.
dotnet add package LogAssertions.TUnit
Requirements: TUnit 1.43.11+ (declares TUnit.Core 1.43.11 and TUnit.Assertions 1.43.11 as transitive dependencies; [AssertionExtension] itself ships from TUnit 1.41.0+, but DumpToTestOutput() requires TestContext from TUnit.Core 1.43.11+), .NET 10. The package is AOT-compatible, trimmable, and uses no reflection in the assertion path.
This repo ships two NuGet packages:
| Package | Purpose | Depends on |
|---|---|---|
LogAssertions |
Framework-agnostic core: ILogRecordFilter + LogFilter + rendering + collector inspection extensions |
Microsoft.Extensions.Diagnostics.Testing |
LogAssertions.TUnit |
TUnit-specific entry points: HasLogged(), HasNotLogged(), HasLoggedSequence() and shorthands |
LogAssertions + TUnit.Assertions |
You install LogAssertions.TUnit; LogAssertions comes transitively. Adapters for other test frameworks (NUnit, xUnit, MSTest) are not shipped today: they'd reuse the LogAssertions core. If you'd find one useful, open a feature request.
The two packages place types in two namespaces with deliberately-different scopes:
| Type / member | Namespace | Auto-imported? |
|---|---|---|
HasLogged(), HasNotLogged(), HasLoggedSequence() (the source-generated entry points) |
TUnit.Assertions.Extensions |
Yes: TUnit auto-imports this namespace |
HasLoggedOnce(), HasLoggedExactly(), ... (shorthand entry points, since 0.2.2) |
TUnit.Assertions.Extensions |
Yes: same auto-import path |
LogCollectorBuilder.Create(...) (the (factory, collector) factory) |
LogAssertions |
No: needs using LogAssertions; |
LogFilter.AtLevel(...), ILogRecordFilter, Filter/CountMatching/DumpTo extensions |
LogAssertions |
No: needs using LogAssertions; |
AssertAllAsync(...) batch terminator |
TUnit.Assertions.Extensions |
Yes: same auto-import path |
Practical consequence: test files that only call assertion entry points need no using from this package. Files that use LogCollectorBuilder or build composable filters via LogFilter need using LogAssertions;.
Recommended: put all four into a single GlobalUsings.cs in your test project so every test file sees them without ceremony:
// tests/MyApp.Tests/GlobalUsings.cs
global using System; // StringComparison
global using LogAssertions; // LogCollectorBuilder, LogFilter, etc.
global using Microsoft.Extensions.Logging; // LogLevel
global using Microsoft.Extensions.Logging.Testing; // FakeLogCollector, FakeLoggerProviderSystem is included because every Containing(...) filter call requires StringComparison. Test projects that disable <ImplicitUsings>enable</ImplicitUsings> (common in strict-analysis codebases) won't get System for free, so this entry prevents a compile error on the first Containing() call.
This setup also eliminates the IDE0005 ("unnecessary using") chatter that otherwise appears in test files that don't directly use LogCollectorBuilder but live alongside ones that do.
using LogAssertions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Testing;
[Test]
public async Task Validation_failure_is_logged()
{
var (factory, collector) = LogCollectorBuilder.Create();
using (factory)
{
var logger = factory.CreateLogger<MyValidator>();
new MyValidator(logger).Validate(invalidInput);
await Assert.That(collector)
.HasLogged()
.AtLevel(LogLevel.Warning)
.Containing("validation failed", StringComparison.Ordinal)
.WithCategory("MyApp.MyValidator")
.Once();
await Assert.That(collector).HasNotLogged().AtLevel(LogLevel.Error);
}
}Lifetime / disposal: the IDisposable returned from LogCollectorBuilder.Create() (the factory) owns the underlying FakeLoggerProvider. Disposing it stops new log records from being captured but the records already gathered into the collector snapshot remain valid: you can continue to query the collector after the using block exits. Both block-form (using (factory) { ... }) and declaration-form (using ILoggerFactory factory = ...) work; pick whichever fits your test layout.
If you already use FakeLogCollector directly, the typical "before" pattern is to pull a snapshot, filter with LINQ, and write multiple assertions against the result:
// Before: manual:
var records = collector.GetSnapshot();
var warnings = records.Where(r => r.Level == LogLevel.Warning).ToList();
await Assert.That(warnings).HasCount().EqualTo(1);
await Assert.That(warnings[0].Message).Contains("timeout", StringComparison.Ordinal);The "after" with LogAssertions.TUnit is one fluent chain that reads as a single intent and produces a failure message with every captured record (including structured state and scope content) when the chain doesn't match:
// After: LogAssertions.TUnit:
await Assert.That(collector)
.HasLogged()
.AtLevel(LogLevel.Warning)
.Containing("timeout", StringComparison.Ordinal)
.Once();Two practical wins on top of the readability:
- Failure diagnostics. The "before" snippet's failure says "expected 1, got 3" (or "expected to contain 'timeout'"). The "after" snippet's failure renders the full captured-records snapshot: level, category, message, structured state, scope: so you can see which three records came through and why none matched, without adding
Console.WriteLinecalls. - Composability. The same chain extends to scopes (
WithScopeProperty("RequestId", id)), structured properties (WithProperty("UserId", 42)), exception types (WithException<TimeoutException>()), and combinator nodes (MatchingAny(...),Not(...)) without restructuring the test.
See the Cookbook for the patterns this replaces in practice.
Three core entry points are emitted by TUnit's source generator and surface as extension methods on Assert.That(FakeLogCollector).
| Entry point | Default expectation | Terminators allowed |
|---|---|---|
HasLogged() |
At least 1 matching record | All count terminators (see below) |
HasNotLogged() |
Zero matching records | None: fixed at zero |
HasLoggedSequence() |
An ordered series of matches; Then() separates steps |
None: each step's match is implicit |
All three accept the full filter chain. HasLogged() is the workhorse; HasNotLogged() is its inverse with cleaner failure semantics; HasLoggedSequence() is for multi-step traces (e.g. "Started → Validation failed → Stopped").
Wrappers that pre-configure the most common chains. Each returns the underlying assertion type so additional filters can still be appended.
| Shorthand | Equivalent to |
|---|---|
HasLoggedOnce() |
HasLogged().Once() |
HasLoggedExactly(int) |
HasLogged().Exactly(int) |
HasLoggedAtLeast(int) |
HasLogged().AtLeast(int) |
HasLoggedAtMost(int) |
HasLogged().AtMost(int) |
HasLoggedBetween(int, int) |
HasLogged().Between(int, int) |
HasLoggedNothing() |
HasNotLogged() (no filters: asserts the collector is empty) |
HasLoggedWarningOrAbove() |
HasLogged().AtLevelOrAbove(LogLevel.Warning) |
HasLoggedErrorOrAbove() |
HasLogged().AtLevelOrAbove(LogLevel.Error) |
await Assert.That(collector).HasLoggedOnce().AtLevel(LogLevel.Warning).Containing("retry", StringComparison.Ordinal);
await Assert.That(collector).HasLoggedNothing();
await Assert.That(collector).HasLoggedErrorOrAbove();Filters chain freely. Within a single assertion (or within a single sequence step) every filter is AND-combined: a record matches only when every filter's predicate holds.
| Filter | Behaviour |
|---|---|
AtLevel(LogLevel) |
Exact level match |
AtLevelOrAbove(LogLevel) |
record.Level >= threshold (e.g. "any warning or worse") |
AtLevelOrBelow(LogLevel) |
record.Level <= threshold (e.g. "only diagnostic-tier") |
AtAnyLevel(params LogLevel[]) |
Match any level in the supplied set (e.g. "Warning or Error but not Critical") |
NotAtLevel(LogLevel) |
Inverse of AtLevel: convenience over Not(LogFilter.AtLevel(...)) |
ExcludingLevel(LogLevel) |
Alias for NotAtLevel, reads better in negative-filter chains |
await Assert.That(collector).HasLogged().AtLevelOrAbove(LogLevel.Warning).AtLeast(1);
await Assert.That(collector).HasNotLogged().AtLevelOrAbove(LogLevel.Error);
await Assert.That(collector).HasLogged().AtAnyLevel(LogLevel.Warning, LogLevel.Error).AtLeast(1);| Filter | Behaviour |
|---|---|
Containing(string substring, StringComparison comparison) |
Formatted message contains substring (comparison explicit by design: no implicit culture) |
ContainingAll(StringComparison, params string[]) |
Formatted message contains every one of the substrings |
ContainingAny(StringComparison, params string[]) |
Formatted message contains at least one of the substrings |
Matching(Regex) |
Formatted message matches the regex |
WithMessage(Func<string, bool> predicate) |
Predicate over the formatted message |
WithMessageTemplate(string template) |
The pre-substitution template (e.g. "Order {OrderId} processed") equals template exactly. Resolved from MEL's magic {OriginalFormat} structured-state entry |
NotContaining(string, StringComparison) |
Inverse of Containing: convenience over Not(LogFilter.Containing(...)) |
WithMessageTemplate is useful when you want to pin a specific call site without coupling to the substituted parameter values:
// matches every "Order N processed" log regardless of N
await Assert.That(collector).HasLogged()
.WithMessageTemplate("Order {OrderId} processed").AtLeast(1);| Filter | Behaviour |
|---|---|
WithException<TException>() |
record.Exception is TException (assignable) |
WithException() |
Any record with a non-null Exception, regardless of type |
WithException(Func<Exception, bool> predicate) |
Predicate over the exception (predicate not invoked for null exception) |
WithExceptionMessage(string substring, StringComparison comparison) (explicit-comparison overload added in v0.4.0) |
record.Exception?.Message contains substring under the supplied comparison; records without an exception never match. The legacy single-arg (string substring) overload remains as [Obsolete] defaulting to StringComparison.Ordinal and is removed in v0.6.0. |
WithInnerException<TInner>() (v0.4.0+) |
record.Exception?.InnerException is TInner (assignable). Walks one level only; deeper nesting is not searched. |
WithInnerExceptionMessage(string substring, StringComparison comparison) (v0.4.0+) |
record.Exception?.InnerException?.Message contains substring under the supplied comparison; records without an inner exception never match. |
await Assert.That(collector).HasLogged()
.WithException<TimeoutException>()
.WithExceptionMessage("connection", StringComparison.Ordinal)
.Once();
// gRPC / RPC pattern: transport exception (RpcException) wraps the underlying domain
// exception. Combine the outer-type filter with the inner-type filter on the same chain:
await Assert.That(collector).HasLogged()
.WithException<RpcException>()
.WithInnerException<TimeoutException>()
.WithInnerExceptionMessage("upstream", StringComparison.Ordinal)
.Once();The WithInnerException filters walk only Exception.InnerException (one level). Deeper chains (InnerException.InnerException...) and AggregateException.InnerExceptions are not flattened: most logged exception graphs in MEL flows are one level deep, and one-level matching keeps the predicate fast and predictable. If you need deeper inspection, use WithException(predicate) and walk the chain yourself.
Microsoft.Extensions.Logging exposes structured properties on each record (the parameters captured by LoggerMessage source generators or by message-template logging calls).
| Filter | Behaviour |
|---|---|
WithProperty(string key, string? value) |
Property's formatted string value equals value (ordinal) |
WithProperty(string key, Func<string?, bool> predicate) |
Predicate over the formatted string value (use for ranges, regex, or null-checks) |
Note: FakeLogRecord exposes structured-state values as strings (the formatted form), so the predicate receives a string?. Parse to your target type inside the predicate when needed:
await Assert.That(collector).HasLogged()
.WithProperty("OrderId", v =>
int.TryParse(v, CultureInfo.InvariantCulture, out var n) && n > 1000)
.AtLeast(1);Scopes are values pushed via logger.BeginScope(...). They surround any log records emitted while the scope is active.
| Filter | Behaviour |
|---|---|
WithScope<TScope>() |
A scope of type TScope was active when the record was emitted |
WithScopeProperty(string key, object? value) |
A scope contains a property key matching value (object.Equals semantics) |
WithScopeProperty(string key, Func<object?, bool> predicate) |
A scope contains a property key whose value satisfies the predicate |
WithScopeProperties(IDictionary<string, object?> required) (v0.4.0+) |
Subset match across all active scopes: every key/value pair in required must match in some scope, but different pairs may match in different scopes. Empty dictionary matches every record (vacuous truth). |
Scope-property filters recognise the two AOT-friendly idioms:
// dictionary scope: the canonical structured pattern
using (logger.BeginScope(new Dictionary<string, object?> { ["OrderId"] = 42 }))
DoWork();
await Assert.That(collector).HasLogged().WithScopeProperty("OrderId", 42).AtLeast(1);// formatted-template scope via LoggerMessage.DefineScope (avoids CA1848)
private static readonly Func<ILogger, int, IDisposable?> OrderScope =
LoggerMessage.DefineScope<int>("Order {OrderId}");
using (OrderScope(logger, 42)) DoWork();
await Assert.That(collector).HasLogged().WithScopeProperty("OrderId", 42).AtLeast(1);Anonymous-object scopes (
logger.BeginScope(new { OrderId = 42 })) are not recognised byWithScopeProperty: reading their fields requires reflection, which would compromise AOT-compatibility. Prefer dictionary orLoggerMessage.DefineScopeform.
When a record is emitted under several nested scopes (e.g. an outer RequestId scope plus an inner OrderId scope), WithScopeProperty matches one key at a time and lives across the whole active-scope set. WithScopeProperties extends this to a batch match: pass a dictionary of required key/value pairs, and each pair must match in some scope (different pairs may match in different scopes: they don't have to share one scope).
using (logger.BeginScope(new[] { new KeyValuePair<string, object?>("RequestId", "abc-123") }))
using (logger.BeginScope(new[] { new KeyValuePair<string, object?>("OrderId", 42) }))
{
DoWork();
}
var required = new Dictionary<string, object?>(StringComparer.Ordinal)
{
["RequestId"] = "abc-123",
["OrderId"] = 42,
};
await Assert.That(collector).HasLogged().WithScopeProperties(required).AtLeast(1);The dictionary is snapshotted on construction; mutating the input dictionary after the filter is created does not affect subsequent matches. Comparison is via object.Equals for values; keys are ordinal.
| Filter | Behaviour |
|---|---|
WithCategory(string) |
Logger category equals string (ordinal) |
WithLoggerName(string) |
Alias for WithCategory |
ExcludingCategory(string) |
Inverse of WithCategory |
WithEventId(int) |
EventId.Id equals value |
WithEventIdInRange(int min, int max) |
EventId.Id is within the inclusive range |
WithEventName(string) |
EventId.Name equals string (ordinal) |
await Assert.That(collector).HasLogged()
.WithCategory("MyApp.Bootstrap")
.WithEventName("Startup")
.Once();| Filter | Behaviour |
|---|---|
Where(Func<FakeLogRecord, bool> predicate) |
Arbitrary predicate over the full FakeLogRecord |
Use only when no other filter expresses the constraint cleanly: composing built-in filters is preferred for diagnostic clarity in failure messages.
The fluent chain is implicitly AND-combined. These four chain methods let you compose richer expressions inside the chain without dropping to Where:
| Method | Behaviour |
|---|---|
MatchingAny(params ILogRecordFilter[]) |
OR of the supplied filters as one composite filter on the chain. Empty array matches no record. |
MatchingAll(params ILogRecordFilter[]) |
Explicit AND of the supplied filters. Empty array matches every record. |
Not(ILogRecordFilter) |
Negates the supplied filter. |
WithFilter(ILogRecordFilter) |
Adds a user-supplied or pre-built filter to the chain. |
// "level == Warning AND (msg contains "a" OR msg contains "b")"
await Assert.That(collector).HasLogged()
.AtLevel(LogLevel.Warning)
.MatchingAny(
LogFilter.Containing("a", StringComparison.Ordinal),
LogFilter.Containing("b", StringComparison.Ordinal))
.AtLeast(1);
// Reusable filter shared across many tests:
static readonly ILogRecordFilter CriticalDbError = LogFilter.All(
LogFilter.AtLevel(LogLevel.Critical),
LogFilter.WithException<DbException>());
await Assert.That(collector).HasLogged().WithFilter(CriticalDbError).AtLeast(1);// In a parameterised test, fold a boolean branch into the chain
// instead of duplicating the entire await:
await Assert.That(collector).HasLogged()
.AtLevel(LogLevel.Warning)
.When(expectRetry, b => b.Containing("retry", StringComparison.Ordinal))
.AtLeast(1);Terminators express the count expectation. Pick exactly one: chain it after all filters. HasNotLogged has no terminators (the expectation is fixed at zero matches).
| Terminator | Match count expectation |
|---|---|
Once() |
Exactly 1 |
Exactly(int count) |
Exactly N |
AtLeast(int count) |
At least N (inclusive) |
AtMost(int count) |
At most N (inclusive) |
Between(int min, int max) |
Inclusive range [min, max] |
Never() |
Exactly 0 (semantic synonym for HasNotLogged()) |
await Assert.That(collector).HasLogged().AtLevel(LogLevel.Warning).Between(1, 5);
await Assert.That(collector).HasLogged().WithEventId(42).Never();Never() vs HasNotLogged(): when to use which. They produce identical assertions; the only difference is reading order. Prefer HasNotLogged() when "this should not happen" is the primary intent of the test (the negative is the headline). Use .Never() when you've already started building a positive filter chain and only at the end realise you expect zero matches: saves rewriting the prefix. Don't agonise over the choice; either reads clearly to a future maintainer.
Two terminators on HasLogged() return the matched record(s) once the assertion passes, so a follow-up TUnit assertion can inspect them without a duplicate collector.Filter(...) call.
| Terminator | Returns | Required preceding terminator |
|---|---|---|
.GetMatch() |
Task<FakeLogRecord> |
Any terminator that constrains the count to exactly one (typically Once() or Exactly(1); Between(1, 1) also accepted) |
.GetMatches() |
Task<IReadOnlyList<FakeLogRecord>> |
Any count terminator |
// Get the single matched record and assert further on it:
FakeLogRecord match = await Assert.That(collector)
.HasLogged()
.AtLevel(LogLevel.Error)
.WithException<TimeoutException>()
.Once()
.GetMatch();
await Assert.That(match.Exception!.Message).Contains("connection pool exhausted");
// Or get all matches (here: every retry) and assert on the collection:
IReadOnlyList<FakeLogRecord> retries = await Assert.That(collector)
.HasLogged()
.WithMessageTemplate("Retrying after {Delay}ms")
.AtLeast(1)
.GetMatches();
await Assert.That(retries).Count().IsEqualTo(3);.GetMatch() throws InvalidOperationException upfront if the chain's count expectation doesn't constrain the match count to exactly one: fail-fast on a nonsensical "give me the single match" expectation against a chain that allows N matches.
The returned list from .GetMatches() is a defensive snapshot captured at evaluation time; it isn't bound to the live collector.
For tests that need to verify a series of records appeared in order:
await Assert.That(collector).HasLoggedSequence()
.AtLevel(LogLevel.Information).Containing("Started", StringComparison.Ordinal)
.Then()
.AtLevel(LogLevel.Warning) .Containing("validation failed", StringComparison.Ordinal)
.Then()
.AtLevel(LogLevel.Information).Containing("Stopped", StringComparison.Ordinal);Semantics:
- The walk is order-preserving but not contiguous: records between matches are skipped.
Then()commits the current step's filters and starts a new step.- Each step's filters AND-combine, exactly like the single-match assertions.
- A step with no filters always matches the next available record (use sparingly).
- Failure diagnostics indicate which step failed and dump the full captured-records list (see Failure diagnostics).
Then() requires the next step's match to come after the previous step's match in the captured-records timeline. For workflows where several events must all occur in some window but the relative order between them isn't fixed (parallel work on a request, fan-out logging from multiple workers, completion notifications that race), use ThenAnyOrder(...) to commit a concurrent group of sub-steps. All sub-steps must match somewhere in the remaining records, but the order among them is unconstrained.
await Assert.That(collector).HasLoggedSequence()
.AtLevel(LogLevel.Information).Containing("Request received", StringComparison.Ordinal)
.ThenAnyOrder(
s => s.AtLevel(LogLevel.Information).Containing("Auth check passed", StringComparison.Ordinal),
s => s.AtLevel(LogLevel.Information).Containing("Quota check passed", StringComparison.Ordinal),
s => s.AtLevel(LogLevel.Information).Containing("Cache lookup completed", StringComparison.Ordinal))
.Then()
.AtLevel(LogLevel.Information).Containing("Response sent", StringComparison.Ordinal);Semantics:
- Each sub-step is configured by an
Action<HasLoggedSequenceAssertion>: call the same filter methods (AtLevel,Containing,WithException<T>, etc.) on the supplied assertion to build that sub-step's filter chain. - All sub-steps must match in the remaining records before the group is satisfied. Records that match no sub-step are skipped.
- Backtracking matcher: sub-steps are matched against records via backtracking, so any order-independent valid assignment is found if one exists. Overlapping filters compose safely: a broad sub-step that matches many records will not starve a more specific sub-step that needs a particular record. Sub-step declaration order does not affect whether the group succeeds (it still affects the order filter descriptions appear in failure messages).
- Sub-step configurators add filters only. Calling
Then()or recursiveThenAnyOrder(...)from within a sub-step configurator throwsInvalidOperationException: outer-sequence structure must be expressed at the top level afterThenAnyOrder(...)returns. - The chain re-enters strictly-ordered mode after
ThenAnyOrder(...): call.Then()to add a strictly-ordered next step, or end the chain. - Failure diagnostics list every sub-step's filter description, so a missing match is easy to attribute.
Use
ThenAnyOrdersparingly. Most production logging IS strictly ordered (a single thread writes the records). Reach forThenAnyOrderonly when the workflow genuinely involves parallel work and the test would otherwise be brittle to reordering.
Because the assertion types derive from TUnit's Assertion<T>, the standard TUnit chaining works. .And is genuinely useful for log assertions: chain a positive and a negative invariant in one expression:
await Assert.That(collector)
.HasLogged().AtLevel(LogLevel.Information).AtLeast(1)
.And.HasNotLogged().AtLevel(LogLevel.Error);For three-or-more conditions, prefer the dedicated AssertAllAsync batch terminator: it aggregates failures into a single message rather than failing fast on the first.
.Or is rarely useful for log assertions. "Either no errors were logged OR a specific recovery was logged" is a contrived shape; in practice tests want both, not either. The mechanism is available via TUnit if you need it, but the cookbook below shows no examples because the use case is genuinely uncommon. If you find yourself reaching for .Or, consider whether MatchingAny(...) (an OR of filters, not whole assertions) expresses the intent more clearly.
Two ways to run several invariants in one pass and see every violation, not just the first.
AssertAllAsync: the log-specific batch terminator. Aggregates failures from log-shape assertions against the same collector:
await Assert.That(collector).AssertAllAsync(
c => c.HasLogged().AtLevel(LogLevel.Information).AtLeast(1),
c => c.HasNotLogged().AtLevel(LogLevel.Error),
c => c.HasLoggedSequence()
.Containing("Started", StringComparison.Ordinal)
.Then().Containing("Stopped", StringComparison.Ordinal));If two of three fail, the thrown exception's message lists both: not just the first.
A second overload (added in 0.2.1) accepts the more verbose async c => await c.HasLogged()... form for cases where the lambda needs to mix in non-assertion async work between checks. Pick whichever is clearer for the case at hand; both have identical failure-aggregation semantics.
Assert.Multiple: TUnit's general-purpose batch. Works cleanly with our chains too. Useful when a test mixes log assertions with non-log assertions (e.g. assert on the return value of the system-under-test AND on what it logged):
using (Assert.Multiple())
{
await Assert.That(result.Status).IsEqualTo(OperationStatus.Succeeded);
await Assert.That(collector).HasLogged().AtLevel(LogLevel.Information).AtLeast(1);
await Assert.That(collector).HasNotLogged().AtLevelOrAbove(LogLevel.Error);
}All three failures (if all three are wrong) appear in the aggregated exception. When to prefer which: AssertAllAsync reads more naturally when every check is against the same collector (no Assert.That(collector) repeated); Assert.Multiple shines when mixing log and non-log assertions, or when the lambda form of AssertAllAsync would force a separate-statement layout anyway.
Because LogAssertions.TUnit's chains derive from TUnit's Assertion<T>, several TUnit core features just work without any LogAssertions-specific wiring. The patterns most worth knowing:
.Because("reason"): annotate why the assertion matters. Chains cleanly on any of our terminators and surfaces the justification in the failure message. Useful when the failure-message snapshot alone doesn't make the intent of the assertion obvious to whoever's reading the CI log:
await Assert.That(collector)
.HasNotLogged()
.AtLevelOrAbove(LogLevel.Error)
.Because("happy-path workflow must not log errors");A failure renders both the captured-records snapshot AND the reason, so the next person to triage sees what happened and why it shouldn't have.
[NotInParallel] and the FakeLogCollector lifetime model. FakeLogCollector is instance-scoped: each test gets its own via LogCollectorBuilder.Create(): so tests using LogAssertions.TUnit DO NOT need [NotInParallel] for the collector itself. They run safely in parallel.
[NotInParallel] becomes necessary only when the test interacts with global logging state (e.g. NLog's LogManager.Configuration, Serilog's static Log.Logger, or any singleton logging configuration). In that case, give every test that touches the same global a single shared [NotInParallel("global-logging-config")] key: different keys do NOT serialize against each other, which is a subtle source of latent races.
Should() style: currently deferred. Upstream TUnit ships a separate TUnit.Assertions.Should package that adds value.Should().BeEqualTo(...) style on top of TUnit.Assertions. In principle, our [AssertionExtension]-decorated chains could auto-generate collector.Should().HaveLogged()... counterparts via that package's source generator. In practice we have not verified this hands-on: the upstream TUnit.Assertions.Should package is currently beta-only, this project's dependency policy forbids beta packages, and our own quick spike could not resolve collector.Should() from a consumer test project. We have parked the investigation pending the upstream package reaching stable. If you adopt LogAssertions.TUnit and care about Should-style ergonomics, please open an issue on the GitHub repo and we will prioritize verification.
Sometimes a test wants to inspect what was logged without asserting: for further calculations, debugging output, or cross-checking. The core package adds three extensions on FakeLogCollector; the TUnit adapter package adds one more bound to TUnit's per-test output writer.
| Method | Package | Returns |
|---|---|---|
Filter(params ILogRecordFilter[] filters) |
LogAssertions |
The matching records as a defensive IReadOnlyList<FakeLogRecord> |
CountMatching(params ILogRecordFilter[] filters) |
LogAssertions |
Just the match count (no list materialisation) |
DumpTo(TextWriter writer) |
LogAssertions |
Writes every captured record in the failure-message format (Default verbosity) |
DumpTo(TextWriter writer, DumpVerbosity verbosity) (v0.4.0+) |
LogAssertions |
Verbosity-controlled dump. See Dump verbosity. |
DumpToTestOutput() |
LogAssertions.TUnit |
Same as DumpTo, targeting TestContext.Current.Output.StandardOutput |
DumpToTestOutput(DumpVerbosity verbosity) (v0.4.0+) |
LogAssertions.TUnit |
Verbosity-controlled variant. |
// Inspect without asserting
var warnings = collector.Filter(LogFilter.AtLevel(LogLevel.Warning));
int errors = collector.CountMatching(
LogFilter.AtLevelOrAbove(LogLevel.Error),
LogFilter.WithException<DbException>());
// Print the entire snapshot to test output during development: TUnit-aware shorthand
// for the DumpTo(TextWriter) pattern. Records appear inline in the test report.
collector.DumpToTestOutput();
// Verbosity-controlled variants (v0.4.0+)
collector.DumpToTestOutput(DumpVerbosity.Compact); // headlines only
collector.DumpToTestOutput(DumpVerbosity.Verbose); // includes full exception ToString()DumpToTestOutput() throws InvalidOperationException if called outside a TUnit test context (no TestContext.Current). For non-TUnit contexts or when you need the rendered text as a string, use the framework-agnostic DumpTo(TextWriter) overload from the core LogAssertions package.
The DumpVerbosity enum controls how much detail each record renders:
| Verbosity | Per-record output |
|---|---|
DumpVerbosity.Compact |
One line per record: [lvl] category: message. No properties, scopes, or exception details. Use when the captured-records list is only needed as an at-a-glance sanity check. |
DumpVerbosity.Default |
The standard rendering used by failure messages: headline plus indented summary lines for structured state, scopes, and a one-line exception summary. The no-arg overloads of DumpTo / DumpToTestOutput use this. |
DumpVerbosity.Verbose |
Default rendering plus the full exception ToString() (stack trace + inner-exception chain). Use when an exception's stack is the diagnostic signal. |
// Compact during early development: see what was logged at all
collector.DumpToTestOutput(DumpVerbosity.Compact);
// Default: mirrors the failure-message format
collector.DumpToTestOutput();
// Verbose: full stack trace per logged exception
collector.DumpToTestOutput(DumpVerbosity.Verbose);The exact text produced by each level is documented as not stable (consistent with the rest of the rendering surface); pin broad markers like "[warn]" rather than exact whitespace or punctuation.
On a failed assertion the AssertionException message includes:
- The expectation (terminator + filter summary)
- The actual match count
- A snapshot of every captured record, with 4-character level abbreviation (matching the
Microsoft.Extensions.Loggingconsole formatter), category, message, structured properties, active scopes, and exception details
Example failure output:
Expected: exactly 1 log record(s) to have been logged matching: Level = Warning, Message contains "timeout"
3 record(s) matched
Captured records (5 total):
[info] MyApp.Worker: Started cycle 1
props: cycle=1
scope: RequestId=abc-123
[warn] MyApp.Worker: timeout exceeded for cycle 1
props: cycle=1, threshold=500
scope: RequestId=abc-123
[warn] MyApp.Worker: timeout exceeded for cycle 2
props: cycle=2, threshold=500
scope: RequestId=abc-123
[warn] MyApp.Worker: timeout exceeded for cycle 3
props: cycle=3, threshold=500
scope: RequestId=abc-123
[info] MyApp.Worker: Cycle batch finished
scope: RequestId=abc-123
exception: TimeoutException: Connection timed out
Level abbreviations: trce, dbug, info, warn, fail, crit (matching MEL's console formatter; none for LogLevel.None).
This eliminates the historical pattern of adding temporary Console.WriteLine calls to debug failing log assertions: every dimension you can filter on is also rendered in the failure message.
await Assert.That(collector).HasNotLogged().AtLevelOrAbove(LogLevel.Error);Anchored on the message template, not the substituted value:
await Assert.That(collector).HasLogged()
.WithMessageTemplate("Order {OrderId} processed").AtLeast(1);await Assert.That(collector).HasLogged()
.WithProperty("DurationMs", v =>
int.TryParse(v, CultureInfo.InvariantCulture, out var ms) && ms < 1000)
.AtLeast(1);await Assert.That(collector).HasNotLogged()
.WithScopeProperty("RequestId", "req-42")
.AtLevelOrAbove(LogLevel.Error);await Assert.That(collector).HasLogged()
.AtLevel(LogLevel.Error)
.WithException<DbUpdateConcurrencyException>()
.Once();await Assert.That(collector).HasLoggedSequence()
.WithEventName("Startup")
.Then().AtLevel(LogLevel.Information).Containing("processed", StringComparison.Ordinal)
.Then().WithEventName("Shutdown");await Assert.That(collector).HasLogged()
.AtLevel(LogLevel.Warning)
.WithMessageTemplate("Retrying after {Delay}ms")
.Exactly(3);var (factory, collector) = LogCollectorBuilder.Create();
using (factory)
{
var logger = factory.CreateLogger("MyService");
new MyService(logger).DoWork();
await Assert.That(collector).HasLoggedOnce().Containing("done", StringComparison.Ordinal);
}// Define once in a test base class:
private static readonly ILogRecordFilter CriticalDbError = LogFilter.All(
LogFilter.AtLevel(LogLevel.Critical),
LogFilter.WithException<DbException>());
// Use in many tests:
await Assert.That(collector).HasNotLogged().WithFilter(CriticalDbError);
await Assert.That(otherCollector).HasLoggedExactly(1).WithFilter(CriticalDbError);await Assert.That(collector).AssertAllAsync(
c => c.HasLogged().AtLevel(LogLevel.Information).AtLeast(1),
c => c.HasNotLogged().AtLevelOrAbove(LogLevel.Error),
c => c.HasLoggedSequence()
.WithEventName("Startup")
.Then().WithEventName("Shutdown"));await Assert.That(collector).HasLogged()
.WithScopeProperty("RequestId", "req-42")
.AtAnyLevel(LogLevel.Warning, LogLevel.Error)
.AtLeast(1);// Run your code-under-test, then dump everything to the test output:
using var writer = new StringWriter();
collector.DumpTo(writer);
Console.WriteLine(writer);
// Or get a typed handle on the matching records for further checks:
var retries = collector.Filter(
LogFilter.AtLevel(LogLevel.Warning),
LogFilter.Containing("retry", StringComparison.Ordinal));Three issues that surface during early adoption. If you hit something not listed here, please open an issue on the GitHub repo: the FAQ is built from real adoption signal, not from speculation.
Symptom: error CS1061: 'IAssertionSource<FakeLogCollector>' does not contain a definition for 'HasLoggedOnce'.
Cause: you're on LogAssertions.TUnit 0.2.0 or 0.2.1, where the shorthand entry points lived in the LogAssertions.TUnit namespace and required an explicit using LogAssertions.TUnit; to discover.
Fix: upgrade to 0.2.2 or later. From 0.2.2 the shorthands live in TUnit.Assertions.Extensions and auto-import alongside Assert.That(): no extra using needed. The repo's external-consumer smoke-test project (added in 0.3.0) now guards against this regression at build time, so the bug class can't ship again.
Symptom: error CS0103: The name 'LogCollectorBuilder' does not exist in the current context (or the same for LogFilter, ILogRecordFilter, FakeLogCollectorInspectionExtensions).
Cause: these types live in the framework-agnostic LogAssertions namespace (the core package). Unlike the assertion entry points, they do NOT auto-import: they need an explicit using LogAssertions;.
Fix: add using LogAssertions; at the top of the file, or: if many test files use them: add global using LogAssertions; to a GlobalUsings.cs per the Namespaces section. The recommended GlobalUsings snippet there covers every namespace the test project will need.
Symptom: strict-analysis projects (TreatWarningsAsErrors=true, IDE0005 promoted to error) fail to build because some test files don't reach for LogCollectorBuilder / LogFilter, only for the assertion entry points (which auto-import).
Cause: the using LogAssertions; is required in test files that reach for LogCollectorBuilder / LogFilter, but redundant in files that only call await Assert.That(collector).HasLogged().... IDE0005 flags the redundant uses per file.
Fix: move using LogAssertions; (and the other recommended imports) into a single GlobalUsings.cs per the Namespaces section. Globals are exempt from IDE0005's per-file unused-import check: one declaration covers every file in the project, no analyzer noise.
-
Built on
[AssertionExtension](TUnit 1.41.0+, thomhurst/TUnit#5785): the entry-point methods are emitted by TUnit's source generator. No extension-method wrappers needed. -
No cross-package coupling. This package depends on
TUnit.AssertionsandMicrosoft.Extensions.Diagnostics.Testing. Neither of those depends on the other; this library is the bridge. -
AOT-compatible / trimmable.
IsAotCompatible=true,IsTrimmable=true,EnableTrimAnalyzer=true. No reflection in the assertion path. Scope-property matching uses interface casts only, never reflection. -
TFM policy: LTS-anchored, multi-target during STS support windows: targets
net10.0today (the current LTS, supported through November 2028). When .NET 11 ships as a non-LTS (STS) release, the package multi-targetsnet10.0;net11.0. When the next LTS (.NET 12) ships, both 10 and 11 are dropped on the same release; the new LTS becomes the single target until its STS sibling lands the following November. Wide multi-targeting (net8;net9;net10) is explicitly out: the goal is "current LTS, plus current STS while it exists", never long historical tails. SeeCONVENTIONS.mdfor the full schedule.You can still consume this package even if your production code targets an older TFM. Test projects routinely target a higher TFM than the production code they test: the .NET SDK supports a
net10test project referencing anet8production project (net10runtime is forward-compatible withnet8assemblies). The test exe loads on thenet10runtime and invokes the production code through itsnet8surface. The reverse: referencing anet10production lib from anet8test: does not work, but that's not a typical setup.Concrete: if your production lib targets
net8.0, set your test project's<TargetFramework>tonet10.0, installLogAssertions.TUnit, and the production<ProjectReference>continues to resolve cleanly. -
Explicit
StringComparison. Every string-matching API requires the caller to pass aStringComparison(or usesOrdinalinternally where unambiguous). No silent culture defaults. -
Source Link + deterministic builds. Both packages ship with
Microsoft.SourceLink.GitHub, a separate.snupkgsymbol package, and embedded sources (EmbedUntrackedSources). When a debugger steps into the assertion code, the source is fetched directly from this GitHub repo at the exact commit the package was built from: useful when you're investigating why a filter didn't match the record you expected. Builds are deterministic by default (the SDK's<Deterministic>true</Deterministic>).
Per SemVer, the 0.x series is initial development: anything may change in any minor version, and there is no formal contract yet. The intent below documents what we try to keep stable so consumers can plan. A 1.0 release will turn this from intent into contract.
Intended-stable (we will not break these without a CHANGELOG-flagged reason and a clear migration path):
- The three entry-point methods on
IAssertionSource<FakeLogCollector>:HasLogged(),HasNotLogged(),HasLoggedSequence(). - The top-level shorthand entry points (
HasLoggedOnce,HasLoggedExactly,HasLoggedNothing,HasLoggedWarningOrAbove, etc.). - The fluent chain methods on
HasLoggedAssertion,HasNotLoggedAssertion,HasLoggedSequenceAssertion: every named filter (AtLevel,Containing,WithCategory,WithException,WithInnerException,WithInnerExceptionMessage,WithScopeProperty,WithScopeProperties, etc.), every terminator (Once,Exactly,Between,GetMatch,GetMatches, etc.), the sequence-step combinators (Then,ThenAnyOrder), and the combinator methods (WithFilter,MatchingAny,MatchingAll,Not,When). - The
ILogRecordFilterinterface and theLogFilterstatic factory's public methods. - The
LogCollectorBuilder.Createfactory. - The
FakeLogCollectorextension methods:Filter,CountMatching,DumpTo,AssertAllAsync.
Explicitly unstable (will change without notice, do not depend on):
LogAssertionBase<TSelf>and its protected/internal members. The type ispubliconly because the CRTP pattern requires it (C# does not allow public classes to inherit from internal); it is annotated[EditorBrowsable(Never)]and is not a supported derivation point. Treat it as a sealed implementation detail of the three public assertion classes.- The internal filter classes (
PredicateFilter,AndFilter,OrFilter,NotFilter). These live behindILogRecordFilterand theLogFilterfactory. - The exact format of failure-message snapshot text rendered by
LogAssertionRenderingand exposed viaDumpTo. The rendering may gain extra detail or change formatting in any release. Do not pin exact failure-message text in tests: pin filter match counts and broad markers (e.g.Contains("[warn]")) only. - The
CompatibilitySuppressions.xmlfile is a build artifact tracking baseline acceptance, not part of the API contract.
Breaking changes log (every release with a breaking change is listed in CHANGELOG.md):
- 0.2.0:
LogAssertionBase<TSelf>annotated[EditorBrowsable(Never)]; theprotected virtual void AddPredicate(Func, string)extension hook replaced byprotected virtual void AddFilter(ILogRecordFilter)as part of theILogRecordFilterrefactor. Affects only consumers who derived fromLogAssertionBase(an unsupported scenario). Framework-agnostic types (ILogRecordFilter,LogFilter, etc.) moved fromLogAssertions.TUnitto a newLogAssertionspackage + namespace; theLogAssertions.TUnitpackage now has aLogAssertionstransitive dependency.
The current 0.4.0 surface covers the high-frequency 80%+ of real-world log-assertion needs: composable filters (now including inner-exception and multi-property scope filters), all common count terminators (including value-returning GetMatch/GetMatches), sequence assertions with both strict-order (Then) and concurrent-group (ThenAnyOrder) semantics, scope-property subset matching, batch assertions (AssertAllAsync and Assert.Multiple interop), Because reason annotation, the inspection extensions (including TUnit-aware DumpToTestOutput with verbosity control), and the framework-agnostic core split. The list below is the candidate backlog for future versions; nothing here is committed and nothing will be built without demonstrated demand.
Items that landed in this release. Documented here for historical context; the surfaces themselves live in the relevant sections above.
WithInnerException<TInner>()+WithInnerExceptionMessage(string substring, StringComparison comparison)filters: match againstException.InnerException(one level). Designed for the gRPC / RPC pattern where a transport exception wraps the underlying domain exception once. See Exception filters.WithExceptionMessage(string substring, StringComparison comparison)overload: explicit-comparison variant of the legacy single-argWithExceptionMessage(string)(which is now[Obsolete]and removed in v0.6.0). Aligns the public surface with the family-wideStringComparisonrule.WithScopeProperties(IDictionary<string, object?>)filter: subset match across all active scopes for a record; each key/value pair must match in some scope, but different pairs may match in different scopes. See Subset match across multiple scopes.HasLoggedSequence.ThenAnyOrder(...): concurrent step group: all sub-steps must match in the remaining records but the relative order is unconstrained. Backtracking match (no order-dependence on overlapping filters); records that match no sub-step are skipped. See Concurrent steps:ThenAnyOrder.DumpVerbosityenum +DumpTo(TextWriter, DumpVerbosity)/DumpToTestOutput(DumpVerbosity)overloads: three levels:Compact(headlines only),Default(existing one-liner detail),Verbose(Default plus full exceptionToString()including stack trace). See Dump verbosity.
GetMatch()/GetMatches(): value-returning terminators onHasLogged(). See Value-returning terminators.DumpToTestOutput(): TUnit-aware variant ofDumpTo(TextWriter). See Non-asserting inspection.- External-consumer smoke-test project in CI:
tests/LogAssertions.TUnit.SmokeTest/consumes the just-packed nupkg viaPackageReferencefrom a deliberately-different namespace (Smoke.Consumer.*). Pins the v0.2.0/v0.2.1 namespace-resolution regression closed at build time. - Documentation interop pins:
Because,Assert.Multiple,[NotInParallel]guidance, Troubleshooting FAQ. See TUnit-native conveniences and Troubleshooting.
These items are concrete and tracked but require either consumer demand or upstream movement before they ship.
Should()syntax verification + documentation: currently deferred. UpstreamTUnit.Assertions.Shouldis beta-only and our dependency policy forbids beta packages, so we cannot yet verifycollector.Should().HaveLogged()hands-on. Will revisit when the upstream package goes stable.DescribedAs(string label)on filters: letWhere(predicate)and composedMatchingAny/MatchingAllfilters carry a human-readable label that shows in failure diagnostics instead of the generic rendering.- Time-based filters:
WithElapsedTime(min, max),WithTimestamp(at, tolerance),ThenGap(TimeSpan)in sequence,Throttled(maxPerWindow)for rate-limit verification. Coherent set; needs a unified design pass. WithinTimeout(TimeSpan)polling terminator: for tests against background services / event handlers, replacing the brittleawait Task.Delay(...)pattern. Real design questions about snapshot-vs-time mental model and composition with existing terminators (Once().WithinTimeoutsemantics): needs a focused design spike before implementation.Member()/AndTheMatch()bridge: let aHasLogged()chain hand its match(es) to TUnit's.Member()assertion shape in one expression. Largely subsumed byGetMatch()/GetMatches()shipped in 0.3.0; this stays parked unless a second consumer reports the chained form is materially better than the variable-capture form.- Sequence variants:
ThenImmediately()(strict adjacency),NotInterleaved()(no other records from same category between matches),InOrder()terminator onHasLogged(multiple matches in chronological order, not necessarily adjacent). - Cursor / direction:
FromNewest()/FromOldest()direction control,SinceLastAssert()watermark,Pin()snapshot pinning,HasLoggedDistinct(int)(dedupe + count). HasNotLoggedSequence(): mirror ofHasLoggedSequence, asserts a specific sequence did NOT occur.
Larger pieces of work that need either real demand or a separate package. None of these is on a timeline.
- Source generator for
[LoggerMessage]-derived typed assertion helpers: e.g.HasLogged().RetryExhausted(maxRetries: 3)generated from the[LoggerMessage]declaration. Would be a flagship differentiator; significant undertaking. - Verify integration:
collector.ToVerifyString()for golden-file approval of full log sequences. Likely a separateLogAssertions.Verifypackage. - Framework adapter packages:
LogAssertions.NUnit,LogAssertions.xUnit,LogAssertions.MSTest. TheLogAssertionscore package already supports them architecturally; only built when someone asks.
- Multi-collector aggregate:
Assert.That(c1, c2, c3).HasLogged(...)for pipeline tests with several loggers. - Diagnostic upgrades: per-record match-tagging in failure dump, grouping by category/level.
- Scope-aware sequence:
HasLoggedSequence().WithScopeProperty("RequestId", "abc").... - Parallel-safe collector partitioning (depends on TUnit's parallel-test story).
- Benchmarks + perf documentation (will probably do once before v1.0 to honestly characterise).
- Roslyn analyzer package for "common mistakes" (forgotten terminator → silent
AtLeast(1), etc.). Considered and declined: a rule that says "the implicitAtLeast(1)default may mask count bugs" is a band-aid for an API design choice. If the implicit default is wrong, the right fix is to change the default; shipping an analyzer that polices our own API surface is not the right shape. Would reconsider only if a different class of mistake surfaces from real adoption that isn't a patch on the API. - Package-shipped
<Using Include="LogAssertions" />viabuild/LogAssertions.props: silently adding globals via a package's.propsfile is a NuGet-ecosystem pattern consumers have learned to distrust. The documentedGlobalUsings.csrecommendation stays the lower-surprise default.
WithCallerInfo(...): MEL doesn't auto-propagate[CallerMemberName]etc. into log records.WithContext<T>AsyncLocal context filter: niche, conflates withWithScope.WithStructuredState<T>typed state:FakeLoggerempirically does not preserve the typed state object (we proved this by testing).- JSON property matching (
HasLoggedJson): depends on JSON serializer, AOT-incompatible without source-gen, ecosystem-fragmenting. - Anonymous-object scope inspection: would require reflection; intentionally out of scope for AOT-compatibility.
- Localization-aware level names:
LevelAbbreviationis intentionally English-centric to match MEL's console formatter.
- Multi-target
net8;net9;net10: see "Single TFM, forward-only" in Design notes.
If you'd find any of the candidate items useful, open a feature request.
The three assertion-family packages: LogAssertions.TUnit, TimeAssertions.TUnit, and SnapshotAssertions.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:
TimeAssertions.TUnit:FakeTimeProviderstate assertions,TimeProvider-awareDateTimeOffsetrecency / past / future checks, and the cross-cutting.And.WithinTimeBudget(TimeSpan)chain extension. Compose withHasLogged()to add a timing budget to log assertions.SnapshotAssertions.TUnit: text-snapshot assertions for API-surface tests and similar deterministic-string scenarios. UseMatchesSnapshot()to pin the rendered output ofLogAssertionRendering(e.g.DumpTooutput) in integration tests.
The TUnit feature request that motivated this package was thomhurst/TUnit#5627, declined on architectural grounds (no cross-package coupling between TUnit.Logging.Microsoft and TUnit.Assertions). The user-space pattern was unblocked when thomhurst/TUnit#5785 shipped [AssertionExtension] infrastructure in TUnit 1.41.0. This package implements the user-space pattern.
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, and MathAssertions.TUnit.
MIT. Copyright (c) 2026 John Verheij.