Tags: mvanhorn/TUnit
Tags
+semver:minor - feat: add TUnit.Assertions.Should package (thomhurst#… …5785) * feat: add TUnit.Assertions.Should package Source-generated FluentAssertions-style `Should()` syntax over TUnit.Assertions without duplicating any assertion logic. - `TUnit.Assertions.Should` runtime: IShouldSource<T>, ShouldSource<T> (struct entry), ShouldCollectionSource<T> (collection entry with element-typed instance methods like BeInOrder/All/Any), ShouldDelegateSource<T> (delegate entry exposing Throw/ThrowExactly), ShouldAssertion<T>, ShouldContinuation<T>, ShouldName/ShouldEntryPoint attributes. - `TUnit.Assertions.Should.SourceGenerator`: scans referenced assemblies for [AssertionExtension] classes, applies a conjugation table (Is->Be, IsNot->NotBe, Has->Have, DoesNot->Not, Does->[strip], 3rd-person -s drop), emits IShouldSource extensions wrapping the original assertion in ShouldAssertion. Skips assertions whose Should{Name}Extensions is already baked in another reference, so user- authored assertions still get fresh emission. - 87 runtime tests covering value/string/collection/datetime/delegate types, And/Or chaining (incl. mixed-combiner exception), Assert.Multiple scopes, Because messages, element-type inference for assorted collection shapes, user-defined assertion emission. Multi-targets net8/9/10. * fix: address PR thomhurst#5785 review feedback - Remove ShouldEntryPointAttribute (was never wired up; the generated extensions live on IShouldSource<T> not T, so they never collided with FluentAssertions in the first place — only the hand-written Should() overloads do, and the attribute couldn't suppress those) - Link EquatableArray<T> from TUnit.Core.SourceGenerator/Models/ instead of carrying a 4th copy in this repo - Strip dead method-level covariance branch and the unused NeedsMethodLevelCovariance / TypeParamIsTypeParameter / TypeParamConstraintName fields on AssertionData; emit always uses IShouldSource<typeParam> direct now - Skip generating extensions for collection assertions whose Should counterpart is a hand-crafted instance method on ShouldCollectionSource<T> (CollectionIsInOrderAssertion, CollectionAllAssertion, etc.) so the two paths can't drift or shadow each other - Add NullSourceTests covering null string/list sources to verify the null-forgiving propagation in ShouldCollectionSource produces meaningful AssertionException rather than NRE * feat: discover [GenerateAssertion]/[AssertionFrom]/hand-written extensions Switch ShouldExtensionGenerator from class-scan ([AssertionExtension] only) to extension-method-scan: walk all public static extension methods on IAssertionSource<T> whose return type derives from Assertion<TReturn>, and emit a Should-flavored counterpart when the method is a "simple factory" — i.e. its non-CAE parameters map 1-to-1 by type onto a public constructor of the return type after the leading AssertionContext<TReturn>. This unifies four assertion sources under one path: - classes with [AssertionExtension] (already worked) - methods with [GenerateAssertion] (e.g. int.IsZero, Range.IsAll) - types decorated with [AssertionFrom<T>] (e.g. AssemblyAssertionExtensions) - hand-written extensions following the same shape Methods that don't fit the factory template (context mapping like ImplicitConversionEqualityExtensions.IsEqualTo<TValue, TOther>, regex chain hops, etc.) are silently skipped — they couldn't be wrapped without inspecting the body anyway, so users keep using Assert.That for those. Also propagates [DynamicallyAccessedMembers] on type parameters and forwards [UnconditionalSuppressMessage("Trimming", ...)] from the source method so trim-analyzer warnings don't leak (Lazy<T>, IsParsableInto<T>, etc.). GenerateAssertionTests covers the new path: BeZero/NotBeZero/BeEven/BeOdd on int/double, BeTrue/BeFalse on bool, BeAll on Range. 297 tests pass on net8/9/10. * ci: wire TUnit.Assertions.Should into pipeline as beta package - RunAssertionsShouldTestsModule runs the new test project per TFM via the shared TestBaseModule - GetPackageProjectsModule picks up the new package so PackTUnitFilesModule packs and UploadToNuGetModule pushes it - Adds "TUnit.Assertions.Should" to the BetaPackages set so its version is suffixed with "-beta" until the API is considered stable, matching the TUnit.Mocks family's release cadence * refactor: address /simplify review findings - Generator: skip referenced assemblies that don't transitively reference TUnit.Assertions before walking namespaces. Cuts the per-compilation cost from "every type in every BCL/NuGet assembly" to a small set of TUnit-aware references — single biggest perf win for the generator. - Generator: wrap CollectMethods result in EquatableArray<MethodData> so the top-level incremental cache catches no-op compilations. ImmutableArray<T> uses reference equality which was defeating the cache at the boundary. - Generator: cache the AssertionContext`1 symbol on CollectionContext and use SymbolEqualityComparer in HasMatchingConstructor instead of the name+namespace string comparison. - Generator: promote remaining inline attribute name strings ("UnconditionalSuppressMessageAttribute", "DynamicallyAccessedMembersAttribute") to consts alongside the others. - ShouldCollectionSource / ShouldDelegateSource: mark public ctors [EditorBrowsable(Never)] for consistency with ShouldAssertion — these are intended for internal-by-convention call sites. - ShouldCollectionSource: rewrite the ExpressionBuilder seeded by CollectionAssertion's "Assert.That({expr})" prefix to the Should-flavored "{expr}.Should()" form so failure messages from collection assertions match the value-path style. Pinned by a new test in CollectionTests. * feat: source-generate ShouldCollectionSource instance methods Replaces the hand-written 7-method list (BeInOrder/BeInDescendingOrder/All/Any/ HaveSingleItem/HaveSingleItem(predicate)/HaveDistinctItems) with a generator path that scans the wrapped CollectionAssertion<TItem>'s public instance methods returning Assertion<X>-derived types and emits Should-flavored counterparts. Mechanism: - New [ShouldGeneratePartial] marker attribute applied to the wrapper class. - Generator finds the single Assertion<T>-derived field (convention: _inner) and walks its base chain for instance methods. - Each method passes the same simple-factory filter the extension-method-scan uses (return-type ctor matches non-CAE method params); matches emit a partial method calling new ReturnType(Context, args). - Wrapped return-type names feed into a skip-set that prevents the main extension-method-scan from emitting dead-code extensions (replaces the hardcoded InstanceMethodAssertions HashSet). Coverage: ~10 auto-generated methods vs the previous 7 hand-written. New auto-emitted methods include HaveCount(int), HaveDistinctItems(IEqualityComparer), NotContain(...), Contain(predicate), and the various ctor overloads of Contain. Three methods (All, Any, HaveSingleItem(predicate)) stay hand-written: their target ctors take a `predicateDescription` string filled from the CAE expression with a literal fallback — a 1-method-param-to-2-ctor-params shape the simple-factory filter rejects. Methods with method-level generic parameters (IsAssignableTo<T>, IsTypeOf<T>, etc.) are skipped — emitting them needs propagating type-arg references through the return type's generic args, deferred for v1. Adds tests for HaveCount, Contain(predicate). 306 tests pass on net8/9/10. * feat(analyzers): extend existing analyzers to recognise Should syntax Three TUnit.Assertions analyzers now also fire for the Should-flavored entry surface so users of value.Should().X() get the same compile-time guidance as Assert.That(value).X(): - AwaitAssertionAnalyzer (TUnitAssertions0002) recognises TUnit.Assertions.Should.ShouldExtensions.Should as an assertion entry, so unawaited Should chains (silent no-ops) are flagged. - MixAndOrOperatorsAnalyzer (TUnitAssertions0001) recognises IShouldSource<T> alongside IAssertionSource<T>, and walks ShouldAssertion<T> alongside Assertion<T>, so mixed And/Or chains from the Should chain raise the diagnostic at compile time (runtime behaviour was already covered by ChainingTests.Mixed_And_Or_throws). - IsNotNullAssertionSuppressor recognises NotBeNull() reached via a TUnit-namespace Should() extension, suppressing CS8602/etc. on the asserted variable. Symbol-based namespace checks ensure we don't suppress for unrelated user-defined Should()/NotBeNull() pairs. All three checks are scoped to TUnit namespaces by either fully-qualified method names or symbol-based ContainingType comparison — unrelated Should() extensions in other libraries (e.g. FluentAssertions, custom user code) don't trigger any of the diagnostics. Test infrastructure: ShouldAnalyzerTests covers the three new paths plus a namespace-scoped negative case. The analyzer-test framework references net9.0 BCL (Microsoft.CodeAnalysis.Testing 1.1.2) so the netstandard2.0 build of TUnit.Assertions.Should is copied into the test bin and loaded as a metadata reference to avoid CS1705 against System.Runtime v10. Pre-existing AwaitAssertionAnalyzerTests failures on main are not addressed. * refactor(analyzers): align Should-aware analyzer code with existing patterns - IsNotNullAssertionSuppressor: replace the bespoke ContainingType.ToDisplayString() comparison with the existing GloballyQualifiedNonGeneric() extension every other analyzer in the project uses. Collapses IsTUnitAssertThat / IsTUnitShould into a single IsTUnitMethod helper parameterised by the fully-qualified name. - AnalyzerTestHelpers / Verifiers: lift GetCompatibleShouldDllPath into AnalyzerTestHelpers as the single source of truth for resolving the netstandard2.0 build of TUnit.Assertions.Should compatible with the test framework's net9.0 reference assemblies. Both AnalyzerTestHelpers metadata blocks and the verifier now route through it; previously the verifier had its own copy and AnalyzerTestHelpers loaded the net10.0 build directly, which would have triggered CS1705 for any future suppressor test. - AnalyzerTestHelpers: collapse redundant `#elif NET9_0` / `#elif NET10_0_OR_GREATER` branches both returning Net90. - ShouldAnalyzerTests: drop the class-level XML doc that restated the test names. - AwaitAssertionAnalyzer / MixAndOrOperatorsAnalyzer: replace the leftover Roslyn-template "sample analyzer that reports the company name" doc comments with accurate descriptions of what each analyzer does. * docs: add Should syntax page New docs/docs/assertions/should-syntax.md (sidebar position 1.5) covers the optional TUnit.Assertions.Should package: installation, an Assert.That ↔ Should() comparison table, the conjugation rules (Is->Be, Has->Have, DoesNot->Not, 3rd-person -s drop, [ShouldName] override), entry points for value/collection/delegate, chaining, Assert.Multiple, Because messages, how user-defined assertions automatically get Should counterparts, analyzer support, FluentAssertions coexistence, and known limitations. Cross-link from the Assertions getting-started page so users coming in via the default flow discover the Should alternative; new line in README's package table flagging Should as an optional beta. * fix: address PR review — CI sln, generator tests, ShouldNameAttribute Three blocking items from the latest PR review: 1. TUnit.CI.slnx now lists all four new projects (Should runtime + tests, SourceGenerator + tests). The CI workflow builds and tests exclusively from this file, so without these entries the runtime tests never ran in CI and the package wouldn't have been built or published through the pipeline. Mirrors the structure used in TUnit.slnx. 2. TUnit.Assertions.Should.SourceGenerator.Tests gets an in-memory compilation harness covering five generator scenarios: - Simple [AssertionExtension]-style class -> conjugated extension method - Generic <TValue> assertion -> method-level generic propagation - [ShouldName(...)] override wins over auto-conjugation - Is*/IsNot* prefix -> Be*/NotBe* - [CallerArgumentExpression] is forwarded to the generated method The harness compiles inline source snippets, runs the generator, and string-matches key tokens in the emitted source. Doesn't use Verify-style .verified.txt snapshots — the existing TUnit.Assertions.SourceGenerator.Tests uses the same string-content style. 3. ShouldNameAttribute.Negated removed. The property was parsed by the generator but always discarded — emitting a negated form requires correlating two extension methods to one assertion class, which the method-scan path doesn't do. The natural double-emission from [AssertionExtension(NegatedMethodName = "...")] already handles negated forms via independent auto-conjugation (Contains -> Contain; DoesNotContain -> NotContain). Documented in the attribute remarks: to override a negated name, place a separate [ShouldName] on the negated assertion class. * fix: address PR round 5 — docs example + simplify NameConjugator Two items from the latest review: 1. docs/docs/assertions/should-syntax.md "Custom names" example used ShouldName(..., Negated = "...") which was removed in 2830fed. Replaced with two patterns: a single-class override (BeAnOddNumber example) and the separate-class pattern for TUnit assertions like EqualsAssertion/NotEqualsAssertion where positive and negated forms live on different classes. 2. NameConjugator.Conjugate now returns string instead of (string, bool). The Matched flag was advertised in the doc as a hook for an unimplemented TUSHOULD001 diagnostic and discarded at every call site — it was misleading dead state. Unrecognised names pass through unchanged, which is correct forward-compat behaviour: callers can layer [ShouldName] overrides on top to handle individual assertions whose names don't match any rule. Both call sites in ShouldExtensionGenerator updated. 5 generator tests and 102 runtime tests still pass. * chore: round 6 cleanup — drop unused packages, kill empty if block Three non-blocking items from the latest review: - Removed unused Verify, Verify.TUnit, xunit, and xunit.runner.visualstudio package references from TUnit.Assertions.Should.SourceGenerator.Tests.csproj. All tests use TUnit syntax via Microsoft.Testing.Platform; the xunit and Verify packages were template carry-over and added unnecessary transitive dependencies. - Removed the empty `if (!isCurrentAssembly)` block in CollectWrapper. The comment-only block had a future reader trap — the actual recording happens unconditionally below via builder.Add(...). Comment moved to the recording site so the intent is co-located with the code. * fix(should-gen): handle -es endings in name conjugator `MatchesRegex` was conjugating to `MatcheRegex` because TryDropTrailingS only ever dropped one letter. Recognise English -es third-person endings (sibilants ch/sh/ss/x/z plus -o) and drop both letters; plain -es (e.g. `Writes`) still drops one. Adds direct unit tests for NameConjugator covering the full conjugation table; tighten CollectionContext (record -> sealed class — it's a mutable bag, not a cache key). * test(should-gen): add Verify snapshots for generator output Per CLAUDE.md, source generator output requires Verify-based snapshot tests with committed .verified.txt files. The existing token-level Contains assertions remain as explicit guard-rails; the snapshots additionally lock down whitespace, ordering, and global:: prefix correctness across the full emitted surface. Multi-targets net8.0/net9.0/net10.0 for cross-TFM coverage of the generator host. net472 dropped: the generator output is determined by the input compilation, not the consumer runtime, so cross-TFM snapshots are identical text — and the in-memory compilation harness can't resolve user-declared generic assertions reliably against the .NET Framework BCL, which produced spurious snapshot diffs. * fix(should): render generic exception type names without backtick mangling `typeof(MyException<int>).Name` returns "MyException`1" — that mangled form was leaking into Should-flavored failure messages from Throw<T> / ThrowExactly<T>. The Should layer now strips the arity suffix and recurses into generic args so the call site portion of the expression reads "Throw<MyException<Int32>>()". The inner assertion's "Expected to throw…" prefix still comes from TUnit's own ThrowsAssertion and uses Type.Name; that's a separate concern in the core library. * docs(should): clarify analyzer extension match, Clear() coverage, Does conjugation caveat Round 10 review feedback: - IsNotNullAssertionSuppressor.FindShouldInChain: add a comment at the call site explaining that parentName must stay null (Should is an extension method; receiver is arbitrary). Prevents future contributors from constraining it and breaking the suppressor for user-defined entry points. - ShouldCollectionSource: cross-reference Failure_message_uses_Should_flavored_expression from the ExpressionBuilder.Clear() comment so that any drift in CollectionAssertion's base ctor surfaces immediately via the regression test. - should-syntax.md: document that the Does* strip rule can produce awkward names for non-verb auxiliaries (DoesRun -> Run, DoesLoad -> Load), and that [ShouldName] is the escape hatch for those cases. * perf(should-gen): cache per-reference walk by MetadataReference The generator's per-keystroke walk over every assertions-referencing assembly is the hot path for IDE responsiveness. Roslyn typically reuses the same MetadataReference instance across compilations (until the underlying assembly is rebuilt), so a static ConcurrentDictionary<MetadataReference, ReferenceData> turns the cross-assembly scan into an O(1) lookup once each reference has been seen. Cache entries store raw walk results; the union dedup is applied at merge time so cache validity isn't sensitive to changes in other references. Also fixes: - FormatDefaultValue: numeric defaults now emit the C# type suffix (1.5F, 1L, 1.5M, etc.). Previously a `float` parameter with default `1.5f` would have generated `= 1.5` (a double literal) and failed to compile. Forced invariant culture so comma-decimal locales don't produce malformed literals like `1,5F`. - MixAndOrOperatorsAnalyzer: drop redundant IsShouldAssertionType check. ShouldAssertion<T> implements IShouldSource<T>, so it's already matched by the isAssertionSource branch of the await guard. * fix(analyzer-tests): load netstandard2.0 builds to avoid CS1705 silently breaking symbol resolution The analyzer-test framework (Microsoft.CodeAnalysis.Testing 1.1.2) targets net9.0 reference assemblies. Loading TUnit.Core / TUnit.Assertions via typeof(...).Assembly.Location resolves to the test process's net10.0 build, which references System.Runtime v10. Roslyn raises CS1705 (referenced assembly has a higher version), which the verifier suppresses via `CompilerDiagnostics = None`. That suppression silently broke symbol resolution for every assertion extension method ("IsEqualTo", "Throws", etc.) — analyzers found nothing to flag and tests reported "expected 1 diagnostic, actual 0" with no compiler errors surfaced. Switch to loading the netstandard2.0 builds (which target netstandard 2.0 — compatible with both Net90 ref assemblies and the test runtime) by copying them into each test project's bin and routing every typeof(...).Assembly.Location through a GetCompatibleDllPath helper that prefers the copy. Also adds Microsoft.Bcl.AsyncInterfaces 9.0.0 to the analyzer verifier's reference set so test sources that use IAsyncEnumerable resolve cleanly under the netstandard2.0 TUnit.Core typeforward. Net change across analyzer test projects: - TUnit.Analyzers.Tests: 358 -> 0 failures (692 pass, 1 skip) - TUnit.AspNetCore.Analyzers.Tests: 16 -> 0 failures (25 pass) - TUnit.Assertions.Analyzers.Tests: 34 -> 0 failures (75 pass) - TUnit.Assertions.Analyzers.CodeFixers.Tests: 5 -> 0 failures (18 pass) * fix(should-gen): swap reference cache to ConditionalWeakTable to avoid IDE memory leak ConcurrentDictionary keyed on MetadataReference pinned stale entries indefinitely when Roslyn replaced a MetadataReference instance after the underlying assembly was rebuilt. In long IDE sessions with frequent rebuilds, that accumulated unbounded memory. ConditionalWeakTable uses weak keys so entries become eligible for GC the moment Roslyn drops the reference. Pulled the per-reference scan into a separate ScanReference helper so the GetOrComputeReferenceData entry point stays focused on cache lookup + insertion. ConditionalWeakTable lacks a TryAdd in netstandard2.0; concurrent races between two compilations seeing the same uncached reference are caught via ArgumentException (both compute identical ReferenceData; first writer wins). * ci(pipeline): wire Should test modules into the release gate RunEngineTestsModule is the gate before UploadToNuGetModule. It already depends on RunAssertionsTestsModule and RunSourceGeneratorTestsModule, but lacked dependencies on the new Should test modules. A Should regression — value, generator, or analyzer — could ship in a tagged release without blocking. - Added [DependsOn<RunAssertionsShouldTestsModule>] for the consumer test project. - New RunAssertionsShouldSourceGeneratorTestsModule covers the generator snapshot tests in TUnit.Assertions.Should.SourceGenerator.Tests (separate project from TUnit.Core.SourceGenerator.Tests, which RunSourceGeneratorTestsModule covers). Multi-targets net8.0/net9.0/net10.0 to match the test project's TFMs. Also: - docs/should-syntax.md: rename `async` local variable to `asyncFunc` (avoids the contextual-keyword shadow that some editors flag with CS1948). - ShouldExtensions.Should(string?): drop the unnecessary `value!` suppressor — AssertionContext<string> accepts string? so the suppressor was contradicting the parameter type. Zero behaviour change. * fix(assertions): IsAll -> IsFullRange; propagate Obsolete/EditorBrowsable through generators Range.IsAll's Should-flavored conjugation reads as "Range.All.Should().BeAll()" — the "all-range" context is lost and "be all of what?" is the natural reader question. Renamed to IsFullRange, which conjugates cleanly to BeFullRange. The old IsAll is kept for backwards compatibility, marked [Obsolete("Use IsFullRange instead.")] and [EditorBrowsable(Never)]. For the deprecation to actually surface on the user-callable API, both generators needed to learn to forward [Obsolete] and [EditorBrowsable]: - MethodAssertionGenerator (TUnit.Assertions.SourceGenerator) now propagates these attributes from the [GenerateAssertion]-decorated source method onto the generated extension method. Without this, [Obsolete] on a file-scoped assertion method is a no-op since users only see the generated extension. - ShouldExtensionGenerator captures the same attributes from the extension method it scans and emits them on the Should-flavored counterpart. So deprecating IsAll automatically deprecates BeAll. Updated the Should consumer test from BeAll -> BeFullRange. Updated the PublicAPI verified snapshots to capture the new IsFullRange surface and the Obsolete attribute on IsAll. * refactor(analyzer-tests): centralize GetCompatibleDllPath; document analyzer flow-insensitivity Reviewer flagged that the netstandard2.0 fallback resolution helper was duplicated across four analyzer test projects (TUnit.Analyzers.Tests, TUnit.AspNetCore.Analyzers.Tests, TUnit.Assertions.Analyzers.Tests, TUnit.Assertions.Analyzers.CodeFixers.Tests). The duplication makes the workaround's "why" comment fragile and forces any future change to be applied in four places. Extracted the helper into SharedTestHelpers/AnalyzerTestCompatibility.cs, linked into each test csproj via <Compile Include="..\SharedTestHelpers\..." Link="Shared\..." />. This matches the existing CovarianceHelper / EquatableArray linked-source pattern in the repo. The previous wrappers (GetCompatibleCoreDllPath / GetCompatibleAssertionsDllPath / GetCompatibleShouldDllPath) now delegate to the shared helper instead of carrying their own copy of the resolution logic. Also documents the flow-insensitivity of WasAssertedNotNull (statement-order match; not control-flow aware) and the IsAwaited walk (false positive on split-variable patterns like `var src = value.Should(); await src.X();`). Reviewer flagged these as known limitations; full dataflow analysis is significant complexity for a niche imprecision case, and a comment beats a hidden surprise. * refactor(should): drop ExpressionBuilder.Clear coupling; centralize generator helpers Round 15 review pointed out that ShouldCollectionSource constructed an inner CollectionAssertion solely to extract its AssertionContext, then overwrote the context's seed text with the Should-flavored entry expression. The _inner field held dead state, the value! null-bang signaled the abuse, and a future change to CollectionAssertion's seed format would silently corrupt failure messages. Refactored ShouldCollectionSource to construct AssertionContext directly: var sb = new StringBuilder(...); sb.Append(expression ?? "?").Append(".Should()"); Context = new AssertionContext<IEnumerable<TItem>>(value, sb); ShouldGeneratePartialAttribute now declares the wrapped type explicitly via [ShouldGeneratePartial(typeof(CollectionAssertion<>))] rather than the source generator scanning private fields. C# 11 generic attributes were the natural fit but cannot take an enclosing type parameter as a type argument (CS8968), so the workaround is the open-generic typeof form — the source generator closes the type by substituting the wrapper class's type parameters. The previous wrapper-vs-extension dedup filtered any extension whose return type matched a wrapper method's inner assertion type. That filter was over-aggressive: the simple-factory wrapper template only emits methods whose source overload exactly matches a public ctor, so overloads with optional/default parameters landed only on the extension surface — and the filter was removing those even though instance methods take precedence at call sites and both can co-exist without ambiguity. Removed. Also centralizes [Obsolete] / [EditorBrowsable] formatters into a shared TUnit.Assertions.SourceGenerator/Shared/AttributeForwardingFormatters.cs linked by both generator projects, so the format definitions can't drift. PR description updated to remove the stale Negated and ShouldEntryPoint references and add an accurate summary of the current shape. * refactor: drop redundant GetCompatibleDllPath wrappers; document round-16 deferrals Round 16 review feedback: - Per-project GetCompatibleDllPath wrappers (one in each of the four analyzer test projects) added no value over the SharedTestHelpers/AnalyzerTestCompatibility helper they delegated to. Removed. Callers invoke the shared helper directly. - ShouldAssertion<T> as `sealed class`: documented why it isn't `readonly struct`. GetAwaiter forwards to Assertion<T>'s state-machine awaiter, which mutates instance state during evaluation; a struct would either copy the mutation away from the receiver or have to box through IShouldSource<T>. The single per-call allocation is the conscious cost; entry types stay structs because they're pure routers. - AwaitAssertionAnalyzer.IsAwaited: comment now spells out the split-variable false positive shared by both Assert.That and Should() paths so users hitting it know it's a known imprecision rather than a bug in their code. - ReferencesAssertionsAssembly: documented the one-level-deep filter — safe because IAssertionSource<T> lives in TUnit.Assertions, so any assembly declaring extensions on it must directly reference TUnit.Assertions. - ShouldDelegateSource.FormatTypeName: documented that runtime CLR type names ("Int32") are emitted instead of C# aliases ("int") because no Roslyn is available at runtime; asymmetry vs the source-generator's display-format output is acceptable for failure messages on (rare) generic exception types. - ShouldExtensionGenerator.Initialize: documented the SyntaxProvider follow-up — current-compilation walk re-runs per keystroke; the cross-assembly path is already absorbed by the ConditionalWeakTable cache. Deferred to a follow-up because the refactor is non-trivial and the per-keystroke walk is bounded. * fix(should): address review edge cases * fix(should): handle z-ending verbs * fix(should): address review blockers * test(should): cover assertion failures * test(should): cover failing entry shapes * fix(ci): align analyzer test references
refactor(opentelemetry): depend on TUnit.Core instead of umbrella TUn… …it (thomhurst#5774) * refactor(opentelemetry): depend on TUnit.Core instead of umbrella TUnit Inverts the cross-assembly contract for external span ingestion so TUnit.OpenTelemetry no longer pulls in TUnit.Engine + TUnit.Assertions + Microsoft.Testing.Extensions transitively. - Move SpanData/SpanEvent/SpanLink/ReportKeyValue POCOs to TUnit.Core. - Add ExternalSpanSink hook in TUnit.Core (Action<SpanData>? slot, Interlocked.CompareExchange register/unregister, first-wins). - ActivityCollector registers IngestExternalSpan as the sink during Start/Stop. OtlpReceiver pushes through the sink instead of calling ActivityCollector.Current directly. - Drop dead InternalsVisibleTo for TUnit.OpenTelemetry from TUnit.Engine.csproj. * review: Volatile.Read on ExternalSpanSink.Current; register SpanLink in JSON context Addresses PR thomhurst#5774 review feedback: - ExternalSpanSink.Current now uses Volatile.Read so weak memory models (ARM) cannot observe a stale null after Register publishes the sink. - HtmlReportJsonContext explicitly registers SpanLink for consistency with the other peer DTOs (was reachable transitively via SpanData.Links).
fix(assertions): gate IsEqualTo<TValue, TOther> overload to net9+ (th… …omhurst#5765) (thomhurst#5767) * fix(assertions): gate IsEqualTo<TValue, TOther> overload to net9+ (thomhurst#5765) The IsEqualTo<TValue, TOther> / IsNotEqualTo<TValue, TOther> overloads added in 1.40.0 (thomhurst#5751) rely on [OverloadResolutionPriority(-1)] to lose to the source-generated single-generic overload when both apply. That attribute is only honored by C# 13+ and only present in System.Runtime.CompilerServices on .NET 9+. On the net8.0 / netstandard2.0 builds of TUnit.Assertions, Polyfill silently drops the attribute and every same-type IsEqualTo call becomes ambiguous (CS0121), breaking effectively every existing test suite. Gating the new overloads behind #if NET9_0_OR_GREATER means net8.0 / netstandard2.0 consumers fall back to the original well-defined overload with no contest. net9.0+ consumers using a modern SDK keep the wrapper Value Object support introduced in thomhurst#5720. Public API snapshots for net8.0 and netstandard2.0 (Net4_7) updated to drop the gated overloads. Issue5720Tests gated to match. Added Issue5765 regression test covering same-type IsEqualTo across enum/primitive/record. * chore: trim verbose comments and test names from thomhurst#5765 fix Per simplify review: - Shrunk the gate-rationale comment in ImplicitConversionEqualityExtensions from 9 lines to 4 — keep the WHY, drop the narration. - Replaced the duplicate gate-rationale block in Issue5720Tests with a one-line pointer to the source file. - Dropped the redundant XML <summary> on Issue5765Tests (sibling IssueNNNNTests files don't carry one) and the "_Compiles_And_Passes" suffix on each test name.
+semver:minor - perf: skip TimeoutHelper wrap when no explicit [Timeo… …ut] is set (thomhurst#5711) (thomhurst#5728) * perf(engine): skip TimeoutHelper wrap when no explicit [Timeout] is set (thomhurst#5711) Before: every test ran through TimeoutHelper.ExecuteWithTimeoutAsync using the built-in 30-minute default, paying a linked CTS + TCS + Task.WhenAny cost per test for a ceiling almost no one relies on. After: TestBuilder no longer seeds TestDetails.Timeout from the default, and TestCoordinator only feeds TestExecutor a non-null timeout when either a [Timeout] attribute set TestDetails.Timeout, or the user opted into a session-wide default via TUnitSettings.Timeouts.DefaultTestTimeout. TestExecutor's existing null-timeout fast path then invokes the test directly with no wrapping overhead. User-visible behavior is preserved: [Timeout(...)] still enforces its duration, and programmatic DefaultTestTimeout overrides are tracked via a new internal DefaultTestTimeoutExplicitlySet flag so they continue to apply to every test. * docs(timeout): clarify unset-DefaultTestTimeout behavior; drop issue refs from comments - DefaultTestTimeout XML doc now spells out that leaving the property unset skips the timeout wrapper entirely for tests without [Timeout]; the 30-minute fallback only applies once the property is assigned. - Remove thomhurst#5711 markers from TimeoutSettings and TestCoordinator comments (issue numbers belong in commit/PR messages, not source). - Short-circuit TUnitSettings.Default.Timeouts lookup in TestCoordinator when [Timeout] already set TestDetails.Timeout, keeping the static singleton access off the common path. * refactor(timeout): collapse explicit-set bool into nullable backing field Replaces the dual-state (TimeSpan _defaultTestTimeout + bool _defaultTestTimeoutExplicitlySet) with a single TimeSpan? backing field. The public getter still reports TimeSpan.FromMinutes(30) as the documented default; the new internal ExplicitDefaultTestTimeout exposes the raw optional so the engine's wrap-skip path reads "null means unset" directly via a null-coalescing chain. Addresses the non-blocking review suggestion on thomhurst#5728 and cleans up the style inconsistency Codacy flagged. * test(engine): cover ExplicitDefaultTestTimeout fallback in TestCoordinator Adds three unit tests for the `test.Timeout ?? ExplicitDefaultTestTimeout` coalesce in TestCoordinator.ExecuteTestLifecycleAsync: - unset DefaultTestTimeout keeps ExplicitDefaultTestTimeout null (no wrap) - setter assigns the explicit override - feeding the override into TimeoutHelper fires TimeoutException on hangs * test(settings): preserve unset DefaultTestTimeout across snapshot/restore SnapshotSettings read through the public DefaultTestTimeout getter, which coalesces the null backing field to 30 minutes. RestoreSettings then wrote that value back through the validating setter, permanently pinning the field after the first test in the process ran. Every later test that relied on ExplicitDefaultTestTimeout being null inherited a 30-minute ceiling it never asked for. - Add internal SetExplicitDefaultTestTimeout(TimeSpan?) that accepts null. - Snapshot via ExplicitDefaultTestTimeout; restore via the new setter. - Add a round-trip test on TUnitSettings.Default to guard the regression. * fix(engine): classify DefaultTestTimeout failures as timeouts, not errors When a test times out via TUnitSettings.Default.Timeouts.DefaultTestTimeout (rather than a [Timeout] attribute), the resolved timeout was not written back to TestDetails.Timeout. TUnitMessageBus.GetFailureStateProperty gates TimeoutTestNodeStateProperty emission on TestDetails.Timeout != null, so those failures slipped through to ErrorTestNodeStateProperty and downstream reporters (JUnit XML / GitHub / HTML) labelled them as generic errors. Propagate the resolved timeout onto TestDetails.Timeout at the coordinator so the classification guard sees a non-null value. Adds an end-to-end integration test under TUnit.TestProject/Bugs/5728 and a RunOptions hook that lets engine tests thread environment variables into the spawned TestProject process. * test(5728): gate discovery hook on filter string, not env var CI runs TUnit.TestProject under Microsoft.Testing.Platform's test-host-controller mode (enabled by --hangdump), which does not reliably propagate arbitrary env vars from the CliWrap parent through to the actual test host on all runners. The hook's env-var gate therefore never tripped in CI and the timeout stayed at its default, so the 10s Task.Delay ran to completion and the integration test saw Outcome="Completed" instead of "Failed". Switch the gate to a filter-string match. As part of this, fix BeforeTestDiscoveryContext.TestFilter to carry the actual filter pattern (via FilterParser.StringifyFilter) instead of the filter object's ToString() — the previous value was just the TreeNodeFilter class name, which made GlobalContext.TestFilter comparisons (e.g. PropertySetterTests.IsMatchingTestFilter) silently always-false. Also drop the now-unused WithEnvironmentVariable plumbing on InvokableTestBase/RunOptions. * test(5728): read filter from GlobalContext.Current in discovery hook The BeforeTestDiscoveryContext parameter's required-init TestFilter came through empty on CI (both reflection and AOT paths under MTP's test-host-controller mode), so the gate's substring check failed and the 200ms DefaultTestTimeout was never applied — the 10s Task.Delay ran to completion and the integration test saw Outcome="Completed". GlobalContext.Current is installed by TUnitTestFramework with the stringified filter before any hooks run and survives the controller + AOT codepaths. * fix(engine): make GlobalContext.Current process-wide, not AsyncLocal The previous AsyncLocal-backed Current getter mutated the AsyncLocal value on first read (`Contexts.Value ??= new GlobalContext()`), which poisoned that async branch with a fresh empty instance. Any code path that touched GlobalContext.Current before TUnitTestFramework's later `Current = ...` assignment would keep seeing the empty instance — including discovery hooks that ran on async branches not descended from the assignment site (parallel hook execution, MTP test-host-controller mode under --hangdump, etc.). GlobalContext is the session-wide root by definition; per-async-context isolation is meaningless for it (each MTP test-host is a separate process). Switch the backing store to a static field initialized via LazyInitializer so every thread/branch observes the same instance once installed. Also hoist the framework's GlobalContext.Current/BeforeTestDiscoveryContext. Current/TestDiscoveryContext.Current/TestSessionContext.Current assignments to before InitializeAsync runs, so background work spawned during init can see the populated contexts rather than the lazy fallback. This unblocks the gating hook in DefaultTimeoutClassificationHooks (and any other Before(TestDiscovery) hook that needs to read TestFilter) under CI's test-host-controller hangdump mode. * diag(5728): emit hook execution markers to bisect CI failure The Hanging_Test still fails on macOS after the AsyncLocal→static fix — subprocess runs the full 10s, meaning the 200ms DefaultTestTimeout never takes effect. Two possibilities remain: 1. The hook isn't being invoked at all in AOT/CI mode 2. The hook fires but the filter gate fails (filter is something other than the TreeNodeFilter string we expect) Add three Console.Error markers — fired-on-entry, gate-fail, gate-pass — so the next CI run shows which path the hook actually takes. The actual gate logic is untouched so other TUnit.TestProject runs continue to skip this hook. * Revert "diag(5728): emit hook execution markers to bisect CI failure" This reverts commit 8343c32. * fix(engine): pass explicit factory to LazyInitializer for AOT safety The factory-less `LazyInitializer.EnsureInitialized<T>(ref T)` overload calls `Activator.CreateInstance<T>()` internally, which is annotated `[DynamicallyAccessedMembers(PublicParameterlessConstructor)]`. GlobalContext only has an internal constructor, so the AOT trimmer may not preserve it under that codepath. Per CLAUDE.md "All code must work with Native AOT" — pass an explicit factory so the call site is the one the trimmer roots. * chore: trim verbose comments on GlobalContext + TUnitTestFramework * test: drop flaky DefaultTimeoutClassification engine + fixture The integration test relies on a [Before(TestDiscovery)] hook in TestProject to gate on filter substring and programmatically set DefaultTestTimeout in the spawned subprocess. The gate held up locally on Windows reflection mode but consistently failed across ubuntu/macos/windows CI runners in both Reflection and AOT mode after multiple iterations: - env-var → BeforeTestDiscoveryContext.TestFilter → GlobalContext.Current - AsyncLocal-poisoning fix on GlobalContext (now landed) - AOT-safe LazyInitializer factory (now landed) - Hoisted Current assignments before InitializeAsync (now landed) CI's test-host-controller path under --hangdump still drops the gate, so the 200ms timeout is never applied, the 10s hanging test runs to completion and the engine test sees Outcome="Completed". Coverage for the underlying behaviour remains: - TUnitSettingsTests.Configured_Default_Timeout_Fires_On_Hanging_Test exercises TimeoutHelper end-to-end with the resolved DefaultTestTimeout. - The TestCoordinator classification fix is a four-line propagation whose correctness is reviewable from the call site. Production code (TestCoordinator + TimeoutSettings) is unchanged.
+semver:minor - perf(engine): reduce message-bus contention on test s… …tart (thomhurst#5685) (thomhurst#5695) * perf(engine): reduce message-bus contention on test start (thomhurst#5685) Two fixes targeting the 12.95% exclusive CPU cost in WaitHandle.WaitOneNoCheck observed in ~1000-test profiles: 1. InProgress is now awaited in TestCoordinator.ExecuteTestInternalAsync instead of fire-and-forget. Back-pressure from MTP's bounded AsynchronousMessageBus channel now spreads publishes across each test task rather than fanning 1000+ concurrent PublishAsync writers into the channel at once. Ordering is preserved (InProgress still published before the terminal Passed/Failed/Skipped/Cancelled message per test). 2. EventReceiverOrchestrator gains RegisterClassInstanceReceiver, a targeted single-object registration used by TestInitializer.PrepareTest in place of the previous full-iteration RegisterReceivers call. Only the freshly created ClassInstance can newly become an event receiver between the initial registration (pre-instance) and PrepareTest (post-instance), so the second iteration over attributes/arguments/etc. was pure overhead. * review: address thomhurst#5695 feedback — drop issue refs, trim doc * review: document RegisterClassInstanceReceiver precondition * review: address thomhurst#5695 round 3 — skipped guard, comment, dead param * review: remove unused CancellationToken from RegisterReceivers
fix(telemetry): remove duplicate HTTP client spans (thomhurst#5668) * fix(telemetry): remove duplicate HTTP client spans Strip span synthesis from both HTTP propagation handlers. .NET's System.Net.Http ActivitySource already emits a properly-shaped client span for real-socket traffic (Aspire), and the ASP.NET Core server span carries HTTP semconv tags for in-memory WAF traffic — synthesizing a second client span produced duplicate rows in trace timelines. Both handlers collapse to a single call to a shared HttpActivityPropagator that only injects traceparent + baggage so the SUT can correlate requests to the originating test. TUnitTestCorrelationProcessor now tags in OnEnd as well as OnStart: spans with a remote-context parent (ASP.NET Core server spans created from extracted traceparent) receive baggage via the propagator after StartActivity returns, so OnStart alone couldn't see it — the previous topology masked this by tagging the synthesized client span instead. Public-API impact: AspireHttpSourceName removed (shipped only in the unreleased thomhurst#5666); AspNetCoreHttpSourceName marked [Obsolete] for binary compatibility. * fix(telemetry): address review feedback on TryTag + polling race - TUnitTestCorrelationProcessor: replace Activity.Current fallback with TraceRegistry.GetContextId keyed on the activity's own TraceId. The previous ambient-current fallback could cross-attribute a span to a different concurrent test if the span was stopped on a thread whose Activity.Current had swung to another test's context. Trace-id lookup is bound to the span itself and can't mis-attribute. - AutoConfigureOpenTelemetryTests: polling loop now uses a monotonic deadline instead of CancellationToken threaded into Task.Delay, so timeout surfaces as "no tagged span found" rather than a TaskCanceledException. - Added Processor_FallsBackToTraceRegistry_WhenActivityHasNoBaggage covering the new fallback path. * style: use Activity.Current via System.Diagnostics import Minor consistency nit surfaced in review — match the import style used by the Aspire handler. * docs: tighten HttpActivityPropagator visibility + OnEnd note - HttpActivityPropagator.Inject: public -> internal. Class is internal, so the method visibility was redundant; explicit internal matches other internal helpers in TUnit. - TUnitTestCorrelationProcessor: add a remarks paragraph explaining that OnEnd tag writes are visible to deferred-serialization exporters (BatchExportProcessor, InMemoryExporter). Synchronous exporters need this processor to be registered before them to observe the tag. * refactor(aspire): drop TUnitBaggagePropagationHandler, use runtime DiagnosticsHandler The Aspire handler injected traceparent before SocketsHttpHandler's internal DiagnosticsHandler got to do it, so outgoing requests carried the test body's span ID instead of the runtime-emitted client span ID. SUT server spans then parented to test body as siblings of the client span rather than as children, breaking the standard OTel client/server waterfall. The fix is to stop injecting. AspireFixture.CreateHttpClient now returns new HttpClient(new SocketsHttpHandler { SslOptions = ... }) and lets the runtime handle everything: - DiagnosticsHandler creates the client Activity - DistributedContextPropagator.Current.Inject emits traceparent+baggage against that client Activity's span ID (W3C) - baggage walks the parent chain so tunit.test.id flows to the SUT - SUT server span parents correctly under the client span Also subscribes the test-runner's TracerProvider to System.Net.Http so the runtime-emitted client span is actually exported — without this the span is created but not visible on dashboards, leaving server spans with orphan parents in cross-process traces. Users with TUNIT_KEEP_LEGACY_PROPAGATOR=1 no longer get the W3C baggage belt-and-braces emission that the handler did; the runtime uses whatever propagator is configured, which is the correct respect-the-opt-out behavior. Verified end-to-end against Jaeger: the Aspire trace-demo test now produces a clean 11-span waterfall with proper client/server pairing at every HTTP boundary. * style: trim CreateHttpClient/AutoStart comments; narrow _httpHandler type Drop the WHAT explanation of what the runtime's DiagnosticsHandler does in AspireFixture.CreateHttpClient — the behavior is already documented on the method's XML summary and is self-evident from the code. Drop the WHAT sentence at AutoStart's AddSource("System.Net.Http"), keep only the WHY (orphan-parent server spans without it). Narrow `_httpHandler` field from `HttpMessageHandler?` to `SocketsHttpHandler?` — accurate to what's actually stored, makes SslOptions/PooledConnectionLifetime accessible without casts if we need them later. * docs: call out SimpleExportProcessor ordering in correlation example
feat(aspire): emit client spans for HTTP (thomhurst#5666) * feat(aspire): emit client spans for HTTP Restore normal client/server trace topology for\nAspireFixture.CreateHttpClient requests so dashboards show\na real outbound client span instead of flattening under\nthe ambient test span.\n\nRefs thomhurst#4818 * fix(http): align span error handling Record transport exceptions on synthesized HTTP client spans\nand align status handling with current OTel HTTP semconv:\n3xx stays unset, 4xx/5xx set Error with error.type.\n\nAlso expand Aspire and ASP.NET Core handler tests to cover\ntraceparent format, baggage formatting, status semantics,\nand thrown inner-handler failures. * refactor(http): reuse shared exception helper * test(http): clarify fallback span coverage * fix(http): align client span names
fix(asp-net): forward disposal in FlowSuppressingHostedService (thomh… …urst#5651) (thomhurst#5652) FlowSuppressingHostedService implements IHostedLifecycleService but did not implement IDisposable or IAsyncDisposable. When the DI container disposed the host, the container only saw the non-disposable wrapper, so the inner service's Dispose / DisposeAsync was never called, leaking unmanaged resources silently. Add both IDisposable and IAsyncDisposable to the wrapper, forwarding to the inner service. DisposeAsync prefers the async path on inner and falls back to sync Dispose when inner is IDisposable only. Dispose forwards only when inner implements IDisposable; async-only inner services are not disposed on the synchronous path, matching the "never block on async" rule in CLAUDE.md. Callers with async-only resources should use DisposeAsync (or await using on the owning factory). Regression coverage: 7 pure-unit-tests in HostedServiceDisposalForwardingTests covering single-interface, dual-interface, async-only-on-sync-dispose no-op, and non-disposable inner cases. Verified stable over 10 consecutive runs (70/70 assertion successes). Fixes thomhurst#5651
feat(playwright): propagate W3C trace context into browser contexts (t… …homhurst#5636) * feat(playwright): propagate W3C trace context into browser contexts Seed each BrowserContext created via BrowserTest.NewContext with traceparent/baggage HTTP headers from the current test's Activity so backend spans and logs correlate to the originating test. Opt-out via the new PropagateTraceContext virtual property. User-supplied ExtraHTTPHeaders win on key conflict. * chore(playwright): address PR feedback on trace propagation - Drop defensive is-pattern in propagator callback (carrier type is known). - Document why TryBuildBaggageHeader is called explicitly after Inject — matches the rationale in ActivityPropagationHandler / TUnitBaggagePropagationHandler. - Document netstandard2.0 no-op behavior on PropagateTraceContext.
+semver:minor - fix(pipeline): isolate AOT publish outputs to stop cl… …obbering pack DLLs (thomhurst#5622) (thomhurst#5624) * fix(pipeline): isolate AOT publish outputs to stop overwriting packable bin DLLs (thomhurst#5622) AOT publish of TUnit.Mocks.Tests / TUnit.NugetTester writes into the same bin/Release/{tfm}/ tree as the initial CI build. The re-compile drops AssemblyVersion back to 1.0.0.0 because CI only passes -p:AssemblyVersion to the top-level `dotnet build` command. PackTUnitFilesModule runs with --no-build and packages whichever DLL the race leaves behind, shipping strong-name-mismatched packages (CS1705 on net8.0 consumers). Redirect each AOT publish into bin/aot-{tfm}/ + obj/aot-{tfm}/ so the re-compile cannot touch the pristine 1.36 DLLs that pack consumes. * fix(ci): isolate TestProject AOT publish from main bin/Release (thomhurst#5622) Same hardening as the pipeline AOT modules — redirect build outputs to bin/aot-testproject/ so pack can never pick up a DLL that was rewritten by this publish step. Today it happens to produce matching-version DLLs because -p:AssemblyVersion is passed explicitly, but defensive isolation removes the dependency on those props flowing correctly.
PreviousNext