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

14 KiB
Raw Permalink Blame History

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

  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