TON:沙盒测试
🥦

TON:沙盒测试

沙盒测试

TON提供了一个sanbox库允许模拟任意的 TON 智能合约,向它们发送消息并运行获取方法,就像部署在真实网络上一样。
因为我们有了自己的 TypeScript "实验室",所以可以借助另一个库(jest)创建一系列测试。这样,我们就有了一个测试套件,用不同的输入数据模拟所有重要的行为,并检查结果。这是编写、调试和全面测试合同的最佳方法,然后再将它们发布到网络上。
 

准备测试套件

首先,在当前位于项目的根目录下安装 sandbox、jest 和一个与 TON entities交互所需的库 - ton:
yarn add @ton/sandbox jest ts-jest @types/jest @ton/ton --dev
我们还需要在项目根目录下为 jest 创建一个 jest.config.js 文件,内容如下:
module.exports = { preset: 'ts-jest', testEnvironment: 'node', };
再创建一个新文件夹 test,包括文件 main.spec.ts:
mkdir tests && cd tests && touch main.spec.ts
设置好 main.spec.ts 文件,以便编写第一个测试:
describe("main.fc contract tests", () => { it("our first test", async () => { }); });
现在在目录根目录下运行 yarn jest 命令,应该会看到下面的内容:
PASS tests/main.spec.ts main.fc contract tests ✓ our first test (1 ms)
可以考虑在 package.json 文件中创建另一个脚本运行快捷方式:
{ "scripts": { "test": "yarn jest" } }

创建合同实例

合约代码在编译后会存储为一个 Cell,在 build/main.compiled.json 文件中,已经有了一个之前的 Cell 的十六进制表示。让我们将它与 ton-core 中的 Cell 类型一起导入测试文件:
import { Cell } from "@ton/core"; import { hex } from "../build/main.compiled.json"; describe("main.fc contract tests", () => { it("our first test", async () => { }); });
现在,为了还原十六进制并得到一个真正的 Cell,将使用以下命令:
const codeCell = Cell.fromBoc(Buffer.from(hex, "hex"))[0]
我们从十六进制字符串创建了一个 Buffer,并将其传递给 .fromBoc method。
从sandbox快速入门指南中可以得知,在获得合约实例之前,必须先获得区块链实例。
从sandbox库中导入一个Blockchain类 ,并调用它 .create() 方法。
import { Cell } from "@ton/core"; import { hex } from "../build/main.compiled.json"; import { Blockchain } from "@ton/sandbox"; describe("main.fc contract tests", () => { it("our first test", async () => { const blockchain = await Blockchain.create(); }); });
现在准备与合约实例进行交互,推荐使用 ton-core 的 Contract 接口为合约编写封装程序。
创建一个新文件夹 wrappers 和一个名为 MainContract.ts 的文件。该文件将围绕合约实现并导出一个封装器。
mkdir wrappers && cd wrappers && touch MainContract.ts
确保从项目根目录运行此命令。
打开 MainContract.ts 进行编辑,先从 ton-core 库中导入一个 Contract 接口,然后定义并导出一个实现 Contract 的类。
import { Contract } from '@ton/core'; export class MainContract implements Contract { }
如果查看一下 Contract 接口的内容,就会发现它需要 address、init 和 abi 参数。我们将只使用 address 和 init,为了使用它们,首先为 MainContract 类定义一个构造函数。
import { Address, Cell, Contract } from "@ton/core"; export class MainContract implements Contract { constructor( readonly address: Address, readonly init?: { code: Cell; data: Cell } ) {} }
init 属性,指的是合约的初始状态。代码显然指的是合约的代码。Data则比较复杂,有了 Cell 数据,就可以定义一旦合约首次执行,这个存储空间里会有什么,它们会在合约的生命周期内存储在 TVM 的内存中。同样的Code和Data cell也用于计算合约的未来地址。
 
