跳转至内容

测试网关连接器

概述

综合测试对于网关连接器至关重要,以确保可靠性、安全性和兼容性。本指南涵盖了网关连接器开发的测试策略、工具和最佳实践。

测试要求

所有网关连接器必须满足以下最低要求:

  • 代码覆盖率:≥75%整体覆盖率
  • 单元测试:所有公共方法都经过测试
  • 集成测试:所有 API 端点都经过测试
  • 错误案例:所有失败场景都已覆盖
  • 性能:负载下的响应时间已测试

测试结构

目录组织

test/
├── connectors/
│   └── mydex/
│       ├── mydex.test.ts           # Unit tests
│       ├── mydex.integration.test.ts # Integration tests
│       └── fixtures/                # Test data
│           ├── tokens.json
│           ├── pools.json
│           └── transactions.json
├── mocks/
│   └── mydex/
│       ├── sdk.mock.ts            # SDK mocks
│       └── responses.mock.ts      # API response mocks
└── utils/
    ├── test-helpers.ts            # Shared utilities
    └── test-constants.ts          # Common test data

单元测试

基本测试结构

import { MyDex } from '../../../src/connectors/mydex/mydex';
import { MockSDK } from '../../mocks/mydex/sdk.mock';
import { fixtures } from './fixtures';

describe('MyDex Connector', () => {
  let connector: MyDex;
  let mockSDK: MockSDK;

  beforeEach(() => {
    mockSDK = new MockSDK();
    connector = new MyDex('ethereum', 'mainnet', mockSDK);
  });

  afterEach(() => {
    jest.clearAllMocks();
  });

  describe('initialization', () => {
    it('should initialize with correct chain and network', () => {
      expect(connector.chain).toBe('ethereum');
      expect(connector.network).toBe('mainnet');
    });

    it('should load configuration correctly', () => {
      expect(connector.config).toBeDefined();
      expect(connector.config.allowedSlippage).toBe(1.0);
    });
  });
});

测试兑换操作

describe('swap operations', () => {
  describe('quote', () => {
    it('should return valid quote for token swap', async () => {
      const quote = await connector.quote({
        base: fixtures.tokens.USDC,
        quote: fixtures.tokens.WETH,
        amount: '1000000', // 1 USDC
        side: 'SELL'
      });

      expect(quote).toMatchObject({
        expectedOut: expect.any(String),
        price: expect.any(String),
        priceImpact: expect.any(Number),
        route: expect.any(Array)
      });

      expect(Number(quote.expectedOut)).toBeGreaterThan(0);
      expect(quote.priceImpact).toBeLessThan(0.1);
    });

    it('should handle buy side quotes', async () => {
      const quote = await connector.quote({
        base: fixtures.tokens.USDC,
        quote: fixtures.tokens.WETH,
        amount: '1000000000000000', // 0.001 WETH
        side: 'BUY'
      });

      expect(quote.expectedOut).toBeDefined();
    });

    it('should throw on insufficient liquidity', async () => {
      mockSDK.setLiquidity(0);

      await expect(connector.quote({
        base: fixtures.tokens.USDC,
        quote: fixtures.tokens.WETH,
        amount: '999999999999999999',
        side: 'SELL'
      })).rejects.toThrow('Insufficient liquidity');
    });
  });

  describe('trade', () => {
    it('should execute swap successfully', async () => {
      const tx = await connector.trade({
        wallet: fixtures.wallet,
        base: fixtures.tokens.USDC,
        quote: fixtures.tokens.WETH,
        amount: '1000000',
        side: 'SELL',
        slippage: 0.01
      });

      expect(tx).toMatchObject({
        hash: expect.stringMatching(/^0x/),
        gasUsed: expect.any(String),
        status: 'success'
      });
    });

    it('should respect slippage settings', async () => {
      const spy = jest.spyOn(mockSDK, 'swap');

      await connector.trade({
        wallet: fixtures.wallet,
        base: fixtures.tokens.USDC,
        quote: fixtures.tokens.WETH,
        amount: '1000000',
        side: 'SELL',
        slippage: 0.005
      });

      expect(spy).toHaveBeenCalledWith(
        expect.objectContaining({
          minAmountOut: expect.any(String)
        })
      );
    });
  });
});

