调试 Tact 合约
作为智能合约开发人员,我们编写的代码并不总是能实现我们的预期。 有时,它做的事情完全不同! 当意外发生时,接下来的任务就是找出原因。 为此,有多种方法可以揭示代码中的问题或 “错误”。 让我们开始_调试_! 有时,它做的事情完全不同! 当意外发生时,接下来的任务就是找出原因。 为此,有多种方法可以揭示代码中的问题或 “错误”。 让我们开始_调试_!
一般方法
目前,Tact 还没有步进式调试器。 目前,Tact 还没有步进式调试器。 尽管如此,仍然可以使用”printf 调试” 方法。
这包括在整个代码中主动调用 dump()
和 dumpStack()
函数,并观察特定时间点的变量状态。 请注意,这些函数只在 调试模式 下工作,否则不会执行。
除了转储值之外,使用一些断言函数通常也很有帮助,例如 require()
、nativeThrowIf()
和 nativeThrowUnless()
。 它们有助于明确说明你的假设,并方便设置 “绊线”,以便在将来发现问题。
如果您没有找到或无法解决您的问题,请尝试在 Tact 的Telegram 聊天中询问社区;如果您的问题或疑问与 TON 的关系大于与 Tact 的关系,请进入TON Dev Telegram 聊天。
常用调试功能
Tact 提供了大量对调试有用的各种函数:核心库 → 调试。
在编译选项中启用调试模式
为了使 dump()
或 dumpStack()
等函数正常工作,需要启用调试模式。
最简单和推荐的方法是修改项目根目录下的 tact.config.json
文件(如果还不存在,则创建该文件),并 将 debug
属性设置为 true
。
如果您正在处理基于 Blueprint 的项目,可以在合约的编译配置中启用调试模式,这些配置位于名为 wrappers/
的目录中:
import { CompilerConfig } from '@ton/blueprint';
export const compile: CompilerConfig = { lang: 'tact', target: 'contracts/your_contract_name.tact', options: { debug: true, // ← that's the stuff! }};
请注意,从 0.20.0 开始的 Blueprint 版本会自动为新合约启用 wrappers/
中的调试模式。
除此之外,tact.config.json
仍然可以用于 Blueprint 项目。 除此之外,Blueprint 项目中仍可使用 tact.config.json
。 在这种情况下,除非在 wrappers/
中修改,否则 tact.config.json
中指定的值将作为默认值。
在 Blueprint 中编写测试,使用 Sandbox 和 Jest
Blueprint 是一个流行的开发框架,用于在 TON 区块链上编写、测试和部署智能合约。
为了测试智能合约,它使用了本地 TON 区块链模拟器Sandbox和 JavaScript 测试框架Jest。
无论何时创建一个新的 Blueprint 项目,或在现有项目中使用 “blueprint create “命令,都会创建一个新的合约以及测试套件文件。
这些文件被放在tests/
文件夹中,并用Jest执行。 默认情况下,除非指定特定组或测试关闭,否则所有测试都会运行。 有关其他选项,请参阅 Jest CLI 中的简要文档:jest --help
。
测试文件的结构
假设我们有一份名为 Playground
的合约,写在 contracts/playground.tact
文件中。 假设我们有一份名为 Playground
的合约,写在 contracts/playground.tact
文件中。 如果我们通过 Blueprint 创建了该合约,那么它也会为我们创建一个 tests/Playground.spec.ts
测试套件文件。
测试文件包含一个 describe()
Jest 函数调用,表示一个测试组。
在该组中,有三个变量在所有测试中都可用:
blockchain
- 由沙盒提供的本地区块链实例deployer
— 一个 TypeScript 封装器,用于部署我们的Playground
合约或我们希望部署的任何其他合约playground
- 我们的Playground
合约的 TypeScript 封装器
然后,调用一个 beforeEach()
Jest 函数—它指定了在每个后续测试闭包之前要执行的所有代码。
最后,通过调用 it()
Jest 函数来描述每个测试闭包—这就是实际编写测试的地方。
一个最简单的测试闭包示例如下:
it('should deploy', async () => { // The check is done inside beforeEach, so this can be empty});
使用 dump()
调试
要查看 dump()
函数调用的结果,并使用”printf 调试” 方法,就必须
假设你已经创建了一个 新计数器合约项目,让我们来看看它是如何实际运行的。
首先,让我们在 contracts/simple_counter.tact
中调用 dump()
,这将把 msg
Struct 中传递的 amount
输出到合约的调试控制台:
// ...receive(msg: Add) { dump(msg.amount); // ...}// ...
接下来,让我们注释掉 tests/SimpleCounter.spec.ts
文件中所有现有的 it()
测试闭包。 然后再加上下面一条: 然后再加上下面一条:
it('should dump', async () => { await playground.send( deployer.getSender(), { value: toNano('0.5') }, { $$type: 'Add', queryId: 1n, amount: 1n }, );});
它向我们合约的 receive(msg: Add)
接收器 发送信息,而不存储发送结果。
现在,如果我们使用 yarn build
构建我们的合约,并使用 yarn test
运行我们的测试套件,我们将在测试日志中看到以下内容:
console.log #DEBUG#: [DEBUG] File contracts/simple_counter.tact:17:9 #DEBUG#: 1
at SmartContract.runCommon (node_modules/@ton/sandbox/dist/blockchain/SmartContract.js:221:21)
这是由我们上面的 dump()
调用产生的。
使用expect()
说明期望
编写测试不可或缺的部分是确保你的期望与观察到的现实相吻合。 编写测试不可或缺的部分是确保你的期望与观察到的现实相吻合。 为此,Jest 提供了一个函数 expect()
,使用方法如下:
- 首先,提供一个观测变量。
- 然后,调用特定的方法来检查该变量的某个属性。
下面是一个更复杂的示例,它使用 expect()
函数来检查计数器合约是否确实正确地增加了计数器:
it('should increase counter', async () => { const increaseTimes = 3; for (let i = 0; i < increaseTimes; i++) { console.log(`increase ${i + 1}/${increaseTimes}`);
const increaser = await blockchain.treasury('increaser' + i);
const counterBefore = await simpleCounter.getCounter(); console.log('counter before increasing', counterBefore);
const increaseBy = BigInt(Math.floor(Math.random() * 100)); console.log('increasing by', increaseBy);
const increaseResult = await simpleCounter.send( increaser.getSender(), { value: toNano('0.05') }, { $$type: 'Add', queryId: 0n, amount: increaseBy } );
expect(increaseResult.transactions).toHaveTransaction({ from: increaser.address, to: simpleCounter.address, success: true, });
const counterAfter = await simpleCounter.getCounter(); console.log('counter after increasing', counterAfter);
expect(counterAfter).toBe(counterBefore + increaseBy); }});
实用方法
由 Blueprint 生成的测试文件导入了 @ton/test-utils
库,该库为 expect()
Jest 函数的结果类型提供了一些额外的辅助方法。 请注意,toEqual()
等常规方法仍然存在,随时可以使用。
toHaveTransaction
方法 expect(…).toHaveTransaction()
检查事务列表中是否有符合你指定的某些属性的事务:
const res = await yourContractName.send(…);expect(res.transactions).toHaveTransaction({ // For example, let's check that a transaction to your contract was successful: to: yourContractName.address, success: true,});
要了解此类属性的完整列表,请查看编辑器或集成开发环境提供的自动完成选项。
toEqualCell
方法 expect(…).toEqualCell()
检查两个 cell是否相等:
expect(oneCell).toEqualCell(anotherCell);
toEqualSlice
方法 expect(…).toEqualSlice()
检查两个 slices 是否相等:
expect(oneSlice).toEqualSlice(anotherSlice);
toEqualAddress
方法 expect(…).toEqualAddress()
检查两个 地址是否相等:
expect(oneAddress).toEqualAddress(anotherAddress);
发送信息至
要向合约发送消息,请在其 TypeScript 封装器上使用 .send()
方法,如下所示:
// It accepts 3 arguments:await yourContractName.send( // 1. sender of the message deployer.getSender(), // this is a default treasury, can be replaced
// 2. value and (optional) bounce, which is true by default { value: toNano('0.5'), bounce: false },
// 3. a message body, if any 'Look at me!',);
消息体可以是简单的字符串,也可以是指定 消息类型字段的对象:
await yourContractName.send( deployer.getSender(), { value: toNano('0.5') }, { $$type: 'NameOfYourMessageType', field1: 0n, // bigint zero field2: 'yay', },);
通常情况下,存储此类发送的结果非常重要,因为它们包含发生的事件、进行的事务和发送的外部信息:
const res = await yourContractName.send(…);// res.events - 发生的事件数组// res.externals - 外部输出消息数组// res.transactions - 完成的交易数组
这样,我们就可以轻松地过滤或检查某些交易:
expect(res.transactions).toHaveTransaction(…);
观察费用和数值
沙盒提供了一个辅助函数 printTransactionFees()
,它可以漂亮地打印所提供交易的所有值和费用。 它对观察 nano Toncoins的流动非常方便。
要使用它,请在测试文件顶部修改来自 @ton/sandbox
的导入:
import { Blockchain, SandboxContract, TreasuryContract, printTransactionFees } from '@ton/sandbox';// ^^^^^^^^^^^^^^^^^^^^
然后,提供一个事务数组作为参数,就像这样:
printTransactionFees(res.transactions);
要处理计算和操作 阶段的总费用或费用的单个值,请逐个检查每笔交易:
// Storing the transaction handled by the receiver in a separate constantconst receiverHandledTx = res.transactions[1];expect(receiverHandledTx.description.type).toEqual('generic');
// Needed to please TypeScriptif (receiverHandledTx.description.type !== 'generic') { throw new Error('Generic transaction expected');}
// Total feesconsole.log('Total fees: ', receiverHandledTx.totalFees);
// Compute feeconst computeFee = receiverHandledTx.description.computePhase.type === 'vm' ? receiverHandledTx.description.computePhase.gasFees : undefined;console.log('Compute fee: ', computeFee);
// Action feeconst actionFee = receiverHandledTx.description.actionPhase?.totalActionFees;console.log('Action fee: ', actionFee);
// Now we can do some involved checks, like limiting the fees to 1 Toncoinexpect( (computeFee ?? 0n) + (actionFee ?? 0n)).toBeLessThanOrEqual(toNano('1'));
有故意错误的交易
有时,进行负面测试也很有用,它可以故意出错并抛出特定的退出码。
it('throws specific exit code', async () => { // Send a specific message to our contract and store the results const res = await your_contract_name.send( deployer.getSender(), { value: toNano('0.5'), // value in nanoToncoins sent bounce: true, // (default) bounceable message }, 'the message your receiver expects', // ← change it to yours );
// Expect the transaction to our contract fail with a certain exit code expect(res.transactions).toHaveTransaction({ to: your_contract_name.address, exitCode: 5, // ← change it to yours });});
请注意,要跟踪具有特定退出码的事务,只需在 expect()
方法的 toHaveTransaction()
对象参数中指定 exitCode
字段即可。
不过,通过指定收件人地址 to
来缩小范围是很有用的,这样 Jest 就只能查看我们发送给合约的消息所引起的事务。
模拟时间流逝
由 Sandbox 提供的本地区块链实例中的 Unix 时间从 beforeEach()
块中创建这些实例的时刻开始。
beforeEach(async () => { blockchain = await Blockchain.create(); // ← here // ...});
在此之前,我们曾被警告不要修改 beforeEach()
块,除非我们真的需要这样做。 而现在,我们要做的,就是稍稍推翻时间和时空旅行。 而现在,为了超越时间并进行一些时光旅行,我们这样做。
让我们在末尾添加下面一行,将 blockchain.now
明确设置为处理部署消息的时间:
beforeEach(async () => { // ... blockchain.now = deployResult.transactions[1].now;});
现在,我们可以在测试子句中操作时间了。 现在,我们可以在测试子句中操作时间了。 例如,让我们在部署一分钟后进行一次交易,两分钟后再进行一次交易:
it('your test clause title', async () => { blockchain.now += 60; // 60 seconds late const res1 = await yourContractName.send(…); blockchain.now += 60; // another 60 seconds late const res2 = await yourContractName.send(…);});
通过 emit
记录
全局静态函数 emit()
向外部世界发送信息—它没有特定的接收者。
该功能对于记录和分析链外数据非常方便,只需查看合约生成的 external messages 即可。
本地沙箱测试中的日志
在 Sandbox 中部署时,您可以从 receiver function 中调用 emit()
,然后观察已发送的 external messages 列表:
it('emits', async () => { const res = await simpleCounter.send( deployer.getSender(), { value: toNano('0.05') }, 'emit_receiver', // ← change to the message your receiver handles );
console.log("Address of our contract: " + simpleCounter.address); console.log(res.externals); // ← here one would see results of emit() calls, // and all external messages in general});
已部署合约的日志
TON 区块链上的每笔交易都包含out_msgs
- 这是一个字典,保存着执行交易时创建的传出消息列表。
要查看字典中 emit()
的日志,请查找没有收件人的外部消息。 在各种 TON 区块链探索器中,此类交易将被标记为 “外部输出(external-out)“,目的地指定为”-“或 “空”。
请注意,有些探索者会为你反序列化发送的信息体,而有些则不会。 不过,您可以随时在本地自行解析。
解析已发送信息的正文
请参考以下示例:
// We have a Structstruct Ballroom { meme: Bool; in: Int; theory: String;}
// And a simple contract,contract Bonanza { // which can receive a String message, receive("time to emit") { // emit a String emit("But to the Supes? Absolutely diabolical.".asComment());
// and a Struct emit(Ballroom{meme: true, in: 42, theory: "Duh"}.toCell()); }}
现在,让我们为 “Bonanza “合约制作一个简单的 测试条款:
it('emits', async () => { const res = await bonanza.send( deployer.getSender(), { value: toNano('0.05') }, 'time to emit', );});
在这里,res
对象的externals
字段将包含已发送的外部信息 列表。 让我们访问它,以解析通过调用 Tact 代码中的 emit()
(或简称 emitted)发送的第一条信息:
it('emits', async () => { // ... prior code ...
// We'll need only the body of the observed message: const firstMsgBody = res.externals[0].body;
// Now, let's parse it, knowing that it's a text message. // NOTE: In a real-world scenario, // you'd want to check that first or wrap this in a try...catch const firstMsgText = firstMsgBody.asSlice().loadStringTail();
// "But to the Supes? Absolutely diabolical." console.log(firstMsgText);});
要解析第二条发出的信息,我们可以手动使用一堆 .loadSomething()
函数,但这样做太麻烦了—如果 Ballroom
Struct 的字段发生变化,就需要重新开始。 当你以这种方式编写大量测试时,可能会适得其反。
幸运的是,Tact 编译器会自动为合约生成 TypeScript 绑定(或封装),在测试套件中重新使用它们非常容易。 它们不仅有一个你正在测试的合约的包装器,而且还导出了一堆辅助函数来存储或加载合约中定义的 Structs 和 Messages 。 后者的命名方式与 Structs 和 Messages 一样,只是在前面加上了 load
前缀。
例如,在我们的例子中,我们需要一个名为 loadBallroom()
的函数,用于将 Slice
解析为 TypeScript 中的 Ballroom
Struct 。 要导入它,要么键入名称,让集成开发环境建议自动导入,要么查看测试套件文件的顶部—应该有类似的一行:
import { Bonanza } from '../wrappers/Bonanza';// ^ here you could import loadBallroom
现在,让我们来解析第二条发出的信息:
it('emits', async () => { // ... prior code ...
// We'll need only the body of the observed message: const secondMsgBody = res.externals[1].body;
// Now, let's parse it, knowing that it's the Ballroom Struct. // NOTE: In a real-world scenario, // you'd want to check that first or wrap this in a try...catch const secondMsgStruct = loadBallroom(secondMsgBody.asSlice());
// { '$$type': 'Ballroom', meme: true, in: 42n, theory: 'Duh' } console.log(secondMsgStruct);});
请注意,即使在我们的测试套件之外,也可以解析已部署合约的发射信息。 您只需获取已触发的消息体,然后像上面的示例一样,在 @ton/core
库旁使用自动生成的 Tact 的 TypeScript 绑定。
处理退回消息
当 sent 带有 bounce: true
时,消息可以在出现错误时退回. 确保编写相关的 bounced()
消息接收器,并优雅地处理被退回的消息:
bounced(msg: YourMessage) { // ...alright squad, let's bounce!...}
请记住,在 TON 中被退回的消息正文中只有 个可用数据位,而且没有任何引用,因此无法从中恢复很多数据。 不过,您仍然可以看到消息是否被退回,从而可以创建更稳健的合约。
了解更多关于退回消息和接收者的信息:退回消息。
实验室设置
如果你对 Blueprint 的测试设置感到不知所措,或者只是想快速测试一些东西,不用担心—有一种方法可以建立一个简单的游戏场作为实验实验室,来测试你的想法和假设。
-
创建新的Blueprint项目
这将防止任意代码和测试污染您现有的程序。
新项目可以取任何名字,但我会取名为 “Playground”,以表达正确的意图。
要创建它,请运行以下命令:
Terminal window # recommendedyarn create ton tact-playground --type tact-empty --contractName PlaygroundTerminal window npm create ton@latest -- tact-playground --type tact-empty --contractName PlaygroundTerminal window pnpm create ton@latest tact-playground --type tact-empty --contractName PlaygroundTerminal window bun create ton@latest tact-playground --type tact-empty --contractName Playground从 0.20.0 开始的 Blueprint 版本会自动为新合约启用
wrappers/
中的调试模式,因此我们只需调整测试套件并准备好我们的Playground
合约即可进行测试。 -
更新测试套件
移动到新创建的
tact-playground/
项目,在tests/Playground.spec.ts
中,将"should deploy"
测试闭包改为以下内容:tests/Playground.spec.ts it('plays', async () => {const res = await playground.send(deployer.getSender(),{ value: toNano('0.5') }, // ← here you may increase the value in nanoToncoins sent'plays',);console.log("Address of our contract: " + playground.address);console.log(res.externals); // ← here one would see results of emit() calls}); -
修改合约
用以下代码替换
contracts/playground.tact
中的代码:contracts/playground.tact import "@stdlib/deploy";contract Playground with Deployable {receive("plays") {// NOTE: write your test logic here!}}此设置的基本思想是将要测试的代码放入 receiver function 中,以响应 string 消息
"plays"
。请注意,您仍然可以在接收器 之外编写任何有效的 Tact 代码。 但为了测试它,你需要在其中编写相关的测试逻辑。
-
我们来测试一下! 我们来测试一下!
这样,我们的实验装置就完成了。 要执行我们为 “Playground “合约准备的单个测试,请运行以下程序:
Terminal window yarn test -t plays从现在起,您只需修改 Tact 合约文件中已测试的 receiver function 的内容,然后重新运行上述命令,就可以对某些内容进行测试。 重复该过程,直到您测试了想要测试的内容。
为了简化和更干净的输出,您可以在
package.json
中为scripts
添加一个新字段,这样您只需运行yarn lab
即可在一个字段中完成构建和测试。在 Linux 或 macOS 上,它看起来就像这样:
{"scripts": {"lab": "blueprint build --all 1>/dev/null && yarn test -t plays"}}下面是它在 Windows 上的样子:
{"scripts": {"build": "blueprint build --all | out-null","lab": "yarn build && yarn test -t plays"}}要运行
Terminal window yarn lab