Skip to content

Testing Strategy — Laundry Management System

Document Information

Field Value
Project Laundry Management System
Version 1.0
Language English
Document Type Testing Strategy & Quality Assurance

Table of Contents

  1. Testing Pyramid
  2. Backend Testing (xUnit + TestContainers)
  3. Frontend Testing (Jest + TestBed)
  4. Tauri Integration Testing
  5. Accounting Validation Tests
  6. Offline Sync Tests
  7. API Integration Tests
  8. Code Coverage Requirements
  9. CI Pipeline Test Execution
  10. Manual Testing — Tauri Desktop Checklist

1. Testing Pyramid

          ╱  E2E  ╲             Manual Tauri checklist (per release)
         ╱──────────╲
        ╱Integration  ╲          API integration (TestContainers) + NgRx Effects
       ╱────────────────╲
      ╱   Unit Tests      ╲      xUnit + Jest — the majority
     ╱──────────────────────╲
    ──────────────────────────
Layer Percentage Tool Target Coverage
Unit 70% xUnit (BE), Jest (FE) ≥ 80% overall
Integration 20% TestContainers (BE), TestBed (FE) Critical paths 100%
E2E / Manual 10% Manual Tauri checklist All features per release

2. Backend Testing (xUnit + TestContainers)

2.1 Unit Tests

// Example: Accounting Rules Engine
public class AccountingRulesEngineTests
{
    [Fact]
    public void Evaluate_InvoiceCreated_PaidByCash_ReturnsCorrectEntries()
    {
        var engine = new AccountingRulesEngine(GetTestRules());
        var invoice = CreateTestInvoice(grandTotal: 150.00m);

        var entries = engine.Evaluate("InvoiceCreated", invoice, PaymentMethod.Cash);

        entries.Should().HaveCount(2);
        entries[0].AccountCode.Should().Be("1000");  // Cash
        entries[0].Debit.Should().Be(150.00m);
        entries[1].AccountCode.Should().Be("4000");   // Laundry Income
        entries[1].Credit.Should().Be(150.00m);
    }

    [Fact]
    public void Evaluate_InvoiceCreated_Deferred_ReturnsReceivableEntry()
    {
        var engine = new AccountingRulesEngine(GetTestRules());
        var invoice = CreateTestInvoice(grandTotal: 200.00m);

        var entries = engine.Evaluate("InvoiceCreated", invoice, PaymentMethod.Deferred);

        entries[0].AccountCode.Should().Be("1200");  // AR
        entries[1].AccountCode.Should().Be("4000");   // Income
    }
}

2.2 Integration Tests with TestContainers

public class InvoiceIntegrationTests : IAsyncLifetime
{
    private readonly PostgreSqlContainer _postgres;

    public InvoiceIntegrationTests()
    {
        _postgres = new PostgreSqlBuilder()
            .WithDatabase("laundry_test")
            .WithUsername("test")
            .WithPassword("test")
            .Build();
    }

    public async Task InitializeAsync()
    {
        await _postgres.StartAsync();
        // Apply migrations
        await using var db = CreateDbContext();
        await db.Database.MigrateAsync();
    }

    public Task DisposeAsync() => _postgres.DisposeAsync().AsTask();

    [Fact]
    public async Task CreateInvoice_WithPayment_SavesInvoiceAndPostsToGL()
    {
        // Arrange
        var handler = CreateHandler();

        // Act
        var result = await handler.Handle(new CreateInvoiceCommand(...), CancellationToken.None);

        // Assert
        result.IsSuccess.Should().BeTrue();

        // Verify GL entries exist
        var glLines = await GetGlLinesForInvoice(result.Value.Id);
        glLines.Should().NotBeEmpty();
        glLines.Sum(x => x.Debit).Should().Be(glLines.Sum(x => x.Credit));
    }
}

2.3 Key Test Scenarios

