Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 117 additions & 0 deletions TUnit.Assertions.Tests/IsAssignableToTypedReturnTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
using System.Text;

namespace TUnit.Assertions.Tests;

/// <summary>
/// Tests for GitHub issue #6184: <c>IsAssignableTo&lt;T&gt;()</c> should return the value
/// cast to <c>T</c> (parity with <c>IsTypeOf&lt;T&gt;()</c>), so the caller doesn't need a
/// second manual cast.
/// </summary>
public class IsAssignableToTypedReturnTests
{
private interface IElement { }
private class Element : IElement { }
private class DerivedElement : Element { }

[Test]
public async Task IsAssignableTo_ReturnsValueCastToInterface()
{
// The exact scenario from the issue: an object/base-typed value that we want back
// as the interface it implements.
object obj = new List<string> { "a", "b", "c" };

IEnumerable<string> result = await Assert.That(obj).IsAssignableTo<IEnumerable<string>>();

await Assert.That(result.Count()).IsEqualTo(3);
await Assert.That(result.First()).IsEqualTo("a");
}

[Test]
public async Task IsAssignableTo_ReturnsValueCastToBaseType()
{
DerivedElement derived = new DerivedElement();

Element result = (await Assert.That(derived).IsAssignableTo<Element>())!;

await Assert.That(result).IsSameReferenceAs(derived);
}

[Test]
public async Task IsAssignableTo_ExactType_ReturnsValue()
{
var sb = new StringBuilder("Test");
object obj = sb;

StringBuilder result = (await Assert.That(obj).IsAssignableTo<StringBuilder>())!;

await Assert.That(result.ToString()).IsEqualTo("Test");
}

[Test]
public async Task IsAssignableTo_NotAssignable_StillFails()
{
await Assert.ThrowsAsync<TUnit.Assertions.Exceptions.AssertionException>(async () =>
{
Element element = new Element();
await Assert.That(element).IsAssignableTo<DerivedElement>();
});
}

[Test]
public async Task IsAssignableTo_Null_StillFails()
{
await Assert.ThrowsAsync<TUnit.Assertions.Exceptions.AssertionException>(async () =>
{
object? obj = null;
await Assert.That(obj).IsAssignableTo<IElement>();
});
}

[Test]
public async Task IsAssignableTo_ChainedAfterThrows_ChecksAndReturnsExceptionType()
{
// Dual-mode: when chained after Throws, the asserted "value" is the thrown
// exception, so IsAssignableTo validates the exception's type and returns it cast.
var result = await Assert.That(async () =>
{
throw new InvalidOperationException("boom");
})
.Throws<InvalidOperationException>()
.And
.IsAssignableTo<Exception>();

await Assert.That(result!.Message).IsEqualTo("boom");
}

[Test]
public async Task IsAssignableTo_ChainedAfterThrows_Fails_WhenExceptionNotAssignable()
{
await Assert.ThrowsAsync<TUnit.Assertions.Exceptions.AssertionException>(async () =>
{
await Assert.That(async () =>
{
throw new InvalidOperationException("boom");
})
.Throws<InvalidOperationException>()
.And
.IsAssignableTo<ArgumentException>();
});
}

[Test]
public async Task IsAssignableTo_EvaluatesSourceOnlyOnce()
{
// Regression guard for the dual-context design: CheckAsync reads the original
// context while the base reads the mapped context; both must resolve to a single
// cached evaluation of the source delegate.
var invocations = 0;

await Assert.That(() =>
{
invocations++;
return (object)new List<string> { "a" };
}).IsAssignableTo<IEnumerable<string>>();

await Assert.That(invocations).IsEqualTo(1);
}
}
31 changes: 19 additions & 12 deletions TUnit.Assertions/Conditions/TypeOfAssertion.cs
Original file line number Diff line number Diff line change
Expand Up @@ -91,26 +91,33 @@ protected override Task<AssertionResult> CheckAsync(EvaluationMetadata<TValue> m
}

