# 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 ```javascript // 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: ```typescript // src/__tests__/utils/mock-factories.ts import { nanoid } from 'nanoid'; export const mockUserFactory = { create: (overrides: Partial = {}): 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[] => { return Array.from({ length: count }, () => mockUserFactory.create(overrides)); }, }; export const mockFileFactory = { create: (overrides: Partial = {}): 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[] => { 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 ```typescript // src/__tests__/utils/test-helpers.ts import { ConfigService } from '@nestjs/config'; // Mock config service export function createMockConfigService(overrides: Record = {}) { const config: Record = { 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: (result: Result): T => { expect(result.ok).toBe(true); if (!result.ok) throw new Error('Expected ok result'); return result.data; }, assertResultErr: (result: Result, 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 ```typescript // 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; beforeEach(async () => { mockDb = createMockDb(); const module: TestingModule = await Test.createTestingModule({ providers: [ FileService, { provide: DATABASE_CONNECTION, useValue: mockDb }, ], }).compile(); service = module.get(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 ```typescript // 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; 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); fileService = module.get(FileService) as jest.Mocked; }); 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 ```typescript // 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 ```typescript // 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 ```typescript // 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 ```typescript // 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 ```bash # Run all tests pnpm test # Run with coverage pnpm test:cov # Run specific project pnpm --filter @storage/backend 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