OnchainTestKit:链上应用端到端测试框架

本文介绍了OnchainTestKit,一个旨在简化和提高链上应用测试可靠性的工具。它通过自动化的钱包管理、可靠的弹窗处理、真正的测试隔离以及确定性的合约部署,解决了传统测试框架在应对去中心化应用时的挑战。该工具已在Verified Pools项目中成功应用,显著减少了测试所需的代码量,并提高了CI/CD的效率。

使链上应用测试可靠且直接

介绍

测试去中心化应用一直与测试传统的 web 应用有根本的不同。传统的应用处理的是直接的 HTTP 请求和可预测的数据库状态,而链上应用必须驾驭区块链交互的复杂世界:钱包连接、交易批准、网络切换和链状态。每一个交互都带来了现有测试框架未被设计用于处理的独特挑战。

在为 Verified Pools 项目 编写端到端测试时,我们遇到了许多这样的挑战。这个复杂的 DeFi 应用将机构级的流动性基础设施带到了链上市场,需要对前端、后端和智能合约集成之间的复杂用户流程进行测试。例如,用户连接他们的钱包,批准 token 支出,签署 Permit2 消息,向池提供流动性,并在多个合约中管理他们的头寸。原本应该是一个简单的测试工作,却迅速变成了一个长达数周的技术挑战和生产力瓶颈的历程。

E2E 链上测试的核心挑战

测试去中心化应用引入了 web 自动化和区块链交互交叉处的复杂性。标准的端到端测试框架并非天生为此环境而构建,这迫使开发者在编写有效的测试之前解决几个根本问题。

根据我们的经验,有 4 个主要的测试障碍:

1. 钱包扩展设置 每一个测试都需要从头开始安装、配置和提供资金的浏览器钱包。这个过程对每个钱包(如 MetaMask 或 Coinbase Wallet)都是唯一的,并可能导致脆弱的自定义脚本。

2. 不可预测的钱包弹窗 在测试过程中,用户操作会触发钱包弹窗,用于交易批准、消息签名和网络切换。这些弹窗以不一致的时间和 UI 模式异步出现,导致不可预测性和不稳定性。

3. 共享链上状态 在共享区块链上,真正的测试并行是不可能的。当多个测试同时运行时,它们使用相同的钱包地址,并与同一区块链上的相同智能合约进行交互。这导致测试相互干扰,导致难以调试的不可预测的故障。

4. 合约部署和状态管理 在 Solidity 中的智能合约开发(使用 Foundry)和 TypeScript 中的端到端测试之间存在着一个根本的工具缺口。测试需要以特定的初始状态部署合约,但合约地址是不确定的,这破坏了与前端的连接。


OnchainTestKit 如何解决每个问题

OnchainTestKit 直接针对这四个关键问题,提供了专门构建的解决方案:

解决方案 1:自动钱包管理

OnchainTestKit 不是手动设置每种钱包类型,而是自动处理一切,提供了一个适用于所有钱包类型的统一接口。以下是如何使用 Coinbase Wallet 配置测试的示例:

// 使用本地节点和 coinbase 钱包配置测试
const coinbaseWalletConfig = configure()
  .withLocalNode({
    chainId: baseSepolia.id,
    forkUrl: process.env.E2E_TEST_FORK_URL,
    forkBlockNumber: BigInt(process.env.E2E_TEST_FORK_BLOCK_NUMBER ?? "0"),
    hardfork: "cancun",
  })
  .withCoinbase()
  .withSeedPhrase({
    seedPhrase: DEFAULT_SEED_PHRASE ?? "",
    password: DEFAULT_PASSWORD,
  })
  .withNetwork({
    name: "Base Sepolia",
    chainId: baseSepolia.id,
    symbol: "ETH",
    rpcUrl: "http://localhost:8545",
  })
  .build();

结果:对开发者友好,与钱包无关,且易于使用。我们将钱包设置和管理的所有复杂性都隐藏在开发者之外。

解决方案 2:可靠的钱包弹窗处理

OnchainTestKit 提供了一个统一的接口,抽象了所有弹窗的复杂性:

// 无需处理时间和弹窗检测:
await coinbaseWallet.handleAction(BaseActionType.CONNECT_TO_DAPP);
await coinbaseWallet.handleAction(BaseActionType.HANDLE_TRANSACTION, {
  approvalType: ActionApprovalType.APPROVE
});

await coinbaseWallet.handleAction(BaseActionType.CHANGE_SPENDING_CAP, {
  approvalType: ActionApprovalType.APPROVE
});

智能等待策略和重试:OnchainTestKit 使用智能等待策略,理解区块链的时间模式和钱包行为。内置的重试机制自动处理瞬时故障,而自适应超时则根据网络状况和弹窗复杂性进行调整。

结果:测试可靠地通过。不再有因时间问题或不可预测的钱包行为导致的不稳定测试。

解决方案 3:真正的测试隔离

OnchainTestKit 为每个测试提供其自己隔离的 Anvil 区块链节点,从而消除了所有状态冲突:

// 每个测试都获得其自己的 LocalNodeManager
test('parallel test A', async ({ localNodeManager, smartContractManager }) => {
  // 自动在可用端口上启动 Anvil 节点(例如,10543)
  // 此测试的交易仅影响其自己的区块链
  await page.click('#swap-button');
  // 测试逻辑...
});

test('parallel test B', async ({ localNodeManager, smartContractManager }) => {
  // 自动在不同的端口上启动单独的 Anvil 节点(例如,10847)
  // 完全独立的区块链状态
  await page.click('#approve-button');
  // 测试逻辑...
});

LocalNodeManager 的工作方式:OnchainTestKit 自动在进程之间分配可用的端口,并为每个测试启动隔离的 Anvil 节点。它支持三种不同的并行测试策略:

1. Fork 现有网络:在特定的区块高度 fork 一个测试网或主网,而无需部署合约。非常适合针对现有协议部署进行测试:

.withLocalNode({
  forkUrl: process.env.BASE_MAINNET_RPC_URL,
  forkBlockNumber: process.env.E2E_TEST_FORK_BLOCK_NUMBER,
  chainId: 8453
})

2. 清理本地状态:从一个全新的区块链开始,并部署所有依赖的合约。非常适合测试新的协议或复杂的状态设置:

.withLocalNode({
  chainId: 84532,
  // 无 fork - 从干净状态开始
})

// 然后通过 smartContractManager 部署合约

3. 混合方法:Fork 一个网络并在其上部署额外的测试合约。将真实的协议状态与自定义的测试合约相结合:

.withLocalNode({
  forkUrl: process.env.BASE_SEPOLIA_RPC_URL,
  forkBlockNumber: 10_000_000n,
  chainId: 84532
})

// 然后根据需要部署额外的测试合约

每种方法都提供了完全的测试隔离,具有独立的区块链状态,允许测试操纵时间、账户余额和合约状态,而不会影响其他测试。

智能 RPC 路由:关键的创新之一是自动请求拦截。你的前端可以始终使用固定的 RPC URL,例如 localhost:8545,而 LocalNodeManager 会自动将这些请求路由到每个测试的正确的 Anvil 节点。无需动态配置不同的端口——框架会透明地处理路由。

自动清理:LocalNodeManager 处理完整的节点生命周期,在每个测试完成后优雅地终止每个 Anvil 进程。这确保了没有资源泄漏或端口冲突,即使运行包含数十个并行节点的大型测试套件也是如此。

结果:完全并行化的测试,没有协调开销。CI 时间从数小时缩短到数分钟。

解决方案 4:确定性的合约部署

OnchainTestKit 使用 CREATE2 弥合了 Solidity/TypeScript 的差距,以实现确定性的合约部署:

await smartContractManager.setContractState({
  deployments: [\
    {\
      name: 'MockUSDC',\
      salt: '0x1234...', // 用于确定性地址的 CREATE2 salt\
      deployer: admin,\
      args: ['USD Coin', 'USDC', 6]\
    },\
    {\
      name: 'DEXContract',\
      salt: '0x5678...',\
      deployer: admin,\
      args: [mockUsdcAddress]\
    }\
  ],
  calls: [\
    { target: mockUsdcAddress, functionName: 'mint', args: [user, amount], account: admin },\
    { target: mockUsdcAddress, functionName: 'approve', args: [dexAddress, amount], account: user }\
  ]
});

CREATE2 的工作方式:OnchainTestKit 使用带有固定 salts 的 CREATE2 部署来确保合约始终部署到相同的地址。SmartContractManager 在部署之前预测部署地址,检查这些地址上是否已存在合约,并自动从你的 out/ artifact 目录加载 Foundry artifacts。这在你的 Solidity 合约和 TypeScript 测试之间创建了一个无缝的桥梁。

Foundry 集成:自动加载编译后的合约 artifacts,消除了合约和测试团队之间手动 ABI 管理或部署脚本协调的需要。

结果:可靠的合约测试,具有可预测的地址。从智能合约交互到 UI 反馈的完整用户旅程在所有测试运行中都保持一致。


我们如何在 Verified Pools 中使用 OnchainTestKit

以下是我们 Verified Pools 项目中的一个真实示例,展示了 OnchainTestKit 如何将复杂的链上应用测试转化为简洁、可读的测试:

// walletConfig/metamaskWalletConfig.ts
import { baseSepolia } from 'viem/chains';
import { configure } from '@coinbase/onchaintestkit';

export const DEFAULT_PASSWORD = 'PASSWORD';
export const DEFAULT_SEED_PHRASE = process.env.E2E_TEST_SEED_PHRASE;

// 用于具有 Base Sepolia fork 的 MetaMask 测试的可重用配置
const metamaskConfig = configure()
  .withLocalNode({
    chainId: baseSepolia.id,
    forkUrl: process.env.E2E_TEST_FORK_URL,
    forkBlockNumber: BigInt(process.env.E2E_TEST_FORK_BLOCK_NUMBER ?? '0'),
    hardfork: 'cancun',
  })
  .withMetaMask()
  .withSeedPhrase({
    seedPhrase: DEFAULT_SEED_PHRASE ?? '',
    password: DEFAULT_PASSWORD,
  })
  .withNetwork({
    name: 'Base Sepolia',
    chainId: baseSepolia.id,
    symbol: 'ETH',
    rpcUrl: 'http://localhost:8545', // 固定 URL,自动路由到正确的端口
  })
  .build();

export { metamaskConfig };

此测试涵盖了 Verified Pools 中的完整用户旅程:

  1. 将 MetaMask 钱包连接到链上应用
  2. 导航到 swap 界面
  3. 输入 swap 金额 (0.0001 ETH)
  4. 执行 swap 交易
  5. 处理所有必需的钱包弹窗(支出上限批准、Permit2 签名、交易确认)
  6. 验证 swap 是否成功完成
// swap.spec.ts

import { createOnchainTest } from '@coinbase/onchaintestkit';
import { NotificationPageType } from '@coinbase/onchaintestkit/wallets/MetaMask';
import { ActionApprovalType, BaseActionType } from '@coinbase/onchaintestkit/wallets/BaseWallet';
import { metamaskConfig } from './walletConfig/metamaskWalletConfig';

const test = createOnchainTest(metamaskConfig);
const { expect } = test;

