testing - saltict/Demo-Docs GitHub Wiki

Testing

This document covers the comprehensive testing strategy, frameworks, and best practices for the SubWallet Services SDK.

Quick Navigation

Overview

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.

Testing Strategy

Testing Pyramid

%%{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
Loading

Test Types & Distribution

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

Test Categories

Functional Testing

  • Unit Tests: Individual service methods
  • Integration Tests: Cross-service communication
  • API Tests: External service integration

Non-Functional Testing

  • Performance Tests: Response time, throughput
  • Load Tests: Concurrent request handling
  • Security Tests: Input validation, error handling

Test Configuration

Jest Configuration

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'
  }
};

Test Setup

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);
    }
  }
};

Unit Testing

Service Testing Example

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);
    });
  });
});

Utility Testing

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 });
    });
  });
});

Integration Testing

Service Integration Tests

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');
      }
    });
  });
});

API Integration Tests

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();
      });
    });
  });
});

End-to-End Testing

E2E Test Configuration

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
};

E2E Test Example

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));
        }
      }
    });
  });
});

Coverage & Reporting

Coverage Configuration

// 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
    }
  }
};

Test Commands

# 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

Coverage Reporting

// 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`);
}

Test Quality Metrics

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:


Navigation: โ† Build Process | Development Overview | Workflow โ†’

โš ๏ธ **GitHub.com Fallback** โš ๏ธ