Waffle测试框架实战:从入门到精通的智能合约测试指南

Waffle智能合约测试框架界面,展示Solidity测试用例执行与断言验证

引言

智能合约测试是区块链开发中最关键的环节之一。由于合约一旦部署就无法修改,任何bug都可能导致不可逆的损失。Waffle是专为Solidity设计的轻量级测试框架,以其简洁的API和优秀的TypeScript支持,成为许多开发者的首选测试工具。

本文将系统性地介绍Waffle框架的使用方法,从基础配置到高级技巧,帮助你建立完善的合约测试体系。

Waffle测试框架核心功能流程图,涵盖合约部署、测试断言、Fixture状态管理与Mock模拟

一、Waffle基础入门

1.1 Waffle简介与特点

Waffle是一个基于 ethers.js 的智能合约测试框架,它的设计理念是简洁、可扩展、强类型。与Hardhat和Foundry相比,Waffle更轻量,学习曲线更平缓,特别适合中小型项目。

bash

# 创建项目
mkdir my-project && cd my-project
npm init -y

# 安装依赖
npm install --save-dev waffle chai ethers @types/chai @types/mocha ts-node typescript
npm install solc @ethereum-waffle

# 初始化TypeScript配置
npx tsc --init

1.2 项目配置

json

// tsconfig.json
{
  "compilerOptions": {
    "target": "es2020",
    "module": "commonjs",
    "strict": true,
    "esModuleInterop": true,
    "resolveJsonModule": true,
    "outDir": "./dist",
    "rootDir": "./src",
    "types": ["mocha", "chai"]
  },
  "include": ["src/**/*"],
  "files": ["waffle.json"]
}

json

// waffle.json
{
  "compilerVersion": "0.8.24",
  "sourceDirectory": "./src",
  "outputDirectory": "./build",
  "nodeModulesDirectory": "./node_modules",
  "flattenOutputDirectory": "./build/flattened"
}

1.3 首个测试文件

solidity

// src/SimpleStorage.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

contract SimpleStorage {
    uint256 private value;
    address public owner;
    
    event ValueChanged(uint256 newValue, address changedBy);
    
    constructor() {
        owner = msg.sender;
    }
    
    function setValue(uint256 _value) external {
        value = _value;
        emit ValueChanged(_value, msg.sender);
    }
    
    function getValue() external view returns (uint256) {
        return value;
    }
}

typescript

// test/SimpleStorage.test.ts
import { expect, use } from 'chai';
import { waffle, loadFixture } from 'ethereum-waffle';
import { BigNumber } from 'ethers';
import { ethers } from 'ethers';

import SimpleStorageArtifact from '../build/SimpleStorage.json';
import { SimpleStorage } from '../typechain/SimpleStorage';

use(waffle);

describe('SimpleStorage', () => {
  // 使用fixture管理测试状态
  async function fixture([wallet, otherWallet]: any[]) {
    const contract = await waffle.deployContract(wallet, SimpleStorageArtifact);
    return { contract, wallet, otherWallet };
  }

  let contract: SimpleStorage;
  let wallet: any;
  let otherWallet: any;

  beforeEach(async () => {
    const fixtures = await loadFixture(fixture);
    contract = fixtures.contract;
    wallet = fixtures.wallet;
    otherWallet = fixtures.otherWallet;
  });

  describe('setValue', () => {
    it('should set value correctly', async () => {
      const newValue = 42;
      await contract.setValue(newValue);
      
      expect(await contract.getValue()).to.eq(newValue);
    });

    it('should emit ValueChanged event', async () => {
      const newValue = 100;
      
      await expect(contract.setValue(newValue))
        .to.emit(contract, 'ValueChanged')
        .withArgs(newValue, wallet.address);
    });

    it('should allow anyone to set value', async () => {
      const newValue = 999;
      
      // 从其他钱包调用
      await contract.connect(otherWallet).setValue(newValue);
      
      expect(await contract.getValue()).to.eq(newValue);
    });
  });

  describe('getValue', () => {
    it('should return initial value of 0', async () => {
      expect(await contract.getValue()).to.eq(0);
    });
  });

  describe('owner', () => {
    it('should set correct owner', async () => {
      expect(await contract.owner()).to.eq(wallet.address);
    });
  });
});

