跳转至主要内容
Change page

测试智能合约

上次修改时间: @weiyuzhi2022(opens in a new tab), 2024年2月20日

公共区块链(如以太坊)是不可变的,这使得在部署后修改智能合约代码变得很困难。 虽然存在用于执行“虚拟升级”的合约升级模式,但这些模式很难实现,并且需要社会共识。 此外,升级只能修复发现的错误 — 如果攻击者先发现了漏洞,你的智能合约就面临被利用的风险。

因此,在将智能合约部署到主网之前进行测试是确保安全性的最低要求。 有许多用于测试合约和评估代码正确性的技术,你可以根据需求进行选择。 然而,由不同工具和方法组成的测试套件很适合捕捉合约代码中的细微或重大安全缺陷。

前提条件

本页面将解释如何在部署到以太坊网络之前进行智能合约测试。 我们假设你熟悉智能合约

什么是智能合约测试?

智能合约测试是验证智能合约代码是否按预期工作的过程。 测试对于检查特定智能合约是否满足可靠性、可用性和安全性的要求非常有用。

虽然具体的方法可能各不相同,但大多数测试方法都要求使用合约要处理的少量样本数据执行智能合约。 如果合约样本数据能产生正确的结果,就可以认为合约能正常运行。 大多数测试工具提供了编写和执行测试用例(opens in a new tab)的资源,用于检查合约的执行是否与预期结果相符。

为什么测试智能合约很重要?

由于智能合约通常管理高价值的金融资产,因此即使是很小的编程错误也往往会导致用户遭受巨大的损失(opens in a new tab)。 但是严格的测试可以帮助你在部署到主网之前,及早发现智能合约代码中的缺陷和问题,并进行修复。

尽管发现错误后可以对合约进行升级,但升级很复杂,而且如果处理不当可能会导致错误(opens in a new tab)。 进一步升级合约会削弱不可变性原则,并给用户增加额外的信任假设。 相反,对合约进行全面测试的计划可以减轻智能合约的安全风险,并减少在部署后执行复杂逻辑升级的需求。

测试智能合约的方法

以太坊智能合约的测试方法可以分为两大类:自动化测试手动测试。 自动化测试和手动测试各有独特的优点和权衡,但你可以将二者结合起来,创建强大的测试计划来分析你的合约。

自动化测试

自动化测试使用工具来自动检查智能合约代码的执行错误。 自动化测试的好处在于使用脚本(opens in a new tab)来指导对合约功能的评估。 脚本化测试可以按计划重复运行,人工干预极少,因此自动化测试比手动测试更高效。

自动化测试特别适用于以下情况:测试重复且耗时;手动执行困难时;容易出现人为错误时;或涉及评估关键合约功能时。 但是自动化测试工具可能存在缺陷 — 它们可能会忽略某些错误并产生一些误报(opens in a new tab)。 因此,理想的方法是结合自动化测试与手动测试。

手动测试

手动测试需要人工辅助,在分析智能合约的正确性时,涉及逐个执行测试套件中的每个测试用例。 这与自动化测试不同,在自动化测试中,你可以同时在合约上运行多个独立的测试,并获得显示所有失败和通过的测试的报告。

手动测试可以由单个人员按照包含不同测试场景的书面测试计划进行。 你还可以在指定的时间段内,让多个个人或团体与智能合约进行交互,作为手动测试的一部分。 测试人员将对比合约的实际行为与预期行为,将任何差异标记为错误。

高效的手动测试需要大量的资源(技能、时间、金钱和精力),由于人为错误的存在,在执行测试时可能会错过某些错误。 但手动测试也有好处,例如,人工测试人员(例如审计员)可以凭直觉来检测自动化测试工具可能忽略的边缘情况。

智能合约的自动化测试

单元测试

单元测试对合约功能分别进行评估,并检查每个组件是否正常工作。 良好的单元测试应该简单、运行快速,并且在测试失败时清晰地说明出了什么问题。

单元测试对于检查函数返回预期值以及在函数执行后正确更新合约存储非常有用。 此外,在更改了合约代码库后运行单元测试,可以确保添加新逻辑不会引入错误。 以下是运行有效单元测试的一些准则:

智能合约单元测试的准则

1. 理解你的合约业务逻辑和工作流程

在编写单元测试之前,了解智能合约提供的功能以及用户如何访问和使用这些函数很有帮助。 这对于运行 happy path 测试(opens in a new tab)特别有用,该测试用于确定合约中的函数是否对有效的用户输入返回正确的输出。 我们将使用这个(简化版)的拍卖合约(opens in a new tab)示例来解释此概念。

