# 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**: ```typescript // 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 : . :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 : { 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**: ```typescript // 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(); 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(); 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(); expect(screen.getByTestId('loading-spinner')).toBeInTheDocument(); expect(screen.queryByText(custodian.name)).not.toBeInTheDocument(); }); }); ``` ### Hook Testing **Approach**: Test hooks in isolation using renderHook **Example**: ```typescript // 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 : . :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**: ```typescript // 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; beforeEach(() => { store = setupTestStore({ rdf: { triples: mockTriples(), format: 'turtle', }, }); }); it('should execute SPARQL query and display results', async () => { const user = userEvent.setup(); render( ); // 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( ); // 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**: ```typescript // 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**: ```typescript // 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('
'); 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('
'); 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 ```typescript // 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 ```typescript // test/builders/CustodianBuilder.ts export class CustodianBuilder { private custodian: Partial = { 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 ```typescript // 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" } ] } } ``` ```typescript // 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) ```typescript // 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' })); } }), ]; ``` ```typescript // 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 ```typescript // test/utils/setupTestStore.ts import { create } from 'zustand'; import { RDFSlice, QuerySlice, UISlice } from '@/stores/slices'; export function setupTestStore(initialState?: Partial) { return create()((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 ```typescript // 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 ```json // 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 ```yaml # .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 ```json // 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" } } ``` ## Related Documents - [01-architecture.md](01-architecture.md) - System architecture - [02-design-patterns.md](02-design-patterns.md) - Design patterns - [04-example-ld-mapping.md](04-example-ld-mapping.md) - Example LD reuse - [05-d3-visualization.md](05-d3-visualization.md) - D3.js visualization testing