第五篇:测试体系建设
Web3项目的测试体系建设:单元测试与E2E测试实践
在Web3项目开发中,测试体系的建设尤为重要。区块链应用的特殊性使得测试不仅要验证功能正确性,还要确保资金安全和用户体验。在我的项目中,我构建了一套完整的测试体系,包括单元测试、组件测试和端到端测试,以确保代码质量和产品稳定性。
测试策略思考
为什么测试对Web3项目至关重要?
Web3应用的特殊性使得测试比传统Web应用更为重要:
- 资金安全:代码错误可能导致用户资产损失
- 智能合约交互:与区块链的交互需要特别验证
- 钱包兼容性:不同钱包的行为差异需要测试覆盖
- 用户体验:交易流程的顺畅性直接影响用户留存
测试金字塔策略
我采用了经典的测试金字塔策略:
- 单元测试:覆盖工具函数、hooks和业务逻辑
- 组件测试:验证UI组件的渲染和交互
- E2E测试:模拟用户操作流程,验证整体功能
单元测试配置
1. 安装依赖
# Jest核心库
pnpm add -D jest@30.1.3 jest-environment-jsdom@30.1.2
# React测试工具
pnpm add -D @testing-library/react@16.3.0 @testing-library/jest-dom@6.8.0
pnpm add -D @testing-library/user-event@14.6.1
# TypeScript支持
pnpm add -D ts-jest @types/jest
# 模拟工具
pnpm add -D jest-when @types/node
2. Jest配置
// jest.config.js
module.exports = {
preset: 'ts-jest',
testEnvironment: 'jsdom',
roots: ['<rootDir>/src'],
setupFilesAfterEnv: ['<rootDir>/src/setupTests.ts'],
testMatch: [
'<rootDir>/src/**/__tests__/**/*.{ts,tsx}',
'<rootDir>/src/**/*.{test,spec}.{ts,tsx}',
],
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
'\\.(css|less|scss|sass)$': 'identity-obj-proxy',
'\\.(jpg|jpeg|png|gif|webp|svg)$': '<rootDir>/__mocks__/fileMock.js',
},
collectCoverageFrom: [
'src/**/*.{ts,tsx}',
'!src/**/*.d.ts',
'!src/index.tsx',
'!src/serviceWorker.ts',
'!**/node_modules/**',
],
coverageDirectory: 'coverage',
coverageReporters: ['text', 'lcov', 'clover'],
testTimeout: 10000,
};
3. 测试环境设置
// src/setupTests.ts
import '@testing-library/jest-dom';
// 模拟Web3相关的全局对象
Object.defineProperty(window, 'ethereum', {
writable: true,
value: {
isMetaMask: true,
request: jest.fn(),
on: jest.fn(),
removeListener: jest.fn(),
selectedAddress: '0x742d35Cc6634C0532925a3b8D4C9db96590c6C87',
chainId: '0x5',
},
});
// 模拟其他需要的全局对象
global.ResizeObserver = jest.fn().mockImplementation(() => ({
observe: jest.fn(),
unobserve: jest.fn(),
disconnect: jest.fn(),
}));
// 扩展Jest匹配器
expect.extend({
toBeValidAddress(received: string) {
const isValid = /^0x[a-fA-F0-9]{40}$/.test(received);
if (isValid) {
return {
message: () => `expected ${received} not to be a valid Ethereum address`,
pass: true,
};
} else {
return {
message: () => `expected ${received} to be a valid Ethereum address`,
pass: false,
};
}
},
});
单元测试示例
1. 工具函数测试
// src/utils/__tests__/web3.test.ts
import { formatAddress, parseEther, formatEther, isValidAddress } from '../web3';
describe('Web3工具函数测试', () => {
describe('formatAddress', () => {
it('正确格式化以太坊地址', () => {
const address = '0x742d35Cc6634C0532925a3b8D4C9db96590c6C87';
expect(formatAddress(address)).toBe('0x742d...c6C87');
expect(formatAddress(address, 6)).toBe('0x742d35...b96590c6C87');
});
it('处理空地址', () => {
expect(formatAddress('')).toBe('');
expect(formatAddress(undefined as any)).toBe('');
});
});
describe('isValidAddress', () => {
it('验证有效地址', () => {
const validAddress = '0x742d35Cc6634C0532925a3b8D4C9db96590c6C87';
expect(isValidAddress(validAddress)).toBe(true);
expect(validAddress).toBeValidAddress();
});
it('识别无效地址', () => {
expect(isValidAddress('invalid-address')).toBe(false);
expect(isValidAddress('0x123')).toBe(false);
expect(isValidAddress('')).toBe(false);
});
});
describe('单位转换函数', () => {
it('正确解析以太币', () => {
const wei = parseEther('1.0');
expect(wei.toString()).toBe('1000000000000000000');
const ether = formatEther(wei);
expect(ether).toBe('1.0');
});
it('处理大数转换', () => {
const wei = parseEther('1234.5678');
expect(formatEther(wei)).toBe('1234.5678');
});
});
});
2. 自定义Hook测试
// src/hooks/__tests__/useContract.test.ts
import { renderHook } from '@testing-library/react';
import { useContract, useERC20Contract } from '../useContract';
import { useWeb3React } from '@web3-react/core';
import { ethers } from 'ethers';
// Mock dependencies
jest.mock('@web3-react/core');
jest.mock('ethers');
describe('useContract Hook', () => {
const mockUseWeb3React = useWeb3React as jest.Mock;
const mockContract = {
address: '0xContractAddress',
balanceOf: jest.fn(),
};
beforeEach(() => {
(ethers.Contract as jest.Mock).mockImplementation(() => mockContract);
mockUseWeb3React.mockReturnValue({
library: {
getSigner: jest.fn().mockReturnValue({}),
},
account: '0xAccountAddress',
chainId: 1,
});
});
afterEach(() => {
jest.clearAllMocks();
});
it('创建合约实例', () => {
const { result } = renderHook(() =>
useContract('0xContractAddress', [] as any)
);
expect(ethers.Contract).toHaveBeenCalledWith(
'0xContractAddress',
[],
expect.anything()
);
expect(result.current).toEqual(mockContract);
});
it('当参数不足时返回null', () => {
// 没有地址
let { result } = renderHook(() => useContract('', [] as any));
expect(result.current).toBeNull();
// 没有ABI
({ result } = renderHook(() => useContract('0xAddress', null as any)));
expect(result.current).toBeNull();
// 没有library
mockUseWeb3React.mockReturnValue({ library: null, account: '0xAccount' });
({ result } = renderHook(() => useContract('0xAddress', [] as any)));
expect(result.current).toBeNull();
});
it('使用ERC20合约Hook', () => {
const { result } = renderHook(() =>
useERC20Contract('0xTokenAddress')
);
expect(ethers.Contract).toHaveBeenCalled();
expect(result.current).toEqual(mockContract);
});
});
3. 组件测试
// src/components/__tests__/WalletConnector.test.tsx
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { Web3ReactProvider } from '@web3-react/core';
import WalletConnector from '../WalletConnector';
import { metaMask } from '../../connectors/metaMask';
// Mock connectors
jest.mock('../../connectors/metaMask', () => ({
metaMask: {
activate: jest.fn(),
deactivate: jest.fn(),
},
hooks: {
useMetaMask: () => ({ isActivated: false }),
},
}));
jest.mock('../../connectors/walletConnect', () => ({
walletConnect: {
activate: jest.fn(),
},
}));
// Mock useWeb3React
jest.mock('@web3-react/core', () => ({
...jest.requireActual('@web3-react/core'),
useWeb3React: () => ({
account: null,
active: false,
deactivate: jest.fn(),
}),
}));
// 创建测试Provider
const TestProvider = ({ children }: { children: React.ReactNode }) => (
<Web3ReactProvider getLibrary={() => ({})}>
{children}
</Web3ReactProvider>
);
describe('WalletConnector组件', () => {
it('初始状态显示连接钱包按钮', () => {
render(
<TestProvider>
<WalletConnector />
</TestProvider>
);
expect(screen.getByText('连接钱包')).toBeInTheDocument();
});
it('点击按钮显示钱包选项', async () => {
render(
<TestProvider>
<WalletConnector />
</TestProvider>
);
// 点击连接钱包按钮
fireEvent.click(screen.getByText('连接钱包'));
// 检查菜单选项
await waitFor(() => {
expect(screen.getByText('MetaMask')).toBeInTheDocument();
expect(screen.getByText('WalletConnect')).toBeInTheDocument();
});
});
it('选择MetaMask调用激活方法', async () => {
render(
<TestProvider>
<WalletConnector />
</TestProvider>
);
// 打开菜单
fireEvent.click(screen.getByText('连接钱包'));
// 选择MetaMask
await waitFor(() => {
fireEvent.click(screen.getByText('MetaMask'));
});
expect(metaMask.activate).toHaveBeenCalled();
});
it('已连接状态显示账户信息', () => {
// Mock已连接状态
jest.mock('@web3-react/core', () => ({
...jest.requireActual('@web3-react/core'),
useWeb3React: () => ({
account: '0x742d35Cc6634C0532925a3b8D4C9db96590c6C87',
active: true,
deactivate: jest.fn(),
}),
}));
render(
<TestProvider>
<WalletConnector />
</TestProvider>
);
expect(screen.getByText('0x742d...c6C87')).toBeInTheDocument();
});
});
E2E测试配置
1. 安装Cypress
pnpm add -D cypress@15.2.0 @testing-library/cypress
npx cypress install
2. Cypress配置
// cypress.config.ts
import { defineConfig } from 'cypress';
export default defineConfig({
e2e: {
baseUrl: 'http://localhost:3000',
setupNodeEvents(on, config) {
// 实现节点事件监听器
},
specPattern: 'cypress/e2e/**/*.cy.{js,jsx,ts,tsx}',
supportFile: 'cypress/support/e2e.ts',
video: true,
screenshotOnRunFailure: true,
viewportWidth: 1280,
viewportHeight: 720,
defaultCommandTimeout: 10000,
env: {
TEST_ACCOUNT: '0x742d35Cc6634C0532925a3b8D4C9db96590c6C87',
TEST_PRIVATE_KEY: 'test_private_key_here',
},
},
});
3. Cypress自定义命令
// cypress/support/commands.ts
import '@testing-library/cypress/add-commands';
// 模拟钱包连接
Cypress.Commands.add('mockWallet', (options = {}) => {
const defaults = {
address: '0x742d35Cc6634C0532925a3b8D4C9db96590c6C87',
chainId: '0x5',
balance: '0x1bc16d674ec80000', // 2 ETH
};
const config = { ...defaults, ...options };
// 模拟window.ethereum对象
cy.window().then((win) => {
win.ethereum = {
isMetaMask: true,
selectedAddress: config.address,
chainId: config.chainId,
request: cy.stub().callsFake((request) => {
switch (request.method) {
case 'eth_requestAccounts':
case 'eth_accounts':
return [config.address];
case 'eth_chainId':
return config.chainId;
case 'eth_getBalance':
return config.balance;
case 'eth_sendTransaction':
return '0x' + Math.random().toString(16).substr(2, 64);
default:
return Promise.resolve();
}
}),
on: cy.stub(),
removeListener: cy.stub(),
};
});
});
// 连接钱包
Cypress.Commands.add('connectWallet', () => {
cy.get('[data-testid="connect-wallet-button"]').click();
cy.contains('MetaMask').click();
});
E2E测试示例
// cypress/e2e/wallet-connection.cy.ts
describe('钱包连接流程', () => {
beforeEach(() => {
// 访问应用并模拟钱包
cy.visit('/');
cy.mockWallet();
});
it('显示连接钱包按钮', () => {
cy.get('[data-testid="connect-wallet-button"]').should('be.visible');
cy.get('[data-testid="connect-wallet-button"]').contains('连接钱包');
});
it('成功连接钱包并显示账户信息', () => {
// 连接钱包
cy.connectWallet();
// 验证连接状态
cy.get('[data-testid="wallet-info"]').should('be.visible');
cy.get('[data-testid="wallet-address"]').contains('0x742d...c6C87');
cy.get('[data-testid="wallet-balance"]').contains('2.0 ETH');
});
it('断开钱包连接', () => {
// 先连接钱包
cy.connectWallet();
// 断开连接
cy.get('[data-testid="disconnect-button"]').click();
// 验证已断开
cy.get('[data-testid="connect-wallet-button"]').should('be.visible');
});
});
// cypress/e2e/red-packet.cy.ts
describe('红包功能', () => {
beforeEach(() => {
cy.visit('/');
cy.mockWallet();
cy.connectWallet();
});
it('创建红包流程', () => {
// 导航到创建红包页面
cy.get('[data-testid="create-redpacket-link"]').click();
// 填写表单
cy.get('[name="amount"]').type('0.1');
cy.get('[name="count"]').type('5');
cy.get('[name="password"]').type('123456');
// 提交表单
cy.get('[data-testid="create-button"]').click();
// 模拟交易确认
cy.window().then((win) => {
win.ethereum.request.withArgs({ method: 'eth_sendTransaction' })
.resolves('0xTransactionHash');
});
// 验证成功消息
cy.contains('红包创建成功').should('be.visible');
cy.contains('0xTransactionHash').should('be.visible');
});
});
测试最佳实践
单元测试最佳实践
- 测试隔离:每个测试应该独立运行,不依赖外部状态
- 模拟依赖:使用Jest mock模拟外部依赖,尤其是Web3相关服务
- 测试命名:使用清晰的命名描述测试目的
- 测试覆盖:关注核心业务逻辑和边界条件
- 避免实现细节:测试行为而非实现细节
E2E测试最佳实践
- 用户视角:从用户角度设计测试场景
- 关键流程:优先测试核心用户流程
- 环境隔离:使用测试网或本地节点进行测试
- 数据清理:测试后清理测试数据
- 稳定测试:避免不稳定的测试,使用适当的等待策略
Web3测试特别注意事项
- 钱包模拟:合理模拟钱包行为,避免依赖真实钱包
- 交易模拟:模拟交易确认和失败场景
- 多链测试:测试不同网络的兼容性
- gas处理:测试不同gas策略的表现
- 错误处理:验证错误情况的处理逻辑
测试自动化
在CI/CD流程中集成测试:
# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v2
with:
version: 8
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- name: Install dependencies
run: pnpm install
- name: Run unit tests
run: pnpm test:unit
- name: Build
run: pnpm build
- name: Start server
run: pnpm start &
env:
NODE_ENV: test
- name: Wait for server
run: npx wait-on http://localhost:3000
- name: Run E2E tests
run: pnpm test:e2e
总结
测试体系是Web3项目质量保障的关键环节。通过构建完整的测试体系,可以有效减少生产环境的bug,提升用户体验,保障资金安全。在实际开发中,我注重测试的有效性和效率,选择合适的测试工具和策略,确保测试能够真正发现问题而不增加过多的开发负担。
下一篇,我将分享组件开发与文档化的实践经验,包括Storybook的应用和组件设计原则。
