From ac64008db66b2b1b3af0ef72217de6e39474b96d Mon Sep 17 00:00:00 2001 From: santidev21 Date: Thu, 1 Oct 2026 17:04:16 -0500 Subject: [PATCH] chore(ci): add Stryker.NET mutation testing for the split math - stryker-config.json: mutate SplitIt.Infrastructure/Services/SplitCalculator.cs and run only the SplitCalculator tests (pure, no DB). Thresholds high=80 / low=70 / break=65. - .github/workflows/mutation.yml: runs on PRs touching the calculator, its tests or the config, on a weekly schedule and on demand; fails below break and uploads the report. - SplitCalculatorBoundaryTests: boundary cases (null/empty input, zero total, 0/100 percentages, rounding drift) that raise the mutation score from 69% to 82%. --- .github/workflows/mutation.yml | 51 ++++++++++++++++ .gitignore | 3 + SplitIt.Tests/SplitCalculatorBoundaryTests.cs | 59 +++++++++++++++++++ stryker-config.json | 15 +++++ 4 files changed, 128 insertions(+) create mode 100644 .github/workflows/mutation.yml create mode 100644 SplitIt.Tests/SplitCalculatorBoundaryTests.cs create mode 100644 stryker-config.json 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 + } +}