代码改变世界

web3的DApp测试框架设计(一)

2025-04-28 17:07  第二个卿老师  阅读(116)  评论(0)    收藏  举报

web3的DApp测试框架初步设计

背景:

由于公司后面偏向做DApp项目(纯去中心化较少),有去中心化智能合约与中心化接口的测试需求,每次测试新DApp项目时,测试脚本与数据比较分散,又极具个人风格,导致项目结构杂乱,代码复用性低,团队成员上手麻烦。而目前接触到的测试框架,要么是传统的中心化测试框架(HttpRunner、Seldom等),要么是完全的合约测试框架(Hardhat,Brownie等),是否可以结合两者设计一个DApp测试框架来匹配目前公司的项目需求

初步分析:

  1. 生成一个针对Web3中Dapp的测试框架,该框架使用python实现,并结合成熟的测试框架的设计思想
  2. 该框架应该简单灵活,方便我在本地及远程测试合约,也可以测试中心化的业务接口,有新的Dapp项目也能快速进行脚手架测试
  3. 框架功能点,里面可能包含account类,该类可以随机生成钱包用户并可以保存到配置文件中,也可以根据账户文件的私钥生成钱包用户,然后可能包含accout_manager类,该类可以进行钱包用户的签名、对合约的交易签名等;然后可能包含Contract类,该类可以实例化对应业务的合约abi.json文件,封装了合约的基本操作方法等;然后可能包含一个common文件,里面有一些当前项目的公共方法;最后应该还有一个config文件,有一些接口域名、网络,rpc节点,gas费的配置等
  4. 框架中的相关配置文件可以参考业内最佳实践
  5. 先实现框架核心功能,后续可实现其他功能(比如日志、插件、报告、脚手架等)
  6. 第一个版本实现后,后续版本可能需要实现自动化能力

框架实现

由于准备框架时,又有测试任务,紧凑中凑出了一个初步不完善的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

去中心化场景测试

  1. 初始化合约处理器
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)
  1. 基础调用
# 只读调用
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
)
  1. 高级使用模式
# 直接访问原始合约方法(需要手动处理)
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

实践总结

由上可知,目前存在的主要问题如下,后面需要持续完善:

  • 测试用例层级划分不够清晰
  • 测试数据与测试用例分离不够彻底
  • 项目测试数据有点杂乱
  • 整体框架中部分文件存在冗余
  • 框架上手成本较大