Version: 1.0
Date: February 10, 2026
Status: Pre-Development
- Testing Overview
- Unit Testing Strategy
- Integration Testing
- End-to-End Testing
- Offline Testing Scenarios
- Performance Testing
- Security Testing
- Test Data Management
▲
╱ ╲
╱ E2E╲ 10% - End-to-End Tests
╱───────╲ (Playwright, Manual)
╱ ╲
╱Integration╲ 30% - Integration Tests
╱─────────────╲ (API + Database)
╱ ╲
╱ Unit Tests ╲ 60% - Unit Tests
╱───────────────────╲ (xUnit, Jest)
╱_____________________╲
- Test Early, Test Often: Write tests alongside code
- Fast Feedback: Unit tests run in <5 seconds
- Isolated Tests: Each test independent
- Realistic Data: Use realistic test data
- Continuous Integration: Tests run on every commit
| Component | Unit Tests | Integration Tests | E2E Tests | Target Coverage |
|---|---|---|---|---|
| API | Services, Repositories | Controllers + DB | Full flows | 80%+ |
| SPA | Services, Utils | API integration | User workflows | 70%+ |
| WPF | ViewModels, Services | Sync + DB | User workflows | 70%+ |
Framework: xUnit + Moq + FluentAssertions
Setup:
cd FamilyTogether.API
dotnet new xunit -n FamilyTogether.API.Tests
cd FamilyTogether.API.Tests
dotnet add package Moq
dotnet add package FluentAssertions
dotnet add reference ../FamilyTogether.API/FamilyTogether.API.csprojTest Structure:
FamilyTogether.API.Tests/
├── Services/
│ ├── TaskServiceTests.cs
│ ├── PointsServiceTests.cs
│ └── SyncServiceTests.cs
├── Repositories/
│ ├── TaskRepositoryTests.cs
│ └── PointTransactionRepositoryTests.cs
├── Helpers/
│ └── ConflictResolverTests.cs
└── Fixtures/
└── DatabaseFixture.cs
Example Test: Task Service
public class TaskServiceTests
{
private readonly Mock<ITaskRepository> _taskRepoMock;
private readonly Mock<IPointsService> _pointsServiceMock;
private readonly TaskService _sut; // System Under Test
public TaskServiceTests()
{
_taskRepoMock = new Mock<ITaskRepository>();
_pointsServiceMock = new Mock<IPointsService>();
_sut = new TaskService(_taskRepoMock.Object, _pointsServiceMock.Object);
}
[Fact]
public async Task ApproveTask_WithValidTask_ShouldUpdateStatusAndAwardPoints()
{
// Arrange
var taskId = Guid.NewGuid();
var reviewerId = Guid.NewGuid();
var task = new TaskEntity
{
Id = taskId,
Status = TaskStatus.AwaitingApproval,
Points = 20,
AssignedTo = Guid.NewGuid()
};
_taskRepoMock
.Setup(x => x.GetByIdAsync(taskId))
.ReturnsAsync(task);
// Act
var result = await _sut.ApproveTaskAsync(taskId, reviewerId, "Good job!");
// Assert
result.Status.Should().Be(TaskStatus.Approved);
result.ReviewedBy.Should().Be(reviewerId);
result.ReviewNote.Should().Be("Good job!");
_taskRepoMock.Verify(x => x.UpdateTaskAsync(It.IsAny<TaskEntity>()), Times.Once);
_pointsServiceMock.Verify(x => x.AwardPointsAsync(task.AssignedTo, 20, taskId), Times.Once);
}
[Fact]
public async Task ApproveTask_WithNotAwaitingApproval_ShouldThrowException()
{
// Arrange
var task = new TaskEntity { Status = TaskStatus.Pending };
_taskRepoMock.Setup(x => x.GetByIdAsync(It.IsAny<Guid>())).ReturnsAsync(task);
// Act
Func<Task> act = async () => await _sut.ApproveTaskAsync(Guid.NewGuid(), Guid.NewGuid(), "");
// Assert
await act.Should().ThrowAsync<InvalidOperationException>()
.WithMessage("*not awaiting approval*");
}
}Example Test: Conflict Resolver
public class ConflictResolverTests
{
[Theory]
[InlineData(150, 100, true)] // Client newer: client wins
[InlineData(100, 150, false)] // Server newer: server wins
[InlineData(100, 100, true)] // Equal: client wins (tie-breaker)
public void ResolveConflict_LastWriteWins_ShouldReturnCorrectWinner(
long clientTimestamp,
long serverTimestamp,
bool clientShouldWin)
{
// Arrange
var clientData = new { last_modified = clientTimestamp };
var serverData = new { last_modified = serverTimestamp };
var resolver = new ConflictResolver();
// Act
var result = resolver.Resolve(clientData, serverData);
// Assert
result.ClientWins.Should().Be(clientShouldWin);
result.ServerWins.Should().Be(!clientShouldWin);
}
}Framework: Jest
Setup:
cd FamilyTogether.SPA
npm install --save-dev jest @testing-library/jest-dompackage.json:
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage"
}
}Test Structure:
FamilyTogether.SPA/
├── tests/
│ ├── services/
│ │ ├── db.test.js
│ │ ├── sync.test.js
│ │ └── auth.test.js
│ └── utils/
│ ├── validators.test.js
│ └── uuid.test.js
Example Test: UUID Generator
import { generateUUID, isValidUUID } from '../src/utils/uuid.js';
describe('UUID Utilities', () => {
test('generateUUID should create valid UUID', () => {
const uuid = generateUUID();
expect(uuid).toBeDefined();
expect(uuid).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i);
});
test('isValidUUID should validate UUIDs correctly', () => {
const validUUID = '550e8400-e29b-41d4-a716-446655440000';
const invalidUUID = 'not-a-uuid';
expect(isValidUUID(validUUID)).toBe(true);
expect(isValidUUID(invalidUUID)).toBe(false);
});
});Example Test: Sync Manager
import { SyncManager } from '../src/services/sync.js';
describe('SyncManager', () => {
let syncManager;
let mockDb;
let mockFetch;
beforeEach(() => {
mockDb = {
syncQueue: {
where: jest.fn().mockReturnThis(),
equals: jest.fn().mockReturnThis(),
toArray: jest.fn()
}
};
mockFetch = jest.fn();
global.fetch = mockFetch;
syncManager = new SyncManager(mockDb, 'http://api.test', 'fake-token');
});
test('sync should not run when offline', async () => {
Object.defineProperty(navigator, 'onLine', { value: false, writable: true });
await syncManager.sync();
expect(mockFetch).not.toHaveBeenCalled();
});
test('sync should send pending changes to server', async () => {
const pendingChanges = [
{ id: 1, table_name: 'tasks', operation: 'create', data_json: '{}' }
];
mockDb.syncQueue.toArray.mockResolvedValue(pendingChanges);
mockFetch.mockResolvedValue({
json: async () => ({ accepted_changes: [{ record_id: '1', status: 'accepted' }] })
});
await syncManager.sync();
expect(mockFetch).toHaveBeenCalledWith(
'http://api.test/api/sync',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
'Authorization': 'Bearer fake-token'
})
})
);
});
});Framework: xUnit + Moq
Test Structure:
FamilyTogether.WPF.Tests/
├── ViewModels/
│ ├── MainViewModelTests.cs
│ └── TasksViewModelTests.cs
├── Services/
│ ├── SyncServiceTests.cs
│ └── TaskServiceTests.cs
Example Test: TasksViewModel
public class TasksViewModelTests
{
private readonly Mock<ITaskService> _taskServiceMock;
private readonly TasksViewModel _sut;
public TasksViewModelTests()
{
_taskServiceMock = new Mock<ITaskService>();
_sut = new TasksViewModel(_taskServiceMock.Object);
}
[Fact]
public async Task LoadTasks_ShouldPopulateTasksCollection()
{
// Arrange
var tasks = new List<TaskEntity>
{
new TaskEntity { Id = Guid.NewGuid(), Title = "Task 1" },
new TaskEntity { Id = Guid.NewGuid(), Title = "Task 2" }
};
_taskServiceMock
.Setup(x => x.GetTasksAsync())
.ReturnsAsync(tasks);
// Act
await _sut.LoadTasksCommand.ExecuteAsync(null);
// Assert
_sut.Tasks.Should().HaveCount(2);
_sut.Tasks.First().Title.Should().Be("Task 1");
}
}Setup: Use in-memory SQLite database
public class TasksControllerIntegrationTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly HttpClient _client;
private readonly WebApplicationFactory<Program> _factory;
public TasksControllerIntegrationTests(WebApplicationFactory<Program> factory)
{
_factory = factory.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
// Remove real database
var descriptor = services.SingleOrDefault(
d => d.ServiceType == typeof(DbContextOptions<AppDbContext>));
services.Remove(descriptor);
// Add in-memory database
services.AddDbContext<AppDbContext>(options =>
{
options.UseInMemoryDatabase("TestDb");
});
});
});
_client = _factory.CreateClient();
}
[Fact]
public async Task CreateTask_WithValidData_ShouldReturn201()
{
// Arrange
var createRequest = new
{
title = "Test Task",
description = "Test description",
points = 10,
assigned_to = Guid.NewGuid()
};
// Act
var response = await _client.PostAsJsonAsync("/api/tasks", createRequest);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Created);
var task = await response.Content.ReadFromJsonAsync<TaskResponse>();
task.Should().NotBeNull();
task.Title.Should().Be("Test Task");
}
}Setup:
npm install --save-dev @playwright/test
npx playwright installplaywright.config.js:
export default {
testDir: './e2e',
use: {
baseURL: 'http://localhost:8000',
screenshot: 'only-on-failure',
video: 'retain-on-failure'
},
webServer: {
command: 'npm run dev',
port: 8000
}
};E2E Test: Complete Task Flow
import { test, expect } from '@playwright/test';
test.describe('Task Management Flow', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
// Login
await page.fill('[data-testid="email"]', 'test@example.com');
await page.fill('[data-testid="password"]', 'Password123!');
await page.click('[data-testid="login-btn"]');
await page.waitForURL('/dashboard');
});
test('should create, complete, and approve task', async ({ page }) => {
// Create task
await page.click('[data-testid="create-task-btn"]');
await page.fill('[data-testid="task-title"]', 'Clean room');
await page.fill('[data-testid="task-points"]', '20');
await page.click('[data-testid="save-task-btn"]');
await expect(page.locator('text=Clean room')).toBeVisible();
// Complete task
await page.click('[data-testid="task-item"]:has-text("Clean room")');
await page.click('[data-testid="complete-btn"]');
await page.fill('[data-testid="completion-note"]', 'All done!');
await page.click('[data-testid="submit-completion-btn"]');
await expect(page.locator('text=Awaiting Approval')).toBeVisible();
// Approve task (as parent)
await page.click('[data-testid="approve-btn"]');
await page.fill('[data-testid="review-note"]', 'Great job!');
await page.click('[data-testid="submit-approval-btn"]');
await expect(page.locator('text=Approved')).toBeVisible();
await expect(page.locator('[data-testid="points-balance"]')).toHaveText('20');
});
});Steps:
- Disconnect from network
- Create new task
- Verify task saved locally (IndexedDB/SQLite)
- Verify task in sync queue
- Reconnect to network
- Wait for auto-sync (30 seconds) or trigger manually
- Verify task synced to server
- Verify sync queue cleared
Expected Result: Task appears in cloud database with correct data
Test Code (Playwright):
test('offline task creation syncs when online', async ({ page, context }) => {
// Go offline
await context.setOffline(true);
// Create task
await page.click('[data-testid="create-task-btn"]');
await page.fill('[data-testid="task-title"]', 'Offline Task');
await page.click('[data-testid="save-task-btn"]');
// Verify local save
const localTask = await page.evaluate(() => {
return new Promise((resolve) => {
const request = indexedDB.open('FamilyTogetherDB', 1);
request.onsuccess = () => {
const db = request.result;
const tx = db.transaction('tasks', 'readonly');
const store = tx.objectStore('tasks');
const getRequest = store.getAll();
getRequest.onsuccess = () => resolve(getRequest.result);
};
});
});
expect(localTask).toHaveLength(1);
expect(localTask[0].title).toBe('Offline Task');
// Go online
await context.setOffline(false);
// Wait for sync
await page.waitForSelector('[data-testid="sync-status"]:has-text("Synced")');
// Verify on server (check via API)
const response = await page.request.get('/api/tasks');
const tasks = await response.json();
expect(tasks.data.tasks.some(t => t.title === 'Offline Task')).toBeTruthy();
});Steps:
- User A goes offline
- User A edits task (timestamp: 100)
- User B (online) edits same task (timestamp: 150)
- User B's change syncs to server
- User A comes online
- User A's sync triggers
- Server detects conflict (100 < 150)
- Server sends back version 150 (Server Wins)
- Client applies server version
Expected Result: User A's local copy updated with User B's changes
Tool: Apache JMeter or k6
Scenario: 100 concurrent users syncing
// k6 script
import http from 'k6/http';
import { check } from 'k6';
export let options = {
stages: [
{ duration: '2m', target: 100 }, // Ramp up to 100 users
{ duration: '5m', target: 100 }, // Stay at 100 users
{ duration: '2m', target: 0 }, // Ramp down
],
thresholds: {
http_req_duration: ['p(95)<500'], // 95% requests < 500ms
http_req_failed: ['rate<0.01'], // < 1% failures
},
};
export default function () {
const payload = JSON.stringify({
changes: [
{
table_name: 'tasks',
operation: 'update',
record_id: '550e8400-e29b-41d4-a716-446655440000',
data: { status: 'completed' }
}
]
});
const params = {
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer fake-token'
},
};
const response = http.post('http://api.test/api/sync', payload, params);
check(response, {
'status is 200': (r) => r.status === 200,
'response time < 500ms': (r) => r.timings.duration < 500,
});
}Query Performance Benchmarks:
-- Test: Get tasks for family member (should be < 50ms)
EXPLAIN ANALYZE
SELECT * FROM tasks
WHERE family_id = '550e8400-e29b-41d4-a716-446655440000'
AND assigned_to = '650e8400-e29b-41d4-a716-446655440000'
AND is_deleted = FALSE;
-- Expected: Index Scan using idx_tasks_family_assigned
-- Execution time: ~10-20ms for 1000 tasks- SQL Injection: Test all inputs with malicious SQL
- XSS: Test all text inputs with
<script>alert('xss')</script> - CSRF: Verify JWT required for state-changing operations
- JWT Validation: Test expired, malformed, missing tokens
- Authorization: Test accessing other family's data
- Rate Limiting: Test exceeding rate limits
- File Upload: Test malicious file uploads
- Password Security: Verify bcrypt hashing, minimum complexity
Example: Authorization Test
[Fact]
public async Task GetTasks_ForDifferentFamily_ShouldReturn403()
{
// Arrange
var user1Token = await GetJwtTokenForUser("user1@test.com");
var user2FamilyId = Guid.Parse("..."); // User 2's family
_client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", user1Token);
// Act
var response = await _client.GetAsync($"/api/tasks?family_id={user2FamilyId}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Forbidden);
}Seed Data Script (for testing):
-- Families
INSERT INTO families (id, name, created_by) VALUES
('550e8400-e29b-41d4-a716-446655440000', 'Test Family 1', 'user-id-1'),
('650e8400-e29b-41d4-a716-446655440000', 'Test Family 2', 'user-id-2');
-- Members
INSERT INTO members (id, family_id, name, role, points_balance) VALUES
('750e8400-e29b-41d4-a716-446655440000', '550e8400-e29b-41d4-a716-446655440000', 'Parent Test', 'parent', 0),
('850e8400-e29b-41d4-a716-446655440000', '550e8400-e29b-41d4-a716-446655440000', 'Child Test', 'child', 50);
-- Tasks
INSERT INTO tasks (id, family_id, title, points, assigned_to, created_by, status) VALUES
('950e8400-e29b-41d4-a716-446655440000', '550e8400-e29b-41d4-a716-446655440000',
'Test Task 1', 20, '850e8400-e29b-41d4-a716-446655440000', '750e8400-e29b-41d4-a716-446655440000', 'pending');public class DatabaseFixture : IDisposable
{
public AppDbContext Context { get; private set; }
public DatabaseFixture()
{
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
Context = new AppDbContext(options);
SeedTestData();
}
private void SeedTestData()
{
// Add test data
}
public void Dispose()
{
Context.Database.EnsureDeleted();
Context.Dispose();
}
}.github/workflows/test.yml:
name: Run Tests
on: [push, pull_request]
jobs:
api-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup .NET
uses: actions/setup-dotnet@v3
with:
dotnet-version: '6.0.x'
- name: Restore dependencies
run: dotnet restore FamilyTogether.API
- name: Build
run: dotnet build FamilyTogether.API --no-restore
- name: Test
run: dotnet test FamilyTogether.API.Tests --no-build --verbosity normal --collect:"XPlat Code Coverage"
- name: Upload coverage
uses: codecov/codecov-action@v3
spa-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node
uses: actions/setup-node@v3
with:
node-version: '16'
- name: Install dependencies
run: npm install
working-directory: FamilyTogether.SPA
- name: Run tests
run: npm test
working-directory: FamilyTogether.SPAEND OF TESTING STRATEGY DOCUMENT