测试网关连接器¶
概述¶
综合测试对于网关连接器至关重要,以确保可靠性、安全性和兼容性。本指南涵盖了网关连接器开发的测试策略、工具和最佳实践。
测试要求¶
所有网关连接器必须满足以下最低要求:
- 代码覆盖率:≥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();
});
});