二、Waffle核心API详解

2.1 合约部署

typescript

// 基础部署
const contract = await waffle.deployContract(wallet, ContractArtifact);

// 带构造函数参数
const contractWithArgs = await waffle.deployContract(
  wallet, 
  ContractWithArgsArtifact,
  ['arg1', 100] // 构造函数参数
);

// 带自定义选项
const contractWithOptions = await waffle.deployContract(
  wallet,
  ContractArtifact,
  [], // 无构造函数参数
  { 
    value: ethers.utils.parseEther('1'), // 发送ETH
    gasLimit: 5000000 // 自定义Gas限制
  }
);

// 在指定地址部署(用于升级测试)
const predictedAddress = await wallet.getAddress();
await waffle.deployContract(wallet, ContractArtifact, [], {
  salt: ethers.utils.keccak256(ethers.utils.toUtf8Bytes('my-salt'))
});

2.2 Chai断言增强

Waffle扩展了Chai断言库,提供专门针对智能合约的断言:

typescript

import { expect, use } from 'chai';
import { waffle, solidity } from 'ethereum-waffle';

use(solidity); // 启用Solidity特定的断言

describe('Waffle Assertions', () => {
  let token: Token;
  
  beforeEach(async () => {
    token = await deployToken();
  });

  // 交易断言
  describe('Transaction Assertions', () => {
    it('should emit event', async () => {
      await expect(token.transfer(recipient, 100))
        .to.emit(token, 'Transfer')
        .withArgs(wallet.address, recipient, 100);
    });

    it('should revert with reason', async () => {
      await expect(
        token.transfer(recipient, 1000000) // 超过余额
      ).to.be.revertedWith('Insufficient balance');
    });

    it('should revert without reason', async () => {
      await expect(
        token.someFunctionThatReverts()
      ).to.be.reverted;
    });

    it('should revert with custom error', async () => {
      await expect(
        token.someFunction()
      ).to.be.revertedWithCustomError(token, 'Unauthorized');
    });

    it('should emit value', async () => {
      const vault = await deployVault();
      
      await expect(
        wallet.sendTransaction({ 
          to: vault.address, 
          value: ethers.utils.parseEther('1') 
        })
      ).to.emit(vault, 'Deposit').withArgs(wallet.address, ethers.utils.parseEther('1'));
    });
  });

  // 状态断言
  describe('State Assertions', () => {
    it('should change balance correctly', async () => {
      const initialBalance = await token.balanceOf(wallet.address);
      
      await token.burn(100);
      
      expect(await token.balanceOf(wallet.address))
        .to.eq(initialBalance.sub(100));
    });

    it('should support big numbers', async () => {
      const largeNumber = BigNumber.from('999999999999999999999999999');
      
      await token.mint(wallet.address, largeNumber);
      
      expect(await token.balanceOf(wallet.address))
        .to.eq(largeNumber);
    });

    // 接近相等(用于浮点数处理)
    it('should assert approximately equal', async () => {
      const expected = ethers.utils.parseEther('1.001');
      const actual = ethers.utils.parseEther('1.002');
      
      expect(actual).to.be.closeTo(expected, 1000); // 误差在1000 wei以内
    });
  });

  // 地址断言
  describe('Address Assertions', () => {
    it('should have correct address', async () => {
      expect(await token.name()).to.be.properAddress;
    });

    it('should emit from correct address', async () => {
      await expect(token.transfer(recipient, 100))
        .to.emit(token, 'Transfer')
        .withArgs(wallet.address, recipient, 100);
    });
  });
});

2.3 Mock与Stub

typescript

import { mockContract, stubContract } from 'ethereum-waffle';

