diff --git a/TUnit.Example.FsCheck.TestProject/CancellationTokenBehaviourTests.cs b/TUnit.Example.FsCheck.TestProject/CancellationTokenBehaviourTests.cs new file mode 100644 index 0000000000..d845049a7d --- /dev/null +++ b/TUnit.Example.FsCheck.TestProject/CancellationTokenBehaviourTests.cs @@ -0,0 +1,89 @@ +using System.Diagnostics; +using FsCheck; +using FsCheck.Fluent; +using TUnit.Core; +using TUnit.FsCheck; + +namespace TUnit.Example.FsCheck.TestProject; + +/// +/// Verifies that FsCheck properties receive the TUnit timeout-backed CancellationToken +/// rather than a token generated by FsCheck's default reflection-based arbitrary. +/// +[Timeout(10_000)] +public sealed class CancellationTokenBehaviourTests +{ + [Test, FsCheckProperty(MaxTest = 100)] + public bool CancellationTokenIsNotPreCancelled(CancellationToken cancellationToken) + { + return !cancellationToken.IsCancellationRequested; + } + + [Test, FsCheckProperty(MaxTest = 1)] + public bool CancellationTokenIsTestContextToken(CancellationToken cancellationToken) + { + var expected = TestContext.Current?.Execution.CancellationToken ?? CancellationToken.None; + return cancellationToken == expected; + } + + [Test, FsCheckProperty(MaxTest = 10, Arbitrary = [typeof(AlwaysNoneTokenArbitrary)])] + public bool UserSuppliedArbitraryOverridesDefault(CancellationToken cancellationToken) + { + return cancellationToken == CancellationToken.None; + } + + [Test, FsCheckProperty(MaxTest = 10, Arbitrary = [typeof(PositiveIntArbitrary)])] + public bool UserArbitraryForDifferentTypeDoesNotBypassDefault(int x, CancellationToken cancellationToken) + { + return x > 0 && !cancellationToken.IsCancellationRequested; + } + + [Test, FsCheckProperty(MaxTest = 1, Arbitrary = [])] + public bool EmptyArbitraryArrayStillUsesDefault(CancellationToken cancellationToken) + { + return !cancellationToken.IsCancellationRequested; + } + +#pragma warning disable TUnit0015 // intentionally omitted to verify the no-CT path + [Test, FsCheckProperty(MaxTest = 50)] + public bool NoCancellationTokenParameter_UnchangedBehaviour(int x, int y) + { + return x + y == y + x; + } +#pragma warning restore TUnit0015 + + /// + /// [Timeout(500)] + an await on the CT for 5s. If the CT is the timeout token, + /// the await is cooperatively cancelled at ~500ms. Without the fix, FsCheck injected its + /// own token and the await either threw immediately (pre-cancelled) or ran to completion. + /// + [Test, FsCheckProperty(MaxTest = 1)] + [Timeout(500)] + public async Task CooperativeCancellationViaTimeout(CancellationToken cancellationToken) + { + var stopwatch = Stopwatch.StartNew(); + try + { + await Task.Delay(5000, cancellationToken).ConfigureAwait(false); + return false; + } + catch (OperationCanceledException) + { + return stopwatch.Elapsed.TotalMilliseconds is >= 200 and <= 1_500; + } + } +} + +/// +/// Test arbitrary that always returns . +/// Used by +/// to verify that user-supplied arbitraries take precedence over the default. +/// +internal static class AlwaysNoneTokenArbitrary +{ + /// Always returns . + public static Arbitrary CancellationToken() + { + return Arb.From(Gen.Constant(System.Threading.CancellationToken.None)); + } +} diff --git a/TUnit.FsCheck/CancellationTokenArbitrary.cs b/TUnit.FsCheck/CancellationTokenArbitrary.cs new file mode 100644 index 0000000000..7467aeb7bf --- /dev/null +++ b/TUnit.FsCheck/CancellationTokenArbitrary.cs @@ -0,0 +1,19 @@ +using FsCheck; +using FsCheck.Fluent; +using TUnit.Core; + +namespace TUnit.FsCheck; + +/// +/// Default for ; surfaces the +/// timeout-backed token from . +/// +internal static class CancellationTokenArbitrary +{ + public static Arbitrary CancellationToken() + { + // Called by FsCheck via reflection during property execution; TestContext.Current is in scope. + var token = TestContext.Current?.Execution.CancellationToken ?? System.Threading.CancellationToken.None; + return Arb.From(Gen.Constant(token)); + } +} diff --git a/TUnit.FsCheck/FsCheckPropertyTestExecutor.cs b/TUnit.FsCheck/FsCheckPropertyTestExecutor.cs index a2dc1e0fb9..1a56ce3d5b 100644 --- a/TUnit.FsCheck/FsCheckPropertyTestExecutor.cs +++ b/TUnit.FsCheck/FsCheckPropertyTestExecutor.cs @@ -87,6 +87,7 @@ private static MethodInfo GetMethodInfo( return methods[0]; } + [DynamicDependency(DynamicallyAccessedMemberTypes.PublicMethods, typeof(CancellationTokenArbitrary))] private Config CreateConfig() { var config = Config.QuickThrowOnFailure @@ -105,10 +106,12 @@ private Config CreateConfig() } } - if (_propertyAttribute.Arbitrary != null && _propertyAttribute.Arbitrary.Length > 0) - { - config = config.WithArbitrary(_propertyAttribute.Arbitrary); - } + // Register a default Arbitrary that surfaces TestContext's + // timeout-backed token. User-supplied arbitraries are listed first; FsCheck's + // WithArbitrary resolves the first type in the list as highest priority, so + // user registrations override the default for conflicting types. + config = config.WithArbitrary( + (_propertyAttribute.Arbitrary ?? []).Append(typeof(CancellationTokenArbitrary))); return config; }