test.describe('Verified Pools Swap', () => {
  test('connect wallet and swap @tx', async ({ page, metamask }) => {
    if (!metamask) throw new Error('MetaMask fixture is required');

    // 导航到 swap 界面
    await page.goto('/swap');

    // 连接钱包 - OnchainTestKit 处理所有复杂性
    await page.getByTestId('ockConnectButton').first().click();
    await page.getByTestId('ockModalOverlay')
      .first()
      .getByRole('button', { name: 'MetaMask' })
      .click();
    await metamask.handleAction(BaseActionType.CONNECT_TO_DAPP);
    await page.getByRole('button', { name: /^Accept$/ }).click();

    // 设置 swap
    const inputField = page.locator('input[placeholder="0.0"]').first();
    await inputField.fill('0.0001');

    // 执行 swap
    await page.getByRole('button', { name: 'Swap' }).click();
    await page.getByRole('button', { name: 'Confirm' }).click();

    // 处理支出上限批准 (Permit2)
    let notificationType = await metamask.identifyNotificationType();
    if (notificationType === NotificationPageType.SpendingCap) {
      await metamask.handleAction(BaseActionType.CHANGE_SPENDING_CAP, {
        approvalType: ActionApprovalType.APPROVE,
      });
      notificationType = await metamask.identifyNotificationType();
    }

    // 处理 Permit2 的签名
    if (notificationType === NotificationPageType.SpendingCap) {
      await metamask.handleAction(BaseActionType.HANDLE_SIGNATURE, {
        approvalType: ActionApprovalType.APPROVE,
      });
      notificationType = await metamask.identifyNotificationType();
    }

    // 处理实际的 swap 交易
    if (notificationType === NotificationPageType.Transaction) {
      await metamask.handleAction(BaseActionType.HANDLE_TRANSACTION, {
        approvalType: ActionApprovalType.APPROVE,
      });
    }

    // 验证 swap 完成
    await expect(page.getByRole('link', { name: 'View on Explorer' })).toBeVisible({
      timeout: 10_000,
    });
  });
});

这说明了:

  • 无需自定义钱包设置:OnchainTestKit 自动处理 MetaMask 的安装和配置
  • Fork 测试:测试针对真实的 Base Sepolia 网络在部署了所有依赖合约的特定区块高度运行,但每个测试都获得其自己隔离的 fork
  • 可靠的弹窗处理:复杂的钱包交互简化为简单的 handleAction 调用
  • 智能 RPC 路由:前端使用固定的 localhost:8545,框架路由到正确的测试节点
  • 自动清理:无需手动资源管理

在 OnchainTestKit 之前,同样的测试将需要数百行自定义钱包自动化代码。现在它简洁、可读且可维护。

使用 OnchainTestKit 进行生产 CI/CD

以下是我们在 Verified Pools CI 管道中大规模运行这些测试的方式:

## .github/workflows/playwright.yml

jobs:
  e2e-tests:
    # 为了简洁起见,省略了其他设置步骤
    # 安装 xvfb 用于无头浏览器测试

    - name: Install xvfb
      run: |
        sudo apt-get update
        sudo apt-get install -y xvfb

    - name: Install dependencies
      run: yarn install --frozen-lockfile

    - name: Install Playwright Browsers
      run: yarn playwright install --with-deps

    - name: Build application
      run: yarn build
      env:
        NEXT_PUBLIC_BASE_SEPOLIA_RPC_URLS: http://localhost:8545

    - name: Prepare MetaMask Extension
      run: yarn e2e:metamask:prepare

    # 并行运行交易测试,使用 10 个 workers
    - name: Run Playwright TX tests with xvfb
      run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" yarn playwright test --workers=10 --reporter=list,github
      env:
        E2E_TEST_SEED_PHRASE: ${{ secrets.E2E_TEST_SEED_PHRASE }}

关键的生产功能:

  • 并行执行:--workers=10 同时运行 10 个测试,每个测试都有隔离的区块链节点
  • 环境隔离:每个测试都获得其自己的 Base Sepolia fork,没有冲突
  • 强大的 CI 设置:使用 xvfb 处理无头浏览器自动化和适当的清理

结果:我们的 CI 在不到 10 分钟的时间内并行运行 100 多个全面的链上应用测试。


立即试用 OnchainTestKit

OnchainTestKit 现已可用,并已发布到 NPM。要开始使用,请查看 GitHub 仓库 中的文档和示例。

对改进链上开发者体验感兴趣?

如果你对增加链上开发者的影响力感兴趣,请了解我们的 Onchain DevX 团队很乐意与你见面! 我们正在招聘

  • 原文链接: blog.base.dev/introducin...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
Base 中文
Base 中文
江湖只有他的大名,没有他的介绍。