// 创建Mock合约
describe('Mock Contracts', () => {
  it('should create a mock contract', async () => {
    const mock = await mockContract(wallet, ExternalContractArtifact);
    
    // 设置Mock行为
    mock.getValue.returns(42);
    
    // 在测试中使用
    const caller = await deployCaller(mock.address);
    expect(await caller.getExternalValue()).to.eq(42);
  });

  it('should mock with different values', async () => {
    const mock = await mockContract(wallet, ExternalContractArtifact);
    
    // 连续调用返回不同值
    mock.getValue.onFirstCall().returns(1);
    mock.getValue.onSecondCall().returns(2);
    mock.getValue.returns(3); // 默认返回
    
    expect(await mock.getValue()).to.eq(1);
    expect(await mock.getValue()).to.eq(2);
    expect(await mock.getValue()).to.eq(3);
  });

  it('should verify mock was called', async () => {
    const mock = await mockContract(wallet, ExternalContractArtifact);
    
    await mock.getValue();
    
    // 验证调用次数
    expect(mock.getValue).to.have.been.calledOnce;
    expect(mock.getValue).to.have.been.calledBefore(someOtherCall);
  });
});

// 创建Stub合约
describe('Stub Contracts', () => {
  it('should create stub with predefined behavior', async () => {
    const stub = await stubContract(wallet, ExternalContractArtifact, {
      getValue: 42,
      getName: 'Test'
    });
    
    expect(await stub.getValue()).to.eq(42);
    expect(await stub.getName()).to.eq('Test');
  });
});

三、Fixture与测试隔离

3.1 Fixture基础

typescript

import { loadFixture } from 'ethereum-waffle';

// 定义可重用的测试fixture
async function tokenFixture([wallet, user]: any[]) {
  const token = await waffle.deployContract(wallet, TokenArtifact);
  const mintAmount = ethers.utils.parseEther('1000');
  await token.mint(wallet.address, mintAmount);
  
  return { token, wallet, user, mintAmount };
}

describe('Token', () => {
  let token: Token;
  let wallet: any;
  let user: any;
  let mintAmount: BigNumber;

  // 每个测试前重置状态
  beforeEach(async () => {
    ({ token, wallet, user, mintAmount } = await loadFixture(tokenFixture));
  });

  it('should have correct initial supply', async () => {
    expect(await token.totalSupply()).to.eq(mintAmount);
  });
});

3.2 复杂Fixture场景

typescript

// 完整的DeFi测试fixture
async function defiFixture([deployer, user1, user2, liquidityProvider]: any[]) {
  // 部署Token
  const tokenA = await waffle.deployContract(deployer, TokenArtifact, ['TokenA', 'TKA']);
  const tokenB = await waffle.deployContract(deployer, TokenArtifact, ['TokenB', 'TKB']);
  
  // 部署AMM合约
  const amm = await waffle.deployContract(
    deployer, 
    AMMArtifact, 
    [tokenA.address, tokenB.address]
  );
  
  // 设置初始流动性
  const amountA = ethers.utils.parseEther('1000');
  const amountB = ethers.utils.parseEther('1000');
  
  await tokenA.mint(liquidityProvider.address, amountA);
  await tokenB.mint(liquidityProvider.address, amountB);
  
  await tokenA.connect(liquidityProvider).approve(amm.address, amountA);
  await tokenB.connect(liquidityProvider).approve(amm.address, amountB);
  
  await amm.connect(liquidityProvider).addLiquidity(amountA, amountB);
  
  // 给用户分发测试代币
  const userAmount = ethers.utils.parseEther('100');
  await tokenA.mint(user1.address, userAmount);
  await tokenA.mint(user2.address, userAmount);
  
  await tokenA.connect(user1).approve(amm.address, userAmount);
  await tokenA.connect(user2).approve(amm.address, userAmount);
  
  return {
    tokenA,
    tokenB,
    amm,
    deployer,
    user1,
    user2,
    liquidityProvider
  };
}

