diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml
new file mode 100644
index 0000000..b6bc4ff
--- /dev/null
+++ b/.github/workflows/mutation.yml
@@ -0,0 +1,51 @@
+name: Mutation testing
+
+# Stryker.NET on the pure split math (SplitCalculator): the only unit-testable
+# logic that is fully DB-free, so the job is fast and hermetic (no SQL Server).
+#
+# Runs when the mutated code, its tests or the config change; nightly-style on a
+# schedule; and on demand. Fails when the mutation score drops below the `break`
+# threshold in stryker-config.json.
+on:
+ pull_request:
+ branches: [main]
+ paths:
+ - 'SplitIt.API/SplitIt.Infrastructure/Services/SplitCalculator.cs'
+ - 'SplitIt.Tests/SplitCalculator*'
+ - 'stryker-config.json'
+ - '.github/workflows/mutation.yml'
+ schedule:
+ - cron: '0 4 * * 1'
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ stryker:
+ name: Stryker.NET (SplitCalculator)
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: 8.0.x
+
+ - name: Install Stryker.NET
+ run: dotnet tool install --global dotnet-stryker
+
+ - name: Restore
+ run: dotnet restore SplitIt.API/SplitIt.Back.sln
+
+ - name: Run mutation tests
+ run: dotnet stryker --config-file stryker-config.json
+
+ - name: Upload mutation report
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: mutation-report
+ path: StrykerOutput/**/reports/
diff --git a/.gitignore b/.gitignore
index f25fcce..6b4ad82 100644
--- a/.gitignore
+++ b/.gitignore
@@ -201,3 +201,6 @@ Desktop.ini
# SonarScanner for .NET working directory
.sonarqube/
+
+# Mutation testing output (Stryker.NET)
+StrykerOutput/
diff --git a/SplitIt.Tests/SplitCalculatorBoundaryTests.cs b/SplitIt.Tests/SplitCalculatorBoundaryTests.cs
new file mode 100644
index 0000000..b56ee45
--- /dev/null
+++ b/SplitIt.Tests/SplitCalculatorBoundaryTests.cs
@@ -0,0 +1,59 @@
+using SplitIt.Infrastructure.Services;
+
+namespace SplitIt.Tests;
+
+///
+/// Boundary and validation cases for the split math, complementing
+/// so mutation testing has fewer survivors.
+///
+public class SplitCalculatorBoundaryTests
+{
+ [Fact]
+ public void EqualSplit_ZeroTotal_Throws() =>
+ Assert.Throws(() => SplitCalculator.EqualSplit(0m, new[] { 1, 2 }));
+
+ [Fact]
+ public void EqualSplit_NullParticipants_Throws() =>
+ Assert.Throws(() => SplitCalculator.EqualSplit(100m, null!));
+
+ [Fact]
+ public void EqualSplit_EmptyParticipants_Throws() =>
+ Assert.Throws(() => SplitCalculator.EqualSplit(100m, Array.Empty()));
+
+ [Fact]
+ public void ByAmount_NullEntries_Throws() =>
+ Assert.Throws(() => SplitCalculator.ByAmount(null!, 100m));
+
+ [Fact]
+ public void ByAmount_EmptyEntries_Throws() =>
+ Assert.Throws(() => SplitCalculator.ByAmount(Array.Empty<(int, decimal)>(), 100m));
+
+ [Fact]
+ public void ByPercentage_NullEntries_Throws() =>
+ Assert.Throws(() => SplitCalculator.ByPercentage(null!, 100m));
+
+ [Fact]
+ public void ByPercentage_ZeroTotal_Throws() =>
+ Assert.Throws(() => SplitCalculator.ByPercentage(new[] { (1, 100m) }, 0m));
+
+ [Fact]
+ public void ByPercentage_AcceptsBoundaryPercentagesZeroAndHundred()
+ {
+ var result = SplitCalculator.ByPercentage(new[] { (1, 0m), (2, 100m) }, 100m);
+
+ Assert.Equal(0m, result[0].AmountOwed);
+ Assert.Equal(100m, result[1].AmountOwed);
+ }
+
+ [Fact]
+ public void ByPercentage_AbsorbsRoundingDriftIntoLastParticipant()
+ {
+ var result = SplitCalculator.ByPercentage(
+ new[] { (1, 33.33m), (2, 33.33m), (3, 33.33m) },
+ 100m);
+
+ Assert.Equal(100m, result.Sum(r => r.AmountOwed));
+ Assert.Equal(33.33m, result[0].AmountOwed);
+ Assert.Equal(33.34m, result[2].AmountOwed);
+ }
+}
diff --git a/stryker-config.json b/stryker-config.json
new file mode 100644
index 0000000..bb23aba
--- /dev/null
+++ b/stryker-config.json
@@ -0,0 +1,15 @@
+{
+ "stryker-config": {
+ "project": "SplitIt.API/SplitIt.Infrastructure/SplitIt.Infrastructure.csproj",
+ "test-projects": ["SplitIt.Tests/SplitIt.Tests.csproj"],
+ "mutate": ["Services/SplitCalculator.cs"],
+ "test-case-filter": "FullyQualifiedName~SplitCalculator",
+ "thresholds": {
+ "high": 80,
+ "low": 70,
+ "break": 65
+ },
+ "reporters": ["progress", "html", "json"],
+ "concurrency": 4
+ }
+}