life-docs/testing.md
2026-03-20 09:32:20 -07:00

549 lines
14 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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