managarten/.claude/guidelines/testing.md
Till JS ab387b9b3d chore: remove all NestJS backend references, replace with Hono/Bun
- Delete nestjs-backend.md guideline (replaced by hono-server.md)
- Delete Dockerfile.nestjs-base and Dockerfile.nestjs templates
- Delete stale BACKEND_ARCHITECTURE.md doc (NestJS-era, obsolete)
- Update CLAUDE.md, GUIDELINES.md, authentication.md to Hono/Bun first
- Update all app CLAUDE.md files: backend/ → server/, NestJS → Hono+Bun
- Update all app package.json files: @*/backend → @*/server
- Update docs: LOCAL_DEVELOPMENT, PORT_SCHEMA, ENVIRONMENT_VARIABLES,
  DATABASE_MIGRATIONS, MAC_MINI_SERVER, PROJECT_OVERVIEW
- Update scripts: generate-env.mjs, setup-databases.sh, build-app.sh
- Update CI/CD: cd-macmini.yml backend → server paths
- Update Astro docs site: @chat/backend → @chat/server

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 16:52:25 +02:00

15 KiB

Testing Guidelines

Overview

App Type Framework Config File Pattern
NestJS Backend Jest + ts-jest jest.config.js *.spec.ts
Expo Mobile Jest + jest-expo jest.config.js *.test.tsx
SvelteKit Web Vitest vitest.config.ts *.test.ts
E2E Playwright playwright.config.ts e2e/*.spec.ts

Coverage Requirements

// Target: 80% for all new code
coverageThresholds: {
  global: {
    branches: 80,
    functions: 80,
    lines: 80,
    statements: 80,
  },
}

Test File Organization

src/
├── __tests__/
│   ├── utils/
│   │   ├── mock-factories.ts    # Centralized factories
│   │   └── test-helpers.ts      # Shared utilities
│   └── fixtures/                # Test data files
├── feature/
│   ├── feature.service.ts
│   └── feature.service.spec.ts  # Colocated test
└── ...

Mock Factories Pattern

Create reusable factories for test data:

// src/__tests__/utils/mock-factories.ts
import { nanoid } from 'nanoid';

export const mockUserFactory = {
  create: (overrides: Partial<User> = {}): User => ({
    id: nanoid(),
    email: `test-${nanoid(6)}@example.com`,
    name: 'Test User',
    role: 'user',
    createdAt: new Date(),
    updatedAt: new Date(),
    ...overrides,
  }),

  createMany: (count: number, overrides: Partial<User> = {}): User[] => {
    return Array.from({ length: count }, () => mockUserFactory.create(overrides));
  },
};

export const mockFileFactory = {
  create: (overrides: Partial<File> = {}): File => ({
    id: nanoid(),
    userId: nanoid(),
    name: `file-${nanoid(6)}.txt`,
    mimeType: 'text/plain',
    size: 1024,
    storagePath: `/files/${nanoid()}`,
    storageKey: nanoid(),
    isDeleted: false,
    isFavorite: false,
    createdAt: new Date(),
    updatedAt: new Date(),
    ...overrides,
  }),

  createMany: (count: number, overrides: Partial<File> = {}): File[] => {
    return Array.from({ length: count }, () => mockFileFactory.create(overrides));
  },
};

// Usage in tests:
const user = mockUserFactory.create({ role: 'admin' });
const files = mockFileFactory.createMany(5, { userId: user.id });

Test Helpers

// src/__tests__/utils/test-helpers.ts
import { ConfigService } from '@nestjs/config';

// Mock config service
export function createMockConfigService(overrides: Record<string, any> = {}) {
  const config: Record<string, any> = {
    DATABASE_URL: 'postgresql://test:test@localhost:5432/test',
    MANA_CORE_AUTH_URL: 'http://localhost:3001',
    ...overrides,
  };

  return {
    get: jest.fn((key: string) => config[key]),
    getOrThrow: jest.fn((key: string) => {
      if (!(key in config)) throw new Error(`Missing config: ${key}`);
      return config[key];
    }),
  } as unknown as ConfigService;
}

// Mock database with chainable methods
export function createMockDb() {
  const results: any[] = [];
  let resultIndex = 0;

  const mockDb = {
    select: jest.fn().mockReturnThis(),
    from: jest.fn().mockReturnThis(),
    where: jest.fn().mockReturnThis(),
    limit: jest.fn().mockReturnThis(),
    offset: jest.fn().mockReturnThis(),
    orderBy: jest.fn().mockReturnThis(),
    insert: jest.fn().mockReturnThis(),
    values: jest.fn().mockReturnThis(),
    update: jest.fn().mockReturnThis(),
    set: jest.fn().mockReturnThis(),
    delete: jest.fn().mockReturnThis(),
    returning: jest.fn().mockReturnThis(),
    leftJoin: jest.fn().mockReturnThis(),
    transaction: jest.fn(),

    // Thenable for await
    then: jest.fn((resolve) => resolve(results[resultIndex++] || [])),

    // Helper to set results
    mockResults: (...newResults: any[]) => {
      results.length = 0;
      results.push(...newResults);
      resultIndex = 0;
    },

    // Reset all mocks
    reset: () => {
      jest.clearAllMocks();
      results.length = 0;
      resultIndex = 0;
    },
  };

  return mockDb;
}

// Assertion helpers
export const assertHelpers = {
  assertIsUuid: (value: string) => {
    expect(value).toMatch(
      /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
    );
  },

  assertIsRecent: (date: Date, toleranceMs = 5000) => {
    const now = Date.now();
    expect(date.getTime()).toBeGreaterThan(now - toleranceMs);
    expect(date.getTime()).toBeLessThanOrEqual(now);
  },

  assertResultOk: <T>(result: Result<T>): T => {
    expect(result.ok).toBe(true);
    if (!result.ok) throw new Error('Expected ok result');
    return result.data;
  },

  assertResultErr: (result: Result<any>, expectedCode?: ErrorCode) => {
    expect(result.ok).toBe(false);
    if (result.ok) throw new Error('Expected error result');
    if (expectedCode) {
      expect(result.error.code).toBe(expectedCode);
    }
    return result.error;
  },
};

NestJS Unit Tests

Service Tests

// src/files/file.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { FileService } from './file.service';
import { DATABASE_CONNECTION } from '../db/database.module';
import { mockFileFactory, createMockDb, assertHelpers } from '../__tests__/utils';
import { ErrorCode } from '@manacore/shared-errors';

describe('FileService', () => {
  let service: FileService;
  let mockDb: ReturnType<typeof createMockDb>;

  beforeEach(async () => {
    mockDb = createMockDb();

    const module: TestingModule = await Test.createTestingModule({
      providers: [
        FileService,
        { provide: DATABASE_CONNECTION, useValue: mockDb },
      ],
    }).compile();

    service = module.get<FileService>(FileService);
  });

  afterEach(() => {
    mockDb.reset();
  });

  describe('findById', () => {
    it('should return file when found', async () => {
      const mockFile = mockFileFactory.create();
      mockDb.mockResults([mockFile]);

      const result = await service.findById(mockFile.id, mockFile.userId);

      const file = assertHelpers.assertResultOk(result);
      expect(file.id).toBe(mockFile.id);
      expect(mockDb.select).toHaveBeenCalled();
    });

    it('should return NOT_FOUND error when file does not exist', async () => {
      mockDb.mockResults([]);

      const result = await service.findById('non-existent', 'user-123');

      const error = assertHelpers.assertResultErr(result, ErrorCode.FILE_NOT_FOUND);
      expect(error.message).toContain('not found');
    });

    it('should not return files belonging to other users', async () => {
      mockDb.mockResults([]);  // Query returns empty due to userId filter

      const result = await service.findById('file-123', 'different-user');

      assertHelpers.assertResultErr(result, ErrorCode.FILE_NOT_FOUND);
    });
  });

  describe('create', () => {
    it('should create and return new file', async () => {
      const userId = 'user-123';
      const dto = {
        name: 'test.txt',
        mimeType: 'text/plain',
        size: 1024,
        storagePath: '/files/test.txt',
        storageKey: 'key-123',
      };
      const createdFile = mockFileFactory.create({ ...dto, userId });
      mockDb.mockResults([createdFile]);

      const result = await service.create(userId, dto);

      const file = assertHelpers.assertResultOk(result);
      expect(file.name).toBe(dto.name);
      expect(mockDb.insert).toHaveBeenCalled();
    });

    it('should return validation error for empty name', async () => {
      const result = await service.create('user-123', {
        name: '',
        mimeType: 'text/plain',
        size: 100,
        storagePath: '/test',
        storageKey: 'key',
      });

      assertHelpers.assertResultErr(result, ErrorCode.MISSING_REQUIRED_FIELD);
    });
  });
});

Controller Tests

// src/files/file.controller.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { FileController } from './file.controller';
import { FileService } from './file.service';
import { JwtAuthGuard } from '@manacore/shared-nestjs-auth';
import { mockFileFactory } from '../__tests__/utils';
import { ok, err, ErrorCode, AppException } from '@manacore/shared-errors';

describe('FileController', () => {
  let controller: FileController;
  let fileService: jest.Mocked<FileService>;

  const mockUser = { userId: 'user-123', email: 'test@example.com', role: 'user' };

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      controllers: [FileController],
      providers: [
        {
          provide: FileService,
          useValue: {
            findById: jest.fn(),
            create: jest.fn(),
            delete: jest.fn(),
          },
        },
      ],
    })
      .overrideGuard(JwtAuthGuard)
      .useValue({ canActivate: () => true })
      .compile();

    controller = module.get<FileController>(FileController);
    fileService = module.get(FileService) as jest.Mocked<FileService>;
  });

  describe('GET /files/:id', () => {
    it('should return file when found', async () => {
      const mockFile = mockFileFactory.create();
      fileService.findById.mockResolvedValue(ok(mockFile));

      const result = await controller.getFile(mockFile.id, mockUser);

      expect(result.file).toEqual(mockFile);
      expect(fileService.findById).toHaveBeenCalledWith(mockFile.id, mockUser.userId);
    });

    it('should throw AppException when file not found', async () => {
      fileService.findById.mockResolvedValue(
        err(ErrorCode.FILE_NOT_FOUND, 'File not found')
      );

      await expect(controller.getFile('non-existent', mockUser))
        .rejects
        .toThrow(AppException);
    });
  });

  describe('Guards', () => {
    it('should have JwtAuthGuard applied', () => {
      const guards = Reflect.getMetadata('__guards__', FileController);
      expect(guards).toContain(JwtAuthGuard);
    });
  });
});

Vitest (SvelteKit) Tests

Store Tests

// src/lib/stores/files.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { fileStore } from './files.svelte';
import { api } from '$lib/api/client';

vi.mock('$lib/api/client', () => ({
  api: {
    files: {
      list: vi.fn(),
      delete: vi.fn(),
    },
  },
}));

describe('fileStore', () => {
  beforeEach(() => {
    vi.clearAllMocks();
    fileStore.reset();
  });

  it('should load files successfully', async () => {
    const mockFiles = [
      { id: '1', name: 'file1.txt' },
      { id: '2', name: 'file2.txt' },
    ];
    vi.mocked(api.files.list).mockResolvedValue({ ok: true, data: mockFiles });

    await fileStore.loadFiles();

    expect(fileStore.files).toEqual(mockFiles);
    expect(fileStore.loading).toBe(false);
    expect(fileStore.error).toBeNull();
  });

  it('should handle load error', async () => {
    vi.mocked(api.files.list).mockResolvedValue({
      ok: false,
      error: { code: 'ERR_7001', message: 'Database error' },
    });

    await fileStore.loadFiles();

    expect(fileStore.files).toEqual([]);
    expect(fileStore.error).toBe('Database error');
  });

  it('should remove file from list after delete', async () => {
    fileStore.files = [
      { id: '1', name: 'file1.txt' },
      { id: '2', name: 'file2.txt' },
    ];
    vi.mocked(api.files.delete).mockResolvedValue({ ok: true, data: undefined });

    await fileStore.deleteFile('1');

    expect(fileStore.files).toHaveLength(1);
    expect(fileStore.files[0].id).toBe('2');
  });
});

Component Tests

// src/lib/components/FileItem.test.ts
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
import FileItem from './FileItem.svelte';

describe('FileItem', () => {
  const mockFile = {
    id: '1',
    name: 'document.pdf',
    size: 1024,
    mimeType: 'application/pdf',
    createdAt: new Date('2024-01-01'),
  };

  it('should render file name', () => {
    render(FileItem, { props: { file: mockFile } });

    expect(screen.getByText('document.pdf')).toBeInTheDocument();
  });

  it('should format file size', () => {
    render(FileItem, { props: { file: mockFile } });

    expect(screen.getByText('1 KB')).toBeInTheDocument();
  });

  it('should call onDelete when delete button clicked', async () => {
    const onDelete = vi.fn();
    render(FileItem, { props: { file: mockFile, onDelete } });

    const deleteButton = screen.getByRole('button', { name: /delete/i });
    await fireEvent.click(deleteButton);

    expect(onDelete).toHaveBeenCalledWith(mockFile.id);
  });
});

E2E Tests (Playwright)

Configuration

// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,

  use: {
    baseURL: 'http://localhost:5173',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
  },

  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'Mobile Safari', use: { ...devices['iPhone 12'] } },
  ],

  webServer: {
    command: 'pnpm run build && pnpm run preview',
    port: 5173,
    reuseExistingServer: !process.env.CI,
  },
});

E2E Test Example

// e2e/file-upload.spec.ts
import { test, expect } from '@playwright/test';

test.describe('File Upload', () => {
  test.beforeEach(async ({ page }) => {
    // Login before each test
    await page.goto('/login');
    await page.fill('[name="email"]', 'test@example.com');
    await page.fill('[name="password"]', 'password123');
    await page.click('button[type="submit"]');
    await expect(page).toHaveURL('/files');
  });

  test('should upload a file successfully', async ({ page }) => {
    // Open upload dialog
    await page.click('button:has-text("Upload")');

    // Select file
    const fileInput = page.locator('input[type="file"]');
    await fileInput.setInputFiles('./e2e/fixtures/test-file.txt');

    // Wait for upload
    await expect(page.getByText('test-file.txt')).toBeVisible();
    await page.click('button:has-text("Upload")');

    // Verify file appears in list
    await expect(page.getByRole('listitem', { name: 'test-file.txt' })).toBeVisible();
  });

  test('should show error for oversized file', async ({ page }) => {
    await page.click('button:has-text("Upload")');

    const fileInput = page.locator('input[type="file"]');
    await fileInput.setInputFiles('./e2e/fixtures/large-file.zip');

    await expect(page.getByText(/file too large/i)).toBeVisible();
  });
});

Running Tests

# Run all tests
pnpm test

# Run with coverage
pnpm test:cov

# Run specific project
pnpm --filter @storage/server test

# Run in watch mode
pnpm test:watch

# Run E2E tests
pnpm test:e2e

# Run E2E in headed mode
pnpm test:e2e --headed

Best Practices

Do's

  1. Use factories for consistent test data
  2. Test behavior, not implementation
  3. One assertion per test when possible
  4. Clean up after each test (reset mocks, state)
  5. Use descriptive test names that explain expected behavior

Don'ts

  1. Don't test framework code - trust NestJS, Svelte, etc.
  2. Don't mock everything - integration tests are valuable
  3. Don't test private methods - test through public API
  4. Don't share state between tests - each test should be independent
  5. Don't write flaky tests - fix or remove them