web3的DApp测试框架设计(一)
2025-04-28 17:07 第二个卿老师 阅读(116) 评论(0) 收藏 举报web3的DApp测试框架初步设计
背景:
由于公司后面偏向做DApp项目(纯去中心化较少),有去中心化智能合约与中心化接口的测试需求,每次测试新DApp项目时,测试脚本与数据比较分散,又极具个人风格,导致项目结构杂乱,代码复用性低,团队成员上手麻烦。而目前接触到的测试框架,要么是传统的中心化测试框架(HttpRunner、Seldom等),要么是完全的合约测试框架(Hardhat,Brownie等),是否可以结合两者设计一个DApp测试框架来匹配目前公司的项目需求
初步分析:
- 生成一个针对Web3中Dapp的测试框架,该框架使用python实现,并结合成熟的测试框架的设计思想
- 该框架应该简单灵活,方便我在本地及远程测试合约,也可以测试中心化的业务接口,有新的Dapp项目也能快速进行脚手架测试
- 框架功能点,里面可能包含account类,该类可以随机生成钱包用户并可以保存到配置文件中,也可以根据账户文件的私钥生成钱包用户,然后可能包含accout_manager类,该类可以进行钱包用户的签名、对合约的交易签名等;然后可能包含Contract类,该类可以实例化对应业务的合约abi.json文件,封装了合约的基本操作方法等;然后可能包含一个common文件,里面有一些当前项目的公共方法;最后应该还有一个config文件,有一些接口域名、网络,rpc节点,gas费的配置等
- 框架中的相关配置文件可以参考业内最佳实践
- 先实现框架核心功能,后续可实现其他功能(比如日志、插件、报告、脚手架等)
- 第一个版本实现后,后续版本可能需要实现自动化能力
框架实现
由于准备框架时,又有测试任务,紧凑中凑出了一个初步不完善的Web3_test_framework测试框架,框架使用python3.10版本,依赖Pytest(8.3.5+),Requests(2.32.3+)、Web3(7.8.0+)等开源库
框架目录结构规划如下:
web3_test_framework/ # 项目根目录
├── contracts/ # Solidity智能合约源文件
│ ├── ERC20/ # 代币合约示例目录
│ │ └── ERC20.sol # 合约源码
│ └── project_name.sol # 其他业务合约
│
├── artifacts/ # 编译输出目录(自动生成)
│ ├── ERC20/
│ │ ├── ERC20.json # 编译产物(包含ABI/bytecode)
│ │ └── ERC20.dbg.json # 调试信息
│ └── project_name.json
│
├── scripts/ # 部署和维护脚本
│ ├── deploy.py # 合约部署入口脚本
│ └── upgrade_contract.py # 合约升级脚本
│
├── data/ # 全局数据
│ └── project_name # 项目数据
│ └── accounts_base.json
├── src/ # 框架核心代码
│ ├── core/ # 区块链核心代码
│ │ ├── blockchain/ # 编译部署器
│ │ │ ├── standard_contracts.py # 标准合约依赖类
│ │ │ ├── compiler.py # Solidity编译器
│ │ │ └── deployer.py # 合约部署器
│ │ ├── account/ # 账户管理
│ │ │ ├── manager.py # 账户管理器
│ │ │ └── wallet.py # 钱包核心类
│ │ ├── contract/ # 合约交互管理
│ │ │ ├── abi_loader.py # 多源ABI加载器
│ │ │ ├── contract_handler.py # 专注合约交互,不处理实例化逻辑
│ │ │ └── contract_loader.py # 合约实例化工厂类
│ │ └── transaction # 交易管理
│ │ ├── gas_estimator.py # gas计算
│ │ ├── transaction_builder.py # 交易构建器
│ │ └── transaction_sender.py # 交易发送器
│ │
│ ├── libs/ # 合约依赖仓库
│ │ └── openzeppelin/
│ │ └── ERC721/
│ │ └── 5.0.1
│ │ └── ERC721.json
│ │
│ ├── services/ # 中心化服务测试模块
│ │ ├── http_client.py # REST API测试客户端
│ │ └── db_client.py # 数据库连接器
│ │
│ ├── utils/ # 工具类
│ │ ├── abi_summary.py # 合约abi接口文档生成工具
│ │ ├── contract_tool.py # 合约工具
│ │ ├── transfer_utils.py # 区块链转账工具
│ │ ├── validator.py # 验证器
│ │ ├── crypto.py # 加密相关
│ │ ├── wallet_batch_generator.py # 钱包批量生成器
│ │ └── file_util.py # 文件处理
│ │
│ └── config/ # 配置管理
│ ├── loader.py # 配置加载器
│ ├── projects.yaml # 项目配置
│ ├── networks.yaml # 网络配置
│ └── accounts.yaml # 账户配置
│
├── tests/ # 测试用例目录
│ ├── project_name/ # 项目名
│ │ └── test_xxx.py # 测试用例
│ └── pytest/ # 框架测试用例
│ └── test_transfer_utils.py
│
├── conftest.py # Pytest全局fixture
└── README.md # 项目文档
核心逻辑示例
框架目前实现版本支持多个项目的去中心化测试(Api接口测试)、中心化测试(合约远程调用)
基础配置
由上面框架目录可知,框架核心配置在于projects.yaml,networks.yaml,accounts.yaml三个文件,其中conftest.py用于后续pytest测试,可先不管
# config/networks.yaml配置参考,可配置多个rpc网络
chains:
bsc_testnet:
rpc: "https://bsc-testnet.public.blastapi.io"
chain_id: 97
explorer: "https://testnet.bscscan.com"
gas_config:
strategy: "dynamic" # dynamic|fixed
max_gwei: 50 # 最大接受gas价格,fixed对应 gwei: 10
priority_fee: 1.5 # 优先费倍数
# config/accounts.yaml配置参考,可配置默认账户
accounts:
# 明文账户(不推荐)
test_account:
address: "0xB270B2fa0c9033F1d81fc418FBeEc99fe01xxx"
private_key: "xxxfdf711a32c80927b2c8ffb9e83569317093af619f904e5633b239ebxxx" # private_key: "encrypted:gAAAAABkO..." 加密账户需要单独设置加解密
networks: ["bsc_testnet", "polygon_mainnet"] # 限制可用网络
# config/projects.yaml配置参考,可配置DApp项目信息
CC:
env_config:
base_domain: "wj.cc.fun" # 基础域名
api_paths:
prod:
api: "https://api.cc.fun"
graphql: "https://graphql.api.cc.fun"
test:
api: "https://testapi.cc.fun"
graphql: "https://staging-graphql.testapi.cc.fun"
chain_config:
default_network: "bsc_testnet"
supported_networks:
- "bsc_mainnet"
- "bsc_testnet"
account_groups:
admins: ["test_account", "admin"] # 使用accounts.yaml中的账户别名
中心化场景测试
1.定义项目接口类与基础数据验证器等
# cc_api.py,项目接口基础类
from core.services.http_client import HttpClient
class CCApi:
def __init__(self, client: HttpClient):
self.client = client
def login(self):
# 用户进行登录
endpoint = "/login"
method = "POST"
body = {}
rsp = self.client.call_api(endpoint, method)
if rsp:
self._token = rsp.json()["token"]
self.client.set_default_header("token", self._token)
return True
def get_index_value(self, tw_rest_id):
"""查询首页"""
endpoint = "index/value"
method = "GET"
if tw_rest_id:
try:
rsp = self.client.call_api(endpoint, method, params={"Id": id}).json()
return rsp
except Exception as e:
print(e)
return None
# buy_cost.py,数据验证器
def calc_P0(num):
if num < 30000:
return 0.01
elif 30000<=num and num <100000:
return 0.03
elif 100000 < num:
return 0.5
2.定义用户操作层
# cc_user.py用户操作层
class CCUser(CCApi):
def __init__(self, client: HttpClient, user: Wallet = None, contract: CCContract = None):
super().__init__(client)
self.user = user
self._contract = contract
3.编写测试用例
# test_api.py接口类测试用例
from cc_user import CCUser
# 获取项目配置
project_config = loader.load_project_config()
base_url = project_config["env_config"]["current"]["test"]["api"]
http_client = HttpClient(base_url)
all_tx = {}
# 初始化用户
def create_user(user_private_key:str = None):
user = Wallet.from_private_key(user_private_key)
return CCUser(client=http_client, user=user)
def test_login_success():
A = create_user("bxxx68a6357c374dd714a17adc71063199942e4fdb49df2793dbe8bf5e8xxx")
rsp = A.login
assert rsp
# test_cost.py数据类测试用例
from buy_cost import calc_P0
def test_cost_success():
# 定义常量和测试数据
fans = 1000
P0 = calc_P0(fans)
assert P0 == 0.01
去中心化场景测试
- 初始化合约处理器
from web3 import Web3
from core.account.wallet import Wallet
from core.contract.contract_handler import ContractHandler
from core.contract.contract_loader import ContractLoader
# 初始化
w3 = Web3(Web3.HTTPProvider(RPC_URL))
wallet = Wallet.from_private_key("0x...")
contract_addr = "0x..." # 远程合约地址
try:
# 合约加载器(支持加载本地、标准(ERC20、ERC721等)、远程的ABI)
contract = ContractLoader(w3).from_address(
address=contract_addr,
abi_source="local",
source_params={
"project": "CC", # 项目名为CC
"contract_name": "CC" #(名称为项目名+合约名_abi.json,项目下默认abi存放路径)
})
# 创建合约处理器
handler = ContractHandler(contract=contract, wallet=wallet)
except FileNotFoundError as e:
print(e)
- 基础调用
# 只读调用
balance = handler.call("balanceOf", wallet.address)
print(f"Current balance: {balance}")
# 交易调用(扩展了transaction_builder与transaction_sender,contract_handler不处理Gas/Nonce)
# contract_handler专注合约交互,不处理实例化逻辑
# 支持transact(本地账户,直接发送交易),transact_rpc(自己构造交易,与远程合约交互)两种方式调用,也可自定义,如后端构造交易,与远程合约交互
# transact示例
result = handler.transact(
func_name="transfer",
to_address="0x...",
amount=100,
gas_strategy="high"
)
if result["status"] == "success":
print(f"Tx confirmed in block {result['receipt'].blockNumber}")
# transact_rpc示例
result = handler.transact_rpc(
func_name="transfer",
to_address="0x...",
amount=100,
gas_strategy="high",
value=value,
data=data
)
if result["status"] == "success":
print(f"Tx confirmed in block {result['receipt'].blockNumber}")
# 工具类转账,(ERC20,ERC721)
utils = TransferUtils(w3)
# ETH转账
eth_result = utils.send_eth(
sender=alice_wallet,
to_address=bob_address,
amount_wei=Web3.to_wei(0.1, 'ether'),
gas_strategy="fast"
)
# NFT转账
nft_result = utils.send_erc721(
sender=alice_wallet,
contract_address=nft_contract,
receiver=bob_address,
token_id=123
)
- 高级使用模式
# 直接访问原始合约方法(需要手动处理)
raw_transfer = handler.functions.transfer("0x...", 100)
tx_data = raw_transfer.build_transaction({
"from": wallet.address,
"nonce": handler.builder._get_next_nonce(wallet.address)
})
# 事件监听
transfer_events = handler.events("Transfer", from_block=12345)
for event in transfer_events:
print(f"Transfer: {event.args}")
混合场景测试:
1.新增业务中间层(考虑合约交互)
# CC_contract.py项目合约交互层
class CCContract:
def __init__(self, w3: Web3, contract_addr=None):
self.w3 = w3
self.chain_id = w3.eth.chain_id
self._builder = TransactionBuilder(w3)
self._sender = TransactionSender(w3)
self.handler = None
if w3.is_checksum_address(contract_addr):
self._contract = ContractLoader(w3).from_address(
address=contract_addr,
abi_source="local",
source_params={
"project": "CC",
"contract_name": "CC"
})
def func(self, wallet: Optional[Wallet] = None, value: str=None, data: str=None):
value_int = Web3.to_int(hexstr=value)
self.handler = ContractHandler(contract=self._contract, wallet=wallet)
return self.handler.transact_data(value=value_int, data=data)
def get_event(self, wallet: Optional[Wallet] = None, name: str=None, filter: dict=None):
if name is None:
raise ValueError("Event name must be provided")
self.handler = ContractHandler(contract=self._contract, wallet=wallet)
# 设置默认的from_block为0
from_block = filter.get('from_block', 0) if filter else 0
# 定义要查询的事件类型(例如Transfer事件)
transfer_event = self.handler.events(event_name=name, filter=filter)
# 获取事件日志
return self.handler.events(event_name=name, from_block=from_block)
# cc_user.py用户合约交互封装
class CCUser(CCApi):
def __init__(self, client: HttpClient, user: Wallet = None, contract: CCContract = None):
super().__init__(client)
self.user = user
self._contract = contract
def send_transaction(self, json_data: dict):
try:
if json_data:
value = json_data["data"]["value"]
data = json_data["data"]["data"]
print("开始调用合约")
return self.call_contract(value=value, data=data)
except Exception as e:
print(e, "json_data:", json_data)
return None
def call_contract(self, value: str=None, data: str=None):
try:
return self._contract.func(wallet=self.user, value=value, data=data)
except Exception as e:
print(e)
return None
def get_contract_events(self, filter:dict=None):
pass
2.编写测试用例
# test_service.py基础业务流(接口组装)
def user_ask(cc_user: CCUser = None, tw_rest_id: str = None, msg: str = None):
if not msg:
msg = "这是向" + tid +"发出的购买!时间为" + datetime.now().strftime('%Y-%m-%d %H:%M:%S')
rsp = cc_user.ask(target_id=tid, msg=msg, address=cc_user.user.address)
tx = cc_user.send_transaction(rsp)
if tx:
print("buy上链成功")
all_tx["buy_tx"] = tx
return True
else:
return False
# test_case.py业务测试用例
import test_service as ts
# 初始化合约配置
loader = ConfigLoader(project_name="CC")
network = loader.load_network_config("bsc_testnet")
w3 = Web3(Web3.HTTPProvider(network["rpc"]))
contract_addr = "0xee1181fE761aAd6505988EEB9EA9d6303141234" # 合约地址
cc_contract = CCContract(w3=w3, contract_addr=contract_addr)
# 获取项目配置
project_config = loader.load_project_config()
base_url = project_config["env_config"]["current"]["test"]["api"]
http_client = HttpClient(base_url)
all_tx = {}
# 获取账号池
ac_manager = Manager(project_name="CC")
def create_user(user_private_key:str = None):
user = Wallet.from_private_key(user_private_key)
return CCUser(client=http_client, user=user, contract=cc_contract)
def test_user_buy_success(cc_user: CCUser = None, t_id: str = None, msg: str = None):
E = create_user("as31w3a6357c374dd714a17adc71063199942e4fdb49df2793dbe8bf5e123121")
rsp = ts.user_ask(E, tw_rest_id=t_id)
assert rsp
实践总结
由上可知,目前存在的主要问题如下,后面需要持续完善:
- 测试用例层级划分不够清晰
- 测试数据与测试用例分离不够彻底
- 项目测试数据有点杂乱
- 整体框架中部分文件存在冗余
- 框架上手成本较大
浙公网安备 33010602011771号