Area Test Cases
Auth Login success, invalid password, expired token, missing role
Invoice Create draft, confirm, change status, cancel, duplicate number prevention
Payment Single, multi-method, partial, overpayment rejection
Accounting All 13 flows generate correct entries, SUM debit == SUM credit
Carpet Receipt creation, final invoice, advance deduction, surplus credit
Tailoring Task assignment, cost recording, payout calculation
License Valid license, expired grace, expired hard stop, tampered file
Sync Export, import, duplicate import idempotency

3. Frontend Testing (Jest + TestBed)

3.1 Component Testing with TestBed

describe('InvoiceListContainer', () => {
  let component: InvoiceListContainer;
  let facade: InvoiceFacade;
  let store: MockStore;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [InvoiceListContainer],
      providers: [
        provideMockStore({
          initialState: { invoices: { ...initialInvoiceState } }
        }),
        InvoiceFacade,
        { provide: TranslateService, useValue: { instant: jest.fn(), get: jest.fn(() => of('')) } },
      ]
    }).compileComponents();

    store = TestBed.inject(MockStore);
    facade = TestBed.inject(InvoiceFacade);
    component = TestBed.createComponent(InvoiceListContainer).componentInstance;
  });

  it('should load invoices on init', () => {
    const loadSpy = jest.spyOn(facade, 'load');
    component.ngOnInit();
    expect(loadSpy).toHaveBeenCalled();
  });

  it('should display invoices from store', () => {
    const mockInvoices = [{ id: '1', invoiceNumber: 'INV-001', grandTotal: 150 }];
    store.overrideSelector(selectAllInvoices, mockInvoices);
    store.refreshState();
    const fixture = TestBed.createComponent(InvoiceListContainer);
    fixture.detectChanges();
    expect(fixture.nativeElement.textContent).toContain('INV-001');
  });
});

3.2 Service Testing

describe('InvoiceService', () => {
  let service: InvoiceService;
  let httpMock: HttpTestingController;

  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [InvoiceService, provideHttpClient(), provideHttpClientTesting()]
    });
    service = TestBed.inject(InvoiceService);
    httpMock = TestBed.inject(HttpTestingController);
  });

  it('should call GET /api/invoices', () => {
    service.getAll().subscribe(invoices => {
      expect(invoices.length).toBe(2);
    });
    const req = httpMock.expectOne('/api/invoices');
    expect(req.request.method).toBe('GET');
    req.flush([{ id: '1' }, { id: '2' }]);
  });
});

3.3 NgRx Testing

describe('InvoiceEffects', () => {
  let effects: InvoiceEffects;
  let actions$: Observable<Action>;
  let invoiceService: jest.Mocked<InvoiceService>;

  beforeEach(() => {
    invoiceService = { getAll: jest.fn() } as any;
    TestBed.configureTestingModule({
      providers: [
        InvoiceEffects,
        provideMockActions(() => actions$),
        { provide: InvoiceService, useValue: invoiceService },
      ]
    });
    effects = TestBed.inject(InvoiceEffects);
  });

  it('should return loadSuccess on successful load', (done) => {
    const invoices = [{ id: '1' } as InvoiceDto];
    invoiceService.getAll.mockReturnValue(of(invoices));
    actions$ = of(InvoiceActions.load());

    effects.load$.subscribe(result => {
      expect(result).toEqual(InvoiceActions.loadSuccess({ invoices }));
      done();
    });
  });
});

4. Tauri Integration Testing

// Using @tauri-apps/api/mocks mockIPC
import { mockIPC } from '@tauri-apps/api/mocks';

describe('TauriBridgeService', () => {
  let service: TauriBridgeService;

  beforeEach(() => {
    // Must be called before component initialized
    (window as any).__TAURI_INTERNALS__ = {}; // Enable Tauri mode
    mockIPC((cmd, args) => {
      switch (cmd) {
        case 'compute_client_token':
          return 'test-token-abc123';
        case 'start_docker_services':
          return 'All services started';
        default:
          return null;
      }
    });
    service = TestBed.inject(TauriBridgeService);
  });

  it('should compute client token via Tauri invoke', async () => {
    const token = await service.computeClientToken();
    expect(token).toBe('test-token-abc123');
  });

  it('should start Docker services', async () => {
    const result = await service.startDockerServices();
    expect(result).toContain('started');
  });
});

