diff --git a/TUnit.Core/Helpers/CastHelper.cs b/TUnit.Core/Helpers/CastHelper.cs
index 837c482d5c..060b439e71 100644
--- a/TUnit.Core/Helpers/CastHelper.cs
+++ b/TUnit.Core/Helpers/CastHelper.cs
@@ -29,6 +29,28 @@ public static class CastHelper
return (T?)result;
}
+ ///
+ /// Returns the value as-is if it's null or already assignable to the target type;
+ /// otherwise delegates to to apply conversion operators.
+ /// Unlike , null input always returns null (no default-value creation for value types).
+ /// In Native AOT mode, throws when reflection-based
+ /// operator discovery is unavailable.
+ ///
+ [UnconditionalSuppressMessage("AOT", "IL3050:Calling members annotated with 'RequiresDynamicCodeAttribute' may break functionality when AOT compiling.")]
+ public static object? CastIfNeeded(
+ [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor
+ | DynamicallyAccessedMemberTypes.Interfaces
+ | DynamicallyAccessedMemberTypes.PublicMethods)] Type type,
+ object? value)
+ {
+ if (value is null || value.GetType().IsAssignableTo(type))
+ {
+ return value;
+ }
+
+ return Cast(type, value);
+ }
+
///
/// Attempts to cast or convert a value to the specified type.
/// Conversion priority:
diff --git a/TUnit.Engine/Services/ObjectLifecycleService.cs b/TUnit.Engine/Services/ObjectLifecycleService.cs
index bbc0f0590b..a582ffa897 100644
--- a/TUnit.Engine/Services/ObjectLifecycleService.cs
+++ b/TUnit.Engine/Services/ObjectLifecycleService.cs
@@ -173,6 +173,7 @@ public async Task InitializeObjectForExecutionAsync(object? obj, CancellationTok
/// This is used to apply cached property values to new instances created during retries.
///
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Reflection mode is not used in AOT")]
+ [UnconditionalSuppressMessage("Trimming", "IL2072", Justification = "PropertyType is preserved through source generation or reflection discovery — annotation can't flow through PropertyInfo/PropertyInjectionMetadata")]
private void SetCachedPropertiesOnInstance(object instance, TestContext testContext)
{
var plan = PropertyInjectionCache.GetOrCreatePlan(instance.GetType());
@@ -192,7 +193,8 @@ private void SetCachedPropertiesOnInstance(object instance, TestContext testCont
if (cachedProperties.TryGetValue(cacheKey, out var cachedValue) && cachedValue != null)
{
- // Set the cached value on the new instance
+ // Convert if needed — cached values may be unconverted when pre-resolved during registration
+ cachedValue = CastHelper.CastIfNeeded(metadata.PropertyType, cachedValue);
metadata.SetProperty(instance, cachedValue);
}
}
@@ -205,7 +207,8 @@ private void SetCachedPropertiesOnInstance(object instance, TestContext testCont
if (cachedProperties.TryGetValue(cacheKey, out var cachedValue) && cachedValue != null)
{
- // Set the cached value on the new instance
+ // Convert if needed — cached values may be unconverted when pre-resolved during registration
+ cachedValue = CastHelper.CastIfNeeded(property.PropertyType, cachedValue);
var setter = PropertySetterFactory.CreateSetter(property);
setter(instance, cachedValue);
}
diff --git a/TUnit.Engine/Services/PropertyInjector.cs b/TUnit.Engine/Services/PropertyInjector.cs
index ec81f56c15..f2df872ea6 100644
--- a/TUnit.Engine/Services/PropertyInjector.cs
+++ b/TUnit.Engine/Services/PropertyInjector.cs
@@ -241,6 +241,7 @@ private Task InjectSourceGeneratedPropertiesAsync(
}
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Source-gen properties are AOT-safe")]
+ [UnconditionalSuppressMessage("Trimming", "IL2072", Justification = "PropertyType is preserved through source generation — annotation can't flow through PropertyInjectionMetadata interface")]
[UnconditionalSuppressMessage("Trimming", "IL2075", Justification = "ContainingType is annotated with DynamicallyAccessedMembers in PropertyInjectionMetadata")]
private async Task InjectSourceGeneratedPropertyAsync(
object instance,
@@ -285,14 +286,21 @@ private async Task InjectSourceGeneratedPropertyAsync(
}
}
+ // Convert the value if the runtime type doesn't match the property type.
+ // This handles implicit/explicit conversion operators when the source generator
+ // doesn't know the data source type (e.g., custom data sources).
+ resolvedValue = CastHelper.CastIfNeeded(metadata.PropertyType, resolvedValue);
+
// Set the property value
metadata.SetProperty(instance, resolvedValue);
- // Store for potential reuse with composite key
+ // Store the converted value for potential reuse (e.g., retries).
+ // Use indexer to overwrite any pre-resolved unconverted value so that
+ // SetCachedPropertiesOnInstance can use the value directly without re-converting.
if (testContext != null)
{
((ConcurrentDictionary)testContext.Metadata.TestDetails.TestClassInjectedPropertyArguments)
- .TryAdd(cacheKey, resolvedValue);
+ [cacheKey] = resolvedValue;
}
}
@@ -311,6 +319,7 @@ private Task InjectReflectionPropertiesAsync(
}
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Reflection mode is not used in AOT")]
+ [UnconditionalSuppressMessage("Trimming", "IL2072", Justification = "PropertyType is preserved through reflection discovery — annotation can't flow through PropertyInfo.PropertyType")]
private async Task InjectReflectionPropertyAsync(
object instance,
PropertyInfo property,
@@ -334,6 +343,11 @@ private async Task InjectReflectionPropertyAsync(
return;
}
+ // Convert the value if the runtime type doesn't match the property type.
+ // This handles implicit/explicit conversion operators when the source generator
+ // doesn't know the data source type (e.g., custom data sources).
+ resolvedValue = CastHelper.CastIfNeeded(property.PropertyType, resolvedValue);
+
propertySetter(instance, resolvedValue);
}
diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt
index e88e081dd5..a750c49483 100644
--- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt
+++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt
@@ -2132,6 +2132,9 @@ namespace .Helpers
"ile time. The runtime TryParsableConvert fallback is only used in non-AOT scenar" +
"ios.")]
public static T? Cast<[.(..PublicParameterlessConstructor)] T>(object? value) { }
+ [.("AOT", "IL3050:Calling members annotated with \'RequiresDynamicCodeAttribute\' may break fu" +
+ "nctionality when AOT compiling.")]
+ public static object? CastIfNeeded([.(..None | ..PublicParameterlessConstructor | ..PublicMethods | ..Interfaces)] type, object? value) { }
}
public static class ClassConstructorHelper
{
diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt
index c522b2d530..348b7a337e 100644
--- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt
+++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt
@@ -2132,6 +2132,9 @@ namespace .Helpers
"ile time. The runtime TryParsableConvert fallback is only used in non-AOT scenar" +
"ios.")]
public static T? Cast<[.(..PublicParameterlessConstructor)] T>(object? value) { }
+ [.("AOT", "IL3050:Calling members annotated with \'RequiresDynamicCodeAttribute\' may break fu" +
+ "nctionality when AOT compiling.")]
+ public static object? CastIfNeeded([.(..None | ..PublicParameterlessConstructor | ..PublicMethods | ..Interfaces)] type, object? value) { }
}
public static class ClassConstructorHelper
{
diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt
index f3bcb841ed..f3a7961d03 100644
--- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt
+++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt
@@ -2132,6 +2132,9 @@ namespace .Helpers
"ile time. The runtime TryParsableConvert fallback is only used in non-AOT scenar" +
"ios.")]
public static T? Cast<[.(..PublicParameterlessConstructor)] T>(object? value) { }
+ [.("AOT", "IL3050:Calling members annotated with \'RequiresDynamicCodeAttribute\' may break fu" +
+ "nctionality when AOT compiling.")]
+ public static object? CastIfNeeded([.(..None | ..PublicParameterlessConstructor | ..PublicMethods | ..Interfaces)] type, object? value) { }
}
public static class ClassConstructorHelper
{
diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt
index e15ce58db6..685adf2d3a 100644
--- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt
+++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt
@@ -2069,6 +2069,7 @@ namespace .Helpers
{
public static object? Cast( type, object? value) { }
public static T? Cast(object? value) { }
+ public static object? CastIfNeeded( type, object? value) { }
}
public static class ClassConstructorHelper
{
diff --git a/TUnit.TestProject/ImplicitOperatorPropertyInjectionTests.cs b/TUnit.TestProject/ImplicitOperatorPropertyInjectionTests.cs
new file mode 100644
index 0000000000..7b2ccb155f
--- /dev/null
+++ b/TUnit.TestProject/ImplicitOperatorPropertyInjectionTests.cs
@@ -0,0 +1,29 @@
+using TUnit.TestProject.Attributes;
+
+namespace TUnit.TestProject;
+
+[EngineTest(ExpectedResult.Pass)]
+[DynamicCodeOnly]
+public class ImplicitOperatorPropertyInjectionTests
+{
+ [ClassDataSource]
+ public required ImplicitDbContext Db { get; init; }
+
+ [Test]
+ public async Task Injection_Works_With_Implicit_Operator()
+ {
+ await Assert.That(Db).IsNotNull();
+ await Assert.That(Db.ConnStr).IsEqualTo("test-conn");
+ }
+}
+
+public class ImplicitDbContext(string connStr)
+{
+ public string ConnStr { get; } = connStr;
+}
+
+public class ImplicitDbFixture : IAsyncDisposable
+{
+ public static implicit operator ImplicitDbContext(ImplicitDbFixture f) => new("test-conn");
+ public ValueTask DisposeAsync() => ValueTask.CompletedTask;
+}
diff --git a/TUnit.TestProject/RuntimeConversionPropertyInjectionTests.cs b/TUnit.TestProject/RuntimeConversionPropertyInjectionTests.cs
new file mode 100644
index 0000000000..9d6b05cb53
--- /dev/null
+++ b/TUnit.TestProject/RuntimeConversionPropertyInjectionTests.cs
@@ -0,0 +1,293 @@
+#pragma warning disable TUnit0042
+
+using TUnit.TestProject.Attributes;
+
+namespace TUnit.TestProject;
+
+#region Types for runtime conversion tests
+
+// --- Implicit operator on source type (source defines how to convert to target) ---
+
+public class RuntimeSourceWithImplicit
+{
+ public string Value { get; init; } = "from-source-implicit";
+ public static implicit operator RuntimeTarget(RuntimeSourceWithImplicit s) => new() { Value = s.Value };
+}
+
+public class RuntimeTarget
+{
+ public string Value { get; init; } = "";
+}
+
+// --- Explicit operator on source type ---
+
+public class RuntimeSourceWithExplicit
+{
+ public string Value { get; init; } = "from-source-explicit";
+ public static explicit operator RuntimeExplicitTarget(RuntimeSourceWithExplicit s) => new() { Value = s.Value };
+}
+
+public class RuntimeExplicitTarget
+{
+ public string Value { get; init; } = "";
+}
+
+// --- Implicit operator on target type (target defines how to convert from source) ---
+
+public class RuntimeTargetDefinesImplicit
+{
+ public string Value { get; init; } = "";
+ public static implicit operator RuntimeTargetDefinesImplicit(RuntimeSourceForTargetImplicit s) => new() { Value = s.Value };
+}
+
+public class RuntimeSourceForTargetImplicit
+{
+ public string Value { get; init; } = "from-target-implicit";
+}
+
+// --- Struct with implicit operator ---
+
+public readonly struct RuntimeValueWrapper
+{
+ public string Value { get; init; }
+ public RuntimeValueWrapper(string value) => Value = value;
+ public static implicit operator RuntimeValueTarget(RuntimeValueWrapper w) => new() { Value = w.Value };
+}
+
+public class RuntimeValueTarget
+{
+ public string Value { get; init; } = "";
+}
+
+// --- Interface-based conversion (same type, no conversion needed) ---
+
+public class RuntimeSameTypeData : IAsyncDisposable
+{
+ public string Value { get; init; } = "same-type";
+ public ValueTask DisposeAsync() => ValueTask.CompletedTask;
+}
+
+// --- Custom UntypedDataSourceGeneratorAttribute implementations ---
+
+///
+/// Custom data source that yields a RuntimeSourceWithImplicit instance
+/// for a property typed as RuntimeTarget. The source generator has no type info
+/// for the produced value — conversion must happen at runtime via CastHelper.
+///
+public class ImplicitSourceDataSourceAttribute : UntypedDataSourceGeneratorAttribute
+{
+ protected override IEnumerable> GenerateDataSources(DataGeneratorMetadata dataGeneratorMetadata)
+ {
+ yield return () => [new RuntimeSourceWithImplicit { Value = "custom-implicit" }];
+ }
+}
+
+///
+/// Custom data source that yields a RuntimeSourceWithExplicit instance
+/// for a property typed as RuntimeExplicitTarget.
+///
+public class ExplicitSourceDataSourceAttribute : UntypedDataSourceGeneratorAttribute
+{
+ protected override IEnumerable> GenerateDataSources(DataGeneratorMetadata dataGeneratorMetadata)
+ {
+ yield return () => [new RuntimeSourceWithExplicit { Value = "custom-explicit" }];
+ }
+}
+
+///
+/// Custom data source where the target type defines the implicit conversion from the source type.
+///
+public class TargetDefinesImplicitDataSourceAttribute : UntypedDataSourceGeneratorAttribute
+{
+ protected override IEnumerable> GenerateDataSources(DataGeneratorMetadata dataGeneratorMetadata)
+ {
+ yield return () => [new RuntimeSourceForTargetImplicit { Value = "target-defines-implicit" }];
+ }
+}
+
+///
+/// Custom data source that yields a value type (struct) with an implicit operator.
+///
+public class StructImplicitDataSourceAttribute : UntypedDataSourceGeneratorAttribute
+{
+ protected override IEnumerable> GenerateDataSources(DataGeneratorMetadata dataGeneratorMetadata)
+ {
+ yield return () => [new RuntimeValueWrapper("struct-implicit")];
+ }
+}
+
+///
+/// Custom data source that yields the same type as the property (no conversion needed).
+/// Baseline test to ensure no regression when types match.
+///
+public class SameTypeDataSourceAttribute : UntypedDataSourceGeneratorAttribute
+{
+ protected override IEnumerable> GenerateDataSources(DataGeneratorMetadata dataGeneratorMetadata)
+ {
+ yield return () => [new RuntimeSameTypeData { Value = "same-type-custom" }];
+ }
+}
+
+#endregion
+
+#region Test classes
+
+///
+/// Tests the runtime conversion fallback with a custom untyped data source
+/// that yields a type with an implicit operator defined on the source type.
+/// The source generator cannot know what type the custom data source will produce,
+/// so conversion must happen at runtime via CastHelper.
+///
+[EngineTest(ExpectedResult.Pass)]
+[DynamicCodeOnly]
+public class RuntimeImplicitConversionFromCustomDataSourceTests
+{
+ [ImplicitSourceDataSource]
+ public required RuntimeTarget Target { get; init; }
+
+ [Test]
+ public async Task Custom_DataSource_With_Implicit_Operator_On_Source()
+ {
+ await Assert.That(Target).IsNotNull();
+ await Assert.That(Target.Value).IsEqualTo("custom-implicit");
+ }
+}
+
+///
+/// Tests the runtime conversion fallback with a custom untyped data source
+/// that yields a type with an explicit operator.
+///
+[EngineTest(ExpectedResult.Pass)]
+[DynamicCodeOnly]
+public class RuntimeExplicitConversionFromCustomDataSourceTests
+{
+ [ExplicitSourceDataSource]
+ public required RuntimeExplicitTarget Target { get; init; }
+
+ [Test]
+ public async Task Custom_DataSource_With_Explicit_Operator()
+ {
+ await Assert.That(Target).IsNotNull();
+ await Assert.That(Target.Value).IsEqualTo("custom-explicit");
+ }
+}
+
+///
+/// Tests the runtime conversion fallback where the target type defines the implicit
+/// conversion operator (as opposed to the source type).
+///
+[EngineTest(ExpectedResult.Pass)]
+[DynamicCodeOnly]
+public class RuntimeImplicitOnTargetTypeConversionTests
+{
+ [TargetDefinesImplicitDataSource]
+ public required RuntimeTargetDefinesImplicit Target { get; init; }
+
+ [Test]
+ public async Task Custom_DataSource_With_Implicit_Operator_On_Target()
+ {
+ await Assert.That(Target).IsNotNull();
+ await Assert.That(Target.Value).IsEqualTo("target-defines-implicit");
+ }
+}
+
+///
+/// Tests the runtime conversion fallback with a struct (value type) that defines
+/// an implicit operator. This exercises the boxing/unboxing + conversion path.
+///
+[EngineTest(ExpectedResult.Pass)]
+[DynamicCodeOnly]
+public class RuntimeStructImplicitConversionTests
+{
+ [StructImplicitDataSource]
+ public required RuntimeValueTarget Target { get; init; }
+
+ [Test]
+ public async Task Custom_DataSource_With_Struct_Implicit_Operator()
+ {
+ await Assert.That(Target).IsNotNull();
+ await Assert.That(Target.Value).IsEqualTo("struct-implicit");
+ }
+}
+
+///
+/// Baseline test: custom data source yields the same type as the property.
+/// No conversion is needed — verifies no regression when types already match.
+///
+[EngineTest(ExpectedResult.Pass)]
+[DynamicCodeOnly]
+public class RuntimeSameTypeNoConversionTests
+{
+ [SameTypeDataSource]
+ public required RuntimeSameTypeData Data { get; init; }
+
+ [Test]
+ public async Task Custom_DataSource_Same_Type_No_Conversion_Needed()
+ {
+ await Assert.That(Data).IsNotNull();
+ await Assert.That(Data.Value).IsEqualTo("same-type-custom");
+ }
+}
+
+///
+/// Tests the runtime conversion fallback via MethodDataSource which returns a
+/// different type than the property type. The method returns RuntimeSourceWithImplicit
+/// but the property expects RuntimeTarget.
+///
+[EngineTest(ExpectedResult.Pass)]
+[DynamicCodeOnly]
+public class RuntimeMethodDataSourceImplicitConversionTests
+{
+ [MethodDataSource(nameof(GetSource))]
+ public required RuntimeTarget Target { get; init; }
+
+ public static RuntimeSourceWithImplicit GetSource() => new() { Value = "method-source-implicit" };
+
+ [Test]
+ public async Task MethodDataSource_With_Implicit_Operator_Runtime_Conversion()
+ {
+ await Assert.That(Target).IsNotNull();
+ await Assert.That(Target.Value).IsEqualTo("method-source-implicit");
+ }
+}
+
+///
+/// Explicitly exercises the cached property path used for retry-created instances.
+/// The first attempt fails after the converted property has been applied, and the retry
+/// must reapply the cached raw value through the cached-property preparation path to a new instance.
+///
+[EngineTest(ExpectedResult.Pass)]
+[DynamicCodeOnly]
+[NotInParallel(nameof(RuntimeCachedRetryImplicitConversionTests))]
+public class RuntimeCachedRetryImplicitConversionTests
+{
+ private static int _attemptCount;
+
+ [ImplicitSourceDataSource]
+ public required RuntimeTarget Target { get; init; }
+
+ [Before(TestSession)]
+ public static void ResetAttempts()
+ {
+ _attemptCount = 0;
+ }
+
+ [Test]
+ [Retry(1)]
+ public async Task Cached_Runtime_Conversion_Works_For_Retry_Created_Instance()
+ {
+ _attemptCount++;
+
+ await Assert.That(Target).IsNotNull();
+ await Assert.That(Target.Value).IsEqualTo("custom-implicit");
+
+ if (_attemptCount == 1)
+ {
+ throw new Exception("Force a retry so cached property values are applied to a new instance");
+ }
+
+ await Assert.That(_attemptCount).IsEqualTo(2);
+ }
+}
+
+#endregion