测试流动性操作

describe('liquidity operations', () => {
  describe('addLiquidity', () => {
    it('should add liquidity to pool', async () => {
      const tx = await connector.addLiquidity({
        wallet: fixtures.wallet,
        pool: fixtures.pools.USDC_WETH,
        baseAmount: '1000000',
        quoteAmount: '1000000000000000',
        slippage: 0.01
      });

      expect(tx.hash).toBeDefined();
      expect(tx.lpTokensReceived).toBeGreaterThan(0);
    });

    it('should calculate correct token ratios', async () => {
      const result = await connector.quoteLiquidity({
        pool: fixtures.pools.USDC_WETH,
        baseAmount: '1000000'
      });

      expect(result.quoteAmount).toBeDefined();
      expect(result.shareOfPool).toBeGreaterThan(0);
      expect(result.shareOfPool).toBeLessThan(1);
    });
  });

  describe('removeLiquidity', () => {
    it('should remove liquidity from pool', async () => {
      const tx = await connector.removeLiquidity({
        wallet: fixtures.wallet,
        pool: fixtures.pools.USDC_WETH,
        liquidity: '1000000000000000000',
        slippage: 0.01
      });

      expect(tx.baseAmountReceived).toBeGreaterThan(0);
      expect(tx.quoteAmountReceived).toBeGreaterThan(0);
    });
  });
});

集成测试

API 端点测试

import request from 'supertest';
import { app } from '../../../src/app';

describe('MyDex API Endpoints', () => {
  describe('POST /connectors/mydex/router/quote-swap', () => {
    it('should return swap quote', async () => {
      const response = await request(app)
        .post('/connectors/mydex/router/quote-swap')
        .send({
          chain: 'ethereum',
          network: 'mainnet',
          connector: 'mydex',
          base: 'USDC',
          quote: 'WETH',
          amount: '1000000',
          side: 'SELL'
        });

      expect(response.status).toBe(200);
      expect(response.body).toMatchObject({
        network: 'mainnet',
        base: 'USDC',
        quote: 'WETH',
        expectedOut: expect.any(String),
        price: expect.any(String)
      });
    });

    it('should validate request parameters', async () => {
      const response = await request(app)
        .post('/connectors/mydex/router/quote-swap')
        .send({
          chain: 'ethereum',
          network: 'mainnet'
          // Missing required fields
        });

      expect(response.status).toBe(400);
      expect(response.body.error).toContain('validation');
    });
  });

  describe('POST /connectors/mydex/router/execute-swap', () => {
    it('should execute swap transaction', async () => {
      const response = await request(app)
        .post('/connectors/mydex/router/execute-swap')
        .send({
          chain: 'ethereum',
          network: 'mainnet',
          connector: 'mydex',
          address: '0x...',
          base: 'USDC',
          quote: 'WETH',
          amount: '1000000',
          side: 'SELL',
          slippage: 0.01
        });

      expect(response.status).toBe(200);
      expect(response.body.txHash).toMatch(/^0x/);
    });
  });
});

WebSocket 测试

describe('WebSocket connections', () => {
  let ws: WebSocket;

  beforeEach((done) => {
    ws = new WebSocket('ws://localhost:15888/ws');
    ws.on('open', done);
  });

  afterEach(() => {
    ws.close();
  });

  it('should stream price updates', (done) => {
    ws.send(JSON.stringify({
      type: 'subscribe',
      channel: 'prices',
      params: {
        connector: 'mydex',
        pairs: ['USDC-WETH']
      }
    }));

    ws.on('message', (data) => {
      const message = JSON.parse(data);
      expect(message.type).toBe('price_update');
      expect(message.data.pair).toBe('USDC-WETH');
      expect(message.data.price).toBeGreaterThan(0);
      done();
    });
  });
});

