From 62c693c475ca768f484b3dc3a732a51efbbcf935 Mon Sep 17 00:00:00 2001
From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com>
Date: Sun, 9 Nov 2025 20:36:03 +0000
Subject: [PATCH 1/3] Fix Type ambiguity errors when using IsAssignableTo
assertions
---
.../TypeAssertionAmbiguityTests.cs | 322 ++++++++++++++++++
.../Assertions/PropertyAssertion.cs | 22 ++
.../Assertions/Strings/ParseAssertions.cs | 22 ++
.../Conditions/MemberAssertion.cs | 22 ++
.../Conditions/TypeOfAssertion.cs | 44 ++-
.../Conditions/Wrappers/CountWrapper.cs | 30 ++
.../Conditions/Wrappers/LengthWrapper.cs | 30 ++
TUnit.Assertions/Core/IAssertionSource.cs | 20 +-
.../Extensions/AssertionExtensions.cs | 29 --
.../Sources/AsyncDelegateAssertion.cs | 52 +++
.../Sources/AsyncFuncAssertion.cs | 22 ++
.../Sources/CollectionAssertionBase.cs | 23 ++
TUnit.Assertions/Sources/DelegateAssertion.cs | 22 ++
TUnit.Assertions/Sources/FuncAssertion.cs | 22 ++
TUnit.Assertions/Sources/TaskAssertion.cs | 52 +++
TUnit.Assertions/Sources/ValueAssertion.cs | 11 +
...Has_No_API_Changes.DotNet10_0.verified.txt | 50 ++-
..._Has_No_API_Changes.DotNet8_0.verified.txt | 50 ++-
..._Has_No_API_Changes.DotNet9_0.verified.txt | 50 ++-
...ary_Has_No_API_Changes.Net4_7.verified.txt | 50 ++-
20 files changed, 858 insertions(+), 87 deletions(-)
create mode 100644 TUnit.Assertions.Tests/TypeAssertionAmbiguityTests.cs
diff --git a/TUnit.Assertions.Tests/TypeAssertionAmbiguityTests.cs b/TUnit.Assertions.Tests/TypeAssertionAmbiguityTests.cs
new file mode 100644
index 0000000000..f7bf1d8031
--- /dev/null
+++ b/TUnit.Assertions.Tests/TypeAssertionAmbiguityTests.cs
@@ -0,0 +1,322 @@
+namespace TUnit.Assertions.Tests;
+
+///
+/// Tests to ensure no ambiguous invocation errors for type assertion methods.
+/// Regression tests for GitHub issue #3737.
+///
+public class TypeAssertionAmbiguityTests
+{
+ // Test hierarchy for inheritance testing
+ private interface IElement { }
+ private class Element : IElement { }
+ private class DerivedElement : Element { }
+
+ // ============ IsTypeOf TESTS ============
+
+ [Test]
+ public async Task IsTypeOf_SingleTypeParameter_NoAmbiguity()
+ {
+ object obj = "test";
+ var result = await Assert.That(obj).IsTypeOf();
+ await Assert.That(result).IsEqualTo("test");
+ }
+
+ [Test]
+ public async Task IsTypeOf_WithBaseType_NoAmbiguity()
+ {
+ IElement element = new Element();
+ var result = await Assert.That(element).IsTypeOf();
+ await Assert.That(result).IsNotNull();
+ }
+
+ [Test]
+ public async Task IsTypeOf_WithDerivedType_NoAmbiguity()
+ {
+ Element element = new DerivedElement();
+ var result = await Assert.That(element).IsTypeOf();
+ await Assert.That(result).IsNotNull();
+ }
+
+ // ============ IsNotTypeOf TESTS ============
+
+ [Test]
+ public async Task IsNotTypeOf_SingleTypeParameter_NoAmbiguity()
+ {
+ object obj = "test";
+ await Assert.That(obj).IsNotTypeOf();
+ }
+
+ [Test]
+ public async Task IsNotTypeOf_WithBaseType_NoAmbiguity()
+ {
+ IElement element = new Element();
+ await Assert.That(element).IsNotTypeOf();
+ }
+
+ [Test]
+ public async Task IsNotTypeOf_WithDerivedType_NoAmbiguity()
+ {
+ Element element = new Element();
+ await Assert.That(element).IsNotTypeOf();
+ }
+
+ [Test]
+ public async Task IsNotTypeOf_Fails_WhenTypeMatches()
+ {
+ await Assert.That(async () =>
+ {
+ object obj = "test";
+ await Assert.That(obj).IsNotTypeOf();
+ }).Throws();
+ }
+
+ // ============ IsAssignableTo TESTS ============
+
+ [Test]
+ public async Task IsAssignableTo_SingleTypeParameter_NoAmbiguity()
+ {
+ Element element = new Element();
+ await Assert.That(element).IsAssignableTo();
+ }
+
+ [Test]
+ public async Task IsAssignableTo_DerivedToBase_NoAmbiguity()
+ {
+ DerivedElement derived = new DerivedElement();
+ await Assert.That(derived).IsAssignableTo();
+ }
+
+ [Test]
+ public async Task IsAssignableTo_DerivedToInterface_NoAmbiguity()
+ {
+ DerivedElement derived = new DerivedElement();
+ await Assert.That(derived).IsAssignableTo();
+ }
+
+ [Test]
+ public async Task IsAssignableTo_ObjectVariable_NoAmbiguity()
+ {
+ object obj = new Element();
+ await Assert.That(obj).IsAssignableTo();
+ }
+
+ [Test]
+ public async Task IsAssignableTo_ExactType_NoAmbiguity()
+ {
+ Element element = new Element();
+ await Assert.That(element).IsAssignableTo();
+ }
+
+ [Test]
+ public async Task IsAssignableTo_Fails_WhenNotAssignable()
+ {
+ await Assert.That(async () =>
+ {
+ Element element = new Element();
+ await Assert.That(element).IsAssignableTo();
+ }).Throws();
+ }
+
+ // ============ IsNotAssignableTo TESTS ============
+
+ [Test]
+ public async Task IsNotAssignableTo_SingleTypeParameter_NoAmbiguity()
+ {
+ Element element = new Element();
+ await Assert.That(element).IsNotAssignableTo();
+ }
+
+ [Test]
+ public async Task IsNotAssignableTo_UnrelatedTypes_NoAmbiguity()
+ {
+ Element element = new Element();
+ await Assert.That(element).IsNotAssignableTo();
+ }
+
+ [Test]
+ public async Task IsNotAssignableTo_ObjectVariable_NoAmbiguity()
+ {
+ object obj = new Element();
+ await Assert.That(obj).IsNotAssignableTo();
+ }
+
+ [Test]
+ public async Task IsNotAssignableTo_Fails_WhenAssignable()
+ {
+ await Assert.That(async () =>
+ {
+ Element element = new Element();
+ await Assert.That(element).IsNotAssignableTo();
+ }).Throws();
+ }
+
+ // ============ ISSUE #3737 REGRESSION TESTS ============
+
+ [Test]
+ public async Task Issue3737_IsAssignableTo_WithInterface_NoAmbiguity()
+ {
+ Element element = new Element();
+ await Assert.That(element).IsAssignableTo();
+ }
+
+ [Test]
+ public async Task Issue3737_IsAssignableTo_WithBaseClass_NoAmbiguity()
+ {
+ DerivedElement derived = new DerivedElement();
+ await Assert.That(derived).IsAssignableTo();
+ }
+
+ [Test]
+ public async Task Issue3737_IsNotAssignableTo_WithUnrelatedType_NoAmbiguity()
+ {
+ Element element = new Element();
+ await Assert.That(element).IsNotAssignableTo();
+ }
+
+ // ============ CHAINING TESTS ============
+
+ [Test]
+ public async Task IsTypeOf_Chained_NoAmbiguity()
+ {
+ object obj = "test";
+ await Assert.That(obj)
+ .IsNotNull()
+ .And
+ .IsTypeOf()
+ .And
+ .HasLength(4);
+ }
+
+ [Test]
+ public async Task IsNotTypeOf_Chained_NoAmbiguity()
+ {
+ object obj = "test";
+ await Assert.That(obj)
+ .IsNotNull()
+ .And
+ .IsNotTypeOf()
+ .And
+ .IsTypeOf();
+ }
+
+ [Test]
+ public async Task IsAssignableTo_Chained_NoAmbiguity()
+ {
+ Element element = new Element();
+ await Assert.That(element)
+ .IsNotNull()
+ .And
+ .IsAssignableTo()
+ .And
+ .IsAssignableTo();
+ }
+
+ [Test]
+ public async Task IsNotAssignableTo_Chained_NoAmbiguity()
+ {
+ Element element = new Element();
+ await Assert.That(element)
+ .IsNotNull()
+ .And
+ .IsNotAssignableTo()
+ .And
+ .IsNotAssignableTo();
+ }
+
+ // ============ GENERIC TYPE TESTS ============
+
+ [Test]
+ public async Task IsTypeOf_GenericType_NoAmbiguity()
+ {
+ object obj = new List { "a", "b" };
+ var result = await Assert.That(obj).IsTypeOf>();
+ await Assert.That(result.Count).IsEqualTo(2);
+ }
+
+ [Test]
+ public async Task IsNotTypeOf_GenericType_NoAmbiguity()
+ {
+ object obj = new List();
+ await Assert.That(obj).IsNotTypeOf>();
+ }
+
+ [Test]
+ public async Task IsAssignableTo_GenericInterface_NoAmbiguity()
+ {
+ List list = new List();
+ await Assert.That(list).IsAssignableTo>();
+ }
+
+ [Test]
+ public async Task IsNotAssignableTo_WrongGenericType_NoAmbiguity()
+ {
+ List list = new List();
+ await Assert.That(list).IsNotAssignableTo>();
+ }
+
+ // ============ VALUE TYPE TESTS ============
+
+ [Test]
+ public async Task IsTypeOf_ValueType_NoAmbiguity()
+ {
+ object boxed = 42;
+ var result = await Assert.That(boxed).IsTypeOf();
+ await Assert.That(result).IsEqualTo(42);
+ }
+
+ [Test]
+ public async Task IsNotTypeOf_ValueType_NoAmbiguity()
+ {
+ object boxed = 42;
+ await Assert.That(boxed).IsNotTypeOf();
+ }
+
+ [Test]
+ public async Task IsAssignableTo_ValueType_NoAmbiguity()
+ {
+ object boxed = 42;
+ await Assert.That(boxed).IsAssignableTo();
+ }
+
+ [Test]
+ public async Task IsNotAssignableTo_ValueType_NoAmbiguity()
+ {
+ object boxed = 42;
+ await Assert.That(boxed).IsNotAssignableTo();
+ }
+
+ // ============ ALL FOUR METHODS TOGETHER ============
+
+ [Test]
+ public async Task AllFourMethods_Combined_NoAmbiguity()
+ {
+ object obj1 = new Element();
+ await Assert.That(obj1).IsTypeOf();
+
+ object obj2 = "test";
+ await Assert.That(obj2).IsNotTypeOf();
+
+ Element element = new Element();
+ await Assert.That(element).IsAssignableTo();
+
+ Element element2 = new Element();
+ await Assert.That(element2).IsNotAssignableTo();
+ }
+
+ [Test]
+ public async Task AllFourMethods_InSingleChain_NoAmbiguity()
+ {
+ object obj = new Element();
+
+ await Assert.That(obj)
+ .IsNotNull()
+ .And
+ .IsNotTypeOf()
+ .And
+ .IsTypeOf()
+ .And
+ .IsAssignableTo()
+ .And
+ .IsNotAssignableTo();
+ }
+}
diff --git a/TUnit.Assertions/Assertions/PropertyAssertion.cs b/TUnit.Assertions/Assertions/PropertyAssertion.cs
index 8112b3db53..5b167cf11e 100644
--- a/TUnit.Assertions/Assertions/PropertyAssertion.cs
+++ b/TUnit.Assertions/Assertions/PropertyAssertion.cs
@@ -121,6 +121,28 @@ public TypeOfAssertion IsTypeOf()
return new TypeOfAssertion(Context);
}
+ public IsAssignableToAssertion IsAssignableTo()
+ {
+ Context.ExpressionBuilder.Append($".IsAssignableTo<{typeof(TTarget).Name}>()");
+ return new IsAssignableToAssertion(Context);
+ }
+
+ public IsNotAssignableToAssertion IsNotAssignableTo()
+ {
+ Context.ExpressionBuilder.Append($".IsNotAssignableTo<{typeof(TTarget).Name}>()");
+ return new IsNotAssignableToAssertion(Context);
+ }
+
+ ///
+ /// Asserts that the parent object is NOT of the specified type.
+ /// Example: await Assert.That(obj).HasProperty(x => x.Name).IsEqualTo("test").IsNotTypeOf();
+ ///
+ public IsNotTypeOfAssertion IsNotTypeOf()
+ {
+ Context.ExpressionBuilder.Append($".IsNotTypeOf<{typeof(TExpected).Name}>()");
+ return new IsNotTypeOfAssertion(Context);
+ }
+
///
/// Enables await syntax by executing the property assertion and returning the parent object.
///
diff --git a/TUnit.Assertions/Assertions/Strings/ParseAssertions.cs b/TUnit.Assertions/Assertions/Strings/ParseAssertions.cs
index 231f4091c9..944333a3f2 100644
--- a/TUnit.Assertions/Assertions/Strings/ParseAssertions.cs
+++ b/TUnit.Assertions/Assertions/Strings/ParseAssertions.cs
@@ -253,6 +253,28 @@ public TypeOfAssertion IsTypeOf()
return new TypeOfAssertion(Context);
}
+ public IsAssignableToAssertion IsAssignableTo()
+ {
+ Context.ExpressionBuilder.Append($".IsAssignableTo<{typeof(TTarget).Name}>()");
+ return new IsAssignableToAssertion(Context);
+ }
+
+ public IsNotAssignableToAssertion IsNotAssignableTo()
+ {
+ Context.ExpressionBuilder.Append($".IsNotAssignableTo<{typeof(TTarget).Name}>()");
+ return new IsNotAssignableToAssertion(Context);
+ }
+
+ ///
+ /// Asserts that the parsed value is NOT of the specified type.
+ /// Example: await Assert.That("123").WhenParsedInto
+ public IsNotTypeOfAssertion IsNotTypeOf()
+ {
+ Context.ExpressionBuilder.Append($".IsNotTypeOf<{typeof(TExpected).Name}>()");
+ return new IsNotTypeOfAssertion(Context);
+ }
+
///
/// Specifies the format provider to use when parsing.
/// Returns a new assertion with the format provider set.
diff --git a/TUnit.Assertions/Conditions/MemberAssertion.cs b/TUnit.Assertions/Conditions/MemberAssertion.cs
index 991c6147b5..7fd76d7be5 100644
--- a/TUnit.Assertions/Conditions/MemberAssertion.cs
+++ b/TUnit.Assertions/Conditions/MemberAssertion.cs
@@ -146,6 +146,28 @@ public TypeOfAssertion IsTypeOf()
Context.ExpressionBuilder.Append($".IsTypeOf<{typeof(TExpected).Name}>()");
return new TypeOfAssertion(Context);
}
+
+ public IsAssignableToAssertion IsAssignableTo()
+ {
+ Context.ExpressionBuilder.Append($".IsAssignableTo<{typeof(TTarget).Name}>()");
+ return new IsAssignableToAssertion(Context);
+ }
+
+ public IsNotAssignableToAssertion IsNotAssignableTo()
+ {
+ Context.ExpressionBuilder.Append($".IsNotAssignableTo<{typeof(TTarget).Name}>()");
+ return new IsNotAssignableToAssertion(Context);
+ }
+
+ ///
+ /// Asserts that the value is NOT of the specified type.
+ /// Example: await Assert.That(obj).Member(x => x.Property).Satisfies(val => val.IsNotTypeOf());
+ ///
+ public IsNotTypeOfAssertion IsNotTypeOf()
+ {
+ Context.ExpressionBuilder.Append($".IsNotTypeOf<{typeof(TExpected).Name}>()");
+ return new IsNotTypeOfAssertion(Context);
+ }
}
///
diff --git a/TUnit.Assertions/Conditions/TypeOfAssertion.cs b/TUnit.Assertions/Conditions/TypeOfAssertion.cs
index 323c4dec05..4121c14e25 100644
--- a/TUnit.Assertions/Conditions/TypeOfAssertion.cs
+++ b/TUnit.Assertions/Conditions/TypeOfAssertion.cs
@@ -48,11 +48,52 @@ protected override Task CheckAsync(EvaluationMetadata meta
protected override string GetExpectation() => $"to be of type {_expectedType.Name}";
}
+///
+/// Asserts that a value is NOT exactly of the specified type.
+///
+public class IsNotTypeOfAssertion : Assertion
+{
+ private readonly Type _expectedType;
+
+ public IsNotTypeOfAssertion(
+ AssertionContext context)
+ : base(context)
+ {
+ _expectedType = typeof(TExpected);
+ }
+
+ protected override Task CheckAsync(EvaluationMetadata metadata)
+ {
+ var value = metadata.Value;
+ var exception = metadata.Exception;
+
+ if (exception != null)
+ {
+ return Task.FromResult(AssertionResult.Failed($"threw {exception.GetType().Name}"));
+ }
+
+ if (value == null)
+ {
+ return Task.FromResult(AssertionResult.Failed("value was null"));
+ }
+
+ var actualType = value.GetType();
+
+ if (actualType != _expectedType)
+ {
+ return Task.FromResult(AssertionResult.Passed);
+ }
+
+ return Task.FromResult(AssertionResult.Failed($"type was {actualType.Name}"));
+ }
+
+ protected override string GetExpectation() => $"to not be of type {_expectedType.Name}";
+}
+
///
/// Asserts that a value's type is assignable to a specific type (is the type or a subtype).
/// Works with both direct value assertions and exception assertions (via .And after Throws).
///
-[AssertionExtension("IsAssignableTo")]
public class IsAssignableToAssertion : Assertion
{
private readonly Type _targetType;
@@ -103,7 +144,6 @@ protected override Task CheckAsync(EvaluationMetadata m
/// Asserts that a value's type is NOT assignable to a specific type.
/// Works with both direct value assertions and exception assertions (via .And after Throws).
///
-[AssertionExtension("IsNotAssignableTo")]
public class IsNotAssignableToAssertion : Assertion
{
private readonly Type _targetType;
diff --git a/TUnit.Assertions/Conditions/Wrappers/CountWrapper.cs b/TUnit.Assertions/Conditions/Wrappers/CountWrapper.cs
index 34095447a1..f2b2ea3364 100644
--- a/TUnit.Assertions/Conditions/Wrappers/CountWrapper.cs
+++ b/TUnit.Assertions/Conditions/Wrappers/CountWrapper.cs
@@ -33,6 +33,36 @@ TypeOfAssertion IAssertionSource.IsTypeOf>().HasCount().EqualTo(5)");
}
+ ///
+ /// Not supported on CountWrapper - use IsAssignableTo on the assertion source before calling HasCount().
+ ///
+ IsAssignableToAssertion IAssertionSource.IsAssignableTo()
+ {
+ throw new NotSupportedException(
+ "IsAssignableTo is not supported after HasCount(). " +
+ "Use: Assert.That(value).IsAssignableTo>().HasCount().EqualTo(5)");
+ }
+
+ ///
+ /// Not supported on CountWrapper - use IsNotAssignableTo on the assertion source before calling HasCount().
+ ///
+ IsNotAssignableToAssertion IAssertionSource.IsNotAssignableTo()
+ {
+ throw new NotSupportedException(
+ "IsNotAssignableTo is not supported after HasCount(). " +
+ "Use: Assert.That(value).IsNotAssignableTo>().HasCount().EqualTo(5)");
+ }
+
+ ///
+ /// Not supported on CountWrapper - use IsNotTypeOf on the assertion source before calling HasCount().
+ ///
+ IsNotTypeOfAssertion IAssertionSource.IsNotTypeOf()
+ {
+ throw new NotSupportedException(
+ "IsNotTypeOf is not supported after HasCount(). " +
+ "Use: Assert.That(value).IsNotTypeOf>().HasCount().EqualTo(5)");
+ }
+
///
/// Asserts that the collection count is equal to the expected count.
///
diff --git a/TUnit.Assertions/Conditions/Wrappers/LengthWrapper.cs b/TUnit.Assertions/Conditions/Wrappers/LengthWrapper.cs
index ec81506cf2..c1d027bfcf 100644
--- a/TUnit.Assertions/Conditions/Wrappers/LengthWrapper.cs
+++ b/TUnit.Assertions/Conditions/Wrappers/LengthWrapper.cs
@@ -30,6 +30,36 @@ TypeOfAssertion IAssertionSource.IsTypeOf(
"Use: Assert.That(value).IsTypeOf().HasLength().EqualTo(5)");
}
+ ///
+ /// Not supported on LengthWrapper - use IsAssignableTo on the assertion source before calling HasLength().
+ ///
+ IsAssignableToAssertion IAssertionSource.IsAssignableTo()
+ {
+ throw new NotSupportedException(
+ "IsAssignableTo is not supported after HasLength(). " +
+ "Use: Assert.That(value).IsAssignableTo().HasLength().EqualTo(5)");
+ }
+
+ ///
+ /// Not supported on LengthWrapper - use IsNotAssignableTo on the assertion source before calling HasLength().
+ ///
+ IsNotAssignableToAssertion IAssertionSource.IsNotAssignableTo()
+ {
+ throw new NotSupportedException(
+ "IsNotAssignableTo is not supported after HasLength(). " +
+ "Use: Assert.That(value).IsNotAssignableTo().HasLength().EqualTo(5)");
+ }
+
+ ///
+ /// Not supported on LengthWrapper - use IsNotTypeOf on the assertion source before calling HasLength().
+ ///
+ IsNotTypeOfAssertion IAssertionSource.IsNotTypeOf()
+ {
+ throw new NotSupportedException(
+ "IsNotTypeOf is not supported after HasLength(). " +
+ "Use: Assert.That(value).IsNotTypeOf().HasLength().EqualTo(5)");
+ }
+
///
/// Asserts that the string length is equal to the expected length.
///
diff --git a/TUnit.Assertions/Core/IAssertionSource.cs b/TUnit.Assertions/Core/IAssertionSource.cs
index 54f884f75f..92a34e915a 100644
--- a/TUnit.Assertions/Core/IAssertionSource.cs
+++ b/TUnit.Assertions/Core/IAssertionSource.cs
@@ -24,10 +24,22 @@ public interface IAssertionSource : IAssertionSource
AssertionContext Context { get; }
///
- /// Asserts that the value is of the specified type and returns an assertion on the casted value.
- /// This allows chaining additional assertions on the typed value.
- /// Only available at assertion source points (initial Assert.That, or after .And/.Or).
- /// Example: await Assert.That(obj).IsTypeOf<string>().And.HasLength(5);
+ /// Asserts that the value is assignment-compatible with the specified type.
///
TypeOfAssertion IsTypeOf();
+
+ ///
+ /// Asserts that the value is NOT exactly of the specified type.
+ ///
+ IsNotTypeOfAssertion IsNotTypeOf();
+
+ ///
+ /// Asserts that the value's type is assignable to the specified type.
+ ///
+ IsAssignableToAssertion IsAssignableTo();
+
+ ///
+ /// Asserts that the value's type is NOT assignable to the specified type.
+ ///
+ IsNotAssignableToAssertion IsNotAssignableTo();
}
diff --git a/TUnit.Assertions/Extensions/AssertionExtensions.cs b/TUnit.Assertions/Extensions/AssertionExtensions.cs
index 1261b6fa33..d42eabee1e 100644
--- a/TUnit.Assertions/Extensions/AssertionExtensions.cs
+++ b/TUnit.Assertions/Extensions/AssertionExtensions.cs
@@ -1532,33 +1532,4 @@ public static Assertions.Enums.DoesNotHaveSameValueAsAssertion DoesNotHav
return new Assertions.Enums.DoesNotHaveSameValueAsAssertion(source.Context, otherEnumValue);
}
- ///
- /// Asserts that a value's type is assignable to a specific type (specialized for object).
- ///
- public static IsAssignableToAssertion IsAssignableTo(
- this IAssertionSource