testing - saltict/Demo-Docs GitHub Wiki
This document covers the comprehensive testing strategy, frameworks, and best practices for the SubWallet Services SDK.
- ๐ Overview
- ๐งช Testing Strategy
- โ๏ธ Test Configuration
- ๐ฌ Unit Testing
- ๐ Integration Testing
- ๐ฏ End-to-End Testing
- ๐ Coverage & Reporting
The SubWallet Services SDK employs a multi-layered testing approach ensuring reliability, maintainability, and quality across all components. Our testing strategy covers unit tests, integration tests, and end-to-end tests with comprehensive coverage reporting.
%%{init: {'theme':'dark'}}%%
graph TB
E2E[End-to-End Tests<br/>~10 tests<br/>High confidence, Slow]
Integration[Integration Tests<br/>~50 tests<br/>Medium confidence, Medium speed]
Unit[Unit Tests<br/>~200+ tests<br/>Fast, Low confidence per test]
E2E --> Integration
Integration --> Unit
style E2E fill:#ef4444,stroke:#dc2626,color:#fff
style Integration fill:#3b82f6,stroke:#2563eb,color:#fff
style Unit fill:#10b981,stroke:#059669,color:#fff
Test Type | Percentage | Focus | Speed | Confidence |
---|---|---|---|---|
Unit Tests | 70% | Individual functions/classes | Fast | Per component |
Integration Tests | 20% | Service interactions | Medium | Feature level |
E2E Tests | 10% | Complete workflows | Slow | System level |
- Unit Tests: Individual service methods
- Integration Tests: Cross-service communication
- API Tests: External service integration
- Performance Tests: Response time, throughput
- Load Tests: Concurrent request handling
- Security Tests: Input validation, error handling
File: libs/subwallet-services-sdk/jest.config.ts
import { readFileSync } from 'fs';
// Reading the SWC compilation config and remove the "exclude"
// for the test files to be compiled by SWC
const { exclude: _, ...swcJestConfig } = JSON.parse(
readFileSync(`${__dirname}/../../.swcrc`, 'utf-8')
);
export default {
displayName: 'subwallet-services-sdk',
preset: '../../jest.preset.cjs',
transform: {
'^.+\\.[tj]s$': ['@swc/jest', swcJestConfig],
},
moduleFileExtensions: ['ts', 'js', 'html'],
coverageDirectory: '../../coverage/libs/subwallet-services-sdk',
collectCoverageFrom: [
'src/**/*.ts',
'!src/**/*.spec.ts',
'!src/**/*.test.ts',
'!src/**/index.ts',
'!src/**/*.d.ts'
],
coverageReporters: ['text', 'lcov', 'html', 'cobertura'],
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80
}
},
testEnvironment: 'node',
setupFilesAfterEnv: ['<rootDir>/src/test/setup.ts'],
testMatch: [
'<rootDir>/src/**/__tests__/**/*.(js|ts)',
'<rootDir>/src/**/*.(test|spec).(js|ts)'
],
moduleNameMapping: {
'^@subwallet-services-sdk/(.*)$': '<rootDir>/src/$1'
}
};
File: libs/subwallet-services-sdk/src/test/setup.ts
import { jest } from '@jest/globals';
// Global test setup
beforeAll(() => {
// Set test environment variables
process.env.NODE_ENV = 'test';
process.env.API_BASE_URL = 'http://localhost:3000';
// Configure global mocks
setupGlobalMocks();
});
afterAll(() => {
// Cleanup after all tests
jest.clearAllMocks();
});
function setupGlobalMocks(): void {
// Mock console methods in test environment
global.console = {
...console,
log: jest.fn(),
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn()
};
// Mock fetch for HTTP requests
global.fetch = jest.fn();
// Mock timers
jest.useFakeTimers();
}
// Test utilities
export const testUtils = {
async flushPromises(): Promise<void> {
return new Promise(resolve => setImmediate(resolve));
},
createMockResponse<T>(data: T, status = 200): Response {
return new Response(JSON.stringify(data), {
status,
headers: { 'Content-Type': 'application/json' }
});
},
expectToThrowAsync: async (fn: () => Promise<any>, expectedError?: string) => {
let error: Error | undefined;
try {
await fn();
} catch (e) {
error = e as Error;
}
expect(error).toBeDefined();
if (expectedError) {
expect(error?.message).toContain(expectedError);
}
}
};
File: libs/subwallet-services-sdk/src/services/__tests__/price-history.test.ts
import { PriceHistoryService } from '../price-history';
import { HttpClient } from '../../core/http-client';
import { testUtils } from '../../test/setup';
// Mock dependencies
jest.mock('../../core/http-client');
const mockHttpClient = HttpClient as jest.MockedClass<typeof HttpClient>;
describe('PriceHistoryService', () => {
let service: PriceHistoryService;
let httpClientInstance: jest.Mocked<HttpClient>;
beforeEach(() => {
// Create fresh mocks for each test
httpClientInstance = {
get: jest.fn(),
post: jest.fn(),
put: jest.fn(),
delete: jest.fn()
} as any;
mockHttpClient.mockImplementation(() => httpClientInstance);
service = new PriceHistoryService();
});
afterEach(() => {
jest.clearAllMocks();
});
describe('getPriceHistory', () => {
it('should fetch price history successfully', async () => {
// Arrange
const symbol = 'DOT';
const mockResponse = {
data: [
{ timestamp: '2024-01-01', price: 10.5 },
{ timestamp: '2024-01-02', price: 11.2 }
],
success: true
};
httpClientInstance.get.mockResolvedValue(
testUtils.createMockResponse(mockResponse)
);
// Act
const result = await service.getPriceHistory(symbol);
// Assert
expect(httpClientInstance.get).toHaveBeenCalledWith(
`/price-history/${symbol}`,
expect.any(Object)
);
expect(result).toEqual(mockResponse.data);
});
it('should handle API errors gracefully', async () => {
// Arrange
const symbol = 'INVALID';
httpClientInstance.get.mockRejectedValue(
new Error('Token not found')
);
// Act & Assert
await testUtils.expectToThrowAsync(
() => service.getPriceHistory(symbol),
'Token not found'
);
});
it('should validate input parameters', async () => {
// Act & Assert
await testUtils.expectToThrowAsync(
() => service.getPriceHistory(''),
'Symbol is required'
);
await testUtils.expectToThrowAsync(
() => service.getPriceHistory(null as any),
'Symbol is required'
);
});
it('should use correct timeout settings', async () => {
// Arrange
const symbol = 'DOT';
httpClientInstance.get.mockResolvedValue(
testUtils.createMockResponse({ data: [], success: true })
);
// Act
await service.getPriceHistory(symbol);
// Assert
expect(httpClientInstance.get).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
timeout: 30000
})
);
});
});
describe('getPriceRange', () => {
it('should fetch price range with date filters', async () => {
// Arrange
const symbol = 'DOT';
const from = '2024-01-01';
const to = '2024-01-31';
const mockResponse = {
data: {
min: 8.5,
max: 12.3,
average: 10.4
},
success: true
};
httpClientInstance.get.mockResolvedValue(
testUtils.createMockResponse(mockResponse)
);
// Act
const result = await service.getPriceRange(symbol, from, to);
// Assert
expect(httpClientInstance.get).toHaveBeenCalledWith(
`/price-history/${symbol}/range`,
expect.objectContaining({
params: { from, to }
})
);
expect(result).toEqual(mockResponse.data);
});
});
});
File: libs/subwallet-services-sdk/src/core/__tests__/http-client.test.ts
import { HttpClient } from '../http-client';
import { testUtils } from '../../test/setup';
describe('HttpClient', () => {
let httpClient: HttpClient;
beforeEach(() => {
httpClient = new HttpClient('https://api.example.com');
});
describe('constructor', () => {
it('should initialize with base URL', () => {
expect(httpClient.baseURL).toBe('https://api.example.com');
});
it('should apply default configuration', () => {
expect(httpClient.timeout).toBe(30000);
expect(httpClient.retryAttempts).toBe(3);
});
});
describe('request handling', () => {
it('should make GET requests with proper headers', async () => {
// Arrange
const mockFetch = global.fetch as jest.MockedFunction<typeof fetch>;
mockFetch.mockResolvedValue(
testUtils.createMockResponse({ success: true })
);
// Act
await httpClient.get('/test-endpoint');
// Assert
expect(mockFetch).toHaveBeenCalledWith(
'https://api.example.com/test-endpoint',
expect.objectContaining({
method: 'GET',
headers: expect.objectContaining({
'Content-Type': 'application/json'
})
})
);
});
it('should handle request timeouts', async () => {
// Arrange
const mockFetch = global.fetch as jest.MockedFunction<typeof fetch>;
mockFetch.mockImplementation(
() => new Promise(resolve => setTimeout(resolve, 35000))
);
// Act & Assert
await testUtils.expectToThrowAsync(
() => httpClient.get('/slow-endpoint'),
'timeout'
);
});
it('should retry failed requests', async () => {
// Arrange
const mockFetch = global.fetch as jest.MockedFunction<typeof fetch>;
mockFetch
.mockRejectedValueOnce(new Error('Network error'))
.mockRejectedValueOnce(new Error('Network error'))
.mockResolvedValueOnce(testUtils.createMockResponse({ success: true }));
// Act
const result = await httpClient.get('/retry-endpoint');
// Assert
expect(mockFetch).toHaveBeenCalledTimes(3);
expect(result).toEqual({ success: true });
});
});
});
File: libs/subwallet-services-sdk/src/__tests__/integration/services.integration.test.ts
import { SubWalletServicesSDK } from '../../lib/subwallet-services-sdk';
describe('Services Integration', () => {
let sdk: SubWalletServicesSDK;
beforeEach(() => {
sdk = new SubWalletServicesSDK({
apiBaseUrl: 'http://localhost:3000',
apiKey: 'test-api-key'
});
});
describe('cross-service interactions', () => {
it('should coordinate between price and balance services', async () => {
// This test verifies that services work together correctly
const address = '1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa';
const symbol = 'DOT';
// Get balance
const balance = await sdk.balanceDetection.getBalance(address);
expect(balance).toBeDefined();
expect(balance.amount).toBeGreaterThanOrEqual(0);
// Get price for the same token
const priceHistory = await sdk.priceHistory.getPriceHistory(symbol);
expect(priceHistory).toBeDefined();
expect(Array.isArray(priceHistory)).toBe(true);
// Calculate USD value (integration between services)
const latestPrice = priceHistory[priceHistory.length - 1];
const usdValue = balance.amount * latestPrice.price;
expect(usdValue).toBeGreaterThanOrEqual(0);
});
it('should handle XCM and balance detection together', async () => {
const sourceChain = 'polkadot';
const destChain = 'moonbeam';
const address = '1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa';
// Check XCM transfer options
const xcmOptions = await sdk.xcm.getTransferOptions(sourceChain, destChain);
expect(xcmOptions).toBeDefined();
// Verify balance on source chain
const sourceBalance = await sdk.balanceDetection.getBalance(address);
expect(sourceBalance).toBeDefined();
// Simulate XCM transfer validation
const canTransfer = sourceBalance.amount > 0 && xcmOptions.length > 0;
expect(typeof canTransfer).toBe('boolean');
});
});
describe('error propagation', () => {
it('should handle cascading service failures', async () => {
// Mock a scenario where one service failure affects others
const invalidAddress = 'invalid-address';
try {
await sdk.balanceDetection.getBalance(invalidAddress);
} catch (error) {
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toContain('invalid');
}
});
});
});
File: libs/subwallet-services-sdk/src/__tests__/integration/api.integration.test.ts
import { HttpClient } from '../../core/http-client';
describe('API Integration', () => {
let httpClient: HttpClient;
beforeAll(() => {
// Use real API endpoints for integration testing
httpClient = new HttpClient(process.env.API_BASE_URL || 'http://localhost:3000');
});
describe('external API communication', () => {
it('should connect to price history API', async () => {
const response = await httpClient.get('/health');
expect(response.status).toBe('ok');
});
it('should handle API rate limiting', async () => {
// Make multiple rapid requests to test rate limiting
const requests = Array.from({ length: 10 }, (_, i) =>
httpClient.get(`/price-history/DOT?page=${i}`)
);
const results = await Promise.allSettled(requests);
// Should handle rate limiting gracefully
const successful = results.filter(r => r.status === 'fulfilled');
const rateLimited = results.filter(r =>
r.status === 'rejected' &&
(r.reason as Error).message.includes('rate limit')
);
expect(successful.length + rateLimited.length).toBe(10);
});
it('should maintain session consistency', async () => {
// Test that multiple requests maintain proper session/auth state
const requests = [
httpClient.get('/user/profile'),
httpClient.get('/user/balances'),
httpClient.get('/user/transactions')
];
const results = await Promise.all(requests);
results.forEach(result => {
expect(result).toBeDefined();
});
});
});
});
File: apps/subwallet-services-sdk-e2e/jest.config.ts
export default {
displayName: 'subwallet-services-sdk-e2e',
preset: '../../jest.preset.cjs',
globalSetup: '<rootDir>/src/support/global-setup.ts',
globalTeardown: '<rootDir>/src/support/global-teardown.ts',
setupFilesAfterEnv: ['<rootDir>/src/support/test-setup.ts'],
testEnvironment: 'node',
testMatch: ['<rootDir>/src/**/*.e2e.ts'],
timeout: 120000 // 2 minutes for E2E tests
};
File: apps/subwallet-services-sdk-e2e/src/subwallet-services-sdk.e2e.ts
import { SubWalletServicesSDK } from '@subwallet-monorepos/subwallet-services-sdk';
describe('SubWallet Services SDK E2E', () => {
let sdk: SubWalletServicesSDK;
beforeAll(async () => {
sdk = new SubWalletServicesSDK({
apiBaseUrl: process.env.E2E_API_URL || 'https://staging-api.subwallet.io',
apiKey: process.env.E2E_API_KEY
});
// Wait for services to be ready
await sdk.waitForReady();
});
describe('complete user workflows', () => {
it('should complete full balance check workflow', async () => {
const testAddress = '1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa';
// Step 1: Check if address is valid
const isValid = await sdk.balanceDetection.validateAddress(testAddress);
expect(isValid).toBe(true);
// Step 2: Get balance
const balance = await sdk.balanceDetection.getBalance(testAddress);
expect(balance).toBeDefined();
expect(typeof balance.amount).toBe('number');
// Step 3: Get balance history
const history = await sdk.balanceDetection.getBalanceHistory(testAddress);
expect(Array.isArray(history)).toBe(true);
// Step 4: Calculate USD value
const price = await sdk.priceHistory.getCurrentPrice('DOT');
const usdValue = balance.amount * price;
expect(usdValue).toBeGreaterThanOrEqual(0);
});
it('should complete XCM transfer workflow', async () => {
const sourceChain = 'polkadot';
const destChain = 'moonbeam';
const amount = 1;
// Step 1: Get transfer options
const options = await sdk.xcm.getTransferOptions(sourceChain, destChain);
expect(options.length).toBeGreaterThan(0);
// Step 2: Calculate fees
const fees = await sdk.xcm.calculateFees(sourceChain, destChain, amount);
expect(fees.totalFee).toBeGreaterThan(0);
// Step 3: Validate transfer
const isValid = await sdk.xcm.validateTransfer({
sourceChain,
destChain,
amount,
fees
});
expect(typeof isValid).toBe('boolean');
});
it('should handle complex multi-service scenarios', async () => {
// Scenario: User wants to swap tokens and check their new balance
const fromToken = 'DOT';
const toToken = 'USDT';
const amount = 10;
const userAddress = '1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa';
// Step 1: Check current balance
const initialBalance = await sdk.balanceDetection.getBalance(userAddress);
// Step 2: Get swap quote
const quote = await sdk.swap.getQuote(fromToken, toToken, amount);
expect(quote.outputAmount).toBeGreaterThan(0);
// Step 3: Check if user has enough balance
const hasEnoughBalance = initialBalance.amount >= amount;
// Step 4: Get current prices for reference
const prices = await Promise.all([
sdk.priceHistory.getCurrentPrice(fromToken),
sdk.priceHistory.getCurrentPrice(toToken)
]);
// Verify the quote makes sense given current prices
const expectedRatio = prices[0] / prices[1];
const actualRatio = amount / quote.outputAmount;
const ratioTolerance = 0.05; // 5% tolerance for slippage
expect(Math.abs(expectedRatio - actualRatio) / expectedRatio)
.toBeLessThan(ratioTolerance);
});
});
describe('error handling and recovery', () => {
it('should handle network interruptions gracefully', async () => {
// Simulate network issues and verify recovery
let attempts = 0;
const maxAttempts = 3;
while (attempts < maxAttempts) {
try {
const result = await sdk.priceHistory.getPriceHistory('DOT');
expect(result).toBeDefined();
break;
} catch (error) {
attempts++;
if (attempts === maxAttempts) {
throw error;
}
// Wait before retry
await new Promise(resolve => setTimeout(resolve, 1000));
}
}
});
});
});
// File: jest.config.ts (coverage settings)
export default {
collectCoverage: true,
collectCoverageFrom: [
'src/**/*.ts',
'!src/**/*.spec.ts',
'!src/**/*.test.ts',
'!src/**/index.ts',
'!src/**/*.d.ts',
'!src/test/**/*'
],
coverageReporters: ['text', 'lcov', 'html', 'cobertura', 'json'],
coverageDirectory: 'coverage',
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80
},
'./src/services/': {
branches: 85,
functions: 85,
lines: 85,
statements: 85
}
}
};
# Run all tests
nx test subwallet-services-sdk
# Run tests with coverage
nx test subwallet-services-sdk --coverage
# Run tests in watch mode
nx test subwallet-services-sdk --watch
# Run specific test files
nx test subwallet-services-sdk --testPathPattern="price-history"
# Run integration tests only
nx test subwallet-services-sdk --testPathPattern="integration"
# Run E2E tests
nx e2e subwallet-services-sdk-e2e
# Generate coverage report
nx test subwallet-services-sdk --coverage --coverageReporters=html
// File: scripts/coverage-report.ts
export async function generateCoverageReport(): Promise<void> {
const fs = await import('fs/promises');
const path = await import('path');
const coveragePath = 'coverage/coverage-summary.json';
const coverage = JSON.parse(await fs.readFile(coveragePath, 'utf-8'));
const report = {
timestamp: new Date().toISOString(),
overall: coverage.total,
services: Object.keys(coverage)
.filter(key => key.startsWith('src/services/'))
.reduce((acc, key) => {
acc[key] = coverage[key];
return acc;
}, {} as any)
};
console.log('๐ Coverage Report');
console.log(`Overall: ${report.overall.lines.pct}% lines covered`);
console.log(`Functions: ${report.overall.functions.pct}% covered`);
console.log(`Branches: ${report.overall.branches.pct}% covered`);
}
Metric | Target | Current | Status |
---|---|---|---|
Line Coverage | โฅ 80% | 85% | โ |
Function Coverage | โฅ 80% | 88% | โ |
Branch Coverage | โฅ 80% | 82% | โ |
Test Execution Time | < 60s | 45s | โ |
Test Success Rate | 100% | 100% | โ |
Next Steps:
- Set up Development Workflow
- Configure CI/CD Pipeline
- Review Build Process
Navigation: โ Build Process | Development Overview | Workflow โ