From 139c0e0926f971ac5c9e5f373532b075fad7048c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Fri, 1 May 2026 18:01:20 +0200 Subject: [PATCH 1/4] feat: add playground projects --- Mockolate.Migration.slnx | 4 + Pipeline/Build.UnitTest.cs | 2 + .../ArgumentMatcherTests.cs | 98 +++++++++ .../CreationTests.cs | 59 ++++++ .../Domain/ChocolateBar.cs | 4 + .../Domain/ChocolateDispensedDelegate.cs | 4 + .../Domain/ChocolateRecipe.cs | 18 ++ .../Domain/ChocolateShop.cs | 52 +++++ .../Domain/IChocolateAuditor.cs | 8 + .../Domain/IChocolateDispenser.cs | 27 +++ .../Domain/IChocolateFactory.cs | 10 + .../Domain/InvalidChocolateException.cs | 8 + .../EventTests.cs | 80 ++++++++ .../Mockolate.Migration.MoqPlayground.csproj | 23 +++ .../SequenceTests.cs | 41 ++++ .../SetupTests.cs | 192 ++++++++++++++++++ .../UnsupportedFeatureTests.cs | 176 ++++++++++++++++ .../Usings.cs | 7 + .../VerifyTests.cs | 124 +++++++++++ .../ArgumentMatcherTests.cs | 58 ++++++ .../CreationTests.cs | 60 ++++++ .../Domain/ChocolateBar.cs | 4 + .../Domain/ChocolateDispensedDelegate.cs | 4 + .../Domain/ChocolateRecipe.cs | 19 ++ .../Domain/ChocolateShop.cs | 52 +++++ .../Domain/IChocolateAuditor.cs | 8 + .../Domain/IChocolateDispenser.cs | 30 +++ .../Domain/IChocolateFactory.cs | 10 + .../Domain/InvalidChocolateException.cs | 8 + .../Domain/StaticAuditor.cs | 9 + .../EventTests.cs | 72 +++++++ ...ate.Migration.NSubstitutePlayground.csproj | 23 +++ .../SetupTests.cs | 149 ++++++++++++++ .../UnsupportedFeatureTests.cs | 156 ++++++++++++++ .../Usings.cs | 7 + .../VerifyTests.cs | 103 ++++++++++ .../WhenDoTests.cs | 49 +++++ 37 files changed, 1758 insertions(+) create mode 100644 Tests/Mockolate.Migration.MoqPlayground/ArgumentMatcherTests.cs create mode 100644 Tests/Mockolate.Migration.MoqPlayground/CreationTests.cs create mode 100644 Tests/Mockolate.Migration.MoqPlayground/Domain/ChocolateBar.cs create mode 100644 Tests/Mockolate.Migration.MoqPlayground/Domain/ChocolateDispensedDelegate.cs create mode 100644 Tests/Mockolate.Migration.MoqPlayground/Domain/ChocolateRecipe.cs create mode 100644 Tests/Mockolate.Migration.MoqPlayground/Domain/ChocolateShop.cs create mode 100644 Tests/Mockolate.Migration.MoqPlayground/Domain/IChocolateAuditor.cs create mode 100644 Tests/Mockolate.Migration.MoqPlayground/Domain/IChocolateDispenser.cs create mode 100644 Tests/Mockolate.Migration.MoqPlayground/Domain/IChocolateFactory.cs create mode 100644 Tests/Mockolate.Migration.MoqPlayground/Domain/InvalidChocolateException.cs create mode 100644 Tests/Mockolate.Migration.MoqPlayground/EventTests.cs create mode 100644 Tests/Mockolate.Migration.MoqPlayground/Mockolate.Migration.MoqPlayground.csproj create mode 100644 Tests/Mockolate.Migration.MoqPlayground/SequenceTests.cs create mode 100644 Tests/Mockolate.Migration.MoqPlayground/SetupTests.cs create mode 100644 Tests/Mockolate.Migration.MoqPlayground/UnsupportedFeatureTests.cs create mode 100644 Tests/Mockolate.Migration.MoqPlayground/Usings.cs create mode 100644 Tests/Mockolate.Migration.MoqPlayground/VerifyTests.cs create mode 100644 Tests/Mockolate.Migration.NSubstitutePlayground/ArgumentMatcherTests.cs create mode 100644 Tests/Mockolate.Migration.NSubstitutePlayground/CreationTests.cs create mode 100644 Tests/Mockolate.Migration.NSubstitutePlayground/Domain/ChocolateBar.cs create mode 100644 Tests/Mockolate.Migration.NSubstitutePlayground/Domain/ChocolateDispensedDelegate.cs create mode 100644 Tests/Mockolate.Migration.NSubstitutePlayground/Domain/ChocolateRecipe.cs create mode 100644 Tests/Mockolate.Migration.NSubstitutePlayground/Domain/ChocolateShop.cs create mode 100644 Tests/Mockolate.Migration.NSubstitutePlayground/Domain/IChocolateAuditor.cs create mode 100644 Tests/Mockolate.Migration.NSubstitutePlayground/Domain/IChocolateDispenser.cs create mode 100644 Tests/Mockolate.Migration.NSubstitutePlayground/Domain/IChocolateFactory.cs create mode 100644 Tests/Mockolate.Migration.NSubstitutePlayground/Domain/InvalidChocolateException.cs create mode 100644 Tests/Mockolate.Migration.NSubstitutePlayground/Domain/StaticAuditor.cs create mode 100644 Tests/Mockolate.Migration.NSubstitutePlayground/EventTests.cs create mode 100644 Tests/Mockolate.Migration.NSubstitutePlayground/Mockolate.Migration.NSubstitutePlayground.csproj create mode 100644 Tests/Mockolate.Migration.NSubstitutePlayground/SetupTests.cs create mode 100644 Tests/Mockolate.Migration.NSubstitutePlayground/UnsupportedFeatureTests.cs create mode 100644 Tests/Mockolate.Migration.NSubstitutePlayground/Usings.cs create mode 100644 Tests/Mockolate.Migration.NSubstitutePlayground/VerifyTests.cs create mode 100644 Tests/Mockolate.Migration.NSubstitutePlayground/WhenDoTests.cs diff --git a/Mockolate.Migration.slnx b/Mockolate.Migration.slnx index 4217b11..a945f8d 100644 --- a/Mockolate.Migration.slnx +++ b/Mockolate.Migration.slnx @@ -2,6 +2,10 @@ + + + + diff --git a/Pipeline/Build.UnitTest.cs b/Pipeline/Build.UnitTest.cs index 9d51c7c..be24468 100644 --- a/Pipeline/Build.UnitTest.cs +++ b/Pipeline/Build.UnitTest.cs @@ -16,6 +16,8 @@ partial class Build Project[] UnitTestProjects => [ + Solution.Playground.Mockolate_Migration_MoqPlayground, + Solution.Playground.Mockolate_Migration_NSubstitutePlayground, Solution.Tests.Mockolate_Migration_Tests, ]; diff --git a/Tests/Mockolate.Migration.MoqPlayground/ArgumentMatcherTests.cs b/Tests/Mockolate.Migration.MoqPlayground/ArgumentMatcherTests.cs new file mode 100644 index 0000000..2b8c21a --- /dev/null +++ b/Tests/Mockolate.Migration.MoqPlayground/ArgumentMatcherTests.cs @@ -0,0 +1,98 @@ +using System.Text.RegularExpressions; +using Mockolate.Migration.MoqPlayground.Domain; +using Moq; + +namespace Mockolate.Migration.MoqPlayground; + +using It = Moq.It; +using Range = Moq.Range; + +/// Argument matchers: It.IsAny / It.Is / It.IsRegex / It.IsInRange / It.IsNotNull / It.Ref / out. +public class ArgumentMatcherTests +{ + [Fact] + public async Task ItIs_predicate_matchesEvenAmounts() + { + Mock dispenser = new(); + dispenser.Setup(d => d.Dispense("Dark", It.Is(i => i % 2 == 0))).Returns(true); + + await That(dispenser.Object.Dispense("Dark", 4)).IsTrue(); + await That(dispenser.Object.Dispense("Dark", 3)).IsFalse(); + } + + [Fact] + public async Task ItIsAny_matchesAnyValue() + { + Mock dispenser = new(); + dispenser.Setup(d => d.Dispense(It.IsAny(), It.IsAny())).Returns(true); + + await That(dispenser.Object.Dispense("Dark", 1)).IsTrue(); + await That(dispenser.Object.Dispense("Milk", 99)).IsTrue(); + } + + [Fact] + public async Task ItIsInRange_inclusive_matchesBoundaries() + { + Mock dispenser = new(); + dispenser.Setup(d => d.Dispense("Dark", It.IsInRange(1, 5, Range.Inclusive))).Returns(true); + + await That(dispenser.Object.Dispense("Dark", 1)).IsTrue(); + await That(dispenser.Object.Dispense("Dark", 5)).IsTrue(); + await That(dispenser.Object.Dispense("Dark", 6)).IsFalse(); + } + + [Fact] + public async Task ItIsNotNull_rejectsNull() + { + Mock factory = new(); + factory.Setup(f => f.RegisterRecipe(It.IsNotNull())).Returns(true); + + await That(factory.Object.RegisterRecipe("Pralines")).IsTrue(); + await That(factory.Object.RegisterRecipe(null!)).IsFalse(); + } + + [Fact] + public async Task ItIsRegex_matchesPattern() + { + Mock factory = new(); + factory.Setup(f => f.RegisterRecipe(It.IsRegex("^Dark", RegexOptions.IgnoreCase))).Returns(true); + + await That(factory.Object.RegisterRecipe("DarkTruffle")).IsTrue(); + await That(factory.Object.RegisterRecipe("MilkTruffle")).IsFalse(); + } + + [Fact] + public async Task OutParameter_isSetByItIsOut() + { + Mock dispenser = new(); + int reserved = 7; + dispenser.Setup(d => d.TryReserve("Dark", out reserved)).Returns(true); + + bool ok = dispenser.Object.TryReserve("Dark", out int actual); + + await That(ok).IsTrue(); + await That(actual).IsEqualTo(7); + } + + [Fact] + public async Task PlainValue_isUsedAsExactMatch() + { + Mock dispenser = new(); + dispenser.Setup(d => d.Dispense("Milk", 3)).Returns(true); + + await That(dispenser.Object.Dispense("Milk", 3)).IsTrue(); + await That(dispenser.Object.Dispense("Milk", 4)).IsFalse(); + } + + [Fact] + public async Task RefParameter_anyMatch_acceptsAnyRef() + { + Mock dispenser = new(); + dispenser.Setup(d => d.Refill("Dark", ref It.Ref.IsAny)).Returns(true); + + int amount = 10; + bool ok = dispenser.Object.Refill("Dark", ref amount); + + await That(ok).IsTrue(); + } +} diff --git a/Tests/Mockolate.Migration.MoqPlayground/CreationTests.cs b/Tests/Mockolate.Migration.MoqPlayground/CreationTests.cs new file mode 100644 index 0000000..5c083b9 --- /dev/null +++ b/Tests/Mockolate.Migration.MoqPlayground/CreationTests.cs @@ -0,0 +1,59 @@ +using Mockolate.Migration.MoqPlayground.Domain; +using Moq; + +namespace Mockolate.Migration.MoqPlayground; + +using MockBehavior = Moq.MockBehavior; + +/// Mock construction patterns. +public class CreationTests +{ + [Fact] + public async Task ClassMockWithConstructorArgs_isCreatedWithThoseArgs() + { + // ChocolateRecipe has a parameterless ctor, but Moq supports passing args to base. + Mock recipe = new(); + recipe.SetupGet(r => r.Name).Returns("Praline"); + + await That(recipe.Object.Name).IsEqualTo("Praline"); + } + + [Fact] + public async Task DefaultLooseMock_returnsDefaultsForUnsetMembers() + { + Mock dispenser = new(); + + // Loose Moq returns default(bool) = false when not set up. + bool dispensed = dispenser.Object.Dispense("Dark", 1); + + await That(dispensed).IsFalse(); + } + + [Fact] + public async Task ExplicitLooseMock_isEquivalentToDefault() + { + Mock dispenser = new(MockBehavior.Loose); + + await That(dispenser.Object.Dispense("Dark", 1)).IsFalse(); + } + + [Fact] + public async Task ObjectAccess_isUsedToReachTheMockedInstance() + { + Mock factory = new(); + factory.Setup(f => f.RegisterRecipe("Truffle")).Returns(true); + + bool registered = factory.Object.RegisterRecipe("Truffle"); + + await That(registered).IsTrue(); + } + + [Fact] + public async Task StrictMock_throwsForUnsetMembers() + { + Mock dispenser = new(MockBehavior.Strict); + + await That(() => dispenser.Object.Dispense("Dark", 1)) + .Throws(); + } +} diff --git a/Tests/Mockolate.Migration.MoqPlayground/Domain/ChocolateBar.cs b/Tests/Mockolate.Migration.MoqPlayground/Domain/ChocolateBar.cs new file mode 100644 index 0000000..4300568 --- /dev/null +++ b/Tests/Mockolate.Migration.MoqPlayground/Domain/ChocolateBar.cs @@ -0,0 +1,4 @@ +namespace Mockolate.Migration.MoqPlayground.Domain; + +/// A baked chocolate bar. +public sealed record ChocolateBar(string Type, int Cocoa, decimal Price); diff --git a/Tests/Mockolate.Migration.MoqPlayground/Domain/ChocolateDispensedDelegate.cs b/Tests/Mockolate.Migration.MoqPlayground/Domain/ChocolateDispensedDelegate.cs new file mode 100644 index 0000000..2a5db22 --- /dev/null +++ b/Tests/Mockolate.Migration.MoqPlayground/Domain/ChocolateDispensedDelegate.cs @@ -0,0 +1,4 @@ +namespace Mockolate.Migration.MoqPlayground.Domain; + +/// Custom event delegate carried by . +public delegate void ChocolateDispensedDelegate(string type, int amount); diff --git a/Tests/Mockolate.Migration.MoqPlayground/Domain/ChocolateRecipe.cs b/Tests/Mockolate.Migration.MoqPlayground/Domain/ChocolateRecipe.cs new file mode 100644 index 0000000..fe566ae --- /dev/null +++ b/Tests/Mockolate.Migration.MoqPlayground/Domain/ChocolateRecipe.cs @@ -0,0 +1,18 @@ +namespace Mockolate.Migration.MoqPlayground.Domain; + +/// Concrete recipe — used for partial mocks (Moq CallBase, NSubstitute ForPartsOf). +public class ChocolateRecipe +{ + public virtual string Name { get; set; } = "Truffle"; + public virtual int CocoaPercent { get; set; } = 70; + + public virtual ChocolateBar Bake(int amount) => + new(Name, CocoaPercent, amount * 1.5m); + + public virtual bool Validate() => !string.IsNullOrEmpty(Name); + + /// Used for Moq Protected(). + protected virtual int InternalSecret() => 42; + + public int CallInternalSecret() => InternalSecret(); +} diff --git a/Tests/Mockolate.Migration.MoqPlayground/Domain/ChocolateShop.cs b/Tests/Mockolate.Migration.MoqPlayground/Domain/ChocolateShop.cs new file mode 100644 index 0000000..feef5e1 --- /dev/null +++ b/Tests/Mockolate.Migration.MoqPlayground/Domain/ChocolateShop.cs @@ -0,0 +1,52 @@ +namespace Mockolate.Migration.MoqPlayground.Domain; + +/// The system-under-test that orchestrates the dependencies. +public sealed class ChocolateShop +{ + private readonly IChocolateAuditor? _auditor; + private readonly IChocolateDispenser _dispenser; + private readonly IChocolateFactory _factory; + + public ChocolateShop( + IChocolateDispenser dispenser, + IChocolateFactory factory, + IChocolateAuditor? auditor = null) + { + _dispenser = dispenser; + _factory = factory; + _auditor = auditor; + _dispenser.ChocolateDispensed += OnDispensed; + } + + public int TotalSold { get; private set; } + + public string DispenserName + { + get => _dispenser.Name; + set => _dispenser.Name = value; + } + + public bool Sell(string type, int amount, decimal pricePerUnit = 1.5m) + { + if (!_dispenser.Dispense(type, amount)) + { + return false; + } + + _auditor?.RecordSale(type, amount, amount * pricePerUnit); + return true; + } + + public Task SellAsync(string type, int amount) + => _dispenser.DispenseAsync(type, amount); + + public Task RestockAsync(string recipe, int cocoa) + => _factory.BakeAsync(recipe, cocoa); + + public int CheckStock(string type) => _dispenser[type]; + + public bool TryReserveStock(string type, out int reserved) + => _dispenser.TryReserve(type, out reserved); + + private void OnDispensed(string type, int amount) => TotalSold += amount; +} diff --git a/Tests/Mockolate.Migration.MoqPlayground/Domain/IChocolateAuditor.cs b/Tests/Mockolate.Migration.MoqPlayground/Domain/IChocolateAuditor.cs new file mode 100644 index 0000000..348bbde --- /dev/null +++ b/Tests/Mockolate.Migration.MoqPlayground/Domain/IChocolateAuditor.cs @@ -0,0 +1,8 @@ +namespace Mockolate.Migration.MoqPlayground.Domain; + +/// Used for multi-interface mocks (Moq As<T>(), NSubstitute Substitute.For<T1,T2>()). +public interface IChocolateAuditor +{ + int AuditCount { get; } + void RecordSale(string type, int amount, decimal total); +} diff --git a/Tests/Mockolate.Migration.MoqPlayground/Domain/IChocolateDispenser.cs b/Tests/Mockolate.Migration.MoqPlayground/Domain/IChocolateDispenser.cs new file mode 100644 index 0000000..98a7d69 --- /dev/null +++ b/Tests/Mockolate.Migration.MoqPlayground/Domain/IChocolateDispenser.cs @@ -0,0 +1,27 @@ +namespace Mockolate.Migration.MoqPlayground.Domain; + +/// +/// Surface intended to exercise as much of Moq/NSubstitute as possible: +/// indexer, properties (read/write), method overloads, async, ref, out, custom event, standard event. +/// +public interface IChocolateDispenser +{ + int this[string type] { get; set; } + int TotalDispensed { get; set; } + string Name { get; set; } + + bool Dispense(string type, int amount); + bool Dispense(string type); + Task DispenseAsync(string type, int amount); + + /// Tries to reserve some stock for the given type, returning the reserved amount. + bool TryReserve(string type, out int reserved); + + /// Refills stock; the caller passes a desired amount and gets the actual amount back via ref. + bool Refill(string type, ref int amount); + + int CountByType(string type); + + event ChocolateDispensedDelegate ChocolateDispensed; + event EventHandler StockLow; +} diff --git a/Tests/Mockolate.Migration.MoqPlayground/Domain/IChocolateFactory.cs b/Tests/Mockolate.Migration.MoqPlayground/Domain/IChocolateFactory.cs new file mode 100644 index 0000000..7e58cc5 --- /dev/null +++ b/Tests/Mockolate.Migration.MoqPlayground/Domain/IChocolateFactory.cs @@ -0,0 +1,10 @@ +namespace Mockolate.Migration.MoqPlayground.Domain; + +/// Used to exercise async, generics on parameters, collection parameters. +public interface IChocolateFactory +{ + int Capacity { get; } + Task BakeAsync(string recipe, int cocoa); + Task> BatchBakeAsync(IEnumerable recipes); + bool RegisterRecipe(string name); +} diff --git a/Tests/Mockolate.Migration.MoqPlayground/Domain/InvalidChocolateException.cs b/Tests/Mockolate.Migration.MoqPlayground/Domain/InvalidChocolateException.cs new file mode 100644 index 0000000..804e295 --- /dev/null +++ b/Tests/Mockolate.Migration.MoqPlayground/Domain/InvalidChocolateException.cs @@ -0,0 +1,8 @@ +namespace Mockolate.Migration.MoqPlayground.Domain; + +/// Domain exception thrown for invalid chocolate operations. +public class InvalidChocolateException : Exception +{ + public InvalidChocolateException() { } + public InvalidChocolateException(string message) : base(message) { } +} diff --git a/Tests/Mockolate.Migration.MoqPlayground/EventTests.cs b/Tests/Mockolate.Migration.MoqPlayground/EventTests.cs new file mode 100644 index 0000000..5a35051 --- /dev/null +++ b/Tests/Mockolate.Migration.MoqPlayground/EventTests.cs @@ -0,0 +1,80 @@ +using Mockolate.Migration.MoqPlayground.Domain; +using Moq; + +namespace Mockolate.Migration.MoqPlayground; + +using It = Moq.It; + +/// Event subscription / raising / verification. +public class EventTests +{ + [Fact] + public async Task Raise_customDelegate_invokesSubscribedHandler() + { + Mock dispenser = new(); + string? observedType = null; + int observedAmount = 0; + dispenser.Object.ChocolateDispensed += (t, a) => + { + observedType = t; + observedAmount = a; + }; + + dispenser.Raise(d => d.ChocolateDispensed += null, "Dark", 5); + + await That(observedType).IsEqualTo("Dark"); + await That(observedAmount).IsEqualTo(5); + } + + [Fact] + public async Task Raise_eventHandlerStandard_passesArgs() + { + Mock dispenser = new(); + int? observed = null; + dispenser.Object.StockLow += (_, low) => observed = low; + + dispenser.Raise(d => d.StockLow += null, dispenser.Object, 2); + + await That(observed).IsEqualTo(2); + } + + [Fact] + public async Task ShopSubscribesOnConstruction_andTracksDispensedAmounts() + { + Mock dispenser = new(); + Mock factory = new(); + dispenser.Setup(d => d.Dispense(It.IsAny(), It.IsAny())).Returns(true); + ChocolateShop shop = new(dispenser.Object, factory.Object); + + shop.Sell("Dark", 2); + dispenser.Raise(d => d.ChocolateDispensed += null, "Dark", 2); + shop.Sell("Milk", 5); + dispenser.Raise(d => d.ChocolateDispensed += null, "Milk", 5); + + await That(shop.TotalSold).IsEqualTo(7); + dispenser.VerifyAdd( + d => d.ChocolateDispensed += It.IsAny(), + Times.Once()); + } + + [Fact] + public async Task VerifyAdd_recordsSubscription() + { + Mock dispenser = new(); + ChocolateDispensedDelegate handler = (_, _) => { }; + dispenser.Object.ChocolateDispensed += handler; + + dispenser.VerifyAdd(d => d.ChocolateDispensed += It.IsAny(), Times.Once()); + } + + [Fact] + public async Task VerifyRemove_recordsUnsubscription() + { + Mock dispenser = new(); + ChocolateDispensedDelegate handler = (_, _) => { }; + dispenser.Object.ChocolateDispensed += handler; + dispenser.Object.ChocolateDispensed -= handler; + + dispenser.VerifyRemove(d => d.ChocolateDispensed -= It.IsAny(), Times.Once()); + } +} diff --git a/Tests/Mockolate.Migration.MoqPlayground/Mockolate.Migration.MoqPlayground.csproj b/Tests/Mockolate.Migration.MoqPlayground/Mockolate.Migration.MoqPlayground.csproj new file mode 100644 index 0000000..f550433 --- /dev/null +++ b/Tests/Mockolate.Migration.MoqPlayground/Mockolate.Migration.MoqPlayground.csproj @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + diff --git a/Tests/Mockolate.Migration.MoqPlayground/SequenceTests.cs b/Tests/Mockolate.Migration.MoqPlayground/SequenceTests.cs new file mode 100644 index 0000000..2c9c0ef --- /dev/null +++ b/Tests/Mockolate.Migration.MoqPlayground/SequenceTests.cs @@ -0,0 +1,41 @@ +using Mockolate.Migration.MoqPlayground.Domain; +using Moq; + +namespace Mockolate.Migration.MoqPlayground; + +using It = Moq.It; +using MockBehavior = Moq.MockBehavior; + +/// MockSequence + InSequence call ordering (Moq pattern). +public class SequenceTests +{ + [Fact] + public async Task MockSequence_strictOrdering_isHonored() + { + Mock dispenser = new(MockBehavior.Strict); + MockSequence sequence = new(); + + dispenser.InSequence(sequence).Setup(d => d.Dispense("Dark", 1)).Returns(true); + dispenser.InSequence(sequence).Setup(d => d.Dispense("Milk", 2)).Returns(true); + dispenser.InSequence(sequence).Setup(d => d.Dispense("White", 3)).Returns(true); + + await That(dispenser.Object.Dispense("Dark", 1)).IsTrue(); + await That(dispenser.Object.Dispense("Milk", 2)).IsTrue(); + await That(dispenser.Object.Dispense("White", 3)).IsTrue(); + } + + [Fact] + public async Task SetupSequence_returnsValuesInOrder() + { + Mock factory = new(); + factory.SetupSequence(f => f.RegisterRecipe(It.IsAny())) + .Returns(true) + .Returns(false) + .Throws(new InvalidChocolateException("registry full")); + + await That(factory.Object.RegisterRecipe("a")).IsTrue(); + await That(factory.Object.RegisterRecipe("b")).IsFalse(); + await That(() => factory.Object.RegisterRecipe("c")) + .Throws(); + } +} diff --git a/Tests/Mockolate.Migration.MoqPlayground/SetupTests.cs b/Tests/Mockolate.Migration.MoqPlayground/SetupTests.cs new file mode 100644 index 0000000..70dcf87 --- /dev/null +++ b/Tests/Mockolate.Migration.MoqPlayground/SetupTests.cs @@ -0,0 +1,192 @@ +using Mockolate.Migration.MoqPlayground.Domain; +using Moq; + +namespace Mockolate.Migration.MoqPlayground; + +using It = Moq.It; + +/// Method/property setup patterns: Returns / Throws / Callback / Sequence / Async. +public class SetupTests +{ + [Fact] + public async Task Callback_observesArgumentsBeforeReturning() + { + Mock dispenser = new(); + string? observedType = null; + int observedAmount = 0; + dispenser + .Setup(d => d.Dispense(It.IsAny(), It.IsAny())) + .Callback((t, a) => + { + observedType = t; + observedAmount = a; + }) + .Returns(true); + + dispenser.Object.Dispense("Milk", 4); + + await That(observedType).IsEqualTo("Milk"); + await That(observedAmount).IsEqualTo(4); + } + + [Fact] + public async Task NestedMockSetup_recursive_chainsThroughChildMock() + { + // Auto-mocking hierarchy: Moq creates child mocks for properties on demand. + Mock outer = new() + { + DefaultValue = DefaultValue.Mock, + }; + outer.Setup(o => o.Inner.Inner.Name).Returns("deep"); + + await That(outer.Object.Inner.Inner.Name).IsEqualTo("deep"); + } + + [Fact] + public async Task Returns_argumentBased_lowercasesTheRecipe() + { + Mock dispenser = new(); + dispenser.Setup(d => d.Dispense(It.IsAny())).Returns((string s) => s.Length > 0); + + await That(dispenser.Object.Dispense("Dark")).IsTrue(); + await That(dispenser.Object.Dispense("")).IsFalse(); + } + + [Fact] + public async Task Returns_directValue_dispensesAndShopRecordsTotal() + { + Mock dispenser = new(); + Mock factory = new(); + dispenser.Setup(d => d.Dispense("Dark", 3)).Returns(true); + ChocolateShop shop = new(dispenser.Object, factory.Object); + + bool sold = shop.Sell("Dark", 3); + dispenser.Raise(d => d.ChocolateDispensed += null, "Dark", 3); + + await That(sold).IsTrue(); + await That(shop.TotalSold).IsEqualTo(3); + } + + [Fact] + public async Task Returns_lazyFactory_evaluatedOnEachCall() + { + Mock dispenser = new(); + int count = 1; + dispenser.Setup(d => d.CountByType("Dark")).Returns(() => count); + + int first = dispenser.Object.CountByType("Dark"); + count = 5; + int second = dispenser.Object.CountByType("Dark"); + + await That(first).IsEqualTo(1); + await That(second).IsEqualTo(5); + } + + [Fact] + public async Task ReturnsAsync_completesWithValue() + { + Mock dispenser = new(); + dispenser.Setup(d => d.DispenseAsync("Dark", 1)).ReturnsAsync(true); + + await That(await dispenser.Object.DispenseAsync("Dark", 1)).IsTrue(); + } + + [Fact] + public async Task ReturnsAsync_factory_evaluatesPerCall() + { + Mock factory = new(); + factory.Setup(f => f.BakeAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((string r, int c) => new ChocolateBar(r, c, c * 0.05m)); + + ChocolateBar bar = await factory.Object.BakeAsync("Dark", 80); + + await That(bar.Type).IsEqualTo("Dark"); + await That(bar.Cocoa).IsEqualTo(80); + await That(bar.Price).IsEqualTo(4.0m); + } + + [Fact] + public async Task SetupGet_property_returnsConfiguredValue() + { + Mock dispenser = new(); + dispenser.SetupGet(d => d.Name).Returns("Choco-9000"); + + await That(dispenser.Object.Name).IsEqualTo("Choco-9000"); + } + + [Fact] + public async Task SetupProperty_tracksAssignmentsAndReturnsLastValue() + { + Mock dispenser = new(); + dispenser.SetupProperty(d => d.Name); + + dispenser.Object.Name = "ChocoMatic"; + + await That(dispenser.Object.Name).IsEqualTo("ChocoMatic"); + } + + [Fact] + public async Task SetupProperty_withInitialValue_returnsThatBeforeAssignment() + { + Mock dispenser = new(); + dispenser.SetupProperty(d => d.Name, "Default"); + + await That(dispenser.Object.Name).IsEqualTo("Default"); + } + + [Fact] + public async Task SetupSequence_returnsValuesInOrder_thenDefault() + { + Mock dispenser = new(); + dispenser.SetupSequence(d => d.Dispense(It.IsAny(), It.IsAny())) + .Returns(true) + .Throws(new InvalidChocolateException("temporarily out")) + .Returns(false); + + await That(dispenser.Object.Dispense("Dark", 1)).IsTrue(); + await That(() => dispenser.Object.Dispense("Dark", 1)) + .Throws(); + await That(dispenser.Object.Dispense("Dark", 1)).IsFalse(); + } + + [Fact] + public async Task Throws_genericException_isRaisedOnInvocation() + { + Mock dispenser = new(); + dispenser.Setup(d => d.Dispense("reset", 0)).Throws(); + + await That(() => dispenser.Object.Dispense("reset", 0)) + .Throws(); + } + + [Fact] + public async Task Throws_specificInstance_isRaisedOnInvocation() + { + Mock dispenser = new(); + dispenser.Setup(d => d.Dispense("", 0)).Throws(new InvalidChocolateException("empty type")); + + await That(() => dispenser.Object.Dispense("", 0)) + .Throws() + .WithMessage("empty type"); + } + + [Fact] + public async Task ThrowsAsync_completesWithException() + { + Mock dispenser = new(); + dispenser.Setup(d => d.DispenseAsync("Dark", 1)).ThrowsAsync(new TimeoutException()); + + Task Act() + { + return dispenser.Object.DispenseAsync("Dark", 1); + } + + await That((Func>)Act).Throws(); + } + + public interface INested + { + INested Inner { get; } + string Name { get; } + } +} diff --git a/Tests/Mockolate.Migration.MoqPlayground/UnsupportedFeatureTests.cs b/Tests/Mockolate.Migration.MoqPlayground/UnsupportedFeatureTests.cs new file mode 100644 index 0000000..07b7dd4 --- /dev/null +++ b/Tests/Mockolate.Migration.MoqPlayground/UnsupportedFeatureTests.cs @@ -0,0 +1,176 @@ +using Mockolate.Migration.MoqPlayground.Domain; +using Moq; +using Moq.Protected; + +namespace Mockolate.Migration.MoqPlayground; + +using It = Moq.It; +using MockBehavior = Moq.MockBehavior; + +/// +/// Moq features the migration does NOT yet handle. They still pass against Moq; +/// run the migration to see what is/isn't transformed and where manual rewrites are needed. +/// +public class UnsupportedFeatureTests +{ + // NOT YET MIGRATED: CallBase = true (delegate to base implementation) + [Fact] + public async Task CallBase_invokesBaseClassImplementation() + { + Mock recipe = new() + { + CallBase = true, + }; + + // Validate() falls through to base, which checks Name is non-empty + bool ok = recipe.Object.Validate(); + + await That(ok).IsTrue(); + } + + // NOT YET MIGRATED: Custom DefaultValueProvider + [Fact] + public async Task DefaultValueProviderMock_returnsAutoMockedReferenceTypes() + { + Mock factory = new() + { + DefaultValue = DefaultValue.Mock, + }; + + // Reading a Task-returning member without a setup yields a Task that completes, + // rather than a NullReferenceException. + Task result = Task.FromResult(factory.Object.RegisterRecipe("auto")); + + await That(await result).IsFalse(); + } + + // NOT YET MIGRATED: It.IsNotIn / It.IsIn (set membership matchers) + [Fact] + public async Task ItIsIn_acceptsAnyValueFromTheSet() + { + Mock dispenser = new(); + dispenser.Setup(d => d.Dispense(It.IsIn("Dark", "Milk"), 1)).Returns(true); + + await That(dispenser.Object.Dispense("Dark", 1)).IsTrue(); + await That(dispenser.Object.Dispense("Milk", 1)).IsTrue(); + await That(dispenser.Object.Dispense("White", 1)).IsFalse(); + } + + // NOT YET MIGRATED: Mock.As() to add a secondary interface + [Fact] + public async Task MockAs_castToAdditionalInterface() + { + Mock dispenser = new(); + dispenser.As() + .Setup(a => a.AuditCount).Returns(7); + + IChocolateAuditor auditor = (IChocolateAuditor)dispenser.Object; + await That(auditor.AuditCount).IsEqualTo(7); + } + + // NOT YET MIGRATED: Mock.Of() (LINQ to Mocks) + [Fact] + public async Task MockOf_setsUpAllReturnsImplicitly() + { + IChocolateDispenser dispenser = Moq.Mock.Of(d => + d.Name == "Quick" && + d.TotalDispensed == 99); + + await That(dispenser.Name).IsEqualTo("Quick"); + await That(dispenser.TotalDispensed).IsEqualTo(99); + } + + // NOT YET MIGRATED: MockRepository for grouped Verifiable + VerifyAll + [Fact] + public async Task MockRepository_groupsAndVerifiesAllInOneShot() + { + MockRepository repo = new(MockBehavior.Strict); + Mock dispenser = repo.Create(); + Mock factory = repo.Create(); + dispenser.Setup(d => d.Dispense("Dark", 1)).Returns(true); + factory.Setup(f => f.RegisterRecipe("Truffle")).Returns(true); + + _ = dispenser.Object.Dispense("Dark", 1); + _ = factory.Object.RegisterRecipe("Truffle"); + + repo.VerifyAll(); + } + + // NOT YET MIGRATED: Protected() to set up protected virtual members + [Fact] + public async Task Protected_setupOfProtectedMethod() + { + Mock recipe = new(); + recipe.Protected().Setup("InternalSecret").Returns(123); + + int secret = recipe.Object.CallInternalSecret(); + + await That(secret).IsEqualTo(123); + } + + // NOT YET MIGRATED: Returns overload that takes the Mock itself (mock.Object self-reference) + [Fact] + public async Task Returns_factoryWithCapturedMock_canReadOtherSetups() + { + Mock dispenser = new(); + dispenser.SetupGet(d => d.TotalDispensed).Returns(42); + dispenser + .Setup(d => d.CountByType(It.IsAny())) + .Returns(() => dispenser.Object.TotalDispensed / 2); + + await That(dispenser.Object.CountByType("Dark")).IsEqualTo(21); + } + + // NOT YET MIGRATED: SetupAllProperties stubs every readable+writable property + [Fact] + public async Task SetupAllProperties_makesAllPropertiesStateful() + { + Mock dispenser = new(); + dispenser.SetupAllProperties(); + + dispenser.Object.Name = "All-Stub"; + dispenser.Object.TotalDispensed = 12; + + await That(dispenser.Object.Name).IsEqualTo("All-Stub"); + await That(dispenser.Object.TotalDispensed).IsEqualTo(12); + } + + // NOT YET MIGRATED: Strict mock with Verifiable() chain to check that every verifiable setup ran + [Fact] + public async Task Strict_withVerifiableSetups_passesWhenAllAreInvoked() + { + Mock dispenser = new(MockBehavior.Strict); + dispenser.Setup(d => d.Dispense("Dark", 1)).Returns(true).Verifiable(); + dispenser.Setup(d => d.Dispense("Milk", 1)).Returns(true).Verifiable(); + + _ = dispenser.Object.Dispense("Dark", 1); + _ = dispenser.Object.Dispense("Milk", 1); + + dispenser.Verify(); + } + + // NOT YET MIGRATED: Verifiable() + mock.Verify() + [Fact] + public async Task VerifiableSetup_andMockVerify() + { + Mock dispenser = new(); + dispenser.Setup(d => d.Dispense("Dark", 1)).Returns(true).Verifiable(); + + _ = dispenser.Object.Dispense("Dark", 1); + + dispenser.Verify(); + } + + // NOT YET MIGRATED: VerifyAll() / VerifyNoOtherCalls() + [Fact] + public async Task VerifyAll_andVerifyNoOtherCalls() + { + Mock dispenser = new(); + dispenser.Setup(d => d.Dispense("Dark", 1)).Returns(true); + + _ = dispenser.Object.Dispense("Dark", 1); + + dispenser.VerifyAll(); + dispenser.VerifyNoOtherCalls(); + } +} diff --git a/Tests/Mockolate.Migration.MoqPlayground/Usings.cs b/Tests/Mockolate.Migration.MoqPlayground/Usings.cs new file mode 100644 index 0000000..88f147d --- /dev/null +++ b/Tests/Mockolate.Migration.MoqPlayground/Usings.cs @@ -0,0 +1,7 @@ +global using System; +global using System.Collections.Generic; +global using System.Threading; +global using System.Threading.Tasks; +global using Xunit; +global using aweXpect; +global using static aweXpect.Expect; diff --git a/Tests/Mockolate.Migration.MoqPlayground/VerifyTests.cs b/Tests/Mockolate.Migration.MoqPlayground/VerifyTests.cs new file mode 100644 index 0000000..02c8a63 --- /dev/null +++ b/Tests/Mockolate.Migration.MoqPlayground/VerifyTests.cs @@ -0,0 +1,124 @@ +using Mockolate.Migration.MoqPlayground.Domain; +using Moq; + +namespace Mockolate.Migration.MoqPlayground; + +using It = Moq.It; +using Range = Moq.Range; + +/// Verification patterns: Verify, VerifyGet, VerifySet, Times.*. +public class VerifyTests +{ + [Fact] + public async Task Verify_method_atLeast() + { + Mock dispenser = new(); + dispenser.Setup(d => d.Dispense(It.IsAny(), It.IsAny())).Returns(true); + + dispenser.Object.Dispense("Dark", 1); + dispenser.Object.Dispense("Dark", 2); + + dispenser.Verify(d => d.Dispense("Dark", It.IsAny()), Times.AtLeast(2)); + dispenser.Verify(d => d.Dispense("Dark", It.IsAny()), Times.AtLeastOnce); + } + + [Fact] + public async Task Verify_method_atMost() + { + Mock dispenser = new(); + dispenser.Setup(d => d.Dispense(It.IsAny(), It.IsAny())).Returns(true); + + dispenser.Object.Dispense("Dark", 1); + + dispenser.Verify(d => d.Dispense("Dark", It.IsAny()), Times.AtMost(2)); + dispenser.Verify(d => d.Dispense("Dark", It.IsAny()), Times.AtMostOnce); + } + + [Fact] + public async Task Verify_method_between() + { + Mock dispenser = new(); + dispenser.Setup(d => d.Dispense(It.IsAny(), It.IsAny())).Returns(true); + + dispenser.Object.Dispense("Dark", 1); + dispenser.Object.Dispense("Dark", 2); + + dispenser.Verify( + d => d.Dispense("Dark", It.IsAny()), + Times.Between(1, 3, Range.Inclusive)); + } + + [Fact] + public async Task Verify_method_exactCount() + { + Mock dispenser = new(); + dispenser.Setup(d => d.Dispense(It.IsAny(), It.IsAny())).Returns(true); + + dispenser.Object.Dispense("Dark", 1); + dispenser.Object.Dispense("Dark", 2); + dispenser.Object.Dispense("Dark", 3); + + dispenser.Verify(d => d.Dispense("Dark", It.IsAny()), Times.Exactly(3)); + } + + [Fact] + public async Task Verify_method_wasCalledOnce() + { + Mock dispenser = new(); + Mock factory = new(); + dispenser.Setup(d => d.Dispense("Dark", 2)).Returns(true); + ChocolateShop shop = new(dispenser.Object, factory.Object); + + shop.Sell("Dark", 2); + + dispenser.Verify(d => d.Dispense("Dark", 2), Times.Once()); + } + + [Fact] + public async Task Verify_method_wasNeverCalled() + { + Mock dispenser = new(); + Mock factory = new(); + dispenser.Setup(d => d.Dispense("Dark", 1)).Returns(false); + ChocolateShop shop = new(dispenser.Object, factory.Object); + + shop.Sell("Dark", 1); // dispense returns false → no audit follow-up + + dispenser.Verify(d => d.Dispense("Milk", It.IsAny()), Times.Never); + } + + [Fact] + public async Task VerifyGet_property_wasRead() + { + Mock dispenser = new(); + dispenser.SetupGet(d => d.Name).Returns("Choc-Box"); + + _ = dispenser.Object.Name; + _ = dispenser.Object.Name; + + dispenser.VerifyGet(d => d.Name, Times.Exactly(2)); + } + + [Fact] + public async Task VerifySet_anyValue_isMatchedWithItIsAny() + { + Mock dispenser = new(); + dispenser.SetupProperty(d => d.TotalDispensed); + + dispenser.Object.TotalDispensed = 7; + dispenser.Object.TotalDispensed = 9; + + dispenser.VerifySet(d => d.TotalDispensed = It.IsAny(), Times.Exactly(2)); + } + + [Fact] + public async Task VerifySet_property_wasAssigned() + { + Mock dispenser = new(); + dispenser.SetupProperty(d => d.Name); + + dispenser.Object.Name = "Choco-2025"; + + dispenser.VerifySet(d => d.Name = "Choco-2025", Times.Once()); + } +} diff --git a/Tests/Mockolate.Migration.NSubstitutePlayground/ArgumentMatcherTests.cs b/Tests/Mockolate.Migration.NSubstitutePlayground/ArgumentMatcherTests.cs new file mode 100644 index 0000000..175ab64 --- /dev/null +++ b/Tests/Mockolate.Migration.NSubstitutePlayground/ArgumentMatcherTests.cs @@ -0,0 +1,58 @@ +using Mockolate.Migration.NSubstitutePlayground.Domain; +using NSubstitute; + +namespace Mockolate.Migration.NSubstitutePlayground; + +/// Argument matchers: Arg.Any / Arg.Is / Arg.Compat. (Arg.Do/Arg.Invoke are in the Unsupported file.) +public class ArgumentMatcherTests +{ + [Fact] + public async Task ArgAny_matchesAnyValue() + { + IChocolateDispenser dispenser = Substitute.For(); + dispenser.Dispense(Arg.Any(), Arg.Any()).Returns(true); + + await That(dispenser.Dispense("Dark", 1)).IsTrue(); + await That(dispenser.Dispense("Milk", 99)).IsTrue(); + } + + [Fact] + public async Task ArgCompat_AnyAndIs_workLikePlainArg() + { + IChocolateDispenser dispenser = Substitute.For(); + dispenser.Dispense(Arg.Compat.Any(), Arg.Compat.Is(i => i > 0)).Returns(true); + + await That(dispenser.Dispense("Dark", 5)).IsTrue(); + await That(dispenser.Dispense("Dark", 0)).IsFalse(); + } + + [Fact] + public async Task ArgIs_predicate_matchesEvenAmounts() + { + IChocolateDispenser dispenser = Substitute.For(); + dispenser.Dispense("Dark", Arg.Is(i => i % 2 == 0)).Returns(true); + + await That(dispenser.Dispense("Dark", 4)).IsTrue(); + await That(dispenser.Dispense("Dark", 3)).IsFalse(); + } + + [Fact] + public async Task ArgIs_value_matchesExactValue() + { + IChocolateDispenser dispenser = Substitute.For(); + dispenser.Dispense(Arg.Is("Dark"), Arg.Is(2)).Returns(true); + + await That(dispenser.Dispense("Dark", 2)).IsTrue(); + await That(dispenser.Dispense("Dark", 3)).IsFalse(); + } + + [Fact] + public async Task PlainValue_isUsedAsExactMatch() + { + IChocolateDispenser dispenser = Substitute.For(); + dispenser.Dispense("Milk", 3).Returns(true); + + await That(dispenser.Dispense("Milk", 3)).IsTrue(); + await That(dispenser.Dispense("Milk", 4)).IsFalse(); + } +} diff --git a/Tests/Mockolate.Migration.NSubstitutePlayground/CreationTests.cs b/Tests/Mockolate.Migration.NSubstitutePlayground/CreationTests.cs new file mode 100644 index 0000000..91c8128 --- /dev/null +++ b/Tests/Mockolate.Migration.NSubstitutePlayground/CreationTests.cs @@ -0,0 +1,60 @@ +using Mockolate.Migration.NSubstitutePlayground.Domain; +using NSubstitute; + +namespace Mockolate.Migration.NSubstitutePlayground; + +/// Substitute creation patterns. +public class CreationTests +{ + [Fact] + public async Task SubstituteFor_classWithCtorArgs_constructsWithThoseArgs() + { + // ChocolateRecipe has a parameterless ctor — pass empty args anyway to exercise the overload. + ChocolateRecipe recipe = Substitute.For(); + recipe.Name.Returns("Pralines"); + + await That(recipe.Name).IsEqualTo("Pralines"); + } + + [Fact] + public async Task SubstituteFor_multipleInterfaces_implementsBoth() + { + IChocolateDispenser dispenser = Substitute.For(); + + // The same proxy implements both, so a cast yields the secondary face. + IChocolateAuditor auditor = (IChocolateAuditor)dispenser; + auditor.RecordSale("Dark", 2, 3.0m); + + await That(auditor.AuditCount).IsEqualTo(0); + } + + [Fact] + public async Task SubstituteFor_singleInterface_createsLooseSubstitute() + { + IChocolateDispenser dispenser = Substitute.For(); + + // Loose substitute returns default (false) for unconfigured calls. + await That(dispenser.Dispense("Dark", 1)).IsFalse(); + } + + [Fact] + public async Task SubstituteForPartsOf_callsRealVirtualMembers() + { + ChocolateRecipe recipe = Substitute.ForPartsOf(); + + // Virtual members fall through to the real implementation unless configured. + await That(recipe.Validate()).IsTrue(); + await That(recipe.Name).IsEqualTo("Truffle"); + } + + [Fact] + public async Task SubstituteForTypeForwardingTo_forwardsCallsToConcreteImpl() + { + IChocolateAuditor auditor = Substitute.ForTypeForwardingTo(); + + auditor.RecordSale("Dark", 1, 1.5m); + + // The forwarded StaticAuditor increments its counter. + await That(auditor.AuditCount).IsEqualTo(1); + } +} diff --git a/Tests/Mockolate.Migration.NSubstitutePlayground/Domain/ChocolateBar.cs b/Tests/Mockolate.Migration.NSubstitutePlayground/Domain/ChocolateBar.cs new file mode 100644 index 0000000..1e69f5a --- /dev/null +++ b/Tests/Mockolate.Migration.NSubstitutePlayground/Domain/ChocolateBar.cs @@ -0,0 +1,4 @@ +namespace Mockolate.Migration.NSubstitutePlayground.Domain; + +/// A baked chocolate bar. +public sealed record ChocolateBar(string Type, int Cocoa, decimal Price); diff --git a/Tests/Mockolate.Migration.NSubstitutePlayground/Domain/ChocolateDispensedDelegate.cs b/Tests/Mockolate.Migration.NSubstitutePlayground/Domain/ChocolateDispensedDelegate.cs new file mode 100644 index 0000000..b47d407 --- /dev/null +++ b/Tests/Mockolate.Migration.NSubstitutePlayground/Domain/ChocolateDispensedDelegate.cs @@ -0,0 +1,4 @@ +namespace Mockolate.Migration.NSubstitutePlayground.Domain; + +/// Custom event delegate carried by . +public delegate void ChocolateDispensedDelegate(string type, int amount); diff --git a/Tests/Mockolate.Migration.NSubstitutePlayground/Domain/ChocolateRecipe.cs b/Tests/Mockolate.Migration.NSubstitutePlayground/Domain/ChocolateRecipe.cs new file mode 100644 index 0000000..ea8e995 --- /dev/null +++ b/Tests/Mockolate.Migration.NSubstitutePlayground/Domain/ChocolateRecipe.cs @@ -0,0 +1,19 @@ +namespace Mockolate.Migration.NSubstitutePlayground.Domain; + +/// Concrete recipe — used for partial mocks (NSubstitute ForPartsOf). +public class ChocolateRecipe +{ + public virtual string Name { get; set; } = "Truffle"; + public virtual int CocoaPercent { get; set; } = 70; + + public virtual ChocolateBar Bake(int amount) => + new(Name, CocoaPercent, amount * 1.5m); + + public virtual bool Validate() => !string.IsNullOrEmpty(Name); + + public virtual void Reset() + { + Name = "Truffle"; + CocoaPercent = 70; + } +} diff --git a/Tests/Mockolate.Migration.NSubstitutePlayground/Domain/ChocolateShop.cs b/Tests/Mockolate.Migration.NSubstitutePlayground/Domain/ChocolateShop.cs new file mode 100644 index 0000000..3e9a3d7 --- /dev/null +++ b/Tests/Mockolate.Migration.NSubstitutePlayground/Domain/ChocolateShop.cs @@ -0,0 +1,52 @@ +namespace Mockolate.Migration.NSubstitutePlayground.Domain; + +/// The system-under-test that orchestrates the dependencies. +public sealed class ChocolateShop +{ + private readonly IChocolateAuditor? _auditor; + private readonly IChocolateDispenser _dispenser; + private readonly IChocolateFactory _factory; + + public ChocolateShop( + IChocolateDispenser dispenser, + IChocolateFactory factory, + IChocolateAuditor? auditor = null) + { + _dispenser = dispenser; + _factory = factory; + _auditor = auditor; + _dispenser.ChocolateDispensed += OnDispensed; + } + + public int TotalSold { get; private set; } + + public string DispenserName + { + get => _dispenser.Name; + set => _dispenser.Name = value; + } + + public bool Sell(string type, int amount, decimal pricePerUnit = 1.5m) + { + if (!_dispenser.Dispense(type, amount)) + { + return false; + } + + _auditor?.RecordSale(type, amount, amount * pricePerUnit); + return true; + } + + public Task SellAsync(string type, int amount) + => _dispenser.DispenseAsync(type, amount); + + public Task RestockAsync(string recipe, int cocoa) + => _factory.BakeAsync(recipe, cocoa); + + public int CheckStock(string type) => _dispenser[type]; + + public bool TryReserveStock(string type, out int reserved) + => _dispenser.TryReserve(type, out reserved); + + private void OnDispensed(string type, int amount) => TotalSold += amount; +} diff --git a/Tests/Mockolate.Migration.NSubstitutePlayground/Domain/IChocolateAuditor.cs b/Tests/Mockolate.Migration.NSubstitutePlayground/Domain/IChocolateAuditor.cs new file mode 100644 index 0000000..1c5b28f --- /dev/null +++ b/Tests/Mockolate.Migration.NSubstitutePlayground/Domain/IChocolateAuditor.cs @@ -0,0 +1,8 @@ +namespace Mockolate.Migration.NSubstitutePlayground.Domain; + +/// Used for multi-interface mocks (NSubstitute Substitute.For<T1,T2>()). +public interface IChocolateAuditor +{ + int AuditCount { get; } + void RecordSale(string type, int amount, decimal total); +} diff --git a/Tests/Mockolate.Migration.NSubstitutePlayground/Domain/IChocolateDispenser.cs b/Tests/Mockolate.Migration.NSubstitutePlayground/Domain/IChocolateDispenser.cs new file mode 100644 index 0000000..65c4e3f --- /dev/null +++ b/Tests/Mockolate.Migration.NSubstitutePlayground/Domain/IChocolateDispenser.cs @@ -0,0 +1,30 @@ +namespace Mockolate.Migration.NSubstitutePlayground.Domain; + +/// +/// Surface intended to exercise as much of Moq/NSubstitute as possible: +/// indexer, properties (read/write), method overloads, async, ref, out, custom event, standard event. +/// +public interface IChocolateDispenser +{ + int this[string type] { get; set; } + int TotalDispensed { get; set; } + string Name { get; set; } + + bool Dispense(string type, int amount); + bool Dispense(string type); + Task DispenseAsync(string type, int amount); + + /// Tries to reserve some stock for the given type, returning the reserved amount. + bool TryReserve(string type, out int reserved); + + /// Refills stock; the caller passes a desired amount and gets the actual amount back via ref. + bool Refill(string type, ref int amount); + + int CountByType(string type); + + /// Void method — used to exercise NSubstitute When/Do. + void Notify(string type, int amount); + + event ChocolateDispensedDelegate ChocolateDispensed; + event EventHandler StockLow; +} diff --git a/Tests/Mockolate.Migration.NSubstitutePlayground/Domain/IChocolateFactory.cs b/Tests/Mockolate.Migration.NSubstitutePlayground/Domain/IChocolateFactory.cs new file mode 100644 index 0000000..d4daf83 --- /dev/null +++ b/Tests/Mockolate.Migration.NSubstitutePlayground/Domain/IChocolateFactory.cs @@ -0,0 +1,10 @@ +namespace Mockolate.Migration.NSubstitutePlayground.Domain; + +/// Used to exercise async, generics on parameters, collection parameters. +public interface IChocolateFactory +{ + int Capacity { get; } + Task BakeAsync(string recipe, int cocoa); + Task> BatchBakeAsync(IEnumerable recipes); + bool RegisterRecipe(string name); +} diff --git a/Tests/Mockolate.Migration.NSubstitutePlayground/Domain/InvalidChocolateException.cs b/Tests/Mockolate.Migration.NSubstitutePlayground/Domain/InvalidChocolateException.cs new file mode 100644 index 0000000..88d1284 --- /dev/null +++ b/Tests/Mockolate.Migration.NSubstitutePlayground/Domain/InvalidChocolateException.cs @@ -0,0 +1,8 @@ +namespace Mockolate.Migration.NSubstitutePlayground.Domain; + +/// Domain exception thrown for invalid chocolate operations. +public class InvalidChocolateException : Exception +{ + public InvalidChocolateException() { } + public InvalidChocolateException(string message) : base(message) { } +} diff --git a/Tests/Mockolate.Migration.NSubstitutePlayground/Domain/StaticAuditor.cs b/Tests/Mockolate.Migration.NSubstitutePlayground/Domain/StaticAuditor.cs new file mode 100644 index 0000000..118eccc --- /dev/null +++ b/Tests/Mockolate.Migration.NSubstitutePlayground/Domain/StaticAuditor.cs @@ -0,0 +1,9 @@ +namespace Mockolate.Migration.NSubstitutePlayground.Domain; + +/// Concrete forwarding target — used for Substitute.ForTypeForwardingTo. +public class StaticAuditor : IChocolateAuditor +{ + public int AuditCount { get; private set; } + + public void RecordSale(string type, int amount, decimal total) => AuditCount++; +} diff --git a/Tests/Mockolate.Migration.NSubstitutePlayground/EventTests.cs b/Tests/Mockolate.Migration.NSubstitutePlayground/EventTests.cs new file mode 100644 index 0000000..6caf335 --- /dev/null +++ b/Tests/Mockolate.Migration.NSubstitutePlayground/EventTests.cs @@ -0,0 +1,72 @@ +using Mockolate.Migration.NSubstitutePlayground.Domain; +using NSubstitute; + +namespace Mockolate.Migration.NSubstitutePlayground; + +/// Event raising via the Raise helper. +public class EventTests +{ + [Fact] + public async Task Raise_customDelegate_invokesSubscribedHandler() + { + IChocolateDispenser dispenser = Substitute.For(); + string? observedType = null; + int observedAmount = 0; + dispenser.ChocolateDispensed += (t, a) => + { + observedType = t; + observedAmount = a; + }; + + dispenser.ChocolateDispensed += Raise.Event("Dark", 5); + + await That(observedType).IsEqualTo("Dark"); + await That(observedAmount).IsEqualTo(5); + } + + [Fact] + public async Task Raise_eventHandlerWithArgsOnly_passesNullSender() + { + IChocolateDispenser dispenser = Substitute.For(); + int? observedLow = null; + dispenser.StockLow += (_, low) => observedLow = low; + + dispenser.StockLow += Raise.Event>(null, 7); + + await That(observedLow).IsEqualTo(7); + } + + [Fact] + public async Task Raise_eventHandlerWithSenderAndArgs_passesBoth() + { + IChocolateDispenser dispenser = Substitute.For(); + object? observedSender = null; + int? observedLow = null; + dispenser.StockLow += (s, low) => + { + observedSender = s; + observedLow = low; + }; + + dispenser.StockLow += Raise.Event>(dispenser, 2); + + await That(observedSender).IsSameAs(dispenser); + await That(observedLow).IsEqualTo(2); + } + + [Fact] + public async Task ShopSubscribesOnConstruction_andTracksDispensedAmounts() + { + IChocolateDispenser dispenser = Substitute.For(); + IChocolateFactory factory = Substitute.For(); + dispenser.Dispense(Arg.Any(), Arg.Any()).Returns(true); + ChocolateShop shop = new(dispenser, factory); + + shop.Sell("Dark", 2); + dispenser.ChocolateDispensed += Raise.Event("Dark", 2); + shop.Sell("Milk", 5); + dispenser.ChocolateDispensed += Raise.Event("Milk", 5); + + await That(shop.TotalSold).IsEqualTo(7); + } +} diff --git a/Tests/Mockolate.Migration.NSubstitutePlayground/Mockolate.Migration.NSubstitutePlayground.csproj b/Tests/Mockolate.Migration.NSubstitutePlayground/Mockolate.Migration.NSubstitutePlayground.csproj new file mode 100644 index 0000000..1ec0960 --- /dev/null +++ b/Tests/Mockolate.Migration.NSubstitutePlayground/Mockolate.Migration.NSubstitutePlayground.csproj @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + diff --git a/Tests/Mockolate.Migration.NSubstitutePlayground/SetupTests.cs b/Tests/Mockolate.Migration.NSubstitutePlayground/SetupTests.cs new file mode 100644 index 0000000..1e6b7d6 --- /dev/null +++ b/Tests/Mockolate.Migration.NSubstitutePlayground/SetupTests.cs @@ -0,0 +1,149 @@ +using Mockolate.Migration.NSubstitutePlayground.Domain; +using NSubstitute; +using NSubstitute.ExceptionExtensions; + +namespace Mockolate.Migration.NSubstitutePlayground; + +/// Setup patterns: Returns / Throws / sequence Returns / ReturnsForAnyArgs / ThrowsForAnyArgs / AndDoes. +public class SetupTests +{ + [Fact] + public async Task NestedSubstituteSetup_chainedAcrossDependentSubstitutes() + { + // NSubstitute auto-creates child substitutes for properties returning interfaces / classes. + // Migration emits a TODO comment because Mockolate needs the child registered explicitly. + INested outer = Substitute.For(); + outer.Inner.Inner.Name.Returns("deep"); + + await That(outer.Inner.Inner.Name).IsEqualTo("deep"); + } + + [Fact] + public async Task PropertyAssignment_returnsLastSetValue() + { + IChocolateDispenser dispenser = Substitute.For(); + + dispenser.Name = "ChocoMatic"; + + await That(dispenser.Name).IsEqualTo("ChocoMatic"); + } + + [Fact] + public async Task PropertyReturns_setsConfiguredValue() + { + IChocolateDispenser dispenser = Substitute.For(); + dispenser.Name.Returns("Choco-9000"); + + await That(dispenser.Name).IsEqualTo("Choco-9000"); + } + + [Fact] + public async Task Returns_directValue_dispensesAndShopRecordsTotal() + { + IChocolateDispenser dispenser = Substitute.For(); + IChocolateFactory factory = Substitute.For(); + dispenser.Dispense("Dark", 3).Returns(true); + ChocolateShop shop = new(dispenser, factory); + + bool sold = shop.Sell("Dark", 3); + dispenser.ChocolateDispensed += Raise.Event("Dark", 3); + + await That(sold).IsTrue(); + await That(shop.TotalSold).IsEqualTo(3); + } + + [Fact] + public async Task Returns_factory_evaluatesPerCall() + { + IChocolateDispenser dispenser = Substitute.For(); + int counter = 1; + dispenser.CountByType(Arg.Any()).Returns(_ => counter); + + int first = dispenser.CountByType("Dark"); + counter = 5; + int second = dispenser.CountByType("Dark"); + + await That(first).IsEqualTo(1); + await That(second).IsEqualTo(5); + } + + [Fact] + public async Task Returns_sequenceOfValues_returnsThemInOrder() + { + IChocolateDispenser dispenser = Substitute.For(); + dispenser.CountByType("Dark").Returns(1, 2, 3); + + await That(dispenser.CountByType("Dark")).IsEqualTo(1); + await That(dispenser.CountByType("Dark")).IsEqualTo(2); + await That(dispenser.CountByType("Dark")).IsEqualTo(3); + } + + [Fact] + public async Task ReturnsAndDoes_combinesReturnAndSideEffect() + { + IChocolateDispenser dispenser = Substitute.For(); + int sideEffect = 0; + dispenser.Dispense("Dark", 1).Returns(true).AndDoes(_ => sideEffect++); + + _ = dispenser.Dispense("Dark", 1); + _ = dispenser.Dispense("Dark", 1); + + await That(sideEffect).IsEqualTo(2); + } + + [Fact] + public async Task ReturnsAsync_completesWithValue() + { + IChocolateDispenser dispenser = Substitute.For(); + dispenser.DispenseAsync("Dark", 1).Returns(Task.FromResult(true)); + + await That(await dispenser.DispenseAsync("Dark", 1)).IsTrue(); + } + + [Fact] + public async Task ReturnsForAnyArgs_ignoresArgumentsAtSetup() + { + IChocolateDispenser dispenser = Substitute.For(); + dispenser.Dispense("Dark", 1).ReturnsForAnyArgs(true); + + await That(dispenser.Dispense("Milk", 99)).IsTrue(); + await That(dispenser.Dispense("White", 1)).IsTrue(); + } + + [Fact] + public async Task Throws_genericException_isRaisedOnInvocation() + { + IChocolateDispenser dispenser = Substitute.For(); + dispenser.Dispense("reset", 0).Throws(); + + await That(() => dispenser.Dispense("reset", 0)) + .Throws(); + } + + [Fact] + public async Task Throws_specificInstance_isRaisedOnInvocation() + { + IChocolateDispenser dispenser = Substitute.For(); + dispenser.Dispense("", 0).Throws(new InvalidChocolateException("empty type")); + + await That(() => dispenser.Dispense("", 0)) + .Throws() + .WithMessage("empty type"); + } + + [Fact] + public async Task ThrowsForAnyArgs_appliesEveryCall() + { + IChocolateDispenser dispenser = Substitute.For(); + dispenser.Dispense("ignored", 0).ThrowsForAnyArgs(); + + await That(() => dispenser.Dispense("Dark", 1)) + .Throws(); + } + + public interface INested + { + INested Inner { get; } + string Name { get; } + } +} diff --git a/Tests/Mockolate.Migration.NSubstitutePlayground/UnsupportedFeatureTests.cs b/Tests/Mockolate.Migration.NSubstitutePlayground/UnsupportedFeatureTests.cs new file mode 100644 index 0000000..64fde60 --- /dev/null +++ b/Tests/Mockolate.Migration.NSubstitutePlayground/UnsupportedFeatureTests.cs @@ -0,0 +1,156 @@ +using Mockolate.Migration.NSubstitutePlayground.Domain; +using NSubstitute; +using NSubstitute.ClearExtensions; +using NSubstitute.ExceptionExtensions; +using NSubstitute.Extensions; + +namespace Mockolate.Migration.NSubstitutePlayground; + +/// +/// NSubstitute features the migration does NOT yet handle. They still pass against NSubstitute; +/// run the migration to see what is/isn't transformed and where manual rewrites are needed. +/// +public class UnsupportedFeatureTests +{ + // NOT YET MIGRATED: Arg.Do(action) — capture each invocation's argument + [Fact] + public async Task ArgDo_capturesEveryInvocationArgument() + { + IChocolateDispenser dispenser = Substitute.For(); + List amounts = new(); + dispenser.Dispense("Dark", Arg.Do(a => amounts.Add(a))).Returns(true); + + _ = dispenser.Dispense("Dark", 2); + _ = dispenser.Dispense("Dark", 5); + + await That(amounts).IsEqualTo([2, 5,]).InAnyOrder(); + } + + // NOT YET MIGRATED: Arg.Invoke<...> to call back into a delegate parameter + [Fact] + public async Task ArgInvoke_callsThroughDelegateParameter() + { + // Use a mini-substitute with an Action parameter to exercise Arg.Invoke. + IInvokeTarget target = Substitute.For(); + int called = 0; + target.Run(Arg.Invoke()); // when target.Run(action) is called, NSubstitute invokes action() + + target.Run(() => called++); + + await That(called).IsEqualTo(1); + } + + // NOT YET MIGRATED: CallInfo argument access via x => x.Arg() / x.ArgAt(index) + [Fact] + public async Task CallInfo_argumentAccessInReturns() + { + IChocolateDispenser dispenser = Substitute.For(); + dispenser.Dispense(Arg.Any(), Arg.Any()) + .Returns(call => + { + string type = call.Arg(); + int amount = call.ArgAt(1); + return type == "Dark" && amount > 0; + }); + + await That(dispenser.Dispense("Dark", 4)).IsTrue(); + await That(dispenser.Dispense("Milk", 4)).IsFalse(); + await That(dispenser.Dispense("Dark", 0)).IsFalse(); + } + + // NOT YET MIGRATED: ClearSubstitute — removes setups and call history together + [Fact] + public async Task ClearSubstitute_resetsBothCallsAndSetups() + { + IChocolateDispenser dispenser = Substitute.For(); + dispenser.Dispense("Dark", 1).Returns(true); + _ = dispenser.Dispense("Dark", 1); + + dispenser.ClearSubstitute(); + + // After clear: no calls remembered, and the previous Returns(true) is gone. + dispenser.DidNotReceive().Dispense("Dark", 1); + await That(dispenser.Dispense("Dark", 1)).IsFalse(); + } + + // NOT YET MIGRATED: Configure() — re-enter setup mode after calls have been recorded + [Fact] + public async Task Configure_changesReturnAfterUse() + { + IChocolateDispenser dispenser = Substitute.For(); + dispenser.Dispense("Dark", 1).Returns(false); + + _ = dispenser.Dispense("Dark", 1); + dispenser.Configure().Dispense("Dark", 1).Returns(true); + + await That(dispenser.Dispense("Dark", 1)).IsTrue(); + } + + // NOT YET MIGRATED: Out parameters via Arg.Any() with discard pattern + [Fact] + public async Task OutParameter_isSetByCallback() + { + IChocolateDispenser dispenser = Substitute.For(); + dispenser + .TryReserve(Arg.Any(), out Arg.Any()) + .Returns(call => + { + call[1] = 7; + return true; + }); + + bool ok = dispenser.TryReserve("Dark", out int reserved); + + await That(ok).IsTrue(); + await That(reserved).IsEqualTo(7); + } + + // NOT YET MIGRATED: Received.InOrder for ordered cross-substitute verification + [Fact] + public async Task ReceivedInOrder_ordersAcrossSubstitutes() + { + IChocolateDispenser dispenser = Substitute.For(); + IChocolateAuditor auditor = Substitute.For(); + dispenser.Dispense(Arg.Any(), Arg.Any()).Returns(true); + + _ = dispenser.Dispense("Dark", 1); + auditor.RecordSale("Dark", 1, 1.5m); + + Received.InOrder(() => + { + dispenser.Dispense("Dark", 1); + auditor.RecordSale("Dark", 1, 1.5m); + }); + } + + // NOT YET MIGRATED: ReturnsNull / ReturnsNullForAnyArgs (extension methods) + [Fact] + public async Task ReturnsNull_isShortcutForReturnsDefault() + { + IChocolateFactory factory = Substitute.For(); + factory.BatchBakeAsync(Arg.Any>()).Returns(Task.FromResult>(null!)); + + IReadOnlyList result = await factory.BatchBakeAsync(["Dark", "Milk",]); + await That(result).IsNull(); + } + + // NOT YET MIGRATED: ThrowsAsync extension on async setups + [Fact] + public async Task ThrowsAsync_appliesToAwaitedTask() + { + IChocolateDispenser dispenser = Substitute.For(); + dispenser.DispenseAsync("Dark", 1).ThrowsAsync(new TimeoutException()); + + Task Act() + { + return dispenser.DispenseAsync("Dark", 1); + } + + await That((Func>)Act).Throws(); + } + + public interface IInvokeTarget + { + void Run(Action action); + } +} diff --git a/Tests/Mockolate.Migration.NSubstitutePlayground/Usings.cs b/Tests/Mockolate.Migration.NSubstitutePlayground/Usings.cs new file mode 100644 index 0000000..88f147d --- /dev/null +++ b/Tests/Mockolate.Migration.NSubstitutePlayground/Usings.cs @@ -0,0 +1,7 @@ +global using System; +global using System.Collections.Generic; +global using System.Threading; +global using System.Threading.Tasks; +global using Xunit; +global using aweXpect; +global using static aweXpect.Expect; diff --git a/Tests/Mockolate.Migration.NSubstitutePlayground/VerifyTests.cs b/Tests/Mockolate.Migration.NSubstitutePlayground/VerifyTests.cs new file mode 100644 index 0000000..fadce84 --- /dev/null +++ b/Tests/Mockolate.Migration.NSubstitutePlayground/VerifyTests.cs @@ -0,0 +1,103 @@ +using Mockolate.Migration.NSubstitutePlayground.Domain; +using NSubstitute; + +namespace Mockolate.Migration.NSubstitutePlayground; + +/// Verification patterns: Received / DidNotReceive / Received(n) / WithAnyArgs. +public class VerifyTests +{ + [Fact] + public async Task ClearReceivedCalls_resetsHistory() + { + IChocolateDispenser dispenser = Substitute.For(); + dispenser.Dispense(Arg.Any(), Arg.Any()).Returns(true); + + dispenser.Dispense("Dark", 1); + dispenser.ClearReceivedCalls(); + dispenser.Dispense("Dark", 2); + + dispenser.Received(1).Dispense(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task DidNotReceive_method_wasNeverCalled() + { + IChocolateDispenser dispenser = Substitute.For(); + IChocolateFactory factory = Substitute.For(); + dispenser.Dispense("Dark", 1).Returns(false); + ChocolateShop shop = new(dispenser, factory); + + shop.Sell("Dark", 1); + + dispenser.DidNotReceive().Dispense("Milk", Arg.Any()); + } + + [Fact] + public async Task DidNotReceiveWithAnyArgs_matchesNoInvocation() + { + IChocolateDispenser dispenser = Substitute.For(); + + dispenser.DidNotReceiveWithAnyArgs().Dispense(default!, default); + } + + [Fact] + public async Task Received_method_wasCalledOnce() + { + IChocolateDispenser dispenser = Substitute.For(); + IChocolateFactory factory = Substitute.For(); + dispenser.Dispense("Dark", 2).Returns(true); + ChocolateShop shop = new(dispenser, factory); + + shop.Sell("Dark", 2); + + dispenser.Received().Dispense("Dark", 2); + } + + [Fact] + public async Task ReceivedExactCount_isHonored() + { + IChocolateDispenser dispenser = Substitute.For(); + dispenser.Dispense(Arg.Any(), Arg.Any()).Returns(true); + + dispenser.Dispense("Dark", 1); + dispenser.Dispense("Dark", 2); + dispenser.Dispense("Dark", 3); + + dispenser.Received(3).Dispense("Dark", Arg.Any()); + } + + [Fact] + public async Task ReceivedProperty_was_read() + { + IChocolateDispenser dispenser = Substitute.For(); + dispenser.Name.Returns("Choc-Box"); + + _ = dispenser.Name; + _ = dispenser.Name; + + _ = dispenser.Received(2).Name; + } + + [Fact] + public async Task ReceivedProperty_was_set() + { + IChocolateDispenser dispenser = Substitute.For(); + + dispenser.Name = "Choco-2025"; + + dispenser.Received().Name = "Choco-2025"; + } + + [Fact] + public async Task ReceivedWithAnyArgs_matchesAllInvocations() + { + IChocolateDispenser dispenser = Substitute.For(); + dispenser.Dispense(Arg.Any(), Arg.Any()).Returns(true); + + dispenser.Dispense("Dark", 1); + dispenser.Dispense("Milk", 5); + + // Arguments below are placeholders; ReceivedWithAnyArgs ignores them. + dispenser.ReceivedWithAnyArgs(2).Dispense(default!, default); + } +} diff --git a/Tests/Mockolate.Migration.NSubstitutePlayground/WhenDoTests.cs b/Tests/Mockolate.Migration.NSubstitutePlayground/WhenDoTests.cs new file mode 100644 index 0000000..e854892 --- /dev/null +++ b/Tests/Mockolate.Migration.NSubstitutePlayground/WhenDoTests.cs @@ -0,0 +1,49 @@ +using Mockolate.Migration.NSubstitutePlayground.Domain; +using NSubstitute; + +namespace Mockolate.Migration.NSubstitutePlayground; + +/// When/Do — used to attach side effects to void members and to substitute for partial mocks. +public class WhenDoTests +{ + [Fact] + public async Task When_DoNotCallBase_disablesPartialBaseInvocation() + { + // ForPartsOf normally calls real virtual methods. DoNotCallBase suppresses that. + ChocolateRecipe recipe = Substitute.ForPartsOf(); + recipe.When(r => r.Reset()) + .DoNotCallBase(); + + recipe.Name = "Praline"; + recipe.Reset(); // would normally reset Name to "Truffle" + + await That(recipe.Name).IsEqualTo("Praline"); + } + + [Fact] + public async Task When_voidMethod_runsCallback() + { + IChocolateDispenser dispenser = Substitute.For(); + string? captured = null; + dispenser.When(d => d.Notify("Dark", 1)) + .Do(_ => captured = "ran"); + + dispenser.Notify("Dark", 1); + + await That(captured).IsEqualTo("ran"); + } + + [Fact] + public async Task WhenForAnyArgs_voidMethod_runsCallbackForAnyInvocation() + { + IChocolateDispenser dispenser = Substitute.For(); + int count = 0; + dispenser.WhenForAnyArgs(d => d.Notify(default!, default)) + .Do(_ => count++); + + dispenser.Notify("Dark", 1); + dispenser.Notify("Milk", 9); + + await That(count).IsEqualTo(2); + } +} From b258e3ee82fa85133eaf196998ed5dd11b58193c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Fri, 1 May 2026 18:32:36 +0200 Subject: [PATCH 2/4] Ignore sonar issues --- Pipeline/Build.CodeAnalysis.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Pipeline/Build.CodeAnalysis.cs b/Pipeline/Build.CodeAnalysis.cs index 9093c26..bfc77c7 100644 --- a/Pipeline/Build.CodeAnalysis.cs +++ b/Pipeline/Build.CodeAnalysis.cs @@ -22,6 +22,11 @@ partial class Build .AddOpenCoverPaths(TestResultsDirectory / "reports" / "OpenCover.xml") .SetPullRequestOrBranchName(GitHubActions, GitVersion) .SetVersion(GitVersion.SemVer) + .AddAdditionalParameter("sonar.issue.ignore.multicriteria", "e1,e2") + .AddAdditionalParameter("sonar.issue.ignore.multicriteria.e1.ruleKey", "external_roslyn:MockolateM001") + .AddAdditionalParameter("sonar.issue.ignore.multicriteria.e1.resourceKey", "**/Mockolate.Migration.MoqPlayground/**/*") + .AddAdditionalParameter("sonar.issue.ignore.multicriteria.e2.ruleKey", "external_roslyn:MockolateM002") + .AddAdditionalParameter("sonar.issue.ignore.multicriteria.e2.resourceKey", "**/Mockolate.Migration.NSubstitutePlayground/**/*") .SetToken(SonarToken)); }); From 0ffb21a16a4db9cf1ae74000fc2c466641433973 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Fri, 1 May 2026 18:44:56 +0200 Subject: [PATCH 3/4] Fix review issues --- Pipeline/Build.CodeAnalysis.cs | 8 +++----- .../SetupTests.cs | 2 +- .../UnsupportedFeatureTests.cs | 8 ++++---- .../VerifyTests.cs | 18 +++++++++--------- .../CreationTests.cs | 4 ++-- .../VerifyTests.cs | 16 ++++++++-------- 6 files changed, 27 insertions(+), 29 deletions(-) diff --git a/Pipeline/Build.CodeAnalysis.cs b/Pipeline/Build.CodeAnalysis.cs index bfc77c7..5e1c245 100644 --- a/Pipeline/Build.CodeAnalysis.cs +++ b/Pipeline/Build.CodeAnalysis.cs @@ -22,11 +22,9 @@ partial class Build .AddOpenCoverPaths(TestResultsDirectory / "reports" / "OpenCover.xml") .SetPullRequestOrBranchName(GitHubActions, GitVersion) .SetVersion(GitVersion.SemVer) - .AddAdditionalParameter("sonar.issue.ignore.multicriteria", "e1,e2") - .AddAdditionalParameter("sonar.issue.ignore.multicriteria.e1.ruleKey", "external_roslyn:MockolateM001") - .AddAdditionalParameter("sonar.issue.ignore.multicriteria.e1.resourceKey", "**/Mockolate.Migration.MoqPlayground/**/*") - .AddAdditionalParameter("sonar.issue.ignore.multicriteria.e2.ruleKey", "external_roslyn:MockolateM002") - .AddAdditionalParameter("sonar.issue.ignore.multicriteria.e2.resourceKey", "**/Mockolate.Migration.NSubstitutePlayground/**/*") + .AddSourceExclusions( + "**/Mockolate.Migration.MoqPlayground/**/*", + "**/Mockolate.Migration.NSubstitutePlayground/**/*") .SetToken(SonarToken)); }); diff --git a/Tests/Mockolate.Migration.MoqPlayground/SetupTests.cs b/Tests/Mockolate.Migration.MoqPlayground/SetupTests.cs index 70dcf87..2a3c238 100644 --- a/Tests/Mockolate.Migration.MoqPlayground/SetupTests.cs +++ b/Tests/Mockolate.Migration.MoqPlayground/SetupTests.cs @@ -43,7 +43,7 @@ public async Task NestedMockSetup_recursive_chainsThroughChildMock() } [Fact] - public async Task Returns_argumentBased_lowercasesTheRecipe() + public async Task Returns_argumentBased_evaluatesFromArgument() { Mock dispenser = new(); dispenser.Setup(d => d.Dispense(It.IsAny())).Returns((string s) => s.Length > 0); diff --git a/Tests/Mockolate.Migration.MoqPlayground/UnsupportedFeatureTests.cs b/Tests/Mockolate.Migration.MoqPlayground/UnsupportedFeatureTests.cs index 07b7dd4..83590fa 100644 --- a/Tests/Mockolate.Migration.MoqPlayground/UnsupportedFeatureTests.cs +++ b/Tests/Mockolate.Migration.MoqPlayground/UnsupportedFeatureTests.cs @@ -37,11 +37,11 @@ public async Task DefaultValueProviderMock_returnsAutoMockedReferenceTypes() DefaultValue = DefaultValue.Mock, }; - // Reading a Task-returning member without a setup yields a Task that completes, - // rather than a NullReferenceException. - Task result = Task.FromResult(factory.Object.RegisterRecipe("auto")); + // Calling an unconfigured Task-returning member yields a Task that completes + // (with an auto-mocked default), rather than null/NullReferenceException. + IReadOnlyList bars = await factory.Object.BatchBakeAsync(["auto"]); - await That(await result).IsFalse(); + await That(bars).IsNotNull(); } // NOT YET MIGRATED: It.IsNotIn / It.IsIn (set membership matchers) diff --git a/Tests/Mockolate.Migration.MoqPlayground/VerifyTests.cs b/Tests/Mockolate.Migration.MoqPlayground/VerifyTests.cs index 02c8a63..0c46dce 100644 --- a/Tests/Mockolate.Migration.MoqPlayground/VerifyTests.cs +++ b/Tests/Mockolate.Migration.MoqPlayground/VerifyTests.cs @@ -10,7 +10,7 @@ namespace Mockolate.Migration.MoqPlayground; public class VerifyTests { [Fact] - public async Task Verify_method_atLeast() + public void Verify_method_atLeast() { Mock dispenser = new(); dispenser.Setup(d => d.Dispense(It.IsAny(), It.IsAny())).Returns(true); @@ -23,7 +23,7 @@ public async Task Verify_method_atLeast() } [Fact] - public async Task Verify_method_atMost() + public void Verify_method_atMost() { Mock dispenser = new(); dispenser.Setup(d => d.Dispense(It.IsAny(), It.IsAny())).Returns(true); @@ -35,7 +35,7 @@ public async Task Verify_method_atMost() } [Fact] - public async Task Verify_method_between() + public void Verify_method_between() { Mock dispenser = new(); dispenser.Setup(d => d.Dispense(It.IsAny(), It.IsAny())).Returns(true); @@ -49,7 +49,7 @@ public async Task Verify_method_between() } [Fact] - public async Task Verify_method_exactCount() + public void Verify_method_exactCount() { Mock dispenser = new(); dispenser.Setup(d => d.Dispense(It.IsAny(), It.IsAny())).Returns(true); @@ -62,7 +62,7 @@ public async Task Verify_method_exactCount() } [Fact] - public async Task Verify_method_wasCalledOnce() + public void Verify_method_wasCalledOnce() { Mock dispenser = new(); Mock factory = new(); @@ -75,7 +75,7 @@ public async Task Verify_method_wasCalledOnce() } [Fact] - public async Task Verify_method_wasNeverCalled() + public void Verify_method_wasNeverCalled() { Mock dispenser = new(); Mock factory = new(); @@ -88,7 +88,7 @@ public async Task Verify_method_wasNeverCalled() } [Fact] - public async Task VerifyGet_property_wasRead() + public void VerifyGet_property_wasRead() { Mock dispenser = new(); dispenser.SetupGet(d => d.Name).Returns("Choc-Box"); @@ -100,7 +100,7 @@ public async Task VerifyGet_property_wasRead() } [Fact] - public async Task VerifySet_anyValue_isMatchedWithItIsAny() + public void VerifySet_anyValue_isMatchedWithItIsAny() { Mock dispenser = new(); dispenser.SetupProperty(d => d.TotalDispensed); @@ -112,7 +112,7 @@ public async Task VerifySet_anyValue_isMatchedWithItIsAny() } [Fact] - public async Task VerifySet_property_wasAssigned() + public void VerifySet_property_wasAssigned() { Mock dispenser = new(); dispenser.SetupProperty(d => d.Name); diff --git a/Tests/Mockolate.Migration.NSubstitutePlayground/CreationTests.cs b/Tests/Mockolate.Migration.NSubstitutePlayground/CreationTests.cs index 91c8128..ed3aa17 100644 --- a/Tests/Mockolate.Migration.NSubstitutePlayground/CreationTests.cs +++ b/Tests/Mockolate.Migration.NSubstitutePlayground/CreationTests.cs @@ -7,9 +7,9 @@ namespace Mockolate.Migration.NSubstitutePlayground; public class CreationTests { [Fact] - public async Task SubstituteFor_classWithCtorArgs_constructsWithThoseArgs() + public async Task SubstituteFor_class_canConfigureVirtualMemberReturnValue() { - // ChocolateRecipe has a parameterless ctor — pass empty args anyway to exercise the overload. + // Configure a class substitute's virtual member to return a specific value. ChocolateRecipe recipe = Substitute.For(); recipe.Name.Returns("Pralines"); diff --git a/Tests/Mockolate.Migration.NSubstitutePlayground/VerifyTests.cs b/Tests/Mockolate.Migration.NSubstitutePlayground/VerifyTests.cs index fadce84..857caf0 100644 --- a/Tests/Mockolate.Migration.NSubstitutePlayground/VerifyTests.cs +++ b/Tests/Mockolate.Migration.NSubstitutePlayground/VerifyTests.cs @@ -7,7 +7,7 @@ namespace Mockolate.Migration.NSubstitutePlayground; public class VerifyTests { [Fact] - public async Task ClearReceivedCalls_resetsHistory() + public void ClearReceivedCalls_resetsHistory() { IChocolateDispenser dispenser = Substitute.For(); dispenser.Dispense(Arg.Any(), Arg.Any()).Returns(true); @@ -20,7 +20,7 @@ public async Task ClearReceivedCalls_resetsHistory() } [Fact] - public async Task DidNotReceive_method_wasNeverCalled() + public void DidNotReceive_method_wasNeverCalled() { IChocolateDispenser dispenser = Substitute.For(); IChocolateFactory factory = Substitute.For(); @@ -33,7 +33,7 @@ public async Task DidNotReceive_method_wasNeverCalled() } [Fact] - public async Task DidNotReceiveWithAnyArgs_matchesNoInvocation() + public void DidNotReceiveWithAnyArgs_matchesNoInvocation() { IChocolateDispenser dispenser = Substitute.For(); @@ -41,7 +41,7 @@ public async Task DidNotReceiveWithAnyArgs_matchesNoInvocation() } [Fact] - public async Task Received_method_wasCalledOnce() + public void Received_method_wasCalledOnce() { IChocolateDispenser dispenser = Substitute.For(); IChocolateFactory factory = Substitute.For(); @@ -54,7 +54,7 @@ public async Task Received_method_wasCalledOnce() } [Fact] - public async Task ReceivedExactCount_isHonored() + public void ReceivedExactCount_isHonored() { IChocolateDispenser dispenser = Substitute.For(); dispenser.Dispense(Arg.Any(), Arg.Any()).Returns(true); @@ -67,7 +67,7 @@ public async Task ReceivedExactCount_isHonored() } [Fact] - public async Task ReceivedProperty_was_read() + public void ReceivedProperty_was_read() { IChocolateDispenser dispenser = Substitute.For(); dispenser.Name.Returns("Choc-Box"); @@ -79,7 +79,7 @@ public async Task ReceivedProperty_was_read() } [Fact] - public async Task ReceivedProperty_was_set() + public void ReceivedProperty_was_set() { IChocolateDispenser dispenser = Substitute.For(); @@ -89,7 +89,7 @@ public async Task ReceivedProperty_was_set() } [Fact] - public async Task ReceivedWithAnyArgs_matchesAllInvocations() + public void ReceivedWithAnyArgs_matchesAllInvocations() { IChocolateDispenser dispenser = Substitute.For(); dispenser.Dispense(Arg.Any(), Arg.Any()).Returns(true); From d29aebbe357f8d2950591519947856ff647eb1ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Fri, 1 May 2026 18:53:16 +0200 Subject: [PATCH 4/4] Revert sonar exclusions (done in web UI) --- Pipeline/Build.CodeAnalysis.cs | 3 --- 1 file changed, 3 deletions(-) diff --git a/Pipeline/Build.CodeAnalysis.cs b/Pipeline/Build.CodeAnalysis.cs index 5e1c245..9093c26 100644 --- a/Pipeline/Build.CodeAnalysis.cs +++ b/Pipeline/Build.CodeAnalysis.cs @@ -22,9 +22,6 @@ partial class Build .AddOpenCoverPaths(TestResultsDirectory / "reports" / "OpenCover.xml") .SetPullRequestOrBranchName(GitHubActions, GitVersion) .SetVersion(GitVersion.SemVer) - .AddSourceExclusions( - "**/Mockolate.Migration.MoqPlayground/**/*", - "**/Mockolate.Migration.NSubstitutePlayground/**/*") .SetToken(SonarToken)); });