describe('AMM Integration', () => {
  let tokenA: Token;
  let tokenB: Token;
  let amm: AMM;
  let user1: any;
  let user2: any;
  
  beforeEach(async () => {
    const fixtures = await loadFixture(defiFixture);
    ({ tokenA, tokenB, amm, user1, user2 } = fixtures);
  });
  
  it('should swap tokens correctly', async () => {
    const swapAmount = ethers.utils.parseEther('10');
    const expectedOutput = await amm.getOutputAmount(tokenA.address, swapAmount);
    
    await expect(() => 
      amm.connect(user1).swap(tokenA.address, swapAmount)
    ).to.changeTokenBalances(
      tokenA,
      [user1, amm],
      [swapAmount.mul(-1), swapAmount]
    );
  });
});

四、高级测试技巧

4.1 时间操控

typescript

import { mine, time } from 'ethereum-waffle';

describe('Time-dependent Tests', () => {
  let staking: Staking;
  let user: any;
  
  beforeEach(async () => {
    staking = await deployStaking();
    user = getUser();
  });

  it('should release tokens after lock period', async () => {
    const stakeAmount = ethers.utils.parseEther('100');
    const lockDuration = 7 * 24 * 60 * 60; // 7天
    
    await staking.stake(stakeAmount, lockDuration);
    
    // 快进7天
    await time.increase(lockDuration);
    await mine(); // 挖出一个区块使时间变化生效
    
    const initialBalance = await ethers.provider.getBalance(user.address);
    
    await staking.withdraw();
    
    const finalBalance = await ethers.provider.getBalance(user.address);
    expect(finalBalance.sub(initialBalance)).to.eq(stakeAmount);
  });

  it('should not allow withdrawal before lock period', async () => {
    await staking.stake(ethers.utils.parseEther('100'), 7 * 24 * 60 * 60);
    
    await time.increase(7 * 24 * 60 * 60 - 1); // 差1秒
    
    await expect(staking.withdraw()).to.be.revertedWith('Lock period not ended');
  });

  it('should handle multiple time jumps', async () => {
    const lockDuration = 30 * 24 * 60 * 60;
    
    await staking.stake(ethers.utils.parseEther('100'), lockDuration);
    
    // 分阶段测试
    await time.increase(10 * 24 * 60 * 60);
    await expect(staking.withdraw()).to.be.revertedWith('Lock period not ended');
    
    await time.increase(20 * 24 * 60 * 60);
    await expect(staking.withdraw()).to.be.revertedWith('Lock period not ended');
    
    await time.increase(1 * 24 * 60 * 60); // 总共31天
    await mine();
    
    // 现在应该可以提现
    await expect(staking.withdraw()).to.not.be.reverted;
  });
});

4.2 Gas报告分析

typescript

import { getBalance } from 'ethers';

describe('Gas Optimization', () => {
  let contract: GasHeavyContract;
  
  beforeEach(async () => {
    contract = await deployGasHeavyContract();
  });

  it('should track gas usage', async () => {
    const tx = await contract.someFunction();
    const receipt = await tx.wait();
    
    console.log('Gas used:', receipt.gasUsed.toString());
    
    // 验证Gas使用在预期范围内
    expect(receipt.gasUsed).to.be.lt(100000);
  });

  it('should compare gas between implementations', async () => {
    const optimized = await deployOptimizedContract();
    const original = await deployOriginalContract();
    
    const optimizedTx = await optimized.process(100);
    const optimizedReceipt = await optimizedTx.wait();
    
    const originalTx = await original.process(100);
    const originalReceipt = await originalTx.wait();
    
    const savings = originalReceipt.gasUsed
      .sub(optimizedReceipt.gasUsed)
      .mul(100)
      .div(originalReceipt.gasUsed);
    
    console.log(`Gas savings: ${savings.toString()}%`);
    
    expect(savings).to.be.gt(20); // 至少节省20%
  });
});

4.3 事件深度测试

typescript

