14 KiB
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
// 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
// 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
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
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
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
// 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
// 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
// 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:
# 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
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
# 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
# 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
pnpm install --frozen-lockfiledocker compose -f docker-compose.test.yml up -d(test DB)turbo build(compile shared → backend → frontend)turbo typecheck(TypeScript checks)turbo test(unit + integration tests with coverage)turbo test:e2e(e2e tests against test DB)- Coverage report upload