- 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.
904 lines
24 KiB
Markdown
904 lines
24 KiB
Markdown
# 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 : <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**:
|
|
|
|
```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(<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**:
|
|
|
|
```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 : <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**:
|
|
|
|
```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<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**:
|
|
|
|
```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('<!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
|
|
|
|
```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<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
|
|
|
|
```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<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
|
|
|
|
```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
|