glam/docs/plan/frontend/03-tdd-strategy.md
kempersc fa5680f0dd Add initial versions of custodian hub UML diagrams in Mermaid and PlantUML formats
- Introduced custodian_hub_v3.mmd, custodian_hub_v4_final.mmd, and custodian_hub_v5_FINAL.mmd for Mermaid representation.
- Created custodian_hub_FINAL.puml and custodian_hub_v3.puml for PlantUML representation.
- Defined entities such as CustodianReconstruction, Identifier, TimeSpan, Agent, CustodianName, CustodianObservation, ReconstructionActivity, Appellation, ConfidenceMeasure, Custodian, LanguageCode, and SourceDocument.
- Established relationships and associations between entities, including temporal extents, observations, and reconstruction activities.
- Incorporated enumerations for various types, statuses, and classifications relevant to custodians and their activities.
2025-11-22 14:33:51 +01:00

24 KiB

Test-Driven Development Strategy

Overview

This document outlines the TDD (Test-Driven Development) strategy for the Heritage Custodian Frontend application. We follow a comprehensive testing pyramid approach with emphasis on automated testing at all levels.

Testing Philosophy

Core Principles

  1. Write Tests First: Tests drive design decisions
  2. Red-Green-Refactor: Follow TDD cycle rigorously
  3. Test Behavior, Not Implementation: Focus on what, not how
  4. Fast Feedback: Tests should run quickly
  5. Maintainable Tests: Tests are first-class code

Testing Pyramid

        ┌─────────────┐
        │   E2E (5%)  │  ← Playwright
        ├─────────────┤
        │Integration  │  ← React Testing Library
        │   (15%)     │     + API mocks
        ├─────────────┤
        │   Unit      │  ← Vitest + Testing Library
        │   (80%)     │
        └─────────────┘

Test Stack

Unit Testing

Tools:

  • Vitest: Fast unit test runner (Vite-native)
  • React Testing Library: Component testing
  • @testing-library/user-event: User interaction simulation
  • @testing-library/jest-dom: Custom matchers

What to Test:

  • Pure functions (RDF parsers, transformers, utilities)
  • Custom hooks (useRDF, useSPARQL, useVisualization)
  • Component rendering logic
  • State management stores

Example:

// lib/rdf/parser.test.ts
import { describe, it, expect } from 'vitest';
import { RDFParser } from './parser';

describe('RDFParser', () => {
  describe('parse', () => {
    it('should parse valid Turtle syntax', async () => {
      const parser = new RDFParser();
      const turtle = `
        @prefix : <http://example.org/> .
        :alice a :Person ;
               :name "Alice" .
      `;
      
      const triples = await parser.parse(turtle, 'turtle');
      
      expect(triples).toHaveLength(2);
      expect(triples[0].predicate).toBe('http://www.w3.org/1999/02/22-rdf-syntax-ns#type');
      expect(triples[1].predicate).toBe('http://example.org/name');
    });
    
    it('should throw error on invalid syntax', async () => {
      const parser = new RDFParser();
      const invalid = '@prefix : <unclosed';
      
      await expect(parser.parse(invalid, 'turtle')).rejects.toThrow();
    });
    
    it('should handle empty input', async () => {
      const parser = new RDFParser();
      
      const triples = await parser.parse('', 'turtle');
      
      expect(triples).toHaveLength(0);
    });
  });
  
  describe('serialize', () => {
    it('should serialize triples to Turtle', async () => {
      const parser = new RDFParser();
      const triples: Triple[] = [
        {
          subject: 'http://example.org/alice',
          predicate: 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type',
          object: 'http://example.org/Person',
        },
      ];
      
      const turtle = await parser.serialize(triples, 'turtle');
      
      expect(turtle).toContain('a :Person');
    });
  });
});

Component Testing

Approach: Test components from user perspective

Example:

// components/custodian/CustodianCard.test.tsx
import { describe, it, expect, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { CustodianCard } from './CustodianCard';
import { CustodianBuilder } from '@/test/builders';

describe('CustodianCard', () => {
  it('should render custodian information', () => {
    const custodian = new CustodianBuilder()
      .withName('Rijksmuseum')
      .withType('MUSEUM')
      .withLocation('Amsterdam', 'NL')
      .build();
    
    render(<CustodianCard custodian={custodian} />);
    
    expect(screen.getByText('Rijksmuseum')).toBeInTheDocument();
    expect(screen.getByText('MUSEUM')).toBeInTheDocument();
    expect(screen.getByText(/Amsterdam/)).toBeInTheDocument();
  });
  
  it('should call onClick when card is clicked', async () => {
    const user = userEvent.setup();
    const handleClick = vi.fn();
    const custodian = new CustodianBuilder().build();
    
    render(<CustodianCard custodian={custodian} onClick={handleClick} />);
    
    await user.click(screen.getByRole('button'));
    
    expect(handleClick).toHaveBeenCalledWith(custodian);
    expect(handleClick).toHaveBeenCalledTimes(1);
  });
  
  it('should show loading state while fetching details', async () => {
    const custodian = new CustodianBuilder().build();
    
    render(<CustodianCard custodian={custodian} isLoading />);
    
    expect(screen.getByTestId('loading-spinner')).toBeInTheDocument();
    expect(screen.queryByText(custodian.name)).not.toBeInTheDocument();
  });
});

Hook Testing

Approach: Test hooks in isolation using renderHook

Example:

// hooks/useRDF.test.ts
import { describe, it, expect, vi } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { useRDF } from './useRDF';

describe('useRDF', () => {
  it('should initialize with empty store', () => {
    const { result } = renderHook(() => useRDF());
    
    expect(result.current.store.size).toBe(0);
    expect(result.current.format).toBe('turtle');
    expect(result.current.isLoading).toBe(false);
  });
  
  it('should parse RDF content', async () => {
    const { result } = renderHook(() => useRDF());
    const turtle = '@prefix : <http://example.org/> . :alice a :Person .';
    
    await waitFor(() => {
      result.current.loadRDF(turtle);
    });
    
    await waitFor(() => {
      expect(result.current.isLoading).toBe(false);
    });
    
    expect(result.current.store.size).toBeGreaterThan(0);
    expect(result.current.error).toBeNull();
  });
  
  it('should handle parse errors', async () => {
    const { result } = renderHook(() => useRDF());
    const invalid = '@prefix invalid';
    
    await waitFor(() => {
      result.current.loadRDF(invalid);
    });
    
    await waitFor(() => {
      expect(result.current.isLoading).toBe(false);
    });
    
    expect(result.current.error).not.toBeNull();
    expect(result.current.error?.message).toMatch(/parse/i);
  });
});

Integration Testing

Approach: Test feature modules with real (or near-real) dependencies

Example:

// features/query/QueryInterface.test.tsx
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { QueryInterface } from './QueryInterface';
import { setupTestStore } from '@/test/utils';
import { N3 } from 'n3';

describe('QueryInterface (Integration)', () => {
  let store: ReturnType<typeof setupTestStore>;
  
  beforeEach(() => {
    store = setupTestStore({
      rdf: {
        triples: mockTriples(),
        format: 'turtle',
      },
    });
  });
  
  it('should execute SPARQL query and display results', async () => {
    const user = userEvent.setup();
    
    render(
      <StoreProvider store={store}>
        <QueryInterface />
      </StoreProvider>
    );
    
    // Enter SPARQL query
    const editor = screen.getByRole('textbox', { name: /sparql/i });
    await user.type(editor, 'SELECT * WHERE { ?s ?p ?o } LIMIT 10');
    
    // Execute query
    await user.click(screen.getByRole('button', { name: /execute/i }));
    
    // Wait for results
    await waitFor(() => {
      expect(screen.getByTestId('results-table')).toBeInTheDocument();
    });
    
    // Verify results rendered
    const rows = screen.getAllByRole('row');
    expect(rows.length).toBeGreaterThan(1); // Header + data rows
  });
  
  it('should show error for invalid SPARQL', async () => {
    const user = userEvent.setup();
    
    render(
      <StoreProvider store={store}>
        <QueryInterface />
      </StoreProvider>
    );
    
    // Enter invalid SPARQL
    const editor = screen.getByRole('textbox', { name: /sparql/i });
    await user.type(editor, 'INVALID QUERY');
    
    // Execute query
    await user.click(screen.getByRole('button', { name: /execute/i }));
    
    // Wait for error
    await waitFor(() => {
      expect(screen.getByText(/syntax error/i)).toBeInTheDocument();
    });
  });
});

End-to-End Testing

Tool: Playwright

Approach: Test critical user journeys

Example:

// e2e/rdf-exploration.spec.ts
import { test, expect } from '@playwright/test';

test.describe('RDF Exploration Flow', () => {
  test('complete user journey: load RDF → query → visualize → export', async ({ page }) => {
    // Navigate to app
    await page.goto('/');
    
    // Load RDF file
    await page.click('button:has-text("Load RDF")');
    await page.setInputFiles('input[type="file"]', 'fixtures/custodian.ttl');
    
    await expect(page.locator('text=Loading complete')).toBeVisible();
    await expect(page.locator('text=1,234 triples loaded')).toBeVisible();
    
    // Navigate to query page
    await page.click('a[href="/query"]');
    
    // Write SPARQL query
    await page.fill('[data-testid="sparql-editor"]', 
      'SELECT * WHERE { ?s a :CustodianObservation } LIMIT 50'
    );
    
    // Execute query
    await page.click('button:has-text("Execute")');
    
    await expect(page.locator('[data-testid="results-table"]')).toBeVisible();
    const resultRows = await page.locator('tbody tr').count();
    expect(resultRows).toBe(50);
    
    // Select first result
    await page.click('tbody tr:first-child');
    
    // Verify detail view
    await expect(page.locator('[data-testid="custodian-detail"]')).toBeVisible();
    await expect(page.locator('text=CustodianObservation')).toBeVisible();
    
    // Navigate to visualization tab
    await page.click('button:has-text("Network Graph")');
    
    await expect(page.locator('svg#graph-svg')).toBeVisible();
    const nodes = await page.locator('circle.node').count();
    expect(nodes).toBeGreaterThan(0);
    
    // Export results
    await page.click('button:has-text("Export")');
    await page.click('text=CSV');
    
    // Wait for download
    const [download] = await Promise.all([
      page.waitForEvent('download'),
      page.click('button:has-text("Download")'),
    ]);
    
    expect(download.suggestedFilename()).toMatch(/custodians.*\.csv$/);
  });
  
  test('error handling: invalid RDF file', async ({ page }) => {
    await page.goto('/');
    
    // Try to load invalid file
    await page.click('button:has-text("Load RDF")');
    await page.setInputFiles('input[type="file"]', 'fixtures/invalid.txt');
    
    // Expect error message
    await expect(page.locator('.alert-error')).toBeVisible();
    await expect(page.locator('text=/Failed to parse/i')).toBeVisible();
  });
});

D3.js Visualization Testing

Strategy

D3.js visualizations are challenging to test. We use a multi-layered approach:

  1. Unit tests: Test data transformations
  2. Snapshot tests: Test SVG output
  3. Visual regression tests: Detect visual changes

Example:

// lib/viz/d3/uml.test.ts
import { describe, it, expect } from 'vitest';
import { JSDOM } from 'jsdom';
import * as d3 from 'd3';
import { UMLVisualizer } from './uml';

describe('UMLVisualizer', () => {
  describe('renderClassDiagram', () => {
    it('should create SVG with correct structure', () => {
      const dom = new JSDOM('<!DOCTYPE html><div id="container"></div>');
      const container = dom.window.document.getElementById('container')!;
      
      const schema = mockLinkMLSchema();
      const visualizer = new UMLVisualizer();
      
      const svg = visualizer.renderClassDiagram(container, schema);
      
      // Verify SVG created
      expect(svg).toBeDefined();
      expect(container.querySelector('svg')).toBeTruthy();
      
      // Verify classes rendered
      const classNodes = container.querySelectorAll('.class-node');
      expect(classNodes.length).toBe(schema.classes.length);
      
      // Verify relationships rendered
      const relationships = container.querySelectorAll('.relationship-line');
      expect(relationships.length).toBe(schema.relationships.length);
    });
    
    it('should apply correct class names to nodes', () => {
      const dom = new JSDOM('<!DOCTYPE html><div id="container"></div>');
      const container = dom.window.document.getElementById('container')!;
      
      const schema = mockLinkMLSchema();
      const visualizer = new UMLVisualizer();
      
      visualizer.renderClassDiagram(container, schema);
      
      // Check for specific class
      const custodianNode = container.querySelector('[data-class="CustodianObservation"]');
      expect(custodianNode).toBeTruthy();
      expect(custodianNode?.textContent).toContain('CustodianObservation');
    });
  });
  
  describe('data transformation', () => {
    it('should convert LinkML schema to D3 hierarchy', () => {
      const schema = mockLinkMLSchema();
      const visualizer = new UMLVisualizer();
      
      const hierarchy = visualizer.schemaToHierarchy(schema);
      
      expect(hierarchy).toHaveProperty('name');
      expect(hierarchy).toHaveProperty('children');
      expect(hierarchy.children).toHaveLength(schema.classes.length);
    });
  });
});

Visual Regression Testing

Tool: Playwright with screenshot comparison

// e2e/visual/uml-diagrams.spec.ts
import { test, expect } from '@playwright/test';

test('UML class diagram renders consistently', async ({ page }) => {
  await page.goto('/visualizations/uml');
  
  // Wait for visualization to render
  await page.waitForSelector('svg#uml-diagram');
  
  // Take screenshot
  await expect(page.locator('svg#uml-diagram')).toHaveScreenshot('uml-class-diagram.png', {
    maxDiffPixels: 100, // Allow small rendering differences
  });
});

test('Network graph renders consistently', async ({ page }) => {
  await page.goto('/visualizations/network');
  
  // Wait for force simulation to settle
  await page.waitForTimeout(2000);
  
  await expect(page.locator('svg#network-graph')).toHaveScreenshot('network-graph.png', {
    maxDiffPixels: 200, // Force layouts may vary slightly
  });
});

Test Data Management

Test Builders

Pattern: Builder pattern for test data

// test/builders/CustodianBuilder.ts
export class CustodianBuilder {
  private custodian: Partial<CustodianObservation> = {
    id: 'test-' + Math.random().toString(36).substr(2, 9),
    name: 'Test Museum',
    institution_type: 'MUSEUM',
    provenance: {
      data_source: 'CSV_REGISTRY',
      data_tier: 'TIER_1_AUTHORITATIVE',
      extraction_date: new Date().toISOString(),
    },
  };
  
  withId(id: string): this {
    this.custodian.id = id;
    return this;
  }
  
  withName(name: string): this {
    this.custodian.name = name;
    return this;
  }
  
  withType(type: InstitutionType): this {
    this.custodian.institution_type = type;
    return this;
  }
  
  withLocation(city: string, country: string): this {
    this.custodian.locations = [{
      city,
      country,
      latitude: 52.3676 + Math.random(),
      longitude: 4.9041 + Math.random(),
    }];
    return this;
  }
  
  withIdentifier(scheme: string, value: string): this {
    if (!this.custodian.identifiers) {
      this.custodian.identifiers = [];
    }
    this.custodian.identifiers.push({
      identifier_scheme: scheme,
      identifier_value: value,
    });
    return this;
  }
  
  build(): CustodianObservation {
    return this.custodian as CustodianObservation;
  }
  
  buildMany(count: number): CustodianObservation[] {
    return Array.from({ length: count }, (_, i) => 
      new CustodianBuilder()
        .withId(`test-${i}`)
        .withName(`Test Museum ${i}`)
        .build()
    );
  }
}

Fixtures

Pattern: JSON fixtures for complex test data

// test/fixtures/custodians.json
{
  "rijksmuseum": {
    "id": "https://w3id.org/heritage/custodian/nl/rijksmuseum",
    "name": "Rijksmuseum",
    "institution_type": "MUSEUM",
    "locations": [
      {
        "city": "Amsterdam",
        "country": "NL",
        "latitude": 52.3600,
        "longitude": 4.8852
      }
    ],
    "identifiers": [
      {
        "identifier_scheme": "ISIL",
        "identifier_value": "NL-AmRMA"
      },
      {
        "identifier_scheme": "Wikidata",
        "identifier_value": "Q190804"
      }
    ]
  }
}
// test/fixtures/index.ts
import custodiansData from './custodians.json';

export const fixtures = {
  custodians: custodiansData,
  
  getCustodian(id: string): CustodianObservation {
    const data = custodiansData[id];
    if (!data) throw new Error(`Fixture not found: ${id}`);
    return data as CustodianObservation;
  },
};

Mocking Strategies

API Mocking

Tool: MSW (Mock Service Worker)

// test/mocks/handlers.ts
import { rest } from 'msw';
import { fixtures } from '@/test/fixtures';

export const handlers = [
  // SPARQL endpoint
  rest.post('/api/sparql/query', async (req, res, ctx) => {
    const { query } = await req.json();
    
    if (query.includes('CustodianObservation')) {
      return res(
        ctx.status(200),
        ctx.json({
          results: {
            bindings: [
              {
                s: { value: fixtures.getCustodian('rijksmuseum').id },
                name: { value: 'Rijksmuseum' },
                type: { value: 'MUSEUM' },
              },
            ],
          },
        })
      );
    }
    
    return res(ctx.status(400), ctx.json({ error: 'Invalid query' }));
  }),
  
  // Custodian detail endpoint
  rest.get('/api/custodians/:id', (req, res, ctx) => {
    const { id } = req.params;
    
    try {
      const custodian = fixtures.getCustodian(id as string);
      return res(ctx.status(200), ctx.json(custodian));
    } catch {
      return res(ctx.status(404), ctx.json({ error: 'Custodian not found' }));
    }
  }),
];
// test/setup.ts
import { setupServer } from 'msw/node';
import { handlers } from './mocks/handlers';

const server = setupServer(...handlers);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

Store Mocking

// test/utils/setupTestStore.ts
import { create } from 'zustand';
import { RDFSlice, QuerySlice, UISlice } from '@/stores/slices';

export function setupTestStore(initialState?: Partial<Store>) {
  return create<Store>()((set, get) => ({
    // RDF slice
    triples: initialState?.triples || [],
    format: initialState?.format || 'turtle',
    loadRDF: vi.fn(),
    
    // Query slice
    currentQuery: initialState?.currentQuery || '',
    results: initialState?.results || [],
    executeQuery: vi.fn(),
    
    // UI slice
    theme: initialState?.theme || 'light',
    sidebarOpen: initialState?.sidebarOpen || true,
    toggleSidebar: vi.fn(),
  }));
}

TDD Workflow

Red-Green-Refactor Cycle

1. RED: Write a failing test
   ├─ Define expected behavior
   ├─ Write test case
   └─ Verify test fails

2. GREEN: Make test pass
   ├─ Write minimal implementation
   ├─ Run tests
   └─ Verify test passes

3. REFACTOR: Improve code
   ├─ Remove duplication
   ├─ Improve naming
   ├─ Optimize performance
   └─ Run tests to ensure still passing

Example TDD Session

Feature: Add SPARQL query validation

// Step 1: RED - Write failing test
describe('SPARQLQueryValidator', () => {
  it('should reject queries with syntax errors', () => {
    const validator = new SPARQLQueryValidator();
    const invalidQuery = 'SELECT WHERE { ?s ?p ?o'; // Missing closing brace
    
    const result = validator.validate(invalidQuery);
    
    expect(result.isValid).toBe(false);
    expect(result.errors).toContain('Syntax error: missing closing brace');
  });
});

// Run test → FAILS (SPARQLQueryValidator doesn't exist yet)

// Step 2: GREEN - Minimal implementation
export class SPARQLQueryValidator {
  validate(query: string): ValidationResult {
    const openBraces = (query.match(/{/g) || []).length;
    const closeBraces = (query.match(/}/g) || []).length;
    
    if (openBraces !== closeBraces) {
      return {
        isValid: false,
        errors: ['Syntax error: missing closing brace'],
      };
    }
    
    return { isValid: true, errors: [] };
  }
}

// Run test → PASSES

// Step 3: REFACTOR - Add more comprehensive validation
export class SPARQLQueryValidator {
  validate(query: string): ValidationResult {
    const errors: string[] = [];
    
    // Check balanced braces
    if (!this.hasBalancedBraces(query)) {
      errors.push('Syntax error: missing closing brace');
    }
    
    // Check for required clauses
    if (!this.hasSelectOrConstruct(query)) {
      errors.push('Query must have SELECT or CONSTRUCT clause');
    }
    
    // Check for WHERE clause
    if (!this.hasWhereClause(query)) {
      errors.push('Query must have WHERE clause');
    }
    
    return {
      isValid: errors.length === 0,
      errors,
    };
  }
  
  private hasBalancedBraces(query: string): boolean {
    const openBraces = (query.match(/{/g) || []).length;
    const closeBraces = (query.match(/}/g) || []).length;
    return openBraces === closeBraces;
  }
  
  private hasSelectOrConstruct(query: string): boolean {
    return /\b(SELECT|CONSTRUCT)\b/i.test(query);
  }
  
  private hasWhereClause(query: string): boolean {
    return /\bWHERE\b/i.test(query);
  }
}

// Run tests → STILL PASSES

Coverage Requirements

Coverage Targets

  • Statements: ≥ 80%
  • Branches: ≥ 75%
  • Functions: ≥ 80%
  • Lines: ≥ 80%

Critical Paths

Require 100% coverage:

  • RDF parsing and serialization
  • SPARQL query execution
  • Data export functions
  • Security-related code (sanitization, validation)

Coverage Reports

// vitest.config.ts
export default defineConfig({
  test: {
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html', 'lcov'],
      include: ['src/**/*.{ts,tsx}'],
      exclude: [
        'src/**/*.test.{ts,tsx}',
        'src/**/*.spec.{ts,tsx}',
        'src/test/**/*',
      ],
      lines: 80,
      functions: 80,
      branches: 75,
      statements: 80,
    },
  },
});

Continuous Integration

GitHub Actions Workflow

# .github/workflows/test.yml
name: Tests

on: [push, pull_request]

jobs:
  unit-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
        with:
          node-version: 20
      - run: npm ci
      - run: npm run test:unit -- --coverage
      - uses: codecov/codecov-action@v3
        with:
          files: ./coverage/lcov.info
  
  integration-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
        with:
          node-version: 20
      - run: npm ci
      - run: npm run test:integration
  
  e2e-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
        with:
          node-version: 20
      - run: npm ci
      - run: npx playwright install --with-deps
      - run: npm run test:e2e
      - uses: actions/upload-artifact@v3
        if: failure()
        with:
          name: playwright-report
          path: playwright-report/

Test Commands

// package.json scripts
{
  "scripts": {
    "test": "vitest",
    "test:unit": "vitest run src/**/*.test.{ts,tsx}",
    "test:integration": "vitest run src/**/*.integration.test.{ts,tsx}",
    "test:e2e": "playwright test",
    "test:watch": "vitest",
    "test:coverage": "vitest run --coverage",
    "test:ui": "vitest --ui"
  }
}