注意我们还从 @ton/core 库中导入了 Address 和 Cell 类。
在 TON 中,合约的地址是确定的,甚至可以在部署合约之前就知道。之后将在几个步骤中了解如何实现这一点的。
现在为 MainContract 类定义一个静态方法--createFromConfig。我们暂时不会使用任何配置参数,但之后需要输入数据才能创建合约实例。
import { Address, beginCell, Cell, Contract, contractAddress } from "@ton/core"; export class MainContract implements Contract { constructor( readonly address: Address, readonly init?: { code: Cell; data: Cell } ) {} static createFromConfig(config: any, code: Cell, workchain = 0) { const data = beginCell().endCell(); const init = { code, data }; const address = contractAddress(workchain, init); return new MainContract(address, init); } }
createFromConfig 方法接受一个配置参数(暂时忽略)、一个包含合约编译代码的 Cell 代码和一个工作链,后者定义了合约要放置的 TON 工作链。目前,ton 上只有一个工作链 - 0。
这段代码创建了一个 MainContract 类的新实例,根据构造函数的定义,它需要被传递一份合约的未来地址(如前所述,可以计算出该地址)和合约的初始状态。
未来地址由从 @ton/core 库中导入的函数计算得出,可以通过工作链和初始状态参数来获取地址。
init状态是一个包含code和data属性的对象。code会被传入我们的方法中,而data暂时只是一个空单元格。接下来会将配置数据转换为 Cell 格式,以便在合约的init状态中使用config数据。
总之,createFromConfig 接受的是一个带有数据的config,将来会把这些数据存储到合约的持久化存储中,并接受合约的代码。作为输出我们会得到一个合约实例,可以利用sandbox与之交互。
接下来回到 tests/main.spec.ts,执行以下步骤:
  • 导入包装器
  • 以十六进制形式导入合同的编译代码
  • 将其转换为 Cell
  • 使用sandbox的 openContract 和新包装器,以获得最终可以与之交互的合约实例
import { Cell } from "@ton/core"; import { hex } from "../build/main.compiled.json"; import { Blockchain } from "@ton/sandbox"; import { MainContract } from "../wrappers/MainContract"; describe("main.fc contract tests", () => { it("our first test", async () => { const blockchain = await Blockchain.create(); const codeCell = Cell.fromBoc(Buffer.from(hex, "hex"))[0]; const myContract = blockchain.openContract( await MainContract.createFromConfig({}, codeCell) ); }); });
至此就拥有了一个 smartcontract 实例,我们可以通过许多与真实合同类似的方式与之交互,以测试预期行为。
 

与合同互动

@ton/core 库为我们提供了另一个结构,叫做Address。众所周知,TON 区块链上的每个实体都有一个地址。在现实生活中,如果想从一个合约(例如钱包合约)向另一个合约发送消息,需要知道两个合约的地址。
在编写测试时,我们会模拟一个合约,当与之交互时,模拟的合约的地址是已知的。但是,还需要一个钱包来部署合约,另一个钱包用来与合约交互。
在sandbox中调用区块链实例的treasure method,并为其提供一个seed phrase:
const senderWallet = await blockchain.treasury("sender");
 

模仿内部信息

继续编写测试,既然已经有了一个合约实例,那就向它发送一条内部消息,这样发件人地址就会保存在 c4 存储空间中。要与合约实例交互,就需要使用包装器。回到文件 wrappers/MainContract.ts,在包装器中创建一个名为 sendInternalMessage 的新method。
async sendInternalMessage( provider: ContractProvider, sender: Sender, value: bigint ){ }
新method将接收 ContractProvider 类型的参数、Sender 类型的消息发送者和消息值value。
通常情况下在使用此方法时不必担心传递ContractProvide,它将作为合约实例内置功能的一部分传递。别忘了从 ton-core 库中导入这些类型。
在新方法中实现发送内部消息的逻辑是这样的:
async sendInternalMessage( provider: ContractProvider, sender: Sender, value: bigint ) { await provider.internal(sender, { value, sendMode: SendMode.PAY_GAS_SEPARATELY, body: beginCell().endCell(), }); }
可以看到我们使用了provider,并调用了它interal method。我们将发件人sender作为第一个参数传递,然后用参数组成一个对象,其中包括
  • value of message(TON的数量,用nano表示)
  • sendMode -- 使用 @ton/core 提供的SendMode,可以在专门的文档页面阅读更多关于这些模式如何工作的信息。
  • body - -本应是包含信息正文的单元格,但我们暂时将其保留为空单元格。
