42 KiB
Testing Strategy for Manacore Monorepo
Table of Contents
- Overview
- Current State Analysis
- Testing Infrastructure by App Type
- Test Organization
- Coverage Strategy
- Testing Scenarios
- CI/CD Integration
- Implementation Roadmap
- 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
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
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
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
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
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
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.tsor*.spec.ts - Integration tests:
*.integration.test.ts - E2E tests:
*.e2e.spec.tsor*.spec.ts(in e2e/ directory) - Test utilities:
test-utils.ts,*TestUtils.ts - Fixtures:
fixtures/directory ormockData.ts
Test Utilities
Create shared test utilities in __tests__/utils/:
// 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
-
Authentication:
@manacore/shared-authpackageauthService.tsin all appstokenManager.ts- JWT verification logic
-
Payment/Credit System:
- Credit consumption logic
- Stripe integration
- Credit balance checks
- Transaction recording
-
Data Integrity:
- Database migrations
- RLS policy validation (via integration tests)
- User data validation
- File upload validation
Coverage Reporting
Local Development:
# 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
// 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
// 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
// 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
// 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
// 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
// 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
// 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
// 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)
// 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)
// 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
// 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
// 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
// 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
// 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:
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:
{
"scripts": {
"test": "jest --maxWorkers=50%",
"test:ci": "jest --maxWorkers=2 --ci"
}
}
Test Sharding (for large test suites):
strategy:
matrix:
shard: [1, 2, 3, 4]
steps:
- run: pnpm test -- --shard=${{ matrix.shard }}/4
Caching:
- 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.jspackages/test-config/vitest.config.base.tspackages/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-authpackage (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
- AAA Pattern: Arrange, Act, Assert
- Single Responsibility: One test, one assertion (ideally)
- Isolation: Tests should not depend on each other
- Descriptive Names: Test names explain what and why
- Fast Tests: Unit tests < 100ms, integration tests < 1s
- Deterministic: Same input = same output
Test Naming Convention
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
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
# 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
- Vitest Documentation
- Playwright Documentation
- React Native Testing Library
- Testing Library
- NestJS Testing
- Svelte 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