跳转到内容

调试 Tact 合约

作为智能合约开发人员,我们编写的代码并不总是能实现我们的预期。 有时,它做的事情完全不同! 当意外发生时,接下来的任务就是找出原因。 为此,有多种方法可以揭示代码中的问题或 “错误”。 让我们开始_调试_!

一般方法

目前,Tact 还没有步进式调试器。 尽管如此,仍然可以使用”printf 调试” 方法。

这包括在整个代码中主动调用 [dump()][dump]和 dumpStack()函数,并观察特定时间点的变量状态。 请注意,这些函数只在 [调试模式](#debug-mode)下工作,否则不会执行。

除了转储值之外,使用 require()nativeThrowIf()nativeThrowUnless()等自信的函数通常也很有帮助。 它们有助于明确说明你的假设,并方便设置 “绊线”,以便在将来发现问题。

如果您没有找到或无法解决您的问题,请尝试在 Tact 的Telegram 聊天中询问社区;如果您的问题或疑问与 TON 的关系大于与 Tact 的关系,请进入TON Dev Telegram 聊天

常用调试功能

Tact 提供了大量对调试有用的各种函数:核心库 → 调试

在编译选项中启用调试模式

Using @tact-lang/emulator

To make dump work you need to enable the feature debug in tact.conf.json.

如果您正在处理基于 Blueprint 的项目,可以在合约的编译配置中启用调试模式,这些配置位于名为 wrappers/ 的目录中:

wrappers/YourContractName.compile.ts
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。 在这种情况下,除非在 wrappers/ 中修改,否则 tact.config.json中指定的值将作为默认值。

在蓝图中编写测试,使用 Sandbox 和 Jest

蓝图 是一个流行的开发框架,用于在 TON 区块链上编写、测试和部署智能合约。

Ton Emulator allows you to have a small virtual blockchain in your Node.js code. This library is built specifically for testing smart contracts in unit tests.

无论何时创建一个新的 Blueprint 项目,或在现有项目中使用 “blueprint create “命令,都会创建一个新的合同以及测试套件文件。

这些文件被放在tests/文件夹中,并用[Jest][jest]执行。 默认情况下,除非指定特定组或测试关闭,否则所有测试都会运行。 有关其他选项,请参阅 Jest CLI 中的简要文档:jest --help

测试文件的结构

假设我们有一份名为 Playground 的合同,写在 contracts/playground.tact 文件中。 如果我们通过 Blueprint 创建了该合约,那么它也会为我们创建一个 tests/Playground.spec.ts 测试套件文件。

测试文件包含一个 describe() [Jest][jest] 函数调用,表示一个测试组。

在该组中,有三个变量在所有测试中都可用:

  • blockchain - 由[沙盒][sb]提供的本地区块链实例
  • deployer` - 一个 TypeScript 封装器,用于部署我们的 Playground 合约或我们希望部署的任何其他合约
  • playground - 我们的 Playground 合约的 TypeScript 封装器

然后,调用一个 beforeEach() [Jest][jest] 函数—它指定了在每个后续测试闭包之前要执行的所有代码。

最后,通过调用 it() [Jest][jest] 函数来描述每个测试闭包—这就是实际编写测试的地方。

Example of a minimal test file:

it('should deploy', async () => {
// 检查是在 beforeEach 内部进行的,因此此处可以为空
});

使用 dump() 调试

要查看 [dump()][dump]函数调用的结果,并使用”printf 调试” 方法,就必须

  1. 在代码的相关位置调用 [dump()][dump]和其他常用调试函数
  2. 运行 [Jest][jest]测试,这些测试将调用目标函数并向目标接收器发送信息。

假设你已经创建了一个 新计数器合约项目,让我们来看看它是如何实际运行的。

首先,让我们在 contracts/simple_counter.tact 中调用 [dump()][dump],这将把 msg [Struct][struct] 中传递的 amount 输出到合约的调试控制台:

contracts/simple_counter.tact
// ...
receive(msg: Add) {
dump(msg.amount);
// ...
}
// ...

接下来,让我们注释掉 tests/SimpleCounter.spec.ts 文件中所有现有的 it() 测试闭包。 然后再加上下面一条:

tests/SimpleCounter.spec.ts
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()][dump]调用产生的。

使用expect()说明期望

编写测试不可或缺的部分是确保你的期望与观察到的现实相吻合。 为此,[Jest][jest] 提供了一个函数 expect(),使用方法如下:

  1. 首先,提供一个观测变量。
  2. 然后,调用特定的方法来检查该变量的某个属性。

下面是一个更复杂的示例,它使用 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][jest] 函数的结果类型提供了一些额外的辅助方法。 请注意,toEqual()等常规方法仍然存在,随时可以使用。

有交易

方法 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() 检查两个 单元格是否相等:

expect(oneCell).toEqualCell(anotherCell);

对等切片

方法 expect(…).toEqualSlice() 检查两个 slices 是否相等:

expect(oneSlice).toEqualSlice(anotherSlice);

toEqualAddress

方法 expect(…).toEqualAddress() 检查两个 地址是否相等:

expect(oneAddress).toEqualAddress(anotherAddress);

发送信息至

要向合约发送消息,请在其 TypeScript 封装器上使用 .send() 方法,如下所示:

// 它接受 3 个参数:
await yourContractName.send(
// 1. 消息的发送者
deployer.getSender(), // 这是一个默认的宝库,可以替换
// 2. 值和(可选)反弹,默认为 true
{ value: toNano('0.5'), bounce: false },
// 3. 消息正文,如果有的话
'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(…);

遵守收费和价值

[沙盒][sb]提供了一个辅助函数 printTransactionFees(),它可以漂亮地打印所提供交易的所有值和费用。 它对观察 [纳米通币](/book/integers#nanotoncoin)的流动非常方便。

要使用它,请在测试文件顶部修改来自 @ton/sandbox 的导入:

import { Blockchain, SandboxContract, TreasuryContract, printTransactionFees } from '@ton/sandbox';
// ^^^^^^^^^^^^^^^^^^^^

然后,提供一个事务数组作为参数,就像这样:

printTransactionFees(res.transactions);

要处理计算和操作 阶段的总费用或费用的单个值,请逐个检查每笔交易:

// 将接收方处理的交易存储在一个单独的常量中
const receiverHandledTx = res.transactions[1];
expect(receiverHandledTx.description.type).toEqual('generic');
// 为了满足 TypeScript 的要求
if (receiverHandledTx.description.type !== 'generic') {
throw new Error('Generic transaction expected');
} // Total fees
// 总费用
console.log('Total fees: ', receiverHandledTx.totalFees);
// 计算费用
const computeFee = receiverHandledTx.description.computePhase.type === 'vm'
?receiverHandledTx.description.computePhase.gasFees
: undefined;
console.log('Compute fee: ', computeFee);
// Action fee
const actionFee = receiverHandledTx.description.actionPhase?.totalActionFees;
console.log('Action fee: ', actionFee);
// 现在我们可以进行一些相关检查,比如将费用限制在 1 Toncoin
expect(
(computeFee ?? 0n)
+ (actionFee ?? 0n)
).toBeLessThanOrEqual(toNano('1'));

有故意错误的交易

有时,进行负面测试也很有用,它可以故意出错并抛出特定的退出代码

蓝图中此类[Jest][jest]测试闭包的示例:

tests/YourTestFileHere.spec.ts
it('throws specific exit code', async () => {
// 向我们的合约发送特定消息并存储结果
const res = await your_contract_name.send(
deployer.getSender(),
{
value: toNano('0.5'), // 值以发送的 nanoToncoins 为单位
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, // ← 更改为您的
});
});

请注意,要跟踪具有特定退出代码的事务,只需在 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][sb] 中部署时,您可以从 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); // ← 在这里可以看到 emit() 调用的结果,
// 以及所有外部消息
});

已部署合同的日志

TON 区块链上的每笔交易都包含out_msgs - 这是一个字典,保存着执行交易时创建的传出消息列表。

要查看字典中 emit()的日志,请查找没有收件人的外部消息。 在各种 TON 区块链探索器中,此类交易将被标记为 “外部输出”,目的地指定为”-“或 “空”。

请注意,有些探索者会为你反序列化发送的信息体,而有些则不会。 不过,您可以随时在本地[自行解析](#logging-parsing)。

解析已发送信息的正文

Example:

// 我们有一个结构
struct Ballroom {
memeBool;
inInt;
theoryString;
}
// 还有一个简单的合约,
contract Bonanza {
// 可以接收一个字符串消息,
receive("time to emit") {
// 发送一个字符串
emit("But to the Supes?Absolutely diabolical.".asComment());
// and a Struct
emit(Ballroom{meme: true, in42, theory: "Duh"}.toCell());
}
}

现在,让我们为 “Bonanza “合同制作一个简单的 [测试条款](#tests-structure):

it('emits', async () => {
const res = await bonanza.send(
deployer.getSender(),
{ value: toNano('0.05') },
'time to emit',
);
});

在这里,res 对象的externals字段将包含已发送的[外部信息](/book/external)列表。 让我们访问它,以解析通过调用 Tact 代码中的 emit()(或简称 emitted)发送的第一条信息:

it('emits', async () => {
// ... 之前的代码 ...
// 我们只需要观察到的消息正文:
const firstMsgBody = res.externals[0].body;
// 现在,我们来解析它,因为我们知道它是一条文本消息。
// 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?绝对邪恶。"
console.log(firstMsgText);
});

要解析第二条发出的信息,我们可以手动使用一堆 .loadSomething() 函数,但这样做太麻烦了—如果 Ballroom [Struct][struct] 的字段发生变化,就需要重新开始。 当你以这种方式编写大量测试时,可能会适得其反。

幸运的是,Tact 编译器会自动为合约生成 TypeScript 绑定(或封装),在测试套件中重新使用它们非常容易。 它们不仅有一个你正在测试的合约的包装器,而且还导出了一堆辅助函数来存储或加载合约中定义的 [Structs][struct] 和 [Messages][message] 。 后者的命名方式与 [Structs][struct] 和 [Messages][message] 一样,只是在前面加上了 load 前缀。

例如,在我们的例子中,我们需要一个名为 loadBallroom() 的函数,用于将 [Slice][slice]解析为 TypeScript 中的 Ballroom [Struct][struct] 。 要导入它,要么键入名称,让集成开发环境建议自动导入,要么查看测试套件文件的顶部—应该有类似的一行:

import { Bonanza } from '../wrappers/Bonanza';
// ^ 这里可以导入 loadBallroom

现在,让我们来解析第二条发出的信息:

it('emits', async () => {
// ... 之前的代码 ...
// 我们只需要观察到的消息正文:
const secondMsgBody = res.externals[1].body;
// 现在,让我们解析它,知道它是 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 绑定。

处理退回的邮件

send 带有 bounce: true 时,信息会在出错时反弹。确保编写相关的 bounced() 消息接收器,并优雅地处理被退回的消息:

bounced(msg: YourMessage) {
// ...alright squad, let's bounce!...
}

请记住,在 TON 中被退回的邮件正文中只有 224224 个可用数据位,而且没有任何引用,因此无法从中恢复很多数据。 不过,您仍然可以看到邮件是否被退回,从而可以创建更稳健的合同。

了解更多有关退信和收信人的信息:退信

实验实验室设置

如果你对 Blueprint 的测试设置感到不知所措,或者只是想快速测试一些东西,不用担心—有一种方法可以建立一个简单的游戏场作为实验实验室,来测试你的想法和假设。

  1. 创建新的蓝图项目

    这将防止任意代码和测试污染您现有的程序。

    新项目可以取任何名字,但我会取名为 “Playground”,以表达正确的意图。

    要创建它,请运行以下命令:

    Terminal window
    # 推荐
    yarn create ton tact-playground --type tact-empty --contractName Playground

    从 0.20.0 开始的 Blueprint 版本会自动为新合约启用 wrappers/ 中的调试模式,因此我们只需调整测试套件并准备好我们的 Playground 合约即可进行测试。

  2. 更新测试套件

    移动到新创建的 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') }, // ← 在这里你可以增加发送的纳币值
    'plays',
    );
    console.log("Address of our contract: " + playground.address);
    console.log(res.externals); // ← 在这里可以看到 emit() 调用的结果
    });
  3. 修改合同

    用以下代码替换contracts/playground.tact中的代码:

    contracts/playground.tact
    import "@stdlib/deploy";
    合同 Playground with Deployable {
    receive("plays") {
    // NOTE: write your test logic here!
    }
    }

    此设置的基本思想是将要测试的代码放入 receiver function 中,以响应 string 消息 "plays"

    请注意,您仍然可以在[接收器](/book/contracts#receiver-functions)之外编写任何有效的 Tact 代码。 但为了测试它,你需要在其中编写相关的测试逻辑。

  4. 我们来测试一下!

    这样,我们的实验装置就完成了。 要执行我们为 “Playground “合约准备的单个测试,请运行以下程序:

    Terminal window
    yarn test -t plays

    从现在起,您只需修改 Tact 合同文件中已测试的 [receiver function](/book/contracts#receiver-functions)的内容,然后重新运行上述命令,就可以对某些内容进行测试。 冲洗并重复这个过程,直到你测试了你想测试的东西。

    为了简化和更干净的输出,您可以在 package.json 中为 scripts 添加一个新字段,这样您只需运行 yarn lab 即可在一个字段中完成构建和测试。

    在 Linux 或 macOS 上,它看起来就像这样:

    {
    "脚本":{
    "lab""blueprint build 1>/dev/null && yarn test -t plays"
    }
    }

    下面是它在 Windows 上的样子:

    {
    "脚本":{
    "build""blueprint build | out-null",
    "lab""yarn build && yarn test -t plays"
    }
    }

    要运行

    Terminal window
    纱线实验室