| name | testing-strategy |
| description | Node.js testing with Vitest/Jest, MCP protocol testing, European Parliament API mocking, integration tests, and 80%+ coverage |
| license | MIT |
Testing Strategy Skill
Context
This skill applies when:
- Writing unit tests for functions, classes, or modules
- Creating integration tests for MCP protocol handlers
- Mocking European Parliament API responses
- Testing input validation and error handling
- Writing tests for async/await code and Promises
- Implementing test fixtures and test data
- Measuring and improving code coverage
- Testing security controls and edge cases
- Debugging failing or flaky tests
- Setting up test infrastructure and CI/CD
Testing is mandatory for all code. We maintain 80%+ code coverage with fast, deterministic tests that catch regressions early. Tests serve as living documentation and enable confident refactoring.
Rules
- Test Behavior, Not Implementation: Focus on what code does (inputs/outputs), not how it does it
- 80% Coverage Minimum: Maintain 80%+ line coverage, 70%+ branch coverage
- Fast Tests: Unit tests < 100ms, integration tests < 1s, full suite < 5min
- Deterministic Tests: Tests must pass/fail consistently - no flakiness, no randomness
- AAA Pattern: Arrange (setup), Act (execute), Assert (verify) - clear test structure
- One Assertion Per Test: Test one behavior per test case (exceptions for related assertions)
- Mock External Dependencies: Mock European Parliament API, file system, network calls
- Test Edge Cases: Empty inputs, null values, boundary conditions, error paths
- Describe What, Not How: Test names describe expected behavior, not implementation
- Use Type-Safe Mocks: TypeScript mocks should match actual types
- Test Async Properly: Use async/await, avoid callback hell, handle Promise rejections
- Test Security Controls: Validate input validation, rate limiting, authentication, authorization
- Setup and Teardown: Clean up resources, reset mocks, avoid test pollution
- Snapshot Tests Sparingly: Only for stable data structures, not for debugging
- CI/CD Integration: All tests must pass before merge, run on every commit
Examples
✅ Good Pattern: Well-Structured Unit Test
import { describe, it, expect } from 'vitest';
import { InputValidationService, ValidationError } from './InputValidationService';
describe('InputValidationService', () => {
describe('validateSearchQuery', () => {
it('should accept valid search query with all parameters', () => {
const validator = new InputValidationService();
const params = {
keywords: 'climate change',
documentType: 'REPORT',
dateFrom: '2024-01-01',
dateTo: '2024-12-31',
limit: 20,
};
const result = validator.validateSearchQuery(params);
expect(result).toEqual({
keywords: 'climate change',
documentType: 'REPORT',
dateFrom: '2024-01-01',
dateTo: '2024-12-31',
limit: 20,
});
});
it('should accept valid search query with minimal parameters', () => {
const validator = new InputValidationService();
const params = {
keywords: 'digital markets',
};
const result = validator.validateSearchQuery(params);
expect(result.keywords).toBe('digital markets');
expect(result.documentType).toBeUndefined();
expect(result.dateFrom).toBeUndefined();
expect(result.dateTo).toBeUndefined();
expect(result.limit).toBe(20);
});
it('should normalize keywords by trimming whitespace', () => {
const validator = new InputValidationService();
const params = {
keywords: ' climate change ',
};
const result = validator.validateSearchQuery(params);
expect(result.keywords).toBe('climate change');
});
it('should reject empty keywords', () => {
const validator = new InputValidationService();
const params = {
keywords: '',
};
expect(() => validator.validateSearchQuery(params))
.toThrow(ValidationError);
expect(() => validator.validateSearchQuery(params))
.toThrow('Keywords cannot be empty');
});
it('should reject keywords exceeding maximum length', () => {
const validator = new InputValidationService();
const params = {
keywords: 'a'.repeat(201),
};
expect(() => validator.validateSearchQuery(params))
.toThrow(ValidationError);
expect(() => validator.validateSearchQuery(params))
.toThrow('Keywords exceed maximum length');
});
it('should reject keywords with invalid characters', () => {
const validator = new InputValidationService();
const params = {
keywords: 'climate<script>alert("xss")</script>',
};
expect(() => validator.validateSearchQuery(params))
.toThrow(ValidationError);
expect(() => validator.validateSearchQuery(params))
.toThrow('Keywords contain invalid characters');
});
it('should reject invalid document type', () => {
const validator = new InputValidationService();
const params = {
keywords: 'climate change',
documentType: 'INVALID_TYPE',
};
expect(() => validator.validateSearchQuery(params))
.toThrow(ValidationError);
expect(() => validator.validateSearchQuery(params))
.toThrow('Invalid document type');
});
it('should reject invalid date format', () => {
const validator = new InputValidationService();
const params = {
keywords: 'climate change',
dateFrom: '01/01/2024',
};
expect(() => validator.validateSearchQuery(params))
.toThrow(ValidationError);
expect(() => validator.validateSearchQuery(params))
.toThrow('must be in ISO 8601 format');
});
it('should reject limit outside valid range', () => {
const validator = new InputValidationService();
const params = {
keywords: 'climate change',
limit: 101,
};
expect(() => validator.validateSearchQuery(params))
.toThrow(ValidationError);
expect(() => validator.validateSearchQuery(params))
.toThrow('Limit must be between 1 and 100');
});
it('should reject non-object parameters', () => {
const validator = new InputValidationService();
expect(() => validator.validateSearchQuery(null))
.toThrow('Invalid parameters object');
expect(() => validator.validateSearchQuery('string'))
.toThrow('Invalid parameters object');
expect(() => validator.validateSearchQuery(123))
.toThrow('Invalid parameters object');
});
});
});
✅ Good Pattern: Mocking European Parliament API
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { SearchHandler } from './SearchHandler';
import * as europeanParliamentApi from './europeanParliamentApi';
vi.mock('./europeanParliamentApi');
describe('SearchHandler', () => {
let handler: SearchHandler;
beforeEach(() => {
vi.clearAllMocks();
handler = new SearchHandler();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('handleSearch', () => {
it('should return search results from European Parliament API', async () => {
const mockResults = {
total: 42,
documents: [
{
id: 'EP-20240101-00001',
title: 'Climate Change Report',
type: 'REPORT',
date: '2024-01-01',
},
{
id: 'EP-20240102-00002',
title: 'Digital Markets Act',
type: 'REGULATION',
date: '2024-01-02',
},
],
};
vi.mocked(europeanParliamentApi.search).mockResolvedValue(mockResults);
const request = {
method: 'tools/call',
params: {
name: 'search_documents',
arguments: {
keywords: 'climate change',
limit: 10,
},
},
};
const response = await handler.handleSearch(request);
expect(europeanParliamentApi.search).toHaveBeenCalledOnce();
expect(europeanParliamentApi.search).toHaveBeenCalledWith({
keywords: 'climate change',
limit: 10,
});
expect(response.content[0].text).toContain('Climate Change Report');
expect(response.content[0].text).toContain('Digital Markets Act');
});
it('should use cached results for duplicate queries', async () => {
const mockResults = {
total: 1,
documents: [{ id: 'EP-20240101-00001', title: 'Test' }],
};
vi.mocked(europeanParliamentApi.search).mockResolvedValue(mockResults);
const request = {
method: 'tools/call',
params: {
name: 'search_documents',
arguments: {
keywords: 'test query',
},
},
};
await handler.handleSearch(request);
await handler.handleSearch(request);
expect(europeanParliamentApi.search).toHaveBeenCalledOnce();
});
it('should handle API errors gracefully', async () => {
const apiError = new Error('API connection failed');
vi.mocked(europeanParliamentApi.search).mockRejectedValue(apiError);
const request = {
method: 'tools/call',
params: {
name: 'search_documents',
arguments: {
keywords: 'test',
},
},
};
await expect(handler.handleSearch(request))
.rejects
.toThrow('API connection failed');
expect(europeanParliamentApi.search).toHaveBeenCalledOnce();
});
it('should validate input before calling API', async () => {
const request = {
method: 'tools/call',
params: {
name: 'search_documents',
arguments: {
keywords: '',
},
},
};
await expect(handler.handleSearch(request))
.rejects
.toThrow('Keywords cannot be empty');
expect(europeanParliamentApi.search).not.toHaveBeenCalled();
});
it('should handle timeout errors', async () => {
vi.mocked(europeanParliamentApi.search).mockImplementation(() =>
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Request timeout')), 100)
)
);
const request = {
method: 'tools/call',
params: {
name: 'search_documents',
arguments: {
keywords: 'test',
},
},
};
await expect(handler.handleSearch(request))
.rejects
.toThrow('Request timeout');
});
});
});
✅ Good Pattern: Testing Async Operations
import { describe, it, expect, vi } from 'vitest';
import { fetchDocumentsBatch } from './batchProcessor';
import * as europeanParliamentApi from './europeanParliamentApi';
vi.mock('./europeanParliamentApi');
describe('fetchDocumentsBatch', () => {
it('should fetch multiple documents concurrently', async () => {
const documentIds = ['EP-001', 'EP-002', 'EP-003'];
vi.mocked(europeanParliamentApi.getDocument).mockImplementation(
async (id: string) => ({
id,
title: `Document ${id}`,
content: 'Test content',
})
);
const startTime = Date.now();
const results = await fetchDocumentsBatch(documentIds);
const duration = Date.now() - startTime;
expect(results.size).toBe(3);
expect(results.get('EP-001')?.title).toBe('Document EP-001');
expect(results.get('EP-002')?.title).toBe('Document EP-002');
expect(results.get('EP-003')?.title).toBe('Document EP-003');
expect(duration).toBeLessThan(500);
expect(europeanParliamentApi.getDocument).toHaveBeenCalledTimes(3);
});
it('should handle partial failures gracefully', async () => {
const documentIds = ['EP-001', 'EP-002', 'EP-003'];
vi.mocked(europeanParliamentApi.getDocument).mockImplementation(
async (id: string) => {
if (id === 'EP-002') {
throw new Error('Document not found');
}
return {
id,
title: `Document ${id}`,
content: 'Test content',
};
}
);
const results = await fetchDocumentsBatch(documentIds);
expect(results.size).toBe(2);
expect(results.has('EP-001')).toBe(true);
expect(results.has('EP-002')).toBe(false);
expect(results.has('EP-003')).toBe(true);
});
it('should respect concurrency limit', async () => {
const documentIds = Array.from({ length: 50 }, (_, i) => `EP-${i}`);
let concurrentCalls = 0;
let maxConcurrentCalls = 0;
vi.mocked(europeanParliamentApi.getDocument).mockImplementation(
async (id: string) => {
concurrentCalls++;
maxConcurrentCalls = Math.max(maxConcurrentCalls, concurrentCalls);
await new Promise(resolve => setTimeout(resolve, 10));
concurrentCalls--;
return {
id,
title: `Document ${id}`,
content: 'Test content',
};
}
);
await fetchDocumentsBatch(documentIds, { concurrency: 10 });
expect(maxConcurrentCalls).toBeLessThanOrEqual(10);
});
it('should handle timeout for slow requests', async () => {
const documentIds = ['EP-001', 'EP-002'];
vi.mocked(europeanParliamentApi.getDocument).mockImplementation(
async (id: string) => {
if (id === 'EP-001') {
await new Promise(resolve => setTimeout(resolve, 2000));
}
return {
id,
title: `Document ${id}`,
content: 'Test content',
};
}
);
const results = await fetchDocumentsBatch(documentIds, { timeout: 500 });
expect(results.size).toBe(1);
expect(results.has('EP-001')).toBe(false);
expect(results.has('EP-002')).toBe(true);
});
});
✅ Good Pattern: Testing Security Controls
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { RateLimiter, RateLimitError } from './RateLimiter';
describe('RateLimiter', () => {
let rateLimiter: RateLimiter;
beforeEach(() => {
rateLimiter = new RateLimiter();
});
it('should allow requests under burst limit', async () => {
const clientId = 'test-client-1';
for (let i = 0; i < 10; i++) {
await expect(rateLimiter.checkLimit(clientId))
.resolves
.not.toThrow();
}
});
it('should block requests exceeding burst limit', async () => {
const clientId = 'test-client-2';
for (let i = 0; i < 10; i++) {
await rateLimiter.checkLimit(clientId);
}
await expect(rateLimiter.checkLimit(clientId))
.rejects
.toThrow(RateLimitError);
});
it('should enforce sustained rate limit', async () => {
const clientId = 'test-client-3';
for (let i = 0; i < 100; i++) {
await rateLimiter.checkLimit(clientId);
await new Promise(resolve => setTimeout(resolve, 20));
}
await expect(rateLimiter.checkLimit(clientId))
.rejects
.toThrow(RateLimitError);
});
it('should provide retry-after time in error', async () => {
const clientId = 'test-client-4';
for (let i = 0; i < 10; i++) {
await rateLimiter.checkLimit(clientId);
}
try {
await rateLimiter.checkLimit(clientId);
throw new Error('Expected RateLimitError');
} catch (error) {
expect(error).toBeInstanceOf(RateLimitError);
expect((error as RateLimitError).retryAfter).toBeGreaterThan(0);
expect((error as RateLimitError).retryAfter).toBeLessThanOrEqual(10);
}
});
it('should isolate rate limits per client', async () => {
const client1 = 'test-client-5';
const client2 = 'test-client-6';
for (let i = 0; i < 10; i++) {
await rateLimiter.checkLimit(client1);
}
await expect(rateLimiter.checkLimit(client1))
.rejects
.toThrow(RateLimitError);
await expect(rateLimiter.checkLimit(client2))
.resolves
.not.toThrow();
});
it('should reset rate limit after window expires', async () => {
vi.useFakeTimers();
const clientId = 'test-client-7';
for (let i = 0; i < 10; i++) {
await rateLimiter.checkLimit(clientId);
}
await expect(rateLimiter.checkLimit(clientId))
.rejects
.toThrow(RateLimitError);
await vi.advanceTimersByTimeAsync(11000);
await expect(rateLimiter.checkLimit(clientId))
.resolves
.not.toThrow();
vi.useRealTimers();
});
});
✅ Good Pattern: Test Fixtures and Factories
export function createTestDocument(
overrides?: Partial<Document>
): Document {
return {
id: overrides?.id ?? 'EP-20240101-00001',
title: overrides?.title ?? 'Test Document',
type: overrides?.type ?? 'REPORT',
date: overrides?.date ?? '2024-01-01',
author: overrides?.author ?? 'Committee on Environment',
language: overrides?.language ?? 'en',
content: overrides?.content ?? 'Test content',
...overrides,
};
}
export function createTestSearchResult(
overrides?: Partial<SearchResult>
): SearchResult {
return {
total: overrides?.total ?? 10,
documents: overrides?.documents ?? [
createTestDocument({ id: 'EP-001' }),
createTestDocument({ id: 'EP-002' }),
createTestDocument({ id: 'EP-003' }),
],
query: overrides?.query ?? 'test query',
page: overrides?.page ?? 1,
pageSize: overrides?.pageSize ?? 20,
...overrides,
};
}
export function createMockMcpRequest(
tool: string,
args: Record<string, unknown>
): ToolRequest {
return {
method: 'tools/call',
params: {
name: tool,
arguments: args,
},
};
}
describe('SearchHandler', () => {
it('should process search results correctly', async () => {
const mockResults = createTestSearchResult({
total: 5,
documents: [
createTestDocument({ id: 'EP-001', title: 'Climate Report' }),
createTestDocument({ id: 'EP-002', title: 'Digital Markets' }),
],
});
const request = createMockMcpRequest('search_documents', {
keywords: 'climate',
});
});
});
❌ Bad Pattern: Testing Implementation Details
describe('SearchHandler', () => {
it('should call internal validateInput method', async () => {
const handler = new SearchHandler();
const spy = vi.spyOn(handler as any, 'validateInput');
await handler.handleSearch(request);
expect(spy).toHaveBeenCalled();
});
});
describe('SearchHandler', () => {
it('should call the mock correctly', async () => {
const mockFn = vi.fn();
mockFn('test');
expect(mockFn).toHaveBeenCalledWith('test');
});
});
❌ Bad Pattern: Flaky Tests with Timing Issues
it('should process request quickly', async () => {
const start = Date.now();
await processRequest();
const duration = Date.now() - start;
expect(duration).toBeLessThan(100);
});
it('should debounce requests', async () => {
handler.handleRequest(request1);
setTimeout(() => {
handler.handleRequest(request2);
}, 50);
});
❌ Bad Pattern: Not Cleaning Up
describe('Cache', () => {
const cache = new Cache();
it('should cache results', () => {
cache.set('key', 'value');
expect(cache.get('key')).toBe('value');
});
it('should return undefined for missing keys', () => {
expect(cache.get('key')).toBeUndefined();
});
});
it('should use mocked API', async () => {
vi.spyOn(api, 'fetch').mockResolvedValue(mockData);
await handler.process();
});
❌ Bad Pattern: Unclear Test Names
it('works', () => { });
it('test 1', () => { });
it('should return true', () => { });
it('should return search results when keywords are valid', () => { });
it('should throw ValidationError when keywords exceed 200 characters', () => { });
it('should cache results for 5 minutes', () => { });
References
Testing Frameworks
Mocking
Coverage
Best Practices
ISMS Policies
Primary:
Supporting:
Remember
- Test behavior, not implementation: Focus on inputs/outputs, not internal logic
- 80% coverage minimum: Maintain high coverage for confidence in changes
- Fast tests: Unit < 100ms, integration < 1s, suite < 5min
- Deterministic: No flakiness, randomness, or race conditions
- AAA pattern: Arrange, Act, Assert - clear structure
- Mock external dependencies: European Parliament API, file system, network
- Test edge cases: Empty, null, boundaries, errors
- Clean up: Reset mocks, clear state, avoid test pollution
- Type-safe mocks: Use TypeScript for mock type safety
- Async properly: Use async/await, handle Promise rejections
- Security controls: Test validation, rate limiting, authentication
- Descriptive names: Test names describe expected behavior clearly
- CI/CD: All tests pass before merge, run on every commit
- Living documentation: Tests serve as examples of correct usage
- Refactoring confidence: Good tests enable safe refactoring