以太坊測試網完整指南:從環境搭建到 DApp 部署的開發者實戰教學

本文提供從測試網絡選擇、環境配置、智能合約部署、功能測試到前端集成的完整開發者指南。涵蓋 Sepolia 和 Holesky 的詳細配置、測試代幣獲取、多種開發框架的集成使用方法,以及實際項目中測試流程的最佳實踐。

以太坊測試網完整指南:從環境搭建到 DApp 部署的開發者實戰教學

概述

以太坊智能合約開發過程中,測試網絡扮演著至關重要的角色。測試網(Testnet)是一個與主網功能完全一致但使用無價值代幣的區塊鏈環境,允許開發者在不受風險的情況下驗證智能合約功能、測試用戶交互流程、以及進行壓力測試。截至 2026 年第一季度,以太坊生態系統包含多個活躍的測試網絡,其中 Sepolia 和 Holesky 是當前最受推薦的選擇。

本文將提供從測試網絡選擇、環境配置、智能合約部署、功能測試到前端集成的完整開發者指南。我們將涵蓋 Sepolia 和 Holesky 的詳細配置、測試代幣獲取、多種開發框架的集成使用方法,以及實際項目中測試流程的最佳實踐。

一、測試網絡概述與選擇

1.1 以太坊測試網絡發展歷史

以太坊測試網絡經歷了多次迭代,每個測試網都在不同的歷史時期服務於特定的開發需求:

測試網絡演進時間線:

┌─────────────────────────────────────────────────────────────┐
│  2015 - Ropsten                                            │
│  - 首個公開測試網絡                                         │
│  - 基於 PoW 共識                                           │
│  - 最終於 2022 年棄用                                      │
├─────────────────────────────────────────────────────────────┤
│  2016 - Rinkeby                                             │
│  - Clique PoA 共識                                          │
│  - 由 Geth 團隊維護                                        │
│  - 最終於 2023 年棄用                                      │
├─────────────────────────────────────────────────────────────┤
│  2018 - Goerli                                              │
│  - 升級至 PoS 共識                                          │
│  - 跨客戶端兼容性測試                                       │
│  - 計劃於 2025 年棄用                                      │
├─────────────────────────────────────────────────────────────┤
│  2022 - Sepolia                                             │
│  - Beacon Chain PoS                                         │
│  - 當前推薦的主要測試網絡                                   │
│  - 活躍維護                                                │
├─────────────────────────────────────────────────────────────┤
│  2023 - Holesky                                             │
│  - 最新測試網絡                                             │
│  - 取代 Goerli 成為長期測試網絡                           │
│  - 適合長期項目和質押測試                                  │
└─────────────────────────────────────────────────────────────┘

1.2 主流測試網絡詳細比較

Sepolia vs Holesky 詳細對比:

┌────────────────┬────────────────────┬────────────────────┐
│     特性       │      Sepolia       │      Holesky       │
├────────────────┼────────────────────┼────────────────────┤
│  發布時間      │     2022年10月    │    2023年9月       │
├────────────────┼────────────────────┼────────────────────┤
│  共識機制      │      PoS           │      PoS           │
├────────────────┼────────────────────┼────────────────────┤
│  驗證者數量    │    ~80,000+        │    ~40,000+        │
├────────────────┼────────────────────┼────────────────────┤
│  區塊時間      │     12秒           │      12秒          │
├────────────────┼────────────────────┼────────────────────┤
│  代幣符號      │      ETH           │      ETH           │
├────────────────┼────────────────────┼────────────────────┤
│  用途定位      │   短期功能測試     │  長期項目測試      │
├────────────────┼────────────────────┼────────────────────┤
│  瀏覽器        │ sepolia.etherscan   │ holesky.etherscan  │
├────────────────┼────────────────────┼────────────────────┤
│  Faucet        │   多個可用         │    多個可用        │
├────────────────┼────────────────────┼────────────────────┤
│  生態支持      │    最完整          │     較少           │
├────────────────┼────────────────────┼────────────────────┤
│  推薦使用      │  DApp開發首選      │  質押/長期項目     │
└────────────────┴────────────────────┴────────────────────┘

1.3 測試網絡選擇建議

根據不同的開發場景,推薦選擇如下:

場景對應的測試網選擇:

1. DApp 快速開發與迭代
   推薦:Sepolia
   理由:生態支持最完整,許多 DeFi 協議已部署測試版本
   
2. 智能合約安全審計
   推薦:Sepolia + Holesky
   理由:需要覆蓋多個網絡環境
   
3. Layer2 項目測試
   推薦:對應 Layer2 的測試網
   例如:Arbitrum Sepolia, Optimism Sepolia
   
4. 質押相關功能測試
   推薦:Holesky
   理由:驗證者集更穩定,適合長期運行
   
5. NFT 項目測試
   推薦:Sepolia
   理由:Opensea 等平台支持較好
   