/// <summary>
/// Asserts that a value's type is assignable to a specific type (is the type or a subtype).
/// Asserts that a value's type is assignable to a specific type (is the type or a subtype),
/// and transforms the assertion chain to that type so the awaited result is the typed value.
/// Works with both direct value assertions and exception assertions (via .And after Throws).
/// </summary>
public class IsAssignableToAssertion<TTarget, TValue> : Assertion<TValue>
public class IsAssignableToAssertion<TTarget, TValue> : Assertion<TTarget>
{
private readonly Type _targetType;
// The original (pre-map) context. Both this and the mapped base context share the same
// cached underlying evaluation, so the source is still evaluated only once. We read from
// it during the check to preserve the original value/exception type for validation and
// error messages (the mapped value is null when the cast doesn't apply).
private readonly AssertionContext<TValue> _sourceContext;
private readonly Type _targetType = typeof(TTarget);

public IsAssignableToAssertion(
AssertionContext<TValue> context)
: base(context)
: base(context.Map<TTarget>(value => value is TTarget casted ? casted : default))
{
_targetType = typeof(TTarget);
_sourceContext = context;
}

protected override Task<AssertionResult> CheckAsync(EvaluationMetadata<TValue> metadata)
// The mapped metadata is intentionally unused: validation runs against the original
// (pre-map) context so the original value/exception type is available for the message.
protected override async Task<AssertionResult> CheckAsync(EvaluationMetadata<TTarget> _)
{
var value = metadata.Value;
var exception = metadata.Exception;
var (value, exception) = await _sourceContext.GetAsync();

object? objectToCheck = null;
object? objectToCheck;

// If we have an exception (from Throws/ThrowsExactly), check that
if (exception != null)
Expand All @@ -124,17 +131,17 @@ protected override Task<AssertionResult> CheckAsync(EvaluationMetadata<TValue> m
}
else
{
return Task.FromResult(AssertionResult.Failed("value was null"));
return AssertionResult.Failed("value was null");
}

var actualType = objectToCheck.GetType();

if (_targetType.IsAssignableFrom(actualType))
{
return AssertionResult._passedTask;
return AssertionResult.Passed;
}

return Task.FromResult(AssertionResult.Failed($"type {actualType.Name} is not assignable to {_targetType.Name}"));
return AssertionResult.Failed($"type {actualType.Name} is not assignable to {_targetType.Name}");
}

protected override string GetExpectation() => $"to be assignable to {_targetType.Name}";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1400,10 +1400,10 @@ namespace .Conditions
protected override .<.> CheckAsync(.<TValue> metadata) { }
protected override string GetExpectation() { }
}
public class IsAssignableToAssertion<TTarget, TValue> : .<TValue>
public class IsAssignableToAssertion<TTarget, TValue> : .<TTarget>
{
public IsAssignableToAssertion(.<TValue> context) { }
protected override .<.> CheckAsync(.<TValue> metadata) { }
protected override .<.> CheckAsync(.<TTarget> _) { }
protected override string GetExpectation() { }
}
[.("IsDefault")]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1383,10 +1383,10 @@ namespace .Conditions
protected override .<.> CheckAsync(.<TValue> metadata) { }
protected override string GetExpectation() { }
}
public class IsAssignableToAssertion<TTarget, TValue> : .<TValue>
public class IsAssignableToAssertion<TTarget, TValue> : .<TTarget>
{
public IsAssignableToAssertion(.<TValue> context) { }
protected override .<.> CheckAsync(.<TValue> metadata) { }
protected override .<.> CheckAsync(.<TTarget> _) { }
protected override string GetExpectation() { }
}
[.("IsDefault")]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1400,10 +1400,10 @@ namespace .Conditions
protected override .<.> CheckAsync(.<TValue> metadata) { }
protected override string GetExpectation() { }
}
public class IsAssignableToAssertion<TTarget, TValue> : .<TValue>
public class IsAssignableToAssertion<TTarget, TValue> : .<TTarget>
{
public IsAssignableToAssertion(.<TValue> context) { }
protected override .<.> CheckAsync(.<TValue> metadata) { }
protected override .<.> CheckAsync(.<TTarget> _) { }
protected override string GetExpectation() { }
}
[.("IsDefault")]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1219,10 +1219,10 @@ namespace .Conditions
protected override .<.> CheckAsync(.<TValue> metadata) { }
protected override string GetExpectation() { }
}
public class IsAssignableToAssertion<TTarget, TValue> : .<TValue>
public class IsAssignableToAssertion<TTarget, TValue> : .<TTarget>
{
public IsAssignableToAssertion(.<TValue> context) { }
protected override .<.> CheckAsync(.<TValue> metadata) { }
protected override .<.> CheckAsync(.<TTarget> _) { }
protected override string GetExpectation() { }
}
[.("IsDefault")]
Expand Down
Loading