1constructor(
2 uint biddingTime,
3 address payable beneficiaryAddress
4 ) {
5 beneficiary = beneficiaryAddress;
6 auctionEndTime = block.timestamp + biddingTime;
7 }
8
9function bid() external payable {
10
11 if (block.timestamp > auctionEndTime)
12 revert AuctionAlreadyEnded();
13
14 if (msg.value <= highestBid)
15 revert BidNotHighEnough(highestBid);
16
17 if (highestBid != 0) {
18 pendingReturns[highestBidder] += highestBid;
19 }
20 highestBidder = msg.sender;
21 highestBid = msg.value;
22 emit HighestBidIncreased(msg.sender, msg.value);
23 }
24
25 function withdraw() external returns (bool) {
26 uint amount = pendingReturns[msg.sender];
27 if (amount > 0) {
28 pendingReturns[msg.sender] = 0;
29
30 if (!payable(msg.sender).send(amount)) {
31 pendingReturns[msg.sender] = amount;
32 return false;
33 }
34 }
35 return true;
36 }
37
38function auctionEnd() external {
39 if (block.timestamp < auctionEndTime)
40 revert AuctionNotYetEnded();
41 if (ended)
42 revert AuctionEndAlreadyCalled();
43
44 ended = true;
45 emit AuctionEnded(highestBidder, highestBid);
46
47 beneficiary.transfer(highestBid);
48 }
49}
显示全部

这是一个简单的拍卖合约,用于在竞标期间接收竞标。 如果 highestBid 增加,先前的最高出价者将收到他们的钱;一旦竞标期结束,beneficiary 调用合约以收取他们的钱。

对这样的合约进行的单元测试将涵盖用户在与合约交互时可能调用的不同函数。 一个例子是进行单元测试,检查用户是否能够在拍卖进行期间出价(即调用 bid() 成功),或者检查用户是否能够出价高于当前的 highestBid

了解合约的运行流程还有助于编写单元测试,以检查执行是否满足要求。 例如,拍卖合约规定,在拍卖结束时(即当 auctionEndTime 小于 block.timestamp 时),用户无法进行竞标。 因此,开发者可能会运行一个单元测试,检查当拍卖结束时(即当 auctionEndTime > block.timestamp 时)对 bid() 函数的调用成功还是失败。

2. 评估与合约执行相关的所有假设

重要的是记录关于合约执行的任何假设,并编写单元测试来验证这些假设的有效性。 除了提供对意外执行的保护之外,测试断言还迫使你思考可能破坏智能合约安全模型的操作。 一个有用的技巧是不仅要进行“正向测试”,还要编写负面测试,检查函数对错误的输入是否会失败。

许多单元测试框架允许你创建断言,即简单的语句,用于说明合约的能力和限制,并运行测试以验证这些断言在执行过程中是否成立。 在运行负面测试之前,对之前描述的拍卖合约进行开发的开发者可以对其行为做出以下断言:

  • 当拍卖结束或尚未开始时,用户无法进行竞标。

  • 如果竞价低于可接受的阈值,合约将会回滚。

  • 未能赢得竞标的用户将获得其资金的退款

注意:测试假设的另一种方法是编写测试,触发合约中的函数修改器(opens in a new tab),特别是 requireassertif...else 语句。

3. 度量代码覆盖率

代码覆盖率(opens in a new tab)是一种测试指标,用于跟踪在测试过程中执行的代码分支、行数和语句数量。 测试应该具有良好的代码覆盖率,否则你可能会遇到“误报”,即合约通过了所有的测试,但代码中仍存在漏洞。 记录高代码覆盖率,可以确保智能合约中的所有语句/函数都经过了足够的正确性测试。

4. 使用完善的测试框架

运行智能合约单元测试时所使用的工具质量至关重要。 理想的测试框架需经常进行维护;提供有用的功能(例如,日志记录和报告功能);并且必须经过其他开发者广泛使用和审核。

单元测试框架用于对 Solidity 智能合约进行单元测试,提供不同语言的选择(主要是 JavaScript、Python 和 Rust)。 请参阅下面的指南,了解如何开始使用不同的测试框架运行单元测试:

集成测试

