# 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 = {}; 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; 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(); 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(); 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(); expect(screen.getByText(/nu exista inregistrari/i)).toBeInTheDocument(); }); it('filters entries when tag filter is applied', async () => { render( ); 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(); 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; children: React.ReactNode; } export function MockFeatureFlagProvider({ flags = {}, children, }: MockFlagProviderProps) { const allEnabled = new Proxy(flags, { get: (target, prop: string) => target[prop] ?? true, }); return ( {children} ); } ``` ### 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 { 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 { 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 { 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; } export function TestProviders({ children, flags }: TestProvidersProps) { return ( {children} ); } ``` --- ## 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(); }); ``` This test passes for an empty `
` 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); }); ```