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
16 changes: 11 additions & 5 deletions docs/docs/examples/fscheck.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,9 +95,10 @@ The `[FsCheckProperty]` attribute supports several configuration options:
| `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 |
| `Replay` | null | Replay a reported run: `"seed,gamma"` or `"seed,gamma,size"` (see [Reproducing Failures](#reproducing-failures)) |
| `Verbose` | false | Write every generated argument and shrink step to the test output |
| `QuietOnSuccess` | false | Suppress the `Ok, passed N tests.` summary on passing tests |
| `Parallelism` | 1 | Degree of parallelism for property invocations; above 1, the property must be thread-safe |
| `Arbitrary` | null | Types containing custom Arbitrary instances |

### Example with Configuration
Expand All @@ -113,10 +114,15 @@ public bool ListConcatenationPreservesElements(int[] first, int[] second)

## 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:
When a property test fails, FsCheck's message contains the `(seed,gamma)` the run started from and, after more than one test, a `Replay directly at failing step with (seed,gamma,size)` hint. Copy either value into the `Replay` property, with or without the parentheses:

- `"seed,gamma"` replays the whole run.
- `"seed,gamma,size"` replays only the failing step.

The gamma is required: FsCheck always reports it next to the seed, and the same seed with a different gamma generates a different run. A value in any other format fails the test with an explanation.

```csharp
[Test, FsCheckProperty(Replay = "12345,67890")]
[Test, FsCheckProperty(Replay = "(12345,67891)")]
public bool MyProperty(int value)
{
return value >= 0; // Will reproduce the same failing case
Expand Down
128 changes: 128 additions & 0 deletions examples/TUnit.Example.FsCheck.TestProject/ConfigurationOptionTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
using TUnit.Core;
using TUnit.FsCheck;

namespace TUnit.Example.FsCheck.TestProject;

// Each class below checks FsCheck's console output (or other observed behaviour) in an [After(Test)]
// hook, i.e. once FsCheck has finished.

/// <summary>
/// Baseline for <see cref="QuietOnSuccessTests"/> and <see cref="VerboseTests"/>: without options
/// FsCheck reports the summary, but not the generated arguments.
/// </summary>
public sealed class DefaultOutputTests
{
[Test, FsCheckProperty(MaxTest = 5)]
public bool PassingProperty(int value) => true;

[After(Test)]
public async Task OutputHasSummaryButNoArguments(TestContext context)
{
var output = await CapturedOutput.ReadAsync(context);

await Assert.That(output).Contains("Ok, passed 5 tests.");
await Assert.That(output).DoesNotContain($"0:{Environment.NewLine}");
}
}

public sealed class QuietOnSuccessTests
{
[Test, FsCheckProperty(MaxTest = 5, QuietOnSuccess = true)]
public bool PassingProperty(int value) => true;

[After(Test)]
public async Task OutputHasNoSummary(TestContext context)
{
await Assert.That(await CapturedOutput.ReadAsync(context)).DoesNotContain("Ok, passed");
}
}

public sealed class VerboseTests
{
[Test, FsCheckProperty(MaxTest = 5, Verbose = true)]
public bool PassingProperty(int value) => true;

[After(Test)]
public async Task OutputHasEveryGeneratedArgument(TestContext context)
{
var output = await CapturedOutput.ReadAsync(context);

// FsCheck's verbose format: "<test number>:" on its own line, followed by the arguments.
for (var testNumber = 0; testNumber < 5; testNumber++)
{
await Assert.That(output).Contains($"{testNumber}:{Environment.NewLine}");
}
}
}

public sealed class ParallelismTests : IDisposable
{
private readonly CountdownEvent _firstTwoInvocationsStarted = new(2);
private int _invocations;
private int _timeouts;

/// <summary>
/// The first two invocations wait for each other. Run sequentially, the first one can never
/// see the second start, so it times out.
/// </summary>
[Test, FsCheckProperty(MaxTest = 20, Parallelism = 4)]
public bool InvocationsOverlap(int value)
{
if (Interlocked.Increment(ref _invocations) <= 2)
{
_firstTwoInvocationsStarted.Signal();

if (!_firstTwoInvocationsStarted.Wait(TimeSpan.FromSeconds(10)))
{
Interlocked.Increment(ref _timeouts);
}
}

return true;
}

[After(Test)]
public async Task FirstTwoInvocationsRanConcurrently()
{
await Assert.That(_invocations).IsEqualTo(20);
await Assert.That(_timeouts).IsEqualTo(0);
}

public void Dispose() => _firstTwoInvocationsStarted.Dispose();
}

public sealed class ReplayTests
{
private int _invocations;

/// <summary>
/// Value in the exact form of FsCheck's "Replay directly at failing step with (seed,gamma,size)"
/// hint. With a size, FsCheck replays only that single step instead of a whole run.
/// </summary>
[Test, FsCheckProperty(Replay = "(12345,67891,5)")]
public bool ReplayWithSizeRunsOnlyThatStep(int value)
{
Interlocked.Increment(ref _invocations);
return true;
}

[After(Test)]
public async Task PropertyRanOnce()
{
await Assert.That(_invocations).IsEqualTo(1);
}
}

internal static class CapturedOutput
{
/// <summary>
/// FsCheck writes with <c>printf</c>, i.e. <see cref="TextWriter.Write(string)"/> without a
/// <c>WriteLine</c>. TUnit buffers such partial lines per test and routes them to the test output
/// on flush, which otherwise only happens after the <c>[After(Test)]</c> hooks.
/// </summary>
public static async Task<string> ReadAsync(TestContext context)
{
await Console.Out.FlushAsync();
return context.GetStandardOutput();
}
}
14 changes: 8 additions & 6 deletions src/TUnit.FsCheck/FsCheckPropertyAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,24 +41,26 @@ public class FsCheckPropertyAttribute : Attribute, ITestRegisteredEventReceiver,
public int EndSize { get; set; } = 100;

/// <summary>
/// If set, replay the test using this seed. Format: "seed1,seed2" or just "seed1".
/// Useful for reproducing failures.
/// If set, replay a previous run using the values FsCheck reports on failure.
/// Format: "seed,gamma" replays the whole run; "seed,gamma,size" replays only the failing step.
/// The parentheses FsCheck prints around these values are allowed. Any other value fails the test.
/// </summary>
public string? Replay { get; set; }

/// <summary>
/// If true, output all generated arguments to the test output.
/// If true, write every generated argument and every shrink step to the test output.
/// </summary>
public bool Verbose { get; set; }

/// <summary>
/// If true, suppress output on passing tests.
/// If true, suppress FsCheck's "Ok, passed N tests." summary on passing tests.
/// </summary>
public bool QuietOnSuccess { get; set; }

/// <summary>
/// The level of parallelism to use when running tests.
/// Default is 1 (no parallelism within property execution).
/// The degree of parallelism FsCheck uses to invoke the property (its <c>ParallelRunConfig.MaxDegreeOfParallelism</c>).
/// Default is 1; any value below 2 invokes the property sequentially.
/// Above 1, the property must be safe to invoke concurrently on the same class instance.
/// </summary>
public int Parallelism { get; set; } = 1;

Expand Down
70 changes: 60 additions & 10 deletions src/TUnit.FsCheck/FsCheckPropertyTestExecutor.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Reflection;
using System.Runtime.ExceptionServices;
using System.Text;
Expand Down Expand Up @@ -92,22 +93,29 @@ private static MethodInfo GetMethodInfo(
}

[DynamicDependency(DynamicallyAccessedMemberTypes.PublicMethods, typeof(CancellationTokenArbitrary))]
private Config CreateConfig()
internal Config CreateConfig()
{
var config = Config.QuickThrowOnFailure
// VerboseThrowOnFailure is QuickThrowOnFailure plus output of every generated argument and shrink step.
var config = (_propertyAttribute.Verbose ? Config.VerboseThrowOnFailure : Config.QuickThrowOnFailure)
.WithMaxTest(_propertyAttribute.MaxTest)
.WithMaxRejected(_propertyAttribute.MaxFail)
.WithStartSize(_propertyAttribute.StartSize)
.WithEndSize(_propertyAttribute.EndSize);
.WithEndSize(_propertyAttribute.EndSize)
.WithQuietOnSuccess(_propertyAttribute.QuietOnSuccess);

if (!string.IsNullOrEmpty(_propertyAttribute.Replay))
// Any ParallelRunConfig switches FsCheck to its thread-pool runner, even with a degree of 1,
// so only opt in when invocations may actually overlap.
if (_propertyAttribute.Parallelism > 1)
{
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);
}
config = config.WithParallelRunConfig(new ParallelRunConfig(_propertyAttribute.Parallelism));
}

