- 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.
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
- Write Tests First: Tests drive design decisions
- Red-Green-Refactor: Follow TDD cycle rigorously
- Test Behavior, Not Implementation: Focus on what, not how
- Fast Feedback: Tests should run quickly
- 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:
- Unit tests: Test data transformations
- Snapshot tests: Test SVG output
- 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"
}
}
Related Documents
- 01-architecture.md - System architecture
- 02-design-patterns.md - Design patterns
- 04-example-ld-mapping.md - Example LD reuse
- 05-d3-visualization.md - D3.js visualization testing