diff --git a/TUnit.Analyzers.Tests/DataDrivenTestArgumentsAnalyzerTests.cs b/TUnit.Analyzers.Tests/DataDrivenTestArgumentsAnalyzerTests.cs index bb1cec15a9..0b45d57b45 100644 --- a/TUnit.Analyzers.Tests/DataDrivenTestArgumentsAnalyzerTests.cs +++ b/TUnit.Analyzers.Tests/DataDrivenTestArgumentsAnalyzerTests.cs @@ -309,4 +309,26 @@ public override async IAsyncEnumerable>> GetTypedDataRowsAsync """ ); } + + [Test] + public async Task Nullable_Parsable_Type_Is_Not_Flagged_When_String_Argument() + { + await Verifier + .VerifyAnalyzerAsync( + """ + using System; + using TUnit.Core; + + public class MyClass + { + [Test] + [Arguments("2026/04/03 15:45")] + [Arguments(null)] + public void MyTest(DateTime? value) + { + } + } + """ + ); + } } diff --git a/TUnit.Core.SourceGenerator/CodeGenerators/Formatting/TypedConstantFormatter.cs b/TUnit.Core.SourceGenerator/CodeGenerators/Formatting/TypedConstantFormatter.cs index 68e354a8a5..241920e987 100644 --- a/TUnit.Core.SourceGenerator/CodeGenerators/Formatting/TypedConstantFormatter.cs +++ b/TUnit.Core.SourceGenerator/CodeGenerators/Formatting/TypedConstantFormatter.cs @@ -226,7 +226,14 @@ private string FormatPrimitiveForCode(object? value, ITypeSymbol? targetType) if (value is string strForParsing && targetType.SpecialType != SpecialType.System_String && targetType.IsParsableFromString()) { - var fullyQualifiedName = targetType.GloballyQualified(); + // For nullable value types, use the underlying type for Parse + var parseType = targetType; + if (targetType is INamedTypeSymbol { IsGenericType: true, ConstructedFrom.SpecialType: SpecialType.System_Nullable_T } nullableType) + { + parseType = nullableType.TypeArguments[0]; + } + + var fullyQualifiedName = parseType.GloballyQualified(); var escapedValue = SymbolDisplay.FormatLiteral(strForParsing, quote: true); return $"{fullyQualifiedName}.Parse({escapedValue}, global::System.Globalization.CultureInfo.InvariantCulture)"; } diff --git a/TUnit.Core.SourceGenerator/Extensions/ParsableTypeExtensions.cs b/TUnit.Core.SourceGenerator/Extensions/ParsableTypeExtensions.cs index 47a0a77304..3ea3dd8f8d 100644 --- a/TUnit.Core.SourceGenerator/Extensions/ParsableTypeExtensions.cs +++ b/TUnit.Core.SourceGenerator/Extensions/ParsableTypeExtensions.cs @@ -11,6 +11,12 @@ public static bool IsParsableFromString(this ITypeSymbol? type) return false; } + // Unwrap nullable value types (e.g., DateTime? -> DateTime) + if (type is INamedTypeSymbol { IsGenericType: true, ConstructedFrom.SpecialType: SpecialType.System_Nullable_T } nullableType) + { + type = nullableType.TypeArguments[0]; + } + if (type.AllInterfaces.Any(i => i is { IsGenericType: true, MetadataName: "IParsable`1" } && i.ContainingNamespace?.ToDisplayString() == "System" diff --git a/TUnit.TestProject/StringToParsableArgumentsTests.cs b/TUnit.TestProject/StringToParsableArgumentsTests.cs index d3bd2de603..de84a541a0 100644 --- a/TUnit.TestProject/StringToParsableArgumentsTests.cs +++ b/TUnit.TestProject/StringToParsableArgumentsTests.cs @@ -45,4 +45,49 @@ public async Task TimeOnly_From_String(TimeOnly time) await Assert.That(time).IsEqualTo(new TimeOnly(14, 30, 0)); } #endif + + [Test] + [Arguments("2022-05-31")] + [Arguments(null)] + public async Task Nullable_DateTime_From_String(DateTime? testDate) + { + if (testDate is not null) + { + await Assert.That(testDate.Value).IsEqualTo(new DateTime(2022, 5, 31)); + } + else + { + await Assert.That(testDate).IsNull(); + } + } + + [Test] + [Arguments("01:30:00")] + [Arguments(null)] + public async Task Nullable_TimeSpan_From_String(TimeSpan? timeSpan) + { + if (timeSpan is not null) + { + await Assert.That(timeSpan.Value).IsEqualTo(new TimeSpan(1, 30, 0)); + } + else + { + await Assert.That(timeSpan).IsNull(); + } + } + + [Test] + [Arguments("d3b07384-d113-4ec0-8b2a-1e1f0e1e4e57")] + [Arguments(null)] + public async Task Nullable_Guid_From_String(Guid? guid) + { + if (guid is not null) + { + await Assert.That(guid.Value).IsEqualTo(new Guid("d3b07384-d113-4ec0-8b2a-1e1f0e1e4e57")); + } + else + { + await Assert.That(guid).IsNull(); + } + } }