LogAssertions.TUnit
0.2.0
Prefix Reserved
See the version list below for details.
dotnet add package LogAssertions.TUnit --version 0.2.0
NuGet\Install-Package LogAssertions.TUnit -Version 0.2.0
<PackageReference Include="LogAssertions.TUnit" Version="0.2.0" />
<PackageVersion Include="LogAssertions.TUnit" Version="0.2.0" />
<PackageReference Include="LogAssertions.TUnit" />
paket add LogAssertions.TUnit --version 0.2.0
#r "nuget: LogAssertions.TUnit, 0.2.0"
#:package LogAssertions.TUnit@0.2.0
#addin nuget:?package=LogAssertions.TUnit&version=0.2.0
#tool nuget:?package=LogAssertions.TUnit&version=0.2.0
LogAssertions.TUnit
A TUnit-native fluent log-assertion DSL on top of Microsoft.Extensions.Logging.Testing.FakeLogCollector. Built using TUnit 1.41.0+'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.
Table of contents
- Why this package
- Install
- Package layout
- Quick start
- Entry points
- Filter reference
- Terminators (
HasLoggedonly) - Sequence assertions —
HasLoggedSequence - Combining assertions with
.And/.Or - Batch assertions —
AssertAllAsync - Non-asserting inspection
- Failure diagnostics
- Cookbook — common patterns
- Design notes
- Stability intent (pre-1.0)
- Limitations and future work
- 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 every captured record (including structured properties and scope content) in failure messages.
Install
dotnet add package LogAssertions.TUnit
Requirements: TUnit 1.41.0+ (for [AssertionExtension]), .NET 10. The package is AOT-compatible, trimmable, and uses no reflection in the assertion path.
Package layout
This repo ships two NuGet packages:
| Package | Purpose | Depends on |
|---|---|---|
LogAssertions |
Framework-agnostic core: ILogRecordFilter + LogFilter + rendering + collector inspection extensions |
Microsoft.Extensions.Diagnostics.Testing |
LogAssertions.TUnit |
TUnit-specific entry points: HasLogged(), HasNotLogged(), HasLoggedSequence() and shorthands |
LogAssertions + TUnit.Assertions |
You install LogAssertions.TUnit; LogAssertions comes transitively. Adapters for other test frameworks (NUnit, xUnit, MSTest) are not shipped today — they'd reuse the LogAssertions core. If you'd find one useful, open a feature request.
Quick start
using LogAssertions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Testing;
[Test]
public async Task Validation_failure_is_logged()
{
var (factory, collector) = LogCollectorBuilder.Create();
using (factory)
{
var logger = factory.CreateLogger<MyValidator>();
new MyValidator(logger).Validate(invalidInput);
await Assert.That(collector)
.HasLogged()
.AtLevel(LogLevel.Warning)
.Containing("validation failed", StringComparison.Ordinal)
.WithCategory("MyApp.MyValidator")
.Once();
await Assert.That(collector).HasNotLogged().AtLevel(LogLevel.Error);
}
}
Entry points
Three core entry points are emitted by TUnit's source generator and surface as extension methods on Assert.That(FakeLogCollector).
| Entry point | Default expectation | Terminators allowed |
|---|---|---|
HasLogged() |
At least 1 matching record | All count terminators (see below) |
HasNotLogged() |
Zero matching records | None — fixed at zero |
HasLoggedSequence() |
An ordered series of matches; Then() separates steps |
None — each step's match is implicit |
All three accept the full filter chain. HasLogged() is the workhorse; HasNotLogged() is its inverse with cleaner failure semantics; HasLoggedSequence() is for multi-step traces (e.g. "Started → Validation failed → Stopped").
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 | Behaviour |
|---|---|
AtLevel(LogLevel) |
Exact level match |
AtLevelOrAbove(LogLevel) |
record.Level >= threshold (e.g. "any warning or worse") |
AtLevelOrBelow(LogLevel) |
record.Level <= threshold (e.g. "only diagnostic-tier") |
AtAnyLevel(params LogLevel[]) |
Match any level in the supplied set (e.g. "Warning or Error but not Critical") |
NotAtLevel(LogLevel) |
Inverse of AtLevel — convenience over Not(LogFilter.AtLevel(...)) |
ExcludingLevel(LogLevel) |
Alias for NotAtLevel, reads better in negative-filter chains |
await Assert.That(collector).HasLogged().AtLevelOrAbove(LogLevel.Warning).AtLeast(1);
await Assert.That(collector).HasNotLogged().AtLevelOrAbove(LogLevel.Error);
await Assert.That(collector).HasLogged().AtAnyLevel(LogLevel.Warning, LogLevel.Error).AtLeast(1);
Message filters
| Filter | Behaviour |
|---|---|
Containing(string substring, StringComparison comparison) |
Formatted message contains substring (comparison explicit by design — no implicit culture) |
ContainingAll(StringComparison, params string[]) |
Formatted message contains every one of the substrings |
ContainingAny(StringComparison, params string[]) |
Formatted message contains at least one of the substrings |
Matching(Regex) |
Formatted message matches the regex |
WithMessage(Func<string, bool> predicate) |
Predicate over the formatted message |
WithMessageTemplate(string template) |
The pre-substitution template (e.g. "Order {OrderId} processed") equals template exactly. Resolved from MEL's magic {OriginalFormat} structured-state entry |
NotContaining(string, StringComparison) |
Inverse of Containing — convenience over Not(LogFilter.Containing(...)) |
WithMessageTemplate is useful when you want to pin a specific call site without coupling to the substituted parameter values:
// matches every "Order N processed" log regardless of N
await Assert.That(collector).HasLogged()
.WithMessageTemplate("Order {OrderId} processed").AtLeast(1);
Exception filters
| Filter | Behaviour |
|---|---|
WithException<TException>() |
record.Exception is TException (assignable) |
WithException() |
Any record with a non-null Exception, regardless of type |
WithException(Func<Exception, bool> predicate) |
Predicate over the exception (predicate not invoked for null exception) |
WithExceptionMessage(string substring) |
record.Exception?.Message contains substring (ordinal); records without an exception never match |
await Assert.That(collector).HasLogged()
.WithException<TimeoutException>()
.WithExceptionMessage("connection")
.Once();
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 | Behaviour |
|---|---|
WithProperty(string key, string? value) |
Property's formatted string value equals value (ordinal) |
WithProperty(string key, Func<string?, bool> predicate) |
Predicate over the formatted string value (use for ranges, regex, or null-checks) |
Note: FakeLogRecord exposes structured-state values as strings (the formatted form), so the predicate receives a string?. Parse to your target type inside the predicate when needed:
await Assert.That(collector).HasLogged()
.WithProperty("OrderId", v =>
int.TryParse(v, CultureInfo.InvariantCulture, out var n) && n > 1000)
.AtLeast(1);
Scope filters
Scopes are values pushed via logger.BeginScope(...). They surround any log records emitted while the scope is active.
| Filter | Behaviour |
|---|---|
WithScope<TScope>() |
A scope of type TScope was active when the record was emitted |
WithScopeProperty(string key, object? value) |
A scope contains a property key matching value (object.Equals semantics) |
WithScopeProperty(string key, Func<object?, bool> predicate) |
A scope contains a property key whose value satisfies the predicate |
Scope-property filters recognise the two AOT-friendly idioms:
// dictionary scope — the canonical structured pattern
using (logger.BeginScope(new Dictionary<string, object?> { ["OrderId"] = 42 }))
DoWork();
await Assert.That(collector).HasLogged().WithScopeProperty("OrderId", 42).AtLeast(1);
// formatted-template scope via LoggerMessage.DefineScope (avoids CA1848)
private static readonly Func<ILogger, int, IDisposable?> OrderScope =
LoggerMessage.DefineScope<int>("Order {OrderId}");
using (OrderScope(logger, 42)) DoWork();
await Assert.That(collector).HasLogged().WithScopeProperty("OrderId", 42).AtLeast(1);
Anonymous-object scopes (
logger.BeginScope(new { OrderId = 42 })) are not recognised byWithScopeProperty— reading their fields requires reflection, which would compromise AOT-compatibility. Prefer dictionary orLoggerMessage.DefineScopeform.
Identity filters (category, event)
| Filter | Behaviour |
|---|---|
WithCategory(string) |
Logger category equals string (ordinal) |
WithLoggerName(string) |
Alias for WithCategory |
ExcludingCategory(string) |
Inverse of WithCategory |
WithEventId(int) |
EventId.Id equals value |
WithEventIdInRange(int min, int max) |
EventId.Id is within the inclusive range |
WithEventName(string) |
EventId.Name equals string (ordinal) |
await Assert.That(collector).HasLogged()
.WithCategory("MyApp.Bootstrap")
.WithEventName("Startup")
.Once();
Escape hatch
| Filter | Behaviour |
|---|---|
Where(Func<FakeLogRecord, bool> predicate) |
Arbitrary predicate over the full FakeLogRecord |
Use only when no other filter expresses the constraint cleanly — composing built-in filters is preferred for diagnostic clarity in failure messages.
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 | Behaviour |
|---|---|
MatchingAny(params ILogRecordFilter[]) |
OR of the supplied filters as one composite filter on the chain. Empty array matches no record. |
MatchingAll(params ILogRecordFilter[]) |
Explicit AND of the supplied filters. Empty array matches every record. |
Not(ILogRecordFilter) |
Negates the supplied filter. |
WithFilter(ILogRecordFilter) |
Adds a user-supplied or pre-built filter to the chain. |
// "level == Warning AND (msg contains "a" OR msg contains "b")"
await Assert.That(collector).HasLogged()
.AtLevel(LogLevel.Warning)
.MatchingAny(
LogFilter.Containing("a", StringComparison.Ordinal),
LogFilter.Containing("b", StringComparison.Ordinal))
.AtLeast(1);
// Reusable filter shared across many tests:
static readonly ILogRecordFilter CriticalDbError = LogFilter.All(
LogFilter.AtLevel(LogLevel.Critical),
LogFilter.WithException<DbException>());
await Assert.That(collector).HasLogged().WithFilter(CriticalDbError).AtLeast(1);
Conditional configuration (When)
// In a parameterised test, fold a boolean branch into the chain
// instead of duplicating the entire await:
await Assert.That(collector).HasLogged()
.AtLevel(LogLevel.Warning)
.When(expectRetry, b => b.Containing("retry", StringComparison.Ordinal))
.AtLeast(1);
Terminators (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).
| Terminator | Match count expectation |
|---|---|
Once() |
Exactly 1 |
Exactly(int count) |
Exactly N |
AtLeast(int count) |
At least N (inclusive) |
AtMost(int count) |
At most N (inclusive) |
Between(int min, int max) |
Inclusive range [min, max] |
Never() |
Exactly 0 (semantic synonym for HasNotLogged()) |
await Assert.That(collector).HasLogged().AtLevel(LogLevel.Warning).Between(1, 5);
await Assert.That(collector).HasLogged().WithEventId(42).Never();
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 always matches the next available record (use sparingly).
- Failure diagnostics indicate which step failed and dump the full captured-records list (see Failure diagnostics).
Combining assertions with .And / .Or
Because the assertion types derive from TUnit's Assertion<T>, the standard TUnit chaining works:
await Assert.That(collector)
.HasLogged().AtLevel(LogLevel.Information).AtLeast(1)
.And.HasNotLogged().AtLevel(LogLevel.Error);
Batch assertions — AssertAllAsync
Run several independent assertions against the same collector in one pass and aggregate every failure into a single AssertionException. Conceptually similar to TUnit's own Assert.Multiple, scoped to log assertions. Useful when several invariants must all hold and the test author wants to see every violation in one CI run, not just the first.
await Assert.That(collector).AssertAllAsync(
async c => await c.HasLogged().AtLevel(LogLevel.Information).AtLeast(1),
async c => await c.HasNotLogged().AtLevel(LogLevel.Error),
async c => await 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.
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:
| Method | Returns |
|---|---|
Filter(params ILogRecordFilter[] filters) |
The matching records as a defensive IReadOnlyList<FakeLogRecord> |
CountMatching(params ILogRecordFilter[] filters) |
Just the match count (no list materialisation) |
DumpTo(TextWriter writer) |
Writes every captured record in the failure-message format |
// 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
using var writer = new StringWriter();
collector.DumpTo(writer);
Console.WriteLine(writer);
Failure diagnostics
On a failed assertion the AssertionException message includes:
- The expectation (terminator + filter summary)
- The actual match count
- A snapshot of every captured record, with 4-character level abbreviation (matching the
Microsoft.Extensions.Loggingconsole formatter), category, message, structured properties, active scopes, and exception details
Example failure output:
Expected: exactly 1 log record(s) to have been logged matching: Level = Warning, Message contains "timeout"
3 record(s) matched
Captured records (5 total):
[info] MyApp.Worker: Started cycle 1
props: cycle=1
scope: RequestId=abc-123
[warn] MyApp.Worker: timeout exceeded for cycle 1
props: cycle=1, threshold=500
scope: RequestId=abc-123
[warn] MyApp.Worker: timeout exceeded for cycle 2
props: cycle=2, threshold=500
scope: RequestId=abc-123
[warn] MyApp.Worker: timeout exceeded for cycle 3
props: cycle=3, threshold=500
scope: RequestId=abc-123
[info] MyApp.Worker: Cycle batch finished
scope: RequestId=abc-123
exception: TimeoutException: Connection timed out
Level abbreviations: trce, dbug, info, warn, fail, crit (matching MEL's console formatter; none for LogLevel.None).
This eliminates the historical pattern of adding temporary Console.WriteLine calls to debug failing log assertions — every dimension you can filter on is also rendered in the failure message.
Cookbook — common patterns
Assert no errors were logged
await Assert.That(collector).HasNotLogged().AtLevelOrAbove(LogLevel.Error);
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
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
var (factory, collector) = LogCollectorBuilder.Create();
using (factory)
{
var logger = factory.CreateLogger("MyService");
new MyService(logger).DoWork();
await Assert.That(collector).HasLoggedOnce().Containing("done", StringComparison.Ordinal);
}
Reuse a filter across many tests
// 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
await Assert.That(collector).AssertAllAsync(
async c => await c.HasLogged().AtLevel(LogLevel.Information).AtLeast(1),
async c => await c.HasNotLogged().AtLevelOrAbove(LogLevel.Error),
async c => await 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));
Design notes
Built on
[AssertionExtension](TUnit 1.41.0+, thomhurst/TUnit#5785): the entry-point methods are emitted by TUnit's source generator. No extension-method wrappers needed.No cross-package coupling. This package depends on
TUnit.AssertionsandMicrosoft.Extensions.Diagnostics.Testing. Neither of those depends on the other; this library is the bridge.AOT-compatible / trimmable.
IsAotCompatible=true,IsTrimmable=true,EnableTrimAnalyzer=true. No reflection in the assertion path. Scope-property matching uses interface casts only, never reflection.Single TFM, forward-only by policy: targets
net10.0and onlynet10.0. .NET 10 is the current LTS (until November 2028); future versions will track the latest LTS, never multi-target downward. The policy keeps the codebase free of compatibility shims and lets the library use the newest C# / runtime /Microsoft.Extensions.Loggingfeatures as they ship.You can still consume this package even if your production code targets an older TFM. Test projects routinely target a higher TFM than the production code they test — the .NET SDK supports a
net10test project referencing anet8production project (net10runtime is forward-compatible withnet8assemblies). The test exe loads on thenet10runtime and invokes the production code through itsnet8surface. The reverse — referencing anet10production lib from anet8test — does not work, but that's not a typical setup.Concrete: if your production lib targets
net8.0, set your test project's<TargetFramework>tonet10.0, installLogAssertions.TUnit, and the production<ProjectReference>continues to resolve cleanly.Explicit
StringComparison. Every string-matching API requires the caller to pass aStringComparison(or usesOrdinalinternally where unambiguous). No silent culture defaults.
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 three entry-point methods on
IAssertionSource<FakeLogCollector>:HasLogged(),HasNotLogged(),HasLoggedSequence(). - The top-level shorthand entry points (
HasLoggedOnce,HasLoggedExactly,HasLoggedNothing,HasLoggedWarningOrAbove, etc.). - The fluent chain methods on
HasLoggedAssertion,HasNotLoggedAssertion,HasLoggedSequenceAssertion: every named filter (AtLevel,Containing,WithCategory, etc.), every terminator (Once,Exactly,Between, etc.), and the combinator methods (WithFilter,MatchingAny,MatchingAll,Not,When). - The
ILogRecordFilterinterface and theLogFilterstatic factory's public methods. - The
LogCollectorBuilder.Createfactory. - The
FakeLogCollectorextension methods:Filter,CountMatching,DumpTo,AssertAllAsync.
Explicitly unstable (will change without notice, do not depend on):
LogAssertionBase<TSelf>and its protected/internal members. The type ispubliconly because the CRTP pattern requires it (C# does not allow public classes to inherit from internal); it is annotated[EditorBrowsable(Never)]and is not a supported derivation point. Treat it as a sealed implementation detail of the three public assertion classes.- The internal filter classes (
PredicateFilter,AndFilter,OrFilter,NotFilter). These live behindILogRecordFilterand theLogFilterfactory. - The exact format of failure-message snapshot text rendered by
LogAssertionRenderingand exposed viaDumpTo. The rendering may gain extra detail or change formatting in any release. Do not pin exact failure-message text in tests — pin filter match counts and broad markers (e.g.Contains("[warn]")) only. - The
CompatibilitySuppressions.xmlfile is a build artifact tracking baseline acceptance, not part of the API contract.
Breaking changes log (every release with a breaking change is listed in CHANGELOG.md):
- 0.2.0:
LogAssertionBase<TSelf>annotated[EditorBrowsable(Never)]; theprotected virtual void AddPredicate(Func, string)extension hook replaced byprotected virtual void AddFilter(ILogRecordFilter)as part of theILogRecordFilterrefactor. Affects only consumers who derived fromLogAssertionBase(an unsupported scenario). Framework-agnostic types (ILogRecordFilter,LogFilter, etc.) moved fromLogAssertions.TUnitto a newLogAssertionspackage + namespace; theLogAssertions.TUnitpackage now has aLogAssertionstransitive dependency.
Limitations and future work
The 0.2.0 surface covers the high-frequency 80% of real-world log-assertion needs — composable filters, all common count terminators, sequence assertions, scope-property matching, batch assertions, the inspection extensions, and the framework-agnostic core split. The list below is the candidate backlog for future versions; nothing here is committed and nothing will be built without demonstrated demand.
Plausible v0.3.0 (would make the library substantially more capable)
These need new primitives (timestamp + polling + cursor) but are coherent additions, not architectural shifts.
- Time-based filters:
WithElapsedTime(min, max),WithTimestamp(at, tolerance),ThenGap(TimeSpan)in sequence,Throttled(maxPerWindow)for rate-limit verification. - Async-await polling terminator:
WithinTimeout(TimeSpan)for tests against background services / event handlers, replacing the brittleawait Task.Delay(...)pattern. - Sequence variants:
ThenImmediately()(strict adjacency),NotInterleaved()(no other records from same category between matches),InOrder()terminator onHasLogged(multiple matches in chronological order, not necessarily adjacent). - Cursor / direction:
FromNewest()/FromOldest()direction control,SinceLastAssert()watermark,Pin()snapshot pinning,HasLoggedDistinct(int)(dedupe + count). HasNotLoggedSequence()— mirror ofHasLoggedSequence, asserts a specific sequence did NOT occur.
Possible v0.4.0+ (separate packages, more substantial work)
- Roslyn analyzer for common mistakes: forgotten terminator, missing
StringComparison, forgottenawait. Standalone analyzer package. - Source generator for
[LoggerMessage]-derived typed assertion helpers — e.g.HasLogged().RetryExhausted(maxRetries: 3)generated from the[LoggerMessage]declaration. - Verify integration —
collector.ToVerifyString()for golden-file approval of full log sequences. - Framework adapter packages:
LogAssertions.NUnit,LogAssertions.xUnit,LogAssertions.MSTest. TheLogAssertionscore package already supports them architecturally; only built when someone asks.
Could-go-either-way (no current plan, depends on demand)
- Multi-collector aggregate:
Assert.That(c1, c2, c3).HasLogged(...)for pipeline tests with several loggers. - Diagnostic upgrades: per-record match-tagging in failure dump, grouping by category/level.
- Scope-aware sequence:
HasLoggedSequence().InScope("RequestId", "abc").... - Parallel-safe collector partitioning (depends on TUnit's parallel-test story).
- Benchmarks + perf documentation (will probably do once before v1.0 to honestly characterise).
Probably not (wrong fit or no clear demand)
WithCallerInfo(...)— MEL doesn't auto-propagate[CallerMemberName]etc. into log records.WithContext<T>AsyncLocal context filter — niche, conflates withWithScope.WithStructuredState<T>typed state —FakeLoggerempirically does not preserve the typed state object (we proved this by testing).WithFailureMessagecustom override — TUnit's ownAssert.That(...).WithMessage(...)already covers this at the framework level.Should()syntax — orthogonal API style choice.- JSON property matching (
HasLoggedJson) — depends on JSON serializer, AOT-incompatible without source-gen, ecosystem-fragmenting. - Anonymous-object scope inspection — would require reflection; intentionally out of scope for AOT-compatibility.
- Localization-aware level names —
LevelAbbreviationis intentionally English-centric to match MEL's console formatter.
Out of scope per project policy
- Multi-target
net8;net9;net10— see "Single TFM, forward-only" in Design notes.
If you'd find any of the candidate items useful, open a feature request.
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 in TUnit 1.41.0. This package implements the user-space pattern.
Contributing
See CONTRIBUTING.md for branch convention, PR checklist, and code style.
License
MIT — Copyright (c) 2026 John Verheij
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- LogAssertions (>= 0.2.0)
- Microsoft.Extensions.Diagnostics.Testing (>= 10.0.0)
- TUnit.Assertions (>= 1.41.0)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
See CHANGELOG.md