549 lines
14 KiB
Markdown
549 lines
14 KiB
Markdown
# Life Management System — Testing Strategy
|
||
|
||
## Philosophy
|
||
|
||
Test pyramid: more unit tests, fewer integration tests, fewest e2e tests. Focus testing effort where bugs are most likely and most costly: service business logic, encrypted field handling, and API contract compliance.
|
||
|
||
```
|
||
╱╲
|
||
╱ ╲ E2E Tests (few, critical paths only)
|
||
╱────╲ supertest + WebSocket client
|
||
╱ ╲
|
||
╱ ╲ Integration Tests (moderate, per-controller)
|
||
╱──────────╲ Test database + real queries
|
||
╱ ╲
|
||
╱ ╲ Unit Tests (many, per-service)
|
||
╱────────────────╲ Mocked repositories + pure logic
|
||
```
|
||
|
||
---
|
||
|
||
## Test Stack
|
||
|
||
| Layer | Tool | Package |
|
||
|-------|------|---------|
|
||
| Test runner | Vitest | `vitest` |
|
||
| Backend unit | Vitest + NestJS testing | `@nestjs/testing` |
|
||
| Backend integration | Vitest + supertest | `supertest` |
|
||
| Backend e2e | Vitest + supertest + socket.io-client | `supertest`, `socket.io-client` |
|
||
| Frontend unit | Vitest + Testing Library | `@testing-library/react` |
|
||
| Frontend integration | Vitest + MSW | `msw` |
|
||
| Coverage | v8 (via Vitest) | built-in |
|
||
|
||
---
|
||
|
||
## Backend Unit Tests
|
||
|
||
### Scope
|
||
|
||
Test service-layer business logic with mocked TypeORM repositories. No database connection.
|
||
|
||
### Pattern
|
||
|
||
```typescript
|
||
// modules/tasks/tasks.service.spec.ts
|
||
|
||
describe('TasksService', () => {
|
||
let service: TasksService;
|
||
let repository: MockType<Repository<Task>>;
|
||
|
||
beforeEach(async () => {
|
||
const module = await Test.createTestingModule({
|
||
providers: [
|
||
TasksService,
|
||
{
|
||
provide: getRepositoryToken(Task),
|
||
useFactory: repositoryMockFactory,
|
||
},
|
||
{
|
||
provide: EventEmitter2,
|
||
useValue: { emit: vi.fn() },
|
||
},
|
||
],
|
||
}).compile();
|
||
|
||
service = module.get(TasksService);
|
||
repository = module.get(getRepositoryToken(Task));
|
||
});
|
||
|
||
describe('create', () => {
|
||
it('creates a task with default values', async () => {
|
||
const dto: CreateTaskDto = {
|
||
domainId: 'uuid-1',
|
||
title: 'Test task',
|
||
};
|
||
repository.create.mockReturnValue({ ...dto, id: 'uuid-new' });
|
||
repository.save.mockResolvedValue({ ...dto, id: 'uuid-new', status: 'todo' });
|
||
|
||
const result = await service.create(dto);
|
||
|
||
expect(result.status).toBe('todo');
|
||
expect(repository.save).toHaveBeenCalled();
|
||
});
|
||
|
||
it('auto-flags quick wins when estimated under 5 minutes', async () => {
|
||
const dto: CreateTaskDto = {
|
||
domainId: 'uuid-1',
|
||
title: 'Quick task',
|
||
estimatedMinutes: 3,
|
||
};
|
||
repository.create.mockReturnValue({ ...dto, isQuickWin: true });
|
||
repository.save.mockResolvedValue({ ...dto, isQuickWin: true });
|
||
|
||
const result = await service.create(dto);
|
||
|
||
expect(result.isQuickWin).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe('updateStatus', () => {
|
||
it('emits TaskCompletedEvent when status changes to done', async () => {
|
||
const task = { id: 'uuid-1', domainId: 'uuid-d', status: 'todo' };
|
||
repository.findOne.mockResolvedValue(task);
|
||
repository.save.mockResolvedValue({ ...task, status: 'done' });
|
||
|
||
await service.updateStatus('uuid-1', { status: 'done' });
|
||
|
||
expect(eventEmitter.emit).toHaveBeenCalledWith(
|
||
'task.completed',
|
||
expect.any(TaskCompletedEvent),
|
||
);
|
||
});
|
||
});
|
||
});
|
||
```
|
||
|
||
### What to Unit Test
|
||
|
||
- **Service methods:** Create, update, delete logic and edge cases
|
||
- **Entity validation:** DTO constraint validation (class-validator)
|
||
- **Business rules:** Quick win auto-flagging, streak calculation logic, overdue detection, suggestion scoring
|
||
- **Event emission:** Correct events emitted on state transitions
|
||
- **Error handling:** Not-found, validation failures, constraint violations
|
||
|
||
---
|
||
|
||
## Backend Integration Tests
|
||
|
||
### Scope
|
||
|
||
Test controller endpoints with a real test database. Validates request/response shapes, query filtering, pagination, and encrypted field round-trips.
|
||
|
||
### Pattern
|
||
|
||
```typescript
|
||
// modules/tasks/tasks.controller.spec.ts
|
||
|
||
describe('TasksController (integration)', () => {
|
||
let app: INestApplication;
|
||
|
||
beforeAll(async () => {
|
||
const module = await Test.createTestingModule({
|
||
imports: [AppModule],
|
||
})
|
||
.overrideProvider(ConfigService)
|
||
.useValue(testConfigService)
|
||
.compile();
|
||
|
||
app = module.createNestApplication();
|
||
applyGlobalPipes(app);
|
||
await app.init();
|
||
});
|
||
|
||
afterAll(async () => {
|
||
await app.close();
|
||
});
|
||
|
||
beforeEach(async () => {
|
||
await truncateAllTables(app);
|
||
await seedTestDomains(app);
|
||
});
|
||
|
||
describe('GET /api/tasks', () => {
|
||
it('returns paginated tasks filtered by domain', async () => {
|
||
await createTestTask(app, { domainId: domain1.id, title: 'Task 1' });
|
||
await createTestTask(app, { domainId: domain2.id, title: 'Task 2' });
|
||
|
||
const response = await request(app.getHttpServer())
|
||
.get(`/api/tasks?domainId=${domain1.id}`)
|
||
.expect(200);
|
||
|
||
expect(response.body.data).toHaveLength(1);
|
||
expect(response.body.data[0].title).toBe('Task 1');
|
||
expect(response.body.meta.total).toBe(1);
|
||
});
|
||
|
||
it('filters by multiple statuses', async () => {
|
||
await createTestTask(app, { status: 'todo' });
|
||
await createTestTask(app, { status: 'in_progress' });
|
||
await createTestTask(app, { status: 'done' });
|
||
|
||
const response = await request(app.getHttpServer())
|
||
.get('/api/tasks?status=todo,in_progress')
|
||
.expect(200);
|
||
|
||
expect(response.body.data).toHaveLength(2);
|
||
});
|
||
});
|
||
|
||
describe('POST /api/tasks', () => {
|
||
it('validates required fields', async () => {
|
||
const response = await request(app.getHttpServer())
|
||
.post('/api/tasks')
|
||
.send({})
|
||
.expect(400);
|
||
|
||
expect(response.body.details).toContainEqual(
|
||
expect.objectContaining({ field: 'title' }),
|
||
);
|
||
});
|
||
});
|
||
});
|
||
```
|
||
|
||
### Encrypted Field Round-Trip Tests
|
||
|
||
```typescript
|
||
describe('IncomeController (encrypted)', () => {
|
||
it('encrypts amount on write and decrypts on read', async () => {
|
||
const createResponse = await request(app.getHttpServer())
|
||
.post('/api/income')
|
||
.send({
|
||
domainId: domain.id,
|
||
date: '2026-02-24',
|
||
amount: '150.00',
|
||
sourceType: 'session',
|
||
})
|
||
.expect(201);
|
||
|
||
// Verify stored as encrypted in DB
|
||
const raw = await queryRunner.query(
|
||
'SELECT amount_encrypted FROM income_entries WHERE id = $1',
|
||
[createResponse.body.id],
|
||
);
|
||
expect(raw[0].amount_encrypted).toBeInstanceOf(Buffer); // bytea, not plaintext
|
||
|
||
// Verify decrypted on read
|
||
const readResponse = await request(app.getHttpServer())
|
||
.get(`/api/income`)
|
||
.expect(200);
|
||
|
||
expect(readResponse.body.data[0].amount).toBe('150.00');
|
||
});
|
||
});
|
||
```
|
||
|
||
---
|
||
|
||
## Backend E2E Tests
|
||
|
||
### Scope
|
||
|
||
Full API workflow tests and WebSocket event tests. Test complete user flows spanning multiple endpoints.
|
||
|
||
### API Workflow Example
|
||
|
||
```typescript
|
||
describe('Daily workflow (e2e)', () => {
|
||
it('completes a full daily cycle', async () => {
|
||
// 1. Set morning plan
|
||
await request(app.getHttpServer())
|
||
.put('/api/scheduling/daily-plan/2026-02-24')
|
||
.send({ energyLevel: 'high', moodMorning: 4, morningIntention: 'Focus on sprint' })
|
||
.expect(200);
|
||
|
||
// 2. Create tasks
|
||
const task = await createTestTask(app, {
|
||
domainId: softwareDomain.id,
|
||
title: 'Implement auth guards',
|
||
dueDate: '2026-02-24',
|
||
});
|
||
|
||
// 3. Get today view
|
||
const today = await request(app.getHttpServer())
|
||
.get('/api/today')
|
||
.expect(200);
|
||
|
||
expect(today.body.dailyPlan.energyLevel).toBe('high');
|
||
expect(today.body.priorityTasks).toContainEqual(
|
||
expect.objectContaining({ title: 'Implement auth guards' }),
|
||
);
|
||
|
||
// 4. Complete task
|
||
await request(app.getHttpServer())
|
||
.patch(`/api/tasks/${task.id}/status`)
|
||
.send({ status: 'done', actualMinutes: 45 })
|
||
.expect(200);
|
||
|
||
// 5. Verify today updated
|
||
const todayAfter = await request(app.getHttpServer())
|
||
.get('/api/today')
|
||
.expect(200);
|
||
|
||
expect(todayAfter.body.completedToday).toBeGreaterThan(today.body.completedToday);
|
||
});
|
||
});
|
||
```
|
||
|
||
### WebSocket E2E Example
|
||
|
||
```typescript
|
||
describe('Chat WebSocket (e2e)', () => {
|
||
let socket: Socket;
|
||
|
||
beforeEach((done) => {
|
||
socket = io(`http://localhost:${port}/chat`);
|
||
socket.on('connect', done);
|
||
});
|
||
|
||
afterEach(() => {
|
||
socket.disconnect();
|
||
});
|
||
|
||
it('streams message chunks on send_message', (done) => {
|
||
const chunks: string[] = [];
|
||
|
||
socket.on('message_start', ({ messageId }) => {
|
||
expect(messageId).toBeDefined();
|
||
});
|
||
|
||
socket.on('message_chunk', ({ chunk }) => {
|
||
chunks.push(chunk);
|
||
});
|
||
|
||
socket.on('message_end', ({ messageId, tokenCount }) => {
|
||
expect(chunks.length).toBeGreaterThan(0);
|
||
expect(tokenCount).toBeGreaterThan(0);
|
||
done();
|
||
});
|
||
|
||
socket.emit('send_message', {
|
||
conversationId: testConversation.id,
|
||
content: 'What are my overdue tasks?',
|
||
});
|
||
});
|
||
});
|
||
```
|
||
|
||
---
|
||
|
||
## Frontend Unit Tests
|
||
|
||
### Scope
|
||
|
||
Component render tests and hook logic tests. No real API calls — everything mocked.
|
||
|
||
### Pattern
|
||
|
||
```typescript
|
||
// pages/tasks/TaskListItem.test.tsx
|
||
|
||
describe('TaskListItem', () => {
|
||
it('renders task title and badges', () => {
|
||
const task = createMockTask({
|
||
title: 'Design review',
|
||
priority: 'high',
|
||
energyLevel: 'high',
|
||
status: 'todo',
|
||
});
|
||
|
||
render(<TaskListItem task={task} onStatusChange={vi.fn()} />);
|
||
|
||
expect(screen.getByText('Design review')).toBeInTheDocument();
|
||
expect(screen.getByText('high')).toBeInTheDocument(); // priority badge
|
||
});
|
||
|
||
it('calls onStatusChange when checkbox clicked', async () => {
|
||
const onStatusChange = vi.fn();
|
||
const task = createMockTask({ status: 'todo' });
|
||
|
||
render(<TaskListItem task={task} onStatusChange={onStatusChange} />);
|
||
|
||
await userEvent.click(screen.getByRole('checkbox'));
|
||
|
||
expect(onStatusChange).toHaveBeenCalledWith('done');
|
||
});
|
||
});
|
||
```
|
||
|
||
### Hook Tests
|
||
|
||
```typescript
|
||
// api/tasks.test.ts
|
||
|
||
describe('useTasks', () => {
|
||
it('fetches tasks with filters', async () => {
|
||
const wrapper = createQueryClientWrapper();
|
||
|
||
server.use(
|
||
rest.get('/api/tasks', (req, res, ctx) => {
|
||
expect(req.url.searchParams.get('domainId')).toBe('uuid-1');
|
||
return res(ctx.json({ data: [mockTask], meta: { page: 1, limit: 20, total: 1, totalPages: 1 } }));
|
||
}),
|
||
);
|
||
|
||
const { result } = renderHook(
|
||
() => useTasks({ domainId: 'uuid-1' }),
|
||
{ wrapper },
|
||
);
|
||
|
||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||
expect(result.current.data.data).toHaveLength(1);
|
||
});
|
||
});
|
||
```
|
||
|
||
---
|
||
|
||
## Frontend Integration Tests
|
||
|
||
### Scope
|
||
|
||
Page-level tests with MSW (Mock Service Worker) for API mocking. Test complete page rendering and user interactions.
|
||
|
||
### Pattern
|
||
|
||
```typescript
|
||
// pages/tasks/TasksPage.test.tsx
|
||
|
||
describe('TasksPage', () => {
|
||
beforeEach(() => {
|
||
server.use(
|
||
rest.get('/api/tasks', (req, res, ctx) => {
|
||
return res(ctx.json({
|
||
data: [
|
||
createMockTask({ title: 'Overdue task', dueDate: '2026-02-20', status: 'todo' }),
|
||
createMockTask({ title: 'Today task', dueDate: '2026-02-24', status: 'todo' }),
|
||
],
|
||
meta: { page: 1, limit: 20, total: 2, totalPages: 1 },
|
||
}));
|
||
}),
|
||
rest.get('/api/domains', (req, res, ctx) => {
|
||
return res(ctx.json(mockDomains));
|
||
}),
|
||
);
|
||
});
|
||
|
||
it('renders tasks grouped by urgency', async () => {
|
||
render(<TasksPage />, { wrapper: AppProviders });
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByText('Overdue task')).toBeInTheDocument();
|
||
expect(screen.getByText('Today task')).toBeInTheDocument();
|
||
});
|
||
|
||
// Overdue group appears before today group
|
||
const overdue = screen.getByText('OVERDUE');
|
||
const today = screen.getByText('DUE TODAY');
|
||
expect(overdue.compareDocumentPosition(today))
|
||
.toBe(Node.DOCUMENT_POSITION_FOLLOWING);
|
||
});
|
||
|
||
it('filters tasks when filter changes', async () => {
|
||
render(<TasksPage />, { wrapper: AppProviders });
|
||
|
||
await userEvent.click(screen.getByText('Quick wins only'));
|
||
|
||
await waitFor(() => {
|
||
// MSW handler checks isQuickWin param
|
||
expect(screen.getByText('Quick tasks')).toBeInTheDocument();
|
||
});
|
||
});
|
||
});
|
||
```
|
||
|
||
---
|
||
|
||
## Test Database
|
||
|
||
### Configuration
|
||
|
||
Separate PostgreSQL database for tests, created via Docker Compose:
|
||
|
||
```yaml
|
||
# docker-compose.test.yml (extends docker-compose.yml)
|
||
services:
|
||
postgres-test:
|
||
image: postgres:16-alpine
|
||
ports:
|
||
- "25471:5432"
|
||
environment:
|
||
POSTGRES_DB: life_manager_test
|
||
POSTGRES_USER: lilith
|
||
POSTGRES_PASSWORD: test_password
|
||
tmpfs:
|
||
- /var/lib/postgresql/data # RAM-backed for speed
|
||
```
|
||
|
||
### Lifecycle
|
||
|
||
```
|
||
1. Before test suite: Run all migrations on test DB
|
||
2. Before each test: Truncate all tables (preserve schema)
|
||
3. Each test: Seed needed data → run assertions → data discarded
|
||
4. After test suite: Drop test DB (or leave for debugging)
|
||
```
|
||
|
||
### Truncation Helper
|
||
|
||
```typescript
|
||
export async function truncateAllTables(app: INestApplication) {
|
||
const dataSource = app.get(DataSource);
|
||
const tables = dataSource.entityMetadatas.map(e => `"${e.tableName}"`).join(', ');
|
||
await dataSource.query(`TRUNCATE TABLE ${tables} CASCADE`);
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Coverage Targets
|
||
|
||
| Layer | Target | Rationale |
|
||
|-------|--------|-----------|
|
||
| Backend services | 80% | Core business logic — highest value |
|
||
| Backend controllers | 70% | Request/response contracts |
|
||
| Frontend components | 60% | UI rendering correctness |
|
||
| Frontend hooks | 70% | Data fetching logic |
|
||
| E2E workflows | Key paths | Critical user journeys |
|
||
|
||
### Coverage Commands
|
||
|
||
```bash
|
||
# Run all tests with coverage
|
||
pnpm test -- --coverage
|
||
|
||
# Backend only
|
||
pnpm --filter backend-api test -- --coverage
|
||
|
||
# Frontend only
|
||
pnpm --filter frontend test -- --coverage
|
||
```
|
||
|
||
---
|
||
|
||
## CI Pipeline
|
||
|
||
```bash
|
||
# turbo runs tests in dependency order: shared → backend-api → frontend
|
||
turbo test
|
||
|
||
# Pipeline definition (turbo.json):
|
||
{
|
||
"pipeline": {
|
||
"build": { "dependsOn": ["^build"] },
|
||
"typecheck": { "dependsOn": ["^build"] },
|
||
"test": { "dependsOn": ["build"], "outputs": ["coverage/**"] },
|
||
"test:e2e": { "dependsOn": ["build"] }
|
||
}
|
||
}
|
||
```
|
||
|
||
### CI Steps
|
||
|
||
1. `pnpm install --frozen-lockfile`
|
||
2. `docker compose -f docker-compose.test.yml up -d` (test DB)
|
||
3. `turbo build` (compile shared → backend → frontend)
|
||
4. `turbo typecheck` (TypeScript checks)
|
||
5. `turbo test` (unit + integration tests with coverage)
|
||
6. `turbo test:e2e` (e2e tests against test DB)
|
||
7. Coverage report upload
|