模拟创建

SDK 模拟

export class MockSDK {
  private liquidity = 1000000;
  private priceImpact = 0.01;

  setLiquidity(amount: number): void {
    this.liquidity = amount;
  }

  setPriceImpact(impact: number): void {
    this.priceImpact = impact;
  }

  async getQuote(params: QuoteParams): Promise<Quote> {
    if (this.liquidity === 0) {
      throw new Error('Insufficient liquidity');
    }

    return {
      amountOut: this.calculateOutput(params.amountIn),
      priceImpact: this.priceImpact,
      route: [{ pool: '0x...', percentage: 100 }]
    };
  }

  async executeSwap(params: SwapParams): Promise<Transaction> {
    return {
      hash: `0x${Math.random().toString(16).slice(2)}`,
      gasUsed: BigNumber.from('100000'),
      status: 'success',
      blockNumber: 12345678
    };
  }
}

响应模拟

export const mockResponses = {
  tokens: {
    USDC: {
      address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
      decimals: 6,
      symbol: 'USDC',
      name: 'USD Coin'
    },
    WETH: {
      address: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2',
      decimals: 18,
      symbol: 'WETH',
      name: 'Wrapped Ether'
    }
  },

  pools: {
    USDC_WETH: {
      address: '0x8ad599c3A0ff1De082011EFDDc58f1908eb6e6D8',
      token0: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
      token1: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2',
      fee: 3000,
      liquidity: '1000000000000000000',
      sqrtPriceX96: '1234567890123456789012345678901234'
    }
  }
};

性能测试

负载测试

describe('Performance', () => {
  it('should handle concurrent requests', async () => {
    const requests = Array(100).fill(null).map(() =>
      connector.quote({
        base: fixtures.tokens.USDC,
        quote: fixtures.tokens.WETH,
        amount: '1000000',
        side: 'SELL'
      })
    );

    const start = Date.now();
    const results = await Promise.all(requests);
    const duration = Date.now() - start;

    expect(results).toHaveLength(100);
    expect(duration).toBeLessThan(5000); // 5 seconds for 100 requests
  });

  it('should cache appropriately', async () => {
    const spy = jest.spyOn(mockSDK, 'getPool');

    // First call
    await connector.getPoolInfo('USDC-WETH');
    expect(spy).toHaveBeenCalledTimes(1);

    // Second call should use cache
    await connector.getPoolInfo('USDC-WETH');
    expect(spy).toHaveBeenCalledTimes(1);

    // After cache expiry
    jest.advanceTimersByTime(31000);
    await connector.getPoolInfo('USDC-WETH');
    expect(spy).toHaveBeenCalledTimes(2);
  });
});

内存测试

describe('Memory management', () => {
  it('should not leak memory on repeated operations', async () => {
    const initialMemory = process.memoryUsage().heapUsed;

    for (let i = 0; i < 1000; i++) {
      await connector.quote({
        base: fixtures.tokens.USDC,
        quote: fixtures.tokens.WETH,
        amount: '1000000',
        side: 'SELL'
      });
    }

    global.gc(); // Force garbage collection
    const finalMemory = process.memoryUsage().heapUsed;
    const memoryIncrease = finalMemory - initialMemory;

    expect(memoryIncrease).toBeLessThan(10 * 1024 * 1024); // Less than 10MB
  });
});

错误测试

错误场景