5. Accounting Validation Tests

Every accounting flow must pass the Golden Rule test:

[Theory]
[ClassData(typeof(AccountingScenarioProvider))]
public void AccountingFlow_Always_Balances(string scenario, Func<Task<Guid>> act)
{
    // Arrange: seed test data
    // Act: execute the scenario
    var referenceId = await act();

    // Assert: SUM debit == SUM credit
    var glLines = await GetGlLinesForReference(referenceId);
    var totalDebit = glLines.Sum(x => x.Debit);
    var totalCredit = glLines.Sum(x => x.Credit);

    totalDebit.Should().Be(totalCredit,
        $"Scenario '{scenario}' produced unbalanced GL entries: Debit={totalDebit}, Credit={totalCredit}");

    // Assert: no zero-amount lines
    glLines.Should().NotContain(x => x.Debit == 0 && x.Credit == 0);
    glLines.Should().NotContain(x => x.Debit != 0 && x.Credit != 0);
}

public class AccountingScenarioProvider : TheoryData<string, Func<Task<Guid>>>
{
    public AccountingScenarioProvider()
    {
        Add("Paid cash invoice", () => CreatePaidCashInvoiceAsync());
        Add("Deferred invoice", () => CreateDeferredInvoiceAsync());
        Add("Partial payment", () => CreatePartialPaymentAsync());
        Add("Invoice with inventory items", () => CreateInvoiceWithInventoryAsync());
        Add("Carpet receipt advance", () => CreateCarpetAdvanceAsync());
        Add("Carpet final invoice (advance < total)", () => CreateCarpetFinalInvoiceSurplusAsync());
        Add("Carpet final invoice (advance > total)", () => CreateCarpetFinalInvoiceCreditAsync());
        Add("Carpet company accrual", () => ProcessCarpetCompanyAccrualAsync());
        Add("Carpet company cash", () => ProcessCarpetCompanyCashAsync());
        Add("Carpet company prepayment", () => ProcessCarpetCompanyPrepaymentAsync());
        Add("Tailor periodic", () => ProcessTailorPeriodicAsync());
        Add("Tailor per-item", () => ProcessTailorPerItemAsync());
        Add("Tailor salary + commission", () => ProcessTailorSalaryCommissionAsync());
        Add("Damage compensation cash", () => ProcessDamageCompensationCashAsync());
        Add("Damage compensation credit", () => ProcessDamageCompensationCreditAsync());
        Add("Refund (paid invoice)", () => ProcessRefundAsync());
        Add("Cancel unpaid invoice", () => ProcessCancelUnpaidAsync());
    }
}

6. Offline Sync Tests

[Fact]
public async Task ExportImport_RoundTrip_ProducesIdenticalData()
{
    // Arrange: create data on offline branch DB
    var offlineDb = CreateOfflineDbContext();
    await SeedTestData(offlineDb);

    // Act: export
    var exportService = new SyncExportService(offlineDb);
    var syncFile = await exportService.ExportAsync(branchId);

    // Act: import to central DB
    var centralDb = CreateCentralDbContext();
    var importService = new SyncImportService(centralDb);
    var result = await importService.ImportAsync(syncFile);

    // Assert
    result.IsSuccess.Should().BeTrue();
    result.RowsImported.Should().BeGreaterThan(0);
    result.RowsSkipped.Should().Be(0);

    // Assert: row counts match
    var centralCount = await centralDb.Invoices.CountAsync();
    var offlineCount = await offlineDb.Invoices.CountAsync();
    centralCount.Should().Be(offlineCount);
}

[Fact]
public async Task Reimport_SameFile_Twice_IsIdempotent()
{
    var centralDb = CreateCentralDbContext();
    var importService = new SyncImportService(centralDb);

    // Import first time
    await importService.ImportAsync(syncFile);

    // Import same file again
    var result = await importService.ImportAsync(syncFile);

    result.RowsSkipped.Should().BeGreaterThan(0);   // All skipped
    result.RowsImported.Should().Be(0);              // Nothing new
}

