Steve Love
@IAmSteveLove@mastodon.social
@stevelove.bsky.social
https://moderncsharp.blogspot.com/
@IAmSteveLove
Steve Love
@IAmSteveLove@mastodon.social
@stevelove.bsky.social
https://moderncsharp.blogspot.com/
@IAmSteveLove
Please leave feedback! :-)
Property-based testing for .NET programmers
|
|
|
|
|
|
STARE
STARE
SHORT
STARE
0 → 0 : S
1 → 4 : T
2 → x : A
3 → 3 : R
4 → x : E
[ 0, 4, -1, 3, -1 ]
public static int[] Match(string word, string guess)
{
return guess.Select((ch, idx)
=> ch == word[idx] ? idx // prefer exact match
: word.IndexOf(ch)) // returns -1 if not found
.ToArray();
}
[Test]
public void Simple_eg_test()
{
var (word, guess) = ( "SHORT", "STARE" );
var result = Match(word, guess);
int[] expected = [0, 4, -1, 3, -1];
Assert.That(result, Is.EqualTo(expected));
}
Matching a string with itself
should always produce the same output
a.k.a. Reflexivity
SHORT
SHORT
[ 0, 1, 2, 3, 4 ]
[Test]
public void A_string_matches_itself_exactly()
{
string word = "ANYOLDSTRING";
var result = Match(word, word);
var expected = Enumerable.Range(0, word.Length);
Assert.That(result, Is.EqualTo(expected));
}
Strings with no matches have another predictable result:
SHORT → GUNGE |
|
[TestCase("SHORT", "GUNGE")]
[TestCase("GUNGE", "SHORT")]
public void Disjoint_strings_have_no_matches(string word, string guess)
{
var result = Match(word, guess);
var expected = Enumerable.Repeat(-1, word.Length); // every element
// is -1
Assert.That(result, Is.EqualTo(expected));
}
[TestCase("apple", "apple", new[] { 0, 1, 2, 3, 4 })]
[TestCase("apple", "apric", new[] { 0, 1, 2, -1, -1 })]
[TestCase("apple", "ppale", new[] { 1, 0, 2, 3, 4 })]
[TestCase("apple", "leapp", new[] { 3, 4, 0, 1, 2 })]
[TestCase("apple", "xxxxx", new[] { -1, -1, -1, -1, -1 })]
public void Match_ShouldReturnExpectedResults(string word, string guess, int[] expected)
{
var result = Match(word, guess);
Assert.AreEqual(expected, result);
}
Not bad
…although it doesn’t compile
[TestCase("apple", "apple", new[] { 0, 1, 2, 3, 4 })]
[TestCase("apple", "apric", new[] { 0, 1, 2, -1, -1 })]
[TestCase("apple", "ppale", new[] { 1, 0, 2, 3, 4 })]
[TestCase("apple", "leapp", new[] { 3, 4, 0, 1, 2 })]
[TestCase("apple", "xxxxx", new[] { -1, -1, -1, -1, -1 })]
public void Match_ShouldReturnExpectedResults(string word, string guess, int[] expected)
{
var result = Match(word, guess);
Assert.That(result, Is.EqualTo(expected));
}
Includes our 3 test cases
but 2 of these fail:
apric
and ppale
Each matched letter is matched exactly once
SHORT → STARE |
|
[TestCase("SHORT", "STARE")]
[TestCase("STARE", "SHORT")]
public void Matched_indices_are_unique(string word, string guess)
{
var result = Match(word, guess);
var matches = result.Where(i => i != -1).ToArray();
Assert.That(matches, Is.Unique);
}
Reflexivity
no matches
uniqueness
These are properties that should apply to any arbitrary string with respect to the
Match
method
[TestCase("GRASP", "STASH")]
[TestCase("SHORT", "STARE")]
[TestCase("STARE", "SHORT")]
public void Matched_indices_are_unique(string word, string guess)
{
var result = Match(word, guess);
var matches = result.Where(i => i != -1).ToArray();
Assert.That(matches, Is.Unique);
}
Failed Matched_indices_are_unique("GRASP","STASH") [103 ms] Error Message: Assert.That(matches, Is.Unique) Expected: all items unique But was: < 3, 2, 3 > Not unique items: < 3 >
GRASP
STASH
Should have been:
[ -1, -1, 2, 3, -1 ]
But was:
[ 3, -1, 2, 3, -1 ]
public static int[] Match(string word, string guess)
{
return guess.Select((ch, idx)
=> ch == word[idx] ? idx // prefer exact match
: word.IndexOf(ch)) // returns -1 if not found
.ToArray();
}
GRASP
Should be: STASH | Actually: STASH |
A letter in the set word should be matched by only one letter in the guess, with an exact match being preferred
Actually caught this bug with one of the examples
[TestCase("apple", "ppale", new[] { 1, 0, 2, 3, 4 })]
…but for the wrong reason!:
[2, 1, 0, 3, 4]
→
ppale
public static int[] Match(string word, string guess)
{
var found = Enumerable.Range(0, word.Length)
.Select(idx => word[idx] == guess[idx] ? idx : -1)
.ToArray();
foreach(var i in Enumerable.Range(0, word.Length))
{
if(found[i] == -1)
{
found[i] = Enumerable.Range(0, word.Length)
.FirstOrDefault(idx => word[idx] == guess[i]
&& !found.Contains(idx), -1);
}
}
return found;
}
Our example-based tests now pass…
…but we identified properties that should hold for all strings
|
|
[TestCase("hello", "hello", new[] { 0, 1, 2, 3, 4 })]
[TestCase("hello", "hxllo", new[] { 0, -1, 2, 3, 4 })]
[TestCase("hello", "xxxxx", new[] { -1, -1, -1, -1, -1 })]
[TestCase("hello", "olelh", new[] { 4, 3, 2, 1, 0 })] // 4, 2, 1, 3, 0
[TestCase("", "", new int[] { })]
public void Match_AllExactMatches_ReturnsCorrectIndices(string word, string guess, int[] expected)
{
var result = Match(word, guess);
Assert.That(result, Is.EqualTo(expected));
}
…but still fails as shown
…and fails in the same way for the original implementation
Define properties that should hold for all inputs…
…which may be (often are) generated randomly
Identifies counter-examples that don’t hold
Exposes corner-cases you haven’t thought of
Property-based testing for .NET
a function that tests an assumption. Either:
a predicate—a false
return indicates a falsifiable case
a unit (void) method—thrown exception is a falsifiable case
Invariants - sorted list insertion
Inversion - reverse, reverse again
Commutativity - x == y
, y == x
Round-trip - LCM(x,y) % GCD(x,y)
always 0
Idempotency - call Dispose
…but sometimes it’s useful to just be able to test all the things!
[FsCheck.NUnit.Property]
public void Any_string_matches_itself_exactly(string word)
{
var result = Match(word, word);
var expected = Enumerable.Range(0, word.Length);
Assert.That(result, Is.EqualTo(expected));
}
Falsifiable, after 32 tests (0 shrinks) (StdGen (1595582682,297278030)): Original: <null> with exception: System.NullReferenceException: Object reference not set to an instance of an object.
[FsCheck.NUnit.Property]
public void Any_string_matches_itself_exactly(NonEmptyString word)
{
var result = Match(word.Get, word.Get);
var expected = Enumerable.Range(0, word.Get.Length);
Assert.That(result, Is.EqualTo(expected));
}
Requires guess to be at least as long as the set word (ideally they are equal length).
Requires two strings with no matching characters.
Gen<T>
generate instances—usually random—of T
Arbitrary<T>
supplies data to a property parameter, uses Gen<T>
along with a shrinker
class ArbitraryStrings
{
private static Gen<string> StringOfLength(int n)
=> Arb.Default.Char().Generator // 'char' gen as shipped with FsCheck
.Where(char.IsLetterOrDigit) // for readability
.ArrayOf(n) // fixed size array of char
.Select(string.Concat); // create a string
public static Arbitrary<string> FixedLengthString()
=> StringOfLength(15).ToArbitrary();
}
[FsCheck.NUnit.Property(Arbitrary = [ typeof(ArbitraryStrings) ])]
public void Matched_indices_are_unique(string word, string guess)
{
var result = Match(word, guess);
var matches = result.Where(i => i != -1).ToArray();
Assert.That(matches, Is.Unique);
}
Our original implementation fails this test:
Falsifiable, after 4 tests (0 shrinks) (StdGen (1105325589,297278101)): Original: ("yW606jYvi8Bd7P2", "Z7QjVj4oJcycyct") with exception: NUnit.Framework.AssertionException: Assert.That(matches, Is.Unique) Expected: all items unique But was: < 12, 5, 5, 0, 0 > Not unique items: < 5, 0 >
We can use this output to write a concrete example-based test!
public static Arbitrary<(string, string)> DisjointStringPair()
// generate a Tuple<string, string>
=> StringOfLength(15).Two()
// eliminate pairs with any matching character
.Where(pair => !pair.Item1.Intersect(pair.Item2).Any())
// transform to value tuple
.Select(pair => (pair.Item1, pair.Item2))
.ToArbitrary();
Filtering like this can be expensive.
Optimizing input data may be more efficient.
[FsCheck.NUnit.Property(Arbitrary = [ typeof(ArbitraryStrings) ])]
public void Disjoint_strings_have_no_matched_indices((string, string) pair)
{
var (word, guess) = pair;
var result = Match(word, guess).Where(i => i != -1);
result.Should().BeEmpty();
}
or…
Assert.That(result, Is.Empty);
We discovered our original counter-example manually
but the property based test for unique matched indices
would have found it.
Any other properties?
The Oldies are the Besties
public static class Fizzer
{
public static string Buzz(int number)
=> number switch
{
_ when number % 15 == 0 => "FizzBuzz",
_ when number % 3 == 0 => "Fizz",
_ when number % 5 == 0 => "Buzz",
_ => number.ToString()
};
}
[FsCheck.NUnit.Property]
public void All_results_are_valid_outcomes(PositiveInt n)
{
Fizzer.Buzz(n.Get).Should().BeOneOf(n.ToString(), "Fizz", "Buzz", "FizzBuzz");
}
FizzBuzz is traditionally for numbers 1..100
…but only because humans are easily bored!
[TestCase(15, "FizzBuzz")]
[TestCase(30, "FizzBuzz")]
[TestCase(5, "Buzz")]
[TestCase(10, "Buzz")]
[TestCase(3, "Fizz")]
[TestCase(6, "Fizz")]
[TestCase(1, "1")]
[TestCase(2, "2")]
[TestCase(4, "4")]
[TestCase(7, "7")]
public void Buzz_ReturnsExpectedResult(int input, string expected)
{
// Act
var result = Fizzer.Buzz(input);
// Assert
Assert.That(result, Is.EqualTo(expected));
}
Every third result contains "Fizz" | Every "Fizz" comes from a multiple of 3 |
Every fifth result contains "Buzz" | Every "Buzz" comes from a multiple of 5 |
Every fifteenth result is "FizzBuzz" | Every "FizzBuzz" comes from a multiple of both 3 and 5 |
Every other number corresponds to its ordinal position |
[FsCheck.NUnit.Property]
public void All_divisible_by_3_are_Fizz(int n)
{
if(n % 3 == 0)
Assert.That(Fizzer.Buzz(n), Does.StartWith("Fizz"));
}
Can we do better?
[FsCheck.NUnit.Property(Arbitrary = [ typeof(FizzBuzzNumbers) ] )]
public void All_divisible_by_3_are_Fizz(IntDivisibleBy3 n)
{
Fizzer.Buzz(n).Should().StartWith("Fizz");
}
or, being more specific
var expected = n switch
{
_ when n % 5 == 0 => "FizzBuzz",
_ => "Fizz",
};
Fizzer.Buzz(n).Should().Be(expected);
public readonly record struct IntDivisibleBy3(int Val)
{
public static implicit operator int(IntDivisibleBy3 i) => i.Val;
}
static class FizzBuzzNumbers
{
static Gen<int> DivBy(int div)
=> Gen.Choose(1, int.MaxValue)
.Select(n => n + div - n % div);
public static Arbitrary<IntDivisibleBy3> Number3()
=> DivBy(3).Select(n => new IntDivisibleBy3(n)).ToArbitrary();
}
Define the type IntDivisibleBy3
to distinguish from int
Define the generator to register in the test for IntDivisibleBy3
types
The essential ideas are the same for 5 and 15.
Generating numbers not divisible by 3 or 5 is harder…
static Gen<int> NotDivBy(int div)
=> Gen.Choose(1, int.MaxValue)
.Select(n => n % div == 0 ? n + 1 : n);
public static Arbitrary<NonFizzBuzzNumber> NumberOther()
=> NotDivBy(3).Where(n => n % 5 != 0)
.Select(n => new NonFizzBuzzNumber(n)).ToArbitrary();
[FsCheck.NUnit.Property(Arbitrary = [ typeof(FizzBuzzNumbers) ] )]
public void All_numeric_results_correspond_to_original_value(NonFizzBuzzNumber n)
{
Fizzer.Buzz(n).Should().Be(n.Val.ToString());
}
[FsCheck.NUnit.Property]
public void All_results_match_ordinal_position(PositiveInt p)
{
var n = p.Get;
var expected = Fizzer.Buzz(n) switch
{
"FizzBuzz" => n % 15,
"Fizz" => n % 3,
"Buzz" => n % 5,
var v => int.Parse(v) - n,
};
expected.Should().Be(0);
}
[< Property >]
let ``All results match ordinal position`` (n: PositiveInt) =
match Fizzer.Buzz n.Get with
| "Fizz" -> int n % 3 |> should equal 0
| "Buzz" -> int n % 5 |> should equal 0
| "FizzBuzz" -> int n % 15 |> should equal 0
| v -> n.ToString() |> should equal v
module WordTests =
[< Property >]
let ``Any string matches itself exactly``(word: NonEmptyString) =
Match(word.Get, word.Get)
|> should equalSeq [| 0..word.Get.Length - 1 |]
[< Property(Arbitrary=[| typeof<ArbitraryStrings> |]) >]
let ``Matched indices are unique``(word: string, guess: string) =
Match(word, guess)
|> Array.filter(fun i -> i <> -1)
|> should be unique
type ArbitraryStrings =
static member StringOfLength n =
Arb.generate<char>
|> Gen.filter Char.IsLetterOrDigit
|> Gen.arrayOfLength n
|> Gen.map String.Concat
static member FixedLengthString =
ArbitraryStrings.StringOfLength(15)
|> Arb.fromGen
class ArbitraryStrings
{
private static Gen<string> StringOfLength(int n)
=> Arb.Default.Char().Generator // 'char' gen as shipped with FsCheck
.Where(char.IsLetterOrDigit) // for readability
.ArrayOf(n) // fixed size array of char
.Select(string.Concat); // create a string
public static Arbitrary<string> FixedLengthString()
=> StringOfLength(15).ToArbitrary();
}
Reflexive, no matches, unique indices
Definitely all necessary
the latter identified a counter-example in the original algorithm
How to determine sufficiency?
One more interesting property of the word matcher…
SHORT
STARE
[ 0, 4, -1, 3, -1 ]
STARE
SHORT
[ 0, -1, -1, 3, 1 ]
SHORT → STARE | STARE → SHORT |
|
|
(0, 0) | (0, 0) |
it’s true for all matches between strings of the same length
[< Property(Arbitrary=[| typeof<ArbitraryStrings> |]) >]
let ``Matched indices roundtrip in switched words``(word: string, guess: string) =
let orig = Match(word, guess) |> Seq.ofArray
let rev = Match(guess, word) |> Seq.ofArray
let inOrder = Seq.zip orig { 0..word.Length - 1 }
|> Seq.where(fun(x, y) -> x <> -1)
let switched = Seq.zip rev { 0..guess.Length - 1 }
|> Seq.where(fun(x, y) -> x <> -1)
|> Seq.map(fun(x, y) -> y, x)
inOrder |> should equivalent switched
inOrder: (0, 0), (1, 4), (3, 3) switched: (0, 0), (3, 3), (4, 1)
Finding all those edge cases you forgot!