Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
using System.Diagnostics;
using FsCheck;
using FsCheck.Fluent;
using TUnit.Core;
using TUnit.FsCheck;

namespace TUnit.Example.FsCheck.TestProject;

/// <summary>
/// Verifies that FsCheck properties receive the TUnit timeout-backed CancellationToken
/// rather than a token generated by FsCheck's default reflection-based arbitrary.
/// </summary>
[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

/// <summary>
/// <c>[Timeout(500)]</c> + 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.
/// </summary>
[Test, FsCheckProperty(MaxTest = 1)]
[Timeout(500)]
public async Task<bool> 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;
}
}
}

/// <summary>
/// Test arbitrary that always returns <see cref="CancellationToken.None"/>.
/// Used by <see cref="CancellationTokenBehaviourTests.UserSuppliedArbitraryOverridesDefault"/>
/// to verify that user-supplied arbitraries take precedence over the default.
/// </summary>
internal static class AlwaysNoneTokenArbitrary
{
/// <summary>Always returns <see cref="CancellationToken.None"/>.</summary>
public static Arbitrary<CancellationToken> CancellationToken()
{
return Arb.From(Gen.Constant(System.Threading.CancellationToken.None));
}
}
19 changes: 19 additions & 0 deletions TUnit.FsCheck/CancellationTokenArbitrary.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using FsCheck;
using FsCheck.Fluent;
using TUnit.Core;

namespace TUnit.FsCheck;

/// <summary>
/// Default <see cref="Arbitrary{T}"/> for <see cref="CancellationToken"/>; surfaces the
/// timeout-backed token from <see cref="TestContext.Current"/>.
/// </summary>
internal static class CancellationTokenArbitrary
{
public static Arbitrary<CancellationToken> 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));
}
}
11 changes: 7 additions & 4 deletions TUnit.FsCheck/FsCheckPropertyTestExecutor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ private static MethodInfo GetMethodInfo(
return methods[0];
}

[DynamicDependency(DynamicallyAccessedMemberTypes.PublicMethods, typeof(CancellationTokenArbitrary))]
private Config CreateConfig()
{
var config = Config.QuickThrowOnFailure
Expand All @@ -105,10 +106,12 @@ private Config CreateConfig()
}
}

if (_propertyAttribute.Arbitrary != null && _propertyAttribute.Arbitrary.Length > 0)
{
config = config.WithArbitrary(_propertyAttribute.Arbitrary);
}
// Register a default Arbitrary<CancellationToken> 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;
}
Expand Down
Loading