describe('Error handling', () => {
  it('should handle network errors gracefully', async () => {
    mockSDK.simulateNetworkError();

    await expect(connector.quote({
      base: fixtures.tokens.USDC,
      quote: fixtures.tokens.WETH,
      amount: '1000000',
      side: 'SELL'
    })).rejects.toThrow('Network error');
  });

  it('should handle invalid token addresses', async () => {
    await expect(connector.quote({
      base: { ...fixtures.tokens.USDC, address: 'invalid' },
      quote: fixtures.tokens.WETH,
      amount: '1000000',
      side: 'SELL'
    })).rejects.toThrow('Invalid token address');
  });

  it('should handle transaction failures', async () => {
    mockSDK.simulateTransactionFailure();

    await expect(connector.trade({
      wallet: fixtures.wallet,
      base: fixtures.tokens.USDC,
      quote: fixtures.tokens.WETH,
      amount: '1000000',
      side: 'SELL',
      slippage: 0.01
    })).rejects.toThrow('Transaction failed');
  });

  it('should handle slippage exceeded', async () => {
    mockSDK.setPriceImpact(0.1);

    await expect(connector.trade({
      wallet: fixtures.wallet,
      base: fixtures.tokens.USDC,
      quote: fixtures.tokens.WETH,
      amount: '1000000',
      side: 'SELL',
      slippage: 0.01
    })).rejects.toThrow('Slippage exceeded');
  });
});

测试覆盖率

运行覆盖率报告

# Generate coverage report
pnpm test:coverage

# Generate HTML report
pnpm test:coverage -- --coverageReporters=html

# Check coverage thresholds
pnpm test:coverage -- --coverageThreshold='{
  "global": {
    "branches": 75,
    "functions": 75,
    "lines": 75,
    "statements": 75
  }
}'

覆盖率配置

// jest.config.js
module.exports = {
  collectCoverage: true,
  collectCoverageFrom: [
    'src/connectors/mydex/**/*.ts',
    '!src/connectors/mydex/**/*.test.ts',
    '!src/connectors/mydex/types.ts'
  ],
  coverageThreshold: {
    global: {
      branches: 75,
      functions: 75,
      lines: 75,
      statements: 75
    }
  },
  coverageReporters: ['text', 'lcov', 'html']
};

测试工具

辅助函数

// test/utils/test-helpers.ts
export function createMockWallet(address?: string): Wallet {
  return {
    address: address || '0x' + '0'.repeat(40),
    privateKey: '0x' + '0'.repeat(64),
    signTransaction: jest.fn(),
    signMessage: jest.fn()
  };
}

export function createMockToken(
  symbol: string,
  decimals: number = 18
): Token {
  return {
    symbol,
    decimals,
    address: `0x${symbol}${'0'.repeat(40 - symbol.length)}`,
    name: `Mock ${symbol}`
  };
}

export async function waitForEvent(
  emitter: EventEmitter,
  event: string,
  timeout: number = 5000
): Promise<any> {
  return new Promise((resolve, reject) => {
    const timer = setTimeout(() => {
      reject(new Error(`Timeout waiting for event: ${event}`));
    }, timeout);

    emitter.once(event, (data) => {
      clearTimeout(timer);
      resolve(data);
    });
  });
}

最佳实践

1. 测试独立性

每个测试应该是独立的,不依赖于其他测试:

// Good
beforeEach(() => {
  connector = new MyDex('ethereum', 'mainnet');
});

// Bad - relies on previous test state
it('test 1', () => {
  connector.setState('value');
});

it('test 2', () => {
  expect(connector.getState()).toBe('value'); // Depends on test 1
});

2. 描述性测试名称

使用清晰、描述性的测试名称:

// Good
it('should return USDC-WETH pool info with correct reserves and fee tier');

// Bad
it('works');

3. 测试数据构建器

为复杂的测试数据创建构建器:

class PoolBuilder {
  private pool = { ...defaultPool };

  withTokens(token0: string, token1: string): this {
    this.pool.token0 = token0;
    this.pool.token1 = token1;
    return this;
  }

  withLiquidity(amount: string): this {
    this.pool.liquidity = amount;
    return this;
  }

  build(): Pool {
    return this.pool;
  }
}

// Usage
const pool = new PoolBuilder()
  .withTokens('USDC', 'WETH')
  .withLiquidity('1000000')
  .build();

4. 异步测试

始终正确处理异步操作:

// Good
it('should handle async operation', async () => {
  const result = await asyncOperation();
  expect(result).toBeDefined();
});

// Bad - might pass before async completes
it('should handle async operation', () => {
  asyncOperation().then(result => {
    expect(result).toBeDefined();
  });
});

资源