describe('Event Testing', () => {
  let token: Token;
  
  beforeEach(async () => {
    token = await deployToken();
  });

  it('should capture all event arguments', async () => {
    const tx = await token.mint(user.address, 1000);
    const receipt = await tx.wait();
    
    // 从receipt解析事件
    const transferEvent = receipt.events?.find(e => e.event === 'Transfer');
    
    expect(transferEvent?.args?.from).to.eq(ethers.constants.AddressZero);
    expect(transferEvent?.args?.to).to.eq(user.address);
    expect(transferEvent?.args?.value).to.eq(1000);
  });

  it('should verify event ordering', async () => {
    const tx = await token.complexOperation(params);
    const receipt = await tx.wait();
    
    const events = receipt.events || [];
    expect(events[0].event).to.eq('Approval');
    expect(events[1].event).to.eq('Transfer');
    expect(events[2].event).to.eq('ComplexOperationComplete');
  });

  it('should handle anonymous events', async () => {
    // 对于匿名事件,需要使用topic过滤
    const tx = await token.anonymousEventEmittingFunction();
    const receipt = await tx.wait();
    
    // 获取特定topic的事件
    const anonymousTopic = ethers.utils.keccak256(
      ethers.utils.toUtf8Bytes('AnonymousEvent(uint256)')
    );
    
    const logs = receipt.logs?.filter(log => 
      log.topics[0] === anonymousTopic
    );
    
    expect(logs?.length).to.eq(1);
  });
});

4.4 多签名合约测试

typescript

describe('MultiSig Wallet', () => {
  const REQUIRED_CONFIRMATIONS = 2;
  let multiSig: MultiSig;
  let owners: any[];
  
  beforeEach(async () => {
    // 部署3个钱包
    owners = await ethers.getSigners();
    
    multiSig = await waffle.deployContract(
      owners[0], 
      MultiSigArtifact,
      [
        owners.map(o => o.address),
        REQUIRED_CONFIRMATIONS
      ]
    );
  });

  it('should require multiple confirmations', async () => {
    const destination = recipient.address;
    const value = ethers.utils.parseEther('1');
    const data = '0x';
    
    // 提交交易
    await multiSig.submitTransaction(destination, value, data);
    
    // 第一个owner确认
    await multiSig.connect(owners[0]).confirm(0);
    
    // 此时不应该执行(只确认1次,需要2次)
    await expect(
      multiSig.execute(0)
    ).to.be.revertedWith('Not enough confirmations');
    
    // 第二个owner确认
    await multiSig.connect(owners[1]).confirm(0);
    
    // 现在可以执行
    await expect(multiSig.execute(0)).to.not.be.reverted;
  });

  it('should not allow non-owner to confirm', async () => {
    await multiSig.submitTransaction(recipient.address, 0, '0x');
    
    const nonOwner = await generateRandomWallet();
    
    await expect(
      multiSig.connect(nonOwner).confirm(0)
    ).to.be.revertedWith('Not an owner');
  });

  it('should revoke confirmation', async () => {
    await multiSig.submitTransaction(recipient.address, 0, '0x');
    
    await multiSig.connect(owners[0]).confirm(0);
    await multiSig.connect(owners[0]).revoke(0);
    
    await expect(multiSig.execute(0))
      .to.be.revertedWith('Not enough confirmations');
  });
});

五、完整测试示例

5.1 ERC20代币完整测试套件

solidity

// src/MyToken.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

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

