Thanks to visit codestin.com
Credit goes to github.com

Skip to content

[Feature]: First-class log-assertion DSL — Assert.That(FakeLogCollector).HasLogged(...) #5627

Description

@JohnVerheij

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:

  1. Noisy — 3–5 lines of LINQ per log check, dwarfing the actual test logic.
  2. Bad failure diagnosticsAssert.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

  1. HasLogged_Containing_Once_Matches
  2. HasLogged_FailureMessageRendersFullSnapshotthe key ergonomic test
  3. HasLogged_WithException_Typed
  4. HasLogged_WithProperty_MatchesStructuredState
  5. HasNotLogged_PassesWhenNothingMatches
  6. 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:

  1. [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>].
  2. [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.
  3. 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

  • I'm willing to submit a pull request for this feature

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions