Integration Testing Guide
Integration testing ensures that different parts of your plugin work together correctly. This guide covers testing strategies for API integrations, database interactions, and end-to-end workflows.
Table of Contents
- Integration Test Setup
- API Integration Testing
- Database Integration Testing
- Plugin Runtime Testing
- End-to-End Workflows
- Test Environment Management
Integration Test Setup
Test Environment Configuration
// tests/integration/setup.ts
import { PluginRuntimeContext } from '@qirvo/plugin-sdk';
export interface TestEnvironment {
context: PluginRuntimeContext;
cleanup: () => Promise<void>;
}
export async function createTestEnvironment(): Promise<TestEnvironment> {
// Create test storage
const testStorage = new Map<string, any>();
// Create mock context
const context: PluginRuntimeContext = {
plugin: {
id: 'weather-test',
name: 'Weather Plugin Test',
version: '1.0.0',
permissions: ['network-access', 'storage-read', 'storage-write']
},
config: {
apiKey: process.env.TEST_API_KEY || 'test-api-key',
baseUrl: process.env.TEST_API_URL || 'https://api.test-weather.com'
},
storage: {
get: async (key: string) => testStorage.get(key),
set: async (key: string, value: any) => { testStorage.set(key, value); },
delete: async (key: string) => { testStorage.delete(key); },
keys: async () => Array.from(testStorage.keys()),
clear: async () => { testStorage.clear(); }
},
user: {
id: 'test-user-123',
email: 'test@example.com'
},
api: {
http: {
get: jest.fn(),
post: jest.fn(),
put: jest.fn(),
delete: jest.fn()
}
}
};
const cleanup = async () => {
testStorage.clear();
jest.clearAllMocks();
};
return { context, cleanup };
}
Test Database Setup
// tests/integration/database.ts
import { MongoMemoryServer } from 'mongodb-memory-server';
import { MongoClient, Db } from 'mongodb';
export class TestDatabase {
private mongoServer?: MongoMemoryServer;
private client?: MongoClient;
private db?: Db;
async start(): Promise<void> {
this.mongoServer = await MongoMemoryServer.create();
const uri = this.mongoServer.getUri();
this.client = new MongoClient(uri);
await this.client.connect();
this.db = this.client.db('test-plugin-db');
}
async stop(): Promise<void> {
if (this.client) {
await this.client.close();
}
if (this.mongoServer) {
await this.mongoServer.stop();
}
}
getDb(): Db {
if (!this.db) {
throw new Error('Database not initialized. Call start() first.');
}
return this.db;
}
async seedData(collection: string, data: any[]): Promise<void> {
const db = this.getDb();
await db.collection(collection).insertMany(data);
}
async clearData(collection: string): Promise<void> {
const db = this.getDb();
await db.collection(collection).deleteMany({});
}
}
API Integration Testing
HTTP Client Integration
// src/services/httpClient.ts
export class HttpClient {
constructor(private baseUrl: string, private apiKey: string) {}
async get<T>(endpoint: string, params?: Record<string, string>): Promise<T> {
const url = new URL(endpoint, this.baseUrl);
if (params) {
Object.entries(params).forEach(([key, value]) => {
url.searchParams.append(key, value);
});
}
const response = await fetch(url.toString(), {
headers: {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json'
}
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return await response.json();
}
async post<T>(endpoint: string, data: any): Promise<T> {
const url = new URL(endpoint, this.baseUrl);
const response = await fetch(url.toString(), {
method: 'POST',
headers: {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return await response.json();
}
}
// tests/integration/httpClient.test.ts
import { HttpClient } from '../../src/services/httpClient';
import { setupServer } from 'msw/node';
import { rest } from 'msw';
// Mock server setup
const server = setupServer(
rest.get('https://api.test-weather.com/current', (req, res, ctx) => {
const location = req.url.searchParams.get('location');
const authHeader = req.headers.get('Authorization');
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res(ctx.status(401), ctx.json({ error: 'Unauthorized' }));
}
if (!location) {
return res(ctx.status(400), ctx.json({ error: 'Location required' }));
}
return res(ctx.json({
temperature: 22,
description: 'Sunny',
location: location
}));
}),
rest.post('https://api.test-weather.com/alerts', (req, res, ctx) => {
return res(ctx.json({ id: 'alert-123', status: 'created' }));
})
);
describe('HttpClient Integration', () => {
let client: HttpClient;
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
beforeEach(() => {
client = new HttpClient('https://api.test-weather.com', 'test-api-key');
});
describe('GET requests', () => {
it('should make successful GET request with parameters', async () => {
const result = await client.get('/current', { location: 'London' });
expect(result).toEqual({
temperature: 22,
description: 'Sunny',
location: 'London'
});
});
it('should handle authentication errors', async () => {
const unauthorizedClient = new HttpClient('https://api.test-weather.com', '');
await expect(client.get('/current', { location: 'London' }))
.rejects
.toThrow('HTTP 401: Unauthorized');
});
it('should handle validation errors', async () => {
await expect(client.get('/current'))
.rejects
.toThrow('HTTP 400: Bad Request');
});
});
describe('POST requests', () => {
it('should make successful POST request', async () => {
const alertData = { location: 'London', type: 'storm' };
const result = await client.post('/alerts', alertData);
expect(result).toEqual({
id: 'alert-123',
status: 'created'
});
});
});
});
Rate Limiting Integration
// src/services/rateLimiter.ts
export class RateLimiter {
private requests: number[] = [];
constructor(
private maxRequests: number,
private windowMs: number
) {}
async checkLimit(): Promise<boolean> {
const now = Date.now();
// Remove old requests outside the window
this.requests = this.requests.filter(time => now - time < this.windowMs);
if (this.requests.length >= this.maxRequests) {
return false;
}
this.requests.push(now);
return true;
}
async waitForSlot(): Promise<void> {
while (!(await this.checkLimit())) {
await new Promise(resolve => setTimeout(resolve, 100));
}
}
}
// tests/integration/rateLimiter.test.ts
import { RateLimiter } from '../../src/services/rateLimiter';
describe('RateLimiter Integration', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it('should allow requests within limit', async () => {
const limiter = new RateLimiter(3, 1000); // 3 requests per second
expect(await limiter.checkLimit()).toBe(true);
expect(await limiter.checkLimit()).toBe(true);
expect(await limiter.checkLimit()).toBe(true);
});
it('should block requests exceeding limit', async () => {
const limiter = new RateLimiter(2, 1000); // 2 requests per second
expect(await limiter.checkLimit()).toBe(true);
expect(await limiter.checkLimit()).toBe(true);
expect(await limiter.checkLimit()).toBe(false);
});
it('should reset limit after window expires', async () => {
const limiter = new RateLimiter(2, 1000);
// Use up the limit
await limiter.checkLimit();
await limiter.checkLimit();
expect(await limiter.checkLimit()).toBe(false);
// Advance time past window
jest.advanceTimersByTime(1001);
// Should be able to make requests again
expect(await limiter.checkLimit()).toBe(true);
});
it('should wait for available slot', async () => {
const limiter = new RateLimiter(1, 1000);
// Use up the limit
await limiter.checkLimit();
// Start waiting for slot
const waitPromise = limiter.waitForSlot();
// Advance time to free up slot
jest.advanceTimersByTime(1001);
// Should resolve
await expect(waitPromise).resolves.toBeUndefined();
});
});
Database Integration Testing
Storage Service Integration
// src/services/storageService.ts
export interface StorageService {
get<T>(key: string): Promise<T | null>;
set<T>(key: string, value: T): Promise<void>;
delete(key: string): Promise<void>;
keys(): Promise<string[]>;
clear(): Promise<void>;
}
export class DatabaseStorageService implements StorageService {
constructor(private db: Db) {}
async get<T>(key: string): Promise<T | null> {
const doc = await this.db.collection('storage').findOne({ _id: key });
return doc ? doc.value : null;
}
async set<T>(key: string, value: T): Promise<void> {
await this.db.collection('storage').replaceOne(
{ _id: key },
{ _id: key, value, updatedAt: new Date() },
{ upsert: true }
);
}
async delete(key: string): Promise<void> {
await this.db.collection('storage').deleteOne({ _id: key });
}
async keys(): Promise<string[]> {
const docs = await this.db.collection('storage').find({}, { projection: { _id: 1 } }).toArray();
return docs.map(doc => doc._id);
}
async clear(): Promise<void> {
await this.db.collection('storage').deleteMany({});
}
}
// tests/integration/storageService.test.ts
import { DatabaseStorageService } from '../../src/services/storageService';
import { TestDatabase } from './database';
describe('DatabaseStorageService Integration', () => {
let testDb: TestDatabase;
let storageService: DatabaseStorageService;
beforeAll(async () => {
testDb = new TestDatabase();
await testDb.start();
storageService = new DatabaseStorageService(testDb.getDb());
});
afterAll(async () => {
await testDb.stop();
});
beforeEach(async () => {
await testDb.clearData('storage');
});
describe('basic operations', () => {
it('should store and retrieve data', async () => {
const testData = { name: 'John', age: 30 };
await storageService.set('user:123', testData);
const result = await storageService.get('user:123');
expect(result).toEqual(testData);
});
it('should return null for non-existent keys', async () => {
const result = await storageService.get('nonexistent');
expect(result).toBeNull();
});
it('should delete data', async () => {
await storageService.set('temp', 'value');
await storageService.delete('temp');
const result = await storageService.get('temp');
expect(result).toBeNull();
});
it('should list all keys', async () => {
await storageService.set('key1', 'value1');
await storageService.set('key2', 'value2');
await storageService.set('key3', 'value3');
const keys = await storageService.keys();
expect(keys).toHaveLength(3);
expect(keys).toContain('key1');
expect(keys).toContain('key2');
expect(keys).toContain('key3');
});
it('should clear all data', async () => {
await storageService.set('key1', 'value1');
await storageService.set('key2', 'value2');
await storageService.clear();
const keys = await storageService.keys();
expect(keys).toHaveLength(0);
});
});
describe('data types', () => {
it('should handle different data types', async () => {
const testCases = [
{ key: 'string', value: 'hello world' },
{ key: 'number', value: 42 },
{ key: 'boolean', value: true },
{ key: 'array', value: [1, 2, 3] },
{ key: 'object', value: { nested: { data: 'test' } } },
{ key: 'null', value: null }
];
// Store all test data
for (const testCase of testCases) {
await storageService.set(testCase.key, testCase.value);
}
// Verify all test data
for (const testCase of testCases) {
const result = await storageService.get(testCase.key);
expect(result).toEqual(testCase.value);
}
});
});
describe('concurrent operations', () => {
it('should handle concurrent writes', async () => {
const promises = [];
for (let i = 0; i < 10; i++) {
promises.push(storageService.set(`concurrent:${i}`, `value${i}`));
}
await Promise.all(promises);
// Verify all data was stored
for (let i = 0; i < 10; i++) {
const result = await storageService.get(`concurrent:${i}`);
expect(result).toBe(`value${i}`);
}
});
});
});
Plugin Runtime Testing
Full Plugin Integration
// tests/integration/weatherPlugin.test.ts
import { WeatherPlugin } from '../../src/weatherPlugin';
import { createTestEnvironment, TestEnvironment } from './setup';
import { setupServer } from 'msw/node';
import { rest } from 'msw';
const server = setupServer(
rest.get('https://api.test-weather.com/current', (req, res, ctx) => {
const location = req.url.searchParams.get('location');
return res(ctx.json({
temperature: 25,
description: 'Clear sky',
location: location
}));
})
);
describe('WeatherPlugin Integration', () => {
let testEnv: TestEnvironment;
let plugin: WeatherPlugin;
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
beforeEach(async () => {
testEnv = await createTestEnvironment();
plugin = new WeatherPlugin();
});
afterEach(async () => {
await testEnv.cleanup();
});
describe('plugin lifecycle', () => {
it('should initialize and enable successfully', async () => {
await plugin.onInstall(testEnv.context);
await plugin.onEnable(testEnv.context);
expect(plugin.isEnabled()).toBe(true);
});
it('should handle configuration validation', async () => {
const invalidContext = {
...testEnv.context,
config: { apiKey: '' } // Invalid config
};
await expect(plugin.onEnable(invalidContext))
.rejects
.toThrow('API key is required');
});
it('should clean up on disable', async () => {
await plugin.onEnable(testEnv.context);
await plugin.onDisable();
expect(plugin.isEnabled()).toBe(false);
});
});
describe('weather functionality', () => {
beforeEach(async () => {
await plugin.onInstall(testEnv.context);
await plugin.onEnable(testEnv.context);
});
it('should fetch and cache weather data', async () => {
const weather1 = await plugin.getCurrentWeather('London');
const weather2 = await plugin.getCurrentWeather('London');
expect(weather1).toEqual({
temperature: 25,
description: 'Clear sky',
location: 'London'
});
// Second call should use cache (same result)
expect(weather2).toEqual(weather1);
// Verify cache was used (only one API call)
expect(server.listHandlers()).toHaveLength(1);
});
it('should persist user preferences', async () => {
await plugin.setUserPreference('units', 'fahrenheit');
await plugin.setUserPreference('refreshInterval', 300);
const units = await plugin.getUserPreference('units');
const interval = await plugin.getUserPreference('refreshInterval');
expect(units).toBe('fahrenheit');
expect(interval).toBe(300);
});
it('should handle API errors gracefully', async () => {
server.use(
rest.get('https://api.test-weather.com/current', (req, res, ctx) => {
return res(ctx.status(500), ctx.json({ error: 'Internal server error' }));
})
);
await expect(plugin.getCurrentWeather('London'))
.rejects
.toThrow('Weather API error: 500');
});
});
});
End-to-End Workflows
User Journey Testing
// tests/integration/userJourney.test.ts
import { WeatherPlugin } from '../../src/weatherPlugin';
import { createTestEnvironment, TestEnvironment } from './setup';
describe('Weather Plugin User Journey', () => {
let testEnv: TestEnvironment;
let plugin: WeatherPlugin;
beforeEach(async () => {
testEnv = await createTestEnvironment();
plugin = new WeatherPlugin();
});
afterEach(async () => {
await testEnv.cleanup();
});
it('should complete full user onboarding flow', async () => {
// Step 1: Plugin installation
await plugin.onInstall(testEnv.context);
expect(plugin.getState()).toBe('installed');
// Step 2: Initial configuration
const configResult = await plugin.validateConfiguration({
apiKey: 'user-api-key',
defaultLocation: 'New York',
units: 'imperial'
});
expect(configResult.valid).toBe(true);
// Step 3: Plugin enablement
await plugin.onEnable({
...testEnv.context,
config: {
apiKey: 'user-api-key',
defaultLocation: 'New York',
units: 'imperial'
}
});
expect(plugin.isEnabled()).toBe(true);
// Step 4: First weather request
const weather = await plugin.getCurrentWeather('New York');
expect(weather.location).toBe('New York');
// Step 5: Preference customization
await plugin.setUserPreference('theme', 'dark');
await plugin.setUserPreference('notifications', true);
// Step 6: Verify preferences persist
const theme = await plugin.getUserPreference('theme');
const notifications = await plugin.getUserPreference('notifications');
expect(theme).toBe('dark');
expect(notifications).toBe(true);
});
it('should handle plugin update workflow', async () => {
// Initial installation
await plugin.onInstall(testEnv.context);
await plugin.onEnable(testEnv.context);
// Store some user data
await plugin.setUserPreference('favoriteLocation', 'Paris');
// Simulate plugin update
await plugin.onUpdate(testEnv.context, '1.0.0', '1.1.0');
// Verify data migration
const favoriteLocation = await plugin.getUserPreference('favoriteLocation');
expect(favoriteLocation).toBe('Paris');
// Verify new features work
expect(plugin.getVersion()).toBe('1.1.0');
});
it('should handle error recovery workflow', async () => {
await plugin.onInstall(testEnv.context);
await plugin.onEnable(testEnv.context);
// Simulate network error
const originalFetch = global.fetch;
global.fetch = jest.fn().mockRejectedValue(new Error('Network error'));
// First request should fail
await expect(plugin.getCurrentWeather('London'))
.rejects
.toThrow('Network error');
// Restore network
global.fetch = originalFetch;
// Subsequent request should work
const weather = await plugin.getCurrentWeather('London');
expect(weather).toBeDefined();
});
});
Test Environment Management
Environment Configuration
// tests/integration/config.ts
export interface TestConfig {
apiUrl: string;
apiKey: string;
database: {
url: string;
name: string;
};
timeout: number;
}
export function getTestConfig(): TestConfig {
return {
apiUrl: process.env.TEST_API_URL || 'https://api.test-weather.com',
apiKey: process.env.TEST_API_KEY || 'test-api-key',
database: {
url: process.env.TEST_DB_URL || 'mongodb://localhost:27017',
name: process.env.TEST_DB_NAME || 'test-plugin-db'
},
timeout: parseInt(process.env.TEST_TIMEOUT || '30000')
};
}
Test Data Management
// tests/integration/fixtures.ts
export const weatherFixtures = {
london: {
temperature: 18,
description: 'Partly cloudy',
location: 'London',
humidity: 65,
windSpeed: 12
},
paris: {
temperature: 22,
description: 'Sunny',
location: 'Paris',
humidity: 45,
windSpeed: 8
},
invalidLocation: {
error: 'Location not found',
code: 'LOCATION_NOT_FOUND'
}
};
export const userFixtures = {
testUser: {
id: 'test-user-123',
email: 'test@example.com',
preferences: {
units: 'metric',
theme: 'light',
notifications: true
}
}
};
Cleanup and Teardown
// tests/integration/cleanup.ts
export class TestCleanup {
private cleanupTasks: (() => Promise<void>)[] = [];
addTask(task: () => Promise<void>): void {
this.cleanupTasks.push(task);
}
async runAll(): Promise<void> {
const errors: Error[] = [];
for (const task of this.cleanupTasks) {
try {
await task();
} catch (error) {
errors.push(error instanceof Error ? error : new Error(String(error)));
}
}
this.cleanupTasks = [];
if (errors.length > 0) {
throw new Error(`Cleanup failed: ${errors.map(e => e.message).join(', ')}`);
}
}
}
// Global cleanup for all integration tests
const globalCleanup = new TestCleanup();
afterAll(async () => {
await globalCleanup.runAll();
});
export { globalCleanup };
This comprehensive integration testing guide ensures your Qirvo plugins work correctly across all system boundaries and real-world scenarios.
Next: E2E Testing