6. 企業級應用測試
   推薦:Sepolia + 自建測試網
   理由:需要更強的控制能力

二、測試環境配置

2.1 錢包配置

MetaMask 配置 Sepolia 網絡

// MetaMask 自動添加配置
{
  chainId: '0xaa36a7',           // 11155111 的十六進制
  chainName: 'Sepolia Testnet',
  nativeCurrency: {
    name: 'Sepolia Ether',
    symbol: 'ETH',
    decimals: 18
  },
  rpcUrls: [
    'https://rpc.sepolia.org',
    'https://sepolia.infura.io/v3/YOUR_INFURA_PROJECT_ID',
    'https://ethereum-sepolia.publicnode.com'
  ],
  blockExplorerUrls: [
    'https://sepolia.etherscan.io'
  ]
}

手動添加網絡步驟:

步驟 1: 打開 MetaMask 插件
       ↓
步驟 2: 點擊網絡下拉菜單 → 「添加網絡」
       ↓
步驟 3: 點擊「添加網絡手動」
       ↓
步驟 4: 填入上述配置信息
       ↓
步驟 5: 點擊「保存」

批量添加多個測試網絡

// 批量添加測試網絡的 JavaScript 代碼
const testNetworks = [
  {
    chainId: '0xaa36a7',
    chainName: 'Sepolia Testnet',
    nativeCurrency: { name: 'Sepolia Ether', symbol: 'ETH', decimals: 18 },
    rpcUrls: ['https://rpc.sepolia.org'],
    blockExplorerUrls: ['https://sepolia.etherscan.io']
  },
  {
    chainId: '0x4268',
    chainName: 'Holesky Testnet',
    nativeCurrency: { name: 'Holesky Ether', symbol: 'ETH', decimals: 18 },
    rpcUrls: ['https://rpc.holesky.ethdevops.io'],
    blockExplorerUrls: ['https://holesky.etherscan.io']
  }
];

async function addTestNetworks() {
  for (const network of testNetworks) {
    try {
      await ethereum.request({
        method: 'wallet_switchEthereumChain',
        params: [{ chainId: network.chainId }]
      });
    } catch (switchError) {
      try {
        await ethereum.request({
          method: 'wallet_addEthereumChain',
          params: [network]
        });
      } catch (addError) {
        console.error(`Failed to add ${network.chainName}`);
      }
    }
  }
}

2.2 Hardhat 配置測試網絡

// hardhat.config.js
require("@nomicfoundation/hardhat-toolbox");
require("dotenv").config();

/** @type import('hardhat/config').HardhatUserConfig */
module.exports = {
  solidity: {
    version: "0.8.28",
    settings: {
      optimizer: {
        enabled: true,
        runs: 200
      }
    }
  },
  
  networks: {
    // 本地 Hardhat 網絡(默認)
    hardhat: {
      chainId: 31337
    },
    
    // 本地節點
    localhost: {
      url: "http://127.0.0.1:8545"
    },
    
    // Sepolia 測試網絡
    sepolia: {
      url: process.env.SEPOLIA_RPC_URL || "",
      accounts: process.env.PRIVATE_KEY 
        ? [process.env.PRIVATE_KEY] 
        : [],
      chainId: 11155111,
      gasPrice: 20000000000, // 20 Gwei
      timeout: 60000
    },
    
    // Holesky 測試網絡
    holesky: {
      url: process.env.HOLESKY_RPC_URL || "",
      accounts: process.env.PRIVATE_KEY 
        ? [process.env.PRIVATE_KEY] 
        : [],
      chainId: 17000,
      gasPrice: 20000000000
    }
  },
  
  // Etherscan 驗證配置
  etherscan: {
    apiKey: {
      mainnet: process.env.ETHERSCAN_API_KEY,
      sepolia: process.env.ETHERSCAN_API_KEY,
      holesky: process.env.ETHERSCAN_API_KEY
    }
  },
  
  // Gas 報告
  gasReporter: {
    enabled: process.env.REPORT_GAS === "true",
    currency: "USD"
  }
};

2.3 Foundry 配置測試網絡

# foundry.toml

[rpc_endpoints]
sepolia = "${SEPOLIA_RPC_URL}"
holesky = "${HOLESKY_RPC_URL}"

[etherscan]
sepolia = { key = "${ETHERSCAN_API_KEY}" }
holesky = { key = "${ETHERSCAN_API_KEY}" }

[profile.default]
src = "src"
out = "out"
libs = ["lib"]
solc = "0.8.28"

# 測試配置
[fuzz]
runs = 256

[invariant]
runs = 128
depth = 50

2.4 獲取測試 ETH

官方水龍頭

# Sepolia Faucet(需要社交媒體驗證)
# https://faucet.sepolia.org

# 快速獲取測試 ETH
# 方法 1: Alchemy
curl https://faucet.alchemy.com/api/v1/addresses/YOUR_WALLET_ADDRESS \
  -H "Content-Type: application/json" \
  -d '{"network": "sepolia"}'

# 方法 2: QuickNode
curl -X POST https://faucet.quicknode.com/drip/ethereum/sepolia \
  -H "Content-Type: application/json" \
  -d '{"walletAddress": "YOUR_WALLET_ADDRESS"}'

# 方法 3: Infura (需要帳戶)
# 訪問 https://www.infura.io/faucet/sepolia

自動化獲取測試代幣腳本

// getTestTokens.js
const axios = require('axios');
require('dotenv').config();

const WALLET_ADDRESS = process.env.TEST_WALLET;

async function requestTestETH() {
  const faucets = [
    {
      name: 'Alchemy',
      url: 'https://faucet.alchemy.com/api/v1/addresses/' + WALLET_ADDRESS,
      method: 'POST',
      data: { network: 'sepolia' }
    },
    {
      name: 'QuickNode',
      url: 'https://faucet.quicknode.com/drip/ethereum/sepolia',
      method: 'POST',
      data: { walletAddress: WALLET_ADDRESS }
    }
  ];

  for (const faucet of faucets) {
    try {
      console.log(`Trying ${faucet.name}...`);
      const response = await axios({
        method: faucet.method,
        url: faucet.url,
        data: faucet.data,
        headers: { 'Content-Type': 'application/json' }
      });
      console.log(`${faucet.name} success:`, response.data);
      return;
    } catch (error) {
      console.log(`${faucet.name} failed:`, error.message);
    }
  }
}

requestTestETH();

部署自己的測試代幣合約

當水龍頭不可用時,可以部署自己的測試代幣:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";

/**
 * @title TestToken
 * @dev 用於測試的 ERC20 代幣合約
 */
contract TestToken is ERC20 {
    // 每次可領取的數量:1000 代幣
    uint256 public constant CLAIM_AMOUNT = 1000 ether;
    
    // 領取間隔:1 小時
    uint256 public constant CLAIM_INTERVAL = 1 hours;
    
    // 每個地址的上限:10000 代幣
    uint256 public constant MAX_BALANCE = 10000 ether;
    
    mapping(address => uint256) public lastClaimTime;
    
    constructor() ERC20("Test Token", "TEST") {
        // 鑄造總供給給部署者
        _mint(msg.sender, 1000000 ether);
    }
    
    /**
     * @dev 領取測試代幣
     */
    function claim() external {
        require(
            balanceOf(msg.sender) + CLAIM_AMOUNT <= MAX_BALANCE,
            "Max balance exceeded"
        );
        require(
            block.timestamp >= lastClaimTime[msg.sender] + CLAIM_INTERVAL,
            "Claim cooldown not met"
        );
        
        lastClaimTime[msg.sender] = block.timestamp;
        _mint(msg.sender, CLAIM_AMOUNT);
    }
}

三、智能合約開發與部署

3.1 項目初始化

使用 Hardhat 初始化項目:

# 創建項目目錄
mkdir my-dapp-testnet
cd my-dapp-testnet

# 初始化 npm
npm init -y

# 安裝 Hardhat
npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox @openzeppelin/contracts dotenv

# 初始化 Hardhat 項目
npx hardhat init
# 選擇 "Create a JavaScript project"
# 選擇 "No" 不添加 .gitignore

使用 Foundry 初始化項目:

# 使用 Foundry 初始化
forge init my-dapp-foundry
cd my-dapp-foundry

# 安裝 OpenZeppelin
forge install openzeppelin/openzeppelin-contracts

3.2 編寫智能合約

// contracts/MyDapp.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";

/**
 * @title StakingToken
 * @dev 質押代幣合約示例
 */
contract StakingToken is ERC20, Ownable {
    // 事件
    event Staked(address indexed user, uint256 amount);
    event Unstaked(address indexed user, uint256 amount);
    event RewardClaimed(address indexed user, uint256 reward);
    
    // 質押信息
    struct StakeInfo {
        uint256 amount;
        uint256 startTime;
    }
    
    mapping(address => StakeInfo) public stakes;
    uint256 public constant APY = 10; // 10% 年化收益
    uint256 public totalStaked;
    
    constructor() ERC20("Staking Token", "STK") Ownable(msg.sender) {
        // 初始鑄造
        _mint(msg.sender, 1000000 ether);
    }
    
    /**
     * @dev 質押代幣
     */
    function stake(uint256 amount) external {
        require(amount > 0, "Cannot stake 0");
        require(balanceOf(msg.sender) >= amount, "Insufficient balance");
        
        // 領取現有獎勵
        if (stakes[msg.sender].amount > 0) {
            claimReward();
        }
        
        // 轉移代幣到合約
        transfer(address(this), amount);
        
        // 記錄質押信息
        stakes[msg.sender] = StakeInfo({
            amount: stakes[msg.sender].amount + amount,
            startTime: block.timestamp
        });
        
        totalStaked += amount;
        emit Staked(msg.sender, amount);
    }
    
    /**
     * @dev 解除質押
     */
    function unstake(uint256 amount) external {
        require(amount > 0, "Cannot unstake 0");
        require(stakes[msg.sender].amount >= amount, "Insufficient staked");
        
        // 領取獎勵
        claimReward();
        
        // 更新質押信息
        stakes[msg.sender].amount -= amount;
        totalStaked -= amount;
        
        // 轉回代幣
        _transfer(address(this), msg.sender, amount);
        
        emit Unstaked(msg.sender, amount);
    }
    
    /**
     * @dev 領取質押獎勵
     */
    function claimReward() public {
        uint256 stakedAmount = stakes[msg.sender].amount;
        require(stakedAmount > 0, "No staked amount");
        
        uint256 duration = block.timestamp - stakes[msg.sender].startTime;
        uint256 reward = (stakedAmount * APY * duration) / (365 days * 100);
        
        if (reward > 0) {
            _mint(msg.sender, reward);
            stakes[msg.sender].startTime = block.timestamp;
            emit RewardClaimed(msg.sender, reward);
        }
    }
    
    /**
     * @dev 獲取當前獎勵
     */
    function getPendingReward(address user) public view returns (uint256) {
        uint256 stakedAmount = stakes[user].amount;
        if (stakedAmount == 0) return 0;
        
        uint256 duration = block.timestamp - stakes[user].startTime;
        return (stakedAmount * APY * duration) / (365 days * 100);
    }
}

3.3 部署腳本

Hardhat 部署腳本:

// scripts/deploy.js
const { ethers } = require("hardhat");

async function main() {
  console.log("Deploying StakingToken to testnet...");
  
  // 獲取部署帳戶
  const [deployer] = await ethers.getSigners();
  console.log("Deploying with account:", deployer.address);
  console.log("Account balance:", (await deployer.provider.getBalance(deployer.address)).toString());
  
  // 部署合約
  const StakingToken = await ethers.getContractFactory("StakingToken");
  const token = await StakingToken.deploy();
  
  await token.waitForDeployment();
  const tokenAddress = await token.getAddress();
  
  console.log("StakingToken deployed to:", tokenAddress);
  
  // 驗證合約(如果配置了)
  if (process.env.ETHERSCAN_API_KEY) {
    console.log("Verifying contract on Etherscan...");
    try {
      await hre.run("verify:verify", {
        address: tokenAddress,
        constructorArguments: []
      });
      console.log("Contract verified!");
    } catch (error) {
      console.log("Verification failed:", error.message);
    }
  }
  
  // 打印部署信息供後續使用
  console.log("\n=== Deployment Info ===");
  console.log("Network:", hre.network.name);
  console.log("Contract Address:", tokenAddress);
  console.log("=====================\n");
}

main()
  .then(() => process.exit(0))
  .catch((error) => {
    console.error(error);
    process.exit(1);
  });

Foundry 部署腳本:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;

import "forge-std/Script.sol";
import "../src/StakingToken.sol";

contract DeployScript is Script {
    function run() external {
        uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY");
        
        vm.startBroadcast(deployerPrivateKey);
        
        StakingToken token = new StakingToken();
        
        vm.stopBroadcast();
        
        console.log("StakingToken deployed to:", address(token));
    }
}

3.4 執行部署

使用 Hardhat 部署:

# 部署到 Sepolia
npx hardhat run scripts/deploy.js --network sepolia

# 部署到 Holesky
npx hardhat run scripts/deploy.js --network holesky

# 部署到本地 Hardhat 節點
npx hardhat run scripts/deploy.js --network hardhat

# 部署並驗證
npx hardhat run scripts/deploy.js --network sepolia --verify

使用 Foundry 部署:

# 部署到 Sepolia
forge create src/StakingToken.sol:StakingToken \
  --rpc-url sepolia \
  --private-key $PRIVATE_KEY \
  --verify

# 部署並驗證
forge create src/StakingToken.sol:StakingToken \
  --rpc-url sepolia \
  --private-key $PRIVATE_KEY \
  --verify \
  --etherscan-api-key $ETHERSCAN_API_KEY

3.5 部署後驗證

部署完成後,在 Etherscan 上驗證合約:

# Hardhat 自動驗證
npx hardhat verify --network sepolia <CONTRACT_ADDRESS>

# 手動驗證
# 1. 打開 https://sepolia.etherscan.io/verifyContract
# 2. 填入合約地址
# 3. 選擇編譯器版本
# 4. 粘貼源代碼
# 5. 點擊 Verify

四、測試用例設計

4.1 單元測試框架

使用 Hardhat 進行單元測試:

// test/StakingToken.js
const { expect } = require("chai");
const { ethers } = require("hardhat");
const { time } = require("@nomicfoundation/hardhat-network-helpers");

describe("StakingToken", function () {
  let token;
  let owner;
  let user1;
  let user2;
  
  const ONE_YEAR = 365 * 24 * 60 * 60;
  
  beforeEach(async function () {
    [owner, user1, user2] = await ethers.getSigners();
    
    const StakingToken = await ethers.getContractFactory("StakingToken");
    token = await StakingToken.deploy();
    await token.waitForDeployment();
  });
  
  describe("部署", function () {
    it("應該設置正確的名稱和符號", async function () {
      expect(await token.name()).to.equal("Staking Token");
      expect(await token.symbol()).to.equal("STK");
    });
    
    it("應該將初始供給分配給部署者", async function () {
      const ownerBalance = await token.balanceOf(owner.address);
      const totalSupply = await token.totalSupply();
      expect(ownerBalance).to.equal(totalSupply);
    });
  });
  
  describe("質押功能", function () {
    it("應該正確質押代幣", async function () {
      const stakeAmount = ethers.parseEther("100");
      
      // 授權合約
      await token.approve(await token.getAddress(), stakeAmount);
      
      // 質押
      await token.stake(stakeAmount);
      
      // 驗證質押信息
      const stakeInfo = await token.stakes(owner.address);
      expect(stakeInfo.amount).to.equal(stakeAmount);
      expect(await token.totalStaked()).to.equal(stakeAmount);
    });
    
    it("質押餘額不足應該失敗", async function () {
      const largeAmount = ethers.parseEther("1000000000");
      
      await expect(
        token.stake(largeAmount)
      ).to.be.revertedWith("Insufficient balance");
    });
    
    it("質押 0 應該失敗", async function () {
      await expect(
        token.stake(0)
      ).to.be.revertedWith("Cannot stake 0");
    });
  });
  
  describe("解除質押功能", function () {
    beforeEach(async function () {
      // 先質押一些代幣
      const stakeAmount = ethers.parseEther("100");
      await token.approve(await token.getAddress(), stakeAmount);
      await token.stake(stakeAmount);
    });
    
    it("應該正確解除質押", async function () {
      const unstakeAmount = ethers.parseEther("50");
      const balanceBefore = await token.balanceOf(owner.address);
      
      await token.unstake(unstakeAmount);
      
      const stakeInfo = await token.stakes(owner.address);
      expect(stakeInfo.amount).to.equal(ethers.parseEther("50"));
    });
    
    it("解除超過質押額應該失敗", async function () {
      await expect(
        token.unstake(ethers.parseEther("200"))
      ).to.be.revertedWith("Insufficient staked");
    });
  });
  
  describe("獎勵計算", function () {
    beforeEach(async function () {
      // 質押 100 ETH
      const stakeAmount = ethers.parseEther("100");
      await token.approve(await token.getAddress(), stakeAmount);
      await token.stake(stakeAmount);
    });
    
    it("應該正確計算質押獎勵", async function () {
      // 快進 1 年
      await time.increase(ONE_YEAR);
      
      const pendingReward = await token.getPendingReward(owner.address);
      // 10% APY = 10 ETH
      expect(pendingReward).to.be.closeTo(
        ethers.parseEther("10"),
        ethers.parseEther("0.1") // 允許小誤差
      );
    });
    
    it("應該正確領取獎勵", async function () {
      await time.increase(ONE_YEAR);
      
      const balanceBefore = await token.balanceOf(owner.address);
      await token.claimReward();
      const balanceAfter = await token.balanceOf(owner.address);
      
      expect(balanceAfter - balanceBefore).to.be.gt(balanceBefore);
    });
  });
});

4.2 集成測試

// test/StakingIntegration.js
const { expect } = require("chai");
const { ethers } = require("hardhat");

describe("Staking Integration Tests", function () {
  let token;
  let owner;
  let user1;
  let user2;
  let stakingContract;
  
  beforeEach(async function () {
    [owner, user1, user2] = await ethers.getSigners();
    
    // 部署質押代幣
    const Token = await ethers.getContractFactory("StakingToken");
    token = await Token.deploy();
    await token.waitForDeployment();
    
    // 給用戶一些代幣
    await token.transfer(user1.address, ethers.parseEther("1000"));
    await token.transfer(user2.address, ethers.parseEther("1000"));
  });
  
  describe("多用戶質押場景", function () {
    it("多用戶應該能夠同時質押", async function () {
      const amount1 = ethers.parseEther("100");
      const amount2 = ethers.parseEther("200");
      
      // 用戶1 質押
      await token.connect(user1).approve(await token.getAddress(), amount1);
      await token.connect(user1).stake(amount1);
      
      // 用戶2 質押
      await token.connect(user2).approve(await token.getAddress(), amount2);
      await token.connect(user2).stake(amount2);
      
      expect(await token.totalStaked()).to.equal(amount1 + amount2);
    });
    
    it("質押後應該能夠提取並重新質押", async function () {
      const stakeAmount = ethers.parseEther("100");
      
      // 第一次質押
      await token.connect(user1).approve(await token.getAddress(), stakeAmount);
      await token.connect(user1).stake(stakeAmount);
      
      // 解除質押
      await token.connect(user1).unstake(stakeAmount);
      
      // 再次質押
      await token.connect(user1).stake(stakeAmount);
      
      const stakeInfo = await token.stakes(user1.address);
      expect(stakeInfo.amount).to.equal(stakeAmount);
    });
  });
  
  describe("邊界條件測試", function () {
    it("連續質押應該累加", async function () {
      await token.connect(user1).approve(await token.getAddress(), ethers.parseEther("100"));
      await token.connect(user1).stake(ethers.parseEther("50"));
      
      await token.connect(user1).stake(ethers.parseEther("50"));
      
      const stakeInfo = await token.stakes(user1.address);
      expect(stakeInfo.amount).to.equal(ethers.parseEther("100"));
    });
  });
});