虽然单元测试可以独立调试合约函数,但集成测试会将智能合约的各个组件作为一个整体进行评估。 集成测试可以检测到跨合约调用或同一智能合约中不同函数之间的交互引起的问题。 例如,集成测试可以帮助检查诸如继承(opens in a new tab)和依赖注入等功能是否正常工作。

如果合约采用模块化架构或在执行过程中与其他链上合约进行接口交互,集成测试非常有用。 一种运行集成测试的方法是在特定的高度

(使用 Forge(opens in a new tab)安全帽(opens in a new tab)等工具),并模拟你的合约与已部署合约之间的交互。

分叉的区块链将与主网的行为类似,其帐户具有关联的状态和余额。 但是它只是一个沙盒式的本地开发环境,举例来说这意味着你不需要真正的以太币进行交易,同时你的更改也不会影响真实的以太坊协议。

基于属性的测试

基于属性的测试是一种检查智能合约是否满足一些定义的属性的过程。 属性是关于合约行为的断言,预期其行为在不同的场景中始终保持为真。智能合约属性的一个例子可以是“合约中的算术运算永不溢出或下溢”。

静态分析动态分析是执行基于属性的测试的两种常见技术,它们都可以验证程序代码(此例中的智能合约)是否满足一些预定义的属性。 有些基于属性的测试工具提供预定义的合约属性规则,并根据这些规则检查代码,而其他工具则允许你为智能合约创建自定义属性。

静态分析

静态分析器接受智能合约的源代码作为输入,并输出结果,声明合约是否满足某个属性 与动态分析不同,静态分析不涉及执行合约来分析其正确性。 静态分析则可以推断智能合约在执行过程中可能采取的所有路径(即通过检查源代码的结构来确定合约在运行时的操作意义)。

Linting(opens in a new tab)静态测试(opens in a new tab)是对合约运行静态分析的常见方法。 两者都需要分析合约执行的低级表现,例如编译器输出的抽象语法树(opens in a new tab)控制流图(opens in a new tab)

在大多数情况下,静态分析对于检测合约代码中的安全问题非常有用,例如使用不安全的结构、语法错误或违反编码标准。 然而,静态分析器通常被认为在检测更深层次的漏洞方面不够准确,并且可能会产生过多的误报。

动态分析

动态分析生成智能合约函数的符号输入(例如,在symbolic execution(opens in a new tab)中)或具体输入(例如,在fuzzing(opens in a new tab)中),以查看是否存在任何执行轨迹违反特定属性。 这种基于属性的测试形式与单元测试不同,因为测试用例涵盖多种场景,并且由程序处理测试用例的生成。

模糊测试(opens in a new tab)是一种用于验证智能合约中任意属性的动态分析技术的示例。 模糊测试工具使用随机或畸形的变化调用目标合约中的函数,以对预定义的输入值进行测试。 如果智能合约进入错误状态(例如,断言失败),问题会被标记,并在生成的报告中包含驱动执行进入脆弱路径的输入。

模糊测试对于评估智能合约的输入验证机制非常有用,因为对于非预期输入的处理不当可能导致意外执行并产生危险影响。 这种基于属性的测试形式可能非常理想,原因有多种:

  1. 编写涵盖多种场景的测试用例很困难。属性测试只需要你定义一个行为和一组用于测试该行为的数据范围,程序会根据定义的属性自动生成测试用例。

  2. 你的测试套件可能无法充分覆盖程序中的所有可能路径。即使达到了 100% 的覆盖率,仍然有可能忽略一些极端情况。

  3. 单元测试证明合约对于样本数据的执行是正确的,但是对于样本之外的输入能否正确执行仍然未知。属性测试使用多个变化的给定输入值针对目标合约执行,以找到导致断言失败的执行轨迹。 因此,属性测试为合约在广泛的输入数据类别下正确执行提供了更多的保证。

对智能合约运行基于属性的测试的准则

运行基于属性的测试通常始于定义你希望在智能合约中进行验证的一个属性(例如,整数溢出(opens in a new tab)的缺失)或一组属性。 在编写属性测试时,你可能需要定义一个数值范围,程序可以在此范围生成用于交易输入的数据。

配置正确后,属性测试工具将使用随机生成的输入执行你的智能合约函数。 如果存在任何断言违规情况,你应该获得一份报告,其中包含违反正在评估的属性的具体输入数据。 请参阅下面的指南,了解如何使用不同的工具开始运行基于属性的测试:

智能合约的手动测试

在开发后期,经常会进行智能合约手动测试,而这类测试通常在运行自动化测试之后进行。 这种测试形式将智能合约作为一个完全集成的产品进行评估,以验证其是否按照技术要求的规定顺利运行。

