跳转到内容

调试 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/ 的目录中:

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 仍然可以用于 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 调试” 方法,就必须

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

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

首先,让我们在 contracts/simple_counter.tact 中调用 dump(),这将把 msg 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()调用产生的。

使用expect()说明期望

编写测试不可或缺的部分是确保你的期望与观察到的现实相吻合。 编写测试不可或缺的部分是确保你的期望与观察到的现实相吻合。 为此,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 函数的结果类型提供了一些额外的辅助方法。 请注意,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 constant
const receiverHandledTx = res.transactions[1];
expect(receiverHandledTx.description.type).toEqual('generic');
// Needed to please TypeScript
if (receiverHandledTx.description.type !== 'generic') {
throw new Error('Generic transaction expected');
}
// Total fees
console.log('Total fees: ', receiverHandledTx.totalFees);
// Compute fee
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);
// Now we can do some involved checks, like limiting the fees to 1 Toncoin
expect(
(computeFee ?? 0n)
+ (actionFee ?? 0n)
).toBeLessThanOrEqual(toNano('1'));

有故意错误的交易

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

Blueprint中此类Jest测试闭包的示例:

tests/YourTestFileHere.spec.ts
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 Struct
struct 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 绑定(或封装),在测试套件中重新使用它们非常容易。 它们不仅有一个你正在测试的合约的包装器,而且还导出了一堆辅助函数来存储或加载合约中定义的 StructsMessages 。 后者的命名方式与 StructsMessages 一样,只是在前面加上了 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 中被退回的消息正文中只有 224224 个可用数据位,而且没有任何引用,因此无法从中恢复很多数据。 不过,您仍然可以看到消息是否被退回,从而可以创建更稳健的合约。

了解更多关于退回消息和接收者的信息:退回消息

实验室设置

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

  1. 创建新的Blueprint项目

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

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

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

    Terminal window
    # recommended
    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') }, // ← 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
    });
  3. 修改合约

    用以下代码替换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 代码。 但为了测试它,你需要在其中编写相关的测试逻辑。

  4. 我们来测试一下! 我们来测试一下!

    这样,我们的实验装置就完成了。 要执行我们为 “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