if (!string.IsNullOrWhiteSpace(_propertyAttribute.Replay))
{
var (seed, gamma, size) = ParseReplay(_propertyAttribute.Replay!);
config = size is { } replaySize
? config.WithReplay(seed, gamma, replaySize)
: config.WithReplay(seed, gamma);
}

// Register a default Arbitrary<CancellationToken> that surfaces TestContext's
Expand All @@ -120,6 +128,48 @@ private Config CreateConfig()
return config;
}

/// <summary>
/// Parses the values FsCheck reports on failure: "seed,gamma" (whole run) or "seed,gamma,size"
/// (failing step only), optionally in the one pair of parentheses FsCheck prints them in.
/// </summary>
/// <remarks>
/// A seed alone is rejected rather than paired with a default gamma: FsCheck never reports a seed
/// without its gamma, and any other gamma replays a different run than the one that failed.
/// </remarks>
private static (ulong Seed, ulong Gamma, int? Size) ParseReplay(string replay)
{
var value = replay.Trim();

// Strip only one enclosing pair. Any other parenthesis stays and fails the number parsing below.
if (value.Length >= 2 && value[0] == '(' && value[value.Length - 1] == ')')
{
value = value.Substring(1, value.Length - 2);
}

var parts = value.Split(',');

if (parts.Length is 2 or 3
&& ulong.TryParse(parts[0].Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out var seed)
&& ulong.TryParse(parts[1].Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out var gamma)
&& gamma % 2 == 1)
{
if (parts.Length == 2)
{
return (seed, gamma, null);
}

if (int.TryParse(parts[2].Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out var size))
{
return (seed, gamma, size);
}
}

throw new InvalidOperationException(
$"Invalid {nameof(FsCheckPropertyAttribute.Replay)} value '{replay}'. Use the values FsCheck reports on failure: " +
"\"seed,gamma\" to replay the whole run, or \"seed,gamma,size\" to replay only the failing step. " +
"The gamma must be odd; surrounding parentheses are allowed.");
}

[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)
Expand Down
1 change: 1 addition & 0 deletions src/TUnit.FsCheck/TUnit.FsCheck.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

<ItemGroup>
<PackageReference Include="FsCheck" />
<InternalsVisibleTo Include="TUnit.UnitTests" Key="$(PublicKey)" />
</ItemGroup>

<ItemGroup>
Expand Down
Loading
Loading