From e61b4d4c11b3421d29c2706922398d8938d1ec4f Mon Sep 17 00:00:00 2001 From: Oli Sturm Date: Fri, 24 Apr 2026 16:53:12 +0100 Subject: [PATCH] add fp3 tests --- csharp-fp3.Tests/AccountTests.cs | 73 ++++++ csharp-fp3.Tests/ResultTests.cs | 295 +++++++++++++++++++++++ csharp-fp3.Tests/WithdrawMoneyTests.cs | 110 +++++++++ csharp-fp3.Tests/csharp-fp3.Tests.csproj | 26 ++ csharp.sln | 15 ++ 5 files changed, 519 insertions(+) create mode 100644 csharp-fp3.Tests/AccountTests.cs create mode 100644 csharp-fp3.Tests/ResultTests.cs create mode 100644 csharp-fp3.Tests/WithdrawMoneyTests.cs create mode 100644 csharp-fp3.Tests/csharp-fp3.Tests.csproj diff --git a/csharp-fp3.Tests/AccountTests.cs b/csharp-fp3.Tests/AccountTests.cs new file mode 100644 index 0000000..dcc7632 --- /dev/null +++ b/csharp-fp3.Tests/AccountTests.cs @@ -0,0 +1,73 @@ +using CsharpFp3.Domain; + +namespace CsharpFp3.Tests; + +/// Tests covering domain-layer invariants on the Account aggregate directly, +/// independent of the application layer. +public class AccountTests +{ + [Fact] + public void Opening_an_account_with_a_negative_balance_returns_OpeningBalanceMustBeNonNegative() + { + var result = AccountDomain.Open(Guid.NewGuid(), new Money(-1m)); + + Assert.IsType(result.Error); + } + + [Fact] + public void Withdrawing_a_zero_amount_returns_AmountMustBePositive() + { + var account = AccountDomain.Open(Guid.NewGuid(), new Money(100m)).Value; + + var result = AccountDomain.Withdraw(account, new Money(0m)); + + Assert.IsType(result.Error); + } + + [Fact] + public void Withdrawing_a_negative_amount_returns_AmountMustBePositive() + { + var account = AccountDomain.Open(Guid.NewGuid(), new Money(100m)).Value; + + var result = AccountDomain.Withdraw(account, new Money(-10m)); + + Assert.IsType(result.Error); + } + + [Fact] + public void Withdrawing_more_than_the_balance_returns_InsufficientBalance() + { + var account = AccountDomain.Open(Guid.NewGuid(), new Money(100m)).Value; + + var result = AccountDomain.Withdraw(account, new Money(101m)); + + Assert.IsType(result.Error); + } + + [Fact] + public void InsufficientBalance_error_reports_the_current_balance_and_attempted_amount() + { + var account = AccountDomain.Open(Guid.NewGuid(), new Money(100m)).Value; + + var error = Assert.IsType( + AccountDomain.Withdraw(account, new Money(150m)).Error + ); + + Assert.Equal(100m, error.Balance.Amount); + Assert.Equal(150m, error.Amount.Amount); + } + + [Fact] + public void Successive_withdrawals_are_each_applied_to_the_running_balance() + { + var account = AccountDomain.Open(Guid.NewGuid(), new Money(300m)).Value; + + var result1 = AccountDomain.Withdraw(account, new Money(100m)); + var updatedAccount1 = result1.Value; + + var result2 = AccountDomain.Withdraw(updatedAccount1, new Money(100m)); + var updatedAccount2 = result2.Value; + + Assert.Equal(100m, updatedAccount2.Balance.Amount); + } +} diff --git a/csharp-fp3.Tests/ResultTests.cs b/csharp-fp3.Tests/ResultTests.cs new file mode 100644 index 0000000..ed5d6d3 --- /dev/null +++ b/csharp-fp3.Tests/ResultTests.cs @@ -0,0 +1,295 @@ +using CsharpFp3.Library; + +namespace CsharpFp3.Tests; + +/// Tests covering the Result type and its extension methods. +public class ResultTests +{ + // --- Ok / Fail construction --- + + [Fact] + public void Ok_is_success() + { + var result = Result.Ok(42); + + Assert.True(result.IsSuccess); + Assert.False(result.IsFailure); + } + + [Fact] + public void Fail_is_failure() + { + var result = Result.Fail("oops"); + + Assert.True(result.IsFailure); + Assert.False(result.IsSuccess); + } + + [Fact] + public void Ok_exposes_value() + { + var result = Result.Ok(42); + + Assert.Equal(42, result.Value); + } + + [Fact] + public void Fail_exposes_error() + { + var result = Result.Fail("oops"); + + Assert.Equal("oops", result.Error); + } + + [Fact] + public void Accessing_Value_on_a_failed_result_throws() + { + var result = Result.Fail("oops"); + + Assert.Throws(() => result.Value); + } + + [Fact] + public void Accessing_Error_on_a_successful_result_throws() + { + var result = Result.Ok(42); + + Assert.Throws(() => result.Error); + } + + // --- Match --- + + [Fact] + public void Match_calls_onSuccess_for_Ok() + { + var result = Result.Ok(10); + + var output = result.Match(v => $"value:{v}", e => $"error:{e}"); + + Assert.Equal("value:10", output); + } + + [Fact] + public void Match_calls_onFailure_for_Fail() + { + var result = Result.Fail("bad"); + + var output = result.Match(v => $"value:{v}", e => $"error:{e}"); + + Assert.Equal("error:bad", output); + } + + // --- Switch --- + + [Fact] + public void Switch_calls_onSuccess_for_Ok() + { + var result = Result.Ok(7); + var called = false; + + result.Switch(_ => called = true, _ => { }); + + Assert.True(called); + } + + [Fact] + public void Switch_calls_onFailure_for_Fail() + { + var result = Result.Fail("err"); + var called = false; + + result.Switch(_ => { }, _ => called = true); + + Assert.True(called); + } + + // --- Catch --- + + [Fact] + public void Catch_returns_Ok_when_no_exception_is_thrown() + { + var result = Result.Catch(() => 99, ex => ex.Message); + + Assert.True(result.IsSuccess); + Assert.Equal(99, result.Value); + } + + [Fact] + public void Catch_returns_Fail_when_an_exception_is_thrown() + { + var result = Result.Catch( + () => throw new InvalidOperationException("boom"), + ex => ex.Message + ); + + Assert.True(result.IsFailure); + Assert.Equal("boom", result.Error); + } + + // --- Bind --- + + [Fact] + public void Bind_chains_to_the_next_result_on_success() + { + var result = Result.Ok(5).Bind(v => Result.Ok($"got {v}")); + + Assert.Equal("got 5", result.Value); + } + + [Fact] + public void Bind_short_circuits_on_failure_without_calling_the_binder() + { + var binderCalled = false; + var result = Result + .Fail("nope") + .Bind(v => + { + binderCalled = true; + return Result.Ok($"got {v}"); + }); + + Assert.False(binderCalled); + Assert.Equal("nope", result.Error); + } + + [Fact] + public void Bind_propagates_the_inner_failure_when_the_binder_returns_Fail() + { + var result = Result.Ok(5).Bind(_ => Result.Fail("inner fail")); + + Assert.Equal("inner fail", result.Error); + } + + // --- Map --- + + [Fact] + public void Map_transforms_the_value_on_success() + { + var result = Result.Ok(3).Map(v => v * 2); + + Assert.Equal(6, result.Value); + } + + [Fact] + public void Map_does_not_call_the_mapper_on_failure() + { + var mapperCalled = false; + var result = Result.Fail("err").Map(v => + { + mapperCalled = true; + return v * 2; + }); + + Assert.False(mapperCalled); + Assert.Equal("err", result.Error); + } + + // --- Tap --- + + [Fact] + public void Tap_calls_the_action_and_returns_the_original_result_on_success() + { + var seen = -1; + var result = Result.Ok(8).Tap(v => seen = v); + + Assert.Equal(8, seen); + Assert.Equal(8, result.Value); + } + + [Fact] + public void Tap_does_not_call_the_action_on_failure() + { + var called = false; + Result.Fail("err").Tap(_ => called = true); + + Assert.False(called); + } + + // --- TapError --- + + [Fact] + public void TapError_calls_the_action_and_returns_the_original_result_on_failure() + { + var seen = ""; + var result = Result.Fail("bad").TapError(e => seen = e); + + Assert.Equal("bad", seen); + Assert.Equal("bad", result.Error); + } + + [Fact] + public void TapError_does_not_call_the_action_on_success() + { + var called = false; + Result.Ok(1).TapError(_ => called = true); + + Assert.False(called); + } + + // --- MapError --- + + [Fact] + public void MapError_transforms_the_error_on_failure() + { + var result = Result.Fail("oops").MapError(e => e.Length); + + Assert.Equal(4, result.Error); + } + + [Fact] + public void MapError_does_not_call_the_mapper_on_success() + { + var mapperCalled = false; + var result = Result.Ok(1).MapError(e => + { + mapperCalled = true; + return e.Length; + }); + + Assert.False(mapperCalled); + Assert.Equal(1, result.Value); + } + + // --- Select / SelectMany (LINQ support) --- + + [Fact] + public void Select_is_equivalent_to_Map() + { + var result = Result.Ok(4).Select(v => v + 1); + + Assert.Equal(5, result.Value); + } + + [Fact] + public void SelectMany_sequences_two_successful_results() + { + var result = + from a in Result.Ok(3) + from b in Result.Ok(4) + select a + b; + + Assert.Equal(7, result.Value); + } + + [Fact] + public void SelectMany_short_circuits_when_the_first_result_fails() + { + var result = + from a in Result.Fail("first fail") + from b in Result.Ok(4) + select a + b; + + Assert.Equal("first fail", result.Error); + } + + [Fact] + public void SelectMany_short_circuits_when_the_second_result_fails() + { + var result = + from a in Result.Ok(3) + from b in Result.Fail("second fail") + select a + b; + + Assert.Equal("second fail", result.Error); + } +} diff --git a/csharp-fp3.Tests/WithdrawMoneyTests.cs b/csharp-fp3.Tests/WithdrawMoneyTests.cs new file mode 100644 index 0000000..4e72cfb --- /dev/null +++ b/csharp-fp3.Tests/WithdrawMoneyTests.cs @@ -0,0 +1,110 @@ +using CsharpFp3.Application; +using CsharpFp3.Domain; +using CsharpFp3.Infrastructure; +using CsharpFp3.Library; + +namespace CsharpFp3.Tests; + +/// Tests covering the application-layer use case surface: +/// what the WithdrawMoney feature does from the caller's perspective. +public class WithdrawMoneyTests +{ + private static ( + Func> withdraw, + Repository repo + ) BuildHandler() + { + var repo = InMemoryAccountRepository.Create(); + var withdraw = AccountApplication.CreateWithdrawMoney(repo).Value; + return (withdraw, repo); + } + + [Fact] + public void Withdrawing_from_an_account_reduces_its_balance_by_the_withdrawn_amount() + { + var (withdraw, repo) = BuildHandler(); + var accountId = Guid.NewGuid(); + repo.SaveAccount(AccountDomain.Open(accountId, new Money(200m)).Value); + + withdraw(accountId, 75m); + + var account = repo.LoadAccount(accountId).Value; + Assert.Equal(125m, account.Balance.Amount); + } + + [Fact] + public void Withdrawing_the_entire_balance_leaves_the_account_at_zero() + { + var (withdraw, repo) = BuildHandler(); + var accountId = Guid.NewGuid(); + repo.SaveAccount(AccountDomain.Open(accountId, new Money(100m)).Value); + + withdraw(accountId, 100m); + + var account = repo.LoadAccount(accountId).Value; + Assert.Equal(0m, account.Balance.Amount); + } + + [Fact] + public void Withdrawing_from_a_non_existent_account_returns_AccountNotFound() + { + var (withdraw, _) = BuildHandler(); + + var result = withdraw(Guid.NewGuid(), 50m); + + Assert.IsType(result.Error); + } + + [Fact] + public void Withdrawing_more_than_the_available_balance_returns_InsufficientBalance() + { + var (withdraw, repo) = BuildHandler(); + var accountId = Guid.NewGuid(); + repo.SaveAccount(AccountDomain.Open(accountId, new Money(50m)).Value); + + var result = withdraw(accountId, 100m); + + Assert.IsType(result.Error); + } + + [Fact] + public void AccountNotFound_error_reports_the_requested_account_id() + { + var (withdraw, _) = BuildHandler(); + var accountId = Guid.NewGuid(); + + var error = Assert.IsType( + withdraw(accountId, 50m).Error + ); + + Assert.Equal(accountId, error.AccountId); + } + + [Fact] + public void InsufficientBalance_error_reports_the_current_balance_and_attempted_amount() + { + var (withdraw, repo) = BuildHandler(); + var accountId = Guid.NewGuid(); + repo.SaveAccount(AccountDomain.Open(accountId, new Money(50m)).Value); + + var error = Assert.IsType( + withdraw(accountId, 120m).Error + ); + + Assert.Equal(50m, error.Balance.Amount); + Assert.Equal(120m, error.Amount.Amount); + } + + [Fact] + public void After_a_failed_withdrawal_the_balance_is_unchanged() + { + var (withdraw, repo) = BuildHandler(); + var accountId = Guid.NewGuid(); + repo.SaveAccount(AccountDomain.Open(accountId, new Money(50m)).Value); + + withdraw(accountId, 999m); + + var account = repo.LoadAccount(accountId).Value; + Assert.Equal(50m, account.Balance.Amount); + } +} diff --git a/csharp-fp3.Tests/csharp-fp3.Tests.csproj b/csharp-fp3.Tests/csharp-fp3.Tests.csproj new file mode 100644 index 0000000..c49b663 --- /dev/null +++ b/csharp-fp3.Tests/csharp-fp3.Tests.csproj @@ -0,0 +1,26 @@ + + + + net10.0 + CsharpFp3.Tests + enable + enable + false + + + + + + + + + + + + + + + + + + diff --git a/csharp.sln b/csharp.sln index da5f00e..c9767a6 100644 --- a/csharp.sln +++ b/csharp.sln @@ -23,6 +23,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "csharp-fp2.Tests", "csharp- EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "csharp-fp3", "csharp-fp3\csharp-fp3.csproj", "{83AFEF87-AAC9-45F4-B803-1BCDF0443BF7}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "csharp-fp3.Tests", "csharp-fp3.Tests\csharp-fp3.Tests.csproj", "{B85327F8-AFA8-47E9-97AB-318F74238929}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -129,6 +131,18 @@ Global {83AFEF87-AAC9-45F4-B803-1BCDF0443BF7}.Release|x64.Build.0 = Release|Any CPU {83AFEF87-AAC9-45F4-B803-1BCDF0443BF7}.Release|x86.ActiveCfg = Release|Any CPU {83AFEF87-AAC9-45F4-B803-1BCDF0443BF7}.Release|x86.Build.0 = Release|Any CPU + {B85327F8-AFA8-47E9-97AB-318F74238929}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B85327F8-AFA8-47E9-97AB-318F74238929}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B85327F8-AFA8-47E9-97AB-318F74238929}.Debug|x64.ActiveCfg = Debug|Any CPU + {B85327F8-AFA8-47E9-97AB-318F74238929}.Debug|x64.Build.0 = Debug|Any CPU + {B85327F8-AFA8-47E9-97AB-318F74238929}.Debug|x86.ActiveCfg = Debug|Any CPU + {B85327F8-AFA8-47E9-97AB-318F74238929}.Debug|x86.Build.0 = Debug|Any CPU + {B85327F8-AFA8-47E9-97AB-318F74238929}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B85327F8-AFA8-47E9-97AB-318F74238929}.Release|Any CPU.Build.0 = Release|Any CPU + {B85327F8-AFA8-47E9-97AB-318F74238929}.Release|x64.ActiveCfg = Release|Any CPU + {B85327F8-AFA8-47E9-97AB-318F74238929}.Release|x64.Build.0 = Release|Any CPU + {B85327F8-AFA8-47E9-97AB-318F74238929}.Release|x86.ActiveCfg = Release|Any CPU + {B85327F8-AFA8-47E9-97AB-318F74238929}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -142,5 +156,6 @@ Global {10A6FD3E-117A-4C9E-98E2-DAC31E7CE78A} = {89C53051-77F9-4C1C-ABE5-6D54AB398471} {4AFF063B-7125-4783-BE84-E1E9B4E2A462} = {89C53051-77F9-4C1C-ABE5-6D54AB398471} {83AFEF87-AAC9-45F4-B803-1BCDF0443BF7} = {89C53051-77F9-4C1C-ABE5-6D54AB398471} + {B85327F8-AFA8-47E9-97AB-318F74238929} = {89C53051-77F9-4C1C-ABE5-6D54AB398471} EndGlobalSection EndGlobal