7. API Integration Tests

public class InvoiceApiTests : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly HttpClient _client;

    public InvoiceApiTests(WebApplicationFactory<Program> factory)
    {
        _client = factory.WithWebHostBuilder(builder =>
        {
            builder.UseSetting("ConnectionStrings:Default", _testDbConnectionString);
        }).CreateClient();
    }

    [Fact]
    public async Task POST_invoices_Returns201_WithInvoice()
    {
        var dto = new { customerId = customerGuid, branchId = branchGuid, items = new[] { ... } };

        var response = await _client.PostAsJsonAsync("/api/v1/invoices", dto);

        response.StatusCode.Should().Be(HttpStatusCode.Created);
        var invoice = await response.Content.ReadFromJsonAsync<InvoiceDto>();
        invoice!.InvoiceNumber.Should().NotBeNullOrEmpty();
    }

    [Fact]
    public async Task GET_invoices_search_ReturnsFilteredResults()
    {
        var response = await _client.GetAsync("/api/v1/invoices?phone=0123456");
        response.StatusCode.Should().Be(HttpStatusCode.OK);
    }
}

8. Code Coverage Requirements

Layer Tool Minimum Target
Backend Unit coverlet + reportgenerator 80% line 90%
Backend Integration coverlet Accounting module 100% flows
Frontend Unit Jest coverage 70% line 85%

Strictest: Accounting Engine — every flow must have a test that asserts SUM(debit) == SUM(credit).


9. CI Pipeline Test Execution

# In build-and-test.yml
- name: Backend Tests
  run: dotnet test --no-build -c Release --collect:"XPlat Code Coverage" --logger "trx"

- name: Frontend Tests
  run: npm test -- --watch=false --coverage --ci
  working-directory: frontend/laundry-app

- name: Check Coverage Thresholds
  run: |
    # Backend >= 80%
    reportgenerator -reports:**/coverage.cobertura.xml -targetdir:coverage
    # Fail build if below threshold

- name: Upload Coverage
  uses: actions/upload-artifact@v4
  with:
    name: coverage-report
    path: coverage/

10. Manual Testing — Tauri Desktop Checklist

Performed before every GitHub Release on all 3 platforms.

Setup

  • [ ] Install from .msi / .dmg / .deb
  • [ ] First launch shows wizard (not login)
  • [ ] Wizard: select branch type, enter info, load license, start Docker
  • [ ] Admin account created with both auto-gen and custom password options
  • [ ] App launches to login screen
  • [ ] Login with created admin credentials

Core

  • [ ] Create invoice: add items, select garment + service, quantities
  • [ ] Invoice Draft → Confirm → Ready → Delivered
  • [ ] Barcode tags generated and printable
  • [ ] Payment: cash, card, multi-method, partial
  • [ ] Invoice search by name, phone, number, date, status
  • [ ] Customer CRUD with classification
  • [ ] Cashier shift: open → sales → close → discrepancy check

Carpet

  • [ ] Carpet receipt: add pieces, types, advance payment
  • [ ] Final invoice: link receipt, enter area, select strategy, auto-calculate
  • [ ] Advance > final → customer credit created
  • [ ] Company settlement models work

Tailoring

  • [ ] Tailor profile with payout model
  • [ ] Tailoring order linked to invoice and standalone
  • [ ] Tailor payout report shows earned/paid/remaining

Desktop

  • [ ] Window close → Docker services stay running
  • [ ] App re-open → reconnects to local API
  • [ ] Browser open → 403 "Access Denied" page (offline mode)
  • [ ] License expired → red banner appears (Day 1-7)
  • [ ] License hard stop → write operations blocked (Day 8+)
  • [ ] Replace license.dat → app recovers

Print

  • [ ] Thermal receipt prints on 80mm paper
  • [ ] A4 invoice prints correctly
  • [ ] Excel export generates valid .xlsx
  • [ ] PDF export generates readable .pdf

Revision History

Date Version Author Changes
2026-05-10 1.0 QA Lead Initial testing strategy specification