contract MyToken is ERC20 {
    uint256 public constant MAX_SUPPLY = 1000000 * 10**18;
    uint256 public mintingDeadline;
    address public minter;
    
    mapping(address => bool) public minters;
    
    event MinterAdded(address indexed account);
    event MinterRemoved(address indexed account);
    
    constructor(
        string memory name,
        string memory symbol,
        uint256 _mintingDeadline
    ) ERC20(name, symbol) {
        minter = msg.sender;
        mintingDeadline = _mintingDeadline;
    }
    
    modifier onlyMinter() {
        require(minters[msg.sender] || msg.sender == minter, "Not authorized");
        _;
    }
    
    function mint(address to, uint256 amount) external onlyMinter {
        require(totalSupply() + amount <= MAX_SUPPLY, "Exceeds max supply");
        _mint(to, amount);
    }
    
    function burn(uint256 amount) external {
        _burn(msg.sender, amount);
    }
    
    function addMinter(address account) external {
        require(msg.sender == minter, "Not authorized");
        minters[account] = true;
        emit MinterAdded(account);
    }
    
    function removeMinter(address account) external {
        require(msg.sender == minter, "Not authorized");
        minters[account] = false;
        emit MinterRemoved(account);
    }
    
    function transferAfterDeadline(address to, uint256 amount) external {
        require(block.timestamp > mintingDeadline, "Minting still active");
        _transfer(msg.sender, to, amount);
    }
}

typescript

// test/MyToken.test.ts
import { expect, use } from 'chai';
import { waffle, loadFixture } from 'ethereum-waffle';
import { ethers } from 'ethers';
import { MyToken } from '../typechain/MyToken';
import MyTokenArtifact from '../build/MyToken.json';

use(waffle);

describe('MyToken', () => {
  const MINTING_DEADLINE = 1700000000;
  
  async function tokenFixture([wallet, user, otherUser]: any[]) {
    const token = await waffle.deployContract(
      wallet, 
      MyTokenArtifact,
      ['MyToken', 'MTK', MINTING_DEADLINE]
    );
    
    return { token, wallet, user, otherUser };
  }
  
  let token: MyToken;
  let wallet: any;
  let user: any;
  let otherUser: any;
  
  beforeEach(async () => {
    ({ token, wallet, user, otherUser } = await loadFixture(tokenFixture));
  });
  
  describe('Deployment', () => {
    it('should set correct name and symbol', async () => {
      expect(await token.name()).to.eq('MyToken');
      expect(await token.symbol()).to.eq('MTK');
    });
    
    it('should set deployer as minter', async () => {
      expect(await token.minter()).to.eq(wallet.address);
    });
    
    it('should set correct minting deadline', async () => {
      expect(await token.mintingDeadline()).to.eq(MINTING_DEADLINE);
    });
    
    it('should have zero total supply', async () => {
      expect(await token.totalSupply()).to.eq(0);
    });
  });
  
  describe('Minting', () => {
    it('should allow minter to mint', async () => {
      const amount = ethers.utils.parseEther('100');
      
      await expect(() => 
        token.connect(wallet).mint(user.address, amount)
      ).to.changeTokenBalance(token, user, amount);
      
      expect(await token.totalSupply()).to.eq(amount);
    });
    
    it('should not allow minting beyond max supply', async () => {
      const amount = ethers.utils.parseEther('1000001'); // 超过MAX_SUPPLY
      
      await expect(
        token.connect(wallet).mint(user.address, amount)
      ).to.be.revertedWith('Exceeds max supply');
    });
    
    it('should not allow non-minter to mint', async () => {
      await expect(
        token.connect(user).mint(otherUser.address, 100)
      ).to.be.revertedWith('Not authorized');
    });
    
    it('should allow added minter to mint', async () => {
      await token.connect(wallet).addMinter(user.address);
      
      expect(await token.minters(user.address)).to.be.true;
      
      const amount = ethers.utils.parseEther('50');
      await token.connect(user).mint(otherUser.address, amount);
      
      expect(await token.balanceOf(otherUser.address)).to.eq(amount);
    });
  });
  
  describe('Burning', () => {
    beforeEach(async () => {
      await token.connect(wallet).mint(wallet.address, ethers.utils.parseEther('1000'));
    });
    
    it('should allow holder to burn', async () => {
      const amount = ethers.utils.parseEther('100');
      
      await expect(() => 
        token.burn(amount)
      ).to.changeTokenBalance(token, wallet, amount.mul(-1));
      
      expect(await token.totalSupply()).to.eq(ethers.utils.parseEther('900'));
    });
    
    it('should not allow burning more than balance', async () => {
      await expect(
        token.burn(ethers.utils.parseEther('1001'))
      ).to.be.revertedWith('ERC20: burn amount exceeds balance');
    });
  });
  
  describe('Transfers', () => {
    beforeEach(async () => {
      await token.connect(wallet).mint(wallet.address, ethers.utils.parseEther('1000'));
    });
    
    it('should transfer tokens', async () => {
      const amount = ethers.utils.parseEther('100');
      
      await expect(() =>
        token.transfer(user.address, amount)
      ).to.changeTokenBalances(
        token,
        [wallet, user],
        [amount.mul(-1), amount]
      );
    });
    
    it('should not transfer more than balance', async () => {
      await expect(
        token.transfer(user.address, ethers.utils.parseEther('1001'))
      ).to.be.revertedWith('ERC20: transfer amount exceeds balance');
    });
    
    it('should emit Transfer event', async () => {
      const amount = ethers.utils.parseEther('100');
      
      await expect(token.transfer(user.address, amount))
        .to.emit(token, 'Transfer')
        .withArgs(wallet.address, user.address, amount);
    });
  });
  
  describe('TransferAfterDeadline', () => {
    beforeEach(async () => {
      await token.connect(wallet).mint(wallet.address, ethers.utils.parseEther('1000'));
    });
    
    it('should allow transfer after deadline', async () => {
      // 快进到截止日期之后
      await ethers.provider.send('evm_increaseTime', [MINTING_DEADLINE + 1]);
      await ethers.provider.send('evm_mine', []);
      
      const amount = ethers.utils.parseEther('100');
      await token.transferAfterDeadline(user.address, amount);
      
      expect(await token.balanceOf(user.address)).to.eq(amount);
    });
    
    it('should not allow transfer before deadline', async () => {
      await expect(
        token.transferAfterDeadline(user.address, 100)
      ).to.be.revertedWith('Minting still active');
    });
  });
});

