From 71a4669b0dcabd652956ed2ddb095b66d97cd365 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Sun, 31 May 2026 15:51:35 +0200 Subject: [PATCH 1/2] test: kill statistics/timer mutation survivors --- ...istics.Recorded.Directory.Negated.Tests.cs | 85 ++++++++++ ...cs.Recorded.Directory.TwoMatchers.Tests.cs | 54 +++++++ ....Recorded.FileInfo.PropertyAccess.Tests.cs | 151 ++++++++++++++++++ .../Timer.Executed.Tests.cs | 120 ++++++++++++++ 4 files changed, 410 insertions(+) create mode 100644 Tests/aweXpect.Testably.Tests/Statistics.Recorded.Directory.Negated.Tests.cs create mode 100644 Tests/aweXpect.Testably.Tests/Statistics.Recorded.Directory.TwoMatchers.Tests.cs create mode 100644 Tests/aweXpect.Testably.Tests/Statistics.Recorded.FileInfo.PropertyAccess.Tests.cs diff --git a/Tests/aweXpect.Testably.Tests/Statistics.Recorded.Directory.Negated.Tests.cs b/Tests/aweXpect.Testably.Tests/Statistics.Recorded.Directory.Negated.Tests.cs new file mode 100644 index 0000000..4177a7d --- /dev/null +++ b/Tests/aweXpect.Testably.Tests/Statistics.Recorded.Directory.Negated.Tests.cs @@ -0,0 +1,85 @@ +using Testably.Abstractions.Testing; + +namespace aweXpect.Testably.Tests; + +public sealed partial class Statistics +{ + public sealed partial class Recorded + { + public sealed class DirectoryNegated + { + [Fact] + public async Task WhenNegatingNeverAndCalled_ShouldSucceed() + { + MockFileSystem fileSystem = new(); + fileSystem.Directory.CreateDirectory("foo"); + + async Task Act() + { + await That(fileSystem.Statistics).DoesNotComplyWith(it + => it.Recorded().Directory.CreateDirectory().Never()); + } + + await That(Act).DoesNotThrow(); + } + + [Fact] + public async Task WhenNegatingNeverAndNotCalled_ShouldFailWithAtLeastOneWording() + { + MockFileSystem fileSystem = new(); + + async Task Act() + { + await That(fileSystem.Statistics).DoesNotComplyWith(it + => it.Recorded().Directory.CreateDirectory().Never()); + } + + await That(Act).ThrowsException() + .WithMessage(""" + Expected that fileSystem.Statistics + recorded at least one call to Directory.CreateDirectory, + but it was recorded 0 times + """); + } + + [Fact] + public async Task WhenNegatingNeverWithMatcherAndNotCalled_ShouldIncludeMatcher() + { + MockFileSystem fileSystem = new(); + + async Task Act() + { + await That(fileSystem.Statistics).DoesNotComplyWith(it + => it.Recorded().Directory.CreateDirectory(p => p == "foo").Never()); + } + + await That(Act).ThrowsException() + .WithMessage(""" + Expected that fileSystem.Statistics + recorded at least one call to Directory.CreateDirectory with path matching p => p == "foo", + but it was recorded 0 times + """); + } + + [Fact] + public async Task WhenNegatingExactlyAndMatching_ShouldFailWithDidNotRecordWording() + { + MockFileSystem fileSystem = new(); + fileSystem.Directory.CreateDirectory("foo"); + + async Task Act() + { + await That(fileSystem.Statistics).DoesNotComplyWith(it + => it.Recorded().Directory.CreateDirectory().Once()); + } + + await That(Act).ThrowsException() + .WithMessage(""" + Expected that fileSystem.Statistics + did not record a call to Directory.CreateDirectory exactly once, + but it was recorded 1 time + """); + } + } + } +} diff --git a/Tests/aweXpect.Testably.Tests/Statistics.Recorded.Directory.TwoMatchers.Tests.cs b/Tests/aweXpect.Testably.Tests/Statistics.Recorded.Directory.TwoMatchers.Tests.cs new file mode 100644 index 0000000..cbffc68 --- /dev/null +++ b/Tests/aweXpect.Testably.Tests/Statistics.Recorded.Directory.TwoMatchers.Tests.cs @@ -0,0 +1,54 @@ +using Testably.Abstractions.Testing; + +namespace aweXpect.Testably.Tests; + +public sealed partial class Statistics +{ + public sealed partial class Recorded + { + public sealed class DirectoryTwoMatchers + { +#if NET8_0_OR_GREATER + [Fact] + public async Task WithTwoMatchers_NoMatch_ShouldJoinMatchersWithComma() + { + MockFileSystem fileSystem = new(); + + async Task Act() + { + await That(fileSystem.Statistics).Recorded() + .Directory.CreateSymbolicLink(p => p == "foo", t => t == "bar").Once(); + } + + await That(Act).ThrowsException() + .WithMessage(""" + Expected that fileSystem.Statistics + recorded a call to Directory.CreateSymbolicLink with path matching p => p == "foo", pathToTarget matching t => t == "bar" exactly once, + but it was recorded 0 times + """); + } + + [Fact] + public async Task WithNeverAndMatcher_Matching_ShouldFailIncludingMatcher() + { + MockFileSystem fileSystem = new(); + fileSystem.Directory.CreateDirectory("target"); + fileSystem.Directory.CreateSymbolicLink("link", "target"); + + async Task Act() + { + await That(fileSystem.Statistics).Recorded() + .Directory.CreateSymbolicLink(p => p == "link").Never(); + } + + await That(Act).ThrowsException() + .WithMessage(""" + Expected that fileSystem.Statistics + recorded no call to Directory.CreateSymbolicLink with path matching p => p == "link", + but it was recorded 1 time + """); + } +#endif + } + } +} diff --git a/Tests/aweXpect.Testably.Tests/Statistics.Recorded.FileInfo.PropertyAccess.Tests.cs b/Tests/aweXpect.Testably.Tests/Statistics.Recorded.FileInfo.PropertyAccess.Tests.cs new file mode 100644 index 0000000..1b8bd91 --- /dev/null +++ b/Tests/aweXpect.Testably.Tests/Statistics.Recorded.FileInfo.PropertyAccess.Tests.cs @@ -0,0 +1,151 @@ +using System.IO.Abstractions; +using Testably.Abstractions.Testing; + +namespace aweXpect.Testably.Tests; + +public sealed partial class Statistics +{ + public sealed partial class Recorded + { + public sealed class FileInfoPropertyAccess + { + [Fact] + public async Task WhenNeverAccessed_NeverGet_ShouldSucceed() + { + MockFileSystem fileSystem = new(); + fileSystem.File.WriteAllText("foo.txt", ""); + + async Task Act() + { + await That(fileSystem.Statistics).Recorded() + .FileInfo["foo.txt"].IsReadOnly.Get().Never(); + } + + await That(Act).DoesNotThrow(); + } + + [Fact] + public async Task WhenAccessed_NeverGet_ShouldFailWithNoGetWording() + { + MockFileSystem fileSystem = new(); + fileSystem.File.WriteAllText("foo.txt", ""); + IFileInfo fileInfo = fileSystem.FileInfo.New("foo.txt"); + _ = fileInfo.IsReadOnly; + + async Task Act() + { + await That(fileSystem.Statistics).Recorded() + .FileInfo["foo.txt"].IsReadOnly.Get().Never(); + } + + await That(Act).ThrowsException() + .WithMessage(""" + Expected that fileSystem.Statistics + recorded no get of FileInfo["foo.txt"].IsReadOnly, + but it was recorded 1 time + """); + } + + [Fact] + public async Task WhenSet_NeverSet_ShouldFailWithNoSetWording() + { + MockFileSystem fileSystem = new(); + fileSystem.File.WriteAllText("foo.txt", ""); + IFileInfo fileInfo = fileSystem.FileInfo.New("foo.txt"); + fileInfo.IsReadOnly = false; + + async Task Act() + { + await That(fileSystem.Statistics).Recorded() + .FileInfo["foo.txt"].IsReadOnly.Set().Never(); + } + + await That(Act).ThrowsException() + .WithMessage(""" + Expected that fileSystem.Statistics + recorded no set of FileInfo["foo.txt"].IsReadOnly, + but it was recorded 1 time + """); + } + + [Fact] + public async Task WhenNegatingNeverGetAndAccessed_ShouldSucceed() + { + MockFileSystem fileSystem = new(); + fileSystem.File.WriteAllText("foo.txt", ""); + IFileInfo fileInfo = fileSystem.FileInfo.New("foo.txt"); + _ = fileInfo.IsReadOnly; + + async Task Act() + { + await That(fileSystem.Statistics).DoesNotComplyWith(it + => it.Recorded().FileInfo["foo.txt"].IsReadOnly.Get().Never()); + } + + await That(Act).DoesNotThrow(); + } + + [Fact] + public async Task WhenNegatingNeverGetAndNotAccessed_ShouldFailWithAtLeastOneGetWording() + { + MockFileSystem fileSystem = new(); + fileSystem.File.WriteAllText("foo.txt", ""); + + async Task Act() + { + await That(fileSystem.Statistics).DoesNotComplyWith(it + => it.Recorded().FileInfo["foo.txt"].IsReadOnly.Get().Never()); + } + + await That(Act).ThrowsException() + .WithMessage(""" + Expected that fileSystem.Statistics + recorded at least one get of FileInfo["foo.txt"].IsReadOnly, + but it was recorded 0 times + """); + } + + [Fact] + public async Task WhenNegatingNeverSetAndNotAccessed_ShouldFailWithAtLeastOneSetWording() + { + MockFileSystem fileSystem = new(); + fileSystem.File.WriteAllText("foo.txt", ""); + + async Task Act() + { + await That(fileSystem.Statistics).DoesNotComplyWith(it + => it.Recorded().FileInfo["foo.txt"].IsReadOnly.Set().Never()); + } + + await That(Act).ThrowsException() + .WithMessage(""" + Expected that fileSystem.Statistics + recorded at least one set of FileInfo["foo.txt"].IsReadOnly, + but it was recorded 0 times + """); + } + + [Fact] + public async Task WhenNegatingExactlyGetAndAccessed_ShouldFailWithDidNotRecordGetWording() + { + MockFileSystem fileSystem = new(); + fileSystem.File.WriteAllText("foo.txt", ""); + IFileInfo fileInfo = fileSystem.FileInfo.New("foo.txt"); + _ = fileInfo.IsReadOnly; + + async Task Act() + { + await That(fileSystem.Statistics).DoesNotComplyWith(it + => it.Recorded().FileInfo["foo.txt"].IsReadOnly.Get().Once()); + } + + await That(Act).ThrowsException() + .WithMessage(""" + Expected that fileSystem.Statistics + did not record a get of FileInfo["foo.txt"].IsReadOnly exactly once, + but it was recorded 1 time + """); + } + } + } +} diff --git a/Tests/aweXpect.Testably.Tests/Timer.Executed.Tests.cs b/Tests/aweXpect.Testably.Tests/Timer.Executed.Tests.cs index 68c6169..48a758e 100644 --- a/Tests/aweXpect.Testably.Tests/Timer.Executed.Tests.cs +++ b/Tests/aweXpect.Testably.Tests/Timer.Executed.Tests.cs @@ -197,6 +197,126 @@ but it was """); } + [Fact] + public async Task WhenNeverQuantifier_AndExecutedOnce_ShouldFailWithDidNotExecuteWording() + { + MockTimeSystem timeSystem = new(); + using ITimerMock sut = (ITimerMock)timeSystem.Timer.New( + _ => { }, + null, + TimeSpan.Zero, + Timeout.InfiniteTimeSpan); + sut.Wait(1, 5000); + + async Task Act() + { + // ReSharper disable once AccessToDisposedClosure + await That(sut).Executed().Never().Within(TimeSpan.FromMilliseconds(100)); + } + + await That(Act).ThrowsException() + .WithMessage(""" + Expected that sut + did not execute within 0:00.100, + but it was executed once + """); + } + + [Fact] + public async Task WhenExecutedOnce_AndExpectingExactlyTwice_ShouldRenderOnce() + { + MockTimeSystem timeSystem = new(); + using ITimerMock sut = (ITimerMock)timeSystem.Timer.New( + _ => { }, + null, + TimeSpan.Zero, + Timeout.InfiniteTimeSpan); + sut.Wait(1, 5000); + + async Task Act() + { + // ReSharper disable once AccessToDisposedClosure + await That(sut).Executed().Exactly(2.Times()).Within(TimeSpan.FromMilliseconds(100)); + } + + await That(Act).ThrowsException() + .WithMessage(""" + Expected that sut + executed exactly twice within 0:00.100, + but it was executed once + """); + } + + [Fact] + public async Task WhenExecutedTwice_AndExpectingExactlyThrice_ShouldRenderTwice() + { + MockTimeSystem timeSystem = new(); + ITimerMock? timer = null; + int callbacks = 0; + timer = (ITimerMock)timeSystem.Timer.New( + _ => + { + if (Interlocked.Increment(ref callbacks) >= 2) + { + // ReSharper disable once AccessToModifiedClosure + timer?.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); + } + }, + null, + TimeSpan.Zero, + TimeSpan.FromMilliseconds(5)); + using ITimerMock sut = timer; + sut.Wait(2, 5000); + + async Task Act() + { + // ReSharper disable once AccessToDisposedClosure + await That(sut).Executed().Exactly(3.Times()).Within(TimeSpan.FromMilliseconds(100)); + } + + await That(Act).ThrowsException() + .WithMessage(""" + Expected that sut + executed exactly 3 times within 0:00.100, + but it was executed twice + """); + } + + [Fact] + public async Task WhenExecutedThreeTimes_AndExpectingMore_ShouldRenderTimes() + { + MockTimeSystem timeSystem = new(); + ITimerMock? timer = null; + int callbacks = 0; + timer = (ITimerMock)timeSystem.Timer.New( + _ => + { + if (Interlocked.Increment(ref callbacks) >= 3) + { + // ReSharper disable once AccessToModifiedClosure + timer?.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); + } + }, + null, + TimeSpan.Zero, + TimeSpan.FromMilliseconds(5)); + using ITimerMock sut = timer; + sut.Wait(3, 5000); + + async Task Act() + { + // ReSharper disable once AccessToDisposedClosure + await That(sut).Executed().Exactly(4.Times()).Within(TimeSpan.FromMilliseconds(100)); + } + + await That(Act).ThrowsException() + .WithMessage(""" + Expected that sut + executed exactly 4 times within 0:00.100, + but it was executed 3 times + """); + } + [Fact] public async Task WithoutQuantifier_WhenExecutedAtLeastOnce_ShouldSucceed() { From e231d00525eef1958d71e15ba7adcaa883725f3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Sun, 31 May 2026 16:21:14 +0200 Subject: [PATCH 2/2] test: make exact-count Timer.Executed tests deterministic Drive the timer with DisableAutoAdvance + TimeProvider.AdvanceBy so the execution count is exact and the timer thread blocks on a time-change notification instead of spinning a 1ms period. A synchronization barrier (Executed().Never().Within) before AdvanceBy ensures the timer has subscribed to its first due-time before the clock is advanced. The previous free-running approach overshot the count (e.g. 1090 executions) and spun the CPU, starving the 2-core CI runner and timing out unrelated timer tests. --- .../Timer.Executed.Tests.cs | 76 +++++++++---------- 1 file changed, 38 insertions(+), 38 deletions(-) diff --git a/Tests/aweXpect.Testably.Tests/Timer.Executed.Tests.cs b/Tests/aweXpect.Testably.Tests/Timer.Executed.Tests.cs index 48a758e..d0a103e 100644 --- a/Tests/aweXpect.Testably.Tests/Timer.Executed.Tests.cs +++ b/Tests/aweXpect.Testably.Tests/Timer.Executed.Tests.cs @@ -200,13 +200,18 @@ but it was [Fact] public async Task WhenNeverQuantifier_AndExecutedOnce_ShouldFailWithDidNotExecuteWording() { - MockTimeSystem timeSystem = new(); + MockTimeSystem timeSystem = new(o => o.DisableAutoAdvance()); using ITimerMock sut = (ITimerMock)timeSystem.Timer.New( _ => { }, null, - TimeSpan.Zero, + TimeSpan.FromSeconds(1), Timeout.InfiniteTimeSpan); - sut.Wait(1, 5000); + // Synchronization barrier: ensure the timer thread has started and + // subscribed to its first due-time before advancing the mock clock. + await That(sut).Executed().Never().Within(TimeSpan.FromMilliseconds(200)); + timeSystem.TimeProvider.AdvanceBy(TimeSpan.FromSeconds(2)); + await That(sut).Executed().AtLeast(1.Times()).Within(TimeSpan.FromSeconds(10)); + await That(sut.ExecutionCount).IsEqualTo(1L); async Task Act() { @@ -225,13 +230,18 @@ but it was executed once [Fact] public async Task WhenExecutedOnce_AndExpectingExactlyTwice_ShouldRenderOnce() { - MockTimeSystem timeSystem = new(); + MockTimeSystem timeSystem = new(o => o.DisableAutoAdvance()); using ITimerMock sut = (ITimerMock)timeSystem.Timer.New( _ => { }, null, - TimeSpan.Zero, + TimeSpan.FromSeconds(1), Timeout.InfiniteTimeSpan); - sut.Wait(1, 5000); + // Synchronization barrier: ensure the timer thread has started and + // subscribed to its first due-time before advancing the mock clock. + await That(sut).Executed().Never().Within(TimeSpan.FromMilliseconds(200)); + timeSystem.TimeProvider.AdvanceBy(TimeSpan.FromSeconds(2)); + await That(sut).Executed().AtLeast(1.Times()).Within(TimeSpan.FromSeconds(10)); + await That(sut.ExecutionCount).IsEqualTo(1L); async Task Act() { @@ -250,23 +260,18 @@ but it was executed once [Fact] public async Task WhenExecutedTwice_AndExpectingExactlyThrice_ShouldRenderTwice() { - MockTimeSystem timeSystem = new(); - ITimerMock? timer = null; - int callbacks = 0; - timer = (ITimerMock)timeSystem.Timer.New( - _ => - { - if (Interlocked.Increment(ref callbacks) >= 2) - { - // ReSharper disable once AccessToModifiedClosure - timer?.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); - } - }, + MockTimeSystem timeSystem = new(o => o.DisableAutoAdvance()); + using ITimerMock sut = (ITimerMock)timeSystem.Timer.New( + _ => { }, null, - TimeSpan.Zero, - TimeSpan.FromMilliseconds(5)); - using ITimerMock sut = timer; - sut.Wait(2, 5000); + TimeSpan.FromSeconds(1), + TimeSpan.FromSeconds(1)); + // Synchronization barrier: ensure the timer thread has started and + // subscribed to its first due-time before advancing the mock clock. + await That(sut).Executed().Never().Within(TimeSpan.FromMilliseconds(200)); + timeSystem.TimeProvider.AdvanceBy(TimeSpan.FromMilliseconds(2500)); + await That(sut).Executed().AtLeast(2.Times()).Within(TimeSpan.FromSeconds(10)); + await That(sut.ExecutionCount).IsEqualTo(2L); async Task Act() { @@ -285,23 +290,18 @@ but it was executed twice [Fact] public async Task WhenExecutedThreeTimes_AndExpectingMore_ShouldRenderTimes() { - MockTimeSystem timeSystem = new(); - ITimerMock? timer = null; - int callbacks = 0; - timer = (ITimerMock)timeSystem.Timer.New( - _ => - { - if (Interlocked.Increment(ref callbacks) >= 3) - { - // ReSharper disable once AccessToModifiedClosure - timer?.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); - } - }, + MockTimeSystem timeSystem = new(o => o.DisableAutoAdvance()); + using ITimerMock sut = (ITimerMock)timeSystem.Timer.New( + _ => { }, null, - TimeSpan.Zero, - TimeSpan.FromMilliseconds(5)); - using ITimerMock sut = timer; - sut.Wait(3, 5000); + TimeSpan.FromSeconds(1), + TimeSpan.FromSeconds(1)); + // Synchronization barrier: ensure the timer thread has started and + // subscribed to its first due-time before advancing the mock clock. + await That(sut).Executed().Never().Within(TimeSpan.FromMilliseconds(200)); + timeSystem.TimeProvider.AdvanceBy(TimeSpan.FromMilliseconds(3500)); + await That(sut).Executed().AtLeast(3.Times()).Within(TimeSpan.FromSeconds(10)); + await That(sut.ExecutionCount).IsEqualTo(3L); async Task Act() {