Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
77 changes: 63 additions & 14 deletions TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1886,14 +1886,15 @@ private static void GenerateTypedInvokers(CodeWriter writer, TestMethodMetadata
// Generate InvokeTypedTest for non-generic tests
var isAsync = IsAsyncMethod(testMethod.MethodSymbol);
var returnsValueTask = ReturnsValueTask(testMethod.MethodSymbol);
var returnsVoid = testMethod.MethodSymbol.ReturnType.SpecialType == SpecialType.System_Void;
if (testMethod is { IsGenericType: false, IsGenericMethod: false })
{
GenerateConcreteTestInvoker(writer, testMethod, className, methodName, isAsync, returnsValueTask, hasCancellationToken, parametersFromArgs);
GenerateConcreteTestInvoker(writer, testMethod, className, methodName, isAsync, returnsValueTask, returnsVoid, hasCancellationToken, parametersFromArgs);
}
}


private static void GenerateConcreteTestInvoker(CodeWriter writer, TestMethodMetadata testMethod, string className, string methodName, bool isAsync, bool returnsValueTask, bool hasCancellationToken, IParameterSymbol[] parametersFromArgs)
private static void GenerateConcreteTestInvoker(CodeWriter writer, TestMethodMetadata testMethod, string className, string methodName, bool isAsync, bool returnsValueTask, bool returnsVoid, bool hasCancellationToken, IParameterSymbol[] parametersFromArgs)
{
// Generate InvokeTypedTest which is required by CreateExecutableTestFactory
writer.AppendLine("InvokeTypedTest = static (instance, args, cancellationToken) =>");
Expand Down Expand Up @@ -1941,13 +1942,24 @@ private static void GenerateConcreteTestInvoker(CodeWriter writer, TestMethodMet
}
else
{
writer.AppendLine($"return new global::System.Threading.Tasks.ValueTask({methodCallReconstructed});");
writer.AppendLine($"var methodResult = {methodCallReconstructed};");
writer.AppendLine("if (methodResult is global::System.Threading.Tasks.Task t) return new global::System.Threading.Tasks.ValueTask(t);");
writer.AppendLine("return global::TUnit.Core.AsyncConvert.ConvertObject(methodResult);");
}
}
else
{
writer.AppendLine($"{methodCallReconstructed};");
writer.AppendLine("return default(global::System.Threading.Tasks.ValueTask);");
if (returnsVoid)
{
writer.AppendLine($"{methodCallReconstructed};");
writer.AppendLine("return default(global::System.Threading.Tasks.ValueTask);");
}
else
{
writer.AppendLine($"var methodResult = {methodCallReconstructed};");
writer.AppendLine("if (methodResult == null) return default(global::System.Threading.Tasks.ValueTask);");
writer.AppendLine("return global::TUnit.Core.AsyncConvert.ConvertObject(methodResult);");
}
}
writer.Unindent();
writer.AppendLine("}");
Expand All @@ -1966,13 +1978,24 @@ private static void GenerateConcreteTestInvoker(CodeWriter writer, TestMethodMet
}
else
{
writer.AppendLine($"return new global::System.Threading.Tasks.ValueTask({methodCallDirect});");
writer.AppendLine($"var methodResult = {methodCallDirect};");
writer.AppendLine("if (methodResult is global::System.Threading.Tasks.Task t) return new global::System.Threading.Tasks.ValueTask(t);");
writer.AppendLine("return global::TUnit.Core.AsyncConvert.ConvertObject(methodResult);");
}
}
else
{
writer.AppendLine($"{methodCallDirect};");
writer.AppendLine("return default(global::System.Threading.Tasks.ValueTask);");
if (returnsVoid)
{
writer.AppendLine($"{methodCallDirect};");
writer.AppendLine("return default(global::System.Threading.Tasks.ValueTask);");
}
else
{
writer.AppendLine($"var methodResult = {methodCallDirect};");
writer.AppendLine("if (methodResult == null) return default(global::System.Threading.Tasks.ValueTask);");
writer.AppendLine("return global::TUnit.Core.AsyncConvert.ConvertObject(methodResult);");
}
}
writer.Unindent();
writer.AppendLine("}");
Expand All @@ -1996,13 +2019,24 @@ private static void GenerateConcreteTestInvoker(CodeWriter writer, TestMethodMet
}
else
{
writer.AppendLine($"return new global::System.Threading.Tasks.ValueTask({typedMethodCall});");
writer.AppendLine($"var methodResult = {typedMethodCall};");
writer.AppendLine("if (methodResult is global::System.Threading.Tasks.Task t) return new global::System.Threading.Tasks.ValueTask(t);");
writer.AppendLine("return global::TUnit.Core.AsyncConvert.ConvertObject(methodResult);");
}
}
else
{
writer.AppendLine($"{typedMethodCall};");
writer.AppendLine("return default(global::System.Threading.Tasks.ValueTask);");
if (returnsVoid)
{
writer.AppendLine($"{typedMethodCall};");
writer.AppendLine("return default(global::System.Threading.Tasks.ValueTask);");
}
else
{
writer.AppendLine($"var methodResult = {typedMethodCall};");
writer.AppendLine("if (methodResult == null) return default(global::System.Threading.Tasks.ValueTask);");
writer.AppendLine("return global::TUnit.Core.AsyncConvert.ConvertObject(methodResult);");
}
}
}
else
Expand All @@ -2028,6 +2062,8 @@ private static void GenerateConcreteTestInvoker(CodeWriter writer, TestMethodMet
var argCount = requiredParamCount + i;
writer.AppendLine($"case {argCount}:");
writer.Indent();
writer.AppendLine("{");
writer.Indent();

// Build the arguments to pass, handling params arrays correctly
var argsToPass = TupleArgumentHelper.GenerateArgumentAccessWithParams(parametersFromArgs, "args", argCount);
Expand All @@ -2048,15 +2084,28 @@ private static void GenerateConcreteTestInvoker(CodeWriter writer, TestMethodMet
}
else
{
writer.AppendLine($"return new global::System.Threading.Tasks.ValueTask({typedMethodCall});");
writer.AppendLine($"var methodResult = {typedMethodCall};");
writer.AppendLine("if (methodResult is global::System.Threading.Tasks.Task t) return new global::System.Threading.Tasks.ValueTask(t);");
writer.AppendLine("return global::TUnit.Core.AsyncConvert.ConvertObject(methodResult);");
}
}
else
{
writer.AppendLine($"{typedMethodCall};");
writer.AppendLine("return default(global::System.Threading.Tasks.ValueTask);");
if (returnsVoid)
{
writer.AppendLine($"{typedMethodCall};");
writer.AppendLine("return default(global::System.Threading.Tasks.ValueTask);");
}
else
{
writer.AppendLine($"var methodResult = {typedMethodCall};");
writer.AppendLine("if (methodResult == null) return default(global::System.Threading.Tasks.ValueTask);");
writer.AppendLine("return global::TUnit.Core.AsyncConvert.ConvertObject(methodResult);");
}
}
writer.Unindent();
writer.AppendLine("}");
writer.Unindent();
}

