{"id":49516190,"url":"https://github.com/johnverheij/logassertions.tunit","last_synced_at":"2026-05-01T22:00:50.368Z","repository":{"id":355005724,"uuid":"1226350916","full_name":"JohnVerheij/LogAssertions.TUnit","owner":"JohnVerheij","description":"TUnit-native fluent log-assertion DSL using [AssertionExtension] over Microsoft.Extensions.Logging.Testing.FakeLogCollector","archived":false,"fork":false,"pushed_at":"2026-05-01T12:17:07.000Z","size":67,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":1,"default_branch":"main","last_synced_at":"2026-05-01T12:26:16.837Z","etag":null,"topics":["aot","assertions","dotnet","logging","microsoft-extensions-logging","testing","tunit"],"latest_commit_sha":null,"homepage":"https://www.nuget.org/packages/LogAssertions.TUnit/","language":"C#","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/JohnVerheij.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":"CONTRIBUTING.md","funding":null,"license":"LICENSE","code_of_conduct":"CODE_OF_CONDUCT.md","threat_model":null,"audit":null,"citation":null,"codeowners":".github/CODEOWNERS","security":"SECURITY.md","support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-05-01T09:29:04.000Z","updated_at":"2026-05-01T12:17:11.000Z","dependencies_parsed_at":null,"dependency_job_id":"1597060b-4e07-49ae-a559-578f2e2513ca","html_url":"https://github.com/JohnVerheij/LogAssertions.TUnit","commit_stats":null,"previous_names":["johnverheij/logassertions.tunit"],"tags_count":5,"template":false,"template_full_name":null,"purl":"pkg:github/JohnVerheij/LogAssertions.TUnit","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/JohnVerheij%2FLogAssertions.TUnit","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/JohnVerheij%2FLogAssertions.TUnit/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/JohnVerheij%2FLogAssertions.TUnit/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/JohnVerheij%2FLogAssertions.TUnit/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/JohnVerheij","download_url":"https://codeload.github.com/JohnVerheij/LogAssertions.TUnit/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/JohnVerheij%2FLogAssertions.TUnit/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":32514340,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-04-30T13:12:12.517Z","status":"online","status_checked_at":"2026-05-01T02:00:05.856Z","response_time":64,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["aot","assertions","dotnet","logging","microsoft-extensions-logging","testing","tunit"],"created_at":"2026-05-01T22:00:33.136Z","updated_at":"2026-05-01T22:00:50.331Z","avatar_url":"https://github.com/JohnVerheij.png","language":"C#","funding_links":[],"categories":[],"sub_categories":[],"readme":"# LogAssertions.TUnit\n\n[![CI](https://github.com/JohnVerheij/LogAssertions.TUnit/actions/workflows/ci.yml/badge.svg)](https://github.com/JohnVerheij/LogAssertions.TUnit/actions/workflows/ci.yml)\n[![CodeQL](https://github.com/JohnVerheij/LogAssertions.TUnit/actions/workflows/codeql.yml/badge.svg)](https://github.com/JohnVerheij/LogAssertions.TUnit/actions/workflows/codeql.yml)\n[![codecov](https://codecov.io/gh/JohnVerheij/LogAssertions.TUnit/branch/main/graph/badge.svg)](https://codecov.io/gh/JohnVerheij/LogAssertions.TUnit)\n[![NuGet](https://img.shields.io/nuget/v/LogAssertions.TUnit.svg)](https://www.nuget.org/packages/LogAssertions.TUnit/)\n[![Downloads](https://img.shields.io/nuget/dt/LogAssertions.TUnit.svg)](https://www.nuget.org/packages/LogAssertions.TUnit/)\n[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)\n[![.NET](https://img.shields.io/badge/.NET-10.0-512BD4.svg)](https://dotnet.microsoft.com/download/dotnet/10.0)\n\nA 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.\n\n\u003e **Scope:** Test projects only. Not intended for production code.\n\n---\n\n## Table of contents\n\n- [Why this package](#why-this-package)\n- [Install](#install)\n- [Package layout](#package-layout)\n- [Namespaces (and a `GlobalUsings.cs` recommendation)](#namespaces-and-a-globalusingscs-recommendation)\n- [Quick start](#quick-start)\n- [Entry points](#entry-points)\n  - [Shorthand entry points](#shorthand-entry-points)\n- [Filter reference](#filter-reference)\n  - [Level filters](#level-filters)\n  - [Message filters](#message-filters)\n  - [Exception filters](#exception-filters)\n  - [Structured-state (property) filters](#structured-state-property-filters)\n  - [Scope filters](#scope-filters)\n  - [Identity filters (category, event)](#identity-filters-category-event)\n  - [Escape hatch](#escape-hatch)\n  - [Combinator chain methods (`MatchingAny`, `MatchingAll`, `Not`, `WithFilter`)](#combinator-chain-methods-matchingany-matchingall-not-withfilter)\n  - [Conditional configuration (`When`)](#conditional-configuration-when)\n- [Terminators (`HasLogged` only)](#terminators-haslogged-only)\n- [Sequence assertions — `HasLoggedSequence`](#sequence-assertions--hasloggedsequence)\n- [Combining assertions with `.And` / `.Or`](#combining-assertions-with-and--or)\n- [Batch assertions — `AssertAllAsync`](#batch-assertions--assertallasync)\n- [Non-asserting inspection](#non-asserting-inspection)\n- [Failure diagnostics](#failure-diagnostics)\n- [Cookbook — common patterns](#cookbook--common-patterns)\n- [Design notes](#design-notes)\n- [Stability intent (pre-1.0)](#stability-intent-pre-10)\n- [Limitations and future work](#limitations-and-future-work)\n- [Background](#background)\n- [Contributing](#contributing)\n- [License](#license)\n\n---\n\n## Why this package\n\nAsserting on log output during tests typically devolves into either:\n\n- Manual `collector.GetSnapshot().Where(...).Count()` plumbing in every test, or\n- Adding temporary `Console.WriteLine` calls during debugging because the assertion failure says \"expected 1, got 3\" without showing what was actually logged.\n\nThis 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.\n\n## Install\n\n```\ndotnet add package LogAssertions.TUnit\n```\n\n**Requirements:** TUnit 1.41.0+ (for `[AssertionExtension]`), .NET 10. The package is AOT-compatible, trimmable, and uses no reflection in the assertion path.\n\n## Package layout\n\nThis repo ships **two** NuGet packages:\n\n| Package | Purpose | Depends on |\n|---|---|---|\n| [`LogAssertions`](https://www.nuget.org/packages/LogAssertions/) | Framework-agnostic core: `ILogRecordFilter` + `LogFilter` + rendering + collector inspection extensions | `Microsoft.Extensions.Diagnostics.Testing` |\n| [`LogAssertions.TUnit`](https://www.nuget.org/packages/LogAssertions.TUnit/) | TUnit-specific entry points: `HasLogged()`, `HasNotLogged()`, `HasLoggedSequence()` and shorthands | `LogAssertions` + `TUnit.Assertions` |\n\nYou 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](https://github.com/JohnVerheij/LogAssertions.TUnit/issues/new?template=feature_request.yml).\n\n## Namespaces (and a `GlobalUsings.cs` recommendation)\n\nThe two packages place types in two namespaces with deliberately-different scopes:\n\n| Type / member | Namespace | Auto-imported? |\n|---|---|---|\n| `HasLogged()`, `HasNotLogged()`, `HasLoggedSequence()` (the source-generated entry points) | `TUnit.Assertions.Extensions` | **Yes** — TUnit auto-imports this namespace |\n| `HasLoggedOnce()`, `HasLoggedExactly()`, ... (shorthand entry points, since 0.2.2) | `TUnit.Assertions.Extensions` | **Yes** — same auto-import path |\n| `LogCollectorBuilder.Create(...)` (the `(factory, collector)` factory) | `LogAssertions` | **No** — needs `using LogAssertions;` |\n| `LogFilter.AtLevel(...)`, `ILogRecordFilter`, `Filter`/`CountMatching`/`DumpTo` extensions | `LogAssertions` | **No** — needs `using LogAssertions;` |\n| `AssertAllAsync(...)` batch terminator | `TUnit.Assertions.Extensions` | **Yes** — same auto-import path |\n\n**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;`.\n\n**Recommended:** put both into a single `GlobalUsings.cs` in your test project so every test file sees them without ceremony:\n\n```csharp\n// tests/MyApp.Tests/GlobalUsings.cs\nglobal using LogAssertions;                              // LogCollectorBuilder, LogFilter, etc.\nglobal using Microsoft.Extensions.Logging;               // LogLevel\nglobal using Microsoft.Extensions.Logging.Testing;       // FakeLogCollector, FakeLoggerProvider\n```\n\nThis eliminates the IDE0005 (\"unnecessary using\") chatter that otherwise appears in test files that don't directly use `LogCollectorBuilder` but live alongside ones that do.\n\n## Quick start\n\n```csharp\nusing LogAssertions;\nusing Microsoft.Extensions.Logging;\nusing Microsoft.Extensions.Logging.Testing;\n\n[Test]\npublic async Task Validation_failure_is_logged()\n{\n    var (factory, collector) = LogCollectorBuilder.Create();\n    using (factory)\n    {\n        var logger = factory.CreateLogger\u003cMyValidator\u003e();\n        new MyValidator(logger).Validate(invalidInput);\n\n        await Assert.That(collector)\n            .HasLogged()\n            .AtLevel(LogLevel.Warning)\n            .Containing(\"validation failed\", StringComparison.Ordinal)\n            .WithCategory(\"MyApp.MyValidator\")\n            .Once();\n\n        await Assert.That(collector).HasNotLogged().AtLevel(LogLevel.Error);\n    }\n}\n```\n\n---\n\n## Entry points\n\nThree core entry points are emitted by TUnit's source generator and surface as extension methods on `Assert.That(FakeLogCollector)`.\n\n| Entry point | Default expectation | Terminators allowed |\n|---|---|---|\n| `HasLogged()` | At least 1 matching record | All count terminators (see below) |\n| `HasNotLogged()` | Zero matching records | None — fixed at zero |\n| `HasLoggedSequence()` | An ordered series of matches; `Then()` separates steps | None — each step's match is implicit |\n\nAll 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\"*).\n\n### Shorthand entry points\n\nWrappers that pre-configure the most common chains. Each returns the underlying assertion type so additional filters can still be appended.\n\n| Shorthand | Equivalent to |\n|---|---|\n| `HasLoggedOnce()` | `HasLogged().Once()` |\n| `HasLoggedExactly(int)` | `HasLogged().Exactly(int)` |\n| `HasLoggedAtLeast(int)` | `HasLogged().AtLeast(int)` |\n| `HasLoggedAtMost(int)` | `HasLogged().AtMost(int)` |\n| `HasLoggedBetween(int, int)` | `HasLogged().Between(int, int)` |\n| `HasLoggedNothing()` | `HasNotLogged()` (no filters — asserts the collector is empty) |\n| `HasLoggedWarningOrAbove()` | `HasLogged().AtLevelOrAbove(LogLevel.Warning)` |\n| `HasLoggedErrorOrAbove()` | `HasLogged().AtLevelOrAbove(LogLevel.Error)` |\n\n```csharp\nawait Assert.That(collector).HasLoggedOnce().AtLevel(LogLevel.Warning).Containing(\"retry\", StringComparison.Ordinal);\nawait Assert.That(collector).HasLoggedNothing();\nawait Assert.That(collector).HasLoggedErrorOrAbove();\n```\n\n---\n\n## Filter reference\n\nFilters 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.\n\n### Level filters\n\n| Filter | Behaviour |\n|---|---|\n| `AtLevel(LogLevel)` | Exact level match |\n| `AtLevelOrAbove(LogLevel)` | `record.Level \u003e= threshold` (e.g. *\"any warning or worse\"*) |\n| `AtLevelOrBelow(LogLevel)` | `record.Level \u003c= threshold` (e.g. *\"only diagnostic-tier\"*) |\n| `AtAnyLevel(params LogLevel[])` | Match any level in the supplied set (e.g. *\"Warning or Error but not Critical\"*) |\n| `NotAtLevel(LogLevel)` | Inverse of `AtLevel` — convenience over `Not(LogFilter.AtLevel(...))` |\n| `ExcludingLevel(LogLevel)` | Alias for `NotAtLevel`, reads better in negative-filter chains |\n\n```csharp\nawait Assert.That(collector).HasLogged().AtLevelOrAbove(LogLevel.Warning).AtLeast(1);\nawait Assert.That(collector).HasNotLogged().AtLevelOrAbove(LogLevel.Error);\nawait Assert.That(collector).HasLogged().AtAnyLevel(LogLevel.Warning, LogLevel.Error).AtLeast(1);\n```\n\n### Message filters\n\n| Filter | Behaviour |\n|---|---|\n| `Containing(string substring, StringComparison comparison)` | Formatted message contains substring (comparison **explicit by design** — no implicit culture) |\n| `ContainingAll(StringComparison, params string[])` | Formatted message contains every one of the substrings |\n| `ContainingAny(StringComparison, params string[])` | Formatted message contains at least one of the substrings |\n| `Matching(Regex)` | Formatted message matches the regex |\n| `WithMessage(Func\u003cstring, bool\u003e predicate)` | Predicate over the formatted message |\n| `WithMessageTemplate(string template)` | The pre-substitution template (e.g. `\"Order {OrderId} processed\"`) equals `template` exactly. Resolved from MEL's magic `{OriginalFormat}` structured-state entry |\n| `NotContaining(string, StringComparison)` | Inverse of `Containing` — convenience over `Not(LogFilter.Containing(...))` |\n\n`WithMessageTemplate` is useful when you want to pin a specific call site without coupling to the substituted parameter values:\n\n```csharp\n// matches every \"Order N processed\" log regardless of N\nawait Assert.That(collector).HasLogged()\n    .WithMessageTemplate(\"Order {OrderId} processed\").AtLeast(1);\n```\n\n### Exception filters\n\n| Filter | Behaviour |\n|---|---|\n| `WithException\u003cTException\u003e()` | `record.Exception is TException` (assignable) |\n| `WithException()` | Any record with a non-null `Exception`, regardless of type |\n| `WithException(Func\u003cException, bool\u003e predicate)` | Predicate over the exception (predicate not invoked for null exception) |\n| `WithExceptionMessage(string substring)` | `record.Exception?.Message` contains substring (ordinal); records without an exception never match |\n\n```csharp\nawait Assert.That(collector).HasLogged()\n    .WithException\u003cTimeoutException\u003e()\n    .WithExceptionMessage(\"connection\")\n    .Once();\n```\n\n### Structured-state (property) filters\n\n`Microsoft.Extensions.Logging` exposes structured properties on each record (the parameters captured by `LoggerMessage` source generators or by message-template logging calls).\n\n| Filter | Behaviour |\n|---|---|\n| `WithProperty(string key, string? value)` | Property's formatted string value equals `value` (ordinal) |\n| `WithProperty(string key, Func\u003cstring?, bool\u003e predicate)` | Predicate over the formatted string value (use for ranges, regex, or null-checks) |\n\nNote: `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:\n\n```csharp\nawait Assert.That(collector).HasLogged()\n    .WithProperty(\"OrderId\", v =\u003e\n        int.TryParse(v, CultureInfo.InvariantCulture, out var n) \u0026\u0026 n \u003e 1000)\n    .AtLeast(1);\n```\n\n### Scope filters\n\nScopes are values pushed via `logger.BeginScope(...)`. They surround any log records emitted while the scope is active.\n\n| Filter | Behaviour |\n|---|---|\n| `WithScope\u003cTScope\u003e()` | A scope of type `TScope` was active when the record was emitted |\n| `WithScopeProperty(string key, object? value)` | A scope contains a property `key` matching `value` (`object.Equals` semantics) |\n| `WithScopeProperty(string key, Func\u003cobject?, bool\u003e predicate)` | A scope contains a property `key` whose value satisfies the predicate |\n\nScope-property filters recognise the two AOT-friendly idioms:\n\n```csharp\n// dictionary scope — the canonical structured pattern\nusing (logger.BeginScope(new Dictionary\u003cstring, object?\u003e { [\"OrderId\"] = 42 }))\n    DoWork();\n\nawait Assert.That(collector).HasLogged().WithScopeProperty(\"OrderId\", 42).AtLeast(1);\n```\n\n```csharp\n// formatted-template scope via LoggerMessage.DefineScope (avoids CA1848)\nprivate static readonly Func\u003cILogger, int, IDisposable?\u003e OrderScope =\n    LoggerMessage.DefineScope\u003cint\u003e(\"Order {OrderId}\");\n\nusing (OrderScope(logger, 42)) DoWork();\n\nawait Assert.That(collector).HasLogged().WithScopeProperty(\"OrderId\", 42).AtLeast(1);\n```\n\n\u003e **Anonymous-object scopes** (`logger.BeginScope(new { OrderId = 42 })`) are **not** recognised by `WithScopeProperty` — reading their fields requires reflection, which would compromise AOT-compatibility. Prefer dictionary or `LoggerMessage.DefineScope` form.\n\n### Identity filters (category, event)\n\n| Filter | Behaviour |\n|---|---|\n| `WithCategory(string)` | Logger category equals string (ordinal) |\n| `WithLoggerName(string)` | Alias for `WithCategory` |\n| `ExcludingCategory(string)` | Inverse of `WithCategory` |\n| `WithEventId(int)` | `EventId.Id` equals value |\n| `WithEventIdInRange(int min, int max)` | `EventId.Id` is within the inclusive range |\n| `WithEventName(string)` | `EventId.Name` equals string (ordinal) |\n\n```csharp\nawait Assert.That(collector).HasLogged()\n    .WithCategory(\"MyApp.Bootstrap\")\n    .WithEventName(\"Startup\")\n    .Once();\n```\n\n### Escape hatch\n\n| Filter | Behaviour |\n|---|---|\n| `Where(Func\u003cFakeLogRecord, bool\u003e predicate)` | Arbitrary predicate over the full `FakeLogRecord` |\n\nUse only when no other filter expresses the constraint cleanly — composing built-in filters is preferred for diagnostic clarity in failure messages.\n\n### Combinator chain methods (`MatchingAny`, `MatchingAll`, `Not`, `WithFilter`)\n\nThe fluent chain is implicitly AND-combined. These four chain methods let you compose richer expressions inside the chain without dropping to `Where`:\n\n| Method | Behaviour |\n|---|---|\n| `MatchingAny(params ILogRecordFilter[])` | OR of the supplied filters as one composite filter on the chain. Empty array matches no record. |\n| `MatchingAll(params ILogRecordFilter[])` | Explicit AND of the supplied filters. Empty array matches every record. |\n| `Not(ILogRecordFilter)` | Negates the supplied filter. |\n| `WithFilter(ILogRecordFilter)` | Adds a user-supplied or pre-built filter to the chain. |\n\n```csharp\n// \"level == Warning AND (msg contains \"a\" OR msg contains \"b\")\"\nawait Assert.That(collector).HasLogged()\n    .AtLevel(LogLevel.Warning)\n    .MatchingAny(\n        LogFilter.Containing(\"a\", StringComparison.Ordinal),\n        LogFilter.Containing(\"b\", StringComparison.Ordinal))\n    .AtLeast(1);\n\n// Reusable filter shared across many tests:\nstatic readonly ILogRecordFilter CriticalDbError = LogFilter.All(\n    LogFilter.AtLevel(LogLevel.Critical),\n    LogFilter.WithException\u003cDbException\u003e());\n\nawait Assert.That(collector).HasLogged().WithFilter(CriticalDbError).AtLeast(1);\n```\n\n### Conditional configuration (`When`)\n\n```csharp\n// In a parameterised test, fold a boolean branch into the chain\n// instead of duplicating the entire await:\nawait Assert.That(collector).HasLogged()\n    .AtLevel(LogLevel.Warning)\n    .When(expectRetry, b =\u003e b.Containing(\"retry\", StringComparison.Ordinal))\n    .AtLeast(1);\n```\n\n---\n\n## Terminators (`HasLogged` only)\n\nTerminators express the count expectation. Pick exactly one — chain it after all filters. `HasNotLogged` has no terminators (the expectation is fixed at zero matches).\n\n| Terminator | Match count expectation |\n|---|---|\n| `Once()` | Exactly 1 |\n| `Exactly(int count)` | Exactly N |\n| `AtLeast(int count)` | At least N (inclusive) |\n| `AtMost(int count)` | At most N (inclusive) |\n| `Between(int min, int max)` | Inclusive range `[min, max]` |\n| `Never()` | Exactly 0 (semantic synonym for `HasNotLogged()`) |\n\n```csharp\nawait Assert.That(collector).HasLogged().AtLevel(LogLevel.Warning).Between(1, 5);\nawait Assert.That(collector).HasLogged().WithEventId(42).Never();\n```\n\n**`Never()` vs `HasNotLogged()` — when to use which.** They produce identical assertions; the only difference is reading order. **Prefer `HasNotLogged()`** when \"this should not happen\" is the primary intent of the test (the negative is the headline). **Use `.Never()`** when you've already started building a positive filter chain and only at the end realise you expect zero matches — saves rewriting the prefix. Don't agonise over the choice; either reads clearly to a future maintainer.\n\n---\n\n## Sequence assertions — `HasLoggedSequence`\n\nFor tests that need to verify a series of records appeared in order:\n\n```csharp\nawait Assert.That(collector).HasLoggedSequence()\n    .AtLevel(LogLevel.Information).Containing(\"Started\",          StringComparison.Ordinal)\n    .Then()\n    .AtLevel(LogLevel.Warning)    .Containing(\"validation failed\", StringComparison.Ordinal)\n    .Then()\n    .AtLevel(LogLevel.Information).Containing(\"Stopped\",          StringComparison.Ordinal);\n```\n\nSemantics:\n\n- The walk is **order-preserving but not contiguous** — records between matches are skipped.\n- `Then()` commits the current step's filters and starts a new step.\n- Each step's filters AND-combine, exactly like the single-match assertions.\n- A step with no filters always matches the next available record (use sparingly).\n- Failure diagnostics indicate which step failed and dump the full captured-records list (see [Failure diagnostics](#failure-diagnostics)).\n\n---\n\n## Combining assertions with `.And` / `.Or`\n\nBecause the assertion types derive from TUnit's `Assertion\u003cT\u003e`, the standard TUnit chaining works. **`.And` is genuinely useful for log assertions** — chain a positive and a negative invariant in one expression:\n\n```csharp\nawait Assert.That(collector)\n    .HasLogged().AtLevel(LogLevel.Information).AtLeast(1)\n    .And.HasNotLogged().AtLevel(LogLevel.Error);\n```\n\nFor three-or-more conditions, prefer the dedicated [`AssertAllAsync`](#batch-assertions--assertallasync) batch terminator — it aggregates failures into a single message rather than failing fast on the first.\n\n**`.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.\n\n---\n\n## Batch assertions — `AssertAllAsync`\n\nRun 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.\n\n```csharp\nawait Assert.That(collector).AssertAllAsync(\n    c =\u003e c.HasLogged().AtLevel(LogLevel.Information).AtLeast(1),\n    c =\u003e c.HasNotLogged().AtLevel(LogLevel.Error),\n    c =\u003e c.HasLoggedSequence()\n        .Containing(\"Started\", StringComparison.Ordinal)\n        .Then().Containing(\"Stopped\", StringComparison.Ordinal));\n```\n\nIf two of three fail, the thrown exception's message lists both — not just the first.\n\nA second overload (added in 0.2.1) accepts the more verbose `async c =\u003e await c.HasLogged()...` form for cases where the lambda needs to mix in non-assertion async work between checks. Pick whichever is clearer for the case at hand; both have identical failure-aggregation semantics.\n\n---\n\n## Non-asserting inspection\n\nSometimes 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`:\n\n| Method | Returns |\n|---|---|\n| `Filter(params ILogRecordFilter[] filters)` | The matching records as a defensive `IReadOnlyList\u003cFakeLogRecord\u003e` |\n| `CountMatching(params ILogRecordFilter[] filters)` | Just the match count (no list materialisation) |\n| `DumpTo(TextWriter writer)` | Writes every captured record in the failure-message format |\n\n```csharp\n// Inspect without asserting\nvar warnings = collector.Filter(LogFilter.AtLevel(LogLevel.Warning));\nint errors = collector.CountMatching(\n    LogFilter.AtLevelOrAbove(LogLevel.Error),\n    LogFilter.WithException\u003cDbException\u003e());\n\n// Print the entire snapshot to test output during development\nusing var writer = new StringWriter();\ncollector.DumpTo(writer);\nConsole.WriteLine(writer);\n```\n\n---\n\n## Failure diagnostics\n\nOn a failed assertion the `AssertionException` message includes:\n\n1. The expectation (terminator + filter summary)\n2. The actual match count\n3. A snapshot of every captured record, with **4-character level abbreviation** (matching the `Microsoft.Extensions.Logging` console formatter), category, message, structured properties, active scopes, and exception details\n\nExample failure output:\n\n```\nExpected: exactly 1 log record(s) to have been logged matching: Level = Warning, Message contains \"timeout\"\n\n3 record(s) matched\n\nCaptured records (5 total):\n  [info] MyApp.Worker: Started cycle 1\n    props: cycle=1\n    scope: RequestId=abc-123\n  [warn] MyApp.Worker: timeout exceeded for cycle 1\n    props: cycle=1, threshold=500\n    scope: RequestId=abc-123\n  [warn] MyApp.Worker: timeout exceeded for cycle 2\n    props: cycle=2, threshold=500\n    scope: RequestId=abc-123\n  [warn] MyApp.Worker: timeout exceeded for cycle 3\n    props: cycle=3, threshold=500\n    scope: RequestId=abc-123\n  [info] MyApp.Worker: Cycle batch finished\n    scope: RequestId=abc-123\n    exception: TimeoutException: Connection timed out\n```\n\nLevel abbreviations: `trce`, `dbug`, `info`, `warn`, `fail`, `crit` (matching MEL's console formatter; `none` for `LogLevel.None`).\n\nThis 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.\n\n---\n\n## Cookbook — common patterns\n\n### Assert no errors were logged\n\n```csharp\nawait Assert.That(collector).HasNotLogged().AtLevelOrAbove(LogLevel.Error);\n```\n\n### Assert a specific call site was hit\n\nAnchored on the message template, not the substituted value:\n\n```csharp\nawait Assert.That(collector).HasLogged()\n    .WithMessageTemplate(\"Order {OrderId} processed\").AtLeast(1);\n```\n\n### Assert a structured property is in a numeric range\n\n```csharp\nawait Assert.That(collector).HasLogged()\n    .WithProperty(\"DurationMs\", v =\u003e\n        int.TryParse(v, CultureInfo.InvariantCulture, out var ms) \u0026\u0026 ms \u003c 1000)\n    .AtLeast(1);\n```\n\n### Assert all logs in a request scope were warnings or below\n\n```csharp\nawait Assert.That(collector).HasNotLogged()\n    .WithScopeProperty(\"RequestId\", \"req-42\")\n    .AtLevelOrAbove(LogLevel.Error);\n```\n\n### Assert a specific exception flowed through a logger\n\n```csharp\nawait Assert.That(collector).HasLogged()\n    .AtLevel(LogLevel.Error)\n    .WithException\u003cDbUpdateConcurrencyException\u003e()\n    .Once();\n```\n\n### Assert a startup → work → shutdown sequence\n\n```csharp\nawait Assert.That(collector).HasLoggedSequence()\n    .WithEventName(\"Startup\")\n    .Then().AtLevel(LogLevel.Information).Containing(\"processed\", StringComparison.Ordinal)\n    .Then().WithEventName(\"Shutdown\");\n```\n\n### Assert exactly N retries fired\n\n```csharp\nawait Assert.That(collector).HasLogged()\n    .AtLevel(LogLevel.Warning)\n    .WithMessageTemplate(\"Retrying after {Delay}ms\")\n    .Exactly(3);\n```\n\n### Set up the collector in one line\n\n```csharp\nvar (factory, collector) = LogCollectorBuilder.Create();\nusing (factory)\n{\n    var logger = factory.CreateLogger(\"MyService\");\n    new MyService(logger).DoWork();\n    await Assert.That(collector).HasLoggedOnce().Containing(\"done\", StringComparison.Ordinal);\n}\n```\n\n### Reuse a filter across many tests\n\n```csharp\n// Define once in a test base class:\nprivate static readonly ILogRecordFilter CriticalDbError = LogFilter.All(\n    LogFilter.AtLevel(LogLevel.Critical),\n    LogFilter.WithException\u003cDbException\u003e());\n\n// Use in many tests:\nawait Assert.That(collector).HasNotLogged().WithFilter(CriticalDbError);\nawait Assert.That(otherCollector).HasLoggedExactly(1).WithFilter(CriticalDbError);\n```\n\n### Assert several invariants and report all failures together\n\n```csharp\nawait Assert.That(collector).AssertAllAsync(\n    c =\u003e c.HasLogged().AtLevel(LogLevel.Information).AtLeast(1),\n    c =\u003e c.HasNotLogged().AtLevelOrAbove(LogLevel.Error),\n    c =\u003e c.HasLoggedSequence()\n        .WithEventName(\"Startup\")\n        .Then().WithEventName(\"Shutdown\"));\n```\n\n### Assert \"Warning OR Error in this scope, but not Critical\"\n\n```csharp\nawait Assert.That(collector).HasLogged()\n    .WithScopeProperty(\"RequestId\", \"req-42\")\n    .AtAnyLevel(LogLevel.Warning, LogLevel.Error)\n    .AtLeast(1);\n```\n\n### Inspect what was actually logged during test development\n\n```csharp\n// Run your code-under-test, then dump everything to the test output:\nusing var writer = new StringWriter();\ncollector.DumpTo(writer);\nConsole.WriteLine(writer);\n\n// Or get a typed handle on the matching records for further checks:\nvar retries = collector.Filter(\n    LogFilter.AtLevel(LogLevel.Warning),\n    LogFilter.Containing(\"retry\", StringComparison.Ordinal));\n```\n\n---\n\n## Design notes\n\n- **Built on `[AssertionExtension]`** (TUnit 1.41.0+, [thomhurst/TUnit#5785](https://github.com/thomhurst/TUnit/pull/5785)): the entry-point methods are emitted by TUnit's source generator. No extension-method wrappers needed.\n- **No cross-package coupling.** This package depends on `TUnit.Assertions` and `Microsoft.Extensions.Diagnostics.Testing`. Neither of those depends on the other; this library is the bridge.\n- **AOT-compatible / trimmable.** `IsAotCompatible=true`, `IsTrimmable=true`, `EnableTrimAnalyzer=true`. No reflection in the assertion path. Scope-property matching uses interface casts only, never reflection.\n- **Single TFM, forward-only by policy:** targets `net10.0` and only `net10.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.Logging` features as they ship.\n\n  **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 `net10` test project referencing a `net8` production project (`net10` runtime is forward-compatible with `net8` assemblies). The test exe loads on the `net10` runtime and invokes the production code through its `net8` surface. The reverse — referencing a `net10` production lib from a `net8` test — does not work, but that's not a typical setup.\n\n  Concrete: if your production lib targets `net8.0`, set your test project's `\u003cTargetFramework\u003e` to `net10.0`, install `LogAssertions.TUnit`, and the production `\u003cProjectReference\u003e` continues to resolve cleanly.\n- **Explicit `StringComparison`.** Every string-matching API requires the caller to pass a `StringComparison` (or uses `Ordinal` internally where unambiguous). No silent culture defaults.\n- **Source Link + deterministic builds.** Both packages ship with [`Microsoft.SourceLink.GitHub`](https://github.com/dotnet/sourcelink), a separate `.snupkg` symbol 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 `\u003cDeterministic\u003etrue\u003c/Deterministic\u003e`); the snapshot test project is the one exception (Verify needs absolute PDB paths, so its build is non-deterministic — that project's binaries are not shipped).\n\n---\n\n## Stability intent (pre-1.0)\n\nPer [SemVer](https://semver.org/), 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.\n\n**Intended-stable (we will not break these without a CHANGELOG-flagged reason and a clear migration path):**\n\n- The three entry-point methods on `IAssertionSource\u003cFakeLogCollector\u003e`: `HasLogged()`, `HasNotLogged()`, `HasLoggedSequence()`.\n- The top-level shorthand entry points (`HasLoggedOnce`, `HasLoggedExactly`, `HasLoggedNothing`, `HasLoggedWarningOrAbove`, etc.).\n- 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`).\n- The `ILogRecordFilter` interface and the `LogFilter` static factory's public methods.\n- The `LogCollectorBuilder.Create` factory.\n- The `FakeLogCollector` extension methods: `Filter`, `CountMatching`, `DumpTo`, `AssertAllAsync`.\n\n**Explicitly unstable (will change without notice, do not depend on):**\n\n- `LogAssertionBase\u003cTSelf\u003e` and its protected/internal members. The type is `public` only 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.\n- The internal filter classes (`PredicateFilter`, `AndFilter`, `OrFilter`, `NotFilter`). These live behind `ILogRecordFilter` and the `LogFilter` factory.\n- The exact format of failure-message snapshot text rendered by `LogAssertionRendering` and exposed via `DumpTo`. 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.\n- The `CompatibilitySuppressions.xml` file is a build artifact tracking baseline acceptance, not part of the API contract.\n\n**Breaking changes log (every release with a breaking change is listed in CHANGELOG.md):**\n\n- **0.2.0:** `LogAssertionBase\u003cTSelf\u003e` annotated `[EditorBrowsable(Never)]`; the `protected virtual void AddPredicate(Func, string)` extension hook replaced by `protected virtual void AddFilter(ILogRecordFilter)` as part of the `ILogRecordFilter` refactor. Affects only consumers who derived from `LogAssertionBase` (an unsupported scenario). Framework-agnostic types (`ILogRecordFilter`, `LogFilter`, etc.) moved from `LogAssertions.TUnit` to a new `LogAssertions` package + namespace; the `LogAssertions.TUnit` package now has a `LogAssertions` transitive dependency.\n\n---\n\n## Limitations and future work\n\nThe 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.\n\n### Plausible v0.3.0 (would make the library substantially more capable)\n\nThese need new primitives (timestamp + polling + cursor) but are coherent additions, not architectural shifts.\n\n- **Time-based filters:** `WithElapsedTime(min, max)`, `WithTimestamp(at, tolerance)`, `ThenGap(TimeSpan)` in sequence, `Throttled(maxPerWindow)` for rate-limit verification.\n- **Async-await polling terminator:** `WithinTimeout(TimeSpan)` for tests against background services / event handlers, replacing the brittle `await Task.Delay(...)` pattern.\n- **Sequence variants:** `ThenImmediately()` (strict adjacency), `NotInterleaved()` (no other records from same category between matches), `InOrder()` terminator on `HasLogged` (multiple matches in chronological order, not necessarily adjacent).\n- **Cursor / direction:** `FromNewest()` / `FromOldest()` direction control, `SinceLastAssert()` watermark, `Pin()` snapshot pinning, `HasLoggedDistinct(int)` (dedupe + count).\n- **`HasNotLoggedSequence()`** — mirror of `HasLoggedSequence`, asserts a specific sequence did NOT occur.\n- **`DescribedAs(string label)` on filters** (queued from real consumer feedback) — when a `Where(predicate)` or composed `MatchingAny`/`All` is used, let the caller attach a human-readable label that shows in failure diagnostics instead of the generic `\"Custom predicate\"` / `\"(... AND ...)\"` rendering.\n- **`DumpToTestOutput()` extension** (queued from real consumer feedback) — TUnit-aware variant of `DumpTo(TextWriter)` that routes captured records to TUnit's `TestContext.OutputWriter` automatically, eliminating the `using var sw = new StringWriter(); ... Console.WriteLine(sw)` boilerplate during test development.\n- **External-consumer smoke-test project in CI** (process improvement queued from real consumer feedback) — a deliberately-namespaced test project (e.g. `External.Consumer.Tests`) that references `LogAssertions.TUnit` only via PackageReference and verifies every public entry point resolves without inheriting visibility from the `LogAssertions.TUnit.*` namespace tree. Would have caught the v0.2.0/v0.2.1 shorthand-resolution bug fixed in v0.2.2 before it shipped.\n- **Package-shipped `\u003cUsing Include=\"LogAssertions\" /\u003e` via `build/LogAssertions.props`** — alternative to the documented `GlobalUsings.cs` recommendation. Would auto-add `LogAssertions` as a global using to every consuming project on install (no consumer code change needed for `LogCollectorBuilder` / `LogFilter` to resolve). Trade-off: more invasive (silently adds a global to consumers), strict-using-policy teams might object. Consumer can opt-out via `\u003cUsing Remove=\"LogAssertions\" /\u003e`. Defer until multiple consumers report the explicit-using as friction; the documented `GlobalUsings.cs` route is the lower-surprise default.\n\n### Possible v0.4.0+ (separate packages, more substantial work)\n\n- **Roslyn analyzer** for common mistakes: forgotten terminator, missing `StringComparison`, forgotten `await`. Standalone analyzer package.\n- **Source generator** for `[LoggerMessage]`-derived typed assertion helpers — e.g. `HasLogged().RetryExhausted(maxRetries: 3)` generated from the `[LoggerMessage]` declaration.\n- **Verify integration** — `collector.ToVerifyString()` for golden-file approval of full log sequences.\n- **Framework adapter packages:** `LogAssertions.NUnit`, `LogAssertions.xUnit`, `LogAssertions.MSTest`. The `LogAssertions` core package already supports them architecturally; only built when someone asks.\n\n### Could-go-either-way (no current plan, depends on demand)\n\n- Multi-collector aggregate: `Assert.That(c1, c2, c3).HasLogged(...)` for pipeline tests with several loggers.\n- Diagnostic upgrades: per-record match-tagging in failure dump, grouping by category/level.\n- Scope-aware sequence: `HasLoggedSequence().InScope(\"RequestId\", \"abc\")...`.\n- Parallel-safe collector partitioning (depends on TUnit's parallel-test story).\n- Benchmarks + perf documentation (will probably do once before v1.0 to honestly characterise).\n\n### Probably not (wrong fit or no clear demand)\n\n- `WithCallerInfo(...)` — MEL doesn't auto-propagate `[CallerMemberName]` etc. into log records.\n- `WithContext\u003cT\u003e` AsyncLocal context filter — niche, conflates with `WithScope`.\n- `WithStructuredState\u003cT\u003e` typed state — `FakeLogger` empirically does not preserve the typed state object (we proved this by testing).\n- `WithFailureMessage` custom override — TUnit's own `Assert.That(...).WithMessage(...)` already covers this at the framework level.\n- `Should()` syntax — orthogonal API style choice.\n- JSON property matching (`HasLoggedJson`) — depends on JSON serializer, AOT-incompatible without source-gen, ecosystem-fragmenting.\n- Anonymous-object scope inspection — would require reflection; intentionally out of scope for AOT-compatibility.\n- Localization-aware level names — `LevelAbbreviation` is intentionally English-centric to match MEL's console formatter.\n\n### Out of scope per project policy\n\n- Multi-target `net8;net9;net10` — see \"Single TFM, forward-only\" in [Design notes](#design-notes).\n\nIf you'd find any of the candidate items useful, [open a feature request](https://github.com/JohnVerheij/LogAssertions.TUnit/issues/new?template=feature_request.yml).\n\n---\n\n## Background\n\nThe TUnit feature request that motivated this package was [thomhurst/TUnit#5627](https://github.com/thomhurst/TUnit/issues/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](https://github.com/thomhurst/TUnit/pull/5785) shipped `[AssertionExtension]` infrastructure in TUnit 1.41.0. This package implements the user-space pattern.\n\n## Contributing\n\nSee [CONTRIBUTING.md](CONTRIBUTING.md) for branch convention, PR checklist, and code style.\n\n## License\n\n[MIT](LICENSE) — Copyright (c) 2026 John Verheij\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fjohnverheij%2Flogassertions.tunit","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fjohnverheij%2Flogassertions.tunit","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fjohnverheij%2Flogassertions.tunit/lists"}