不要忘记从 @ton/core 库中导入我们要使用的所有新类型和实体。
wrappers/MainContract.ts 的最终代码如下所示:
import { Address, beginCell, Cell, Contract, contractAddress, ContractProvider, Sender, SendMode } from "@ton/core"; export class MainContract implements Contract { constructor( readonly address: Address, readonly init?: { code: Cell; data: Cell } ) {} static createFromConfig(config: any, code: Cell, workchain = 0) { const data = beginCell().endCell(); const init = { code, data }; const address = contractAddress(workchain, init); return new MainContract(address, init); } async sendInternalMessage( provider: ContractProvider, sender: Sender, value: bigint ) { await provider.internal(sender, { value, sendMode: SendMode.PAY_GAS_SEPARATELY, body: beginCell().endCell(), }); } }
在 tests/main.spec.ts 中调用:
const senderWallet = await blockchain.treasury("sender"); myContract.sendInternalMessage(senderWallet.getSender(), toNano("0.05"));
请注意使用从 @ton/core 库导入的 toNano 辅助函数将字符串值转换为nano bignum格式。
 

调用 getter 方法

我们需要在合约包装器上再创建一个方法,即 getData 方法,该方法将运行合约的 getter 方法,并重获 c4 存储中的结果。
这是getter 方法:
async getData(provider: ContractProvider) { const { stack } = await provider.get("get_the_latest_sender", []); return { recent_sender: stack.readAddress(), }; }
就像发送内部信息一样,我们使用的是provider及其方法。在本例中使用的是 get 方法,然后从接收到的堆栈中读取地址,并将其作为结果返回。
 

测试

到目前为止已经完成了所有的准备工作,现在要编写实际的测试逻辑。下面是测试场景:
  1. 我们发送内部信息
  1. 确保发送成功
  1. 我们调用合约的 getter 方法,并确保调用成功
  1. 将从 getter 收到的结果与我们在原始内部信息中设置的 from 地址进行比较。
Sandbox团队提供了一个额外的测试工具。我们可以运行 yarn add @ton/test-utils -D 来安装额外的 @ton/test-utils 软件包。
这样可以使用 jest matcher 的 .toHaveTransaction 来添加额外的辅助测试工具,以方便测试。安装后,我们还需要在 tests/main.spec.ts 中导入该软件包。
基于上述场景的测试代码是这样的:
import { Cell, toNano } from "@ton/core"; import { hex } from "../build/main.compiled.json"; import { Blockchain } from "@ton/sandbox"; import { MainContract } from "../wrappers/MainContract"; import "@ton/test-utils"; describe("main.fc contract tests", () => { it("should get the proper most recent sender address", async () => { const blockchain = await Blockchain.create(); const codeCell = Cell.fromBoc(Buffer.from(hex, "hex"))[0]; const myContract = blockchain.openContract( await MainContract.createFromConfig({}, codeCell) ); const senderWallet = await blockchain.treasury("sender"); const sentMessageResult = await myContract.sendInternalMessage( senderWallet.getSender(), toNano("0.05") ); expect(sentMessageResult.transactions).toHaveTransaction({ from: senderWallet.address, to: myContract.address, success: true, }); const data = await myContract.getData(); expect(data.recent_sender.toString()).toBe(senderWallet.address.toString()); }); });
我们使用 Jest 功能是来确保
  • 内部信息发送成功
  • 获取方法成功
  • 获取方法返回一些结果
  • 从获取方法返回的地址等于我们在消息中设置 from 地址时使用的地址
将 "our first test "重命名为 "should get the proper most recent sender address",来保证代码的自解读性。
 

运行测试

只需在终端运行 yarn test 命令,会得到如下的结果:
PASS tests/main.spec.ts main.fc contract tests ✓ should get the proper most recent sender address (444 ms) Test Suites: 1 passed, 1 total Tests: 1 passed, 1 total Snapshots: 0 total Time: 4.13 s, estimated 5 s
要做的最后一件事是确保每次运行测试时,同时运行编译器脚本。这有助于提高工作效率。大部分开发工作都是编写功能代码,然后运行测试。简化一下这个过程:
更新 package.json 文件:
{ "scripts": { "test": "yarn compile && yarn jest" } }