5.2 集成测试示例

typescript

// test/StakingIntegration.test.ts
describe('Staking Integration', () => {
  async function stakingFixture([owner, staker, rewardRecipient]: any[]) {
    // 部署测试代币
    const stakingToken = await waffle.deployContract(
      owner, 
      TestTokenArtifact,
      ['Staking Token', 'STK']
    );
    
    // 部署奖励代币
    const rewardToken = await waffle.deployContract(
      owner,
      TestTokenArtifact,
      ['Reward Token', 'RWD']
    );
    
    // 部署质押合约
    const staking = await waffle.deployContract(
      owner,
      StakingArtifact,
      [
        stakingToken.address,
        rewardToken.address,
        rewardRecipient.address,
        ethers.utils.parseEther('1'), // 1 token per second reward rate
        0
      ]
    );
    
    // 分发质押代币给staker
    const stakerAmount = ethers.utils.parseEther('1000');
    await stakingToken.mint(staker.address, stakerAmount);
    await stakingToken.connect(staker).approve(staking.address, stakerAmount);
    
    // 分发奖励代币给staking合约
    const rewardAmount = ethers.utils.parseEther('10000');
    await rewardToken.mint(owner.address, rewardAmount);
    await rewardToken.connect(owner).transfer(staking.address, rewardAmount);
    
    return { stakingToken, rewardToken, staking, owner, staker, rewardRecipient };
  }
  
  let staking: Staking;
  let stakingToken: TestToken;
  let rewardToken: TestToken;
  let staker: any;
  let owner: any;
  
  beforeEach(async () => {
    const fixtures = await loadFixture(stakingFixture);
    ({ stakingToken, rewardToken, staking, owner, staker } = fixtures);
  });
  
  describe('Staking', () => {
    it('should accept stakes', async () => {
      const stakeAmount = ethers.utils.parseEther('100');
      
      await expect(() =>
        staking.connect(staker).stake(stakeAmount)
      ).to.changeTokenBalance(stakingToken, staker, stakeAmount.mul(-1));
      
      expect(await staking.balanceOf(staker.address)).to.eq(stakeAmount);
    });
    
    it('should track time-weighted stakes', async () => {
      const stakeAmount = ethers.utils.parseEther('100');
      
      await staking.connect(staker).stake(stakeAmount);
      
      // 快进1小时
      await time.increase(3600);
      await mine();
      
      // 验证奖励计算
      const earned = await staking.earned(staker.address);
      expect(earned).to.eq(ethers.utils.parseEther('3600')); // 1 token/s * 3600s
    });
    
    it('should allow multiple stakes', async () => {
      await staking.connect(staker).stake(ethers.utils.parseEther('50'));
      await time.increase(100);
      await mine();
      await staking.connect(staker).stake(ethers.utils.parseEther('50'));
      
      expect(await staking.balanceOf(staker.address))
        .to.eq(ethers.utils.parseEther('100'));
    });
  });
  
  describe('Rewards', () => {
    beforeEach(async () => {
      await staking.connect(staker).stake(ethers.utils.parseEther('100'));
    });
    
    it('should calculate correct rewards', async () => {
      await time.increase(3600);
      await mine();
      
      const earned = await staking.earned(staker.address);
      expect(earned).to.eq(ethers.utils.parseEther('3600'));
    });
    
    it('should allow claim rewards', async () => {
      await time.increase(3600);
      await mine();
      
      const initialBalance = await rewardToken.balanceOf(staker.address);
      
      await staking.connect(staker).getReward();
      
      const finalBalance = await rewardToken.balanceOf(staker.address);
      expect(finalBalance.sub(initialBalance)).to.eq(ethers.utils.parseEther('3600'));
    });
    
    it('should compound on restake', async () => {
      await time.increase(3600);
      await mine();
      
      const earned = await staking.earned(staker.address);
      await staking.connect(staker).claimReward();
      
      // 继续质押
      await time.increase(3600);
      await mine();
      
      const newEarned = await staking.earned(staker.address);
      expect(newEarned).to.eq(ethers.utils.parseEther('3600')); // 重新计时
    });
  });
  
  describe('Withdrawal', () => {
    it('should return staked tokens', async () => {
      const stakeAmount = ethers.utils.parseEther('100');
      await staking.connect(staker).stake(stakeAmount);
      
      await time.increase(100);
      await mine();
      
      await expect(() =>
        staking.connect(staker).withdraw(stakeAmount)
      ).to.changeTokenBalance(stakingToken, staker, stakeAmount);
      
      expect(await staking.balanceOf(staker.address)).to.eq(0);
    });
    
    it('should include pending rewards in withdrawal', async () => {
      const stakeAmount = ethers.utils.parseEther('100');
      await staking.connect(staker).stake(stakeAmount);
      
      await time.increase(3600);
      await mine();
      
      const earned = await staking.earned(staker.address);
      
      await staking.connect(staker).withdraw(stakeAmount);
      
      // 应该同时收到本金和奖励
      expect(await rewardToken.balanceOf(staker.address))
        .to.eq(ethers.utils.parseEther('3600'));
    });
  });
});

总结

Waffle作为Solidity生态中的轻量级测试框架,提供了简洁而强大的测试能力:

功能Waffle特性优势
部署deployContract简洁的合约部署API
断言Chai扩展专为Solidity优化的断言
FixtureloadFixture高效的状态管理
MockmockContract便捷的外部依赖模拟
时间time, mine精确的时间操控
类型TypeScript原生完整的类型安全

核心最佳实践

  1. 每个测试独立 – 使用Fixture确保测试间无依赖
  2. 全面覆盖 – 覆盖正常路径、边界条件、错误处理
  3. 事件验证 – 不仅验证状态变化,还要验证事件
  4. Gas监控 – 关注Gas使用,及时发现退化
  5. 集成测试 – 验证合约间的交互正确性

掌握Waffle的使用,将帮助你构建更加可靠、安全的智能合约。

相关推荐

评论

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注