Problem Statement
Asserting on captured log messages is a common need in .NET testing, but the established path — Microsoft.Extensions.Logging.Testing.FakeLogCollector + hand-rolled LINQ — is noisy and produces unhelpful failure messages. TUnit.Logging.Microsoft ships the capture half (CorrelatedTUnitLogger, TUnitLoggingRegistry), but there's no assertion DSL on top of it.
Today, a log check in a TUnit test typically looks like this:
IReadOnlyList<FakeLogRecord> records = collector.GetSnapshot();
var hits = records.Count(r => r.Message.Contains("Started monitoring", StringComparison.Ordinal));
await Assert.That(hits).IsEqualTo(1);
FakeLogRecord ignored = records.Single(r => r.Message.Contains("Start ignored", StringComparison.Ordinal));
await Assert.That(ignored.Level).IsEqualTo(LogLevel.Warning);
Two problems:
- Noisy — 3–5 lines of LINQ per log check, dwarfing the actual test logic.
- Bad failure diagnostics —
Assert.That(hits).IsEqualTo(1) produces expected: 1, got: 0 on failure with no hint of what was actually logged. A workaround seen in practice is adding Console.WriteLine(string.Join(...)) while debugging a failing test — useful in the moment, but occasionally lingers in test code after the fix.
A fluent DSL chainable from TUnit's Assert.That(...) is a natural extension of what TUnit.Logging.Microsoft already ships, and fits cleanly alongside TUnit's existing fluent-assertion surface.
Proposed Solution
Fluent extensions on Assert.That(FakeLogCollector):
// Count-based containment
await Assert.That(collector).HasLogged().Containing("Started monitoring").Once();
await Assert.That(collector).HasLogged().Containing("ping failed").AtLeast(3);
await Assert.That(collector).HasNotLogged().Containing("Position mismatch");
// Level filter
await Assert.That(collector).HasLogged(LogLevel.Warning).Containing("Start ignored");
await Assert.That(collector).HasLogged(LogLevel.Error).Exactly(1);
// Exception-shaped
await Assert.That(collector).HasLogged().WithException<InvalidOperationException>();
await Assert.That(collector).HasLogged().WithException<OperationCanceledException>()
.WithMessage(m => m.Contains("shutdown"));
// Structured state (for source-generated [LoggerMessage] users).
// Uses FakeLogRecord.GetStructuredStateValue(key) under the hood.
// Microsoft serialises structured-state values to strings, so the assertion compares
// string-equality against the result of GetStructuredStateValue(key). Callers pass
// string expected-values to match what ends up in the record.
await Assert.That(collector).HasLogged()
.WithProperty("OrderId", "42")
.WithProperty("CorrelationId", expectedGuid.ToString());
The ergonomic win — failure-message rendering
On failure, the message dumps the actual log contents so the developer sees what did happen:
Expected: collector to log a message containing "Started monitoring" exactly once,
but found 0 matching records of 3 total.
Actual log contents:
[Warning] Start ignored — monitor is already running (duplicate call)
[Info] Periodic ping started
[Debug] Scheduling next ping in 30000 ms
This single change — rendering the captured snapshot on failure — is the biggest improvement over the status quo. A workaround seen in practice is a manual Console.WriteLine(string.Join(...)) added while debugging a failing test, which occasionally stays in the codebase afterward. Built-in failure rendering removes the need for the workaround.
Shape-of-API summary
| Entry point |
Assert.That(collector).HasLogged() / .HasLogged(LogLevel) / .HasNotLogged() |
| Filter builders (chainable, return same type) |
.Containing(string), .WithException<TException>(), .WithMessage(Func<string, bool>), .WithProperty(string key, string? expectedValue) |
| Count terminators |
.Once(), .Exactly(n), .AtLeast(n), .AtMost(n), .Never() |
| Default terminator |
Awaiting without a count verb = AtLeast(1) |
Data sources (all already shipped by Microsoft)
Every field the filters proposed above already exists on FakeLogRecord:
Level (LogLevel)
Message (string)
Exception (Exception?)
StructuredState (IReadOnlyList<KeyValuePair<string, string?>>?) — or use the helper GetStructuredStateValue(string key) which returns string? for direct lookup
No new data layer required. collector.GetSnapshot() → enumerate → match → render failure.
Test suite — 6 tests to pin the contract
HasLogged_Containing_Once_Matches
HasLogged_FailureMessageRendersFullSnapshot — the key ergonomic test
HasLogged_WithException_Typed
HasLogged_WithProperty_MatchesStructuredState
HasNotLogged_PassesWhenNothingMatches
HasLogged_Chained_LevelAndContaining
Happy to provide each as a minimal failing test in a public repo you can pull into CI for fix-validation.
Ship location
Extend the existing TUnit.Logging.Microsoft package so consumers who already take that reference for CorrelatedTUnitLogger pick up the assertion DSL automatically. No new NuGet required.
What's left to Tom's design judgement
Deliberately not prescribing:
- The exact base type (
Assertion<T>, AssertionBuilder, generator-backed, …)
- How
[AssertionExtension] / [AssertionFrom<T>] style attribute registration should expose HasLogged(LogLevel) vs HasLogged() entry points
- Whether
HasNotLogged is a separate class or a flag on the same builder
- The
Times helper — tiny, could also be int overloads if you prefer that style
Alternatives Considered
- Moq +
loggerMock.Verify(l => l.Log(...), Times.Once()) — works but verbose, couples tests to mock internals, requires writing Log(level, eventId, ...) verify expressions by hand per log shape.
- Hand-rolled extensions on
FakeLogCollector — a common pattern in practice; means duplicated effort across projects, inconsistent failure messages, no shared improvement path.
Serilog.Sinks.TestCorrelator — good, but Serilog-specific; most .NET apps now use Microsoft.Extensions.Logging.
- NUnit's constraint model (
Assert.That(collector, Has.Logged(...).Once()) via a custom IConstraint) — first-class within NUnit's paradigm. Different idiom from TUnit's fluent chains, but a legitimate option there.
- xUnit's partial-class
Assert extension (partial class Assert { public static void Logged(...) { } }) — legal, but the static-method style doesn't chain naturally for filter builders like .Containing(...).Once().
The TUnit proposal's distinctive point isn't "others can't do this"; it's that TUnit's Assert.That(...) fluent surface + the pre-existing TUnit.Logging.Microsoft package let the whole feature ship as one package bump, with a single maintainer, consistent failure-message rendering, and zero additional install for anyone already consuming the logging package.
Feature Category
Assertions
How important is this feature to you?
Important - significantly impacts my workflow
Additional Context
Why "Important" priority — concrete impact
Our team is migrating a sizeable codebase from MSTest to TUnit and has ~30 tests hitting this exact boilerplate. Each would collapse from 3–5 lines of log-assertion LINQ to a single fluent line, with materially clearer failure diagnostics. Meaningful DX win for any project using Microsoft.Extensions.Logging.Testing.
Why TUnit is a good home for this
TUnit already owns three pieces the feature needs in one place: the Assert.That(...) fluent surface, the TUnit.Logging.Microsoft capture/correlation package (already on Microsoft.Extensions.Logging), and the release cadence to ship them together. Consumers of CorrelatedTUnitLogger would pick up the assertion DSL via a single version bump, with no new NuGet reference required.
Comparable functionality can be added to xUnit (via partial class Assert extensions) or NUnit (via custom IConstraint implementations), but those routes mean a separate community package per framework, separate release cycles, and no shared failure-message format.
Microsoft's position
Microsoft.Extensions.Logging.Testing — shipped as part of Microsoft.Extensions.Diagnostics.Testing v10.4.0, with the FakeLog* types available back to netstandard2.0 / net framework per the Microsoft Learn monikers — currently ships only the capture primitives (FakeLogger, FakeLogCollector, FakeLogRecord, FakeLoggerProvider). There's no assertion layer in that package. An assertion DSL on top fits TUnit's existing TUnit.Logging.Microsoft scope without duplicating anything Microsoft ships.
If this lands well, three follow-up DX ideas
Not asking you to scope these now — just signalling a compound-value roadmap:
[FakeTime(Start = "...", AutoAdvance = "...")] — source-generated attribute that injects a pre-configured FakeTimeProvider into the test method (and optionally registers it with the test's service collection). Same ergonomics as [ClassDataSource<T>].
[HostedService] — auto-wraps the test method with the IHostedService lifecycle (StartingAsync → StartAsync → StartedAsync → body → StoppingAsync → StopAsync → StoppedAsync) against a named fixture property. Common pattern in ASP.NET Core / Worker Service / message-consumer test suites.
Assert.That(() => action).CompletesWithin(TimeSpan) — granular assertion-level timing budget. [Timeout] fails the whole test; this asserts one specific operation stays within its SLA while the rest of the test continues.
Happy to open separate issues if the log DSL lands and you're interested.
On the PR offer (ticked below) — honest framing
Ticking the Contribution box, but want to be upfront about what I do and don't know today:
- What I've read in TUnit's source:
Assertion<TValue> base class and AssertionContext<TValue> (TUnit.Assertions/Core/Assertion.cs), AssertionResult.Passed / AssertionResult.Failed(...) (TUnit.Assertions/Core/AssertionResult.cs), the [AssertionExtension] + [AssertionFrom<T>] attributes on classes like StringContainsAssertion and StringStaticMethodAssertions. That gives me the base-class pattern and the check-method shape.
- What I haven't yet studied: the source generator that wires
[AssertionExtension("X")] into the user-facing Assert.That(...).X(...) chain; the idiomatic pattern for chained-builder assertions with filter + terminator verbs (closest analog I'd want to study is whatever implements Throws<T>().WithMessage(...)); and TUnit's own test patterns for these extensions.
Proposal: if you confirm the API shape is worth pursuing, I'll study the generator + an existing chained-builder extension, then open a draft PR for you to review / redirect. Rather ship a draft you can correct than a finished PR that doesn't fit TUnit conventions.
Alternative: if you'd prefer to implement it and have us write / test-drive the test suite against the API, that also works. Either way the 6-test contract + the minimal failing-test repo for CI validation is something I can deliver independently of the main PR.
Context on #5613
Filing this separately from the migration-feedback issue because it's a new feature, not a blocker. Your 1.37.0 turnaround on #5613 (code fixes for TUnit0015/TUnit0049, collection-content rendering, TUnitAssertions0016 analyzer) was exactly the right triage and shipped faster than we expected — appreciate that.
Contribution
Problem Statement
Asserting on captured log messages is a common need in .NET testing, but the established path —
Microsoft.Extensions.Logging.Testing.FakeLogCollector+ hand-rolled LINQ — is noisy and produces unhelpful failure messages.TUnit.Logging.Microsoftships the capture half (CorrelatedTUnitLogger,TUnitLoggingRegistry), but there's no assertion DSL on top of it.Today, a log check in a TUnit test typically looks like this:
Two problems:
Assert.That(hits).IsEqualTo(1)producesexpected: 1, got: 0on failure with no hint of what was actually logged. A workaround seen in practice is addingConsole.WriteLine(string.Join(...))while debugging a failing test — useful in the moment, but occasionally lingers in test code after the fix.A fluent DSL chainable from TUnit's
Assert.That(...)is a natural extension of whatTUnit.Logging.Microsoftalready ships, and fits cleanly alongside TUnit's existing fluent-assertion surface.Proposed Solution
Fluent extensions on
Assert.That(FakeLogCollector):The ergonomic win — failure-message rendering
On failure, the message dumps the actual log contents so the developer sees what did happen:
This single change — rendering the captured snapshot on failure — is the biggest improvement over the status quo. A workaround seen in practice is a manual
Console.WriteLine(string.Join(...))added while debugging a failing test, which occasionally stays in the codebase afterward. Built-in failure rendering removes the need for the workaround.Shape-of-API summary
Assert.That(collector).HasLogged()/.HasLogged(LogLevel)/.HasNotLogged().Containing(string),.WithException<TException>(),.WithMessage(Func<string, bool>),.WithProperty(string key, string? expectedValue).Once(),.Exactly(n),.AtLeast(n),.AtMost(n),.Never()AtLeast(1)Data sources (all already shipped by Microsoft)
Every field the filters proposed above already exists on
FakeLogRecord:Level(LogLevel)Message(string)Exception(Exception?)StructuredState(IReadOnlyList<KeyValuePair<string, string?>>?) — or use the helperGetStructuredStateValue(string key)which returnsstring?for direct lookupNo new data layer required.
collector.GetSnapshot()→ enumerate → match → render failure.Test suite — 6 tests to pin the contract
HasLogged_Containing_Once_MatchesHasLogged_FailureMessageRendersFullSnapshot— the key ergonomic testHasLogged_WithException_TypedHasLogged_WithProperty_MatchesStructuredStateHasNotLogged_PassesWhenNothingMatchesHasLogged_Chained_LevelAndContainingHappy to provide each as a minimal failing test in a public repo you can pull into CI for fix-validation.
Ship location
Extend the existing
TUnit.Logging.Microsoftpackage so consumers who already take that reference forCorrelatedTUnitLoggerpick up the assertion DSL automatically. No new NuGet required.What's left to Tom's design judgement
Deliberately not prescribing:
Assertion<T>,AssertionBuilder, generator-backed, …)[AssertionExtension]/[AssertionFrom<T>]style attribute registration should exposeHasLogged(LogLevel)vsHasLogged()entry pointsHasNotLoggedis a separate class or a flag on the same builderTimeshelper — tiny, could also beintoverloads if you prefer that styleAlternatives Considered
loggerMock.Verify(l => l.Log(...), Times.Once())— works but verbose, couples tests to mock internals, requires writingLog(level, eventId, ...)verify expressions by hand per log shape.FakeLogCollector— a common pattern in practice; means duplicated effort across projects, inconsistent failure messages, no shared improvement path.Serilog.Sinks.TestCorrelator— good, but Serilog-specific; most .NET apps now useMicrosoft.Extensions.Logging.Assert.That(collector, Has.Logged(...).Once())via a customIConstraint) — first-class within NUnit's paradigm. Different idiom from TUnit's fluent chains, but a legitimate option there.Assertextension (partial class Assert { public static void Logged(...) { } }) — legal, but the static-method style doesn't chain naturally for filter builders like.Containing(...).Once().The TUnit proposal's distinctive point isn't "others can't do this"; it's that TUnit's
Assert.That(...)fluent surface + the pre-existingTUnit.Logging.Microsoftpackage let the whole feature ship as one package bump, with a single maintainer, consistent failure-message rendering, and zero additional install for anyone already consuming the logging package.Feature Category
Assertions
How important is this feature to you?
Important - significantly impacts my workflow
Additional Context
Why "Important" priority — concrete impact
Our team is migrating a sizeable codebase from MSTest to TUnit and has ~30 tests hitting this exact boilerplate. Each would collapse from 3–5 lines of log-assertion LINQ to a single fluent line, with materially clearer failure diagnostics. Meaningful DX win for any project using
Microsoft.Extensions.Logging.Testing.Why TUnit is a good home for this
TUnit already owns three pieces the feature needs in one place: the
Assert.That(...)fluent surface, theTUnit.Logging.Microsoftcapture/correlation package (already onMicrosoft.Extensions.Logging), and the release cadence to ship them together. Consumers ofCorrelatedTUnitLoggerwould pick up the assertion DSL via a single version bump, with no new NuGet reference required.Comparable functionality can be added to xUnit (via
partial class Assertextensions) or NUnit (via customIConstraintimplementations), but those routes mean a separate community package per framework, separate release cycles, and no shared failure-message format.Microsoft's position
Microsoft.Extensions.Logging.Testing— shipped as part ofMicrosoft.Extensions.Diagnostics.Testingv10.4.0, with theFakeLog*types available back to netstandard2.0 / net framework per the Microsoft Learn monikers — currently ships only the capture primitives (FakeLogger,FakeLogCollector,FakeLogRecord,FakeLoggerProvider). There's no assertion layer in that package. An assertion DSL on top fits TUnit's existingTUnit.Logging.Microsoftscope without duplicating anything Microsoft ships.If this lands well, three follow-up DX ideas
Not asking you to scope these now — just signalling a compound-value roadmap:
[FakeTime(Start = "...", AutoAdvance = "...")]— source-generated attribute that injects a pre-configuredFakeTimeProviderinto the test method (and optionally registers it with the test's service collection). Same ergonomics as[ClassDataSource<T>].[HostedService]— auto-wraps the test method with theIHostedServicelifecycle (StartingAsync → StartAsync → StartedAsync→ body →StoppingAsync → StopAsync → StoppedAsync) against a named fixture property. Common pattern in ASP.NET Core / Worker Service / message-consumer test suites.Assert.That(() => action).CompletesWithin(TimeSpan)— granular assertion-level timing budget.[Timeout]fails the whole test; this asserts one specific operation stays within its SLA while the rest of the test continues.Happy to open separate issues if the log DSL lands and you're interested.
On the PR offer (ticked below) — honest framing
Ticking the Contribution box, but want to be upfront about what I do and don't know today:
Assertion<TValue>base class andAssertionContext<TValue>(TUnit.Assertions/Core/Assertion.cs),AssertionResult.Passed/AssertionResult.Failed(...)(TUnit.Assertions/Core/AssertionResult.cs), the[AssertionExtension]+[AssertionFrom<T>]attributes on classes likeStringContainsAssertionandStringStaticMethodAssertions. That gives me the base-class pattern and the check-method shape.[AssertionExtension("X")]into the user-facingAssert.That(...).X(...)chain; the idiomatic pattern for chained-builder assertions with filter + terminator verbs (closest analog I'd want to study is whatever implementsThrows<T>().WithMessage(...)); and TUnit's own test patterns for these extensions.Proposal: if you confirm the API shape is worth pursuing, I'll study the generator + an existing chained-builder extension, then open a draft PR for you to review / redirect. Rather ship a draft you can correct than a finished PR that doesn't fit TUnit conventions.
Alternative: if you'd prefer to implement it and have us write / test-drive the test suite against the API, that also works. Either way the 6-test contract + the minimal failing-test repo for CI validation is something I can deliver independently of the main PR.
Context on #5613
Filing this separately from the migration-feedback issue because it's a new feature, not a blocker. Your 1.37.0 turnaround on #5613 (code fixes for TUnit0015/TUnit0049, collection-content rendering, TUnitAssertions0016 analyzer) was exactly the right triage and shipped faster than we expected — appreciate that.
Contribution