本帖最后由 riyad 于 2025-3-8 23:13 编辑
3 s" [1 i. A- H9 d3 o; b/ X" f3 [/ L; p$ i; N/ s& S
我们已经编写了第一个 FunC 合约,并已成功编译。接下来要做什么呢? 在这一课中,我们将学习如何确保我们的合约代码确实能按预期运行。让我提醒你我们对代码的期望: 我们的合约应该在每次收到消息时保存一个发件人地址,并且在调用 getter 方法时返回最新的发件人地址。 很好,但我们如何确保它能正常工作呢?我们可以将它部署到区块链上(我们很快就会在下一课中这样做), TON 有一些工具,可以让我们在本地模拟某些行为. 这需要借助 sandbox. 这 library 允许您模拟任意的 TON 智能合约,向它们发送消息并运行获取方法,就像它们部署在真实网络上一样。 因为我们有自己的 TypeScript 环境,所以我们可以借助另一个库创建一系列测试 - jest. 这样,我们就有了一个测试套件,可以用不同的输入数据模拟所有重要行为,并检查结果。这是编写、调试和全面测试合约的最佳方法,然后再将它们发布到网络上。 & Y; s5 e# a8 X9 e1 t
准备测试套件首先,假设你现在位于项目的根目录,让我们安装 sandbox, jest 以及一个我们需要与 TON 实体交互的库 - ton: % F1 j; a2 R! J9 c6 d9 Z# z7 ]2 r" r
- yarn add @ton/sandbox jest ts-jest @types/jest @ton/ton --dev! j) o! l+ u. @4 k x
复制代码我们还需要在项目根目录下为 jest 创建一个 jest.config.js 文件,内容如下: - module.exports = {) u* w% c3 o. p+ V: S. s
- preset: 'ts-jest',
" T4 [; x, y9 e. l+ ]8 p; s - testEnvironment: 'node',
7 Z F( F" o5 L# r1 m+ r' | - };
复制代码现在,我们创建一个新文件夹 test,其中包含文件 main.spec.ts: - mkdir tests && cd tests && touch main.spec.ts0 L G8 Q# e0 q# J
复制代码我们设置好 main.spec.ts 文件,以便编写第一个测试: - describe("main.fc contract tests", () => {
* W9 ]) v( R4 Z" t# s/ _4 h
& @' V0 ~; q% r! c; E- it("our first test", async () => {% x. e4 L) Y! Y% }* t
- # g; e3 T' c6 ?; ?
- });
8 V$ n' X% X# ]) S8 r8 @ - . i3 p$ f8 e4 x% ^7 `6 o
- });
复制代码如果你从未编写过 TypeScript 测试,你一定要试试这种方法。在编写 TON 智能合约时,你会将一半以上的编程时间花在编写测试上。还记得我们的太空卫星例子吗?在这里也是一样,我们甚至在将合约部署到 testnet 之前,就已经模拟了每一种重要情况。 现在尝试运行命令 yarn jest 的根目录中。如果你已经正确安装了所有程序(跟我一起一步步安装),你应该会看到下面的内容: - PASS tests/main.spec.ts1 B }2 T+ Q1 P" J
- main.fc contract tests
# e7 s. K& \: _8 v0 A$ p% q - ✓ our first test (1 ms)
复制代码很好,让我们立即在 package.json 文件中创建另一个脚本运行快捷方式:
7 R# ?3 |# E$ B" ^- y& I; T- {; R) F* u0 ?0 G$ J& y" H3 B9 x
- ... our previous package.json keys
4 W T- s! f& o" d - "scripts": {
, P" o0 P! N" a$ K - ... previous scripts keys+ Y' Z! D1 M/ X3 m0 D
- "test": "yarn jest"
& M$ ~% w9 d; `' y$ O - }
! x2 R' ?! F; u" R# [( ` - }
复制代码 创建合约实例为了编写第一个测试,我们需要了解如何借助 sandbox. 我们之前讨论过,我们的合约代码在编译后会存储为一个 Cell。在 build/main.compiled.json 文件中,我们有一个 Cell 的十六进制表示法。让我们把它导入我们的测试文件,并从以下文件中导入 Cell 类型 ton-core: - import { Cell } from "@ton/core";' @, k0 H" ^' ~" |! Z
- import { hex } from "../build/main.compiled.json";; @% }3 T9 k1 l$ J5 a
- 5 U5 e. p; U7 G Y
- describe("main.fc contract tests", () => { e9 `: l( q+ B* ?. e: u: z) e, I
N) B) H5 ~; l- it("our first test", async () => {
k/ v* J9 d- J# B9 a5 d" M - 7 l: W5 u8 d# p7 L0 S2 R% o8 f- m! @
- });; u1 m- ?2 D* _1 e- W
- 5 K; Y9 J& E; q' v- z8 g
- });
复制代码现在,要恢复十六进制并获得真正的 Cell,我们将使用这条命令: const codeCell = Cell.fromBoc(Buffer.from(hex, "hex"))[0]. 我们用十六进制字符串创建一个缓冲区,并将其传递给 .fromBoc 方法。 让我们从 sandbox 库,并调用它的 .create() 方法。 - import { Cell } from "@ton/core";
t% j. X( w7 Q. U% p. D4 x( T - import { hex } from "../build/main.compiled.json";; }0 f7 F& \5 B* t; D4 I" H# i
- import { Blockchain } from "@ton/sandbox";7 d% O, L% v# |/ ]: ^8 L
/ V O( L: w/ u- } f- describe("main.fc contract tests", () => {5 M8 W, v" M' T% |
- it("our first test", async () => {
3 G" z+ L9 {; ?+ f, G5 p9 R - const blockchain = await Blockchain.create();) ^7 \5 ~$ B( A, R& D
- # R3 k4 k/ |& K/ R0 x4 _( I
- });
; h- S8 Z6 J; C9 u& Q - });
复制代码现在,让我们准备好获取一个合约实例来进行交互。Sandbox 的文档指出,推荐的使用方法是使用 Contract 从 ton-core 这对我们意味着什么?让我们创建一个新文件夹 wrappers 和一个名为 MainContract.ts 其内. 该文件将实现并导出我们合约的包装器。 - mkdir wrappers && cd wrappers && touch MainContract.ts0 V, |. c4 o* u& K0 K$ {
复制代码确保从项目根目录运行此命令序列 打开 MainContract.ts 编辑用. 让我们导入一个 Contract 从 ton-core 库,然后定义并导出一个实现 Contract. - import { Contract } from '@ton/core';
( z& K$ o ?" d$ y
- h1 D* V E K! ^- export class MainContract implements Contract {
/ S. L% _8 Q3 q* c2 W7 c( y0 h -
8 x1 Y* J/ ] F( G6 N - }
复制代码如果您查看一下 Contract 接口 - 你会看到它需要 address, init 和 abi 参数。 我们只会将 address 和 init 用于我们的目的。 为了使用它们,我们要为我们的 MainContract 类定义一个构造函数。 如果您不知道我们这里所说的类和构造函数是什么意思,那么您最好多读一些关于面向对象编程(Object Oriented Programming)的内容。FunC 并不要求这样做,但为了更好地使用 TypeScript 进行测试,您应该了解这些基本概念。 - import { Address, Cell, Contract } from "@ton/core";
k- t- n2 M/ V' u( h2 a1 o
9 G# N( ], l3 h! y4 C- export class MainContract implements Contract {! @+ |. u2 y) g9 c* P* C
- constructor(
& s9 [. b- \: _' T. W0 a: c8 I - readonly address: Address,
* }+ ]$ J: X# \6 G+ r - readonly init?: { code: Cell; data: Cell }
+ @3 W5 N0 f4 O; J o* X - ) {}
( F6 Z, s# u/ A! G - }
复制代码init 属性非常有趣。它具有我们合约的初始状态。代码显然就是合约的代码。数据则更有趣一些。还记得我们在讨论合约的 c4 持续存储吗? 有了这个 data 在Cell中,我们可以定义一旦我们的合约首次执行,该存储空间中会有哪些内容。这两个值都有 Cell 类型,因为它们在合约生命周期内存储在 TVM 的内存中。相同类型 code 和 data Cell还用于计算我们合同的未来地址。 请注意,我们还导入了 Address 和 Cell 从 @ton/core 库. 在 TON 中,合约的地址是确定的。我们甚至可以在部署合约之前就知道它们。我们将通过几个步骤了解如何做到这一点。 现在,让我们为我们的 MainContract 类 - createFromConfig. 我们暂时不会使用任何配置参数,但今后我们会假设需要输入数据才能创建合约实例。 - import { Address, beginCell, Cell, Contract, contractAddress } from "@ton/core";; ` C) k( c( E7 ]( e F3 C2 Q9 ~
2 i/ [& ?" j; N* b3 |6 n; R- export class MainContract implements Contract {
* j5 k: [, S i- X: Q: [ z0 m - constructor(" _. e, e3 J( z
- readonly address: Address,8 v0 d+ K Q% z+ o, x
- readonly init?: { code: Cell; data: Cell }
: k3 p- J! `4 Y' O" o - ) {}
# F/ K. H+ v+ u- x3 j - . A. S1 q0 j W) _* n. o) H9 H" i
- static createFromConfig(config: any, code: Cell, workchain = 0) {4 g+ l* c2 K; e4 V. G0 h2 {
- const data = beginCell().endCell();
$ X0 g# B$ {! r - const init = { code, data };, e# h& V, S4 g, [
- const address = contractAddress(workchain, init);
: a4 P' _$ B6 V g - + S$ z* C1 ^( ~
- return new MainContract(address, init);
. _" B+ Q J. a: e9 \9 r - }& l! s' S7 ]3 m6 b- p
- }
复制代码我们的 createFromConfig 方法接受一个 config 参数(暂时忽略), code 是一个包含我们合约的编译代码的 Cell 和一个 workchain 定义了合约要放置的 TON 工作链。目前,ton 上只有一个工作链 - 0。 为了更好地理解这段代码,让我们从返回的结果开始。我们正在创建 MainContract 类的一个新实例,并按照其构造函数中的定义,我们必须传递其合约的未来 address (如前所述,我们可以计算出)和合约的 init 状态。 我们通过从 @ton/core 库导入的函数计算地址. 通过 workchain 和 init 状态参数,以便获取它。 init 状态只是一个具有 code 和 data 属性的对象. code 被传入至我们的方法中,而 data 现在只是一个空Cell。我们将学习如何将 config 数据转换成 Cell 格式,以便我们可以使用 config 数据中 init 我们的合约状况。 总而言之 createFromConfig 正在接受一项 config 数据,我们将来会将这些数据存储在合约的持续存储和 code 的实例。作为回报,我们会得到一个合约实例,我们可以借助 sandbox. 让我们回到我们的 tests/main.spec.ts 并执行以下步骤: 2 A9 `# q8 h' V$ {
- import { Cell } from "@ton/core";. A. D5 S8 w8 P" R# U' j5 P, \
- import { hex } from "../build/main.compiled.json";
+ j: I; Y ~; S; W - import { Blockchain } from "@ton/sandbox";
4 X1 I8 g D2 Z& n6 C1 Y; l: ] - import { MainContract } from "../wrappers/MainContract";
* ?' v7 }1 }; B! s: Z
|+ l2 A2 X% c* f0 u0 A8 R- describe("main.fc contract tests", () => {
, _3 N: a( z, s! |2 e0 }( F# T - it("our first test", async () => {! \2 j. g" r& p0 q9 K) M" g: p, d
- const blockchain = await Blockchain.create();
2 k/ a8 _6 }$ `4 Q, M - const codeCell = Cell.fromBoc(Buffer.from(hex, "hex"))[0];
4 R/ U6 b4 d" y4 O0 d0 ?% M1 J
6 e! N5 V2 M4 h( T6 X+ `: u- const myContract = blockchain.openContract(! m: G2 w( X6 o6 L: a
- await MainContract.createFromConfig({}, codeCell)
_" V" x* A n! x1 [ - );
! @* j. W# `1 m- ]# p/ b - });
2 D4 h: q9 ~. S+ r9 U6 G - });
复制代码至此,我们就有了一个智能合约实例,我们可以通过许多与真实合约类似的方式与之交互,以测试预期行为。 与合约互动@ton/core 库为我们提供了另一个伟大的组件,名为 Address. 众所周知,TON 区块链上的每个实体都有一个地址。在实际中,如果您想从一个合约(例如钱包合约)向另一个合约发送信息,您需要知道两个合约的地址。 在编写测试时,我们会模拟一个合约,当我们与合约交互时,我们模拟的合约地址是已知的。不过,我们还需要一个钱包,用来部署我们的合约,另一个钱包用来与我们的合约交互。 在 sandbox 这样做的方式是,我们称之为 treasure 方法,并为其提供一个助记词: const senderWallet = await blockchain.treasury("sender"); 模仿内部信息让我们继续编写测试。既然我们已经有了一个合约实例,那就向它发送一条内部信息吧,这样我们的发件人地址就会保存在 c4 存储空间中。我们还记得,要与我们的合约实例交互,我们需要使用包装器。 回到我们的文件 wrappers/MainContract.ts 并在封装器中创建一个名为 sendInternalMessage. 最初看起来是这样的: - async sendInternalMessage(; ^' e- G, s! O4 _9 P+ Y; g& R
- provider: ContractProvider," \0 y3 ]6 R9 J2 a& S9 G
- sender: Sender,
j3 ?# p8 l' e - value: bigint
- {, Y! P1 }3 N - ){
" ]0 H2 N# T& g) m: a2 P" L -
4 q9 h, m2 k% v+ i) D* G. N1 P: y - }
复制代码我们的新方法将接收 ContractProvider 类型的参数、Sender 类型的信息发送者和信息值 value. 通常情况下,使用此方法时我们不必担心传递 ContractProvider,它将作为合约实例内置功能的一部分在底层传递。不过,不要忘了从 ton-core 库中导入这些类型. 让我们在新方法中实现发送内部信息的逻辑。它看起来是这样的: - async sendInternalMessage(
k7 Q( b8 u$ S" j6 m& W - provider: ContractProvider,
; [- b7 g0 d: W4 m - sender: Sender,
; P' o5 j" c+ f: E' Q. g9 ` - value: bigint4 h2 p$ X6 I9 J0 l$ C
- ) {1 N$ e' z+ S5 x! Q9 o v8 D/ u* E
- await provider.internal(sender, {
4 N, \6 q9 G7 m - value,# }& ]2 Z8 e' c: q
- sendMode: SendMode.PAY_GAS_SEPARATELY," Q# U3 H1 _+ ]9 k: p6 ~
- body: beginCell().endCell(),; t0 K7 M$ X& f' p% S
- });
) a- f* r1 P! C# C. K2 T - }
复制代码您可以看到,我们正在使用 provider 并调用其名为 internal. 我们通过了 sender 作为第一个参数,然后我们用参数组成一个对象,其中包括: 请不要忘记将我们使用的所有新类型和实体从 @ton/core 库. 因此,wrappers/MainContract.ts 的最终代码如下所示:
5 d$ p5 x7 S3 Z! M. i- import { Address, beginCell, Cell, Contract, contractAddress, ContractProvider, Sender, SendMode } from "@ton/core";
, z( {( S/ a0 v4 ]
2 e* R8 _1 N5 P% ^% y- export class MainContract implements Contract {
8 T4 S2 x g4 {$ F) y0 n+ B+ L, C - constructor(
* k) b% I5 F2 L4 ]1 X0 u - readonly address: Address,
8 \, R" y; ^) M2 j' a# r - readonly init?: { code: Cell; data: Cell }/ i: ` _% m2 H) ^/ d; G# {- I, H
- ) {}
* S3 Q! D: d- b: a: ~$ p$ ] - \, A' G8 |8 _" B$ f, Z- v; T0 d
- static createFromConfig(config: any, code: Cell, workchain = 0) {# H, Q3 a/ B+ @
- const data = beginCell().endCell();/ ]4 ~% {5 v3 d. e0 q
- const init = { code, data };; H4 k5 f8 [ G; T
- const address = contractAddress(workchain, init);
3 J3 c" p/ ?& R/ H7 \
: b1 P$ j) Q# j- [5 |- return new MainContract(address, init);
! \0 f; Q( X) Z! }: K# _/ L& O - }
% |% w2 o# X3 P( D/ @ B+ d - 3 K! }# |0 X+ a
- async sendInternalMessage(
0 Q8 y# `2 l* P3 {0 @% e9 Q; Y - provider: ContractProvider,0 J+ N3 i& W: v9 i* h0 y* y' I% S7 ^
- sender: Sender,/ i2 L8 Z. V7 w+ f& c5 Y- N
- value: bigint0 f G9 |8 h9 `4 a9 c
- ) {
) L* U8 h" y6 s* J: |8 f4 [ - await provider.internal(sender, {
6 e# y9 o" Y& _) y9 n5 R: u/ l2 [ - value,- E& o# m4 ^# Z p
- sendMode: SendMode.PAY_GAS_SEPARATELY,: \% x2 l! N& g* k) f8 I
- body: beginCell().endCell(),9 W9 Q9 B" U7 m% z# r
- });
6 N7 W- [+ x9 U; |# m- ~) C3 Y - }
|: ?; G$ u* u9 F; l - }
复制代码在我们的 tests/main.spec.ts 中调用该方法会是这样: - const senderWallet = await blockchain.treasury("sender");& Y; ]. W+ J/ D( I& e3 n3 O
- myContract.sendInternalMessage(senderWallet.getSender(), toNano("0.05"));
复制代码请注意我们是如何使用 toNano 辅助函数 @ton/core 库将字符串值转换为nano gram格式。
1 l+ i2 V7 a5 J$ k: A' O6 A$ B( v
9 Y* r! \9 W9 t4 B( b% ?调用 getter 方法我们需要在合约包装器上再创建一个方法,即 getData 方法,该方法将运行合约的 getter 方法,并返回 c4 存储器中的结果。 这就是我们的 getter 方法: - async getData(provider: ContractProvider) {6 L1 W$ ]* n' t/ o+ R
- const { stack } = await provider.get("get_the_latest_sender", []);" `9 o% C9 P* Q. \. J
- return {
# k; O$ S5 W0 O- F$ |) x0 S3 { - recent_sender: stack.readAddress(),( ?3 E* p9 Y1 z0 L3 a
- };0 Q: ^1 I3 i1 o0 y _
- }
复制代码就像我们发送内部信息一样,我们使用的是提供发送者及其方法。在本例中,我们使用的是 get 方法。 然后,我们从接收到的 stack 并将其作为结果返回。 编写测试就是这样。我们已经完成了所有的准备工作,现在要编写实际的测试逻辑。下面是我们的测试场景: 看起来非常简单可行!让我们行动起来吧 沙盒团队又为我们提供了一个神奇的测试工具。我们可以安装额外的 @ton/test-utils 软件包,方法是运行 yarn add @ton/test-utils -D 这将使我们能够使用 .toHaveTransaction 为 jest matcher 添加额外的辅助工具,以方便测试。我们还需要在我们的 tests/main.spec.ts 安装后。 让我们看看基于上述场景的测试代码是怎样的。 - import { Cell, toNano } from "@ton/core"; A2 P, K4 P/ v$ a. \: Z' l- t' ?
- import { hex } from "../build/main.compiled.json";
! R$ R5 z# T: c E- G - import { Blockchain } from "@ton/sandbox";
4 [- |9 y. U& o+ c$ A - import { MainContract } from "../wrappers/MainContract";- i7 H( N+ B9 v. q3 ]
- import "@ton/test-utils";
" I Q! p! Z. N/ m* R. }% s) Z
% G# z+ _7 m! t* c3 G: e) s- describe("main.fc contract tests", () => {
( q/ d- S* d6 H Q9 a - it("should get the proper most recent sender address", async () => {
5 y' a0 W( K% Z& g7 @: I - const blockchain = await Blockchain.create();
9 l s6 U7 P5 A2 R+ y Q% z7 U - const codeCell = Cell.fromBoc(Buffer.from(hex, "hex"))[0];/ p( [* ^4 F/ t# x
& W5 v4 S' q/ Q; e4 J- const myContract = blockchain.openContract(
. m2 ?! a2 Z: t) U0 y - await MainContract.createFromConfig({}, codeCell)
7 e6 @ c# {. i5 J9 n( Y& ]2 Z' u - );( ?% m! s% F* O
# O: Q% N5 V/ _1 a( U3 p! m1 s/ j- const senderWallet = await blockchain.treasury("sender");& @' e* F* ~( H1 c% R% v+ Z
- + C, o$ K+ ]& M4 {' K i' O9 ?
- const sentMessageResult = await myContract.sendInternalMessage(
( j" B, J6 I+ t J6 o - senderWallet.getSender(),
( _% w! M/ B& a; X% h3 p- { - toNano("0.05")/ |0 s% G( L7 `' }$ ~
- );
* n1 g7 a1 R* q- J% I - . @! E' s: q2 Q1 M. h/ [+ q
- expect(sentMessageResult.transactions).toHaveTransaction({7 c) ~8 v" s1 W/ H+ |
- from: senderWallet.address,5 \/ y. W6 R) d# m
- to: myContract.address,
. C+ A; K' E' [% ~ - success: true,
1 L- R' U1 j8 d: g9 ~! m7 \ - });7 K! {3 W9 W7 E; x- Z$ O" m$ ^' J
8 h% b; {3 L0 X1 C* H) R- const data = await myContract.getData();; G% e8 @/ @. D
- n i9 O8 K n8 S5 ]7 O
- expect(data.recent_sender.toString()).toBe(senderWallet.address.toString()); R% d6 {- k( Y( g2 y
- });7 u, w5 n3 [8 I4 G6 ]; Q
- });
复制代码我们使用 Jest 功能来确保这一点: 我们还将 "我们的第一次测试 "更名为 "应获得正确的最新发件人地址",因为我们希望测试名称始终可读。 运行测试瞧!我们已经准备好运行测试了。只需运行我们的命令 yarn test 在终端中,如果你和我一起做了所有的事情,你也会得到类似的结果: - PASS tests/main.spec.ts1 P8 C, X( z, G/ Y7 H, D8 T- t5 Q
- main.fc contract tests U9 ^2 @% q& G0 B
- ✓ should get the proper most recent sender address (444 ms)
! p0 ]. L+ |3 [' _) U
! }5 F/ C7 t! X |& H2 y8 i- Test Suites: 1 passed, 1 total8 ~- V5 b8 b/ ^. k( i7 y
- Tests: 1 passed, 1 total
7 Q* O4 E: D: O4 X2 U$ { - Snapshots: 0 total" O1 C" d9 T* }1 k0 n' }5 h
- Time: 4.13 s, estimated 5 s
复制代码我们要做的最后一件事是确保每次运行测试时,同时运行编译器脚本。这有助于提高我们的工作效率。大部分开发工作都是编写功能代码,然后运行测试。让我们简化这一切: 更新 package.json 文件,使其看起来像这样: - {
; d7 |! l" w: M5 |, w! c - ... our previous package.json keys0 A( H' r+ V4 i# g
- "scripts": {
0 b5 y3 k5 n& L4 w- ^& x - ... previous scripts keys4 o8 E6 [$ m7 h3 w
- "test": "yarn compile && yarn jest"
; Q( L3 r, f( | - }
X* t+ |; Q. P - }
复制代码 在下一课中,我们将构建部署途径,并学习如何在链上测试真正的部署合同
* y) W3 v1 Q; t4 P6 d1 \4 P
& | Z* b; U* E m# p
4 k) {; F, G5 ^( [1 ]4 j |