LogAssertions.TUnit
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.
Part of the DotNetAssertions family of assertion extensions for TUnit.
Table of contents
- Why this package
- Install
- Package layout
- Namespaces (and a
GlobalUsings.csrecommendation) - Quick start
- Migrating from manual assertions
- Entry points
- Filter reference
- Level filters
- Message filters
- Exception filters
- Structured-state (property) filters
- Scope filters
- Identity filters (category, event)
- Typed definition filters (
Matching,MatchingCall) (v0.11.0+) - Escape hatch
- Combinator chain methods (
MatchingAny,MatchingAll,Not,WithFilter) - Conditional configuration (
When)
- 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
Why this package
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 the captured records (including structured properties and scope content) in failure messages.
Install
dotnet add package LogAssertions.TUnit
Requirements: TUnit 1.83.0 or later, .NET 10 and .NET 11. The package is AOT-compatible, trimmable, and uses no reflection in the assertion path.
Setup: the test lifetime attribute (v0.14.0+)
Add one line to the test project, for example in GlobalUsings.cs:
[assembly: LogAssertions.TUnit.LogAssertionsTestLifetime]
TUnit gives code inside a test no way to observe that test's end, so the package needs this opt-in. The attribute receives TUnit's start of each test (before its [Before(Test)] hooks) and end (after its [After(Test)] hooks), once per attempt of a retried test. It enables two things:
DumpToTestOutputOnFailure(), which throws without it, with a message that names this line.- Exact routing for the test-output tee: a record is never written into a test that has ended. Without the attribute, the tee uses a coarser fallback (described there).
Apply it once in every test assembly (every test project), also when the package reaches a project only through another project: an assembly attribute is read from the assembly that holds the tests, so a test project without its own line behaves as if the attribute were missing. Everything else works without it. Without it, the first tee built in a test run writes one note to that test's output, naming this line.
Upgrading to v0.14.0
v0.14.0 has three breaking changes. The first two fail at compile time, the third with a clear assertion failure. The tee also changes what it writes into the test report, without changing any outcome, and projects that run Meziantou's TimeProvider rules see a new diagnostic:
| Change | Migration |
|---|---|
LogCollectorBuilder.Create() and TestOutputLogCollectorBuilder.CreateTeed() return a LogCapture instead of a (Factory, Collector) tuple. var (factory, collector) = LogCollectorBuilder.Create(); fails to compile (CS1061: LogCapture has no Deconstruct; earlier the error named the new form). |
using var logs = LogCollectorBuilder.Create();, then use logs as the ILoggerFactory and logs.Collector for assertions. LogCapture is also IAsyncDisposable: in async code write await using var logs = ... (analyzers such as Roslynator RCS1261 and Meziantou MA0042 report a plain using or an explicit Dispose() there). A field that holds the capture can be typed LogCapture (CA1859 asks for it) |
A project with Meziantou MA0166 / MA0167 enabled (MA0167 is on in all-warnings mode) reports every Create() / CreateTeed() without a clock, since the TimeProvider overloads exist |
pass the clock: the fake clock in scope (records then follow it), else TimeProvider.System |
AddTeedFakeLogging(builder, collector, level, teeProvider) lost its teeProvider parameter |
chain the provider: .AddTeedFakeLogging(collector, level).AddProvider(provider); for TUnit's correlated output, .AddFakeLogging(collector, level).AddCorrelatedTUnit(level) |
HasLogged()...Never(), AtMost(n), Exactly(0) and Between(0, n) fail as vacuous when every level the chain targets sits below the collector's capture floor, as HasNotLogged() already did |
assert at a captured level, or lower the floor (LogCollectorBuilder.Create(minimumLevel)) |
| The built-in tee no longer writes a record that no running test claims into a test that has ended; such a record is skipped and counted. See where each record goes | none; add the lifetime attribute for exact routing |
Package layout
This repo ships two NuGet packages:
| Package | Purpose | Depends on |
|---|---|---|
LogAssertions |
Framework-agnostic core: ILogRecordFilter + LogFilter + rendering (LogSnapshotRenderer, LogContractRenderer) + collector inspection extensions + event-driven waits (WaitForLogAsync) + LogCollectorBuilder and LogCapture |
Microsoft.Extensions.Diagnostics.Testing |
LogAssertions.TUnit |
TUnit-specific entry points: HasLogged(), HasNotLogged(), HasLoggedSequence(), HasLoggedOnly(), WillLog() and shorthands; the test-output tee and the failure dump |
LogAssertions + TUnit.Assertions + TUnit.Core |
You install LogAssertions.TUnit; LogAssertions comes transitively. Both are safe to reference from a shared test-support library (for example one that wires AddTeedFakeLogging for several test projects): LogAssertions.TUnit depends on TUnit.Assertions and TUnit.Core, not on the TUnit meta-package, so the library does not turn into a test project. Adapters for other test frameworks (NUnit, xUnit, MSTest) are not planned; the family targets TUnit.
Namespaces (and a GlobalUsings.cs recommendation)
The two packages place types in two namespaces with deliberately-different scopes:
| Type / member | Namespace | Auto-imported? |
|---|---|---|
HasLogged(), HasNotLogged(), HasLoggedSequence(), HasLoggedOnly(), WillLog() (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(...) (returns a LogCapture), LogCollectorBuilder.CreateDiscarding(...) |
LogAssertions |
No: needs using LogAssertions; |
LogFilter.AtLevel(...), ILogRecordFilter, Filter/CountMatching/DumpTo/WaitForLogAsync extensions |
LogAssertions |
No: needs using LogAssertions; |
LogSnapshotRenderer, LogContractRenderer |
LogAssertions.Render |
No: needs using LogAssertions.Render; |
TestOutputLogCollectorBuilder, AddFakeLogging/AddTeedFakeLogging, DumpToTestOutput/DumpToTestOutputOnFailure, AssertAllAsync(...), LogAssertionsTestLifetimeAttribute |
LogAssertions.TUnit |
No: needs using LogAssertions.TUnit; (the attribute line spells the namespace out) |
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;; files that use the TUnit helpers (the tee builders, DumpToTestOutput*, AssertAllAsync) also need using LogAssertions.TUnit;.
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, FakeLoggerProvider
System 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.
Quick start
using LogAssertions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Testing;
[Test]
public async Task Validation_failure_is_logged()
{
await using var logs = LogCollectorBuilder.Create();
var logger = logs.CreateLogger<MyValidator>();
new MyValidator(logger).Validate(invalidInput);
await Assert.That(logs.Collector)
.HasLogged()
.AtLevel(LogLevel.Warning)
.Containing("validation failed", StringComparison.Ordinal)
.WithCategory("MyApp.MyValidator")
.Once();
await Assert.That(logs.Collector).HasNotLogged().AtLevel(LogLevel.Error);
}
Lifetime / disposal: LogCollectorBuilder.Create() returns a LogCapture (v0.14.0+): the logger factory itself, so you can pass it wherever an ILoggerFactory is expected, plus its Collector for assertions. One using disposes the factory and its providers; in async code use await using, since it is also IAsyncDisposable and analyzers that prefer asynchronous disposal (Roslynator RCS1261, Meziantou MA0042) report a plain using there. Disposal does not stop capture: a logger created before keeps writing into the collector, so dispose at the end of the test, which await using var does. The collector keeps its records after disposal, so assertions can still run.
Migrating from manual assertions
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 the captured records (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.
Prefer structured matchers over substring matching
.Containing("...") couples a test to the rendered message text, so it silently rots when a [LoggerMessage] template is reworded. When the assertion is really about a structured field, an attached exception, or a scope value, the dedicated matcher is both more robust and more precise; reach for the substring form only when the rendered text itself is what the test is about.
| Instead of (substring) | Prefer (structured) |
|---|---|
.Containing("OrderId=42") |
.WithProperty("OrderId", 42) |
.Containing("failed") on an Error line |
.WithException<TException>() (or .WithExceptionMessage(...)) |
.Containing(requestId.ToString()) |
.WithScopeProperty("RequestId", requestId) |
.Containing("retries=") just to prove a key is present |
.WithScopeProperty("retries") (v0.7.0+) |
.Containing("user created") matching the template text |
.WithMessageTemplate("user created") (value-independent) |
| multi-step "warn then debug then reconnected" via several asserts | .HasLoggedSequence().Then(...).Then(...) |
any of the above pinning a specific [LoggerMessage] definition |
.Matching(definition) (v0.11.0+; see typed definition filters) |
.WithMessageTemplate matches the original [LoggerMessage] template rather than the rendered values, so it survives a value change while still pinning intent. The structured matchers also keep the failure diagnostics: a .WithProperty miss lists the structured state actually captured, not just "substring not found". From v0.11.0, .Matching(LogDefinition) goes one step further: it references the definition itself, so the assertion holds no message text to keep in sync, and a renamed definition (or a change to its signature) breaks the capture at compile time.
Entry points
Five 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 and AtMostOncePer (see below) |
HasNotLogged() |
Zero matching records | None: fixed at zero |
HasLoggedSequence() |
An ordered series of matches; Then() separates steps, or Step(definition) (v0.12.0+) adds a typed step |
None: each step's match is implicit |
HasLoggedOnly(floor) (v0.12.0+) |
Nothing at or above floor other than the definitions passed to Allowing(...) |
None: Allowing(...) sets the allowlist |
WillLog(timeout, cancellationToken) (v0.14.0+) |
At least 1 matching record, waiting until it arrives | Once(), Exactly(n), AtLeast(n), Between(min, max) (counts of at least 1), GetMatch(), GetMatches() |
All but HasLoggedOnly 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"); WillLog() waits for a record that background work logs later.
Waiting for background work: WillLog (v0.14.0+)
await Assert.That(collector).WillLog(TimeSpan.FromSeconds(5), cancellationToken)
.AtLevel(LogLevel.Warning)
.Containing("would collide", StringComparison.Ordinal)
.Once();
WillLog is the event-driven form of HasLogged(): it waits on FakeLogCollector.GetLogsAsync, the stream WaitForLogAsync uses (no polling, no real-time delay). The count is read from the collector right after the minimum number of matching records has been captured. Records captured by then count, including ones logged before the assertion ran. A record that arrives after the assertion has passed isn't checked: to pin "no more than one", assert HasLogged()...Once() after the work has settled. The assertion fails when that count is above the maximum. When timeout or the token ends the wait, the records the collector holds at that moment decide (v0.14.1+): enough matching records pass (also a record captured just before the timeout fired, or records already captured when the token was canceled before the assertion ran); otherwise it fails with the same diagnostics as HasLogged(): the expectation, the count, and the captured records. A chain whose levels all sit below the collector's capture floor fails at once, since no such record can arrive.
- The
timeoutis required and is the bound; the token is optional. It is real time, and it starts when the assertion runs (atawait), not when the chain is built. Choose a timeout below the test's own[Timeout], so the wait fails with its own diagnostics first. - A wait proves that records arrived, not that no more follow, so there is no
Never()orAtMost(n). For absence, useHasNotLogged()after the work has finished. GetMatch()/GetMatches()run the assertion and return the matches, as onHasLogged().
WillLog with a fake clock
When the background work waits on a fake clock, the wait must already run while the test advances the clock. Start the assertion, advance, then await it. With AdvanceOnEachTimerUntilAsync from TimeAssertions 0.11.0 or later, which awaits the task it is given, one line does it:
await clock.AdvanceOnEachTimerUntilAsync(WaitForCollisionAsync(), step, cancellationToken);
async Task WaitForCollisionAsync() =>
await Assert.That(logs.Collector).WillLog(TimeSpan.FromSeconds(5), cancellationToken)
.AtLevel(LogLevel.Warning).Containing("would collide", StringComparison.Ordinal).Once();
Before TimeAssertions 0.11.0, AdvanceOnEachTimerUntilAsync advances until the task completes but does not observe its outcome, so a failed WillLog would be dropped and the test would pass. Await the task again:
var logged = WaitForCollisionAsync();
await clock.AdvanceOnEachTimerUntilAsync(logged, step, cancellationToken);
await logged; // rethrows WillLog's failure message if the record did not come
- The chain sits in a local function that awaits it, because TUnit's analyzer (
TUnitAssertions0002, an error) requires everyAssert.That(...)to be awaited; storing the task of.AssertAsync()or.GetMatch()in a variable does not compile under it. For the record itself, make the local function returnTask<FakeLogRecord>with.Once().GetMatch(). - With a plain
FakeTimeProvider, start the task, callclock.Advance(...), then await the task. - The
timeoutstays real time: it bounds how long the test waits, not how much fake time passes.
Clocks (v0.14.0+)
Two clocks are involved, and each member says which one it reads. Record timestamps (FakeLogRecord.Timestamp) come from the collector's clock: the system clock, unless the capture is built with one, LogCollectorBuilder.Create(timeProvider) or TestOutputLogCollectorBuilder.CreateTeed(timeProvider) (or a FakeLogCollector created with FakeLogCollectorOptions.TimeProvider and passed to AddFakeLogging). AtMostOncePer and the times in DumpToTestOutputOnFailure read these timestamps, so they follow a fake clock. WillLog's timeout is real time (TimeProvider.System) whatever the collector's clock: it bounds how long the test waits for real background work. A fake clock is FakeTimeProvider from the Microsoft.Extensions.TimeProvider.Testing package.
using Microsoft.Extensions.Time.Testing;
var clock = new FakeTimeProvider();
await using var logs = LogCollectorBuilder.Create(clock); // record timestamps follow the fake clock
The log gate: HasLoggedOnly (v0.12.0+)
The other entry points assert that an expected record is present. The gate asserts that no unexpected one is, which is the check most suites are missing:
// "nothing at Warning-or-above escaped, except these two known-good events"
await Assert.That(collector).HasLoggedOnly(LogLevel.Warning)
.Allowing(
UpstreamContractViolated, // deliberately emitted; flags an upstream bug
StaleSessionDropped); // benign post-restart close
// no allowlist: the clean-run check
await Assert.That(collector).HasLoggedOnly(LogLevel.Warning);
Every record at or above the floor must carry the identity of an allowed definition. Records below the floor are always permitted and never enumerated - that is what makes the gate usable on a real service, where the Debug/Trace volume (heartbeats, request bodies) is enormous but the warning-and-above band is small enough to list.
Assert it once per test class or fixture (a TUnit [After] hook over the fixture's collector is
the natural home), not per test. Because it keys on definition identity rather than wording, a
reworded template cannot slip an unexpected record past it.
Guard against a false green: if the collector's capture floor sits above the gate's floor, the
records the gate claims to inspect were never captured, so the gate fails loudly as vacuous rather
than passing for the wrong reason (the same protection HasNotLogged() has).
Expect it to fail the first time. Added to an existing golden-path suite, the gate is an audit: it
lists every warning-or-above record the tests have been producing unnoticed. Each one is either a real
defect, a fixture gap (a missing setting, a stub that logs an error), or a known-good event that
belongs in Allowing(...). Work through the list once; after that, a new entry means a regression.
The gate answers what escaped. For how often a known record may appear, pair it with a count on that
definition: await Assert.That(collector).HasLogged().Matching(RetryScheduled).Exactly(3);.
Shorthand entry points
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();
Filter reference
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.
Level filters
| Filter | Behavior |
|---|---|
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);
Message filters
| Filter | Behavior |
|---|---|
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);
Exception filters
| Filter | Behavior |
|---|---|
WithException<TException>() |
record.Exception is TException (assignable) |
WithException() |
Any record with a non-null Exception, regardless of type |
WithoutException() (v0.6.0+) |
record.Exception is null. The complement of WithException(): asserts the deliberate absence of an exception (for example a record logged at warning or error level with no exception attached). |
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 was [Obsolete] from v0.4.0 and was removed in v0.6.0; pass the comparison explicitly. |
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.
Structured-state (property) filters
Microsoft.Extensions.Logging exposes structured properties on each record (the parameters captured by LoggerMessage source generators or by message-template logging calls).
| Filter | Behavior |
|---|---|
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) |
WithProperty<T>(string key, T value) (v0.6.0+; where T : IParsable<T>) |
Parses the stored string back to T (invariant culture) and compares via EqualityComparer<T>.Default. An absent or non-parsable value never matches. |
WithProperty<T>(string key, Func<T, bool> predicate) (v0.6.0+; where T : IParsable<T>) |
Parses the stored string back to T (invariant culture) and applies the predicate to the typed value. |
Note: FakeLogRecord exposes structured-state values as strings (the formatted form), so the string? predicate overload receives a string?. For a known parsable type, prefer the typed WithProperty<T> overloads (added in v0.6.0), which parse the stored string back to T for you using the invariant culture:
using System.Globalization;
// Typed (v0.6.0+): the stored "1001" is parsed back to int before the predicate runs.
await Assert.That(collector).HasLogged()
.WithProperty<int>("OrderId", n => n > 1000)
.AtLeast(1);
// String form (still available) when you want raw-string control:
await Assert.That(collector).HasLogged()
.WithProperty("OrderId", v =>
int.TryParse(v, CultureInfo.InvariantCulture, out var n) && n > 1000)
.AtLeast(1);
Scope filters
Scopes are values pushed via logger.BeginScope(...). They surround any log records emitted while the scope is active.
| Filter | Behavior |
|---|---|
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 |
WithScopeProperty(string key) (v0.7.0+) |
A scope contains a property key, regardless of its value (a null value still counts as present). For scope values set internally and unknown to the test, such as caller-info scopes. Pairs with HasNotLogged().WithScopeProperty(key). |
WithScopeProperty<T>(string key, T value) (v0.6.0+) |
Typed match: the scope value is a T equal to value (compared via EqualityComparer<T>.Default). A scope value of a different runtime type never matches. |
WithScopeProperty<T>(string key, Func<T, bool> predicate) (v0.6.0+) |
Typed predicate: the scope value is a T satisfying the predicate. |
WithScopeProperties(IReadOnlyDictionary<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). |
Chaining WithScopeProperty calls matches each key independently across all active scopes: .WithScopeProperty("a", 1).WithScopeProperty("b", 2) accepts a record where a=1 is in some scope and b=2 is in some scope, not necessarily the same one. To require both on a single scope, push them in one BeginScope(dictionary); to require a set of pairs as a subset across scopes, use WithScopeProperties(dictionary).
Scope values keep their runtime type (unlike structured-state values, which are stored as strings), so the typed WithScopeProperty<T> overloads (added in v0.6.0) compare typed-to-typed without boxing boilerplate. They read cleanly when the scope value is a known type:
// MessageId is a Guid in the scope; CallerLine is an int.
await Assert.That(collector).HasLogged().WithScopeProperty("MessageId", messageId).AtLeast(1);
await Assert.That(collector).HasLogged().WithScopeProperty<int>("CallerLine", line => line > 0).Once();
Scope-property filters recognize 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 recognized byWithScopeProperty: reading their fields requires reflection, which would compromise AOT-compatibility. Prefer dictionary orLoggerMessage.DefineScopeform.
Subset match across multiple scopes: WithScopeProperties (v0.4.0+)
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.
Identity filters (category, event)
| Filter | Behavior |
|---|---|
WithCategory(string) |
Logger category equals string (ordinal) |
WithLoggerName(string) |
Alias for WithCategory |
ExcludingCategory(string) |
Inverse of WithCategory |
WithEventId(int) |
EventId.Id equals value |
WithEventIdIn(params int[] eventIds) |
EventId.Id is any of the listed values. At least one is required (an empty list throws ArgumentException); duplicates count once. The description reads EventId in {1, 2}, with braces, so it is not taken for a range |
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();
// One of several events
await Assert.That(collector).HasLogged().WithEventIdIn(1201, 1202).Exactly(2);
Typed definition filters (Matching, MatchingCall) (v0.11.0+)
Assert that a specific [LoggerMessage] definition was emitted, by identity instead of by
matching rendered message text. Capture the definition once (the capture lambda's argument
values are throwaway), store it in a static readonly field, and match it on any chain:
private static readonly LogDefinition OrderShipped =
LogDefinition.Capture(log => LogMessages.OrderShipped(log, 0, ""));
// identity: "the OrderShipped event fired", whatever the argument values
await Assert.That(collector).HasLogged().Matching(OrderShipped).Once();
// the workhorse composition: identity plus the placeholder values that matter
await Assert.That(collector).HasLogged()
.Matching(OrderShipped).WithProperty("OrderId", 42).Once();
// exact call: every placeholder value and the exception must match
await Assert.That(collector).HasLogged()
.MatchingCall(log => LogMessages.OrderShipped(log, 42, "NYC")).Once();
| Filter | Behavior |
|---|---|
Matching(LogDefinition) |
Record carries the definition's identity: equal EventId.Id, equal EventId.Name, equal message template |
MatchingAny(LogDefinition, ...) (v0.12.0+) |
Record carries the identity of any of the given definitions. For one logical event emitted by more than one definition (a verbose form that attaches the exception and a terse form that folds it into the message, chosen at run time by a flag) |
MatchingCall(Action<ILogger>) |
Identity plus every placeholder value (order-insensitive) plus the exception (both absent, or same runtime type and equal message) |
There is deliberately no "matching all definitions" counterpart: a record carries exactly one
identity, so requiring it to match two distinct definitions could never be satisfied. To narrow one
definition further, chain the level, property, and exception filters after Matching(...).
Where your [LoggerMessage] definitions must live
Typed capture calls the definition, so the definition must be callable from the test project:
internal (with [InternalsVisibleTo]) or public. Two rules follow, and the second one bites:
Promote the
[LoggerMessage]method itself, not just a wrapper. Capture the generated definition; the record carries its identity.Do not host definitions you intend to assert on inside a generic type. Roslynator's RCS1158 - "Static member in generic type should use a type parameter" ignores
privatemembers, so aprivate static partial void LogXxxCore(...)on a generic base compiles fine until you promote it tointernalfor typed capture, at which point the build breaks:// MyProcessBase<TSettings, TClient> [LoggerMessage(Level = LogLevel.Warning, Message = "[{Name}] ping to {Uri} failed")] internal static partial void LogPingFailedCore(ILogger logger, string name, string uri); // RCS1158The fix is to move the definitions off the generic type into a non-generic static holder:
internal static partial class MyProcessLog // non-generic { [LoggerMessage(Level = LogLevel.Warning, Message = "[{Name}] ping to {Uri} failed")] internal static partial void LogPingFailedCore(ILogger logger, string name, string uri); }Capture then reads far better too, since there is no generic to close at the call site:
// on a generic base: verbose and typo-prone LogDefinition.Capture(log => MyProcessBase<TOptions, TClient>.LogPingFailedCore(log, "n", "u")); // on a non-generic holder: LogDefinition.Capture(log => MyProcessLog.LogPingFailedCore(log, "n", "u"));(RCS1158 fires only where the whole Roslynator rule category is enabled, which is common in strict analyzer setups. The non-generic holder is the better shape regardless.)
Negative assertions gain the most
The benefit that is easy to miss: HasNotLogged() with a substring silently passes once the
wording drifts, which is exactly when you want it to fail. Keyed on identity it cannot:
// rots silently: reword the template and this passes for the wrong reason
await Assert.That(collector).HasNotLogged().Containing("stale session", StringComparison.Ordinal);
// cannot rot: the definition either fired or it did not
await Assert.That(collector).HasNotLogged().Matching(StaleSessionDropped);
Typed definitions or a rendered snapshot?
They are complementary. Use typed definitions to assert that a specific event did (or did not)
occur, and to pin the handful of placeholder values that matter. Use LogSnapshotRenderer +
MatchesSnapshot when the whole record sequence is the contract and you want any change to
surface as a diff. Reach for the snapshot when you cannot enumerate what you care about; reach for
typed definitions when you can.
How it works: LogDefinition.Capture invokes the lambda once against a private probe
FakeLogger and records what it emitted. No reflection, no source generation; the probe
collects every level, so IsEnabled gates inside generated code cannot suppress the capture. A
lambda that logs zero or multiple records fails fast with an explanatory ArgumentException.
What this buys:
- Wording edits to a template stop breaking tests: the test asserts intent (this event), not prose. Renaming the definition or changing its signature breaks the capture lambda at compile time (a positional capture call does not bind to a renamed or restructured method).
- Argument values and level are deliberately not part of identity. A definition taking
LogLevelas a runtime parameter matches at every level; chainAtLevel(...)to narrow. - Definitions behind wrapper methods work: the record carries the generated Core method's
identity, so capture through the wrapper or the Core interchangeably. Prefer capturing the
[LoggerMessage]method itself when it is accessible. - Definitions hosted in generic classes capture fine (close the generic in the capture lambda,
log => MyHost<int>.Started(log, ...); identity is shared across closings), but a non-generic holder is the better shape: see Where your definitions must live. - Plain
logger.LogInformation("template {X}", x)calls capture too, keyed on the template with event ID zero. A call with a custom state object (not a key/value list) captures with no template and no properties (v0.14.0+; it used to throwInvalidCastException).
Practical notes:
- A definition declared
privatein production code is not invocable from the test project, soLogDefinition.Capturecannot call it. Either match its records without the definition, byWithEventId(int)andWithEventName(string)onHasLogged()/HasNotLogged()(both are set by the[LoggerMessage]attribute and the method name), or promote it tointernaland expose it via[InternalsVisibleTo](production code never references this package: the capture executes the production logging method in the test process only). TheHasLoggedOnlygate allows definitions, so a method you allow there must beinternal. - Identity matching is broader than a substring pinned to one specific value: on a collector
shared across tests, correlate with
WithProperty(...)or clear the collector between tests. - Two definitions with the same method name and template (in different classes, both with
generator-assigned event IDs) produce indistinguishable records; give one an explicit
EventIdwhen the distinction matters. LogFilter.Matching(LogDefinition)andLogFilter.MatchingCall(LogDefinition)expose the same matching as composable filter objects forWithFilter(...)andLogFilter.All/Any/Not.
Escape hatch
| Filter | Behavior |
|---|---|
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.
Combinator chain methods (MatchingAny, MatchingAll, Not, WithFilter)
The fluent chain is implicitly AND-combined. These four chain methods let you compose richer expressions inside the chain without dropping to Where:
| Method | Behavior |
|---|---|
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. |
using System.Data.Common;
// "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);
A filter may run more than once for the same record within one assertion or wait: WillLog checks each record as it arrives and again when it decides, and a canceled wait checks the collector's records again. Keep a custom filter free of side effects.
Conditional configuration (When)
// In a parameterized 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 (HasLogged only)
Terminators express the count expectation. Pick exactly one: chain it after all filters. HasNotLogged has no terminators (the expectation is fixed at zero matches). WillLog takes the counts of at least 1 (see above).
| 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 (the same check as 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 check the same thing, including the capture-floor guard below; 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 realize you expect zero matches: saves rewriting the prefix. Don't agonize over the choice; either reads clearly to a future maintainer.
Counts and the capture floor (v0.14.0+). A count that admits zero (Never(), AtMost(n), Exactly(0), Between(0, n)) is an absence check. When every level the chain targets sits below the collector's capture floor, nothing at those levels was ever captured, so the check fails as vacuous instead of passing for the wrong reason, as HasNotLogged() does. A chain that needs a match names the floor in its failure for the same reason.
Rate-limited records: AtMostOncePer (v0.14.0+)
A service that logs a recurring condition at most once per interval (a full queue once per minute) is checked from the records' timestamps, that is, the collector's clock (a fake one when the capture is built with LogCollectorBuilder.Create(timeProvider)):
await Assert.That(collector).HasLogged().Matching(QueueFull).AtMostOncePer(TimeSpan.FromMinutes(1));
Every gap between consecutive matching records, ordered by FakeLogRecord.Timestamp, must be at least the interval: two records exactly the interval apart pass, one tick less fails. The failure names the first pair that was too close, with both timestamps, the gap, and how many gaps were too short. AtMostOncePer constrains the spacing only: HasLogged() still needs at least one match, so combine it with a count that admits zero (.AtMost(10).AtMostOncePer(...)) when none is fine, or with any other count (.AtLeast(2).AtMostOncePer(...)), in either order.
Value-returning terminators (GetMatch / GetMatches)
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.
Inside Assert.Multiple(), TUnit records a failed assertion instead of throwing it, so there is no match to return. .GetMatch() and .GetMatches() (also on WillLog) then throw InvalidOperationException, whose message names the failure; the scope still reports the original failure when it ends.
Sequence assertions: HasLoggedSequence
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 is a no-op: it neither matches nor consumes a record, so
.Then().Then()acts as one separator. - Failure diagnostics indicate which step failed and dump the full captured-records list (see Failure diagnostics).
Concurrent steps: ThenAnyOrder (v0.4.0+)
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 a lambda that calls the same filter methods (
AtLevel,Containing,WithException<T>, etc.) on the supplied assertion to build that sub-step's filter chain. An expression lambda as above binds to theFunc<HasLoggedSequenceAssertion, HasLoggedSequenceAssertion>overload, so the awaitable assertion it returns is observed and analyzers that require that (Meziantou MA0134, VSTHRD110) stay quiet; a statement lambda binds to theAction<HasLoggedSequenceAssertion>overload. Mixing the two forms in one call binds the whole call to theActionoverload, so write every sub-step as an expression lambda. - 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.
Combining assertions with .And / .Or
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.
Batch assertions: AssertAllAsync and Assert.Multiple interop
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:
using LogAssertions.TUnit;
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. When exactly one fails, its own exception is rethrown as is.
The original overload (0.2.0) 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; the form shown above uses the overload added in 0.2.1. 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.
TUnit-native conveniences (Because, parallelism, Should)
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. Hands-on verification against the LogAssertions.TUnit chain is pending the upstream package reaching stable; the rest of the chain works today via the canonical Assert.That(collector).HasLogged(...) form.
Non-asserting inspection
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 two 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 materialization) |
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. |
DumpToTestOutputOnFailure(), DumpToTestOutputOnFailure(DumpVerbosity verbosity) (v0.14.0+) |
LogAssertions.TUnit |
Writes the records captured since the call to the test's output, with timestamps, only when the test does not pass. Needs the lifetime attribute. See the cookbook. |
using LogAssertions.TUnit;
using System.Data.Common;
// 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.
Dump verbosity (v0.4.0+)
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.CompactWithException |
The Compact headline, plus one indented line (exception: Type: message) for a record that carries an exception. No properties and no scopes, so a dump stays about one line per record. Use when the records are a timeline and the failures in it matter. |
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. |
using LogAssertions.TUnit;
// Compact during early development: see what was logged at all
collector.DumpToTestOutput(DumpVerbosity.Compact);
// Headlines, plus one line for each exception
collector.DumpToTestOutput(DumpVerbosity.CompactWithException);
// 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.
Failure diagnostics
On a failed count assertion (HasLogged(), HasNotLogged(), WillLog()) the AssertionException message includes:
- The expectation (terminator + filter summary)
- The actual match count
- A snapshot of the captured records, with 4-character level abbreviation (matching the
Microsoft.Extensions.Loggingconsole formatter), category, message, structured properties, active scopes, and exception details. Since v0.14.0 the snapshot shows the last 50 records, after one line(N earlier record(s) not shown)when there are more; a long run's collector can hold thousands, and the latest ones are the context of the failure. The explicit dumps (DumpTo,DumpToTestOutput,DumpToTestOutputOnFailure) write every record.
A sequence failure names the step that did not match instead of a count; a HasLoggedOnly failure lists the records that escaped the allowlist (capped the same way) and the allowlist; a capture-floor failure explains the floor instead of showing a snapshot.
Example failure output (the shape is real; the records are illustrative):
Expected exactly 1 log record(s) to have been logged matching: Level = Warning, Message contains "timeout" (Ordinal)
but 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
at Assert.That(collector).HasLogged().AtLevel(Warning).Containing("timeout", Ordinal).Once()
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.
Cookbook: common patterns
Assert no errors were logged
await Assert.That(collector).HasNotLogged().AtLevelOrAbove(LogLevel.Error);
Wait for a log produced by background work
Background- or pump-driven logs (a BackgroundService, a timer, a fire-and-forget handler) can land after the call that triggered them returns, so a synchronous HasLogged() races the producer and flakes. Assert that the record will be logged (v0.14.0+):
// Passes once a matching record is captured; already-logged records count.
await Assert.That(collector).WillLog(TimeSpan.FromSeconds(5), cancellationToken)
.Containing("dead-lettered", StringComparison.OrdinalIgnoreCase)
.Once();
WillLog takes the same filters as HasLogged() and fails with the same diagnostics when the wait ends (details). It replaces polling the collector with TUnit's Eventually, and the TimeSpan bound of such a poll carries over: Assert.That(() => collector).Eventually(c => c.HasLogged()...Once(), TimeSpan.FromSeconds(5), cancellationToken) becomes Assert.That(collector).WillLog(TimeSpan.FromSeconds(5), cancellationToken)...Once().
When the code needs the record rather than an assertion (to hand it to the next step, or as the condition of a fake-clock helper), wait for it with the core extension (v0.13.0+):
var record = await collector.WaitForLogAsync(
LogFilter.Containing("dead-lettered", StringComparison.OrdinalIgnoreCase),
cancellationToken);
WaitForLogAsync takes any ILogRecordFilter (compose them with LogFilter.All / Any / Not) or a Func<FakeLogRecord, bool>, and WaitForLogsAsync(filter, count, cancellationToken) waits for several. Both waits are built on FakeLogCollector.GetLogsAsync, so they wake on each new record: no polling interval, no real-time delay, and no cost when the record is already there. Here the token is the only bound and is required; pass the test's token, or a linked source with a timeout. On cancellation the collector decides (v0.14.1+): if it holds enough matching records (also ones captured just before the cancellation, or before the call with a token that was already canceled), the wait returns them; otherwise the OperationCanceledException says how many records the collector holds and how many of them match.
A wait proves that a record arrived; it cannot prove that one did not. For "has not logged", let the work finish first (await its completion, or wait for the log that marks the end of the step) and then assert HasNotLogged().
Assert a specific call site was hit
Anchored on the message template, not the substituted value:
await Assert.That(collector).HasLogged()
.WithMessageTemplate("Order {OrderId} processed").AtLeast(1);
Assert a structured property is in a numeric range
using System.Globalization;
await Assert.That(collector).HasLogged()
.WithProperty("DurationMs", v =>
int.TryParse(v, CultureInfo.InvariantCulture, out var ms) && ms < 1000)
.AtLeast(1);
Assert all logs in a request scope were warnings or below
await Assert.That(collector).HasNotLogged()
.WithScopeProperty("RequestId", "req-42")
.AtLevelOrAbove(LogLevel.Error);
Assert a specific exception flowed through a logger
await Assert.That(collector).HasLogged()
.AtLevel(LogLevel.Error)
.WithException<DbUpdateConcurrencyException>()
.Once();
Assert a startup -> work -> shutdown sequence
await Assert.That(collector).HasLoggedSequence()
.WithEventName("Startup")
.Then().AtLevel(LogLevel.Information).Containing("processed", StringComparison.Ordinal)
.Then().WithEventName("Shutdown");
Assert exactly N retries fired
await Assert.That(collector).HasLogged()
.AtLevel(LogLevel.Warning)
.WithMessageTemplate("Retrying after {Delay}ms")
.Exactly(3);
Set up the collector in one line
await using var logs = LogCollectorBuilder.Create();
new MyService(logs.CreateLogger("MyService")).DoWork();
await Assert.That(logs.Collector).HasLoggedOnce().Containing("done", StringComparison.Ordinal);
Create(minimumLevel) sets a capture floor. An absence check (HasNotLogged(), or a HasLogged() count that admits zero) whose level filters target only levels below that floor is rejected as vacuous (it would otherwise pass for the wrong reason, since nothing at that level was ever captured): the failure says so and names the floor. Assert at a captured level, or lower the floor.
Mirror logs into the test report (CreateTeed)
LogCollectorBuilder.Create() captures into the collector but writes nothing to the test's output, so a passing unit test shows an empty log panel in the TUnit HTML report. TestOutputLogCollectorBuilder.CreateTeed() (in LogAssertions.TUnit) adds a tee that mirrors each record to the test's output as it is logged, while still capturing for assertions:
using LogAssertions.TUnit;
await using var logs = TestOutputLogCollectorBuilder.CreateTeed();
new MyService(logs.CreateLogger("MyService")).DoWork();
await Assert.That(logs.Collector).HasLoggedOnce().Containing("done", StringComparison.Ordinal);
// The "done" line also appears inline in this test's report output.
The tee picks the test for each record when it is logged:
- the test current at the log call (its own flow, including the thread-pool work it starts);
- otherwise the test that was current when the tee was built (its owner), while that test runs, so a record logged on a thread with no test context still reaches the report;
- otherwise no test: the record is not teed, and when the factory is disposed the tee writes one line, to the test current at that moment (if any), saying how many records it skipped.
With the lifetime attribute (v0.14.0+) a test counts as running until its [After(Test)] hooks have finished, and a record logged on the flow of a test that has ended is not teed (it does not fall back to the owner). The check and the write are one step, so a record whose test ends while it is being formatted is skipped and counted too (v0.14.1+). Without the attribute, rule 2 ends when the owner has a result, and rule 1 has no end check. Keep the plain Create() for log-heavy soak tests where writing every record to the per-test output is undesirable.
TUnit disposes the test class after the test has ended, so a record logged in the test class's DisposeAsync is not teed (it is counted); log through await using in the test, or in an [After(Test)] hook. A retried test keeps one output across its attempts, so work left running by an earlier attempt logs into the same test's output.
Where each record goes
The tee picks the test from the logging code's own context: it cannot know that a record "belongs to" test 2 unless test 2's context is there. With the attribute, for a logger or host shared across tests:
| Record logged | Goes to |
|---|---|
| on test 2's own flow (test 2 calls the service, or awaits a task it started; also requests it makes) | test 2 (already so since 0.10.1) |
by a loop or BackgroundService started inside test 1 |
test 1 while test 1 runs; after test 1 has ended it is not teed, and the "N log record(s) were not teed" line counts it. Never test 2. |
| on a thread with no test context (timer, suppressed flow) | the test that built the tee, while it runs; after that not teed, counted |
by a host started outside any test (inside ExecutionContext.SuppressFlow()) |
its own loop records: not teed, counted; records from requests a test makes: that test |
So start a host shared across tests outside any test, and every record a test causes lands in that test. TUnit (1.81) runs the [Before(Assembly)], [Before(Class)] and other hooks, and the initialization of shared fixtures, on the flow of the first test that needs them, so a host built and started there counts as started inside that test (the second row). Build and start it inside Task.Run within ExecutionContext.SuppressFlow() instead, and await it after the block. Suppressing the flow does not clear the calling thread's own context (it still has the first test as current), but work queued while the flow is suppressed starts without it. Never await inside the block: it must end on the thread that opened it.
Task<SharedHost> started;
using (ExecutionContext.SuppressFlow())
{
started = Task.Run(async () =>
{
var built = BuildHost(); // its tee has no owning test
await built.StartAsync(cancellationToken); // its loops carry no test context
return built;
});
}
host = await started;
Requests a test makes reach that test when they run on the test's flow, as with an in-memory test server.
Wiring into a builder you already own
When the capture must live inside a logging builder you already configure (a host's ConfigureLogging, or any LoggerFactory.Create callback) rather than a self-contained factory, use AddTeedFakeLogging / AddFakeLogging (in LogAssertions.TUnit):
These extensions (like CreateTeed) live in the LogAssertions.TUnit namespace, which the recommended GlobalUsings.cs does not include, so add using LogAssertions.TUnit; (or a matching global using) in files that call them.
using LogAssertions.TUnit;
var collector = new FakeLogCollector();
using var factory = LoggerFactory.Create(b => b.AddTeedFakeLogging(collector, LogLevel.Trace));
AddTeedFakeLogging adds the built-in tee with the routing above, owned by the test current when it runs. To add another provider, chain it on the builder; for TUnit's own correlated console output, use AddCorrelatedTUnit from the TUnit.Logging.Microsoft package (referenced by your test project), which creates its provider itself:
using LogAssertions.TUnit;
b.AddTeedFakeLogging(collector).AddProvider(myProvider);
// AddCorrelatedTUnit comes from the TUnit.Logging.Microsoft package
b.AddFakeLogging(collector, LogLevel.Trace).AddCorrelatedTUnit(LogLevel.Trace);
AddFakeLogging is the capture-only variant (no tee). Both set the builder's minimum level and register the capture floor, so the vacuous-absence guard applies the same as with CreateTeed.
Write the logs of a test that did not pass
A soak test or a test against a real broker logs too much to tee every record, but when it fails, its logs are what you need. Arm a dump once, in the test or in a [Before(Test)] hook (v0.14.0+; needs the lifetime attribute):
using LogAssertions.TUnit;
private static LogCapture Logs = null!; // shared by the class, built in [Before(Class)]
[Before(Test)]
public void ArmLogDump() => Logs.Collector.DumpToTestOutputOnFailure();
A capture shared by tests that run at the same time holds all their records, so the dump of one of them also holds the records the others logged since its call: mark such a class [NotInParallel], or give each test its own capture.
When the test ends without passing (it failed, timed out, or was canceled; a skipped test does not count), the records captured since the call are written to the test's output, one timestamp (UTC) per record:
Captured records since the dump was armed (3 of 120 total, times in UTC):
08:47:12.345 [warn] Broker: connection lost
...
End of dump: 3 of 120 records.
A test that passes writes nothing. The time on each line is the record's timestamp, from the collector's clock. The start point is the collector's latest record at the time of the call, not a clock reading, so it is right whatever clock the collector uses; when the collector is cleared after the call, every record is written. The dump writes every record, and its last line repeats the summary (v0.14.1+): TUnit keeps only the last ~786,000 characters of a test's output once it grows past about a million, so the report of a very large dump loses its start (the heading and the first records) but still ends with the summary. A retried test arms again in each attempt, and the heading and the last line name the attempt (Captured records of attempt 1 since ..., End of dump of attempt 1: ...). Without the attribute the call throws, with a message that names the line to add; called before TUnit has started the test (in a [Before(Class)] hook, the test class's constructor, or a fixture's initialization), it throws with a message that says so (v0.14.1+).
Don't use NullLogger for code under coverage
A service guarded by if (logger.IsEnabled(LogLevel.Debug)) { ... } needs an enabled logger under test, or the guard only ever takes its false branch and that branch's coverage silently drops. NullLogger and a provider-less factory both report IsEnabled == false. Use a real (enabled) logger. If a particular test does not assert on logs and you only need the guard to evaluate true, an enabled non-capturing factory avoids the unbounded growth of a long-lived FakeLogCollector (v0.13.0+):
// Enabled at every level; IsEnabled-guarded log statements run, nothing is retained.
using var loggerFactory = LogCollectorBuilder.CreateDiscarding();
var service = new MyService(loggerFactory.CreateLogger<MyService>());
// Or enabled from Information up, to exercise the guard's other branch too:
using var quieter = LogCollectorBuilder.CreateDiscarding(LogLevel.Information);
BeginScope on these loggers returns a shared scope whose disposal does nothing, as NullLogger does, so code that requires a scope instance behaves as it does under a production logger. In v0.13.0 it returned null, which made such code throw (fixed in v0.13.1).
To keep nothing but still see the logs in the report, TestOutputLogCollectorBuilder.CreateTeedDiscarding(minimumLevel) (v0.14.0+, in LogAssertions.TUnit) returns the same enabled factory with the test-output tee: the right shape for a fixture or a long-lived host whose logs nobody asserts on.
using LogAssertions.TUnit;
using var loggerFactory = TestOutputLogCollectorBuilder.CreateTeedDiscarding();
The FakeLogCollector itself grows without bound as records accumulate. Scope it per test (the Create() / CreateTeed() helpers return a fresh one each call); never hold one in a static readonly field shared across a reused host.
Reuse a filter across many tests
using System.Data.Common;
// 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);
Assert several invariants and report all failures together
using LogAssertions.TUnit;
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"));
Assert "Warning OR Error in this scope, but not Critical"
await Assert.That(collector).HasLogged()
.WithScopeProperty("RequestId", "req-42")
.AtAnyLevel(LogLevel.Warning, LogLevel.Error)
.AtLeast(1);
Inspect what was actually logged during test development
// 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));
Pin the full log sequence as a snapshot
The HasLogged().Containing(...) chain pins individual records. To pin the entire emitted sequence, so a regression that reorders, drops, or inserts records is caught even when the per-record predicates still pass, render the collector to deterministic text and snapshot it:
using LogAssertions.Render;
var rendered = LogSnapshotRenderer.Render(collector);
await Assert.That(rendered).MatchesSnapshot();
LogSnapshotRenderer lives in the framework-agnostic LogAssertions core; MatchesSnapshot() is from the sibling SnapshotAssertions.TUnit package. The two-line composition is deliberate: the renderer never takes a hard dependency on a snapshot framework.
The rendered text is verbatim and deterministic: LF line endings (a baseline committed on one OS stays valid on every other), capture order preserved, no baked-in scrubbing. Volatile values (GUIDs, timestamps, durations in message bodies) are the snapshot layer's concern: compose SnapshotAssertions.Scrubbers at the MatchesSnapshot() call site.
A recommended scrubber set for log snapshots: Scrubbers.Common (GUIDs in both forms, ISO-8601 timestamps, Unix epoch milliseconds, NNNms durations), plus a Scrubbers.Pattern(...) per volatile value your own messages carry, such as a port or a host name:
await Assert.That(LogSnapshotRenderer.Render(collector))
.MatchesSnapshot()
.WithScrubber(Scrubbers.Common)
.WithScrubber(Scrubbers.Pattern(@"localhost:\d+", "localhost:<port>"));
Migrating from a hand-rolled renderer: if your tests already flatten collector.GetSnapshot() into a string (with scrubbing inlined in the loop), replace the loop with LogSnapshotRenderer.Render(collector, options) and move each inline replacement into a scrubber on the MatchesSnapshot() chain. The first run rewrites every baseline once, because the line format changes; review that diff for content, not layout, and accept it. Keeping the scrubbing out of the renderer is deliberate: the same rendered text can then be inspected unscrubbed when a snapshot fails.
LogSnapshotOptions controls the rendering: level abbreviation vs full name, leaf vs full category, scope inclusion, and exception detail:
using LogAssertions.Render;
var options = LogSnapshotOptions.Default with
{
LevelStyle = LogLevelStyle.Full,
ExceptionStyle = ExceptionStyle.StackTracePlaceholder,
};
var rendered = LogSnapshotRenderer.Render(collector, options);
await Assert.That(rendered).MatchesSnapshot();
Pin the log contract
When dashboards, log parsers, or analysis scripts read a service's structured log, its contract is the set of event IDs, names, levels, message templates, and placeholder names. Renaming a placeholder breaks every reader without failing a test. LogContractRenderer.Render(definitions) (v0.14.0+, in LogAssertions.Render) writes one deterministic line per captured definition; composed with MatchesSnapshot(), the contract becomes a reviewed baseline, and a renamed placeholder or a reworded template shows as a diff:
using LogAssertions.Render;
var contract = LogContractRenderer.Render([OrderProcessed, OrderShipped, PaymentFailed]);
await Assert.That(contract).MatchesSnapshot();
1201 OrderProcessed Information "Order {OrderId} processed in {DurationMs} ms" OrderId,DurationMs
1202 OrderShipped Information "Order {OrderId} shipped to {City}" OrderId,City
1300 PaymentFailed Warning "Payment for order {OrderId} failed" OrderId
Each line is the event ID, the event name (? when there is none), the level, the template in quotes (<no template> when the call carried none), and the placeholder names in template order. Lines are ordered by event ID, then name, template, and level, and then by the line itself, whatever the input order (so definitions that differ only in a placeholder the template does not contain keep a fixed order), and end in LF; there is no header line. In the template, the event name, and the placeholder names, \, ", CR, LF, and tab are escaped so each definition stays on one line. The renderer reads only the definitions' Id, Level, Template, and Properties: no reflection.
Pin the records other tools read
The contract above pins a whole set of captured definitions as one reviewed baseline, with no values. It needs the definitions as LogDefinition values, so it cannot see a [LoggerMessage] method that is private. For one such record, pin its event name and its message template with the filters, in the test that makes the code log:
await Assert.That(collector).HasLogged()
.WithEventName("StaleSessionDropped")
.WithMessageTemplate("Dropped stale session {SessionId} after {IdleSeconds} s")
.Once();
If a reader of the log (a dashboard, a parser) depends on that record, renaming the event or rewording the template now fails this test, and the failure lists the captured records so the new name or template is easy to see. The two approaches pin the same facts; use the contract renderer for the definitions you can reach, as one baseline for all of them, and the filter pair for the records you cannot reach, one test each. A repeated pair is worth a small helper in your test project, for example AssertContract(collector, "StaleSessionDropped", "...").
Troubleshooting
Issues that surface during adoption and upgrades. 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.
"var (factory, collector) = LogCollectorBuilder.Create(); no longer compiles"
Symptom: error CS1061: 'LogCapture' does not contain a definition for 'Deconstruct' and no accessible extension method 'Deconstruct' accepting a first argument of type 'LogCapture' could be found. (In 0.14.x the error was CS0619, with a message that named the new form.)
Cause: since v0.14.0, Create() and CreateTeed() return a LogCapture, not a tuple.
Fix: using var logs = LogCollectorBuilder.Create(); (await using var in async code), then logs is the factory and logs.Collector the collector. See Upgrading to v0.14.0.
"DumpToTestOutputOnFailure() throws InvalidOperationException"
Cause: the test project does not apply the lifetime attribute, so the package cannot see the test end; the call ran before TUnit started the test (in a [Before(Class)] hook, the test class's constructor, or while a fixture or data source was initialized: TUnit runs these with the test already current, before the attribute sees it start); the call ran after the test had ended (for example from a background loop); or it ran where no test is current (TestContext.Current was null). Each case has its own message. In 0.14.0 a call before the start said the attribute was missing; since 0.14.1 it says the call came before the start.
Fix: add [assembly: LogAssertions.TUnit.LogAssertionsTestLifetime] once to the test project (the exception message names the line), and call the method in the test or a [Before(Test)] hook. See Setup.
"Shorthands like HasLoggedOnce() don't resolve in my test file"
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.
"LogCollectorBuilder not found" or "The name LogFilter does not exist in the current context"
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 the core namespaces; add LogAssertions.TUnit (the tee builders, DumpToTestOutput*, AssertAllAsync) and LogAssertions.Render (the renderers) where you use them.
"IDE0005: Using directive is unnecessary on using LogAssertions; in some test files"
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.
Design notes
- Built on
[AssertionExtension](thomhurst/TUnit#5785): the entry-point methods are emitted by TUnit's source generator; the shorthands are thin hand-written wrappers over them. - No cross-package coupling. This package depends on
TUnit.Assertions,TUnit.Core, and (through the core)Microsoft.Extensions.Diagnostics.Testing. Neither TUnit nor the Microsoft testing package 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: every .NET version in support. Targets
net10.0andnet11.0today, and drops a version on its end-of-life date (never long historical tails). A test project may target a higher TFM than the production code it references. SeeCONVENTIONS.mdfor the schedule. - 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>).
Stability intent (pre-1.0)
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 five entry-point methods on
IAssertionSource<FakeLogCollector>:HasLogged(),HasNotLogged(),HasLoggedSequence(),HasLoggedOnly(floor), andWillLog(timeout, cancellationToken). - The top-level shorthand entry points (
HasLoggedOnce,HasLoggedExactly,HasLoggedNothing,HasLoggedWarningOrAbove, etc.) andAssertAllAsync. - The fluent chain methods on
HasLoggedAssertion,HasNotLoggedAssertion,HasLoggedSequenceAssertion,HasLoggedOnlyAssertion, andWillLogAssertion: every named filter (AtLevel,Containing,WithCategory,WithException,WithInnerException,WithInnerExceptionMessage,WithScopeProperty,WithScopeProperties, etc.), every terminator (Once,Exactly,Between,AtMostOncePer,GetMatch,GetMatches, etc.), the sequence-step combinators (Then,ThenAnyOrder,Step), and the combinator methods (WithFilter,MatchingAny,MatchingAll,Not,When). - The
ILogRecordFilterinterface, theLogFilterstatic factory's public methods, andLogDefinition. LogCaptureand the factories:LogCollectorBuilder.Create(with and without aTimeProvider) /CreateDiscarding,TestOutputLogCollectorBuilder.CreateTeed(with and without aTimeProvider) /CreateTeedDiscarding, and theAddFakeLogging/AddTeedFakeLoggingbuilder extensions.- The
LogAssertionsTestLifetimeattribute. - The
FakeLogCollectorextension methods:Filter,CountMatching,DumpTo,WaitForLogAsync,WaitForLogsAsync,DumpToTestOutput,DumpToTestOutputOnFailure. - The rendering types and their members:
LogSnapshotRenderer,LogContractRenderer,LogSnapshotOptionsand its style enums,DumpVerbosity, and the public methods ofLogAssertionRendering(their names and signatures; the text thatLogAssertionRenderingwrites is covered below). - The output of
LogSnapshotRendererandLogContractRenderer: committed baselines depend on it, so any change to it is listed as breaking in the CHANGELOG.
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 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, including how many records a failure message shows. 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.
Limitations and future work
The current surface covers the common cases: composable filters, count terminators, rate limits (AtMostOncePer), sequence assertions (strict and concurrent), scope-property matching, batch assertions, waits for background work (WillLog, WaitForLogAsync), and the LogSnapshotRenderer and LogContractRenderer for pinning a full log sequence or a log contract. Everything below is a demand-driven backlog; nothing is committed.
- Filters: time-based (
WithElapsedTime,WithTimestamp, gaps between sequence steps),DescribedAslabels. - Sequence:
ThenImmediately,NotInterleaved,InOrder,HasNotLoggedSequence, cursor control (FromNewest/SinceLastAssert). - Bigger pieces: a
[LoggerMessage]-derived typed-assertion source generator andToVerifyString()golden files. Adapters for other test frameworks are not planned; the family targets TUnit.
Declined by design: an analyzer policing our own API, and silently injecting global usings via a package .props file. Out of scope for AOT: anonymous-object scope inspection and JSON matching.
Open a feature request if you would use any of these.
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 versions at any moment (every version in Microsoft support, nothing end-of-life; see the TFM policy in CONVENTIONS.md for the 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
- GrpcAssertions.TUnit CHANGELOG
- TracingAssertions.TUnit CHANGELOG
- MetricsAssertions.TUnit CHANGELOG
Pair with
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. ItsAdvanceOnEachTimerUntilAsyncdrives a fake clock while aWillLogassertion waits (WillLogwith a fake clock); neither package depends on the other.SnapshotAssertions.TUnit: text-snapshot assertions for API-surface tests and similar deterministic-string scenarios. UseMatchesSnapshot()to pin the output ofLogSnapshotRenderer(a log sequence) orLogContractRenderer(a log contract); the failure-message format ofDumpTois not stable, so don't pin it.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.GrpcAssertions.TUnit: fluent gRPC outcome assertions (ThrowsGrpcExceptionwithStatusCodeshorthands and detail refinements) plus theGrpcCallBuildertest-double helper.TracingAssertions.TUnit: fluent OpenTelemetry distributed-tracing (Activity/ span) assertions: operation name, tags, status, kind, events, and parent/child, root and same-trace relationships, captured via a rawActivityListenerwith no OpenTelemetry SDK dependency.MetricsAssertions.TUnit: fluent assertions overSystem.Diagnostics.Metricsinstruments (counters, histograms, gauges), built onMetricCollector.
Not a sibling, but often compared: TUnit.Mocks.Logging gives you a MockLogger to inject and verify calls on, mock-style. This package asserts on what real loggers wrote into a FakeLogCollector, including structured state, scopes, and exceptions, across every category the code under test creates. Use the mock when the logger is a collaborator you inject by hand; use the collector when you test a service wired through ILoggerFactory or a host.
Background
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. This package implements the user-space pattern.
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 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, SseAssertions.TUnit, GrpcAssertions.TUnit, TracingAssertions.TUnit, and MetricsAssertions.TUnit.
License
MIT. Copyright (c) 2026 John Verheij.