feat(assertions): return typed value from IsAssignableTo<T> (#6184)#6187
Conversation
IsAssignableTo<T>() now returns the value cast to T, matching IsTypeOf<T>(),
so callers no longer need a second manual cast:
IEnumerable<MyDto> data = (await Assert.That(value).IsAssignableTo<IEnumerable<MyDto>>())!;
IsAssignableToAssertion<TTarget, TValue> now derives from Assertion<TTarget>
and maps the context to the target type, while validating against the original
(pre-map) context so the existing dual-mode behaviour (value vs. thrown
exception), null handling, and error messages are preserved.
Up to standards ✅🟢 Issues
|
| Metric | Results |
|---|---|
| Complexity | 9 |
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
The feature itself is well-motivated and the overall approach is sound. A few points worth considering:
Double-evaluation risk in CheckAsync
The most architecturally interesting part of the change is calling _sourceContext.GetAsync() directly inside CheckAsync instead of using the mapped metadata parameter that the base class provides:
protected override async Task<AssertionResult> CheckAsync(EvaluationMetadata<TTarget> metadata)
{
var (value, exception) = await _sourceContext.GetAsync(); // bypasses metadata
...
}The comment in the code notes that both contexts share the same cached evaluation, which is correct — EvaluationContext<TValue>.GetAsync() caches after first call. However, the metadata parameter passed by the base class ExecuteCoreAsync() already contains the evaluated Value and Exception from Context.GetAsync() (the mapped context). By ignoring metadata and calling _sourceContext.GetAsync() separately, this works today because the cache is shared at the EvaluationContext level (the mapped context's evaluator calls GetAsync() on the source, so both are populated together).
The subtle risk: if someone later changes the evaluation flow (e.g., adds retry/re-evaluation logic) the two calls could potentially diverge, since _sourceContext and Context have independent EvaluationContext instances even though they share the underlying source evaluation chain. The caching only prevents double execution of the original delegate; it does not enforce that both contexts return identical snapshots if the evaluation is ever invalidated.
A safer alternative would be to store the original cast result alongside the assertion and validate using the mapped metadata only, eliminating the need to reach back to _sourceContext at all:
// In constructor:
: base(context.Map<TTarget>(value => {
_castSucceeded = value is TTarget casted ? (true, casted) : (false, default);
return _castSucceeded.Item2;
}))
// In CheckAsync: use metadata.Value/metadata.Exception directlyOr, keep the current approach but add a [MethodImpl(MethodImplOptions.AggressiveInlining)] comment or test that makes the coupling to the caching guarantee explicit.
Missing test coverage for the Throws chaining scenario
The PR description specifically calls out that dual-mode checks (value type vs. thrown-exception type, e.g., .Throws<X>().And.IsAssignableTo<Y>()) are preserved. However, the new test file IsAssignableToTypedReturnTests.cs does not include a test exercising this path directly. The existing tests in Throws.ExactlyTests.cs use IsAssignableTo on an already-awaited exception reference ((object)ex), not chained directly on the Throws assertion.
A test like the following would give direct regression coverage for the exception path through CheckAsync:
[Test]
public async Task IsAssignableTo_OnThrown_ExceptionType_ReturnsTypedException()
{
var result = await Assert.That(async () =>
{
throw new InvalidOperationException("test");
}).ThrowsAsync<InvalidOperationException>()
.And
.IsAssignableTo<Exception>();
// result should be Exception-typed
await Assert.That(result!.Message).IsEqualTo("test");
}Without this test, the exception branch (if (exception != null)) in CheckAsync is exercised only indirectly.
Minor: inconsistent use of AssertionResult.Passed vs AssertionResult._passedTask
This PR converts the async overrides to return AssertionResult.Passed (correct for async Task<AssertionResult> methods), and removes the now-unnecessary Task.FromResult(AssertionResult.Failed(...)) wrappers — both clean improvements. However, the sibling synchronous assertion classes in the same file (IsNotAssignableToAssertion, IsAssignableFromAssertion, IsNotAssignableFromAssertion, TypeOfAssertion, IsNotTypeOfAssertion) still use the internal AssertionResult._passedTask instead of the public AssertionResult.Passed wrapped in a task. This is not a bug but creates a stylistic inconsistency within the file. If there's an intent to clean this up across the codebase, this file would be a natural starting point.
Overall assessment
The feature is correct, the use of context.Map<TTarget>() for type projection is consistent with how TypeOfAssertion works, and the fix eliminates a real user pain point. The implementation is clean. The points above are concerns worth addressing — particularly the missing exception-path test — but none are blockers for the current functionality.
…r IsAssignableTo Adds direct regression coverage for review feedback on #6187: - IsAssignableTo chained after Throws validates and returns the thrown exception cast to the target type (and fails when not assignable). - The source delegate is evaluated exactly once despite CheckAsync reading the original context and the base reading the mapped context.
|
Thanks for the thorough review. Addressed in 7f1c3d7: 1. Missing Throws-chaining test coverage — Good catch, added two direct tests plus a single-evaluation guard:
(Note: the API is 2. Double-evaluation / coupling to caching — Now covered by I'd push back on the suggested closure alternative (capturing the cast result into a field from inside the 3. |
There was a problem hiding this comment.
Follow-up Review
The three concerns from the previous review round have been addressed:
- Throws-chaining test coverage — Added and exercising the
exception != nullbranch inCheckAsyncdirectly. - Double-evaluation guard —
IsAssignableTo_EvaluatesSourceOnlyOncelocks the caching guarantee against future regression. _passedTaskvsPassed— The explanation (sync siblings use a pre-allocated task to avoid aTask.FromResultallocation; theasyncoverride can return the struct directly) is sound. No change needed.
The pushback on the closure-mutating-field alternative is also correct: mutating assertion state from inside a lazy Map lambda would be a worse coupling, and it still wouldn't supply the original type name for the failure message when the cast returns null.
One remaining nit: unused metadata parameter
CheckAsync now ignores its parameter entirely:
protected override async Task<AssertionResult> CheckAsync(EvaluationMetadata<TTarget> metadata)
{
var (value, exception) = await _sourceContext.GetAsync(); // metadata never readIf the project compiles with warnings-as-errors (or has an analyzer that flags unused parameters), this will break the build. Consider using a discard name to signal the intentional bypass:
protected override async Task<AssertionResult> CheckAsync(EvaluationMetadata<TTarget> _)This documents the intentional bypass — readers immediately understand that the mapped metadata isn't used here, not that someone forgot to use it.
Overall
The implementation is correct, the test coverage is thorough, and the dual-context design is well-explained. The unused parameter is the only remaining nit, and it's a minor one. Good change.
Signals the intentional bypass of the mapped metadata (validation runs against the original context). Addresses review nit on #6187.
|
Addressed the remaining nit in 897671f — renamed the unused |
There was a problem hiding this comment.
Final Review — All Previous Concerns Addressed
This is a clean, well-executed improvement. All findings from prior rounds have been resolved:
Round 1 → Round 2 (resolved):
- Exception-path test coverage — and now exercise the
exception != nullbranch inCheckAsyncdirectly. - Double-evaluation guard —
IsAssignableTo_EvaluatesSourceOnlyOncelocks the caching guarantee with a real assertion that fails if the source delegate runs more than once. _passedTaskvsPassed— The performance rationale (sync siblings use the pre-allocated task; theasyncoverride can return the struct directly) was correct. No change needed and the explanation is sound.
Round 2 → Round 3 (resolved):
- Unused parameter name —
CheckAsync(EvaluationMetadata<TTarget> _)now uses a discard with an explanatory comment, clearly documenting the intentional bypass.
The final state of the implementation is solid:
- The dual-context design (
_sourceContextfor validation, mapped context for the return type) is correctly motivated and well-commented. - The
context.Map<TTarget>()approach is consistent with howTypeOfAssertionalready works. - 9 test cases cover the golden path, failure cases, null, Throws-chaining, and the evaluation-count regression guard.
- The
async Task<AssertionResult>return type change allows clean direct returns instead ofTask.FromResultallocations.
Approved.
There was a problem hiding this comment.
Final Review — All Previous Concerns Addressed
This is a clean, well-executed improvement. All findings from prior rounds have been resolved:
Round 1 → Round 2 (resolved):
- Exception-path test coverage —
IsAssignableTo_ChainedAfterThrows_ChecksAndReturnsExceptionTypeandIsAssignableTo_ChainedAfterThrows_Fails_WhenExceptionNotAssignablenow exercise theexception != nullbranch inCheckAsyncdirectly. - Double-evaluation guard —
IsAssignableTo_EvaluatesSourceOnlyOncelocks the caching guarantee with a real assertion that fails if the source delegate runs more than once. _passedTaskvsPassed— The performance rationale (sync siblings use the pre-allocated task; theasyncoverride can return the struct directly) was correct. No change needed and the explanation is sound.
Round 2 → Round 3 (resolved):
- Unused parameter name —
CheckAsync(EvaluationMetadata<TTarget> _)now uses a discard with an explanatory comment, clearly documenting the intentional bypass.
The final state of the implementation is solid:
- The dual-context design (
_sourceContextfor validation, mapped context for the return type) is correctly motivated and well-commented. - The
context.Map<TTarget>()approach is consistent with howTypeOfAssertionalready works. - 9 test cases cover the golden path, failure cases, null, Throws-chaining, and the evaluation-count regression guard.
- The
async Task<AssertionResult>return type change allows clean direct returns instead ofTask.FromResultallocations.
Approved.
IsAssignableToAssertion now derives from Assertion<TTarget> (was Assertion<TValue>) and its CheckAsync parameter is a discard. Updates the public-API snapshots across all target frameworks.
There was a problem hiding this comment.
Code Review — PR #6187: feat(assertions): return typed value from IsAssignableTo<T>
This PR went through three prior review rounds and all previously raised concerns have been addressed. This is a fresh pass over the final code.
What is correct and well-done
Design consistency. The context.Map<TTarget>() pattern is already used by TypeOfAssertion<TFrom, TTo>. Using the same mechanism here keeps the two type-narrowing assertions architecturally symmetric.
Dual-mode preservation. The exception != null branch in CheckAsync reads from _sourceContext directly, so the assertion correctly works when chained after .Throws<X>().And.
Single-evaluation guarantee. The IsAssignableTo_EvaluatesSourceOnlyOnce test locks this as a regression guard.
Discard parameter. CheckAsync(EvaluationMetadata<TTarget> _) correctly signals the intentional bypass of the mapped metadata — the right idiom.
Async cleanup. Converting CheckAsync to async Task<AssertionResult> eliminates Task.FromResult(...) wrappers on all three return paths, which is cleaner.
Test coverage. 9 test cases across interface cast, base-type cast, exact type, not-assignable failure, null failure, Throws-chained success, Throws-chained failure, and single-evaluation guard. Thorough.
Public API snapshots. All four .verified.txt files updated to reflect the base class change and discard parameter rename.
Minor observations (non-blocking)
1. Scope decision is correct. Intentionally leaving IsAssignableFrom<TSource>, IsNotAssignableTo<TTarget>, and IsNotAssignableFrom<TSource> unchanged is the right call — IsAssignableFrom tests a capability of the asserted type rather than narrowing it, and negated forms returning a narrowed value would be misleading. A one-line XML doc comment on IsAssignableFromAssertion explaining why it differs from IsAssignableToAssertion would help future contributors, but not required.
2. Pre-existing pattern inconsistency (not introduced here). Sibling classes like IsNotAssignableToAssertion still use the internal _passedTask field while this class now uses async/await. This is a pre-existing performance tradeoff, not a bug, but worth noting if there's ever a cleanup pass.
Verdict
The implementation is correct and complete. The feature eliminates a real user pain point (manual casting after IsAssignableTo<T>()), the design is consistent with the existing TypeOfAssertion pattern, all edge cases are covered by tests, and all prior review findings have been resolved.
Approved. ✅
Updated [TUnit](https://github.com/thomhurst/TUnit) from 1.51.0 to 1.53.0. <details> <summary>Release notes</summary> _Sourced from [TUnit's releases](https://github.com/thomhurst/TUnit/releases)._ ## 1.53.0 <!-- Release notes generated using configuration in .github/release.yml at v1.53.0 --> ## What's Changed ### Other Changes * feat(assertions): return typed value from IsAssignableTo<T> (#6184) by @thomhurst in thomhurst/TUnit#6187 * fix: stop doubling backslashes in source-gen emitted FilePath (breaks HTML report source links) by @thomhurst in thomhurst/TUnit#6193 * feat(assertions): add ContainsKey().And.Value drill-in for dictionaries (#6185) by @thomhurst in thomhurst/TUnit#6188 * fix(tests): snapshot ExecutionLog under lock to fix parallel race by @thomhurst in thomhurst/TUnit#6194 * fix(engine): run lifecycle hooks before test class construction (#6192) by @thomhurst in thomhurst/TUnit#6195 * feat(assertions): inference-friendly pinned overload for covariant [AssertionExtension] with own generic (#5922) by @thomhurst in thomhurst/TUnit#6196 * feat: add DeferEnumeration to defer data-source expansion to runtime (#5833) by @thomhurst in thomhurst/TUnit#6197 ### Dependencies * chore(deps): update tunit to 1.51.0 by @thomhurst in thomhurst/TUnit#6186 * chore(deps): update microsoft.testing to 18.8.0 by @thomhurst in thomhurst/TUnit#6191 * chore(deps): update aspire to 13.4.3 by @thomhurst in thomhurst/TUnit#6198 **Full Changelog**: thomhurst/TUnit@v1.51.0...v1.53.0 Commits viewable in [compare view](thomhurst/TUnit@v1.51.0...v1.53.0). </details> Updated [TUnit.AspNetCore](https://github.com/thomhurst/TUnit) from 1.51.0 to 1.53.0. <details> <summary>Release notes</summary> _Sourced from [TUnit.AspNetCore's releases](https://github.com/thomhurst/TUnit/releases)._ ## 1.53.0 <!-- Release notes generated using configuration in .github/release.yml at v1.53.0 --> ## What's Changed ### Other Changes * feat(assertions): return typed value from IsAssignableTo<T> (#6184) by @thomhurst in thomhurst/TUnit#6187 * fix: stop doubling backslashes in source-gen emitted FilePath (breaks HTML report source links) by @thomhurst in thomhurst/TUnit#6193 * feat(assertions): add ContainsKey().And.Value drill-in for dictionaries (#6185) by @thomhurst in thomhurst/TUnit#6188 * fix(tests): snapshot ExecutionLog under lock to fix parallel race by @thomhurst in thomhurst/TUnit#6194 * fix(engine): run lifecycle hooks before test class construction (#6192) by @thomhurst in thomhurst/TUnit#6195 * feat(assertions): inference-friendly pinned overload for covariant [AssertionExtension] with own generic (#5922) by @thomhurst in thomhurst/TUnit#6196 * feat: add DeferEnumeration to defer data-source expansion to runtime (#5833) by @thomhurst in thomhurst/TUnit#6197 ### Dependencies * chore(deps): update tunit to 1.51.0 by @thomhurst in thomhurst/TUnit#6186 * chore(deps): update microsoft.testing to 18.8.0 by @thomhurst in thomhurst/TUnit#6191 * chore(deps): update aspire to 13.4.3 by @thomhurst in thomhurst/TUnit#6198 **Full Changelog**: thomhurst/TUnit@v1.51.0...v1.53.0 Commits viewable in [compare view](thomhurst/TUnit@v1.51.0...v1.53.0). </details> 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>
Summary
Closes #6184.
IsTypeOf<T>()returns the value cast toT, butIsAssignableTo<T>()previously returned the original (untyped) value, forcing a second manual cast:IsAssignableTo<T>()now returns the value cast toT, matchingIsTypeOf<T>().Implementation
IsAssignableToAssertion<TTarget, TValue>now derives fromAssertion<TTarget>(wasAssertion<TValue>) and maps the context to the target type viacontext.Map<TTarget>(value => value is TTarget c ? c : default), so awaiting it yieldsTTarget?.Validation still runs against the retained original context, so the existing behaviour is fully preserved:
.Throws<X>().And.IsAssignableTo<Y>()),"type X is not assignable to Y").Both the mapped and original contexts share the same cached underlying evaluation, so the source is still evaluated exactly once. No extension-method signatures changed — the awaited type simply flips from
TValue?toTTarget?.Scope
IsAssignableToonly.IsAssignableFrom<TSource>and the negated variants (IsNotAssignableTo/IsNotAssignableFrom/IsNotTypeOf) are intentionally unchanged — they have no meaningful typed return.Tests
TUnit.Assertions.Tests/IsAssignableToTypedReturnTests.cs: cast-to-interface (the issue scenario), cast-to-base, exact type, plus not-assignable and null failure cases.TypeAssertionAmbiguityTests,TypeOfTests, andThrows*regression suites.