feat(assertions): IsEqualTo with implicitly-convertible wrappers (#5720)#5751
feat(assertions): IsEqualTo with implicitly-convertible wrappers (#5720)#5751
Conversation
… T (#5720) Restores the wrapper-vs-primitive ergonomic so e.g. `Assert.That(productCode).IsEqualTo("Example")` works for Value Object wrappers around string/int/etc. - Adds generic `IsEqualTo<TValue, TOther>` and `IsNotEqualTo<TValue, TOther>` overloads that detect a user-defined implicit operator at runtime and compare the converted value. The reflection lookup is cached per type-pair. - Defines them as partial of the source-generated `EqualsAssertionExtensions` / `NotEqualsAssertionExtensions` so `[OverloadResolutionPriority(-1)]` actually disambiguates the call when the source and expected types match (priority is only consulted between overloads in the same containing type). - Updates `AssertionExtensionGenerator` to emit `public static partial class` so the partials can extend the generated class. - F# does not honour `OverloadResolutionPriority`, so two F# tests now call the int-specific extension method directly.
Not up to standards ⛔🔴 Issues
|
| Category | Results |
|---|---|
| CodeStyle | 2 minor |
🟢 Metrics 31 complexity
Metric Results Complexity 31
NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.
There was a problem hiding this comment.
Code Review: IsEqualTo with implicitly-convertible wrappers
Summary
The PR adds generic IsEqualTo<TValue, TOther> and IsNotEqualTo<TValue, TOther> overloads that discover an implicit conversion operator at runtime via reflection (cached per type-pair), then compares the converted value. The source generator is updated to emit partial classes so hand-written partials can live alongside generated ones, allowing [OverloadResolutionPriority(-1)] to work within the same type.
Overall the design approach is sound — caching reflection, using partial classes for OverloadResolutionPriority, and applying [RequiresUnreferencedCode] properly. However there are a few issues to address before merge.
Issues
Bug: FindImplicitOperator fallback searches in the wrong direction
File: TUnit.Assertions/Extensions/ImplicitConversionEqualityExtensions.cs
var op = FindImplicitOperator(fromType, toType)
?? FindImplicitOperator(toType, fromType); // <-- logically wrongFindImplicitOperator(definedOn, targetType) returns a method defined on definedOn whose ReturnType == targetType. The first call correctly finds an operator on TFrom that converts to TTo. The second call is intended to handle the C# rule that implicit operators may be declared on either participant type — but FindImplicitOperator(toType, fromType) looks for a method on TTo with ReturnType == TFrom, which is the reverse conversion (TTo → TFrom), not the forward one (TFrom → TTo defined on TTo).
The correct second search should look for a method on TTo that has ReturnType == TTo and a single parameter of type TFrom:
// Correct: find "public static implicit operator TTo(TFrom value)" defined on TTo
private static MethodInfo? FindImplicitOperatorOnTarget(Type definedOn, Type fromType)
{
foreach (var method in definedOn.GetMethods(BindingFlags.Public | BindingFlags.Static))
{
if (method.Name != "op_Implicit") continue;
if (method.ReturnType != definedOn) continue; // ReturnType == TTo
var parameters = method.GetParameters();
if (parameters.Length == 1 && parameters[0].ParameterType == fromType)
return method;
}
return null;
}This bug is invisible in the current test suite because all test operators are defined on the source type, but it will silently fail for the legitimate C# pattern of defining the conversion on the target type.
Inconsistency: IsNotEqualTo<TValue,TOther> parameter is not nullable
File: TUnit.Assertions/Extensions/ImplicitConversionEqualityExtensions.cs
The companion IsEqualTo<TValue,TOther> takes TOther? expected (nullable), but IsNotEqualTo takes a non-nullable TOther. This means Assert.That(x).IsNotEqualTo((string?)null) won't compile where Assert.That(x).IsEqualTo((string?)null) does (and is tested). The parameter should be TOther? notExpected for consistency, with a corresponding null test case added.
F# template files not updated — will produce broken projects
File: TUnit.Templates/content/TUnit.FSharp/Tests.fs
The F# template files still use the old call pattern. F# does not honour [OverloadResolutionPriority], so after this PR introduces a second applicable overload in EqualsAssertionExtensions, new projects created from the F# template will fail to compile with an ambiguity error. The project's own test files were patched (with explicit type qualification), but the shipped templates were missed. This will affect every user who creates a new F# TUnit project.
Minor / Non-blocking Notes
Cache write pattern — The current code uses a manual TryGetValue + assignment pattern. Under contention, two threads can both miss the cache check and both call BuildConverter. This is benign (equivalent delegates), but the idiomatic pattern Cache.GetOrAdd(key, _ => BuildConverter<TFrom, TTo>()) is more consistent with how PropertyCache is used elsewhere in the codebase.
MethodInfo.Invoke boxes value types — op.Invoke(null, [value]) boxes value-type arguments on every assertion call even though the delegate is cached. An Expression.Lambda<Func<TFrom,TTo>>(...).Compile() would avoid this, but requires [RequiresDynamicCode] making it incompatible with AOT. Worth a brief comment explaining the trade-off so future maintainers don't blindly switch to expression compilation without adding the AOT annotation.
Missing error-path test — There's no test for the case where no implicit operator exists. The error-throwing lambda is cached, and the resulting InvalidOperationException surfaces inside assertion evaluation rather than at the call site, which may produce a confusing failure message. A test confirming the error is reported clearly would help.
Positives
- The
partialclass approach for co-locating hand-written and generated overloads is the correct design forOverloadResolutionPriorityto work. - Caching the reflection lookup per type-pair is the right performance mitigation.
[RequiresUnreferencedCode]is consistently applied throughout.- The null-guard in the converter lambda correctly handles nullable reference types.
- Snapshot and PublicAPI tests are fully updated including the net4.7 case.
- Test coverage for primary use cases (string wrapper, int wrapper, positive/negative, null, same-type) is solid.
Recommendation: REQUEST_CHANGES
The FindImplicitOperator fallback logic is incorrect for the "operator defined on target type" case — a real bug that current tests don't exercise. The IsNotEqualTo non-nullable parameter is a usability inconsistency. The F# template files are not updated and will produce broken projects for new users. These three issues should be fixed before merge.
- FindImplicitOperator now correctly searches the target type for op_Implicit(TFrom)->TTo declared on the target (the previous fallback searched the reverse direction). Unified the two search call sites into a single helper that takes the predicate explicitly. - IsNotEqualTo<TValue,TOther> parameter is now TOther? for parity with IsEqualTo (was non-nullable). - F# templates updated with the same disambiguation pattern used in the project's own .fs files so new F# projects compile (F# does not honour [OverloadResolutionPriority]). Also updated the AspNet F# template. - New regression tests cover (a) operator declared on the target type, (b) IsNotEqualTo with a null wrapper, and (c) IsNotEqualTo with a non-null wrapper against a null primitive. - Two analyzer test snippets needed an explicit type argument (IsEqualTo<T>) because the analyzer-test harness is pinned to Roslyn 4.8 (which predates [OverloadResolutionPriority]). Production code is unaffected; comment in each test class explains the workaround. - PublicAPI snapshots and F# template snapshots updated for the signature change.
There was a problem hiding this comment.
Follow-up Review: All Previous Blocking Issues Resolved ✅
All three blocking issues from the prior review (commit 33b0b9ef) have been addressed in the current HEAD:
- FindImplicitOperator direction bug — Fixed. The 3-arg signature
FindImplicitOperator(toType, fromType, toType)correctly searches forop_Impliciton the target type returningtoTypewith a parameter offromType. - IsNotEqualTo nullability — Fixed. The parameter is now
TOther? notExpected. - F# template files — Fixed. Both
TUnit.FSharp/Tests.fsandTUnit.AspNet.FSharp/TestProject/Tests.fscall the type-specific extension directly with explanatory comments.
Remaining Observations
Minor: Cache write pattern is not race-safe against wasted work
// Current:
if (Cache.TryGetValue(key, out var cached))
return (Func<TFrom?, TTo?>)cached;
var converter = BuildConverter<TFrom, TTo>();
Cache[key] = converter;
return converter;Under concurrent first-call pressure for the same type pair, multiple threads can each run BuildConverter and overwrite each other — safe because all converters are equivalent, but wasteful. The idiomatic pattern is:
return (Func<TFrom?, TTo?>)Cache.GetOrAdd(key, _ => BuildConverter<TFrom, TTo>());GetOrAdd still allows a race on the factory call (it doesn't lock), but the returned value is always the winner's, and future readers always hit the cache. The intent to "build once" is also more clearly expressed. This is non-blocking.
Minor: No error-path test for missing implicit operator
There is no test verifying that calling IsEqualTo when no implicit conversion operator exists throws InvalidOperationException with the diagnostic message. The message is good:
No implicit conversion operator from 'X' to 'Y' was found. IsEqualTo / IsNotEqualTo with a different argument type requires 'X' to define 'public static implicit operator Y(X value)'.
A test for this path would protect the error message from unintentional changes and confirm the feature behaves sensibly for misuse. Non-blocking but recommended.
Noted: F# fluent API regression
The F# template change from:
do! check(Assert.That(result).IsEqualTo(c))to:
do! check(IntEqualsAssertionExtensions.IsEqualTo(Assert.That(result), c))…is verbose and breaks the fluent experience for F# users. This is a pre-existing limitation (F# ignores [OverloadResolutionPriority]) that is now more visible because the PR introduces the ambiguity. There is no clean solution within this PR's scope — it's a known F# language constraint — but it's worth tracking as a known rough edge.
Positives
- The
partialclass design for co-locating the overload with the source-generated one is the correct architectural choice for[OverloadResolutionPriority]to work. - The cache lookup is per-type-pair and lazily populated — correct performance trade-off.
[RequiresUnreferencedCode]and[DynamicallyAccessedMembers]are applied correctly and consistently; the[UnconditionalSuppressMessage]onFindImplicitOperatoris properly justified.- The AOT comment explaining the
MethodInfo.Invokeboxing trade-off vsExpression.Lambdais exactly the right level of documentation. - Snapshot and PublicAPI tests updated across all TFMs including net4.7.
- Test coverage for the primary use cases (string wrapper, int wrapper, operator on target type, nulls,
IsNotEqualTo) is solid.
Verdict: Approve. The core design is sound, all previous blockers are resolved, and the remaining items are minor and non-blocking.
- Cache write switched to ConcurrentDictionary.GetOrAdd (single-call idiom) - Add error-path test asserting clear diagnostic when no implicit operator exists
There was a problem hiding this comment.
Code Review: Final Pass ✅
This is a follow-up to the two prior review rounds. All previous blocking issues and minor observations from those rounds have been addressed in the current HEAD (). Here is a fresh assessment of the final state.
Previous Observations — Status
| Issue | Status |
|---|---|
FindImplicitOperator direction bug |
✅ Fixed — correct 3-arg signature |
IsNotEqualTo nullability (TOther notExpected → TOther? notExpected) |
✅ Fixed |
| F# template files calling type-specific extension | ✅ Fixed with explanatory comments |
Cache.GetOrAdd instead of manual TryGetValue/set |
✅ Fixed |
| Error-path test for missing implicit operator | ✅ Fixed — IsEqualTo_NoImplicitOperator_Surfaces_Diagnostic + IsNotEqualTo_... |
Fresh Assessment
Core implementation () is clean and correct:
- The with is idiomatic. The keyword is valid here — generic type parameters are not captured variables, they are part of the generic instantiation.
- Null path in the converter lambda is correct for both reference types and struct-based : boxing a with produces a boxed , which unboxes correctly.
- on the parameter of protects public operators from trimming at analysis time. The with a clear justification is appropriate.
- The comment explaining why was not used (would require , breaking Native AOT) is valuable — keep it.
- The null-forgiving operator in works correctly at runtime; the test covers that path.
Source generator change (): emitting is necessary and correct. This is not a breaking change — only adds the ability to split a class across files; it removes no API surface.
Test coverage () is thorough:
- Positive and negative cases for both string and int wrappers
- Operator-on-target-type fallback
- Null source vs non-null expected and vice versa
- Same-type still uses the original overload (critical regression guard)
- Missing-operator diagnostic surfaced correctly
F# workaround (, template snapshots): the direct call to with an explanatory comment is the right approach given that F# does not honour .
One Minor Observation (Non-Blocking)
** in the hand-written partials** uses a single interpolated string:
The source-generated code uses three separate calls to avoid the intermediate allocation. The difference is negligible in assertion paths, but if you ever want strict consistency with the generated style, the three-call pattern would match. Not worth a change on its own.
Summary
The implementation is well-designed and production-ready. The -class approach for is the correct solution to the overload disambiguation problem, the reflection cache is safe and efficient, and the AOT story is handled correctly with appropriate annotations. The test suite is comprehensive.
No further changes required — this is good to merge.
There was a problem hiding this comment.
Code Review: Final Pass ✅
This is a follow-up to the two prior review rounds. All previous blocking issues and minor observations from those rounds have been addressed in the current HEAD (bb27ef48). Here is a fresh assessment of the final state.
Previous Observations — Status
| Issue | Status |
|---|---|
FindImplicitOperator direction bug |
✅ Fixed — correct 3-arg signature |
IsNotEqualTo nullability (TOther notExpected → TOther? notExpected) |
✅ Fixed |
| F# template files calling type-specific extension | ✅ Fixed with explanatory comments |
Cache.GetOrAdd instead of manual TryGetValue/set |
✅ Fixed |
| Error-path test for missing implicit operator | ✅ Fixed — IsEqualTo_NoImplicitOperator_Surfaces_Diagnostic + IsNotEqualTo_... |
Fresh Assessment
Core implementation (ImplicitConversionEqualityExtensions.cs) is clean and correct:
- The
ConcurrentDictionary.GetOrAddwithstatic _ => BuildConverter<TFrom, TTo>()is idiomatic. Thestatickeyword is valid here — generic type parameters are not captured variables; they are part of the generic instantiation. - The null path in the converter lambda is correct for both reference types and struct-based
Nullable<T>: boxing aNullable<T>withHasValue = trueproduces a boxedT, whichMethodInfo.Invokeunboxes correctly. [DynamicallyAccessedMembers(PublicMethods)]on thedefinedOnparameter ofFindImplicitOperatorprotects public operators from trimming at analysis time. The[UnconditionalSuppressMessage]with a clear justification is appropriate.- The comment explaining why
Expression.Lambda(...).Compile()was not used (would require[RequiresDynamicCode], breaking Native AOT) is valuable — keep it. - The
notExpected!null-forgiving operator inIsNotEqualToworks correctly at runtime; the testIsNotEqualTo_NonNull_Wrapper_Against_Null_Primitive_Passesverifies that path.
Source generator change (AssertionExtensionGenerator.cs): emitting public static partial class is necessary and correct. This is not a breaking change — partial only adds the ability to split a class across files; it removes no API surface.
Test coverage (Issue5720Tests.cs) is thorough:
- Positive and negative cases for both string and int wrappers
- Operator-on-target-type fallback
- Null source vs non-null expected and vice versa
- Same-type still uses the original overload (critical regression guard)
- Missing-operator diagnostic surfaced correctly
F# workaround (AsyncTests.fs, template snapshots): the direct call to IntEqualsAssertionExtensions.IsEqualTo(...) with an explanatory comment is the right approach given that F# does not honour [OverloadResolutionPriority].
One Minor Observation (Non-Blocking)
ExpressionBuilder.Append in the hand-written partials uses a single interpolated string:
source.Context.ExpressionBuilder.Append($".IsEqualTo({expectedExpression})");The source-generated code uses three separate Append calls to avoid the intermediate allocation. The difference is negligible in assertion paths, but if consistency with the generated style matters, the three-call pattern would match. Not worth a change on its own.
Summary
The implementation is well-designed and production-ready. The partial-class approach for [OverloadResolutionPriority] is the correct solution to the overload disambiguation problem, the reflection cache is safe and efficient, and the AOT story is handled correctly with appropriate annotations. The test suite is comprehensive.
No further changes required — this is good to merge.
…o wrappers (#5751) (#5759) * fix(tests): disambiguate Count overload broken by IsEqualTo wrappers (#5751) Chained_Collection_Assertions_WithStrings called .Count(c => c.IsEqualTo(3)) on string[]. Before #5751 this failed to bind to the per-item filter overload (int-vs-string mismatch on IsEqualTo) and resolved to the inline-count assertion form, returning a chainable assertion. After #5751 added IsEqualTo<TValue, TOther> with implicit-conversion fallback, both overloads compile and resolution picks the per-item filter, which returns CollectionCountSource<,> -- a count source without .And -- breaking .And.Contains("Bob") with CS1061. Mirror the int test (Chained_Collection_Assertions) and use .Count().IsEqualTo(3) so the resulting assertion is the chainable CollectionCountEqualsAssertion regardless of TItem. * fix(tests): tighten Count overload comment
…ilment timeout (#5761) Rebased onto main brings in #5720/#5751 IsEqualTo wrapper overloads. On netstandard2.0 the [OverloadResolutionPriority] and [RequiresUnreferencedCode] attributes don't render, so the Net4_7 verified snapshot loses six lines. Step3_Worker_Fulfills_Order timed out after the previous 120s bump on the ubuntu-latest runner; widen to 180s to absorb RabbitMQ + Worker delivery under concurrent load.
) (#5767) * fix(assertions): gate IsEqualTo<TValue, TOther> overload to net9+ (#5765) The IsEqualTo<TValue, TOther> / IsNotEqualTo<TValue, TOther> overloads added in 1.40.0 (#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 #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 #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.
Updated [TUnit](https://github.com/thomhurst/TUnit) from 1.39.0 to 1.40.5. <details> <summary>Release notes</summary> _Sourced from [TUnit's releases](https://github.com/thomhurst/TUnit/releases)._ ## 1.40.5 <!-- Release notes generated using configuration in .github/release.yml at v1.40.5 --> ## What's Changed ### Other Changes * Fix reflection property injection reuse by @thomhurst in thomhurst/TUnit#5763 * fix(assertions): gate IsEqualTo<TValue, TOther> overload to net9+ (#5765) by @thomhurst in thomhurst/TUnit#5767 ### Dependencies * chore(deps): update tunit to 1.40.0 by @thomhurst in thomhurst/TUnit#5762 **Full Changelog**: thomhurst/TUnit@v1.40.0...v1.40.5 ## 1.40.0 <!-- Release notes generated using configuration in .github/release.yml at v1.40.0 --> ## What's Changed ### Other Changes * perf(engine): collapse async forwarding wrappers in test execution (#5714) by @thomhurst in thomhurst/TUnit#5725 * perf(engine): skip Console.Out/Err FlushAsync when no output captured (#5712) by @thomhurst in thomhurst/TUnit#5724 * perf(engine): collapse async state machines on hook cache-hit / empty-hook path (#5713) by @thomhurst in thomhurst/TUnit#5726 * perf: eliminate per-test closure + GetOrAdd factory alloc (#5710) by @thomhurst in thomhurst/TUnit#5727 * perf(engine): replace global lock in EventReceiverRegistry with lock-free CAS by @thomhurst in thomhurst/TUnit#5731 * perf(engine): batch per-test overhead cleanups (#5719) by @thomhurst in thomhurst/TUnit#5730 * #5733 handling all arguments for Fact and Theory by @inyutin-maxim in thomhurst/TUnit#5734 * fix(assertions): prefer string overload of Member() over IEnumerable<char> (#5702) by @thomhurst in thomhurst/TUnit#5721 * fix(migration): preserve comments/XML docs when removing sole attributes (#5698) by @thomhurst in thomhurst/TUnit#5739 * perf(build): trim test TFMs and skip viewer dump by default by @thomhurst in thomhurst/TUnit#5741 * fix(pipeline): skip TestBaseModule frameworks with missing binaries by @thomhurst in thomhurst/TUnit#5752 * feat(assertions): focused diff messages for IsEqualTo/IsEquivalentTo (#5732) by @thomhurst in thomhurst/TUnit#5747 * fix(analyzers): remove incorrect AOT rules TUnit0300/0301/0302 (#5722) by @thomhurst in thomhurst/TUnit#5746 * perf(engine): lazy hook metadata registration (#5448) by @thomhurst in thomhurst/TUnit#5750 * chore(templates): unify TUnit version pinning to 1.* (#5709) by @thomhurst in thomhurst/TUnit#5743 * fix(templates): floating TUnit.Aspire version (#5708) by @thomhurst in thomhurst/TUnit#5742 * fix(assertions): preserve specialised source in .Count(itemAssertion) (#5707) by @thomhurst in thomhurst/TUnit#5749 * feat(assertions): IsEqualTo with implicitly-convertible wrappers (#5720) by @thomhurst in thomhurst/TUnit#5751 * feat(aspire): add ability to manually remove resources by @Odonno in thomhurst/TUnit#5586 * fix(fscheck): register default CancellationToken arbitrary that surfaces TestContext token by @JohnVerheij in thomhurst/TUnit#5758 * fix(engine): allow keyed NotInParallel tests to run alongside unconstrained tests (#5700) by @thomhurst in thomhurst/TUnit#5740 * perf: skip TimeoutHelper wrap when no explicit [Timeout] is set (#5711) by @thomhurst in thomhurst/TUnit#5728 ### Dependencies * chore(deps): update tunit to 1.39.0 by @thomhurst in thomhurst/TUnit#5701 * chore(deps): update aspire to 13.2.4 by @thomhurst in thomhurst/TUnit#5735 * chore(deps): bump postcss from 8.5.6 to 8.5.10 in /docs by @dependabot[bot] in thomhurst/TUnit#5736 * chore(deps): update dependency fscheck to 3.3.3 by @thomhurst in thomhurst/TUnit#5760 ## New Contributors * @inyutin-maxim made their first contribution in thomhurst/TUnit#5734 * @Odonno made their first contribution in thomhurst/TUnit#5586 **Full Changelog**: thomhurst/TUnit@v1.39.0...v1.40.0 Commits viewable in [compare view](thomhurst/TUnit@v1.39.0...v1.40.5). </details> [](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) --- <details> <summary>Dependabot commands and options</summary> <br /> You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show <dependency name> ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) </details> Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Updated [TUnit.Core](https://github.com/thomhurst/TUnit) from 1.37.0 to 1.40.10. <details> <summary>Release notes</summary> _Sourced from [TUnit.Core's releases](https://github.com/thomhurst/TUnit/releases)._ ## 1.40.10 <!-- Release notes generated using configuration in .github/release.yml at v1.40.10 --> ## What's Changed ### Other Changes * refactor(opentelemetry): depend on TUnit.Core instead of umbrella TUnit by @thomhurst in thomhurst/TUnit#5774 ### Dependencies * chore(deps): update tunit to 1.40.5 by @thomhurst in thomhurst/TUnit#5769 **Full Changelog**: thomhurst/TUnit@v1.40.5...v1.40.10 ## 1.40.5 <!-- Release notes generated using configuration in .github/release.yml at v1.40.5 --> ## What's Changed ### Other Changes * Fix reflection property injection reuse by @thomhurst in thomhurst/TUnit#5763 * fix(assertions): gate IsEqualTo<TValue, TOther> overload to net9+ (#5765) by @thomhurst in thomhurst/TUnit#5767 ### Dependencies * chore(deps): update tunit to 1.40.0 by @thomhurst in thomhurst/TUnit#5762 **Full Changelog**: thomhurst/TUnit@v1.40.0...v1.40.5 ## 1.40.0 <!-- Release notes generated using configuration in .github/release.yml at v1.40.0 --> ## What's Changed ### Other Changes * perf(engine): collapse async forwarding wrappers in test execution (#5714) by @thomhurst in thomhurst/TUnit#5725 * perf(engine): skip Console.Out/Err FlushAsync when no output captured (#5712) by @thomhurst in thomhurst/TUnit#5724 * perf(engine): collapse async state machines on hook cache-hit / empty-hook path (#5713) by @thomhurst in thomhurst/TUnit#5726 * perf: eliminate per-test closure + GetOrAdd factory alloc (#5710) by @thomhurst in thomhurst/TUnit#5727 * perf(engine): replace global lock in EventReceiverRegistry with lock-free CAS by @thomhurst in thomhurst/TUnit#5731 * perf(engine): batch per-test overhead cleanups (#5719) by @thomhurst in thomhurst/TUnit#5730 * #5733 handling all arguments for Fact and Theory by @inyutin-maxim in thomhurst/TUnit#5734 * fix(assertions): prefer string overload of Member() over IEnumerable<char> (#5702) by @thomhurst in thomhurst/TUnit#5721 * fix(migration): preserve comments/XML docs when removing sole attributes (#5698) by @thomhurst in thomhurst/TUnit#5739 * perf(build): trim test TFMs and skip viewer dump by default by @thomhurst in thomhurst/TUnit#5741 * fix(pipeline): skip TestBaseModule frameworks with missing binaries by @thomhurst in thomhurst/TUnit#5752 * feat(assertions): focused diff messages for IsEqualTo/IsEquivalentTo (#5732) by @thomhurst in thomhurst/TUnit#5747 * fix(analyzers): remove incorrect AOT rules TUnit0300/0301/0302 (#5722) by @thomhurst in thomhurst/TUnit#5746 * perf(engine): lazy hook metadata registration (#5448) by @thomhurst in thomhurst/TUnit#5750 * chore(templates): unify TUnit version pinning to 1.* (#5709) by @thomhurst in thomhurst/TUnit#5743 * fix(templates): floating TUnit.Aspire version (#5708) by @thomhurst in thomhurst/TUnit#5742 * fix(assertions): preserve specialised source in .Count(itemAssertion) (#5707) by @thomhurst in thomhurst/TUnit#5749 * feat(assertions): IsEqualTo with implicitly-convertible wrappers (#5720) by @thomhurst in thomhurst/TUnit#5751 * feat(aspire): add ability to manually remove resources by @Odonno in thomhurst/TUnit#5586 * fix(fscheck): register default CancellationToken arbitrary that surfaces TestContext token by @JohnVerheij in thomhurst/TUnit#5758 * fix(engine): allow keyed NotInParallel tests to run alongside unconstrained tests (#5700) by @thomhurst in thomhurst/TUnit#5740 * perf: skip TimeoutHelper wrap when no explicit [Timeout] is set (#5711) by @thomhurst in thomhurst/TUnit#5728 ### Dependencies * chore(deps): update tunit to 1.39.0 by @thomhurst in thomhurst/TUnit#5701 * chore(deps): update aspire to 13.2.4 by @thomhurst in thomhurst/TUnit#5735 * chore(deps): bump postcss from 8.5.6 to 8.5.10 in /docs by @dependabot[bot] in thomhurst/TUnit#5736 * chore(deps): update dependency fscheck to 3.3.3 by @thomhurst in thomhurst/TUnit#5760 ## New Contributors * @inyutin-maxim made their first contribution in thomhurst/TUnit#5734 * @Odonno made their first contribution in thomhurst/TUnit#5586 **Full Changelog**: thomhurst/TUnit@v1.39.0...v1.40.0 ## 1.39.0 <!-- Release notes generated using configuration in .github/release.yml at v1.39.0 --> ## What's Changed ### Other Changes * perf(mocks): shrink MethodSetup + cache stateless matchers by @thomhurst in thomhurst/TUnit#5669 * fix(mocks): handle base classes with explicit interface impls (#5673) by @thomhurst in thomhurst/TUnit#5674 * fix(mocks): implement indexer in generated mock (#5676) by @thomhurst in thomhurst/TUnit#5683 * fix(mocks): disambiguate IEquatable<T>.Equals from object.Equals (#5675) by @thomhurst in thomhurst/TUnit#5680 * fix(mocks): escape C# keyword identifiers at all emit sites (#5679) by @thomhurst in thomhurst/TUnit#5684 * fix(mocks): emit [SetsRequiredMembers] on generated mock ctor (#5678) by @thomhurst in thomhurst/TUnit#5682 * fix(mocks): skip MockBridge for class targets with static-abstract interfaces (#5677) by @thomhurst in thomhurst/TUnit#5681 * chore(mocks): regenerate source generator snapshots by @thomhurst in thomhurst/TUnit#5691 * perf(engine): collapse async state-machine layers on hot test path (#5687) by @thomhurst in thomhurst/TUnit#5690 * perf(engine): reduce lock contention in scheduling and hook caches (#5686) by @thomhurst in thomhurst/TUnit#5693 * fix(assertions): prevent implicit-to-string op from NREing on null (#5692) by @thomhurst in thomhurst/TUnit#5696 * perf(engine/core): reduce per-test allocations (#5688) by @thomhurst in thomhurst/TUnit#5694 * perf(engine): reduce message-bus contention on test start (#5685) by @thomhurst in thomhurst/TUnit#5695 ### Dependencies * chore(deps): update tunit to 1.37.36 by @thomhurst in thomhurst/TUnit#5667 * chore(deps): update verify to 31.16.2 by @thomhurst in thomhurst/TUnit#5699 **Full Changelog**: thomhurst/TUnit@v1.37.36...v1.39.0 ## 1.37.36 <!-- Release notes generated using configuration in .github/release.yml at v1.37.36 --> ## What's Changed ### Other Changes * fix(telemetry): remove duplicate HTTP client spans by @thomhurst in thomhurst/TUnit#5668 **Full Changelog**: thomhurst/TUnit@v1.37.35...v1.37.36 ## 1.37.35 <!-- Release notes generated using configuration in .github/release.yml at v1.37.35 --> ## What's Changed ### Other Changes * Add TUnit.TestProject.Library to the TUnit.Dev.slnx solution file by @Zodt in thomhurst/TUnit#5655 * fix(aspire): preserve user-supplied OTLP endpoint (#4818) by @thomhurst in thomhurst/TUnit#5665 * feat(aspire): emit client spans for HTTP by @thomhurst in thomhurst/TUnit#5666 ### Dependencies * chore(deps): update dependency dotnet-sdk to v10.0.203 by @thomhurst in thomhurst/TUnit#5656 * chore(deps): update microsoft.aspnetcore to 10.0.7 by @thomhurst in thomhurst/TUnit#5657 * chore(deps): update tunit to 1.37.24 by @thomhurst in thomhurst/TUnit#5659 * chore(deps): update microsoft.extensions to 10.0.7 by @thomhurst in thomhurst/TUnit#5658 * chore(deps): update aspire to 13.2.3 by @thomhurst in thomhurst/TUnit#5661 * chore(deps): update dependency microsoft.net.test.sdk to 18.5.0 by @thomhurst in thomhurst/TUnit#5664 ## New Contributors * @Zodt made their first contribution in thomhurst/TUnit#5655 **Full Changelog**: thomhurst/TUnit@v1.37.24...v1.37.35 ## 1.37.24 <!-- Release notes generated using configuration in .github/release.yml at v1.37.24 --> ## What's Changed ### Other Changes * docs: add Tluma Ask AI widget to Docusaurus site by @thomhurst in thomhurst/TUnit#5638 * Revert "chore(deps): update dependency docusaurus-plugin-llms to ^0.4.0 (#5637)" by @thomhurst in thomhurst/TUnit#5640 * fix(asp-net): forward disposal in FlowSuppressingHostedService (#5651) by @JohnVerheij in thomhurst/TUnit#5652 ### Dependencies * chore(deps): update dependency docusaurus-plugin-llms to ^0.4.0 by @thomhurst in thomhurst/TUnit#5637 * chore(deps): update tunit to 1.37.10 by @thomhurst in thomhurst/TUnit#5639 * chore(deps): update opentelemetry to 1.15.3 by @thomhurst in thomhurst/TUnit#5645 * chore(deps): update opentelemetry by @thomhurst in thomhurst/TUnit#5647 * chore(deps): update dependency dompurify to v3.4.1 by @thomhurst in thomhurst/TUnit#5648 * chore(deps): update dependency system.commandline to 2.0.7 by @thomhurst in thomhurst/TUnit#5650 * chore(deps): update dependency microsoft.entityframeworkcore to 10.0.7 by @thomhurst in thomhurst/TUnit#5649 * chore(deps): update dependency microsoft.templateengine.authoring.cli to v10.0.203 by @thomhurst in thomhurst/TUnit#5653 * chore(deps): update dependency microsoft.templateengine.authoring.templateverifier to 10.0.203 by @thomhurst in thomhurst/TUnit#5654 **Full Changelog**: thomhurst/TUnit@v1.37.10...v1.37.24 ## 1.37.10 <!-- Release notes generated using configuration in .github/release.yml at v1.37.10 --> ## What's Changed ### Other Changes * docs(test-filters): add migration callout for --filter → --treenode-filter by @johnkattenhorn in thomhurst/TUnit#5628 * fix: re-enable RPC tests and modernize harness (#5540) by @thomhurst in thomhurst/TUnit#5632 * fix(mocks): propagate [Obsolete] and null-forgiving raise dispatch (#5626) by @JohnVerheij in thomhurst/TUnit#5631 * ci: use setup-dotnet built-in NuGet cache by @thomhurst in thomhurst/TUnit#5635 * feat(playwright): propagate W3C trace context into browser contexts by @thomhurst in thomhurst/TUnit#5636 ### Dependencies * chore(deps): update tunit to 1.37.0 by @thomhurst in thomhurst/TUnit#5625 ## New Contributors * @johnkattenhorn made their first contribution in thomhurst/TUnit#5628 * @JohnVerheij made their first contribution in thomhurst/TUnit#5631 **Full Changelog**: thomhurst/TUnit@v1.37.0...v1.37.10 Commits viewable in [compare view](thomhurst/TUnit@v1.37.0...v1.40.10). </details> [](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) --- <details> <summary>Dependabot commands and options</summary> <br /> You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show <dependency name> ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) </details> Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Closes #5720
Summary
IsEqualTo<TValue, TOther>andIsNotEqualTo<TValue, TOther>overloads that detect a user-defined implicit operator at runtime (cached per type-pair) and compare the converted value. This restoresAssert.That(productCode).IsEqualTo("Example")for wrapper Value Objects and generalises it to int / decimal / custom wrappers.EqualsAssertionExtensions/NotEqualsAssertionExtensionsso[OverloadResolutionPriority(-1)]actually disambiguates calls where source and expected types match (priority is only consulted between overloads in the same containing type).AssertionExtensionGeneratornow emitspublic static partial classto host these partials.OverloadResolutionPriority.Test plan
Issue5720Testscover string and int wrapper Value Objects (positive, negative, null, same-type-still-wins)TUnit.Assertions.Testspass (net10.0)partialkeyword)