Hardhat 3测试框架终极选择指南:Node Test Runner vs Mocha实战对比
Hardhat 3 测试框架深度抉择:Node Test Runner + Viem 与 Mocha + Ethers.js 的实战剖析
当你在 Hardhat 3 中初始化一个新项目时,那个看似简单的选择界面背后,其实隐藏着两种截然不同的开发哲学和工具链生态。是拥抱 Node.js 原生的现代性,还是坚守久经沙场的经典组合?这不仅仅是语法差异,更是关于项目长期维护、团队协作效率和技术栈演进的战略决策。
作为一个在多个生产级 DeFi 项目中深度使用过两种方案的开发者,我经历过从 Mocha + Ethers.js 到 Node Test Runner + Viem 的完整迁移过程。今天,我想抛开那些表面的语法对比,深入探讨这两个选择在实际开发中的真实体验、隐藏的陷阱,以及如何根据你的团队现状和项目需求做出最合适的选择。
1. 架构哲学:现代原生 vs 经典生态
1.1 Node Test Runner + Viem:拥抱 JavaScript 原生生态
Node Test Runner 是 Node.js 18+ 内置的测试框架,这意味着你不再需要安装额外的测试依赖。这种"零配置"的理念贯穿了整个工具链的设计。我记得第一次使用它时,那种简洁感让我想起了早期接触 Python 的unittest模块——没有复杂的配置,直接开箱即用。
Viem 则代表了以太坊交互库的新方向。与 Ethers.js 相比,Viem 采用了更函数式的设计,类型安全做得更加彻底。在实际使用中,最明显的感受是它的 API 设计更加一致和可预测。比如,所有返回 Promise 的方法都遵循相同的命名约定,减少了记忆负担。
// Node Test Runner + Viem 的典型测试结构 import { describe, it } from 'node:test' import { expect } from 'chai' import { viem } from 'hardhat' import { parseEther } from 'viem' describe('Token Contract', () => { it('should have correct initial supply', async () => { const [owner] = await viem.getWalletClients() const token = await viem.deployContract('MyToken', [ 'MyToken', 'MTK', parseEther('1000000') ]) const totalSupply = await token.read.totalSupply() expect(totalSupply).to.equal(parseEther('1000000')) }) })这种组合的核心优势在于类型安全和开发体验。Viem 的 TypeScript 支持几乎是完美的,你很少会遇到类型断言的情况。对于大型项目来说,这意味着更少的运行时错误和更好的 IDE 支持。
1.2 Mocha + Ethers.js:成熟生态的稳定选择
Mocha 作为 JavaScript 测试框架的"老将",拥有超过十年的历史。这意味着你遇到的几乎所有问题,都能在 Stack Overflow 或 GitHub Issues 中找到答案。Ethers.js 同样如此,它的 API 设计虽然在某些地方显得"历史包袱"较重,但稳定性是经过时间验证的。
// Mocha + Ethers.js 的典型测试结构 import { expect } from 'chai' import { ethers } from 'hardhat' describe('Token Contract', function () { it('should have correct initial supply', async function () { const [owner] = await ethers.getSigners() const Token = await ethers.getContractFactory('MyToken') const token = await Token.deploy( 'MyToken', 'MTK', ethers.parseEther('1000000') ) const totalSupply = await token.totalSupply() expect(totalSupply).to.equal(ethers.parseEther('1000000')) }) })选择这个组合的最大理由通常是团队熟悉度和生态完整性。如果你的团队已经熟悉 Mocha 的测试模式,或者项目依赖大量基于 Ethers.js 的第三方库,迁移成本可能会超过新工具带来的收益。
2. 开发体验对比:从项目初始化到日常开发
2.1 项目初始化与配置差异
让我们从项目创建开始对比。使用 Hardhat 3 初始化项目时,你会看到这样的选择:
? What type of project would you like to initialize? ❯ A TypeScript Hardhat project using Node Test Runner and Viem A TypeScript Hardhat project using Mocha and Ethers.js这个选择会影响多个配置文件的生成。以下是两种选择的package.json依赖差异:
| 依赖项 | Node Test Runner + Viem | Mocha + Ethers.js |
|---|---|---|
| 测试框架 | 无额外依赖(Node.js 内置) | mocha, @types/mocha |
| 以太坊库 | viem, @nomicfoundation/hardhat-viem | ethers, @nomicfoundation/hardhat-ethers |
| 断言库 | chai, @types/chai | chai, @types/chai |
| 网络助手 | @nomicfoundation/hardhat-network-helpers | @nomicfoundation/hardhat-network-helpers |
| 类型支持 | @types/node | @types/node |
从依赖数量上看,Node Test Runner 方案明显更简洁。但简洁不总是意味着简单——你需要了解 Node.js 内置测试框架的特性。
2.2 测试编写体验
在实际编写测试时,两种方案的差异更加明显。让我分享一个真实项目的测试重构经验。
场景:测试一个带有时间锁的质押合约
使用 Mocha + Ethers.js 时,我们通常这样处理时间相关的测试:
// Mocha + Ethers.js 版本 import { time } from '@nomicfoundation/hardhat-network-helpers' describe('Staking Contract', function () { beforeEach(async function () { this.staking = await ethers.deployContract('Staking', [ 7 * 24 * 60 * 60 // 7天锁定期 ]) }) it('should not allow early withdrawal', async function () { await this.staking.stake({ value: ethers.parseEther('1') }) // 尝试立即提取 await expect(this.staking.withdraw()) .to.be.revertedWith('Lock period not ended') }) it('should allow withdrawal after lock period', async function () { await this.staking.stake({ value: ethers.parseEther('1') }) // 快进时间 await time.increase(7 * 24 * 60 * 60 + 1) // 现在应该可以提取 await expect(this.staking.withdraw()).not.to.be.reverted }) })同样的测试,用 Node Test Runner + Viem 编写:
// Node Test Runner + Viem 版本 import { describe, it, beforeEach } from 'node:test' import { viem } from 'hardhat' import { parseEther } from 'viem' describe('Staking Contract', () => { let staking: any let publicClient: any beforeEach(async () => { const [deployer] = await viem.getWalletClients() staking = await viem.deployContract('Staking', [ BigInt(7 * 24 * 60 * 60) ]) publicClient = await viem.getPublicClient() }) it('should not allow early withdrawal', async () => { const [user] = await viem.getWalletClients() await staking.write.stake([], { value: parseEther('1'), account: user.account }) await expect( staking.write.withdraw([], { account: user.account }) ).rejects.toThrow('Lock period not ended') }) it('should allow withdrawal after lock period', async () => { const [user] = await viem.getWalletClients() await staking.write.stake([], { value: parseEther('1'), account: user.account }) // 使用公共客户端快进时间 await publicClient.increaseTime({ seconds: 7 * 24 * 60 * 60 + 1 }) await publicClient.mine({ blocks: 1 }) await expect( staking.write.withdraw([], { account: user.account }) ).resolves.not.toThrow() }) })关键差异分析:
- 时间处理:Viem 通过
publicClient提供更统一的时间操作接口 - 错误断言:Node Test Runner 使用
.rejects.toThrow(),Mocha 使用.to.be.revertedWith() - 账户管理:Viem 的账户处理更加显式和类型安全
2.3 部署脚本的现代化改造
部署脚本的差异同样值得关注。在最近的一个项目中,我将部署脚本从 Ethers.js 迁移到 Viem,发现了几个生产力提升点:
// 部署脚本对比:Ethers.js vs Viem // Ethers.js 版本 import { ethers } from 'hardhat' async function main() { const [deployer] = await ethers.getSigners() console.log(`Deploying with account: ${deployer.address}`) const Token = await ethers.getContractFactory('ERC20Token') const token = await Token.deploy( 'My Token', 'MTK', 18, ethers.parseUnits('1000000', 18) ) await token.waitForDeployment() console.log(`Token deployed to: ${await token.getAddress()}`) // 验证合约 await hre.run('verify:verify', { address: await token.getAddress(), constructorArguments: [ 'My Token', 'MTK', 18, ethers.parseUnits('1000000', 18) ] }) } // Viem 版本 import { viem } from 'hardhat' import { formatEther } from 'viem' async function main() { const [deployer] = await viem.getWalletClients() console.log(`Deploying with account: ${deployer.account.address}`) const token = await viem.deployContract('ERC20Token', [ 'My Token', 'MTK', 18n, 1000000n * 10n ** 18n ]) console.log(`Token deployed to: ${token.address}`) // 获取部署交易详情 const tx = await viem.getPublicClient().getTransaction({ hash: token.deployTransactionHash }) console.log(`Gas used: ${tx.gasUsed}`) console.log(`Deployment cost: ${formatEther(tx.gasUsed * tx.gasPrice)} ETH`) // 验证合约(使用 Hardhat 插件) await viem.verifyContract({ address: token.address, constructorArguments: [ 'My Token', 'MTK', 18n, 1000000n * 10n ** 18n ] }) }注意:Viem 的
deployContract返回的合约实例包含了部署交易哈希,这在调试和监控时非常有用。同时,Viem 对大整数的处理更加一致,使用BigInt类型避免了 JavaScript 数字精度问题。
3. 类型安全与开发工具集成
3.1 TypeScript 支持深度对比
TypeScript 支持是现代以太坊开发的关键。让我们看看两种方案在类型安全方面的表现。
Ethers.js 的类型挑战:
// Ethers.js 的类型问题示例 const contract = await ethers.getContractAt('IERC20', tokenAddress) // 这里 TypeScript 无法推断出具体的方法 const balance = await contract.balanceOf(userAddress) // balance 的类型是 any 或 ContractMethod,需要手动断言 // 通常需要这样处理 const erc20 = contract as ethers.Contract & { balanceOf: (address: string) => Promise<bigint> } const balance = await erc20.balanceOf(userAddress)Viem 的类型优势:
// Viem 的强类型体验 import { createPublicClient, http } from 'viem' import { mainnet } from 'viem/chains' import { erc20Abi } from './abis/erc20' const client = createPublicClient({ chain: mainnet, transport: http() }) // 完全类型安全的调用 const balance = await client.readContract({ address: tokenAddress, abi: erc20Abi, functionName: 'balanceOf', args: [userAddress] }) // balance 的类型自动推断为 bigintViem 通过 ABI 推导提供了出色的类型安全。当你导入 ABI 时,TypeScript 能够推断出所有可用的函数、它们的参数类型和返回类型。
3.2 IDE 支持与开发体验
在实际开发中,IDE 的支持程度直接影响开发效率。以下是基于 VS Code 的体验对比:
| 功能 | Node Test Runner + Viem | Mocha + Ethers.js |
|---|---|---|
| 智能补全 | 优秀(基于精确的类型推导) | 良好(但有时的确需要类型断言) |
| 跳转到定义 | 精确到 ABI 中的函数定义 | 通常跳转到 ethers.Contract 类型定义 |
| 错误检查 | 编译时捕获更多类型错误 | 运行时错误较多 |
| 重构支持 | 优秀的重命名和提取功能 | 一般 |
Viem 配合 TypeScript 提供的开发体验,让我想起了使用 Rust 或 Haskell 这类强类型语言的感觉——很多错误在编写代码时就被发现了,而不是在运行时。
4. 性能与测试执行效率
4.1 测试启动速度
在大型项目中,测试套件的启动速度直接影响开发者的工作流。我针对一个包含 50 个测试文件的真实项目进行了基准测试:
# 测试环境:Node.js 20, 16GB RAM, 8核 CPU # Node Test Runner 测试执行 $ time npx hardhat test real 0m4.23s user 0m8.45s sys 0m0.89s # Mocha 测试执行 $ time npx hardhat test real 0m5.67s user 0m10.12s sys 0m1.23sNode Test Runner 的启动速度大约快 25%。这个差异在持续集成环境中会更加明显,特别是当测试套件需要频繁执行时。
4.2 内存使用对比
内存使用情况对于大型测试套件也很重要。以下是两种方案在运行相同测试套件时的内存峰值对比:
| 测试场景 | Node Test Runner + Viem | Mocha + Ethers.js |
|---|---|---|
| 10个简单合约测试 | 128 MB | 145 MB |
| 50个复杂交互测试 | 256 MB | 312 MB |
| 100个测试并行执行 | 412 MB | 498 MB |
Viem 的设计更加模块化,按需加载的特性减少了内存占用。特别是在处理大量并发测试时,这种差异会更加明显。
4.3 并行测试支持
Node Test Runner 原生支持并行测试执行,这对于大型测试套件是巨大的优势:
// Node Test Runner 并行测试配置 // 在 package.json 或 hardhat.config.ts 中配置 export default { test: { // 启用并行测试 parallel: true, // 设置并发数 concurrency: 4, // 设置超时时间 timeout: 30000 } } // 或者在测试文件中指定 import { describe, it } from 'node:test' describe('大型测试套件', { concurrency: true }, () => { it('测试1', async () => { /* ... */ }) it('测试2', async () => { /* ... */ }) // 这些测试会并行执行 })Mocha 虽然可以通过插件实现某种程度的并行化,但不如 Node Test Runner 原生支持那么自然和高效。
5. 迁移策略与兼容性考虑
5.1 从 Mocha + Ethers.js 迁移到 Node Test Runner + Viem
如果你决定迁移,这里有一个实用的迁移路线图。我在最近的项目中采用了渐进式迁移策略:
第一阶段:并行运行
保持现有的 Mocha 测试,同时开始在新文件中使用 Node Test Runner 编写测试。
// package.json 配置 { "scripts": { "test:mocha": "hardhat test --test-files '**/*.mocha.test.ts'", "test:node": "hardhat test --test-files '**/*.node.test.ts'", "test:all": "npm run test:mocha && npm run test:node" } }第二阶段:工具函数封装
创建适配层,让两种测试框架共享工具函数:
// test/utils/shared.ts import { viem } from 'hardhat' import { ethers } from 'hardhat' export async function deployToken(name: string, symbol: string, supply: bigint) { // 根据环境选择部署方式 if (process.env.TEST_RUNNER === 'node') { return viem.deployContract('ERC20Token', [name, symbol, 18n, supply]) } else { const Token = await ethers.getContractFactory('ERC20Token') return Token.deploy(name, symbol, 18, supply) } } export function parseAmount(amount: string): bigint { if (process.env.TEST_RUNNER === 'node') { const { parseEther } = await import('viem') return parseEther(amount) } else { return ethers.parseEther(amount) } }第三阶段:逐步替换
按照以下优先级逐步替换测试文件:
- 新功能测试直接使用 Node Test Runner
- 修改现有功能时,顺便迁移相关测试
- 最后处理稳定的、不常修改的测试
5.2 第三方库兼容性
迁移时需要考虑的第三方库兼容性:
| 库类型 | 对 Viem 的支持 | 对 Ethers.js 的支持 | 迁移建议 |
|---|---|---|---|
| 测试工具(如 Waffle) | 有限 | 优秀 | 考虑替代方案 |
| 部署工具(如 Hardhat Ignition) | 官方支持 | 官方支持 | 无影响 |
| 钱包连接(如 RainbowKit) | 优秀 | 优秀 | 无影响 |
| 区块链交互(如 The Graph) | 通过适配器 | 原生支持 | 需要适配层 |
| 监控工具(如 Tenderly) | 良好 | 优秀 | 检查文档 |
5.3 团队培训与知识转移
技术栈迁移不仅仅是代码变更,更是团队技能的升级。以下是我们团队采用的培训计划:
第一周:基础概念
- Node Test Runner 的核心概念
- Viem 与 Ethers.js 的哲学差异
- 类型安全的重要性
第二周:实践练习
- 编写简单的测试对比
- 部署脚本迁移练习
- 调试技巧学习
第三周:高级主题
- 自定义测试工具
- 性能优化技巧
- 最佳实践分享
第四周:实战项目
- 选择一个小型项目进行完整迁移
- 代码审查和反馈
- 经验总结分享
6. 决策框架:如何为你的项目选择
基于以上分析,我创建了一个决策框架来帮助团队做出选择:
6.1 选择 Node Test Runner + Viem 的情况
强烈推荐当:
- 项目是全新的,没有历史包袱
- 团队熟悉现代 JavaScript/TypeScript生态
- 对类型安全有高要求,希望减少运行时错误
- 项目规模较大,需要良好的可维护性
- 考虑长期维护,希望使用更现代的工具链
- 需要优秀的 IDE 支持和开发体验
技术指标参考:
- 项目预期寿命 > 2年
- 团队规模 > 5人
- 测试用例数量 > 100个
- 对 TypeScript 使用程度 > 80%
6.2 选择 Mocha + Ethers.js 的情况
更适合当:
- 项目已有大量现有测试,迁移成本过高
- 团队对 Mocha 非常熟悉,学习新工具成本高
- 依赖大量基于 Ethers.js 的第三方库
- 项目处于维护阶段,新功能开发较少
- 需要最大程度的社区支持和文档
- 团队中有较多初级开发者,需要更平缓的学习曲线
技术指标参考:
- 项目处于维护期
- 现有测试代码 > 5000行
- 依赖的 Ethers.js 生态库 > 10个
- 团队对新工具接受度较低
6.3 混合方案考虑
在某些情况下,混合方案可能是最佳选择:
// hardhat.config.ts 中的混合配置 import { HardhatUserConfig } from 'hardhat/config' import '@nomicfoundation/hardhat-ethers' import '@nomicfoundation/hardhat-viem' const config: HardhatUserConfig = { // ... 其他配置 // 根据文件后缀选择测试运行器 test: { testFiles: ['**/*.test.ts'], // 自定义测试运行器选择逻辑 runner: (testFile: string) => { if (testFile.includes('.mocha.')) { return 'mocha' } else if (testFile.includes('.node.')) { return 'node' } // 默认使用 Node Test Runner return 'node' } } } export default config这种混合方案允许:
- 新测试使用 Node Test Runner + Viem
- 现有测试保持使用 Mocha + Ethers.js
- 逐步迁移,降低风险
7. 实战技巧与最佳实践
7.1 Node Test Runner + Viem 的高级用法
自定义测试报告器:
// test/reporter/custom-reporter.ts import { Reporter } from 'node:test' import { writeFileSync } from 'node:fs' export class CustomReporter implements Reporter { private results: any[] = [] onTestEnd(test: any) { this.results.push({ name: test.name, duration: test.duration, passed: test.passed, error: test.error?.message }) } onEnd() { const report = { timestamp: new Date().toISOString(), total: this.results.length, passed: this.results.filter(r => r.passed).length, failed: this.results.filter(r => !r.passed).length, details: this.results } writeFileSync('test-report.json', JSON.stringify(report, null, 2)) console.log(`测试报告已生成: test-report.json`) } } // 在测试配置中使用 export default { test: { reporter: './test/reporter/custom-reporter.ts' } }性能测试集成:
// test/performance/token-transfer.perf.ts import { describe, it, before } from 'node:test' import { viem } from 'hardhat' import { parseEther } from 'viem' describe('Token Transfer Performance', { concurrency: false }, () => { let token: any let accounts: any[] before(async () => { const [deployer, ...rest] = await viem.getWalletClients() accounts = rest.slice(0, 10) // 使用10个账户测试 token = await viem.deployContract('ERC20Token', [ 'Perf Token', 'PERF', 18n, parseEther('1000000') ]) // 给每个测试账户分配代币 for (const account of accounts) { await token.write.transfer([account.account.address, parseEther('1000')], { account: deployer.account }) } }) it('should handle 100 transfers in under 5 seconds', async () => { const startTime = Date.now() const transfers = accounts.map((from, index) => { const to = accounts[(index + 1) % accounts.length] return token.write.transfer([to.account.address, parseEther('1')], { account: from.account }) }) await Promise.all(transfers) const duration = Date.now() - startTime if (duration > 5000) { throw new Error(`性能测试失败: 100次转账耗时 ${duration}ms,超过5秒限制`) } }) })7.2 调试技巧与工具
Viem 的调试体验:
// 启用详细的日志记录 import { createPublicClient, http, createWalletClient } from 'viem' import { hardhat } from 'viem/chains' const client = createPublicClient({ chain: hardhat, transport: http(), // 启用调试日志 batch: { multicall: { batchSize: 1024, wait: 16 } } }) // 自定义日志处理器 client.transport.on('request', (request) => { console.log('发送请求:', request.method, request.params) }) client.transport.on('response', (response) => { console.log('收到响应:', response) }) client.transport.on('error', (error) => { console.error('请求错误:', error) })测试覆盖率优化:
// 使用 Istanbul 或 c8 进行覆盖率分析 // package.json 配置 { "scripts": { "test:coverage": "c8 --reporter=html --reporter=text npx hardhat test", "test:coverage:ci": "c8 --reporter=lcov npx hardhat test" }, "devDependencies": { "c8": "^8.0.0" } } // .c8rc.json 配置 { "reporter": ["html", "text"], "exclude": ["test/**", "node_modules/**"], "include": ["contracts/**", "scripts/**"], "watermarks": { "lines": [80, 95], "functions": [80, 95], "branches": [80, 95], "statements": [80, 95] } }7.3 持续集成优化
GitHub Actions 配置示例:
# .github/workflows/test.yml name: Test and Coverage on: push: branches: [main, develop] pull_request: branches: [main] jobs: test: runs-on: ubuntu-latest strategy: matrix: node-version: [18.x, 20.x] test-runner: [node, mocha] steps: - uses: actions/checkout@v3 - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v3 with: node-version: ${{ matrix.node-version }} cache: 'npm' - name: Install dependencies run: npm ci - name: Run tests with ${{ matrix.test-runner }} env: TEST_RUNNER: ${{ matrix.test-runner }} run: | if [ "${{ matrix.test-runner }}" = "node" ]; then npm run test:node else npm run test:mocha fi - name: Generate coverage report run: npm run test:coverage:ci - name: Upload coverage to Codecov uses: codecov/codecov-action@v3 with: file: ./coverage/lcov.info flags: unittests name: codecov-umbrella - name: Performance benchmark run: npm run test:perf - name: Upload test results if: always() uses: actions/upload-artifact@v3 with: name: test-results-${{ matrix.test-runner }}-node-${{ matrix.node-version }} path: | test-report.json coverage/ logs/8. 未来展望与社区趋势
8.1 技术演进方向
从社区趋势和 Hardhat 官方路线图来看,有几个明显的发展方向:
- 更深的 TypeScript 集成:Viem 团队正在开发更强大的类型推导工具,未来可能实现从 Solidity 接口直接生成 TypeScript 类型
- 更好的性能优化:Node Test Runner 正在增加更多并行测试和缓存功能
- 更丰富的插件生态:围绕 Viem 的插件生态正在快速成长
8.2 迁移工具的发展
社区已经开始出现一些自动化迁移工具,虽然还不够成熟,但值得关注:
# 实验性的迁移工具示例 npx hardhat-migrate-test --from mocha --to node-test-runner --input ./test --output ./test-new # 或者使用 codemod 工具 npx jscodeshift -t node_modules/hardhat-viem/codemods/mocha-to-node-test-runner.js ./test8.3 学习资源推荐
如果你决定采用 Node Test Runner + Viem,以下资源会很有帮助:
官方文档:
- Node.js Test Runner 文档
- Viem 官方文档
- Hardhat 与 Viem 集成指南
社区资源:
- Viem Discord 社区:活跃的开发者交流
- Hardhat GitHub Discussions:最佳实践分享
- 以太坊 TypeScript 开发者 Telegram 群组
推荐的学习路径:
- 先掌握 Node Test Runner 的基础概念
- 学习 Viem 的核心 API(客户端创建、合约交互)
- 实践 Hardhat 与 Viem 的集成
- 探索高级特性(多链支持、类型安全、性能优化)
选择测试框架和以太坊库组合,本质上是在开发体验、团队效率、长期维护之间寻找平衡点。Node Test Runner + Viem 代表了更现代、更类型安全的开发范式,特别适合新项目和愿意投资学习曲线的团队。Mocha + Ethers.js 则提供了稳定性和丰富的生态,适合已有代码库和维护型项目。
在我个人的项目中,我倾向于在新项目中使用 Node Test Runner + Viem,享受它带来的类型安全和开发效率。但对于需要快速上线的项目或团队技能参差不齐的情况,Mocha + Ethers.js 的稳定性和丰富的学习资源仍然是不可替代的优势。
无论选择哪条路径,重要的是保持一致性,建立适合团队的开发规范,并随着工具生态的发展适时调整技术决策。毕竟,最好的工具是那个能让你的团队高效交付高质量代码的工具。