4.3 Foundry 測試

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;

import "forge-std/Test.sol";
import "../src/StakingToken.sol";

contract StakingTokenTest is Test {
    StakingToken public token;
    address public owner;
    address public user1;
    address public user2;
    
    uint256 constant INITIAL_BALANCE = 1000 ether;
    uint256 constant STAKE_AMOUNT = 100 ether;
    
    function setUp() public {
        token = new StakingToken();
        owner = address(this);
        user1 = makeAddr("user1");
        user2 = makeAddr("user2");
        
        // 給用戶初始餘額
        token.transfer(user1, INITIAL_BALANCE);
    }
    
    function testDeployment() public {
        assertEq(token.name(), "Staking Token");
        assertEq(token.symbol(), "STK");
        assertEq(token.totalSupply(), 1000000 ether);
    }
    
    function testStake() public {
        vm.prank(user1);
        token.approve(address(token), STAKE_AMOUNT);
        
        vm.prank(user1);
        token.stake(STAKE_AMOUNT);
        
        (uint256 amount, ) = token.stakes(user1);
        assertEq(amount, STAKE_AMOUNT);
    }
    
    function testUnstake() public {
        // 先質押
        vm.prank(user1);
        token.approve(address(token), STAKE_AMOUNT);
        
        vm.prank(user1);
        token.stake(STAKE_AMOUNT);
        
        // 解除質押
        vm.prank(user1);
        token.unstake(STAKE_AMOUNT);
        
        (uint256 amount, ) = token.stakes(user1);
        assertEq(amount, 0);
    }
    
    function testRewardCalculation() public {
        // 質押
        vm.prank(user1);
        token.approve(address(token), STAKE_AMOUNT);
        
        vm.prank(user1);
        token.stake(STAKE_AMOUNT);
        
        // 快進 1 年
        vm.warp(block.timestamp + 365 days);
        
        // 檢查獎勵
        uint256 reward = token.getPendingReward(user1);
        assertGt(reward, 0);
    }
    
    function testFuzzStake(uint256 amount) public {
        amount = bound(amount, 1, INITIAL_BALANCE);
        
        vm.prank(user1);
        token.approve(address(token), amount);
        
        vm.prank(user1);
        token.stake(amount);
        
        (uint256 stakedAmount, ) = token.stakes(user1);
        assertEq(stakedAmount, amount);
    }
}

五、前端集成測試

5.1 Web3.js 配置

// web3.js 配置測試網絡
const Web3 = require('web3');

const TESTNET_CONFIG = {
  sepolia: {
    chainId: '0xaa36a7', // 11155111
    chainName: 'Sepolia Testnet',
    rpcUrls: ['https://rpc.sepolia.org'],
    blockExplorerUrls: ['https://sepolia.etherscan.io'],
    nativeCurrency: {
      name: 'Sepolia Ether',
      symbol: 'ETH',
      decimals: 18
    }
  },
  holesky: {
    chainId: '0x4268', // 17000
    chainName: 'Holesky Testnet',
    rpcUrls: ['https://rpc.holesky.ethdevops.io'],
    blockExplorerUrls: ['https://holesky.etherscan.io'],
    nativeCurrency: {
      name: 'Holesky Ether',
      symbol: 'ETH',
      decimals: 18
    }
  }
};

class Web3Provider {
  constructor(network = 'sepolia') {
    this.config = TESTNET_CONFIG[network];
    this.web3 = new Web3(window.ethereum);
  }
  
  async switchNetwork() {
    const chainId = this.config.chainId;
    
    try {
      await window.ethereum.request({
        method: 'wallet_switchEthereumChain',
        params: [{ chainId }]
      });
    } catch (switchError) {
      // 網絡未添加,添加它
      if (switchError.code === 4902) {
        await window.ethereum.request({
          method: 'wallet_addEthereumChain',
          params: [this.config]
        });
      }
    }
  }
  
  getContract(abi, address) {
    return new this.web3.eth.Contract(abi, address);
  }
}

export default Web3Provider;

5.2 Ethers.js 配置

// ethers.js 配置
import { ethers } from 'ethers';

