Changelog
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
History note: All version sections were reformatted on 2026-07-05 for one-time CONVENTIONS §CHANGELOG conformance: forbidden sub-headers folded into the six Keep a Changelog headers, non-user-facing content removed (internal refactors, test counts, coverage numbers, CI and build hygiene, governance churn, roadmap notes), bullets kept in past-tense active voice with code-formatted API leads. The nuget.org Release Notes tab and the GitHub Release for each shipped version are unchanged. A CI
family-lintgate keeps future sections conforming; each is frozen per Rule 7 once shipped.
Unreleased
0.12.0 - 2026-07-11: gate a run on its log output
Minor release. Adds the assertion the rest of the DSL was missing: HasLogged() and friends assert that an expected record is present, while the new gate asserts that no unexpected one is. Additive; no existing API changed.
Added
Assert.That(collector).HasLoggedOnly(floor).Allowing(definitions)asserts that every captured record at or abovefloorcarries the identity of an allowed[LoggerMessage]definition. Records below the floor are always permitted and are never enumerated, which is what makes the gate usable on a service whose Debug and Trace volume (heartbeats, request bodies) is large while its warning-and-above band is small enough to list. With noAllowing(...)call the gate is the clean-run check: nothing was logged at or above the floor at all. Because it keys on definition identity rather than rendered text, a reworded template cannot slip an unexpected record past it. Assert it once per test class or fixture (a TUnit[After]hook over the fixture's collector) rather than per test. When the collector's capture floor sits above the gate's floor the records it claims to inspect were never captured, so the gate fails as vacuous rather than passing for the wrong reason, matching the guardHasNotLogged()already had.MatchingAny(LogDefinition, params LogDefinition[])on the assertion chains, andLogFilter.MatchingAny(LogDefinition, params LogDefinition[])in the framework-agnostic core, filter to records carrying the identity of any of the given definitions. This covers one logical event that is emitted by more than one definition, such as a verbose form that attaches the exception and a terse form that folds the exception text into the message, selected at run time by a flag. There is deliberately no conjunction counterpart: a record carries exactly one identity, so requiring it to match two distinct definitions could never be satisfied.Step(LogDefinition)onHasLoggedSequence()adds a strictly-ordered typed step, so a flow reads as one call per event (.Step(OrderReceived).Step(PaymentCaptured).Step(OrderShipped)) instead of alternatingMatching(...)andThen(). Further filters chain onto the step just opened.
Changed
- Documented where
[LoggerMessage]definitions must live to be assertable:internal(with[InternalsVisibleTo]) orpublic, and not on a generic type. Roslynator'sRCS1158ignores private members, so a private definition on a generic base compiles until it is promoted tointernalfor typed capture, at which point the build breaks; hosting definitions in a non-genericstatic partial classresolves it and reads better at the capture site. Also documented that negative assertions gain the most from typed definitions (a substringHasNotLogged()silently passes once the wording drifts; an identity-keyed one cannot), and when to reach for a renderedLogSnapshotRenderersnapshot instead.
0.11.0 - 2026-07-11: typed assertions against log definitions
Minor release. A [LoggerMessage] definition (or any other log call) becomes a reusable assertion value: capture it once, then assert it was logged by identity instead of matching rendered message text. Wording edits to a template no longer break tests that assert intent; renaming a definition or changing its signature breaks the capture lambda at compile time. Binary-compatible and additive; one source-level compatibility note is listed under Changed. Built against TUnit 1.58; pin TUnit at or above that version when adopting.
Added
LogDefinition.Capture(Action<ILogger>)(core) invoked a logging delegate once against a private probe logger and returned the captured call shape: event ID (numeric ID plus name), level, message template, formatted placeholder values, and exception. The probe collects every level, so anIsEnabledgate inside generated logging code cannot suppress the capture. A lambda that logs zero or multiple records fails fast with an explanatoryArgumentException. Argument values in the capture lambda are throwaway; store definitions instatic readonlyfields.Matching(LogDefinition)on theHasLogged()/HasNotLogged()/HasLoggedSequence()chains filtered to records carrying the definition's identity: equal event ID, event name, and message template. Argument values and level are deliberately not part of identity: chainWithProperty(...)to pin placeholder values andAtLevel(...)when the level matters. Works for definitions behind wrapper methods (the record carries the generated Core's identity), definitions with generator-assigned event IDs, and definitions hosted in generic classes (close the generic in the capture lambda).MatchingCall(Action<ILogger>)on the same chains matched a log call exactly: the definition's identity plus every placeholder value (formatted strings, order-insensitive, culture-invariant) plus the exception (both absent, or same runtime type and equal message). The lambda is invoked once against the probe at chain-build time with the exact argument values expected.LogFilter.Matching(LogDefinition)andLogFilter.MatchingCall(LogDefinition)exposed the same matching as composableILogRecordFilterfactories for reuse viaWithFilter(...)andLogFilter.All/Any/Not.
Changed
LogFilter.Matching(null!)call sites became ambiguous between theRegexandLogDefinitionoverloads; a null-literal argument needs a cast to pick one. Compiled code is unaffected.
0.10.1 - 2026-06-26: correct shared-host tee routing
Patch release. The built-in test-output tee now prefers the test resolved at emit time and uses the captured writer only as a per-test fallback, so a tee reused across many tests routes each test's records to its own report. No public API change.
Fixed
TestOutputLogCollectorBuilder.CreateTeedandFakeLoggingBuilderExtensions.AddTeedFakeLoggingbuilt-in tee preferred its construction-captured writer over the test resolved at emit time, so a tee built once and reused across tests (a host shared across many tests using the built-in tee) routed every test's records to the first test's report. It now prefers the emit-time test and falls back to the captured writer only when no test is current, so a background-thread record is still teed in the per-test case while a reused provider routes each test correctly.
0.10.0 - 2026-06-26: drop-free per-test test-output tee
Minor release. The built-in test-output tee now mirrors background-thread records to the per-test report instead of dropping them. No public API change; additive and behavior-improving. Built against TUnit 1.56; pin TUnit at or above that version when adopting so an older runtime does not mismatch.
Changed
TestOutputLogCollectorBuilder.CreateTeedandFakeLoggingBuilderExtensions.AddTeedFakeLoggingbuilt-in tee now binds to the owning test's output writer, captured when it is configured on the test's own thread, so a record logged on a background thread is mirrored to the per-test report rather than dropped. A logging host shared across many tests, where no single owning test can be captured, still passes its own correlation-aware tee provider and keeps emit-time resolution. No API change.- Documented polling the collector with TUnit's
Eventually/WaitsForto assert on logs produced by background or pump-driven work, and that a previously hand-rolledAddTeedFakeLoggingextension should be removed to avoid an ambiguous-call collision.
0.9.0 - 2026-06-15: ILoggingBuilder tee helpers with a pluggable tee provider
Minor release. Adds ILoggingBuilder extensions that wire a FakeLogCollector into a logging builder you already own, with a seam to plug in your own display or correlation provider. Additive; the existing API is unchanged.
Added
FakeLoggingBuilderExtensions.AddTeedFakeLogging(this ILoggingBuilder, FakeLogCollector, LogLevel, ILoggerProvider?)(inLogAssertions.TUnit) registers the capture provider, sets the builder's minimum level, records the capture floor so the vacuous-HasNotLogged()guard applies, and adds a tee provider: the suppliedteeProviderwhen given, otherwise the built-inTestOutputLoggerProvider. Supplying a provider lets a host wire its own background-thread-correlating tee (for example TUnit'sCorrelatedTUnitLoggerProvider) without this package taking a dependency on it. Use it when the self-containedCreateTeedtuple does not fit because the capture must live inside an existing builder, such as an ASP.NET Core test host.FakeLoggingBuilderExtensions.AddFakeLogging(this ILoggingBuilder, FakeLogCollector, LogLevel)registers the capture provider and capture floor without a tee, for builders that do not mirror to the test report.
Changed
TestOutputLogCollectorBuilder.CreateTeednow delegates toAddTeedFakeLogginginternally, so the tuple-shaped and builder-shaped helpers share one wiring definition. Behavior is unchanged.
0.8.0 - 2026-06-14: capture-floor guard and live test-output tee
Minor release. Adds a guard against a vacuous below-floor HasNotLogged(), a live tee into the TUnit test report, and documentation for logging code under coverage. Additive.
Added
TestOutputLogCollectorBuilder.CreateTeed(LogLevel)(inLogAssertions.TUnit) returns the same wired(factory, collector)asLogCollectorBuilder.Create, plus a provider that mirrors each record toTestContext.Current.Outputas it is logged, so a test's logs appear inline in the TUnit HTML report instead of an empty panel. Records logged on a background thread (whereTestContext.Currentdoes not flow) are still captured but not teed. Use the plainLogCollectorBuilder.Createfor log-heavy soak tests where buffering every record in the per-test output is undesirable.
Changed
- Added guidance not to use
NullLogger(or a provider-less factory) for services whose log statements are guarded byif (logger.IsEnabled(...))under coverage: the guard then only ever takes its false branch and that branch's coverage silently drops. Includes a copy-pasteable enabled, non-capturingDiscardingLoggerProvider. - Documented that chained
WithScopePropertyfilters match each key independently across all active scopes (not necessarily the same scope), with the single-scope and subset alternatives. - Noted that
FakeLogCollectorgrows without bound; scope it per test and never hold one in a sharedstatic readonlyfield. - Bumped
PackageValidationBaselineVersionfrom0.6.3to0.7.0on both packages so ApiCompat strict-mode validates0.8.0against the most recently published baseline.
Fixed
HasNotLogged()no longer passes vacuously below the capture floor.LogCollectorBuilder.Create(minimumLevel)sets the floor on the logger factory, but the collector did not know it, soHasNotLogged().AtLevel(Trace)(or any level filter restricted to levels below the floor) passed for the wrong reason: nothing at that level was ever captured. The collector's floor is now recorded, and such an assertion fails with a message naming the floor and the level it targeted. Assertions at or above the floor, and collectors not built byLogCollectorBuilder, are unaffected.
0.7.0 - 2026-06-11: key-existence scope-property assertion
Minor release. Adds a key-only WithScopeProperty(string key) overload for asserting a scope property is present without asserting its value. Purely additive.
Added
WithScopeProperty(string key)onLogFilterand on theHasLogged()/HasNotLogged()chain asserts that a record carried a scope property atkey, regardless of its value (anullvalue still counts as present). The existing two-argument overloads assert a value; this one asserts only presence. It covers the common case of a scope whose value is set at the production log site and is not visible to the test, such as a caller-info scope (CallerFile/CallerLine/CallerMemberfrom[CallerFilePath]/[CallerLineNumber]/[CallerMemberName]). Pairs withHasNotLogged().WithScopeProperty(key)to assert a scope key was never attached.
Changed
- README adds a "Prefer structured matchers over substring matching" guide under the migration section: a table mapping common
.Containing("...")shapes to the structured matcher that is more resilient (.WithProperty,.WithException<T>,.WithScopeProperty,.WithMessageTemplate,HasLoggedSequence), with the rationale that substring matching couples a test to rendered message text that rots when a template is reworded. - Bumped
PackageValidationBaselineVersionfrom0.6.1to0.6.3on both packages so ApiCompat strict-mode validates0.7.0against the most recently published baseline. The new overload is recorded as an additive difference inCompatibilitySuppressions.xml.
0.6.3 - 2026-06-06: release notes sourced from the CHANGELOG
Tooling release. No library API or behavior change.
Changed
- The release workflow now publishes the matching
CHANGELOG.mdsection as the GitHub release body (body_path), so release notes carry the full hand-written detail instead of GitHub's auto-generated commit summary.
0.6.2 - 2026-06-05: documentation refresh
Documentation-only release. No API or behavior change.
Changed
- Refreshed the README (plain-ASCII punctuation) and rewrote the shared
CONVENTIONS.md: removed the version-history preamble so it reads as a conventions document, not a changelog.
0.6.1 - 2026-06-04: README roadmap and family-roster documentation
Changed
- README roadmap label corrected: the "Possible v0.6.0+ (longer horizon, no commitment)" backlog heading was renamed to "Possible v0.7.0+" now that v0.6.0 has shipped.
- README "Shipped in v0.6.0" section added: documents the v0.6.0 surfaces (
WithoutException(), the typedWithProperty<T>overloads, the typedWithScopeProperty<T>overloads) and the removal of the obsolete single-argWithExceptionMessage(string), matching the existing "Shipped in v0.5.0 / v0.4.0 / v0.3.0" entries. - README family roster completed: the package-family enumerations in the root and adapter READMEs were brought to the full seven-package roster.
0.6.0 - 2026-06-02: WithoutException, typed property and scope filters, obsolete-overload removal
BREAKING
- The single-arg
WithExceptionMessage(string substring)overload (onLogFilterand on theHasLoggedAssertion/HasNotLoggedAssertion/HasLoggedSequenceAssertionchain),[Obsolete]since v0.4.0, has been removed. Append, StringComparison.Ordinalto existing call sites to keep the previous behavior, or pass a different comparison for case-insensitive / culture-aware matching. See### Removedbelow.
Added
LogFilter.WithoutException()and the matchingWithoutException()chain method onHasLoggedAssertion/HasNotLoggedAssertion/HasLoggedSequenceAssertion: matched records whoseExceptionisnull. The complement ofWithException(). Lets a test assert the deliberate absence of an exception (for example a record logged at warning or error level with no exception object attached) without the.Where(r => r.Exception is null)escape hatch.LogFilter.WithScopeProperty<T>(string key, T value)andLogFilter.WithScopeProperty<T>(string key, Func<T, bool> predicate)plus the matching chain methods: typed scope-property filters. Scope values keep their runtime type, so the value form compares typed-to-typed viaEqualityComparer<T>.Defaultand the predicate form receives the typed value; a scope value whose runtime type is notTnever matches. Removes the object-boxing boilerplate of the existing object-typed overloads when the scope value is a known type such asGuidorint.LogFilter.WithProperty<T>(string key, T value)andLogFilter.WithProperty<T>(string key, Func<T, bool> predicate)(where T : IParsable<T>) plus the matching chain methods: typed structured-state filters.FakeLogRecordstores structured-state values as their formatted strings, so the stored string is parsed back toTviaIParsable<T>.TryParseusingCultureInfo.InvariantCulturebefore comparing (value form) or applying the predicate; an absent or non-parsable value never matches. Removes the manualint.TryParse(...)boilerplate at the call site. TheIParsable<T>constraint keeps the round-trip reflection-free and AOT-safe.
Changed
TUnit/TUnit.Assertions/TUnit.Coredependency bumped1.44.0->1.48.6. Taken for family lockstep; the sibling adapters are on the same pin.
Removed
LogFilter.WithExceptionMessage(string substring)and the matching single-argWithExceptionMessage(string)chain method onHasLoggedAssertion/HasNotLoggedAssertion/HasLoggedSequenceAssertion: the implicit-Ordinaloverloads,[Obsolete]since v0.4.0, are gone. The explicit-comparison overloadWithExceptionMessage(string substring, StringComparison comparison)is unchanged. Migrate by appending, StringComparison.Ordinalto existing call sites. Removing a public member is a source-and-binary breaking change; see### BREAKINGabove.
0.5.0 - 2026-05-14: log snapshot renderer and rendering options
Added
LogAssertions.Render.LogSnapshotRenderer.Render(FakeLogCollector, LogSnapshotOptions?): rendered every captured record as deterministic multi-line text suitable for snapshot comparison. Each record renders as a[NN] level category "message"header followed by optional indentedstate:,scope:, andexception:detail lines; records are separated by a single blank line; an empty collector renders asstring.Empty. Lines are terminated with the literal LF byte (neverEnvironment.NewLine) so a baseline committed on one OS stays valid for test runs on every other. Output is a stable, pin-able contract, unlikeLogAssertionRenderingwhose output is explicitly not stable. The renderer emits captured text verbatim; volatile values are the snapshot layer's concern, composed viaSnapshotAssertions.Scrubbersat theMatchesSnapshot()call site.LogAssertions.Render.LogSnapshotOptions: controlled rendering viaLevelStyle(Abbreviation/Full),CategoryStyle(LeafOnly/Full),ScopeStyle(Include/Exclude),ExceptionStyle(TypeAndMessage/StackTracePlaceholder/Full).LogSnapshotOptions.Defaultexposed the all-default instance.LogAssertions.Render.LogLevelStyle,CategoryStyle,ScopeStyle,ExceptionStyle: enums backing the options above.
Changed
- Dependency refresh to bring pins back into lockstep with the rest of the assertion family (
TimeAssertionsv0.4.0 moved the baseline forward on 2026-05-13):DotNetProjectFile.Analyzers1.13.1 -> 1.14.0Meziantou.Analyzer3.0.78 -> 3.0.84Microsoft.Extensions.Diagnostics.Testing10.5.0 -> 10.6.0Microsoft.SourceLink.GitHub10.0.203 -> 10.0.300
README.md: added a "Pin the full log sequence as a snapshot" cookbook section showing the two-lineAssert.That(LogSnapshotRenderer.Render(collector)).MatchesSnapshot()composition and theLogSnapshotOptionssurface.
0.4.1 - 2026-05-12: dependency refresh and source-style ratchet
Changed
- Dependency refresh to the family-lockstep versions:
TUnit/TUnit.Assertions/TUnit.Core1.43.11 -> 1.44.0Microsoft.CodeAnalysis.BannedApiAnalyzers3.3.4 -> 4.14.0Meziantou.Analyzer3.0.72 -> 3.0.78SnapshotAssertions.TUnit0.2.0 -> 0.3.0
- Production-code source style ratcheted via
MeziantouAnalysisMode=all-warningsforsrc/projects (path-conditional via the normalizedReplace('\\','/').Contains('/tests/')predicate). Test projects retain Meziantou defaults. Findings surfaced and fixed at source: CRTP self-cast pattern centralized via anUnsafe.As<TSelf>(this)helper property; discrete-value equality comparisons modernized to pattern-matching form (is 0,is SequenceStepKind.Simple); trailing-nullconstructor arguments given explicitAnyOrderSubSteps:named-argument form; culture-sensitive string concatenations switched tostring.Create(CultureInfo.InvariantCulture, ...). BannedSymbols.txt: collapsed bare#comment lines into adjacent text-bearing lines so the file parses cleanly under the stricterBannedApiAnalyzers4.x grammar.
0.4.0 - 2026-05-07: inner-exception filters, ThenAnyOrder, DumpVerbosity, banned-API enforcement
Added
LogFilter.WithInnerException<TInner>()andLogFilter.WithInnerExceptionMessage(string substring, StringComparison comparison): matched records whoseException.InnerExceptionis assignable toTInner(or whose inner-exception message contains a substring under the supplied comparison). Walks one level only; designed for the gRPC / RPC pattern where a transport exception wraps the underlying domain exception once.LogFilter.WithExceptionMessage(string substring, StringComparison comparison)overload: explicit-comparison variant of the legacyWithExceptionMessage(string). The single-arg overload is now[Obsolete]and removed in v0.6.0; see### Deprecatedbelow.LogFilter.WithScopeProperties(IDictionary<string, object?>): subset match across all active scopes for the record. Each key/value pair must match in some scope; different pairs may match in different scopes. The dictionary is snapshotted on construction; mutating the input afterwards does not affect the filter.DumpVerbosityenum +DumpTo(TextWriter, DumpVerbosity)overload: three levels:Compact(headlines only),Default(existing one-liner detail),Verbose(Defaultplus full exceptionToString()including stack trace). The no-arg overload is unchanged (Default).HasLoggedAssertion.WithInnerException<TInner>()andWithInnerExceptionMessage(string substring, StringComparison comparison): chain methods onHasLoggedAssertion/HasNotLoggedAssertion/HasLoggedSequenceAssertion. Compose with the existingWithException<T>()filter via the chain (noLogFilter.All(...)boilerplate needed).HasLoggedAssertion.WithExceptionMessage(string substring, StringComparison comparison): chain-method overload on the same assertion classes; paired with the same overload onLogFilter.HasLoggedAssertion.WithScopeProperties(IDictionary<string, object?>): chain method on the same assertion classes.FakeLogCollectorTUnitInspectionExtensions.DumpToTestOutput(DumpVerbosity)overload: the no-arg overload now delegates to this withDumpVerbosity.Default.HasLoggedSequence.ThenAnyOrder(params Action<HasLoggedSequenceAssertion>[]): concurrent step group. All sub-steps must match somewhere in the remaining records, but the order among them is unconstrained. Records that match no sub-step are skipped. Sub-steps are matched via backtracking, so an order-independent valid assignment is found if one exists: broad filters never starve more specific filters. Sub-step configurators must add filters only; calls toThen()or nestedThenAnyOrder()from within a configurator throwInvalidOperationException. TheThen()chain still works for strictly-ordered next steps before / after aThenAnyOrdergroup.Microsoft.CodeAnalysis.BannedApiAnalyzerswired into bothsrc/projects with a sharedBannedSymbols.txtat the repo root, codifying the no-reflection convention fromCONVENTIONS.mdas a build-time error (RS0030 +TreatWarningsAsErrors=true). Test projects do not reference the banned-symbols file so reflection-using helpers (e.g.PublicApiGenerator) stay usable in tests.
Changed
- Dependency refresh to latest stable for every direct and analyzer dependency:
TUnit/TUnit.Assertions/TUnit.Core1.43.2 -> 1.43.11Microsoft.Extensions.Diagnostics.Testing10.0.0 -> 10.5.0PublicApiGenerator11.4.6 -> 11.5.4Microsoft.Sbom.Targets3.0.1 -> 4.1.5Microsoft.SourceLink.GitHub8.0.0 -> 10.0.203DotNetProjectFile.Analyzers1.12.2 -> 1.13.1Meziantou.Analyzer2.0.219 -> 3.0.72Microsoft.VisualStudio.Threading.Analyzers17.13.61 -> 17.14.15Roslynator.Analyzers4.13.1 -> 4.15.0SonarAnalyzer.CSharp10.24.0.138807 -> 10.25.0.139117
CONVENTIONS.mdupgraded to v0.2: codified family-wide rules (trailingCancellationToken ct = defaulton every new async API,Task.Delay(TimeSpan, TimeProvider, ct)for polling loops, the 100/200/400/800/1000ms exponential schedule for time-based polls, the# <Package> snapshot v<N>header convention forToSnapshotString(), TFM policy, and the "Verify is not promoted" stance).README.md: added a "Pair with" section cross-referencingTimeAssertions.TUnitandSnapshotAssertions.TUnit; added the> **Scope:** Test projects only. Not intended for production code.blockquote at the top of the per-package short README.
Deprecated
LogFilter.WithExceptionMessage(string substring)and the corresponding chain method onHasLoggedAssertion: single-arg overloads now carry[Obsolete(error: false)]. They default toStringComparison.Ordinaland delegate to the new explicit-comparison overload. Will be removed in v0.6.0 (two-minor cycle). Migrate by appending, StringComparison.Ordinalto existing call sites, or pick a different comparison if you want case-insensitive / culture-aware behavior.
0.3.0 - 2026-05-02: GetMatch/GetMatches terminators and DumpToTestOutput
Added
HasLoggedAssertion.GetMatch()/GetMatches(): value-returning terminators on theHasLogged()chain. Eliminated the duplicatecollector.Filter(...)call after asserting: hand the matched record(s) directly to a follow-up TUnit assertion.GetMatch()requires the chain's count expectation to constrain the match count to exactly one (typically viaOnce()orExactly(1), but any terminator with both bounds equal to one is accepted, includingBetween(1, 1)); throwsInvalidOperationExceptionupfront for any other expectation.GetMatches()accepts any terminator and returns the matched records as a defensiveIReadOnlyList<FakeLogRecord>snapshot captured at evaluation time.FakeLogCollectorTUnitInspectionExtensions.DumpToTestOutput(): TUnit-aware variant of the framework-agnosticDumpTo(TextWriter)that routes captured records toTestContext.Current.Output.StandardOutput. Eliminated theusing var sw = new StringWriter(); collector.DumpTo(sw); Console.WriteLine(sw)boilerplate during test development. ThrowsInvalidOperationExceptionwith a clear diagnostic if called outside a TUnit test context.
Changed
TUnitdependency bumped1.41.0->1.43.2(latest stable). AddsTUnit.Coreas a directPackageReferencetoLogAssertions.TUnit(needed forTestContext.CurrentinDumpToTestOutput). The publishedLogAssertions.TUnit.nupkgtherefore declaresTUnit.Core 1.43.2andTUnit.Assertions 1.43.2as direct dependencies; consumers who pin a lower TUnit version will see the standard NuGet upgrade prompt on install.README.md: added a "TUnit-native conveniences" section documentingBecause("reason")(the correct reason-annotation API;.WithMessage(...)is a separate predicate-on-failure-message feature for negative-testing patterns),Assert.Multiple()aggregating failures from our chains,[NotInParallel]guidance (FakeLogCollectoris instance-scoped: tests usingLogAssertions.TUnitdo NOT need[NotInParallel]for the collector itself),Should()interop (deferred pending the upstreamTUnit.Assertions.Shouldpackage reaching stable), and a Troubleshooting section covering shorthand resolution failures on pre-0.2.2 versions,LogCollectorBuildernot found (missingusing LogAssertions;), andIDE0005noise on per-file imports under strict-analysis projects.README.md: corrected v0.2.4 typos in the "Migrating from manual assertions" section:InScope("RequestId", id)->WithScopeProperty("RequestId", id),WithExceptionOfType<TimeoutException>()->WithException<TimeoutException>().
0.2.4 - 2026-05-02: README disposal and migration guidance
Changed
README.md:GlobalUsings.csrecommendation extended withglobal using System;so the first.Containing(text, StringComparison.X)call in a project that does not enable<ImplicitUsings>compiles without an extra per-file import.README.md: Quick Start gained a "Lifetime / disposal" paragraph clarifying what theIDisposablereturned fromLogCollectorBuilder.Create()owns. Disposing thefactorystops new records from being captured but the records already gathered into thecollectorsnapshot remain valid: the collector can be queried after theusingblock exits. Both block-form (using (factory) { ... }) and declaration-form (using ILoggerFactory factory = ...) are explicitly endorsed.README.md: new "Migrating from manual assertions" section with a side-by-side before / after pairing a manualcollector.GetSnapshot()+ LINQ filter + multiple separate assertions against one fluent chain, calling out the two non-obvious wins on top of readability (failure message renders the full captured-records snapshot; the same chain extends to scopes, structured properties, exception types, and combinator nodes without restructuring the test).
0.2.3 - 2026-05-01: README namespaces and terminator guidance
Changed
README.md: new "Namespaces" section explaining the asymmetricusingsituation: the assertion entry points (HasLogged,HasLoggedOnce,AssertAllAsync, ...) live inTUnit.Assertions.Extensionsand auto-import via TUnit's mechanism, whileLogCollectorBuilder,LogFilter,ILogRecordFilter, and theFakeLogCollectorinspection extensions live inLogAssertionsand require an explicitusing LogAssertions;. Includes a recommendedGlobalUsings.cssnippet that consolidates both for any consuming test project.README.md:AssertAllAsynccookbook example updated to use the v0.2.1 sync overload (c => c.HasLogged()...) as the primary form. The verboseasync c => await c.HasLogged()...form is mentioned as a fallback for cases that mix non-assertion async work between checks.README.md:.And/.Orsection rewritten. Confirms.Andis useful for chaining a positive + negative invariant in one expression, and explicitly says.Oris rarely useful for log assertions (considerMatchingAny(...)over filters instead). RecommendsAssertAllAsyncfor three-or-more conditions.README.md:Never()vsHasNotLogged()clarification added to the terminators section: preferHasNotLogged()when "this should not happen" is the test's primary intent; use.Never()when an existing positive filter chain ends up needing a zero-count expectation.
0.2.2 - 2026-05-01: shorthand extensions moved to TUnit.Assertions.Extensions
Changed
- BREAKING:
HasLoggedShorthandExtensions(theHasLoggedOnce,HasLoggedExactly,HasLoggedAtLeast,HasLoggedAtMost,HasLoggedBetween,HasLoggedNothing,HasLoggedWarningOrAbove,HasLoggedErrorOrAboveentry points) moved from namespaceLogAssertions.TUnitto namespaceTUnit.Assertions.Extensions. The shipped assembly (LogAssertions.TUnit.dll), package ID, and method signatures are unchanged. Only the public namespace of the static class moved. Consumers that used the shorthands as the extension methods they were meant to be require no code change: the methods now resolve via TUnit's auto-importedTUnit.Assertions.Extensionsnamespace, so any file that already callsHasLogged()will also pick upHasLoggedOnce()etc. without changes. Migration for fully-qualified call sites: replaceLogAssertions.TUnit.HasLoggedShorthandExtensions.HasLoggedOnce(source)withTUnit.Assertions.Extensions.HasLoggedShorthandExtensions.HasLoggedOnce(source).
0.2.1 - 2026-05-01: sync AssertAllAsync overload
Added
AssertAllExtensions.AssertAllAsyncnew overload acceptingFunc<IAssertionSource<TActual>, Assertion<FakeLogCollector>>(sync return) instead ofFunc<IAssertionSource<TActual>, Task>(awaited delegate). Drops theasync/awaitboilerplate from every entry. The compiler picks the right overload based on the lambda's return type:Assertion<T>for the new sync form,Taskfor the existing async form. Failure-aggregation semantics are identical between the two overloads. The existing overload remains for cases where the lambda needs to mix in non-assertion async work.
Changed
PackageValidationBaselineVersionpinned to0.2.0on both packages (was0.1.0forLogAssertions.TUnit; not previously set onLogAssertionsbecause 0.2.0 was its first release). ApiCompat baseline checks now compare against the previous shipped version of each package.
0.2.0 - 2026-05-01: framework-agnostic core package and filter combinators
Added
LogAssertionsnew framework-agnostic core package, first published at 0.2.0. ContainsILogRecordFilter, theLogFilterfactory, the four internal filter primitives (PredicateFilter,AndFilter,OrFilter,NotFilter),LogAssertionRendering(failure-snapshot rendering),LogCollectorBuilder.Create, and theFakeLogCollectorinspection extensions (Filter,CountMatching,DumpTo).LogAssertions.TUnitnow declares a transitive dependency onLogAssertions. Existing v0.1.0 consumers need no migration:LogAssertions.TUnitis still the package to install;LogAssertionscomes transitively.ILogRecordFilterinterface +LogFilterstatic factory: composable filter primitives. Every built-in chain method (AtLevel,Containing, etc.) creates one of these internally; users can build reusable filters and inject them via the newWithFilter(ILogRecordFilter)chain method.WithFilter,MatchingAny,MatchingAll,Not: combinator chain methods acceptingILogRecordFilter(orparams ILogRecordFilter[]). TheLogFilterfactory exposes the same asLogFilter.All,LogFilter.Any,LogFilter.Not.AtAnyLevel(params LogLevel[]): matched any level in the supplied set.Matching(Regex): regex match against the formatted message.ContainingAll(StringComparison, params string[])andContainingAny(StringComparison, params string[]).WithException()(parameterless: any non-null exception) andWithException(Func<Exception, bool>)(predicate).WithEventIdInRange(int min, int max): inclusive range match.WithLoggerName(string): alias forWithCategory.NotContaining(string, StringComparison),NotAtLevel(LogLevel),ExcludingCategory(string),ExcludingLevel(LogLevel): negation shortcuts.When(bool condition, Action<TSelf> apply): conditional sub-chain configuration for parameterised tests.HasLoggedOnce(),HasLoggedExactly(int),HasLoggedAtLeast(int),HasLoggedAtMost(int),HasLoggedBetween(int, int),HasLoggedNothing(),HasLoggedWarningOrAbove(),HasLoggedErrorOrAbove(): top-level shorthand entry points wrapping the equivalentHasLogged()...X()chain.AssertAllExtensions.AssertAllAsync(...): batch terminator that runs N independent assertions against the same collector and aggregates failures into a singleAssertionException(analogous to TUnit'sAssert.Multiple, scoped to log assertions).LogCollectorBuilder.Create(LogLevel): one-line factory returning a wired(ILoggerFactory, FakeLogCollector)tuple. Replaced the 3-4 lines of boilerplate every test would otherwise need.FakeLogCollector.Filter(params ILogRecordFilter[]),CountMatching(params ILogRecordFilter[]),DumpTo(TextWriter): inspection extensions.LogAssertionRendering: public static class exposing the failure-snapshot rendering helpers (AppendCapturedRecords,LevelAbbreviation) so users can build their own diagnostic surfaces with the same format. Format itself is not stable (see README "Stability promise").
Changed
- BREAKING:
LogAssertionBase<TSelf>annotated[EditorBrowsable(Never)]and documented as not for external derivation. The CRTP + sealed-derived-classes pattern requires the type to remainpublic(C# does not allow public classes to inherit from internal classes), so this is the strongest signal we can send while keeping the source generator working. The README "Stability promise" section spells out which surfaces are stable vs implementation detail.
Removed
- BREAKING:
LogAssertionBase<TSelf>.AddPredicate(Func<FakeLogRecord, bool>, string): removed. Replaced byprotected virtual void AddFilter(ILogRecordFilter)as part of the internal refactor. Only matters for consumers that were deriving fromLogAssertionBase(which was already documented as unsupported).
0.1.0 - 2026-05-01: initial release, log assertions for TUnit
Added
HasLogged(): positive assertion entry point onFakeLogCollector, defaults to "at least 1 matching record".HasNotLogged(): negative assertion entry point onFakeLogCollector, fixed at "zero matching records".HasLoggedSequence(): order-preserving sequence-matching entry point withThen()step terminator.AtLevel(LogLevel),AtLevelOrAbove(LogLevel),AtLevelOrBelow(LogLevel): level filters chained on the assertion entry points.Containing(string, StringComparison)(comparison explicit by design),WithMessage(Func<string, bool>),WithMessageTemplate(string)(matches the pre-substitution template viaMicrosoft.Extensions.Logging's{OriginalFormat}entry): message filters.WithException<TException>(),WithExceptionMessage(string): exception filters. (WithExceptionMessage(string, StringComparison)overload added in v0.4.0; legacy single-arg overload[Obsolete]until v0.6.0.)WithProperty(string key, string? value)(ordinal),WithProperty(string key, Func<string?, bool> predicate)(predicate over formatted value): structured-state filters.WithScope<TScope>()(by scope type),WithScopeProperty(string key, object? value)(object.Equalson scope-property value),WithScopeProperty(string key, Func<object?, bool> predicate): scope filters. Recognizes dictionary scopes andLoggerMessage.DefineScopescopes; anonymous-object scopes intentionally not supported (would require reflection, breaking AOT).WithCategory(string),WithEventId(int),WithEventName(string): identity filters.Where(Func<FakeLogRecord, bool>): escape-hatch filter.Once(),Exactly(int),AtLeast(int),AtMost(int),Between(int, int),Never(): six terminators onHasLogged().- Failure-message snapshot rendering with 4-character level abbreviations matching the
Microsoft.Extensions.Loggingconsole formatter (trce,dbug,info,warn,fail,crit,none), indentedprops:line listing each record's structured properties (excluding the{OriginalFormat}entry, already implied by the message line), indentedscope:line rendering each active scope's content askey=valuepairs (orToString()for opaque scopes), and indentedexception:line with type name and message. .And/.Orchaining via TUnit'sAssertion<T>base class.