writer.AppendLine("default:");
Expand Down
3 changes: 2 additions & 1 deletion TUnit.Engine/Discovery/ReflectionTestDataCollector.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1603,7 +1603,8 @@ private static bool IsCovariantCompatible(Type paramType, [DynamicallyAccessedMe
{
return valueTask.AsTask();
}
Copy link

Copilot AI Dec 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The unconditional use of AsyncConvert.ConvertObject(result).AsTask() introduces a performance regression for void methods. The old code returned Task.CompletedTask (a cached singleton), while the new code creates an async state machine allocation even for null results.

Consider checking the return type or result before calling AsyncConvert:

if (result is null)
{
    return Task.CompletedTask;
}
return AsyncConvert.ConvertObject(result).AsTask();

This preserves the original performance for the common case of void methods while adding F# Async support.

Suggested change
}
}
// Preserve performance for void methods (null result)
if (result is null)
{
return Task.CompletedTask;
}

Copilot uses AI. Check for mistakes.
return Task.CompletedTask;
// F# Async support (reuses existing AsyncConvert logic)
return AsyncConvert.ConvertObject(result).AsTask();
}
catch (TargetInvocationException tie)
{
Expand Down
51 changes: 51 additions & 0 deletions TUnit.TestProject.FSharp/AsyncTests.fs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
namespace TUnit.TestProject.FSharp

open System.Threading.Tasks
open TUnit.Assertions
open TUnit.Assertions.Extensions
open TUnit.Assertions.FSharp.Operations
open TUnit.Core

/// Tests to verify F# Async<'T> return types are properly executed
type AsyncTests() =

/// Static tracker to verify tests actually execute
static member val private ExecutionCount = 0 with get, set

[<Test>]
member _.FSharpAsync_BasicExecution() : Async<unit> = async {
AsyncTests.ExecutionCount <- 1
do! Async.Sleep 10
}

[<Test>]
[<DependsOn("FSharpAsync_BasicExecution")>]
member _.VerifyFSharpAsyncExecuted() =
// This test depends on the previous one and verifies it actually ran
if AsyncTests.ExecutionCount = 0 then
failwith "F# Async test did not execute!"
Task.CompletedTask

[<Test>]
member _.FSharpAsync_WithReturnValue() : Async<int> = async {
do! Async.Sleep 10
return 42
}
Comment on lines +30 to +33
Copy link

Copilot AI Dec 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test FSharpAsync_WithReturnValue() returns Async<int> but doesn't verify that the return value (42) is actually captured and processed correctly by the framework. Consider adding a follow-up test that validates the return value, similar to how VerifyFSharpAsyncExecuted() validates execution state.

Example:

static member val private ReturnedValue = 0 with get, set

[<Test>]
member _.FSharpAsync_WithReturnValue() : Async<int> = async {
    do! Async.Sleep 10
    AsyncTests.ReturnedValue <- 42
    return 42
}

[<Test>]
[<DependsOn("FSharpAsync_WithReturnValue")>]
member _.VerifyFSharpAsyncReturnValue() =
    if AsyncTests.ReturnedValue <> 42 then
        failwith "F# Async return value was not processed!"
    Task.CompletedTask

Copilot uses AI. Check for mistakes.

[<Test>]
member _.FSharpAsync_WithAsyncSleep() : Async<unit> = async {
// Verify async operations work correctly
do! Async.Sleep 50
}

[<Test>]
member _.FSharpAsync_CallingTask() : Async<unit> = async {
// F# Async calling Task-based API
do! Task.Delay(10) |> Async.AwaitTask
}

[<Test>]
member _.FSharpAsync_WithAssertion() : Async<unit> = async {
let result = 1 + 1
do! check (Assert.That(result).IsEqualTo(2))
}
1 change: 1 addition & 0 deletions TUnit.TestProject.FSharp/TUnit.TestProject.FSharp.fsproj
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
<Compile Include="ClassConstructorWithEnumerableTest.fs" />
<Compile Include="ClassDataSourceDrivenTests.fs" />
<Compile Include="TaskAssertTests.fs" />
<Compile Include="AsyncTests.fs" />
<Compile Include="Tests.fs" />
</ItemGroup>

Expand Down
Loading