managarten/docs/TESTING.md
2025-11-27 17:26:18 +01:00

1644 lines
42 KiB
Markdown

# Testing Strategy for Manacore Monorepo
## Table of Contents
- [Overview](#overview)
- [Current State Analysis](#current-state-analysis)
- [Testing Infrastructure by App Type](#testing-infrastructure-by-app-type)
- [Test Organization](#test-organization)
- [Coverage Strategy](#coverage-strategy)
- [Testing Scenarios](#testing-scenarios)
- [CI/CD Integration](#cicd-integration)
- [Implementation Roadmap](#implementation-roadmap)
- [Best Practices](#best-practices)
## Overview
This document outlines the comprehensive automated testing strategy for the Manacore monorepo. The goal is to achieve 80% test coverage for new code while maintaining quality and development velocity.
### Goals
- **80% coverage minimum** for new code
- **100% coverage** for critical paths (auth, payments, data integrity)
- **Fast feedback loops** (<5 minutes for unit tests)
- **Consistent patterns** across all projects
- **Automated testing** in CI/CD pipeline
- **Developer-friendly** test writing experience
## Current State Analysis
### Existing Test Files (25 total)
#### By Project
**Maerchenzauber (13 files, ~4,182 total lines)**:
- Mobile: 5 comprehensive auth flow tests (excellent pattern)
- Backend: 8 NestJS service/controller tests
**Memoro (3 files)**:
- Mobile: Video edge cases, UploadModal integration, media utils
**Uload (9 files)**:
- Web: Vitest unit tests + Playwright E2E (good foundation)
### Testing Infrastructure Currently in Use
| App Type | Framework | Config | Coverage Tool |
|----------|-----------|--------|---------------|
| NestJS Backend | Jest | Inline in package.json | Jest coverage |
| React Native Mobile | Jest + jest-expo | Inline in package.json | Not configured |
| SvelteKit Web | Vitest + Playwright | Separate configs | vitest coverage-v8 |
| Astro Landing | None | - | - |
### Key Findings
**Strengths**:
- Maerchenzauber mobile auth tests show excellent patterns (comprehensive, well-organized)
- Uload web demonstrates good Vitest + Playwright setup
- NestJS backends have Jest configured
**Gaps**:
- No shared test utilities across projects
- No coverage thresholds enforced
- No CI/CD test automation (only 2 backend deployment workflows)
- Sparse coverage in most projects
- No E2E testing for mobile apps
- No shared package tests
- No integration tests with real Supabase
## Testing Infrastructure by App Type
### 1. NestJS Backends
**Framework**: Jest (built-in with NestJS)
**Key Features**:
- Controller unit tests with mocked services
- Service tests with dependency injection
- Integration tests with TestingModule
- E2E tests with supertest
- Supabase client mocking
**Configuration**: `jest.config.js`
```javascript
module.exports = {
moduleFileExtensions: ['js', 'json', 'ts'],
rootDir: 'src',
testRegex: '.*\\.spec\\.ts$',
transform: {
'^.+\\.(t|j)s$': 'ts-jest',
},
collectCoverageFrom: [
'**/*.(t|j)s',
'!**/*.module.ts',
'!**/*.interface.ts',
'!**/main.ts',
'!**/*.dto.ts',
],
coverageDirectory: '../coverage',
testEnvironment: 'node',
coverageThresholds: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
};
```
**Test Structure**:
```
src/
├── module-name/
│ ├── module-name.controller.ts
│ ├── module-name.service.ts
│ ├── __tests__/ # Preferred location
│ │ ├── module-name.controller.spec.ts
│ │ ├── module-name.service.spec.ts
│ │ └── module-name.integration.spec.ts
│ └── dto/
test/ # E2E tests only
└── e2e/
└── module-name.e2e-spec.ts
```
### 2. React Native Mobile (Expo)
**Framework**: Jest + React Native Testing Library
**Key Features**:
- Component rendering tests
- Navigation flow tests
- Zustand store tests
- API integration mocks (MSW)
- Platform-specific tests
- expo-secure-store mocking
**Configuration**: `jest.config.js`
```javascript
module.exports = {
preset: 'jest-expo',
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
testMatch: [
'**/__tests__/**/*.test.[jt]s?(x)',
'**/?(*.)+(spec|test).[jt]s?(x)',
],
testPathIgnorePatterns: [
'/node_modules/',
'/__tests__/utils/',
'/__tests__/fixtures/',
],
transformIgnorePatterns: [
'node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@unimodules/.*|unimodules|native-base|react-native-svg)',
],
collectCoverageFrom: [
'src/**/*.{ts,tsx}',
'app/**/*.{ts,tsx}',
'!**/*.d.ts',
'!**/node_modules/**',
'!**/__tests__/**',
],
coverageDirectory: 'coverage',
coverageThresholds: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
};
```
**Test Structure**:
```
src/
├── components/
│ ├── Button/
│ │ ├── Button.tsx
│ │ └── __tests__/
│ │ ├── Button.test.tsx
│ │ └── Button.integration.test.tsx
│ └── __tests__/ # Shared component tests
├── services/
│ ├── authService.ts
│ └── __tests__/
│ └── authService.test.ts
├── hooks/
│ ├── useAuth.ts
│ └── __tests__/
│ └── useAuth.test.ts
└── utils/
└── __tests__/
└── api.test.ts
app/
└── (tabs)/
└── __tests__/
└── navigation.test.tsx
```
### 3. SvelteKit Web Apps
**Framework**: Vitest (unit) + Playwright (E2E)
**Key Features**:
- Component unit tests (Svelte 5 runes)
- Page/route tests
- SSR behavior tests
- Form validation tests
- Store tests
- Accessibility tests
**Configuration**: `vitest.config.ts`
```typescript
import { defineConfig } from 'vitest/config';
import { sveltekit } from '@sveltejs/kit/vite';
export default defineConfig({
plugins: [sveltekit()],
test: {
include: ['src/**/*.{test,spec}.{js,ts}'],
exclude: ['e2e/**', 'node_modules/**'],
environment: 'jsdom',
globals: true,
setupFiles: ['./vitest.setup.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html', 'lcov'],
include: ['src/**/*.{js,ts,svelte}'],
exclude: [
'**/*.d.ts',
'**/*.config.*',
'**/mockData/**',
'**/__tests__/**',
],
thresholds: {
lines: 80,
functions: 80,
branches: 80,
statements: 80,
},
},
},
});
```
**Playwright Config**: `playwright.config.ts`
```typescript
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,
reporter: process.env.CI ? 'github' : 'html',
use: {
baseURL: 'http://localhost:5173',
trace: 'on-first-retry',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
],
webServer: {
command: 'pnpm run build && pnpm run preview',
port: 5173,
reuseExistingServer: !process.env.CI,
},
});
```
**Test Structure**:
```
src/
├── lib/
│ ├── components/
│ │ └── Button/
│ │ ├── Button.svelte
│ │ └── Button.test.ts
│ ├── stores/
│ │ ├── auth.svelte.ts
│ │ └── auth.test.ts
│ └── utils/
│ ├── cache.ts
│ └── cache.test.ts
├── routes/
│ ├── (app)/
│ │ ├── dashboard/
│ │ │ ├── +page.svelte
│ │ │ ├── +page.server.ts
│ │ │ └── +page.server.test.ts
e2e/
├── auth.spec.ts
├── dashboard.spec.ts
└── helpers/
└── test-utils.ts
```
### 4. Astro Landing Pages
**Framework**: Vitest
**Key Features**:
- Component tests
- Static content validation
- Link checking
- Build output validation
**Configuration**: `vitest.config.ts`
```typescript
import { defineConfig } from 'vitest/config';
import { getViteConfig } from 'astro/config';
export default defineConfig(
getViteConfig({
test: {
include: ['src/**/*.{test,spec}.{js,ts}'],
environment: 'jsdom',
globals: true,
},
})
);
```
### 5. Shared Packages
**Framework**: Vitest (lightweight, fast)
**Configuration**: Create `packages/vitest.config.base.ts`
```typescript
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['src/**/*.{test,spec}.{js,ts}'],
environment: 'node',
globals: true,
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'lcov'],
include: ['src/**/*.{js,ts}'],
exclude: ['**/*.d.ts', '**/__tests__/**'],
thresholds: {
lines: 80,
functions: 80,
branches: 80,
statements: 80,
},
},
},
});
```
## Test Organization
### Directory Structure Convention
**Preferred Pattern**: `__tests__/` directories co-located with source code
```
src/
├── feature/
│ ├── feature.ts
│ └── __tests__/
│ ├── feature.test.ts
│ ├── feature.integration.test.ts
│ └── fixtures/
│ └── mockData.ts
```
**Alternative**: Side-by-side (for simple files)
```
src/
├── utils/
│ ├── format.ts
│ └── format.test.ts
```
### File Naming Conventions
- **Unit tests**: `*.test.ts` or `*.spec.ts`
- **Integration tests**: `*.integration.test.ts`
- **E2E tests**: `*.e2e.spec.ts` or `*.spec.ts` (in e2e/ directory)
- **Test utilities**: `test-utils.ts`, `*TestUtils.ts`
- **Fixtures**: `fixtures/` directory or `mockData.ts`
### Test Utilities
Create shared test utilities in `__tests__/utils/`:
```typescript
// Mobile: src/__tests__/utils/authTestUtils.ts
export const mockAuthService = {
signIn: jest.fn(),
signOut: jest.fn(),
refreshToken: jest.fn(),
};
export const createMockUser = (overrides = {}) => ({
id: 'test-user-id',
email: 'test@example.com',
...overrides,
});
// Backend: src/__tests__/utils/testHelpers.ts
export const createTestingModule = async (providers = []) => {
return Test.createTestingModule({
providers: [
...providers,
{ provide: ConfigService, useValue: mockConfigService },
],
}).compile();
};
// Web: src/lib/__tests__/utils/svelte-test-utils.ts
export const renderComponent = (Component, props = {}) => {
const { container } = render(Component, { props });
return { container, ...screen };
};
```
## Coverage Strategy
### Coverage Thresholds by Component Type
| Component Type | Threshold | Justification |
|----------------|-----------|---------------|
| **Critical Paths** | 100% | Auth, payments, data integrity |
| **Services/API Clients** | 90% | Core business logic |
| **Controllers/Routes** | 85% | Request handling |
| **Components** | 80% | UI layer |
| **Utilities** | 90% | Reusable functions |
| **Types/Interfaces** | Excluded | No runtime logic |
### Critical Paths Requiring 100% Coverage
1. **Authentication**:
- `@manacore/shared-auth` package
- `authService.ts` in all apps
- `tokenManager.ts`
- JWT verification logic
2. **Payment/Credit System**:
- Credit consumption logic
- Stripe integration
- Credit balance checks
- Transaction recording
3. **Data Integrity**:
- Database migrations
- RLS policy validation (via integration tests)
- User data validation
- File upload validation
### Coverage Reporting
**Local Development**:
```bash
# Generate coverage report
pnpm run test:cov
# View HTML report
open coverage/index.html
```
**CI/CD**: Coverage reports uploaded to Codecov or similar service
**Coverage Gates**:
- Pull requests must maintain or increase coverage
- New files must meet 80% threshold
- Critical paths must maintain 100%
## Testing Scenarios
### 1. NestJS Backend Tests
#### Controller Tests
```typescript
// src/story/__tests__/story.controller.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { StoryController } from '../story.controller';
import { StoryService } from '../story.service';
import { CreateStoryDto } from '../dto/create-story.dto';
describe('StoryController', () => {
let controller: StoryController;
let service: StoryService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [StoryController],
providers: [
{
provide: StoryService,
useValue: {
create: jest.fn(),
findAll: jest.fn(),
findOne: jest.fn(),
update: jest.fn(),
remove: jest.fn(),
},
},
],
}).compile();
controller = module.get<StoryController>(StoryController);
service = module.get<StoryService>(StoryService);
});
describe('create', () => {
it('should create a story', async () => {
const dto: CreateStoryDto = {
description: 'A magical adventure',
characterId: 'char-123',
};
const expectedResult = { id: 'story-123', ...dto };
jest.spyOn(service, 'create').mockResolvedValue(expectedResult);
const result = await controller.create(dto, { user: { sub: 'user-123' } });
expect(result).toEqual(expectedResult);
expect(service.create).toHaveBeenCalledWith(dto, 'user-123');
});
it('should handle validation errors', async () => {
const dto: CreateStoryDto = {
description: '', // Invalid
characterId: 'char-123',
};
await expect(controller.create(dto, { user: { sub: 'user-123' } }))
.rejects
.toThrow();
});
});
describe('findAll', () => {
it('should return user stories', async () => {
const stories = [{ id: 'story-1' }, { id: 'story-2' }];
jest.spyOn(service, 'findAll').mockResolvedValue(stories);
const result = await controller.findAll({ user: { sub: 'user-123' } });
expect(result).toEqual(stories);
expect(service.findAll).toHaveBeenCalledWith('user-123');
});
});
});
```
#### Service Tests with Mocked Dependencies
```typescript
// src/story/__tests__/story.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { StoryService } from '../story.service';
import { SupabaseDataService } from '../../core/services/supabase-data.service';
import { PromptingService } from '../../core/services/prompting.service';
import { IllustrationService } from '../illustration.service';
describe('StoryService', () => {
let service: StoryService;
let supabaseService: jest.Mocked<SupabaseDataService>;
let promptingService: jest.Mocked<PromptingService>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
StoryService,
{
provide: SupabaseDataService,
useValue: {
insertStory: jest.fn(),
getStory: jest.fn(),
updateStory: jest.fn(),
},
},
{
provide: PromptingService,
useValue: {
generateStoryText: jest.fn(),
generateIllustrationPrompt: jest.fn(),
},
},
{
provide: IllustrationService,
useValue: {
generateImage: jest.fn(),
},
},
],
}).compile();
service = module.get<StoryService>(StoryService);
supabaseService = module.get(SupabaseDataService);
promptingService = module.get(PromptingService);
});
describe('create', () => {
it('should generate and save a story', async () => {
const dto = {
description: 'A magical forest adventure',
characterId: 'char-123',
};
const userId = 'user-123';
promptingService.generateStoryText.mockResolvedValue({
pages: [{ text: 'Once upon a time...', pageNumber: 1 }],
});
supabaseService.insertStory.mockResolvedValue({
data: { id: 'story-123', ...dto },
error: null,
});
const result = await service.create(dto, userId);
expect(result.data).toBeDefined();
expect(result.data.id).toBe('story-123');
expect(promptingService.generateStoryText).toHaveBeenCalled();
expect(supabaseService.insertStory).toHaveBeenCalled();
});
it('should handle generation errors', async () => {
promptingService.generateStoryText.mockRejectedValue(
new Error('API error')
);
const result = await service.create(
{ description: 'Test', characterId: 'char-123' },
'user-123'
);
expect(result.error).toBeDefined();
expect(result.data).toBeNull();
});
});
});
```
#### Integration Tests with Supabase
```typescript
// src/story/__tests__/story.integration.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigModule } from '@nestjs/config';
import { StoryModule } from '../story.module';
import { SupabaseProvider } from '../../supabase/supabase.provider';
describe('StoryService Integration', () => {
let module: TestingModule;
beforeAll(async () => {
module = await Test.createTestingModule({
imports: [
ConfigModule.forRoot({
envFilePath: '.env.test', // Use test environment
}),
StoryModule,
],
}).compile();
});
afterAll(async () => {
await module.close();
});
it('should create story in test database', async () => {
// Integration test with real Supabase test project
// Uses seeded test data
});
});
```
#### E2E Tests with Supertest
```typescript
// test/e2e/story.e2e-spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from '../../src/app.module';
describe('Story API (e2e)', () => {
let app: INestApplication;
let authToken: string;
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
// Get auth token for tests
const authResponse = await request(app.getHttpServer())
.post('/auth/signin')
.send({ email: 'test@example.com', password: 'test123' });
authToken = authResponse.body.appToken;
});
afterAll(async () => {
await app.close();
});
describe('/story (POST)', () => {
it('should create a story', () => {
return request(app.getHttpServer())
.post('/story')
.set('Authorization', `Bearer ${authToken}`)
.send({
description: 'A magical adventure',
characterId: 'char-123',
})
.expect(201)
.expect((res) => {
expect(res.body.id).toBeDefined();
expect(res.body.description).toBe('A magical adventure');
});
});
it('should require authentication', () => {
return request(app.getHttpServer())
.post('/story')
.send({ description: 'Test' })
.expect(401);
});
});
describe('/story (GET)', () => {
it('should return user stories', () => {
return request(app.getHttpServer())
.get('/story')
.set('Authorization', `Bearer ${authToken}`)
.expect(200)
.expect((res) => {
expect(Array.isArray(res.body)).toBe(true);
});
});
});
});
```
### 2. React Native Mobile Tests
#### Component Tests
```typescript
// src/components/Button/__tests__/Button.test.tsx
import React from 'react';
import { render, fireEvent } from '@testing-library/react-native';
import { Button } from '../Button';
describe('Button', () => {
it('should render with text', () => {
const { getByText } = render(<Button>Click Me</Button>);
expect(getByText('Click Me')).toBeTruthy();
});
it('should call onPress when pressed', () => {
const onPress = jest.fn();
const { getByText } = render(
<Button onPress={onPress}>Click Me</Button>
);
fireEvent.press(getByText('Click Me'));
expect(onPress).toHaveBeenCalledTimes(1);
});
it('should be disabled when loading', () => {
const onPress = jest.fn();
const { getByText } = render(
<Button onPress={onPress} loading>Click Me</Button>
);
fireEvent.press(getByText('Click Me'));
expect(onPress).not.toHaveBeenCalled();
});
it('should show loading indicator', () => {
const { getByTestId } = render(
<Button loading testID="button">Click Me</Button>
);
expect(getByTestId('button-loading')).toBeTruthy();
});
});
```
#### Navigation Tests
```typescript
// app/(tabs)/__tests__/navigation.test.tsx
import { render, waitFor } from '@testing-library/react-native';
import { NavigationContainer } from '@react-navigation/native';
import { TabNavigator } from '../_layout';
describe('Tab Navigation', () => {
it('should render all tabs', () => {
const { getByText } = render(
<NavigationContainer>
<TabNavigator />
</NavigationContainer>
);
expect(getByText('Stories')).toBeTruthy();
expect(getByText('Characters')).toBeTruthy();
expect(getByText('Settings')).toBeTruthy();
});
it('should navigate between tabs', async () => {
const { getByText } = render(
<NavigationContainer>
<TabNavigator />
</NavigationContainer>
);
fireEvent.press(getByText('Characters'));
await waitFor(() => {
expect(getByTestId('characters-screen')).toBeTruthy();
});
});
});
```
#### Zustand Store Tests
```typescript
// src/stores/__tests__/authStore.test.ts
import { renderHook, act } from '@testing-library/react-hooks';
import { useAuthStore } from '../authStore';
describe('useAuthStore', () => {
beforeEach(() => {
const { result } = renderHook(() => useAuthStore());
act(() => {
result.current.reset();
});
});
it('should initialize with null user', () => {
const { result } = renderHook(() => useAuthStore());
expect(result.current.user).toBeNull();
});
it('should set user on sign in', () => {
const { result } = renderHook(() => useAuthStore());
const user = { id: 'user-123', email: 'test@example.com' };
act(() => {
result.current.setUser(user);
});
expect(result.current.user).toEqual(user);
expect(result.current.isAuthenticated).toBe(true);
});
it('should clear user on sign out', () => {
const { result } = renderHook(() => useAuthStore());
act(() => {
result.current.setUser({ id: 'user-123', email: 'test@example.com' });
result.current.signOut();
});
expect(result.current.user).toBeNull();
expect(result.current.isAuthenticated).toBe(false);
});
});
```
#### API Integration Tests with MSW
```typescript
// src/utils/__tests__/api.test.ts
import { setupServer } from 'msw/node';
import { rest } from 'msw';
import { fetchWithAuth } from '../api';
const server = setupServer(
rest.get('http://localhost:3000/api/stories', (req, res, ctx) => {
return res(ctx.json({ stories: [] }));
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
describe('fetchWithAuth', () => {
it('should fetch with auth token', async () => {
const response = await fetchWithAuth('/api/stories');
const data = await response.json();
expect(data).toEqual({ stories: [] });
});
it('should refresh token on 401', async () => {
server.use(
rest.get('http://localhost:3000/api/stories', (req, res, ctx) => {
return res.once(ctx.status(401));
}),
rest.post('http://localhost:3000/auth/refresh', (req, res, ctx) => {
return res(ctx.json({ token: 'new-token' }));
})
);
const response = await fetchWithAuth('/api/stories');
expect(response.ok).toBe(true);
});
});
```
### 3. SvelteKit Web Tests
#### Component Tests (Svelte 5 Runes)
```typescript
// src/lib/components/Button/__tests__/Button.test.ts
import { render, screen } from '@testing-library/svelte';
import { describe, it, expect, vi } from 'vitest';
import Button from '../Button.svelte';
import userEvent from '@testing-library/user-event';
describe('Button', () => {
it('should render with text', () => {
render(Button, { props: { children: 'Click Me' } });
expect(screen.getByText('Click Me')).toBeTruthy();
});
it('should call onclick when clicked', async () => {
const user = userEvent.setup();
const onclick = vi.fn();
render(Button, { props: { onclick, children: 'Click Me' } });
await user.click(screen.getByText('Click Me'));
expect(onclick).toHaveBeenCalledOnce();
});
it('should be disabled when loading', () => {
render(Button, { props: { loading: true, children: 'Click Me' } });
const button = screen.getByRole('button');
expect(button).toHaveProperty('disabled', true);
});
});
```
#### Store Tests (Svelte 5)
```typescript
// src/lib/stores/__tests__/auth.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { authStore } from '../auth.svelte';
describe('authStore', () => {
beforeEach(() => {
authStore.reset();
});
it('should initialize with null user', () => {
expect(authStore.user).toBeNull();
});
it('should set user', () => {
const user = { id: '123', email: 'test@example.com' };
authStore.setUser(user);
expect(authStore.user).toEqual(user);
expect(authStore.isAuthenticated).toBe(true);
});
it('should clear user', () => {
authStore.setUser({ id: '123', email: 'test@example.com' });
authStore.clear();
expect(authStore.user).toBeNull();
expect(authStore.isAuthenticated).toBe(false);
});
});
```
#### Server Load Function Tests
```typescript
// src/routes/(app)/dashboard/__tests__/+page.server.test.ts
import { describe, it, expect, vi } from 'vitest';
import { load } from '../+page.server';
describe('Dashboard Load Function', () => {
it('should load user data', async () => {
const locals = {
pb: {
collection: vi.fn(() => ({
getList: vi.fn().mockResolvedValue({
items: [{ id: '1', title: 'Test' }],
}),
})),
},
};
const result = await load({ locals });
expect(result.items).toHaveLength(1);
expect(result.items[0].title).toBe('Test');
});
it('should handle errors', async () => {
const locals = {
pb: {
collection: vi.fn(() => ({
getList: vi.fn().mockRejectedValue(new Error('DB error')),
})),
},
};
await expect(load({ locals })).rejects.toThrow('DB error');
});
});
```
#### E2E Tests with Playwright
```typescript
// e2e/auth.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Authentication', () => {
test('should sign in successfully', async ({ page }) => {
await page.goto('/signin');
await page.fill('input[name="email"]', 'test@example.com');
await page.fill('input[name="password"]', 'password123');
await page.click('button[type="submit"]');
await expect(page).toHaveURL('/dashboard');
await expect(page.locator('text=Welcome')).toBeVisible();
});
test('should show error for invalid credentials', async ({ page }) => {
await page.goto('/signin');
await page.fill('input[name="email"]', 'test@example.com');
await page.fill('input[name="password"]', 'wrongpassword');
await page.click('button[type="submit"]');
await expect(page.locator('text=Invalid credentials')).toBeVisible();
});
test('should redirect to signin when not authenticated', async ({ page }) => {
await page.goto('/dashboard');
await expect(page).toHaveURL(/.*signin/);
});
});
```
### 4. Shared Package Tests
#### Utility Function Tests
```typescript
// packages/shared-utils/src/__tests__/format.test.ts
import { describe, it, expect } from 'vitest';
import { formatDate, truncate, slugify } from '../format';
describe('formatDate', () => {
it('should format date correctly', () => {
const date = new Date('2024-01-15T12:00:00Z');
expect(formatDate(date, 'yyyy-MM-dd')).toBe('2024-01-15');
});
it('should handle invalid dates', () => {
expect(() => formatDate(null, 'yyyy-MM-dd')).toThrow();
});
});
describe('truncate', () => {
it('should truncate long strings', () => {
const text = 'This is a very long string that should be truncated';
expect(truncate(text, 20)).toBe('This is a very long...');
});
it('should not truncate short strings', () => {
const text = 'Short';
expect(truncate(text, 20)).toBe('Short');
});
it('should handle custom ellipsis', () => {
const text = 'This is a very long string';
expect(truncate(text, 10, '...')).toBe('This is...');
});
});
describe('slugify', () => {
it('should convert to slug', () => {
expect(slugify('Hello World')).toBe('hello-world');
expect(slugify('React & TypeScript')).toBe('react-typescript');
});
});
```
#### Auth Service Tests
```typescript
// packages/shared-auth/src/__tests__/authService.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createAuthService } from '../authService';
import { TokenManager } from '../tokenManager';
describe('createAuthService', () => {
let authService;
let mockTokenManager;
beforeEach(() => {
mockTokenManager = {
getValidToken: vi.fn(),
setTokens: vi.fn(),
clearTokens: vi.fn(),
};
authService = createAuthService({
apiUrl: 'http://localhost:3000',
tokenManager: mockTokenManager,
});
});
describe('signIn', () => {
it('should sign in and store tokens', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
appToken: 'token-123',
refreshToken: 'refresh-123',
}),
});
const result = await authService.signIn('test@example.com', 'password');
expect(result.success).toBe(true);
expect(mockTokenManager.setTokens).toHaveBeenCalledWith({
appToken: 'token-123',
refreshToken: 'refresh-123',
});
});
it('should handle sign in errors', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 401,
});
const result = await authService.signIn('test@example.com', 'wrong');
expect(result.success).toBe(false);
expect(result.error).toBeDefined();
});
});
});
```
## CI/CD Integration
### GitHub Actions Workflow
Create `.github/workflows/test.yml`:
```yaml
name: Test Suite
on:
pull_request:
branches: [main, develop]
push:
branches: [main, develop]
jobs:
test-backends:
name: Test NestJS Backends
runs-on: ubuntu-latest
strategy:
matrix:
project: [maerchenzauber, manadeck, chat, nutriphi]
steps:
- uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v2
with:
version: 9.15.0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run tests
run: pnpm --filter @${{ matrix.project }}/backend test:cov
- name: Upload coverage
uses: codecov/codecov-action@v4
with:
files: ./apps/${{ matrix.project }}/apps/backend/coverage/lcov.info
flags: backend-${{ matrix.project }}
name: backend-${{ matrix.project }}
test-mobile:
name: Test React Native Mobile Apps
runs-on: ubuntu-latest
strategy:
matrix:
project: [maerchenzauber, memoro, picture, chat]
steps:
- uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v2
with:
version: 9.15.0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run tests
run: pnpm --filter @${{ matrix.project }}/mobile test -- --coverage --watchAll=false
- name: Upload coverage
uses: codecov/codecov-action@v4
with:
files: ./apps/${{ matrix.project }}/apps/mobile/coverage/lcov.info
flags: mobile-${{ matrix.project }}
name: mobile-${{ matrix.project }}
test-web:
name: Test SvelteKit Web Apps
runs-on: ubuntu-latest
strategy:
matrix:
project: [maerchenzauber, manacore, memoro, picture, uload, chat]
steps:
- uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v2
with:
version: 9.15.0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run unit tests
run: pnpm --filter @${{ matrix.project }}/web test:unit -- --coverage
- name: Install Playwright browsers
run: pnpm --filter @${{ matrix.project }}/web exec playwright install --with-deps
- name: Run E2E tests
run: pnpm --filter @${{ matrix.project }}/web test:e2e
- name: Upload coverage
uses: codecov/codecov-action@v4
with:
files: ./apps/${{ matrix.project }}/apps/web/coverage/lcov.info
flags: web-${{ matrix.project }}
name: web-${{ matrix.project }}
test-shared-packages:
name: Test Shared Packages
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v2
with:
version: 9.15.0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run tests
run: pnpm --filter '@manacore/*' test -- --coverage
- name: Upload coverage
uses: codecov/codecov-action@v4
with:
files: ./packages/*/coverage/lcov.info
flags: shared-packages
name: shared-packages
coverage-report:
name: Aggregate Coverage Report
needs: [test-backends, test-mobile, test-web, test-shared-packages]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Download all coverage reports
uses: actions/download-artifact@v4
- name: Generate combined coverage report
run: |
echo "## Test Coverage Summary" >> $GITHUB_STEP_SUMMARY
echo "All tests passed with coverage thresholds met" >> $GITHUB_STEP_SUMMARY
```
### Test Performance Optimization
**Parallel Execution**:
```json
{
"scripts": {
"test": "jest --maxWorkers=50%",
"test:ci": "jest --maxWorkers=2 --ci"
}
}
```
**Test Sharding** (for large test suites):
```yaml
strategy:
matrix:
shard: [1, 2, 3, 4]
steps:
- run: pnpm test -- --shard=${{ matrix.shard }}/4
```
**Caching**:
```yaml
- name: Cache test results
uses: actions/cache@v4
with:
path: |
**/node_modules
**/.next/cache
**/coverage
key: test-cache-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
```
## Implementation Roadmap
### Phase 1: Foundation (Week 1-2)
**Goals**: Set up infrastructure and create shared utilities
- [ ] Create shared test configurations
- [ ] `packages/test-config/jest.config.base.js`
- [ ] `packages/test-config/vitest.config.base.ts`
- [ ] `packages/test-config/playwright.config.base.ts`
- [ ] Install testing dependencies across all projects
- [ ] Create shared test utilities package
- [ ] `packages/shared-test-utils/`
- [ ] Mock factories
- [ ] Test helpers
- [ ] Supabase mocks
- [ ] Set up coverage reporting
- [ ] Codecov integration
- [ ] Coverage badges in README
- [ ] Document testing patterns in this file
### Phase 2: Critical Path Coverage (Week 3-4)
**Goals**: Achieve 100% coverage for critical paths
- [ ] **Authentication** (Priority 1)
- [ ] `@manacore/shared-auth` package (100% coverage)
- [ ] Token manager tests
- [ ] JWT validation tests
- [ ] Auth service tests per app
- [ ] **Payment/Credit System** (Priority 2)
- [ ] Credit consumption logic
- [ ] Stripe integration mocks
- [ ] Credit balance validation
- [ ] **Data Integrity** (Priority 3)
- [ ] RLS policy integration tests
- [ ] Database migration tests
- [ ] Data validation tests
### Phase 3: Backend Coverage (Week 5-6)
**Goals**: 80% coverage for all NestJS backends
- [ ] **Maerchenzauber Backend**
- [ ] Story service tests (expand existing)
- [ ] Character service tests (expand existing)
- [ ] AI integration mocks
- [ ] E2E API tests
- [ ] **Chat Backend**
- [ ] Chat service tests
- [ ] WebSocket tests
- [ ] Message persistence tests
- [ ] **Manadeck Backend**
- [ ] Deck service tests
- [ ] Card service tests
- [ ] **Nutriphi Backend**
- [ ] Recipe service tests
- [ ] Nutrition calculation tests
### Phase 4: Mobile Coverage (Week 7-8)
**Goals**: 80% coverage for all mobile apps
- [ ] **Maerchenzauber Mobile** (expand from 5 tests)
- [ ] Component tests
- [ ] Navigation tests
- [ ] Store tests
- [ ] API integration tests
- [ ] **Memoro Mobile** (expand from 3 tests)
- [ ] Audio recording tests
- [ ] Upload flow tests
- [ ] Playback tests
- [ ] **Picture Mobile**
- [ ] Image generation flow
- [ ] Gallery tests
- [ ] Share functionality
- [ ] **Chat Mobile**
- [ ] Message list tests
- [ ] Chat input tests
- [ ] Real-time updates
### Phase 5: Web Coverage (Week 9-10)
**Goals**: 80% coverage for all web apps
- [ ] **Uload Web** (expand from 9 tests)
- [ ] Link management tests
- [ ] QR code tests
- [ ] Analytics tests
- [ ] **Manacore Web**
- [ ] Dashboard tests
- [ ] App switcher tests
- [ ] Profile tests
- [ ] **SvelteKit Apps**
- [ ] Component library tests
- [ ] Form validation tests
- [ ] SSR behavior tests
### Phase 6: Shared Packages (Week 11)
**Goals**: 90% coverage for all shared packages
- [ ] `@manacore/shared-auth` (100%)
- [ ] `@manacore/shared-utils` (90%)
- [ ] `@manacore/shared-types` (validation tests)
- [ ] `@manacore/shared-ui` (component tests)
- [ ] `@manacore/shared-supabase` (90%)
### Phase 7: CI/CD Integration (Week 12)
**Goals**: Automated testing pipeline
- [ ] Create GitHub Actions workflows
- [ ] PR checks
- [ ] Branch protection rules
- [ ] Coverage gates
- [ ] Set up Codecov
- [ ] Coverage badges
- [ ] PR comments
- [ ] Coverage diff reports
- [ ] Performance optimization
- [ ] Test caching
- [ ] Parallel execution
- [ ] Selective test running
### Phase 8: E2E Testing (Week 13-14)
**Goals**: Critical user flows covered
- [ ] Playwright setup for all web apps
- [ ] Detox or Maestro for mobile apps
- [ ] Critical flows:
- [ ] Authentication flow
- [ ] Content creation flow
- [ ] Payment flow
- [ ] Share flow
## Best Practices
### General Testing Principles
1. **AAA Pattern**: Arrange, Act, Assert
2. **Single Responsibility**: One test, one assertion (ideally)
3. **Isolation**: Tests should not depend on each other
4. **Descriptive Names**: Test names explain what and why
5. **Fast Tests**: Unit tests < 100ms, integration tests < 1s
6. **Deterministic**: Same input = same output
### Test Naming Convention
```typescript
describe('ServiceName', () => {
describe('methodName', () => {
it('should do something when condition', () => {
// Test implementation
});
it('should handle error when invalid input', () => {
// Error handling test
});
});
});
```
### Mock Best Practices
**DO**:
- Mock external dependencies (APIs, databases)
- Mock time-dependent functions (`Date.now()`)
- Use factories for test data
- Reset mocks between tests
**DON'T**:
- Mock internal implementation details
- Over-mock (keep some real implementations)
- Forget to restore mocks after tests
### Coverage Best Practices
**What to Cover**:
- Business logic
- Error handling paths
- Edge cases
- Boundary conditions
**What NOT to Cover**:
- Type definitions
- Simple getters/setters
- Framework boilerplate
- Third-party libraries
### Supabase Testing Strategy
**Unit Tests**: Mock Supabase client
```typescript
const mockSupabase = {
from: jest.fn(() => ({
select: jest.fn().mockResolvedValue({ data: [], error: null }),
insert: jest.fn().mockResolvedValue({ data: {}, error: null }),
})),
};
```
**Integration Tests**: Use Supabase local development
```bash
# Start local Supabase
npx supabase start
# Run migrations
npx supabase db reset
# Run integration tests
pnpm test:integration
```
**E2E Tests**: Use dedicated test project in Supabase with seeded data
### Continuous Improvement
- Review coverage reports weekly
- Add tests when bugs are found
- Refactor tests alongside code
- Share testing patterns across teams
- Update this document as patterns evolve
## Resources
- [Jest Documentation](https://jestjs.io/)
- [Vitest Documentation](https://vitest.dev/)
- [Playwright Documentation](https://playwright.dev/)
- [React Native Testing Library](https://callstack.github.io/react-native-testing-library/)
- [Testing Library](https://testing-library.com/)
- [NestJS Testing](https://docs.nestjs.com/fundamentals/testing)
- [Svelte Testing](https://svelte.dev/docs/testing)
## FAQ
**Q: Should I write tests before or after code?**
A: Ideally TDD (test-first), but pragmatically write tests as you develop features.
**Q: How do I test Supabase RLS policies?**
A: Use integration tests with different user contexts, or use Supabase's policy testing features.
**Q: What's the minimum coverage for a PR to be merged?**
A: 80% coverage for new code, no decrease in overall coverage.
**Q: Should I test private methods?**
A: No, test public API. Private methods are tested indirectly.
**Q: How do I mock Expo modules?**
A: Use `jest.mock()` or create manual mocks in `__mocks__/` directory.
**Q: What about snapshot tests?**
A: Use sparingly for UI components, not for data structures.
---
**Last Updated**: 2025-11-27
**Version**: 1.0.0
**Maintainer**: Hive Mind - Tester Agent