Initial commit: ArchiTools modular dashboard platform
Complete Next.js 16 application with 13 fully implemented modules: Email Signature, Word XML Generator, Registratura, Dashboard, Tag Manager, IT Inventory, Address Book, Password Vault, Mini Utilities, Prompt Generator, Digital Signatures, Word Templates, and AI Chat. Includes core platform systems (module registry, feature flags, storage abstraction, i18n, theming, auth stub, tagging), 16 technical documentation files, Docker deployment config, and legacy HTML tool reference. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
864
docs/guides/TESTING-STRATEGY.md
Normal file
864
docs/guides/TESTING-STRATEGY.md
Normal file
@@ -0,0 +1,864 @@
|
||||
# Testing Strategy
|
||||
|
||||
> ArchiTools testing guide -- tools, conventions, coverage targets, and anti-patterns.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
ArchiTools follows a testing pyramid: many fast unit tests at the base, fewer integration tests in the middle, and a small number of E2E tests at the top. Tests are co-located with the source code they test, not in a separate directory tree.
|
||||
|
||||
```
|
||||
┌─────────┐
|
||||
│ E2E │ Playwright -- critical user flows
|
||||
│ (few) │
|
||||
┌┴─────────┴┐
|
||||
│Integration │ Vitest -- module services with real storage
|
||||
│ (moderate) │
|
||||
┌┴────────────┴┐
|
||||
│ Component │ Vitest + React Testing Library
|
||||
│ (moderate) │
|
||||
┌┴──────────────┴┐
|
||||
│ Unit │ Vitest -- hooks, services, utilities
|
||||
│ (many) │
|
||||
└────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tools
|
||||
|
||||
| Layer | Tool | Why |
|
||||
|---|---|---|
|
||||
| Unit | Vitest | Fast, native ESM, TypeScript-first, compatible with Next.js |
|
||||
| Component | Vitest + React Testing Library | Tests component behavior from the user's perspective |
|
||||
| Integration | Vitest | Same runner, but tests span module service + storage layers |
|
||||
| E2E | Playwright | Cross-browser, reliable, good Next.js support |
|
||||
|
||||
### Vitest Configuration
|
||||
|
||||
```typescript
|
||||
// vitest.config.ts
|
||||
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
setupFiles: ['./src/test/setup.ts'],
|
||||
include: ['src/**/*.test.{ts,tsx}'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'html', 'lcov'],
|
||||
include: ['src/**/*.{ts,tsx}'],
|
||||
exclude: [
|
||||
'src/**/*.test.{ts,tsx}',
|
||||
'src/**/*.d.ts',
|
||||
'src/test/**',
|
||||
'src/types/**',
|
||||
],
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Test Setup
|
||||
|
||||
```typescript
|
||||
// src/test/setup.ts
|
||||
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
|
||||
// Mock localStorage for all tests
|
||||
const localStorageMock = (() => {
|
||||
let store: Record<string, string> = {};
|
||||
return {
|
||||
getItem: (key: string) => store[key] ?? null,
|
||||
setItem: (key: string, value: string) => { store[key] = value; },
|
||||
removeItem: (key: string) => { delete store[key]; },
|
||||
clear: () => { store = {}; },
|
||||
get length() { return Object.keys(store).length; },
|
||||
key: (index: number) => Object.keys(store)[index] ?? null,
|
||||
};
|
||||
})();
|
||||
|
||||
Object.defineProperty(window, 'localStorage', { value: localStorageMock });
|
||||
|
||||
// Reset storage between tests
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File Organization
|
||||
|
||||
Tests are co-located with the source files they test. No separate `__tests__` directories.
|
||||
|
||||
```
|
||||
src/modules/registratura/
|
||||
components/
|
||||
RegistryTable.tsx
|
||||
RegistryTable.test.tsx # component test
|
||||
RegistryForm.tsx
|
||||
RegistryForm.test.tsx # component test
|
||||
hooks/
|
||||
use-registry.ts
|
||||
use-registry.test.ts # hook unit test
|
||||
services/
|
||||
registry-service.ts
|
||||
registry-service.test.ts # service unit test
|
||||
registry-service.integration.test.ts # integration test
|
||||
types.ts
|
||||
config.ts
|
||||
|
||||
src/lib/tags/
|
||||
tag-service.ts
|
||||
tag-service.test.ts # unit test
|
||||
tag-service.integration.test.ts # integration test
|
||||
|
||||
src/lib/storage/
|
||||
storage-service.ts
|
||||
storage-service.test.ts # unit test
|
||||
|
||||
src/hooks/
|
||||
useFeatureFlag.ts
|
||||
useFeatureFlag.test.ts # unit test
|
||||
```
|
||||
|
||||
### Naming Convention
|
||||
|
||||
| File type | Pattern | Example |
|
||||
|---|---|---|
|
||||
| Unit test | `[name].test.ts` | `registry-service.test.ts` |
|
||||
| Component test | `[Component].test.tsx` | `RegistryTable.test.tsx` |
|
||||
| Integration test | `[name].integration.test.ts` | `registry-service.integration.test.ts` |
|
||||
| E2E test | `[flow].spec.ts` (in `e2e/`) | `e2e/registratura.spec.ts` |
|
||||
| Test utility | `[name].ts` (in `src/test/`) | `src/test/factories.ts` |
|
||||
|
||||
---
|
||||
|
||||
## Unit Tests
|
||||
|
||||
### What to Test
|
||||
|
||||
- **Services:** All CRUD operations, validation logic, error handling, storage key construction.
|
||||
- **Hooks:** State transitions, side effects, return value shape, error states.
|
||||
- **Utility functions:** Transformations, formatters, parsers, validators.
|
||||
- **Storage adapters:** Read/write/delete operations, namespace isolation.
|
||||
- **Feature flag evaluation:** Flag resolution logic, default values.
|
||||
|
||||
### What NOT to Test
|
||||
|
||||
- TypeScript types (they are compile-time only).
|
||||
- Static configuration objects (they have no logic).
|
||||
- Third-party library internals (test your usage, not their code).
|
||||
|
||||
### Service Test Example
|
||||
|
||||
```typescript
|
||||
// src/modules/registratura/services/registry-service.test.ts
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { RegistryService } from './registry-service';
|
||||
import { createMockStorageService } from '@/test/mocks/storage';
|
||||
|
||||
describe('RegistryService', () => {
|
||||
let service: RegistryService;
|
||||
let storage: ReturnType<typeof createMockStorageService>;
|
||||
|
||||
beforeEach(() => {
|
||||
storage = createMockStorageService();
|
||||
service = new RegistryService(storage);
|
||||
});
|
||||
|
||||
describe('createEntry', () => {
|
||||
it('assigns an ID and timestamps on creation', async () => {
|
||||
const entry = await service.createEntry({
|
||||
title: 'Cerere CU',
|
||||
type: 'incoming',
|
||||
tagIds: [],
|
||||
});
|
||||
|
||||
expect(entry.id).toBeDefined();
|
||||
expect(entry.createdAt).toBeDefined();
|
||||
expect(entry.updatedAt).toBeDefined();
|
||||
});
|
||||
|
||||
it('rejects entries with empty title', async () => {
|
||||
await expect(
|
||||
service.createEntry({ title: '', type: 'incoming', tagIds: [] })
|
||||
).rejects.toThrow('Title is required');
|
||||
});
|
||||
|
||||
it('stores the entry under the correct namespace', async () => {
|
||||
await service.createEntry({
|
||||
title: 'Cerere CU',
|
||||
type: 'incoming',
|
||||
tagIds: [],
|
||||
});
|
||||
|
||||
expect(storage.setItem).toHaveBeenCalledWith(
|
||||
expect.stringContaining('architools.registratura'),
|
||||
expect.any(String)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEntries', () => {
|
||||
it('returns an empty array when no entries exist', async () => {
|
||||
const entries = await service.getEntries();
|
||||
expect(entries).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns entries sorted by creation date descending', async () => {
|
||||
await service.createEntry({ title: 'First', type: 'incoming', tagIds: [] });
|
||||
await service.createEntry({ title: 'Second', type: 'incoming', tagIds: [] });
|
||||
|
||||
const entries = await service.getEntries();
|
||||
expect(entries[0].title).toBe('Second');
|
||||
expect(entries[1].title).toBe('First');
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Hook Test Example
|
||||
|
||||
```typescript
|
||||
// src/modules/registratura/hooks/use-registry.test.ts
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||
import { useRegistry } from './use-registry';
|
||||
import { TestProviders } from '@/test/providers';
|
||||
|
||||
describe('useRegistry', () => {
|
||||
it('loads entries on mount', async () => {
|
||||
const { result } = renderHook(() => useRegistry(), {
|
||||
wrapper: TestProviders,
|
||||
});
|
||||
|
||||
expect(result.current.isLoading).toBe(true);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.entries).toEqual([]);
|
||||
});
|
||||
|
||||
it('adds an entry and updates the list', async () => {
|
||||
const { result } = renderHook(() => useRegistry(), {
|
||||
wrapper: TestProviders,
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.addEntry({
|
||||
title: 'Test Entry',
|
||||
type: 'incoming',
|
||||
tagIds: [],
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.entries).toHaveLength(1);
|
||||
expect(result.current.entries[0].title).toBe('Test Entry');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Component Tests
|
||||
|
||||
### Principles
|
||||
|
||||
- Test **behavior**, not implementation. Click buttons, fill inputs, assert on visible output.
|
||||
- Use `screen.getByRole`, `screen.getByText`, `screen.getByLabelText` -- prefer accessible queries.
|
||||
- Do not test CSS classes or DOM structure. Test what the user sees and can interact with.
|
||||
- Mock services at the hook level, not at the component level. Components should receive data through hooks.
|
||||
|
||||
### Component Test Example
|
||||
|
||||
```typescript
|
||||
// src/modules/registratura/components/RegistryTable.test.tsx
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { RegistryTable } from './RegistryTable';
|
||||
import { createTestEntry } from '@/test/factories';
|
||||
|
||||
describe('RegistryTable', () => {
|
||||
const mockEntries = [
|
||||
createTestEntry({ title: 'Cerere CU - Casa Popescu', type: 'incoming' }),
|
||||
createTestEntry({ title: 'Autorizatie construire', type: 'outgoing' }),
|
||||
];
|
||||
|
||||
it('renders all entries', () => {
|
||||
render(<RegistryTable entries={mockEntries} onEdit={vi.fn()} onDelete={vi.fn()} />);
|
||||
|
||||
expect(screen.getByText('Cerere CU - Casa Popescu')).toBeInTheDocument();
|
||||
expect(screen.getByText('Autorizatie construire')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onEdit when edit button is clicked', async () => {
|
||||
const onEdit = vi.fn();
|
||||
render(<RegistryTable entries={mockEntries} onEdit={onEdit} onDelete={vi.fn()} />);
|
||||
|
||||
const editButtons = screen.getAllByRole('button', { name: /editeaza/i });
|
||||
await userEvent.click(editButtons[0]);
|
||||
|
||||
expect(onEdit).toHaveBeenCalledWith(mockEntries[0].id);
|
||||
});
|
||||
|
||||
it('shows empty state when no entries exist', () => {
|
||||
render(<RegistryTable entries={[]} onEdit={vi.fn()} onDelete={vi.fn()} />);
|
||||
|
||||
expect(screen.getByText(/nu exista inregistrari/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filters entries when tag filter is applied', async () => {
|
||||
render(
|
||||
<RegistryTable
|
||||
entries={mockEntries}
|
||||
onEdit={vi.fn()}
|
||||
onDelete={vi.fn()}
|
||||
activeTagIds={[mockEntries[0].tagIds[0]]}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Cerere CU - Casa Popescu')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Autorizatie construire')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Integration Tests
|
||||
|
||||
Integration tests verify that module services work correctly with the real storage adapter (localStorage mock from the test setup, not a hand-written mock).
|
||||
|
||||
### Module-Level Integration
|
||||
|
||||
```typescript
|
||||
// src/modules/registratura/services/registry-service.integration.test.ts
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { RegistryService } from './registry-service';
|
||||
import { LocalStorageAdapter } from '@/lib/storage/local-storage-adapter';
|
||||
|
||||
describe('RegistryService (integration)', () => {
|
||||
let service: RegistryService;
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
const storage = new LocalStorageAdapter('architools.registratura');
|
||||
service = new RegistryService(storage);
|
||||
});
|
||||
|
||||
it('persists and retrieves entries across service instances', async () => {
|
||||
await service.createEntry({
|
||||
title: 'Persistent Entry',
|
||||
type: 'incoming',
|
||||
tagIds: [],
|
||||
});
|
||||
|
||||
// Create a new service instance pointing to the same storage
|
||||
const storage2 = new LocalStorageAdapter('architools.registratura');
|
||||
const service2 = new RegistryService(storage2);
|
||||
|
||||
const entries = await service2.getEntries();
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0].title).toBe('Persistent Entry');
|
||||
});
|
||||
|
||||
it('does not leak data across storage namespaces', async () => {
|
||||
await service.createEntry({
|
||||
title: 'Registry Entry',
|
||||
type: 'incoming',
|
||||
tagIds: [],
|
||||
});
|
||||
|
||||
const otherStorage = new LocalStorageAdapter('architools.other-module');
|
||||
const keys = Object.keys(localStorage).filter((k) =>
|
||||
k.startsWith('architools.other-module')
|
||||
);
|
||||
expect(keys).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Cross-Module Integration
|
||||
|
||||
Test that the tagging system works correctly when used by a module.
|
||||
|
||||
```typescript
|
||||
// src/test/integration/tags-cross-module.integration.test.ts
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { TagService } from '@/lib/tags/tag-service';
|
||||
import { RegistryService } from '@/modules/registratura/services/registry-service';
|
||||
import { LocalStorageAdapter } from '@/lib/storage/local-storage-adapter';
|
||||
|
||||
describe('Tags cross-module integration', () => {
|
||||
let tagService: TagService;
|
||||
let registryService: RegistryService;
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
tagService = new TagService(new LocalStorageAdapter('architools.tags'));
|
||||
registryService = new RegistryService(
|
||||
new LocalStorageAdapter('architools.registratura')
|
||||
);
|
||||
});
|
||||
|
||||
it('registry entries can reference tags from the tag service', async () => {
|
||||
const tag = await tagService.createTag({
|
||||
label: 'DTAC',
|
||||
category: 'phase',
|
||||
scope: 'global',
|
||||
color: '#14b8a6',
|
||||
});
|
||||
|
||||
const entry = await registryService.createEntry({
|
||||
title: 'Documentatie DTAC',
|
||||
type: 'incoming',
|
||||
tagIds: [tag.id],
|
||||
});
|
||||
|
||||
const resolvedTags = await tagService.getAllTags();
|
||||
const entryTags = resolvedTags.filter((t) => entry.tagIds.includes(t.id));
|
||||
|
||||
expect(entryTags).toHaveLength(1);
|
||||
expect(entryTags[0].label).toBe('DTAC');
|
||||
});
|
||||
|
||||
it('handles deleted tags gracefully in entity tag lists', async () => {
|
||||
const tag = await tagService.createTag({
|
||||
label: 'Temporary',
|
||||
category: 'custom',
|
||||
scope: 'global',
|
||||
});
|
||||
|
||||
await registryService.createEntry({
|
||||
title: 'Entry with temp tag',
|
||||
type: 'incoming',
|
||||
tagIds: [tag.id],
|
||||
});
|
||||
|
||||
await tagService.deleteTag(tag.id);
|
||||
|
||||
const allTags = await tagService.getAllTags();
|
||||
const entries = await registryService.getEntries();
|
||||
|
||||
// The entry still references the deleted tag ID
|
||||
expect(entries[0].tagIds).toContain(tag.id);
|
||||
// But the tag no longer exists
|
||||
expect(allTags.find((t) => t.id === tag.id)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## E2E Tests
|
||||
|
||||
### Setup
|
||||
|
||||
```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,
|
||||
reporter: process.env.CI ? 'github' : 'html',
|
||||
use: {
|
||||
baseURL: 'http://localhost:3000',
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
},
|
||||
projects: [
|
||||
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
|
||||
],
|
||||
webServer: {
|
||||
command: 'npm run dev',
|
||||
url: 'http://localhost:3000',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Critical Paths
|
||||
|
||||
E2E tests cover the flows that, if broken, would block daily use of ArchiTools.
|
||||
|
||||
| Flow | File | What it tests |
|
||||
|---|---|---|
|
||||
| Navigation | `e2e/navigation.spec.ts` | Sidebar renders enabled modules, disabled modules are absent, clicking navigates |
|
||||
| Registratura CRUD | `e2e/registratura.spec.ts` | Create, read, update, delete registry entries |
|
||||
| Tag management | `e2e/tag-manager.spec.ts` | Create tag, apply to entity, filter by tag |
|
||||
| Feature flags | `e2e/feature-flags.spec.ts` | Toggle flag off, verify module disappears from sidebar and route returns 404 |
|
||||
| Email Signature | `e2e/email-signature.spec.ts` | Generate signature, preview renders, copy to clipboard |
|
||||
| Data export/import | `e2e/data-export.spec.ts` | Export data as JSON, clear storage, import, verify data restored |
|
||||
|
||||
### E2E Test Example
|
||||
|
||||
```typescript
|
||||
// e2e/registratura.spec.ts
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Registratura', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/registratura');
|
||||
});
|
||||
|
||||
test('creates a new registry entry', async ({ page }) => {
|
||||
await page.getByRole('button', { name: /adauga/i }).click();
|
||||
|
||||
await page.getByLabel(/titlu/i).fill('Cerere CU - Casa Test');
|
||||
await page.getByLabel(/tip/i).selectOption('incoming');
|
||||
await page.getByRole('button', { name: /salveaza/i }).click();
|
||||
|
||||
await expect(page.getByText('Cerere CU - Casa Test')).toBeVisible();
|
||||
});
|
||||
|
||||
test('edits an existing entry', async ({ page }) => {
|
||||
// Create an entry first
|
||||
await page.getByRole('button', { name: /adauga/i }).click();
|
||||
await page.getByLabel(/titlu/i).fill('Original Title');
|
||||
await page.getByLabel(/tip/i).selectOption('incoming');
|
||||
await page.getByRole('button', { name: /salveaza/i }).click();
|
||||
|
||||
// Edit it
|
||||
await page.getByRole('button', { name: /editeaza/i }).first().click();
|
||||
await page.getByLabel(/titlu/i).clear();
|
||||
await page.getByLabel(/titlu/i).fill('Updated Title');
|
||||
await page.getByRole('button', { name: /salveaza/i }).click();
|
||||
|
||||
await expect(page.getByText('Updated Title')).toBeVisible();
|
||||
await expect(page.getByText('Original Title')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('deletes an entry', async ({ page }) => {
|
||||
// Create an entry first
|
||||
await page.getByRole('button', { name: /adauga/i }).click();
|
||||
await page.getByLabel(/titlu/i).fill('To Be Deleted');
|
||||
await page.getByLabel(/tip/i).selectOption('incoming');
|
||||
await page.getByRole('button', { name: /salveaza/i }).click();
|
||||
|
||||
// Delete it
|
||||
await page.getByRole('button', { name: /sterge/i }).first().click();
|
||||
await page.getByRole('button', { name: /confirma/i }).click();
|
||||
|
||||
await expect(page.getByText('To Be Deleted')).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Feature Flag E2E Test
|
||||
|
||||
```typescript
|
||||
// e2e/feature-flags.spec.ts
|
||||
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Feature Flags', () => {
|
||||
test('disabled module is absent from sidebar', async ({ page }) => {
|
||||
// Set feature flag to disabled via localStorage before navigation
|
||||
await page.goto('/');
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem(
|
||||
'architools.flags',
|
||||
JSON.stringify({ 'module.registratura': false })
|
||||
);
|
||||
});
|
||||
await page.reload();
|
||||
|
||||
const sidebar = page.getByRole('navigation');
|
||||
await expect(sidebar.getByText('Registratura')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('disabled module route returns 404 or redirects', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem(
|
||||
'architools.flags',
|
||||
JSON.stringify({ 'module.registratura': false })
|
||||
);
|
||||
});
|
||||
|
||||
await page.goto('/registratura');
|
||||
|
||||
// Should show a "module not found" or redirect to dashboard
|
||||
await expect(page.getByText(/nu a fost gasit/i)).toBeVisible();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Test Utilities
|
||||
|
||||
### Mock Storage Service
|
||||
|
||||
```typescript
|
||||
// src/test/mocks/storage.ts
|
||||
|
||||
import { vi } from 'vitest';
|
||||
|
||||
export function createMockStorageService() {
|
||||
const store = new Map<string, string>();
|
||||
|
||||
return {
|
||||
getItem: vi.fn((key: string) => store.get(key) ?? null),
|
||||
setItem: vi.fn((key: string, value: string) => { store.set(key, value); }),
|
||||
removeItem: vi.fn((key: string) => { store.delete(key); }),
|
||||
clear: vi.fn(() => store.clear()),
|
||||
getAllKeys: vi.fn(() => Array.from(store.keys())),
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Mock Feature Flag Provider
|
||||
|
||||
```typescript
|
||||
// src/test/mocks/feature-flags.ts
|
||||
|
||||
import React from 'react';
|
||||
import { FeatureFlagContext } from '@/lib/feature-flags/context';
|
||||
|
||||
interface MockFlagProviderProps {
|
||||
flags?: Record<string, boolean>;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function MockFeatureFlagProvider({
|
||||
flags = {},
|
||||
children,
|
||||
}: MockFlagProviderProps) {
|
||||
const allEnabled = new Proxy(flags, {
|
||||
get: (target, prop: string) => target[prop] ?? true,
|
||||
});
|
||||
|
||||
return (
|
||||
<FeatureFlagContext.Provider value={allEnabled}>
|
||||
{children}
|
||||
</FeatureFlagContext.Provider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Test Data Factories
|
||||
|
||||
```typescript
|
||||
// src/test/factories.ts
|
||||
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import type { Tag } from '@/types/tags';
|
||||
|
||||
export function createTestEntry(overrides: Partial<RegistryEntry> = {}): RegistryEntry {
|
||||
return {
|
||||
id: uuid(),
|
||||
title: 'Test Entry',
|
||||
type: 'incoming',
|
||||
tagIds: [],
|
||||
visibility: 'all',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function createTestTag(overrides: Partial<Tag> = {}): Tag {
|
||||
return {
|
||||
id: uuid(),
|
||||
label: 'Test Tag',
|
||||
category: 'custom',
|
||||
scope: 'global',
|
||||
color: '#6366f1',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function createTestContact(overrides: Partial<Contact> = {}): Contact {
|
||||
return {
|
||||
id: uuid(),
|
||||
name: 'Ion Popescu',
|
||||
email: 'ion@example.com',
|
||||
phone: '+40 712 345 678',
|
||||
tagIds: [],
|
||||
visibility: 'all',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Test Providers Wrapper
|
||||
|
||||
```typescript
|
||||
// src/test/providers.tsx
|
||||
|
||||
import React from 'react';
|
||||
import { MockFeatureFlagProvider } from './mocks/feature-flags';
|
||||
|
||||
interface TestProvidersProps {
|
||||
children: React.ReactNode;
|
||||
flags?: Record<string, boolean>;
|
||||
}
|
||||
|
||||
export function TestProviders({ children, flags }: TestProvidersProps) {
|
||||
return (
|
||||
<MockFeatureFlagProvider flags={flags}>
|
||||
{children}
|
||||
</MockFeatureFlagProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Coverage Targets
|
||||
|
||||
| Layer | Target | Rationale |
|
||||
|---|---|---|
|
||||
| Services | 90%+ | Services contain all business logic and data access. Bugs here corrupt data. |
|
||||
| Hooks | 80%+ | Hooks orchestrate service calls and manage state. Most logic is delegated to services. |
|
||||
| Components | 70%+ | Component tests cover interactive behavior. Layout and styling are not tested. |
|
||||
| Utilities | 95%+ | Pure functions with clear inputs/outputs. Easy to test exhaustively. |
|
||||
| Overall | 75%+ | Weighted average across all layers. |
|
||||
|
||||
Coverage is measured with V8 via Vitest and reported in CI. Coverage gates are advisory, not blocking, during the initial build-out phase. They become blocking once the codebase stabilizes.
|
||||
|
||||
---
|
||||
|
||||
## CI Integration
|
||||
|
||||
Tests run on every pull request. The pipeline:
|
||||
|
||||
```yaml
|
||||
# .github/workflows/test.yml (or equivalent CI config)
|
||||
|
||||
steps:
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Type check
|
||||
run: npx tsc --noEmit
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
|
||||
- name: Unit + Component + Integration tests
|
||||
run: npx vitest run --coverage
|
||||
|
||||
- name: E2E tests
|
||||
run: npx playwright test
|
||||
|
||||
- name: Upload coverage
|
||||
run: # upload lcov report to coverage service
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
- All tests must pass before a PR can be merged.
|
||||
- Coverage regressions (dropping below target) produce a warning, not a blocking failure.
|
||||
- E2E tests run against a production build (`npm run build && npm start`) in CI, not against the dev server.
|
||||
- Flaky tests are quarantined (moved to a `*.flaky.test.ts` suffix) and tracked for repair. They do not block the pipeline.
|
||||
|
||||
---
|
||||
|
||||
## Testing Anti-Patterns
|
||||
|
||||
These patterns are explicitly avoided in ArchiTools tests.
|
||||
|
||||
### 1. Testing Implementation Details
|
||||
|
||||
**Bad:** Asserting on internal state, private methods, or specific DOM structure.
|
||||
|
||||
```typescript
|
||||
// BAD: testing internal state
|
||||
expect(component.state.isOpen).toBe(true);
|
||||
|
||||
// BAD: testing CSS classes
|
||||
expect(container.querySelector('.modal-active')).toBeTruthy();
|
||||
```
|
||||
|
||||
**Good:** Assert on what the user sees or what the API returns.
|
||||
|
||||
```typescript
|
||||
// GOOD: testing visible behavior
|
||||
expect(screen.getByRole('dialog')).toBeVisible();
|
||||
```
|
||||
|
||||
### 2. Snapshot Testing for Components
|
||||
|
||||
Snapshot tests are brittle and provide low signal. A single class name change breaks the snapshot, and reviewers rubber-stamp snapshot updates. Do not use `toMatchSnapshot()` or `toMatchInlineSnapshot()` for component output.
|
||||
|
||||
### 3. Mocking Everything
|
||||
|
||||
Over-mocking eliminates the value of the test. If a service test mocks the storage adapter, the formatter, and the validator, it is testing nothing but the function's wiring.
|
||||
|
||||
**Rule:** Mock at the boundary. For service tests, mock storage. For hook tests, mock the service. For component tests, mock the hook. One layer deep, no more.
|
||||
|
||||
### 4. Testing the Framework
|
||||
|
||||
Do not test that React renders a component, that `useState` works, or that `useEffect` fires. Test your logic, not React's.
|
||||
|
||||
```typescript
|
||||
// BAD: testing that React works
|
||||
it('renders without crashing', () => {
|
||||
render(<MyComponent />);
|
||||
});
|
||||
```
|
||||
|
||||
This test passes for an empty `<div>` and catches nothing useful. Test specific behavior instead.
|
||||
|
||||
### 5. Coupling Tests to Data Order
|
||||
|
||||
Tests that depend on array order without explicitly sorting are fragile. If the service returns entries sorted by date and the test asserts on `entries[0].title`, it will break when a second entry has the same timestamp.
|
||||
|
||||
**Rule:** Either sort explicitly in the test or use `expect.arrayContaining` / `toContainEqual`.
|
||||
|
||||
### 6. Not Cleaning Up State
|
||||
|
||||
Tests that write to localStorage (or any shared state) without clearing it in `beforeEach` will produce order-dependent failures. The test setup file clears localStorage globally, but custom state (e.g., module-level caches) must be reset in the test's own `beforeEach`.
|
||||
|
||||
### 7. Giant E2E Tests
|
||||
|
||||
A single E2E test that creates 10 entities, edits 5, deletes 3, and checks a report is slow, fragile, and hard to debug. Keep E2E tests focused on one flow. Use `test.beforeEach` to set up preconditions via API/localStorage rather than through the UI.
|
||||
|
||||
### 8. Ignoring Async Behavior
|
||||
|
||||
```typescript
|
||||
// BAD: not waiting for async updates
|
||||
const { result } = renderHook(() => useRegistry());
|
||||
expect(result.current.entries).toHaveLength(1); // may be empty -- hook hasn't loaded yet
|
||||
|
||||
// GOOD: wait for the async operation
|
||||
await waitFor(() => {
|
||||
expect(result.current.entries).toHaveLength(1);
|
||||
});
|
||||
```
|
||||
Reference in New Issue
Block a user