const TESTNET_RPC = {
  sepolia: process.env.REACT_APP_SEPOLIA_RPC || 'https://rpc.sepolia.org',
  holesky: process.env.REACT_APP_HOLESKY_RPC || 'https://rpc.holesky.ethdevops.io'
};

export const getProvider = (network = 'sepolia') => {
  return new ethers.JsonRpcProvider(TESTNET_RPC[network]);
};

export const getSigner = async () => {
  if (!window.ethereum) {
    throw new Error('MetaMask not installed');
  }
  
  const provider = new ethers.BrowserProvider(window.ethereum);
  const signer = await provider.getSigner();
  
  // 確保連接到正確的測試網絡
  const network = await provider.getNetwork();
  const expectedChainId = network === 'sepolia' ? 11155111 : 17000;
  
  if (network.chainId !== BigInt(expectedChainId)) {
    await window.ethereum.request({
      method: 'wallet_switchEthereumChain',
      params: [{ chainId: '0xaa36a7' }]
    });
  }
  
  return signer;
};

export const getContract = (address, abi, signerOrProvider) => {
  return new ethers.Contract(address, abi, signerOrProvider);
};

5.3 完整的前端交互示例

// 使用 React 和 Ethers.js 的完整示例
import { useState, useEffect } from 'react';
import { ethers } from 'ethers';

const STAKING_ABI = [
  "function stake(uint256 amount) external",
  "function unstake(uint256 amount) external",
  "function claimReward() external",
  "function getPendingReward(address user) external view returns (uint256)",
  "function stakes(address user) external view returns (uint256 amount, uint256 startTime)",
  "function balanceOf(address account) external view returns (uint256)",
  "function approve(address spender, uint256 amount) external returns (bool)"
];

function StakingInterface({ contractAddress }) {
  const [signer, setSigner] = useState(null);
  const [contract, setContract] = useState(null);
  const [balance, setBalance] = useState('0');
  const [stakedAmount, setStakedAmount] = useState('0');
  const [pendingReward, setPendingReward] = useState('0');
  const [inputAmount, setInputAmount] = useState('');
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState('');
  
  // 初始化
  useEffect(() => {
    const init = async () => {
      if (typeof window.ethereum !== 'undefined') {
        try {
          const provider = new ethers.BrowserProvider(window.ethereum);
          const signer = await provider.getSigner();
          const stakingContract = new ethers.Contract(
            contractAddress,
            STAKING_ABI,
            signer
          );
          
          setSigner(signer);
          setContract(stakingContract);
          
          // 獲取餘額
          const address = await signer.getAddress();
          const bal = await stakingContract.balanceOf(address);
          setBalance(ethers.formatEther(bal));
          
          // 獲取質押信息
          const stakeInfo = await stakingContract.stakes(address);
          setStakedAmount(ethers.formatEther(stakeInfo.amount));
          
          // 獲取獎勵
          const reward = await stakingContract.getPendingReward(address);
          setPendingReward(ethers.formatEther(reward));
        } catch (err) {
          console.error('Initialization error:', err);
          setError(err.message);
        }
      }
    };
    
    init();
  }, [contractAddress]);
  
  // 質押函數
  const handleStake = async () => {
    if (!contract || !inputAmount) return;
    
    setLoading(true);
    setError('');
    
    try {
      const amount = ethers.parseEther(inputAmount);
      
      // 首先批准
      const approveTx = await contract.approve(contractAddress, amount);
      await approveTx.wait();
      
      // 質押
      const stakeTx = await contract.stake(amount);
      await stakeTx.wait();
      
      // 刷新數據
      const address = await signer.getAddress();
      const stakeInfo = await contract.stakes(address);
      setStakedAmount(ethers.formatEther(stakeInfo.amount));
      
      setInputAmount('');
    } catch (err) {
      console.error('Stake error:', err);
      setError(err.message);
    } finally {
      setLoading(false);
    }
  };
  
  return (
    <div className="staking-interface">
      <h2>質押界面(測試網)</h2>
      
      <div className="info-panel">
        <p>餘額: {balance} STK</p>
        <p>已質押: {stakedAmount} STK</p>
        <p>待領取獎勵: {pendingReward} STK</p>
      </div>
      
      {error && <div className="error">{error}</div>}
      
      <div className="actions">
        <input
          type="text"
          value={inputAmount}
          onChange={(e) => setInputAmount(e.target.value)}
          placeholder="輸入質押數量"
        />
        <button onClick={handleStake} disabled={loading}>
          {loading ? '處理中...' : '質押'}
        </button>
      </div>
    </div>
  );
}

export default StakingInterface;

六、測試最佳實踐

6.1 測試覆蓋率要求

測試覆蓋率建議:

┌─────────────────────────────────────────────────────────────┐
│  合約類型              │  最低覆蓋率  │  推薦覆蓋率        │
├─────────────────────────────────────────────────────────────┤
│  金融協議              │    95%      │     100%           │
│  (DeFi, 借貸)         │             │                    │
├─────────────────────────────────────────────────────────────┤
│  NFT 合約              │    90%      │     95%            │
├─────────────────────────────────────────────────────────────┤
│  工具類合約            │    85%      │     90%            │
├─────────────────────────────────────────────────────────────┤
│  實驗性合約            │    80%      │     85%            │
└─────────────────────────────────────────────────────────────┘

關注覆蓋的函數:
- 所有公開函數
- 修飾符邏輯
- 邊界條件
- 錯誤處理路徑

6.2 測試矩陣

// 測試矩陣配置
const testMatrix = {
  networks: ['hardhat', 'sepolia', 'holesky'],
  walletTypes: ['metaMask', 'walletConnect'],
  browserTypes: ['chrome', 'firefox', 'safari'],
  
  scenarios: [
    { name: 'stake', amounts: ['0', '1', '1000', 'MAX'] },
    { name: 'unstake', amounts: ['0', '1', 'partial', 'all'] },
    { name: 'claimReward', intervals: ['0', '1day', '1year'] }
  ]
};

6.3 CI/CD 集成

# .github/workflows/test.yml
name: Testnet Tests

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      
      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '20'
          cache: 'npm'
      
      - name: Install dependencies
        run: npm ci
        
      - name: Run Hardhat tests
        run: npx hardhat test
        
      - name: Run coverage
        run: npx hardhat coverage
        
      - name: Deploy to Sepolia
        if: github.ref == 'refs/heads/main'
        run: npx hardhat run scripts/deploy.js --network sepolia
        env:
          SEPOLIA_RPC_URL: ${{ secrets.SEPOLIA_RPC_URL }}
          PRIVATE_KEY: ${{ secrets.PRIVATE_KEY }}
          
      - name: Verify on Etherscan
        if: github.ref == 'refs/heads/main'
        run: npx hardhat verify --network sepolia ${{ secrets.CONTRACT_ADDRESS }}
        env:
          ETHERSCAN_API_KEY: ${{ secrets.ETHERSCAN_API_KEY }}

6.4 測試網絡監控

// 監控測試網絡狀態
const NETWORK_STATUS = {
  sepolia: {
    rpc: 'https://rpc.sepolia.org',
    blockTime: 12,
    expectedBlockTime: 12,
    explorers: ['https://sepolia.etherscan.io']
  },
  holesky: {
    rpc: 'https://rpc.holesky.ethdevops.io',
    blockTime: 12,
    expectedBlockTime: 12,
    explorers: ['https://holesky.etherscan.io']
  }
};

async function checkNetworkHealth(network) {
  const config = NETWORK_STATUS[network];
  const provider = new ethers.JsonRpcProvider(config.rpc);
  
  try {
    const blockNumber = await provider.getBlockNumber();
    const block = await provider.getBlock(blockNumber);
    const currentTime = Math.floor(Date.now() / 1000);
    
    const timeSinceBlock = currentTime - block.timestamp;
    const isHealthy = timeSinceBlock < 60; // 1分鐘內
    
    return {
      network,
      blockNumber,
      lastBlockTime: block.timestamp,
      timeSinceBlock,
      isHealthy,
      explorer: config.explorers[0]
    };
  } catch (error) {
    return {
      network,
      isHealthy: false,
      error: error.message
    };
  }
}

// 定期檢查
setInterval(async () => {
  for (const network of Object.keys(NETWORK_STATUS)) {
    const status = await checkNetworkHealth(network);
    console.log(`${network}:`, status);
  }
}, 60000); // 每分鐘檢查一次

結論

測試網絡是以太坊智能合約開發流程中不可或缺的組成部分。通過本文的詳細指南,開發者應該能夠:

  1. 根據項目需求選擇合適的測試網絡
  2. 正確配置開發環境和錢包
  3. 編寫全面的測試用例
  4. 部署合約到測試網絡並進行驗證
  5. 集成前端並進行完整的交互測試
  6. 建立自動化的 CI/CD 流程

測試不應該是一次性的活動,而是應該貫穿整個開發週期。隨著項目複雜度的增加,測試用例和覆蓋率也應該相應提升。在將合約部署到主網之前,確保在測試網絡上進行了充分的功能測試、安全審計和壓力測試。


參考資源

  1. Sepolia Faucet - https://faucet.sepolia.org
  2. Holesky Faucet - https://holesky.dev
  3. Hardhat Documentation - https://hardhat.org/docs
  4. Foundry Documentation - https://book.getfoundry.sh
  5. Ethers.js Documentation - https://docs.ethers.org
  6. OpenZeppelin Contracts - https://openzeppelin.com/contracts/

延伸閱讀與來源

這篇文章對您有幫助嗎?

評論

發表評論

注意:由於這是靜態網站,您的評論將儲存在本地瀏覽器中,不會公開顯示。

目前尚無評論,成為第一個發表評論的人吧!