From 04f86ad9e55f5d67fe8cec549b258edc8442f07e Mon Sep 17 00:00:00 2001 From: Stuart Lang Date: Sun, 28 Dec 2025 23:39:29 +0000 Subject: [PATCH 01/11] Add TUnit.FsCheck library --- Directory.Packages.props | 1 + .../Generators/TestMetadataGenerator.cs | 134 ++++- .../Models/TestMethodMetadata.cs | 4 +- TUnit.Core/TUnit.Core.csproj | 1 + .../Discovery/ReflectionTestDataCollector.cs | 24 +- .../PropertyTests.cs | 69 +++ .../TUnit.Example.FsCheck.TestProject.csproj | 11 + .../TUnit.Example.FsCheck.TestProject.sln | 24 + TUnit.FsCheck/FsCheckPropertyAttribute.cs | 79 +++ TUnit.FsCheck/FsCheckPropertyTestExecutor.cs | 510 ++++++++++++++++++ TUnit.FsCheck/PropertyFailedException.cs | 15 + TUnit.FsCheck/TUnit.FsCheck.csproj | 21 + TUnit.sln | 30 ++ 13 files changed, 893 insertions(+), 30 deletions(-) create mode 100644 TUnit.Example.FsCheck.TestProject/PropertyTests.cs create mode 100644 TUnit.Example.FsCheck.TestProject/TUnit.Example.FsCheck.TestProject.csproj create mode 100644 TUnit.Example.FsCheck.TestProject/TUnit.Example.FsCheck.TestProject.sln create mode 100644 TUnit.FsCheck/FsCheckPropertyAttribute.cs create mode 100644 TUnit.FsCheck/FsCheckPropertyTestExecutor.cs create mode 100644 TUnit.FsCheck/PropertyFailedException.cs create mode 100644 TUnit.FsCheck/TUnit.FsCheck.csproj diff --git a/Directory.Packages.props b/Directory.Packages.props index b21637a2b2..0f7b3aa8fa 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -13,6 +13,7 @@ + diff --git a/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs b/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs index 794954eab0..0f521849fa 100644 --- a/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs +++ b/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs @@ -36,6 +36,14 @@ public void Initialize(IncrementalGeneratorInitializationContext context) .Where(static m => m is not null) .Combine(enabledProvider); + // Custom test attributes that inherit from BaseTestAttribute + var customTestMethodsProvider = context.SyntaxProvider + .CreateSyntaxProvider( + predicate: static (node, _) => node is MethodDeclarationSyntax { AttributeLists.Count: > 0 }, + transform: static (ctx, _) => GetCustomTestMethodMetadata(ctx)) + .Where(static m => m is not null) + .Combine(enabledProvider); + var inheritsTestsClassesProvider = context.SyntaxProvider .ForAttributeWithMetadataName( "TUnit.Core.InheritsTestsAttribute", @@ -55,6 +63,17 @@ public void Initialize(IncrementalGeneratorInitializationContext context) GenerateTestMethodSource(context, testMethod); }); + context.RegisterSourceOutput(customTestMethodsProvider, + static (context, data) => + { + var (testMethod, isEnabled) = data; + if (!isEnabled) + { + return; + } + GenerateTestMethodSource(context, testMethod); + }); + context.RegisterSourceOutput(inheritsTestsClassesProvider, static (context, data) => { @@ -67,6 +86,86 @@ public void Initialize(IncrementalGeneratorInitializationContext context) }); } + private static TestMethodMetadata? GetCustomTestMethodMetadata(GeneratorSyntaxContext context) + { + var methodSyntax = (MethodDeclarationSyntax)context.Node; + var methodSymbol = context.SemanticModel.GetDeclaredSymbol(methodSyntax) as IMethodSymbol; + + if (methodSymbol == null) + { + return null; + } + + // Find the custom test attribute that inherits from BaseTestAttribute + // Skip any attributes defined in TUnit.Core namespace (handled by built-in providers) + AttributeData? testAttribute = null; + foreach (var attr in methodSymbol.GetAttributes()) + { + var attrType = attr.AttributeClass; + if (attrType == null) + { + continue; + } + + // Skip built-in TUnit.Core attributes - they're handled by other providers + if (attrType.ContainingNamespace?.ToDisplayString() == "TUnit.Core") + { + continue; + } + + var baseType = attrType.BaseType; + while (baseType != null) + { + if (baseType.ToDisplayString() == "TUnit.Core.BaseTestAttribute") + { + testAttribute = attr; + break; + } + baseType = baseType.BaseType; + } + if (testAttribute != null) + { + break; + } + } + + if (testAttribute == null) + { + return null; + } + + var containingType = methodSymbol.ContainingType; + + if (containingType == null) + { + return null; + } + + if (containingType.IsAbstract) + { + return null; + } + + var isGenericType = containingType is { IsGenericType: true, TypeParameters.Length: > 0 }; + var isGenericMethod = methodSymbol is { IsGenericMethod: true }; + + var (filePath, lineNumber) = GetTestMethodSourceLocation(methodSyntax, testAttribute); + + return new TestMethodMetadata + { + MethodSymbol = methodSymbol, + TypeSymbol = containingType, + FilePath = filePath, + LineNumber = lineNumber, + TestAttribute = testAttribute, + SemanticModel = context.SemanticModel, + MethodSyntax = methodSyntax, + IsGenericType = isGenericType, + IsGenericMethod = isGenericMethod, + MethodAttributes = methodSymbol.GetAttributes() + }; + } + private static InheritsTestsClassMetadata? GetInheritsTestsClassMetadata(GeneratorAttributeSyntaxContext context) { var classSyntax = (ClassDeclarationSyntax)context.TargetNode; @@ -85,7 +184,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) { TypeSymbol = classSymbol, ClassSyntax = classSyntax, - Context = context + SemanticModel = context.SemanticModel }; } @@ -120,7 +219,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) FilePath = filePath, LineNumber = lineNumber, TestAttribute = context.Attributes.First(), - Context = context, + SemanticModel = context.SemanticModel, MethodSyntax = methodSyntax, IsGenericType = isGenericType, IsGenericMethod = isGenericMethod, @@ -185,7 +284,7 @@ private static void GenerateInheritedTestSources(SourceProductionContext context FilePath = filePath, LineNumber = lineNumber, TestAttribute = testAttribute, - Context = classInfo.Context, // Use class context to access Compilation + SemanticModel = classInfo.SemanticModel, // Use class context to access Compilation MethodSyntax = null, // No syntax for inherited methods IsGenericType = typeForMetadata.IsGenericType, IsGenericMethod = (concreteMethod ?? method).IsGenericMethod, @@ -228,14 +327,11 @@ private static void GenerateTestMethodSource(SourceProductionContext context, Te { try { - if (testMethod?.MethodSymbol == null || testMethod.Context == null) + if (testMethod?.MethodSymbol == null || testMethod.SemanticModel?.Compilation == null) { return; } - // Get compilation from semantic model instead of parameter - var compilation = testMethod.Context.Value.SemanticModel.Compilation; - var writer = new CodeWriter(); GenerateFileHeader(writer); GenerateTestMetadata(writer, testMethod); @@ -274,7 +370,7 @@ private static void GenerateFileHeader(CodeWriter writer) private static void GenerateTestMetadata(CodeWriter writer, TestMethodMetadata testMethod) { - var compilation = testMethod.Context!.Value.SemanticModel.Compilation; + var compilation = testMethod.SemanticModel?.Compilation!; var className = testMethod.TypeSymbol.GloballyQualified(); var methodName = testMethod.MethodSymbol.Name; @@ -357,7 +453,7 @@ private static void GenerateSpecificGenericInstantiation( string combinationGuid, ImmutableArray typeArguments) { - var compilation = testMethod.Context!.Value.SemanticModel.Compilation; + var compilation = testMethod.SemanticModel?.Compilation!; var methodName = testMethod.MethodSymbol.Name; var typeArgsString = string.Join(", ", typeArguments.Select(t => t.GloballyQualified())); var instantiatedMethodName = $"{methodName}<{typeArgsString}>"; @@ -369,7 +465,7 @@ private static void GenerateSpecificGenericInstantiation( FilePath = testMethod.FilePath, LineNumber = testMethod.LineNumber, TestAttribute = testMethod.TestAttribute, - Context = testMethod.Context, + SemanticModel = testMethod.SemanticModel, MethodSyntax = testMethod.MethodSyntax, IsGenericType = testMethod.IsGenericType, IsGenericMethod = false, // We're creating a concrete instantiation @@ -576,7 +672,7 @@ private static void GenerateTestMetadataInstance(CodeWriter writer, TestMethodMe private static void GenerateMetadata(CodeWriter writer, TestMethodMetadata testMethod) { - var compilation = testMethod.Context!.Value.SemanticModel.Compilation; + var compilation = testMethod.SemanticModel?.Compilation!; var methodSymbol = testMethod.MethodSymbol; @@ -622,7 +718,7 @@ private static void GenerateMetadata(CodeWriter writer, TestMethodMetadata testM private static void GenerateMetadataForConcreteInstantiation(CodeWriter writer, TestMethodMetadata testMethod) { - var compilation = testMethod.Context!.Value.SemanticModel.Compilation; + var compilation = testMethod.SemanticModel?.Compilation!; var methodSymbol = testMethod.MethodSymbol; @@ -674,7 +770,7 @@ private static void GenerateMetadataForConcreteInstantiation(CodeWriter writer, private static void GenerateDataSources(CodeWriter writer, TestMethodMetadata testMethod) { - var compilation = testMethod.Context!.Value.SemanticModel.Compilation; + var compilation = testMethod.SemanticModel?.Compilation!; var methodSymbol = testMethod.MethodSymbol; var typeSymbol = testMethod.TypeSymbol; @@ -1578,7 +1674,7 @@ private static void GeneratePropertyInjections(CodeWriter writer, INamedTypeSymb private static void GeneratePropertyDataSources(CodeWriter writer, TestMethodMetadata testMethod) { - var compilation = testMethod.Context!.Value.SemanticModel.Compilation; + var compilation = testMethod.SemanticModel?.Compilation!; var typeSymbol = testMethod.TypeSymbol; var currentType = typeSymbol; var processedProperties = new HashSet(); @@ -3145,7 +3241,7 @@ private static void GenerateGenericTestWithConcreteTypes( string className, string combinationGuid) { - var compilation = testMethod.Context!.Value.SemanticModel.Compilation; + var compilation = testMethod.SemanticModel?.Compilation!; var methodName = testMethod.MethodSymbol.Name; writer.AppendLine("// Create generic metadata with concrete type registrations"); @@ -4624,7 +4720,7 @@ private static void GenerateConcreteTestMetadata( ITypeSymbol[] typeArguments, AttributeData? specificArgumentsAttribute = null) { - var compilation = testMethod.Context!.Value.SemanticModel.Compilation; + var compilation = testMethod.SemanticModel?.Compilation!; var methodName = testMethod.MethodSymbol.Name; // Separate class type arguments from method type arguments @@ -4844,7 +4940,7 @@ private static void GenerateConcreteMetadataWithFilteredDataSources( AttributeData? specificArgumentsAttribute, ITypeSymbol[] typeArguments) { - var compilation = testMethod.Context!.Value.SemanticModel.Compilation; + var compilation = testMethod.SemanticModel?.Compilation!; var methodSymbol = testMethod.MethodSymbol; var typeSymbol = testMethod.TypeSymbol; @@ -5163,7 +5259,7 @@ private static void GenerateConcreteTestMetadataForNonGeneric( AttributeData? classDataSourceAttribute, AttributeData? methodDataSourceAttribute) { - var compilation = testMethod.Context!.Value.SemanticModel.Compilation; + var compilation = testMethod.SemanticModel?.Compilation!; var methodName = testMethod.MethodSymbol.Name; writer.AppendLine($"var metadata = new global::TUnit.Core.TestMetadata<{className}>"); @@ -5356,6 +5452,6 @@ public class InheritsTestsClassMetadata { public required INamedTypeSymbol TypeSymbol { get; init; } public required ClassDeclarationSyntax ClassSyntax { get; init; } - public GeneratorAttributeSyntaxContext Context { get; init; } + public SemanticModel SemanticModel { get; init; } } diff --git a/TUnit.Core.SourceGenerator/Models/TestMethodMetadata.cs b/TUnit.Core.SourceGenerator/Models/TestMethodMetadata.cs index e97d4fb74c..6e7dd89cdb 100644 --- a/TUnit.Core.SourceGenerator/Models/TestMethodMetadata.cs +++ b/TUnit.Core.SourceGenerator/Models/TestMethodMetadata.cs @@ -11,10 +11,10 @@ public class TestMethodMetadata : IEquatable { public required IMethodSymbol MethodSymbol { get; init; } public required INamedTypeSymbol TypeSymbol { get; init; } + public required SemanticModel SemanticModel { get; init; } public required string FilePath { get; init; } public required int LineNumber { get; init; } public required AttributeData TestAttribute { get; init; } - public GeneratorAttributeSyntaxContext? Context { get; init; } public required MethodDeclarationSyntax? MethodSyntax { get; init; } public bool IsGenericType { get; init; } public bool IsGenericMethod { get; init; } @@ -23,7 +23,7 @@ public class TestMethodMetadata : IEquatable /// All attributes on the method, stored for later use during data combination generation /// public ImmutableArray MethodAttributes { get; init; } = ImmutableArray.Empty; - + /// /// The inheritance depth of this test method. /// 0 = method is declared directly in the test class diff --git a/TUnit.Core/TUnit.Core.csproj b/TUnit.Core/TUnit.Core.csproj index f895e73378..fcaa39b5bd 100644 --- a/TUnit.Core/TUnit.Core.csproj +++ b/TUnit.Core/TUnit.Core.csproj @@ -6,6 +6,7 @@ + diff --git a/TUnit.Engine/Discovery/ReflectionTestDataCollector.cs b/TUnit.Engine/Discovery/ReflectionTestDataCollector.cs index 740f653d48..4531d99197 100644 --- a/TUnit.Engine/Discovery/ReflectionTestDataCollector.cs +++ b/TUnit.Engine/Discovery/ReflectionTestDataCollector.cs @@ -381,7 +381,7 @@ private static async Task> DiscoverTestsInAssembly(Assembly a var testMethodsList = new List(); foreach (var method in allMethods) { - if (method.IsDefined(typeof(TestAttribute), inherit: false) && !method.IsAbstract) + if (IsTestMethod(method) && !method.IsAbstract) { testMethodsList.Add(method); } @@ -394,7 +394,7 @@ private static async Task> DiscoverTestsInAssembly(Assembly a var testMethodsList = new List(declaredMethods.Length); foreach (var method in declaredMethods) { - if (method.IsDefined(typeof(TestAttribute), inherit: false) && !method.IsAbstract) + if (IsTestMethod(method) && !method.IsAbstract) { testMethodsList.Add(method); } @@ -512,14 +512,14 @@ private static async IAsyncEnumerable DiscoverTestsInAssemblyStrea { // Get all test methods including inherited ones testMethods = GetAllTestMethods(type) - .Where(static m => m.IsDefined(typeof(TestAttribute), inherit: false) && !m.IsAbstract); + .Where(static m => IsTestMethod(m) && !m.IsAbstract); } else { // Only get declared test methods testMethods = type.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly) - .Where(static m => m.IsDefined(typeof(TestAttribute), inherit: false) && !m.IsAbstract); + .Where(static m => IsTestMethod(m) && !m.IsAbstract); } } catch (Exception) @@ -582,7 +582,7 @@ private static async Task> DiscoverGenericTests(Type genericT var testMethodsList = new List(declaredMethods.Length); foreach (var method in declaredMethods) { - if (method.IsDefined(typeof(TestAttribute), inherit: false) && !method.IsAbstract) + if (IsTestMethod(method) && !method.IsAbstract) { testMethodsList.Add(method); } @@ -671,7 +671,7 @@ private static async IAsyncEnumerable DiscoverGenericTestsStreamin var testMethodsList = new List(declaredMethods.Length); foreach (var method in declaredMethods) { - if (method.IsDefined(typeof(TestAttribute), inherit: false) && !method.IsAbstract) + if (IsTestMethod(method) && !method.IsAbstract) { testMethodsList.Add(method); } @@ -1010,7 +1010,7 @@ private static bool HasTestMethods([DynamicallyAccessedMembers(DynamicallyAccess var methods = type.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly); foreach (var method in methods) { - if (method.IsDefined(typeof(TestAttribute), inherit: false)) + if (IsTestMethod(method)) { return true; } @@ -1024,14 +1024,20 @@ private static bool HasTestMethods([DynamicallyAccessedMembers(DynamicallyAccess } } + private static bool IsTestMethod(MethodInfo method) + { + // Check if method has any attribute that inherits from BaseTestAttribute + return method.GetCustomAttributes(typeof(BaseTestAttribute), inherit: false).Length > 0; + } + private static string? ExtractFilePath(MethodInfo method) { - return method.GetCustomAttribute()?.File; + return method.GetCustomAttribute()?.File; } private static int? ExtractLineNumber(MethodInfo method) { - return method.GetCustomAttribute()?.Line; + return method.GetCustomAttribute()?.Line; } private static TestMetadata CreateFailedTestMetadataForAssembly(Assembly assembly, Exception ex) diff --git a/TUnit.Example.FsCheck.TestProject/PropertyTests.cs b/TUnit.Example.FsCheck.TestProject/PropertyTests.cs new file mode 100644 index 0000000000..306454eabd --- /dev/null +++ b/TUnit.Example.FsCheck.TestProject/PropertyTests.cs @@ -0,0 +1,69 @@ +using TUnit.FsCheck; + +namespace TUnit.Example.FsCheck.TestProject; + +public class PropertyTests +{ + [FsCheckProperty] + public bool ReverseReverseIsOriginal(int[] array) + { + var reversed = array.AsEnumerable().Reverse().Reverse().ToArray(); + return array.SequenceEqual(reversed); + } + + [FsCheckProperty] + public bool AbsoluteValueIsNonNegative(int value) + { + return Math.Abs((long)value) >= 0; + } + + [FsCheckProperty] + public bool StringConcatenationLength(string a, string b) + { + if (a == null || b == null) + { + return true; // Skip null cases + } + + return (a + b).Length == a.Length + b.Length; + } + + [FsCheckProperty(MaxTest = 50)] + public bool ListConcatenationPreservesElements(int[] first, int[] second) + { + var combined = first.Concat(second).ToArray(); + return combined.Length == first.Length + second.Length; + } + + [FsCheckProperty] + public void AdditionIsCommutative(int a, int b) + { + var result1 = a + b; + var result2 = b + a; + + if (result1 != result2) + { + throw new InvalidOperationException($"Addition is not commutative: {a} + {b} = {result1}, {b} + {a} = {result2}"); + } + } + + [FsCheckProperty] + public async Task AsyncPropertyTest(int value) + { + await Task.Delay(1); // Simulate async work + + if (value * 0 != 0) + { + throw new InvalidOperationException("Multiplication by zero should always be zero"); + } + } + + [FsCheckProperty] + public bool MultiplicationIsAssociative(int a, int b, int c) + { + // Using long to avoid overflow + var left = (long)a * ((long)b * c); + var right = ((long)a * b) * c; + return left == right; + } +} diff --git a/TUnit.Example.FsCheck.TestProject/TUnit.Example.FsCheck.TestProject.csproj b/TUnit.Example.FsCheck.TestProject/TUnit.Example.FsCheck.TestProject.csproj new file mode 100644 index 0000000000..f23b29646c --- /dev/null +++ b/TUnit.Example.FsCheck.TestProject/TUnit.Example.FsCheck.TestProject.csproj @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/TUnit.Example.FsCheck.TestProject/TUnit.Example.FsCheck.TestProject.sln b/TUnit.Example.FsCheck.TestProject/TUnit.Example.FsCheck.TestProject.sln new file mode 100644 index 0000000000..4ddb9e442f --- /dev/null +++ b/TUnit.Example.FsCheck.TestProject/TUnit.Example.FsCheck.TestProject.sln @@ -0,0 +1,24 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.2.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TUnit.Example.FsCheck.TestProject", "TUnit.Example.FsCheck.TestProject.csproj", "{41C48729-CBC0-9C84-9E2E-AD18967D3F54}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {41C48729-CBC0-9C84-9E2E-AD18967D3F54}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {41C48729-CBC0-9C84-9E2E-AD18967D3F54}.Debug|Any CPU.Build.0 = Debug|Any CPU + {41C48729-CBC0-9C84-9E2E-AD18967D3F54}.Release|Any CPU.ActiveCfg = Release|Any CPU + {41C48729-CBC0-9C84-9E2E-AD18967D3F54}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {29D619C4-A78D-44B6-9D5C-304546DF9B20} + EndGlobalSection +EndGlobal diff --git a/TUnit.FsCheck/FsCheckPropertyAttribute.cs b/TUnit.FsCheck/FsCheckPropertyAttribute.cs new file mode 100644 index 0000000000..5a29ac5c3c --- /dev/null +++ b/TUnit.FsCheck/FsCheckPropertyAttribute.cs @@ -0,0 +1,79 @@ +using System.Runtime.CompilerServices; +using TUnit.Core; +using TUnit.Core.Interfaces; + +namespace TUnit.FsCheck; + +/// +/// Marks a test method as an FsCheck property-based test. +/// The test method parameters will be generated by FsCheck and the test +/// will be run multiple times with different generated values. +/// +public class FsCheckPropertyAttribute : BaseTestAttribute, ITestRegisteredEventReceiver +{ + public FsCheckPropertyAttribute([CallerFilePath] string file = "", [CallerLineNumber] int line = 0) + : base(file, line) + { + } + + /// + /// The maximum number of tests to run. Default is 100. + /// + public int MaxTest { get; set; } = 100; + + /// + /// The maximum number of rejected tests (tests that failed the precondition) before failing. + /// + public int MaxFail { get; set; } = 1000; + + /// + /// The starting size for test generation. Size increases linearly between StartSize and EndSize. + /// + public int StartSize { get; set; } = 1; + + /// + /// The ending size for test generation. Size increases linearly between StartSize and EndSize. + /// + public int EndSize { get; set; } = 100; + + /// + /// If set, replay the test using this seed. Format: "seed1,seed2" or just "seed1". + /// Useful for reproducing failures. + /// + public string? Replay { get; set; } + + /// + /// If true, output all generated arguments to the test output. + /// + public bool Verbose { get; set; } + + /// + /// If true, suppress output on passing tests. + /// + public bool QuietOnSuccess { get; set; } + + /// + /// The level of parallelism to use when running tests. + /// Default is 1 (no parallelism within property execution). + /// + public int Parallelism { get; set; } = 1; + + /// + /// Types containing Arbitrary instances to use for generating test data. + /// + public Type[]? Arbitrary { get; set; } + + /// + /// Gets the order in which this event receiver is executed. + /// + public int Order => 0; + + /// + /// Called when the test is registered. Sets up the FsCheck property executor. + /// + public ValueTask OnTestRegistered(TestRegisteredContext context) + { + context.SetTestExecutor(new FsCheckPropertyTestExecutor(this)); + return default; + } +} diff --git a/TUnit.FsCheck/FsCheckPropertyTestExecutor.cs b/TUnit.FsCheck/FsCheckPropertyTestExecutor.cs new file mode 100644 index 0000000000..39d7856458 --- /dev/null +++ b/TUnit.FsCheck/FsCheckPropertyTestExecutor.cs @@ -0,0 +1,510 @@ +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using System.Runtime.ExceptionServices; +using System.Text; +using FsCheck; +using FsCheck.Fluent; +using TUnit.Core; +using TUnit.Core.Interfaces; + +namespace TUnit.FsCheck; + +/// +/// A test executor that runs FsCheck property-based tests. +/// +#pragma warning disable IL2046 // RequiresUnreferencedCode attribute mismatch +#pragma warning disable IL3051 // RequiresDynamicCode attribute mismatch +#pragma warning disable IL2072 // DynamicallyAccessedMembers warning +public class FsCheckPropertyTestExecutor : ITestExecutor +{ + private readonly FsCheckPropertyAttribute _propertyAttribute; + + public FsCheckPropertyTestExecutor(FsCheckPropertyAttribute propertyAttribute) + { + _propertyAttribute = propertyAttribute; + } + + public ValueTask ExecuteTest(TestContext context, Func action) + { + var testDetails = context.Metadata.TestDetails; + var classInstance = testDetails.ClassInstance; + var classType = testDetails.ClassType; + var methodName = testDetails.MethodName; + + // Get MethodInfo via reflection from the class type + var methodInfo = GetMethodInfo(classType, methodName, testDetails.MethodMetadata.Parameters); + + var config = CreateConfig(); + + RunPropertyCheck(methodInfo, classInstance, config); + + return default; + } + + [UnconditionalSuppressMessage("Trimming", "IL2070", Justification = "FsCheck requires reflection")] + private static MethodInfo GetMethodInfo(Type classType, string methodName, ParameterMetadata[] parameters) + { + // Try to find the method by name and parameter count + var methods = classType + .GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static) + .Where(m => m.Name == methodName && m.GetParameters().Length == parameters.Length) + .ToArray(); + + if (methods.Length == 0) + { + throw new InvalidOperationException($"Could not find method '{methodName}' on type '{classType.FullName}'"); + } + + if (methods.Length == 1) + { + return methods[0]; + } + + // Multiple overloads - try to match by parameter types + foreach (var method in methods) + { + var methodParams = method.GetParameters(); + var match = true; + for (var i = 0; i < methodParams.Length; i++) + { + if (methodParams[i].ParameterType != parameters[i].Type) + { + match = false; + break; + } + } + + if (match) + { + return method; + } + } + + // Just return the first one if no exact match + return methods[0]; + } + + private Config CreateConfig() + { + var config = Config.QuickThrowOnFailure + .WithMaxTest(_propertyAttribute.MaxTest) + .WithMaxRejected(_propertyAttribute.MaxFail) + .WithStartSize(_propertyAttribute.StartSize) + .WithEndSize(_propertyAttribute.EndSize); + + if (!string.IsNullOrEmpty(_propertyAttribute.Replay)) + { + var parts = _propertyAttribute.Replay!.Split(','); + if (parts.Length >= 1 && ulong.TryParse(parts[0].Trim(), out var seed1)) + { + var seed2 = parts.Length >= 2 && ulong.TryParse(parts[1].Trim(), out var s2) ? s2 : 0UL; + config = config.WithReplay(seed1, seed2); + } + } + + if (_propertyAttribute.Arbitrary != null && _propertyAttribute.Arbitrary.Length > 0) + { + config = config.WithArbitrary(_propertyAttribute.Arbitrary); + } + + return config; + } + + [UnconditionalSuppressMessage("Trimming", "IL2060", Justification = "FsCheck requires reflection")] + [UnconditionalSuppressMessage("AOT", "IL3050", Justification = "FsCheck requires dynamic code")] + private static void RunPropertyCheck(MethodInfo methodInfo, object classInstance, Config config) + { + var parameters = methodInfo.GetParameters(); + var parameterTypes = parameters.Select(p => p.ParameterType).ToArray(); + var parameterNames = parameters.Select(p => p.Name ?? "arg").ToArray(); + + // Create the property based on parameter count + switch (parameterTypes.Length) + { + case 0: + RunPropertyWithNoParams(methodInfo, classInstance, config); + break; + case 1: + RunPropertyWithParams1(methodInfo, classInstance, config, parameterTypes[0], parameterNames[0]); + break; + case 2: + RunPropertyWithParams2(methodInfo, classInstance, config, parameterTypes[0], parameterTypes[1], + parameterNames); + break; + case 3: + RunPropertyWithParams3(methodInfo, classInstance, config, parameterTypes[0], parameterTypes[1], + parameterTypes[2], parameterNames); + break; + default: + throw new NotSupportedException( + $"FsCheck property tests with {parameterTypes.Length} parameters are not supported. Maximum is 4."); + } + } + + private static void RunPropertyWithNoParams(MethodInfo methodInfo, object classInstance, Config config) + { + object? result; + try + { + result = methodInfo.Invoke(classInstance, null); + } + catch (TargetInvocationException ex) when (ex.InnerException != null) + { + ExceptionDispatchInfo.Capture(ex.InnerException).Throw(); + throw; // Unreachable + } + + HandleVoidResult(result); + } + + [UnconditionalSuppressMessage("AOT", "IL3050", Justification = "FsCheck requires dynamic code")] + private static void RunPropertyWithParams1(MethodInfo methodInfo, object classInstance, Config config, Type type1, + string paramName) + { + var genericMethod = typeof(FsCheckPropertyTestExecutor) + .GetMethod(nameof(RunPropertyGeneric1), BindingFlags.NonPublic | BindingFlags.Static)! + .MakeGenericMethod(type1); + + try + { + genericMethod.Invoke(null, [methodInfo, classInstance, config, paramName]); + } + catch (TargetInvocationException ex) when (ex.InnerException != null) + { + ExceptionDispatchInfo.Capture(ex.InnerException).Throw(); + } + } + + [UnconditionalSuppressMessage("AOT", "IL3050", Justification = "FsCheck requires dynamic code")] + private static void RunPropertyWithParams2(MethodInfo methodInfo, object classInstance, Config config, Type type1, + Type type2, string[] paramNames) + { + var genericMethod = typeof(FsCheckPropertyTestExecutor) + .GetMethod(nameof(RunPropertyGeneric2), BindingFlags.NonPublic | BindingFlags.Static)! + .MakeGenericMethod(type1, type2); + + try + { + genericMethod.Invoke(null, [methodInfo, classInstance, config, paramNames]); + } + catch (TargetInvocationException ex) when (ex.InnerException != null) + { + ExceptionDispatchInfo.Capture(ex.InnerException).Throw(); + } + } + + [UnconditionalSuppressMessage("AOT", "IL3050", Justification = "FsCheck requires dynamic code")] + private static void RunPropertyWithParams3(MethodInfo methodInfo, object classInstance, Config config, Type type1, + Type type2, Type type3, string[] paramNames) + { + var genericMethod = typeof(FsCheckPropertyTestExecutor) + .GetMethod(nameof(RunPropertyGeneric3), BindingFlags.NonPublic | BindingFlags.Static)! + .MakeGenericMethod(type1, type2, type3); + + try + { + genericMethod.Invoke(null, [methodInfo, classInstance, config, paramNames]); + } + catch (TargetInvocationException ex) when (ex.InnerException != null) + { + ExceptionDispatchInfo.Capture(ex.InnerException).Throw(); + } + } + + private static void RunPropertyGeneric1(MethodInfo methodInfo, object classInstance, Config config, + string paramName) + { + T? failingValue = default; + var hasFailed = false; + + try + { + Prop.ForAll(arg => + { + var result = methodInfo.Invoke(classInstance, [arg]); + var passed = HandleResult(result); + if (!passed && !hasFailed) + { + failingValue = arg; + hasFailed = true; + } + + return passed; + }).Check(config); + } + catch (Exception ex) + { + throw new PropertyFailedException( + FormatCounterexample(methodInfo.Name, [(paramName, failingValue)], ex), + ex); + } + } + + private static void RunPropertyGeneric2(MethodInfo methodInfo, object classInstance, Config config, + string[] paramNames) + { + T1? failingValue1 = default; + T2? failingValue2 = default; + var hasFailed = false; + + try + { + Prop.ForAll((arg1, arg2) => + { + var result = methodInfo.Invoke(classInstance, [arg1, arg2]); + var passed = HandleResult(result); + if (!passed && !hasFailed) + { + failingValue1 = arg1; + failingValue2 = arg2; + hasFailed = true; + } + + return passed; + }).Check(config); + } + catch (Exception ex) + { + throw new PropertyFailedException( + FormatCounterexample(methodInfo.Name, [(paramNames[0], failingValue1), (paramNames[1], failingValue2)], + ex), + ex); + } + } + + private static void RunPropertyGeneric3(MethodInfo methodInfo, object classInstance, Config config, + string[] paramNames) + { + T1? failingValue1 = default; + T2? failingValue2 = default; + T3? failingValue3 = default; + var hasFailed = false; + + try + { + Prop.ForAll((arg1, arg2, arg3) => + { + var result = methodInfo.Invoke(classInstance, [arg1, arg2, arg3]); + var passed = HandleResult(result); + if (!passed && !hasFailed) + { + failingValue1 = arg1; + failingValue2 = arg2; + failingValue3 = arg3; + hasFailed = true; + } + + return passed; + }).Check(config); + } + catch (Exception ex) + { + throw new PropertyFailedException( + FormatCounterexample(methodInfo.Name, + [(paramNames[0], failingValue1), (paramNames[1], failingValue2), (paramNames[2], failingValue3)], + ex), + ex); + } + } + + private static string FormatCounterexample(string methodName, (string name, object? value)[] args, Exception ex) + { + var sb = new StringBuilder(); + sb.AppendLine($"Property '{methodName}' failed with counterexample:"); + sb.AppendLine(); + + // Include the original exception message if it has useful info + var innerEx = ex; + while (innerEx is TargetInvocationException { InnerException: not null } tie) + { + innerEx = tie.InnerException; + } + + // Try to extract shrunk values from FsCheck message + var shrunkValues = TryParseShrunkValues(innerEx?.Message); + + // Display args, using shrunk values if available + for (int i = 0; i < args.Length; i++) + { + var (name, value) = args[i]; + var displayValue = (shrunkValues != null && i < shrunkValues.Length) + ? shrunkValues[i] + : FormatValue(value); + sb.AppendLine($" {name} = {displayValue}"); + } + + return sb.ToString(); + } + + private static string[]? TryParseShrunkValues(string? message) + { + if (string.IsNullOrEmpty(message)) + return null; + + // Look for "Shrunk:" followed by values on the next line + var shrunkIndex = message!.IndexOf("Shrunk:", StringComparison.Ordinal); + if (shrunkIndex < 0) + return null; + + var afterShrunk = message[(shrunkIndex + 7)..].TrimStart(); + + // Take only the first line + var newlineIndex = afterShrunk.IndexOfAny(['\r', '\n']); + var shrunkLine = newlineIndex >= 0 ? afterShrunk[..newlineIndex] : afterShrunk; + + if (shrunkLine.StartsWith('(')) + { + return ParseTupleValues(shrunkLine); + } + else + { + // Single value (no brackets) + return [shrunkLine.Trim()]; + } + } + + private static string[]? ParseTupleValues(string tupleString) + { + if (!tupleString.StartsWith('(')) + return null; + + var values = new List(); + var current = new StringBuilder(); + var depth = 0; + var inString = false; + var escaped = false; + + for (var i = 1; i < tupleString.Length; i++) + { + var c = tupleString[i]; + + if (escaped) + { + current.Append(c); + escaped = false; + continue; + } + + if (c == '\\' && inString) + { + current.Append(c); + escaped = true; + continue; + } + + if (c == '"') + { + inString = !inString; + current.Append(c); + continue; + } + + if (inString) + { + current.Append(c); + continue; + } + + switch (c) + { + case '(': + depth++; + current.Append(c); + break; + case ')': + if (depth == 0) + { + if (current.Length > 0) + values.Add(current.ToString().Trim()); + return values.ToArray(); + } + + depth--; + current.Append(c); + break; + case ',' when depth == 0: + values.Add(current.ToString().Trim()); + current.Clear(); + break; + default: + current.Append(c); + break; + } + } + + return values.ToArray(); + } + + private static string FormatValue(object? value) + { + if (value == null) + { + return "null"; + } + + if (value is string s) + { + return $"\"{s}\""; + } + + if (value is char c) + { + return $"'{c}'"; + } + + if (value is Array arr) + { + var elements = new List(); + foreach (var item in arr) + { + elements.Add(FormatValue(item)); + if (elements.Count > 10) + { + elements.Add($"... ({arr.Length} total)"); + break; + } + } + return $"[{string.Join(", ", elements)}]"; + } + + return value.ToString() ?? "null"; + } + + private static void HandleVoidResult(object? result) + { + switch (result) + { + case Task task: + task.GetAwaiter().GetResult(); + break; + case ValueTask valueTask: + valueTask.GetAwaiter().GetResult(); + break; + } + } + + private static bool HandleResult(object? result) + { + switch (result) + { + case Task taskBool: + return taskBool.GetAwaiter().GetResult(); + case ValueTask valueTaskBool: + return valueTaskBool.GetAwaiter().GetResult(); + case Task task: + task.GetAwaiter().GetResult(); + return true; + case ValueTask valueTask: + valueTask.GetAwaiter().GetResult(); + return true; + case bool boolResult: + return boolResult; + default: + // Method returned void or non-boolean - assume success if no exception + return true; + } + } +} +#pragma warning restore IL2046 +#pragma warning restore IL3051 +#pragma warning restore IL2072 diff --git a/TUnit.FsCheck/PropertyFailedException.cs b/TUnit.FsCheck/PropertyFailedException.cs new file mode 100644 index 0000000000..310c832db0 --- /dev/null +++ b/TUnit.FsCheck/PropertyFailedException.cs @@ -0,0 +1,15 @@ +namespace TUnit.FsCheck; + +/// +/// Exception thrown when an FsCheck property test fails. +/// +public class PropertyFailedException : Exception +{ + public PropertyFailedException(string message) : base(message) + { + } + + public PropertyFailedException(string message, Exception innerException) : base(message, innerException) + { + } +} diff --git a/TUnit.FsCheck/TUnit.FsCheck.csproj b/TUnit.FsCheck/TUnit.FsCheck.csproj new file mode 100644 index 0000000000..4e1db6d206 --- /dev/null +++ b/TUnit.FsCheck/TUnit.FsCheck.csproj @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + diff --git a/TUnit.sln b/TUnit.sln index 2bb8c46d11..3350695215 100644 --- a/TUnit.sln +++ b/TUnit.sln @@ -151,6 +151,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TUnit.AspNetCore.Analyzers. EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TUnit.AspNetCore.Analyzers", "TUnit.AspNetCore.Analyzers\TUnit.AspNetCore.Analyzers.csproj", "{6134813B-F928-443F-A629-F6726A1112F9}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TUnit.FsCheck", "TUnit.FsCheck\TUnit.FsCheck.csproj", "{6846A70E-2232-4BEF-9CE5-03F28A221335}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TUnit.Example.FsCheck.TestProject", "TUnit.Example.FsCheck.TestProject\TUnit.Example.FsCheck.TestProject.csproj", "{3428D7AD-B362-4647-B1B0-72674CF3BC7C}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -869,6 +873,30 @@ Global {6134813B-F928-443F-A629-F6726A1112F9}.Release|x64.Build.0 = Release|Any CPU {6134813B-F928-443F-A629-F6726A1112F9}.Release|x86.ActiveCfg = Release|Any CPU {6134813B-F928-443F-A629-F6726A1112F9}.Release|x86.Build.0 = Release|Any CPU + {6846A70E-2232-4BEF-9CE5-03F28A221335}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6846A70E-2232-4BEF-9CE5-03F28A221335}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6846A70E-2232-4BEF-9CE5-03F28A221335}.Debug|x64.ActiveCfg = Debug|Any CPU + {6846A70E-2232-4BEF-9CE5-03F28A221335}.Debug|x64.Build.0 = Debug|Any CPU + {6846A70E-2232-4BEF-9CE5-03F28A221335}.Debug|x86.ActiveCfg = Debug|Any CPU + {6846A70E-2232-4BEF-9CE5-03F28A221335}.Debug|x86.Build.0 = Debug|Any CPU + {6846A70E-2232-4BEF-9CE5-03F28A221335}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6846A70E-2232-4BEF-9CE5-03F28A221335}.Release|Any CPU.Build.0 = Release|Any CPU + {6846A70E-2232-4BEF-9CE5-03F28A221335}.Release|x64.ActiveCfg = Release|Any CPU + {6846A70E-2232-4BEF-9CE5-03F28A221335}.Release|x64.Build.0 = Release|Any CPU + {6846A70E-2232-4BEF-9CE5-03F28A221335}.Release|x86.ActiveCfg = Release|Any CPU + {6846A70E-2232-4BEF-9CE5-03F28A221335}.Release|x86.Build.0 = Release|Any CPU + {3428D7AD-B362-4647-B1B0-72674CF3BC7C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3428D7AD-B362-4647-B1B0-72674CF3BC7C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3428D7AD-B362-4647-B1B0-72674CF3BC7C}.Debug|x64.ActiveCfg = Debug|Any CPU + {3428D7AD-B362-4647-B1B0-72674CF3BC7C}.Debug|x64.Build.0 = Debug|Any CPU + {3428D7AD-B362-4647-B1B0-72674CF3BC7C}.Debug|x86.ActiveCfg = Debug|Any CPU + {3428D7AD-B362-4647-B1B0-72674CF3BC7C}.Debug|x86.Build.0 = Debug|Any CPU + {3428D7AD-B362-4647-B1B0-72674CF3BC7C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3428D7AD-B362-4647-B1B0-72674CF3BC7C}.Release|Any CPU.Build.0 = Release|Any CPU + {3428D7AD-B362-4647-B1B0-72674CF3BC7C}.Release|x64.ActiveCfg = Release|Any CPU + {3428D7AD-B362-4647-B1B0-72674CF3BC7C}.Release|x64.Build.0 = Release|Any CPU + {3428D7AD-B362-4647-B1B0-72674CF3BC7C}.Release|x86.ActiveCfg = Release|Any CPU + {3428D7AD-B362-4647-B1B0-72674CF3BC7C}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -935,6 +963,8 @@ Global {D5C70ADD-B960-4E6C-836C-6041938D04BE} = {503DA9FA-045D-4910-8AF6-905E6048B1F1} {9B33972F-F5B9-4EC2-AE5C-4D48604DEB04} = {62AD1EAF-43C4-4AC0-B9FA-CD59739B3850} {6134813B-F928-443F-A629-F6726A1112F9} = {503DA9FA-045D-4910-8AF6-905E6048B1F1} + {3428D7AD-B362-4647-B1B0-72674CF3BC7C} = {0BA988BF-ADCE-4343-9098-B4EF65C43709} + {6846A70E-2232-4BEF-9CE5-03F28A221335} = {1B56B580-4D59-4E83-9F80-467D58DADAC1} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {109D285A-36B3-4503-BCDF-8E26FB0E2C5B} From 4757fbd98f302ef84e37a1208fd1954cff82be87 Mon Sep 17 00:00:00 2001 From: Stuart Lang Date: Mon, 29 Dec 2025 16:14:35 +0000 Subject: [PATCH 02/11] Use Test attribute --- .../Generators/TestMetadataGenerator.cs | 131 +++--------------- .../Models/TestMethodMetadata.cs | 2 +- .../Discovery/ReflectionTestDataCollector.cs | 7 +- .../PropertyTests.cs | 15 +- TUnit.FsCheck/FsCheckPropertyAttribute.cs | 33 ++++- TUnit.FsCheck/FsCheckPropertyTestExecutor.cs | 27 ++-- 6 files changed, 74 insertions(+), 141 deletions(-) diff --git a/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs b/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs index 0f521849fa..bfb2f78d6a 100644 --- a/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs +++ b/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs @@ -36,14 +36,6 @@ public void Initialize(IncrementalGeneratorInitializationContext context) .Where(static m => m is not null) .Combine(enabledProvider); - // Custom test attributes that inherit from BaseTestAttribute - var customTestMethodsProvider = context.SyntaxProvider - .CreateSyntaxProvider( - predicate: static (node, _) => node is MethodDeclarationSyntax { AttributeLists.Count: > 0 }, - transform: static (ctx, _) => GetCustomTestMethodMetadata(ctx)) - .Where(static m => m is not null) - .Combine(enabledProvider); - var inheritsTestsClassesProvider = context.SyntaxProvider .ForAttributeWithMetadataName( "TUnit.Core.InheritsTestsAttribute", @@ -63,17 +55,6 @@ public void Initialize(IncrementalGeneratorInitializationContext context) GenerateTestMethodSource(context, testMethod); }); - context.RegisterSourceOutput(customTestMethodsProvider, - static (context, data) => - { - var (testMethod, isEnabled) = data; - if (!isEnabled) - { - return; - } - GenerateTestMethodSource(context, testMethod); - }); - context.RegisterSourceOutput(inheritsTestsClassesProvider, static (context, data) => { @@ -86,86 +67,6 @@ public void Initialize(IncrementalGeneratorInitializationContext context) }); } - private static TestMethodMetadata? GetCustomTestMethodMetadata(GeneratorSyntaxContext context) - { - var methodSyntax = (MethodDeclarationSyntax)context.Node; - var methodSymbol = context.SemanticModel.GetDeclaredSymbol(methodSyntax) as IMethodSymbol; - - if (methodSymbol == null) - { - return null; - } - - // Find the custom test attribute that inherits from BaseTestAttribute - // Skip any attributes defined in TUnit.Core namespace (handled by built-in providers) - AttributeData? testAttribute = null; - foreach (var attr in methodSymbol.GetAttributes()) - { - var attrType = attr.AttributeClass; - if (attrType == null) - { - continue; - } - - // Skip built-in TUnit.Core attributes - they're handled by other providers - if (attrType.ContainingNamespace?.ToDisplayString() == "TUnit.Core") - { - continue; - } - - var baseType = attrType.BaseType; - while (baseType != null) - { - if (baseType.ToDisplayString() == "TUnit.Core.BaseTestAttribute") - { - testAttribute = attr; - break; - } - baseType = baseType.BaseType; - } - if (testAttribute != null) - { - break; - } - } - - if (testAttribute == null) - { - return null; - } - - var containingType = methodSymbol.ContainingType; - - if (containingType == null) - { - return null; - } - - if (containingType.IsAbstract) - { - return null; - } - - var isGenericType = containingType is { IsGenericType: true, TypeParameters.Length: > 0 }; - var isGenericMethod = methodSymbol is { IsGenericMethod: true }; - - var (filePath, lineNumber) = GetTestMethodSourceLocation(methodSyntax, testAttribute); - - return new TestMethodMetadata - { - MethodSymbol = methodSymbol, - TypeSymbol = containingType, - FilePath = filePath, - LineNumber = lineNumber, - TestAttribute = testAttribute, - SemanticModel = context.SemanticModel, - MethodSyntax = methodSyntax, - IsGenericType = isGenericType, - IsGenericMethod = isGenericMethod, - MethodAttributes = methodSymbol.GetAttributes() - }; - } - private static InheritsTestsClassMetadata? GetInheritsTestsClassMetadata(GeneratorAttributeSyntaxContext context) { var classSyntax = (ClassDeclarationSyntax)context.TargetNode; @@ -184,7 +85,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) { TypeSymbol = classSymbol, ClassSyntax = classSyntax, - SemanticModel = context.SemanticModel + Context = context }; } @@ -219,7 +120,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) FilePath = filePath, LineNumber = lineNumber, TestAttribute = context.Attributes.First(), - SemanticModel = context.SemanticModel, + Context = context, MethodSyntax = methodSyntax, IsGenericType = isGenericType, IsGenericMethod = isGenericMethod, @@ -284,7 +185,7 @@ private static void GenerateInheritedTestSources(SourceProductionContext context FilePath = filePath, LineNumber = lineNumber, TestAttribute = testAttribute, - SemanticModel = classInfo.SemanticModel, // Use class context to access Compilation + Context = classInfo.Context, // Use class context to access Compilation MethodSyntax = null, // No syntax for inherited methods IsGenericType = typeForMetadata.IsGenericType, IsGenericMethod = (concreteMethod ?? method).IsGenericMethod, @@ -327,7 +228,7 @@ private static void GenerateTestMethodSource(SourceProductionContext context, Te { try { - if (testMethod?.MethodSymbol == null || testMethod.SemanticModel?.Compilation == null) + if (testMethod?.MethodSymbol == null || testMethod.Context == null) { return; } @@ -370,7 +271,7 @@ private static void GenerateFileHeader(CodeWriter writer) private static void GenerateTestMetadata(CodeWriter writer, TestMethodMetadata testMethod) { - var compilation = testMethod.SemanticModel?.Compilation!; + var compilation = testMethod.Context!.Value.SemanticModel.Compilation; var className = testMethod.TypeSymbol.GloballyQualified(); var methodName = testMethod.MethodSymbol.Name; @@ -453,7 +354,7 @@ private static void GenerateSpecificGenericInstantiation( string combinationGuid, ImmutableArray typeArguments) { - var compilation = testMethod.SemanticModel?.Compilation!; + var compilation = testMethod.Context!.Value.SemanticModel.Compilation; var methodName = testMethod.MethodSymbol.Name; var typeArgsString = string.Join(", ", typeArguments.Select(t => t.GloballyQualified())); var instantiatedMethodName = $"{methodName}<{typeArgsString}>"; @@ -465,7 +366,7 @@ private static void GenerateSpecificGenericInstantiation( FilePath = testMethod.FilePath, LineNumber = testMethod.LineNumber, TestAttribute = testMethod.TestAttribute, - SemanticModel = testMethod.SemanticModel, + Context = testMethod.Context, MethodSyntax = testMethod.MethodSyntax, IsGenericType = testMethod.IsGenericType, IsGenericMethod = false, // We're creating a concrete instantiation @@ -672,7 +573,7 @@ private static void GenerateTestMetadataInstance(CodeWriter writer, TestMethodMe private static void GenerateMetadata(CodeWriter writer, TestMethodMetadata testMethod) { - var compilation = testMethod.SemanticModel?.Compilation!; + var compilation = testMethod.Context!.Value.SemanticModel.Compilation; var methodSymbol = testMethod.MethodSymbol; @@ -718,7 +619,7 @@ private static void GenerateMetadata(CodeWriter writer, TestMethodMetadata testM private static void GenerateMetadataForConcreteInstantiation(CodeWriter writer, TestMethodMetadata testMethod) { - var compilation = testMethod.SemanticModel?.Compilation!; + var compilation = testMethod.Context!.Value.SemanticModel.Compilation; var methodSymbol = testMethod.MethodSymbol; @@ -770,7 +671,7 @@ private static void GenerateMetadataForConcreteInstantiation(CodeWriter writer, private static void GenerateDataSources(CodeWriter writer, TestMethodMetadata testMethod) { - var compilation = testMethod.SemanticModel?.Compilation!; + var compilation = testMethod.Context!.Value.SemanticModel.Compilation; var methodSymbol = testMethod.MethodSymbol; var typeSymbol = testMethod.TypeSymbol; @@ -1674,7 +1575,7 @@ private static void GeneratePropertyInjections(CodeWriter writer, INamedTypeSymb private static void GeneratePropertyDataSources(CodeWriter writer, TestMethodMetadata testMethod) { - var compilation = testMethod.SemanticModel?.Compilation!; + var compilation = testMethod.Context!.Value.SemanticModel.Compilation; var typeSymbol = testMethod.TypeSymbol; var currentType = typeSymbol; var processedProperties = new HashSet(); @@ -3241,7 +3142,7 @@ private static void GenerateGenericTestWithConcreteTypes( string className, string combinationGuid) { - var compilation = testMethod.SemanticModel?.Compilation!; + var compilation = testMethod.Context!.Value.SemanticModel.Compilation; var methodName = testMethod.MethodSymbol.Name; writer.AppendLine("// Create generic metadata with concrete type registrations"); @@ -4720,7 +4621,7 @@ private static void GenerateConcreteTestMetadata( ITypeSymbol[] typeArguments, AttributeData? specificArgumentsAttribute = null) { - var compilation = testMethod.SemanticModel?.Compilation!; + var compilation = testMethod.Context!.Value.SemanticModel.Compilation; var methodName = testMethod.MethodSymbol.Name; // Separate class type arguments from method type arguments @@ -4940,7 +4841,7 @@ private static void GenerateConcreteMetadataWithFilteredDataSources( AttributeData? specificArgumentsAttribute, ITypeSymbol[] typeArguments) { - var compilation = testMethod.SemanticModel?.Compilation!; + var compilation = testMethod.Context!.Value.SemanticModel.Compilation; var methodSymbol = testMethod.MethodSymbol; var typeSymbol = testMethod.TypeSymbol; @@ -5259,7 +5160,7 @@ private static void GenerateConcreteTestMetadataForNonGeneric( AttributeData? classDataSourceAttribute, AttributeData? methodDataSourceAttribute) { - var compilation = testMethod.SemanticModel?.Compilation!; + var compilation = testMethod.Context!.Value.SemanticModel.Compilation; var methodName = testMethod.MethodSymbol.Name; writer.AppendLine($"var metadata = new global::TUnit.Core.TestMetadata<{className}>"); @@ -5452,6 +5353,6 @@ public class InheritsTestsClassMetadata { public required INamedTypeSymbol TypeSymbol { get; init; } public required ClassDeclarationSyntax ClassSyntax { get; init; } - public SemanticModel SemanticModel { get; init; } + public GeneratorAttributeSyntaxContext Context { get; init; } } diff --git a/TUnit.Core.SourceGenerator/Models/TestMethodMetadata.cs b/TUnit.Core.SourceGenerator/Models/TestMethodMetadata.cs index 6e7dd89cdb..ca37a34ecf 100644 --- a/TUnit.Core.SourceGenerator/Models/TestMethodMetadata.cs +++ b/TUnit.Core.SourceGenerator/Models/TestMethodMetadata.cs @@ -11,10 +11,10 @@ public class TestMethodMetadata : IEquatable { public required IMethodSymbol MethodSymbol { get; init; } public required INamedTypeSymbol TypeSymbol { get; init; } - public required SemanticModel SemanticModel { get; init; } public required string FilePath { get; init; } public required int LineNumber { get; init; } public required AttributeData TestAttribute { get; init; } + public GeneratorAttributeSyntaxContext? Context { get; init; } public required MethodDeclarationSyntax? MethodSyntax { get; init; } public bool IsGenericType { get; init; } public bool IsGenericMethod { get; init; } diff --git a/TUnit.Engine/Discovery/ReflectionTestDataCollector.cs b/TUnit.Engine/Discovery/ReflectionTestDataCollector.cs index 4531d99197..600924bdd4 100644 --- a/TUnit.Engine/Discovery/ReflectionTestDataCollector.cs +++ b/TUnit.Engine/Discovery/ReflectionTestDataCollector.cs @@ -1026,18 +1026,17 @@ private static bool HasTestMethods([DynamicallyAccessedMembers(DynamicallyAccess private static bool IsTestMethod(MethodInfo method) { - // Check if method has any attribute that inherits from BaseTestAttribute - return method.GetCustomAttributes(typeof(BaseTestAttribute), inherit: false).Length > 0; + return method.IsDefined(typeof(TestAttribute), inherit: false); } private static string? ExtractFilePath(MethodInfo method) { - return method.GetCustomAttribute()?.File; + return method.GetCustomAttribute()?.File; } private static int? ExtractLineNumber(MethodInfo method) { - return method.GetCustomAttribute()?.Line; + return method.GetCustomAttribute()?.Line; } private static TestMetadata CreateFailedTestMetadataForAssembly(Assembly assembly, Exception ex) diff --git a/TUnit.Example.FsCheck.TestProject/PropertyTests.cs b/TUnit.Example.FsCheck.TestProject/PropertyTests.cs index 306454eabd..51e24d58d7 100644 --- a/TUnit.Example.FsCheck.TestProject/PropertyTests.cs +++ b/TUnit.Example.FsCheck.TestProject/PropertyTests.cs @@ -1,23 +1,24 @@ +using TUnit.Core; using TUnit.FsCheck; namespace TUnit.Example.FsCheck.TestProject; public class PropertyTests { - [FsCheckProperty] + [Test, FsCheckProperty] public bool ReverseReverseIsOriginal(int[] array) { var reversed = array.AsEnumerable().Reverse().Reverse().ToArray(); return array.SequenceEqual(reversed); } - [FsCheckProperty] + [Test, FsCheckProperty] public bool AbsoluteValueIsNonNegative(int value) { return Math.Abs((long)value) >= 0; } - [FsCheckProperty] + [Test, FsCheckProperty] public bool StringConcatenationLength(string a, string b) { if (a == null || b == null) @@ -28,14 +29,14 @@ public bool StringConcatenationLength(string a, string b) return (a + b).Length == a.Length + b.Length; } - [FsCheckProperty(MaxTest = 50)] + [Test, FsCheckProperty(MaxTest = 50)] public bool ListConcatenationPreservesElements(int[] first, int[] second) { var combined = first.Concat(second).ToArray(); return combined.Length == first.Length + second.Length; } - [FsCheckProperty] + [Test, FsCheckProperty] public void AdditionIsCommutative(int a, int b) { var result1 = a + b; @@ -47,7 +48,7 @@ public void AdditionIsCommutative(int a, int b) } } - [FsCheckProperty] + [Test, FsCheckProperty] public async Task AsyncPropertyTest(int value) { await Task.Delay(1); // Simulate async work @@ -58,7 +59,7 @@ public async Task AsyncPropertyTest(int value) } } - [FsCheckProperty] + [Test, FsCheckProperty] public bool MultiplicationIsAssociative(int a, int b, int c) { // Using long to avoid overflow diff --git a/TUnit.FsCheck/FsCheckPropertyAttribute.cs b/TUnit.FsCheck/FsCheckPropertyAttribute.cs index 5a29ac5c3c..7433a2618a 100644 --- a/TUnit.FsCheck/FsCheckPropertyAttribute.cs +++ b/TUnit.FsCheck/FsCheckPropertyAttribute.cs @@ -1,4 +1,3 @@ -using System.Runtime.CompilerServices; using TUnit.Core; using TUnit.Core.Interfaces; @@ -9,12 +8,17 @@ namespace TUnit.FsCheck; /// The test method parameters will be generated by FsCheck and the test /// will be run multiple times with different generated values. /// -public class FsCheckPropertyAttribute : BaseTestAttribute, ITestRegisteredEventReceiver +/// +/// This attribute must be used together with the . +/// Example: +/// +/// [Test, FsCheckProperty] +/// public bool MyProperty(int value) => value * 0 == 0; +/// +/// +[AttributeUsage(AttributeTargets.Method)] +public class FsCheckPropertyAttribute : Attribute, ITestRegisteredEventReceiver, IDataSourceAttribute { - public FsCheckPropertyAttribute([CallerFilePath] string file = "", [CallerLineNumber] int line = 0) - : base(file, line) - { - } /// /// The maximum number of tests to run. Default is 100. @@ -68,6 +72,12 @@ public FsCheckPropertyAttribute([CallerFilePath] string file = "", [CallerLineNu /// public int Order => 0; + /// + /// Not used - FsCheck generates its own data during test execution. + /// This property exists to satisfy the IDataSourceAttribute interface. + /// + public bool SkipIfEmpty { get; set; } + /// /// Called when the test is registered. Sets up the FsCheck property executor. /// @@ -76,4 +86,15 @@ public ValueTask OnTestRegistered(TestRegisteredContext context) context.SetTestExecutor(new FsCheckPropertyTestExecutor(this)); return default; } + + /// + /// Returns placeholder data - actual test data is generated by FsCheck during test execution. + /// +#pragma warning disable CS1998 // Async method lacks 'await' operators + async IAsyncEnumerable>> IDataSourceAttribute.GetDataRowsAsync(DataGeneratorMetadata dataGeneratorMetadata) +#pragma warning restore CS1998 + { + // Return null array as placeholder - the FsCheckPropertyTestExecutor will generate actual data + yield return () => Task.FromResult(null); + } } diff --git a/TUnit.FsCheck/FsCheckPropertyTestExecutor.cs b/TUnit.FsCheck/FsCheckPropertyTestExecutor.cs index 39d7856458..b460c9ee1d 100644 --- a/TUnit.FsCheck/FsCheckPropertyTestExecutor.cs +++ b/TUnit.FsCheck/FsCheckPropertyTestExecutor.cs @@ -137,7 +137,7 @@ private static void RunPropertyCheck(MethodInfo methodInfo, object classInstance break; default: throw new NotSupportedException( - $"FsCheck property tests with {parameterTypes.Length} parameters are not supported. Maximum is 4."); + $"FsCheck property tests with {parameterTypes.Length} parameters are not supported. Maximum is 3."); } } @@ -235,8 +235,7 @@ private static void RunPropertyGeneric1(MethodInfo methodInfo, object classIn catch (Exception ex) { throw new PropertyFailedException( - FormatCounterexample(methodInfo.Name, [(paramName, failingValue)], ex), - ex); + FormatCounterexample(methodInfo.Name, [(paramName, failingValue)], ex)); } } @@ -267,8 +266,7 @@ private static void RunPropertyGeneric2(MethodInfo methodInfo, object cl { throw new PropertyFailedException( FormatCounterexample(methodInfo.Name, [(paramNames[0], failingValue1), (paramNames[1], failingValue2)], - ex), - ex); + ex)); } } @@ -302,8 +300,7 @@ private static void RunPropertyGeneric3(MethodInfo methodInfo, objec throw new PropertyFailedException( FormatCounterexample(methodInfo.Name, [(paramNames[0], failingValue1), (paramNames[1], failingValue2), (paramNames[2], failingValue3)], - ex), - ex); + ex)); } } @@ -313,7 +310,7 @@ private static string FormatCounterexample(string methodName, (string name, obje sb.AppendLine($"Property '{methodName}' failed with counterexample:"); sb.AppendLine(); - // Include the original exception message if it has useful info + // Unwrap TargetInvocationException to get to the actual FsCheck exception var innerEx = ex; while (innerEx is TargetInvocationException { InnerException: not null } tie) { @@ -333,6 +330,20 @@ private static string FormatCounterexample(string methodName, (string name, obje sb.AppendLine($" {name} = {displayValue}"); } + // Append the FsCheck message for full details + if (innerEx != null && !string.IsNullOrEmpty(innerEx.Message)) + { + sb.AppendLine(); + sb.AppendLine("FsCheck output:"); + sb.AppendLine(); + // Indent each line of the FsCheck message + foreach (var line in innerEx.Message.Split('\n')) + { + sb.Append(" "); + sb.AppendLine(line.TrimEnd('\r')); + } + } + return sb.ToString(); } From 66331614242d7be74c814febdaaef8c1ad25d8e7 Mon Sep 17 00:00:00 2001 From: Stuart Lang Date: Mon, 29 Dec 2025 17:19:28 +0000 Subject: [PATCH 03/11] Remove some new lines --- TUnit.FsCheck/FsCheckPropertyTestExecutor.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/TUnit.FsCheck/FsCheckPropertyTestExecutor.cs b/TUnit.FsCheck/FsCheckPropertyTestExecutor.cs index b460c9ee1d..693ef45e4d 100644 --- a/TUnit.FsCheck/FsCheckPropertyTestExecutor.cs +++ b/TUnit.FsCheck/FsCheckPropertyTestExecutor.cs @@ -308,7 +308,6 @@ private static string FormatCounterexample(string methodName, (string name, obje { var sb = new StringBuilder(); sb.AppendLine($"Property '{methodName}' failed with counterexample:"); - sb.AppendLine(); // Unwrap TargetInvocationException to get to the actual FsCheck exception var innerEx = ex; @@ -335,7 +334,6 @@ private static string FormatCounterexample(string methodName, (string name, obje { sb.AppendLine(); sb.AppendLine("FsCheck output:"); - sb.AppendLine(); // Indent each line of the FsCheck message foreach (var line in innerEx.Message.Split('\n')) { From 2ac678486ee5af4f17f1aea2ea04e65f10d06219 Mon Sep 17 00:00:00 2001 From: Stuart Lang Date: Mon, 29 Dec 2025 17:23:19 +0000 Subject: [PATCH 04/11] Revert more code --- .../Generators/TestMetadataGenerator.cs | 3 +++ .../Discovery/ReflectionTestDataCollector.cs | 19 +++++++------------ 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs b/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs index bfb2f78d6a..794954eab0 100644 --- a/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs +++ b/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs @@ -233,6 +233,9 @@ private static void GenerateTestMethodSource(SourceProductionContext context, Te return; } + // Get compilation from semantic model instead of parameter + var compilation = testMethod.Context.Value.SemanticModel.Compilation; + var writer = new CodeWriter(); GenerateFileHeader(writer); GenerateTestMetadata(writer, testMethod); diff --git a/TUnit.Engine/Discovery/ReflectionTestDataCollector.cs b/TUnit.Engine/Discovery/ReflectionTestDataCollector.cs index 600924bdd4..740f653d48 100644 --- a/TUnit.Engine/Discovery/ReflectionTestDataCollector.cs +++ b/TUnit.Engine/Discovery/ReflectionTestDataCollector.cs @@ -381,7 +381,7 @@ private static async Task> DiscoverTestsInAssembly(Assembly a var testMethodsList = new List(); foreach (var method in allMethods) { - if (IsTestMethod(method) && !method.IsAbstract) + if (method.IsDefined(typeof(TestAttribute), inherit: false) && !method.IsAbstract) { testMethodsList.Add(method); } @@ -394,7 +394,7 @@ private static async Task> DiscoverTestsInAssembly(Assembly a var testMethodsList = new List(declaredMethods.Length); foreach (var method in declaredMethods) { - if (IsTestMethod(method) && !method.IsAbstract) + if (method.IsDefined(typeof(TestAttribute), inherit: false) && !method.IsAbstract) { testMethodsList.Add(method); } @@ -512,14 +512,14 @@ private static async IAsyncEnumerable DiscoverTestsInAssemblyStrea { // Get all test methods including inherited ones testMethods = GetAllTestMethods(type) - .Where(static m => IsTestMethod(m) && !m.IsAbstract); + .Where(static m => m.IsDefined(typeof(TestAttribute), inherit: false) && !m.IsAbstract); } else { // Only get declared test methods testMethods = type.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly) - .Where(static m => IsTestMethod(m) && !m.IsAbstract); + .Where(static m => m.IsDefined(typeof(TestAttribute), inherit: false) && !m.IsAbstract); } } catch (Exception) @@ -582,7 +582,7 @@ private static async Task> DiscoverGenericTests(Type genericT var testMethodsList = new List(declaredMethods.Length); foreach (var method in declaredMethods) { - if (IsTestMethod(method) && !method.IsAbstract) + if (method.IsDefined(typeof(TestAttribute), inherit: false) && !method.IsAbstract) { testMethodsList.Add(method); } @@ -671,7 +671,7 @@ private static async IAsyncEnumerable DiscoverGenericTestsStreamin var testMethodsList = new List(declaredMethods.Length); foreach (var method in declaredMethods) { - if (IsTestMethod(method) && !method.IsAbstract) + if (method.IsDefined(typeof(TestAttribute), inherit: false) && !method.IsAbstract) { testMethodsList.Add(method); } @@ -1010,7 +1010,7 @@ private static bool HasTestMethods([DynamicallyAccessedMembers(DynamicallyAccess var methods = type.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly); foreach (var method in methods) { - if (IsTestMethod(method)) + if (method.IsDefined(typeof(TestAttribute), inherit: false)) { return true; } @@ -1024,11 +1024,6 @@ private static bool HasTestMethods([DynamicallyAccessedMembers(DynamicallyAccess } } - private static bool IsTestMethod(MethodInfo method) - { - return method.IsDefined(typeof(TestAttribute), inherit: false); - } - private static string? ExtractFilePath(MethodInfo method) { return method.GetCustomAttribute()?.File; From 5b66b6e0b7ac661a887301aceff6b8c4686493a3 Mon Sep 17 00:00:00 2001 From: Stuart Lang Date: Mon, 29 Dec 2025 17:24:40 +0000 Subject: [PATCH 05/11] Revert more code --- TUnit.Core.SourceGenerator/Models/TestMethodMetadata.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TUnit.Core.SourceGenerator/Models/TestMethodMetadata.cs b/TUnit.Core.SourceGenerator/Models/TestMethodMetadata.cs index ca37a34ecf..e97d4fb74c 100644 --- a/TUnit.Core.SourceGenerator/Models/TestMethodMetadata.cs +++ b/TUnit.Core.SourceGenerator/Models/TestMethodMetadata.cs @@ -23,7 +23,7 @@ public class TestMethodMetadata : IEquatable /// All attributes on the method, stored for later use during data combination generation /// public ImmutableArray MethodAttributes { get; init; } = ImmutableArray.Empty; - + /// /// The inheritance depth of this test method. /// 0 = method is declared directly in the test class From 8882b961f66daf4773ec9b5aa2efd3416682ce3c Mon Sep 17 00:00:00 2001 From: Stuart Lang Date: Mon, 29 Dec 2025 17:30:13 +0000 Subject: [PATCH 06/11] No need for internals now --- TUnit.Core/TUnit.Core.csproj | 1 - 1 file changed, 1 deletion(-) diff --git a/TUnit.Core/TUnit.Core.csproj b/TUnit.Core/TUnit.Core.csproj index fcaa39b5bd..f895e73378 100644 --- a/TUnit.Core/TUnit.Core.csproj +++ b/TUnit.Core/TUnit.Core.csproj @@ -6,7 +6,6 @@ - From e72133ea9cb959c04145bac191909101a974f186 Mon Sep 17 00:00:00 2001 From: Stuart Lang Date: Mon, 29 Dec 2025 20:53:04 +0000 Subject: [PATCH 07/11] Simplify --- .../PropertyTests.cs | 21 ++ TUnit.FsCheck/FsCheckPropertyTestExecutor.cs | 267 +----------------- 2 files changed, 32 insertions(+), 256 deletions(-) diff --git a/TUnit.Example.FsCheck.TestProject/PropertyTests.cs b/TUnit.Example.FsCheck.TestProject/PropertyTests.cs index 51e24d58d7..78f99ec30f 100644 --- a/TUnit.Example.FsCheck.TestProject/PropertyTests.cs +++ b/TUnit.Example.FsCheck.TestProject/PropertyTests.cs @@ -1,3 +1,5 @@ +using FsCheck; +using FsCheck.Fluent; using TUnit.Core; using TUnit.FsCheck; @@ -67,4 +69,23 @@ public bool MultiplicationIsAssociative(int a, int b, int c) var right = ((long)a * b) * c; return left == right; } + + [Test, FsCheckProperty] + public bool SumOfFourNumbersIsCommutative(int a, int b, int c, int d) + { + var sum1 = a + b + c + d; + var sum2 = d + c + b + a; + return sum1 == sum2; + } + + [Test, FsCheckProperty] + public Property StringReversalProperty() + { + return Prop.ForAll(str => + { + var reversed = new string(str.Reverse().ToArray()); + var doubleReversed = new string(reversed.Reverse().ToArray()); + return str == doubleReversed; + }); + } } diff --git a/TUnit.FsCheck/FsCheckPropertyTestExecutor.cs b/TUnit.FsCheck/FsCheckPropertyTestExecutor.cs index 693ef45e4d..0d6e8a9edb 100644 --- a/TUnit.FsCheck/FsCheckPropertyTestExecutor.cs +++ b/TUnit.FsCheck/FsCheckPropertyTestExecutor.cs @@ -114,198 +114,25 @@ private Config CreateConfig() [UnconditionalSuppressMessage("AOT", "IL3050", Justification = "FsCheck requires dynamic code")] private static void RunPropertyCheck(MethodInfo methodInfo, object classInstance, Config config) { - var parameters = methodInfo.GetParameters(); - var parameterTypes = parameters.Select(p => p.ParameterType).ToArray(); - var parameterNames = parameters.Select(p => p.Name ?? "arg").ToArray(); - - // Create the property based on parameter count - switch (parameterTypes.Length) - { - case 0: - RunPropertyWithNoParams(methodInfo, classInstance, config); - break; - case 1: - RunPropertyWithParams1(methodInfo, classInstance, config, parameterTypes[0], parameterNames[0]); - break; - case 2: - RunPropertyWithParams2(methodInfo, classInstance, config, parameterTypes[0], parameterTypes[1], - parameterNames); - break; - case 3: - RunPropertyWithParams3(methodInfo, classInstance, config, parameterTypes[0], parameterTypes[1], - parameterTypes[2], parameterNames); - break; - default: - throw new NotSupportedException( - $"FsCheck property tests with {parameterTypes.Length} parameters are not supported. Maximum is 3."); - } - } - - private static void RunPropertyWithNoParams(MethodInfo methodInfo, object classInstance, Config config) - { - object? result; - try - { - result = methodInfo.Invoke(classInstance, null); - } - catch (TargetInvocationException ex) when (ex.InnerException != null) - { - ExceptionDispatchInfo.Capture(ex.InnerException).Throw(); - throw; // Unreachable - } - - HandleVoidResult(result); - } - - [UnconditionalSuppressMessage("AOT", "IL3050", Justification = "FsCheck requires dynamic code")] - private static void RunPropertyWithParams1(MethodInfo methodInfo, object classInstance, Config config, Type type1, - string paramName) - { - var genericMethod = typeof(FsCheckPropertyTestExecutor) - .GetMethod(nameof(RunPropertyGeneric1), BindingFlags.NonPublic | BindingFlags.Static)! - .MakeGenericMethod(type1); - - try - { - genericMethod.Invoke(null, [methodInfo, classInstance, config, paramName]); - } - catch (TargetInvocationException ex) when (ex.InnerException != null) - { - ExceptionDispatchInfo.Capture(ex.InnerException).Throw(); - } - } - - [UnconditionalSuppressMessage("AOT", "IL3050", Justification = "FsCheck requires dynamic code")] - private static void RunPropertyWithParams2(MethodInfo methodInfo, object classInstance, Config config, Type type1, - Type type2, string[] paramNames) - { - var genericMethod = typeof(FsCheckPropertyTestExecutor) - .GetMethod(nameof(RunPropertyGeneric2), BindingFlags.NonPublic | BindingFlags.Static)! - .MakeGenericMethod(type1, type2); - try { - genericMethod.Invoke(null, [methodInfo, classInstance, config, paramNames]); - } - catch (TargetInvocationException ex) when (ex.InnerException != null) - { - ExceptionDispatchInfo.Capture(ex.InnerException).Throw(); - } - } - - [UnconditionalSuppressMessage("AOT", "IL3050", Justification = "FsCheck requires dynamic code")] - private static void RunPropertyWithParams3(MethodInfo methodInfo, object classInstance, Config config, Type type1, - Type type2, Type type3, string[] paramNames) - { - var genericMethod = typeof(FsCheckPropertyTestExecutor) - .GetMethod(nameof(RunPropertyGeneric3), BindingFlags.NonPublic | BindingFlags.Static)! - .MakeGenericMethod(type1, type2, type3); - - try - { - genericMethod.Invoke(null, [methodInfo, classInstance, config, paramNames]); - } - catch (TargetInvocationException ex) when (ex.InnerException != null) - { - ExceptionDispatchInfo.Capture(ex.InnerException).Throw(); - } - } - - private static void RunPropertyGeneric1(MethodInfo methodInfo, object classInstance, Config config, - string paramName) - { - T? failingValue = default; - var hasFailed = false; - - try - { - Prop.ForAll(arg => - { - var result = methodInfo.Invoke(classInstance, [arg]); - var passed = HandleResult(result); - if (!passed && !hasFailed) - { - failingValue = arg; - hasFailed = true; - } - - return passed; - }).Check(config); + Check.Method(config, methodInfo, classInstance); } catch (Exception ex) { - throw new PropertyFailedException( - FormatCounterexample(methodInfo.Name, [(paramName, failingValue)], ex)); + throw new PropertyFailedException(FormatCounterexample(methodInfo, ex)); } } - private static void RunPropertyGeneric2(MethodInfo methodInfo, object classInstance, Config config, - string[] paramNames) + private static string FormatCounterexample(MethodInfo methodInfo, Exception ex) { - T1? failingValue1 = default; - T2? failingValue2 = default; - var hasFailed = false; - - try - { - Prop.ForAll((arg1, arg2) => - { - var result = methodInfo.Invoke(classInstance, [arg1, arg2]); - var passed = HandleResult(result); - if (!passed && !hasFailed) - { - failingValue1 = arg1; - failingValue2 = arg2; - hasFailed = true; - } - - return passed; - }).Check(config); - } - catch (Exception ex) - { - throw new PropertyFailedException( - FormatCounterexample(methodInfo.Name, [(paramNames[0], failingValue1), (paramNames[1], failingValue2)], - ex)); - } - } - - private static void RunPropertyGeneric3(MethodInfo methodInfo, object classInstance, Config config, - string[] paramNames) - { - T1? failingValue1 = default; - T2? failingValue2 = default; - T3? failingValue3 = default; - var hasFailed = false; - - try - { - Prop.ForAll((arg1, arg2, arg3) => - { - var result = methodInfo.Invoke(classInstance, [arg1, arg2, arg3]); - var passed = HandleResult(result); - if (!passed && !hasFailed) - { - failingValue1 = arg1; - failingValue2 = arg2; - failingValue3 = arg3; - hasFailed = true; - } + var parameters = methodInfo.GetParameters(); + var args = parameters + .Select((p, i) => p.Name ?? $"arg{i}") + .ToArray(); - return passed; - }).Check(config); - } - catch (Exception ex) - { - throw new PropertyFailedException( - FormatCounterexample(methodInfo.Name, - [(paramNames[0], failingValue1), (paramNames[1], failingValue2), (paramNames[2], failingValue3)], - ex)); - } - } + var methodName = methodInfo.Name; - private static string FormatCounterexample(string methodName, (string name, object? value)[] args, Exception ex) - { var sb = new StringBuilder(); sb.AppendLine($"Property '{methodName}' failed with counterexample:"); @@ -322,11 +149,9 @@ private static string FormatCounterexample(string methodName, (string name, obje // Display args, using shrunk values if available for (int i = 0; i < args.Length; i++) { - var (name, value) = args[i]; - var displayValue = (shrunkValues != null && i < shrunkValues.Length) - ? shrunkValues[i] - : FormatValue(value); - sb.AppendLine($" {name} = {displayValue}"); + var name = args[i]; + var value = shrunkValues?[i] ?? ""; + sb.AppendLine($" {name} = {value}"); } // Append the FsCheck message for full details @@ -443,76 +268,6 @@ private static string FormatCounterexample(string methodName, (string name, obje return values.ToArray(); } - - private static string FormatValue(object? value) - { - if (value == null) - { - return "null"; - } - - if (value is string s) - { - return $"\"{s}\""; - } - - if (value is char c) - { - return $"'{c}'"; - } - - if (value is Array arr) - { - var elements = new List(); - foreach (var item in arr) - { - elements.Add(FormatValue(item)); - if (elements.Count > 10) - { - elements.Add($"... ({arr.Length} total)"); - break; - } - } - return $"[{string.Join(", ", elements)}]"; - } - - return value.ToString() ?? "null"; - } - - private static void HandleVoidResult(object? result) - { - switch (result) - { - case Task task: - task.GetAwaiter().GetResult(); - break; - case ValueTask valueTask: - valueTask.GetAwaiter().GetResult(); - break; - } - } - - private static bool HandleResult(object? result) - { - switch (result) - { - case Task taskBool: - return taskBool.GetAwaiter().GetResult(); - case ValueTask valueTaskBool: - return valueTaskBool.GetAwaiter().GetResult(); - case Task task: - task.GetAwaiter().GetResult(); - return true; - case ValueTask valueTask: - valueTask.GetAwaiter().GetResult(); - return true; - case bool boolResult: - return boolResult; - default: - // Method returned void or non-boolean - assume success if no exception - return true; - } - } } #pragma warning restore IL2046 #pragma warning restore IL3051 From 078ad24b6123f3352ba81790e20c51ef8ccfdc6a Mon Sep 17 00:00:00 2001 From: Stuart Lang Date: Mon, 29 Dec 2025 20:55:41 +0000 Subject: [PATCH 08/11] Simplify --- TUnit.FsCheck/FsCheckPropertyTestExecutor.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/TUnit.FsCheck/FsCheckPropertyTestExecutor.cs b/TUnit.FsCheck/FsCheckPropertyTestExecutor.cs index 0d6e8a9edb..30b7c9d837 100644 --- a/TUnit.FsCheck/FsCheckPropertyTestExecutor.cs +++ b/TUnit.FsCheck/FsCheckPropertyTestExecutor.cs @@ -150,8 +150,11 @@ private static string FormatCounterexample(MethodInfo methodInfo, Exception ex) for (int i = 0; i < args.Length; i++) { var name = args[i]; - var value = shrunkValues?[i] ?? ""; - sb.AppendLine($" {name} = {value}"); + var value = shrunkValues?[i]; + if (value != null) + { + sb.AppendLine($" {name} = {value}"); + } } // Append the FsCheck message for full details From 721158ad9776972c76c4d85a1095657aee0670f1 Mon Sep 17 00:00:00 2001 From: Stuart Lang Date: Mon, 29 Dec 2025 21:15:24 +0000 Subject: [PATCH 09/11] Respond to feedback --- TUnit.Example.FsCheck.TestProject/PropertyTests.cs | 2 +- TUnit.FsCheck/FsCheckPropertyTestExecutor.cs | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/TUnit.Example.FsCheck.TestProject/PropertyTests.cs b/TUnit.Example.FsCheck.TestProject/PropertyTests.cs index 78f99ec30f..27193b2b26 100644 --- a/TUnit.Example.FsCheck.TestProject/PropertyTests.cs +++ b/TUnit.Example.FsCheck.TestProject/PropertyTests.cs @@ -1,6 +1,5 @@ using FsCheck; using FsCheck.Fluent; -using TUnit.Core; using TUnit.FsCheck; namespace TUnit.Example.FsCheck.TestProject; @@ -88,4 +87,5 @@ public Property StringReversalProperty() return str == doubleReversed; }); } + } diff --git a/TUnit.FsCheck/FsCheckPropertyTestExecutor.cs b/TUnit.FsCheck/FsCheckPropertyTestExecutor.cs index 30b7c9d837..1ab58810ef 100644 --- a/TUnit.FsCheck/FsCheckPropertyTestExecutor.cs +++ b/TUnit.FsCheck/FsCheckPropertyTestExecutor.cs @@ -42,7 +42,11 @@ public ValueTask ExecuteTest(TestContext context, Func action) } [UnconditionalSuppressMessage("Trimming", "IL2070", Justification = "FsCheck requires reflection")] - private static MethodInfo GetMethodInfo(Type classType, string methodName, ParameterMetadata[] parameters) + private static MethodInfo GetMethodInfo( + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods)] + Type classType, + string methodName, + ParameterMetadata[] parameters) { // Try to find the method by name and parameter count var methods = classType From 6c7cd6bdfc7195c2c8b47112ebc7f8283f30afc9 Mon Sep 17 00:00:00 2001 From: Stuart Lang Date: Tue, 13 Jan 2026 19:05:57 +0000 Subject: [PATCH 10/11] Add some docs --- TUnit.FsCheck/FsCheckPropertyTestExecutor.cs | 3 +- docs/docs/examples/fscheck.md | 156 +++++++++++++++++++ docs/sidebars.ts | 1 + 3 files changed, 158 insertions(+), 2 deletions(-) create mode 100644 docs/docs/examples/fscheck.md diff --git a/TUnit.FsCheck/FsCheckPropertyTestExecutor.cs b/TUnit.FsCheck/FsCheckPropertyTestExecutor.cs index 1ab58810ef..a2dc1e0fb9 100644 --- a/TUnit.FsCheck/FsCheckPropertyTestExecutor.cs +++ b/TUnit.FsCheck/FsCheckPropertyTestExecutor.cs @@ -41,9 +41,8 @@ public ValueTask ExecuteTest(TestContext context, Func action) return default; } - [UnconditionalSuppressMessage("Trimming", "IL2070", Justification = "FsCheck requires reflection")] private static MethodInfo GetMethodInfo( - [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods)] + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods | DynamicallyAccessedMemberTypes.NonPublicMethods)] Type classType, string methodName, ParameterMetadata[] parameters) diff --git a/docs/docs/examples/fscheck.md b/docs/docs/examples/fscheck.md new file mode 100644 index 0000000000..5f31dda131 --- /dev/null +++ b/docs/docs/examples/fscheck.md @@ -0,0 +1,156 @@ +# FsCheck (Property-Based Testing) + +[FsCheck](https://fscheck.github.io/FsCheck/) is a property-based testing framework for .NET. Property-based testing generates random test data to verify that properties (invariants) hold true across many inputs. + +There is a NuGet package to help integrate FsCheck with TUnit: `TUnit.FsCheck` + +## Installation + +```bash +dotnet add package TUnit.FsCheck +``` + +## Basic Usage + +Use the `[Test, FsCheckProperty]` attributes together to create a property-based test: + +```csharp +using TUnit.FsCheck; + +public class PropertyTests +{ + [Test, FsCheckProperty] + public bool ReverseReverseIsOriginal(int[] array) + { + var reversed = array.Reverse().Reverse().ToArray(); + return array.SequenceEqual(reversed); + } + + [Test, FsCheckProperty] + public bool AdditionIsCommutative(int a, int b) + { + return a + b == b + a; + } +} +``` + +## Return Types + +Property tests can return: + +- **`bool`** - The test passes if the property returns `true` +- **`void`** - The test passes if no exception is thrown +- **`Task` / `ValueTask`** - For async properties +- **`Property`** - For advanced FsCheck properties with custom generators + +```csharp +// Boolean property +[Test, FsCheckProperty] +public bool StringConcatLength(string a, string b) +{ + if (a == null || b == null) return true; // Skip null cases + return (a + b).Length == a.Length + b.Length; +} + +// Void property (throws on failure) +[Test, FsCheckProperty] +public void MultiplicationByZeroIsZero(int value) +{ + if (value * 0 != 0) + throw new InvalidOperationException("Expected zero"); +} + +// Async property +[Test, FsCheckProperty] +public async Task AsyncPropertyTest(int value) +{ + await Task.Delay(1); + if (value * 0 != 0) + throw new InvalidOperationException("Expected zero"); +} + +// FsCheck Property type for advanced scenarios +[Test, FsCheckProperty] +public Property StringReversalProperty() +{ + return Prop.ForAll(str => + { + var reversed = new string(str.Reverse().ToArray()); + var doubleReversed = new string(reversed.Reverse().ToArray()); + return str == doubleReversed; + }); +} +``` + +## Configuration Options + +The `[FsCheckProperty]` attribute supports several configuration options: + +| Property | Default | Description | +|----------|---------|-------------| +| `MaxTest` | 100 | Maximum number of tests to run | +| `MaxFail` | 1000 | Maximum rejected tests before failing | +| `StartSize` | 1 | Starting size for test generation | +| `EndSize` | 100 | Ending size for test generation | +| `Replay` | null | Seed to replay a specific test run | +| `Verbose` | false | Output all generated arguments | +| `QuietOnSuccess` | false | Suppress output on passing tests | +| `Arbitrary` | null | Types containing custom Arbitrary instances | + +### Example with Configuration + +```csharp +[Test, FsCheckProperty(MaxTest = 50, StartSize = 1, EndSize = 50)] +public bool ListConcatenationPreservesElements(int[] first, int[] second) +{ + var combined = first.Concat(second).ToArray(); + return combined.Length == first.Length + second.Length; +} +``` + +## Reproducing Failures + +When a property test fails, FsCheck reports the seed that can be used to reproduce the failure. Use the `Replay` property to run the test with a specific seed: + +```csharp +[Test, FsCheckProperty(Replay = "12345,67890")] +public bool MyProperty(int value) +{ + return value >= 0; // Will reproduce the same failing case +} +``` + +## Custom Generators + +You can provide custom `Arbitrary` implementations for generating test data: + +```csharp +public class PositiveIntArbitrary +{ + public static Arbitrary PositiveInt() + { + return Arb.Default.Int32().Filter(x => x > 0); + } +} + +[Test, FsCheckProperty(Arbitrary = new[] { typeof(PositiveIntArbitrary) })] +public bool PositiveNumbersArePositive(int value) +{ + return value > 0; +} +``` + +## Limitations + +- **Native AOT**: TUnit.FsCheck is not compatible with Native AOT publishing because FsCheck requires reflection and dynamic code generation +- **Parameter count**: Properties can have any number of parameters that FsCheck can generate + +## When to Use Property-Based Testing + +Property-based testing is particularly useful for: + +- Testing mathematical properties (commutativity, associativity, etc.) +- Serialization/deserialization round-trips +- Data structure invariants +- Parsing and formatting functions +- Any code where you can express general rules that should always hold diff --git a/docs/sidebars.ts b/docs/sidebars.ts index 471beceb6b..19ab94b1b9 100644 --- a/docs/sidebars.ts +++ b/docs/sidebars.ts @@ -195,6 +195,7 @@ const sidebars: SidebarsConfig = { items: [ 'examples/aspnet', 'examples/playwright', + 'examples/fscheck', 'examples/complex-test-infrastructure', ], }, From 485b1c49d21e5a88807d9c50ad7ec48610932fc3 Mon Sep 17 00:00:00 2001 From: Stuart Lang Date: Tue, 13 Jan 2026 20:13:08 +0000 Subject: [PATCH 11/11] Add TUnit.FsCheck to the published packages list --- TUnit.Pipeline/Modules/GetPackageProjectsModule.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/TUnit.Pipeline/Modules/GetPackageProjectsModule.cs b/TUnit.Pipeline/Modules/GetPackageProjectsModule.cs index ecf54c6f4d..fa83ebc66a 100644 --- a/TUnit.Pipeline/Modules/GetPackageProjectsModule.cs +++ b/TUnit.Pipeline/Modules/GetPackageProjectsModule.cs @@ -19,7 +19,8 @@ public class GetPackageProjectsModule : Module> Sourcy.DotNet.Projects.TUnit, Sourcy.DotNet.Projects.TUnit_Playwright, Sourcy.DotNet.Projects.TUnit_Templates, - Sourcy.DotNet.Projects.TUnit_AspNetCore + Sourcy.DotNet.Projects.TUnit_AspNetCore, + Sourcy.DotNet.Projects.TUnit_FsCheck ]; } }