Testing Strategy — Laundry Management System
| Field |
Value |
| Project |
Laundry Management System |
| Version |
1.0 |
| Language |
English |
| Document Type |
Testing Strategy & Quality Assurance |
Table of Contents
- Testing Pyramid
- Backend Testing (xUnit + TestContainers)
- Frontend Testing (Jest + TestBed)
- Tauri Integration Testing
- Accounting Validation Tests
- Offline Sync Tests
- API Integration Tests
- Code Coverage Requirements
- CI Pipeline Test Execution
- 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 |