在本地区块链上测试合约

虽然在本地开发环境中进行的自动化测试可以提供有用的调试信息,但你需要了解你的智能合约在生产环境中的行为。 然而,部署到以太坊主链上会产生燃料费用,更不用说如果你的智能合约仍然存在错误,你或你的用户可能会损失真金白银。

在本地区块链(也称为开发网络)测试你的合约是在主网上测试的推荐替代方法。 本地区块链是在你的计算机本地运行的以太坊区块链副本,模拟以太坊执行层的行为。 因此,你可以编程交易与合约进行交互,而不会产生大量开销。

在本地区块链上运行合约可以作为一种有用的手动集成测试的方式。 智能合约具有高度的可组合性,使你能够与现有协议进行集成,但你仍需要确保这种复杂的链上交互能够产生正确的结果。

更多关于开发网络的信息。

在测试网上测试合约

测试网络或测试网的运行方式与以太坊主网完全相同,唯一的区别在于它使用没有现实价值的以太币 (ETH)。 在测试网上部署你的合约意味着任何人都可以与之交互(例如,通过去中心化应用程序的前端界面),而无需承担资金风险。

这种手动测试形式对于从用户角度评估应用程序的端到端流程非常有用。 在这里,测试人员还可以进行试运行,并报告与合约的业务逻辑和整体功能有关的任何问题。

在本地区块链上进行测试后,部署到测试网是理想的选择,因为测试网更接近以太坊虚拟机的行为。 因此,许多以太坊原生项目通常会在测试网上部署去中心化应用程序,以在真实环境条件下评估智能合约的运行。

更多关于以太坊测试网的信息。

测试与形式化验证

虽然测试有助于确认合约返回某些数据输入的预期结果,但它不能最终证明测试期间未使用的输入也是如此。 因此,测试智能合约无法保证“功能正确性”(即无法证明程序在所有输入值集合上都按照要求运行)。

形式化验证是一种通过检查程序的形式模型是否与形式规范相匹配来评估软件正确性的方法。 形式模型是对程序的抽象数学表述,而形式规范则定义了程序的属性(即关于程序执行的逻辑断言)。

由于属性以数学术语编写,因此可以使用逻辑推理规则验证系统的形式(数学)模型是否满足规范。 因此,形式化验证工具被称为能够提供系统正确性的“数学证明”。

与测试不同,形式化验证可以用于验证智能合约的执行是否满足所有执行情况的形式规范的要求(即,没有缺陷),而无需使用样本数据来执行。 这不仅减少了运行数十个单元测试所花费的时间,而且在发现隐藏的漏洞方面也更加有效。 话虽如此,形式化验证技术在实施难度和实用性上存在一定的变化程度。

更多关于智能合约的形式化验证的信息。

测试与审计以及漏洞奖金计划

正如前面提到的,严格的测试很少能够保证合约中没有错误;形式化验证方法可以提供更强的正确性保证,但目前使用起来困难且成本相当高昂。

尽管如此,你仍可通过进行独立的代码审查来进一步增加捕获合约漏洞的可能性。 智能合约审查(opens in a new tab)漏洞奖励(opens in a new tab)是让他人分析你的合约的两种方式。

审查由具有在智能合约中发现安全漏洞和开发不良实践案例经验的审查人员进行。 审核通常包括对整个代码库进行测试(可能包括形式化验证)以及手动审查。

相反,漏洞奖励计划通常涉及向发现智能合约漏洞并向开发者披露的个人(通常称为白帽黑客(opens in a new tab))提供财务奖励的做法。 漏洞奖励类似于审查,因为它涉及要求其他人帮助发现智能合约中的缺陷。

主要的区别在于漏洞奖励计划对更广泛的开发者/黑客社区开放,并吸引了一批具有独特技能和经验的道德黑客和独立安全专业人员。 与主要依赖可能拥有有限或狭窄专业知识的团队的智能合约审查相比,这可能是一个优势。

测试工具和库

单元测试工具

基于属性测试的工具

静态分析工具

  • Slither(opens in a new tab) - 基于 Python 的 Solidity 静态分析框架,用于查找漏洞、增强代码理解以及为智能合约编写自定义分析。

  • Ethlint(opens in a new tab) - 用于执行Solidity 智能合约编程语言的风格和安全最佳实践的 Linter。

动态分析工具

延伸阅读

本文对你有帮助吗?