Files
ArchiTools/docs/guides/TESTING-STRATEGY.md
Marius Tarau 4c46e8bcdd 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>
2026-02-17 12:50:25 +02:00

26 KiB

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

// 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

// 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

// 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

// 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

// 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

// 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.

// 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

// 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

// 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

// 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

// 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

// 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

// 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

// 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:

# .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.

// 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.

// 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.

// 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

// 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);
});