本帖最后由 riyad 于 2025-3-8 23:13 编辑 $ N9 T8 K, h/ j5 v2 T, a7 k# u
0 Y5 g+ L4 ?- a- [- t, z, l
我们已经编写了第一个 FunC 合约,并已成功编译。接下来要做什么呢? 在这一课中,我们将学习如何确保我们的合约代码确实能按预期运行。让我提醒你我们对代码的期望: 我们的合约应该在每次收到消息时保存一个发件人地址,并且在调用 getter 方法时返回最新的发件人地址。 很好,但我们如何确保它能正常工作呢?我们可以将它部署到区块链上(我们很快就会在下一课中这样做), TON 有一些工具,可以让我们在本地模拟某些行为. 这需要借助 sandbox. 这 library 允许您模拟任意的 TON 智能合约,向它们发送消息并运行获取方法,就像它们部署在真实网络上一样。 因为我们有自己的 TypeScript 环境,所以我们可以借助另一个库创建一系列测试 - jest. 这样,我们就有了一个测试套件,可以用不同的输入数据模拟所有重要行为,并检查结果。这是编写、调试和全面测试合约的最佳方法,然后再将它们发布到网络上。
& O* E! g6 |7 O% {8 a: F" N准备测试套件首先,假设你现在位于项目的根目录,让我们安装 sandbox, jest 以及一个我们需要与 TON 实体交互的库 - ton: ( p4 j1 V) i5 H* }
- yarn add @ton/sandbox jest ts-jest @types/jest @ton/ton --dev" J8 ]4 Y) f. ]8 O9 Q9 ~
复制代码我们还需要在项目根目录下为 jest 创建一个 jest.config.js 文件,内容如下: - module.exports = {2 r/ h# `0 X4 u2 Y1 H
- preset: 'ts-jest',
_. {" {4 [2 ~) w - testEnvironment: 'node',& q: o, }8 i' O4 m3 o" G
- };
复制代码现在,我们创建一个新文件夹 test,其中包含文件 main.spec.ts: - mkdir tests && cd tests && touch main.spec.ts
2 h Q. n* q; B& ]5 X6 b
复制代码我们设置好 main.spec.ts 文件,以便编写第一个测试: - describe("main.fc contract tests", () => {- w1 K! K9 P) m4 S% {2 I; ~
, [- W7 c9 J- w+ o1 O2 J4 g- it("our first test", async () => {6 g5 q1 [: `& D
0 i0 \9 k* A3 H: A0 j- });6 B' U5 u8 A: t% { H7 j9 Q/ s! I
- : h A5 ~+ }6 R2 I
- });
复制代码如果你从未编写过 TypeScript 测试,你一定要试试这种方法。在编写 TON 智能合约时,你会将一半以上的编程时间花在编写测试上。还记得我们的太空卫星例子吗?在这里也是一样,我们甚至在将合约部署到 testnet 之前,就已经模拟了每一种重要情况。 现在尝试运行命令 yarn jest 的根目录中。如果你已经正确安装了所有程序(跟我一起一步步安装),你应该会看到下面的内容: - PASS tests/main.spec.ts4 K( |/ ?& h3 D: Y# x
- main.fc contract tests
5 l4 H! E$ [# x - ✓ our first test (1 ms)
复制代码很好,让我们立即在 package.json 文件中创建另一个脚本运行快捷方式: 4 H4 f5 P# i) J& n
- {
' ?2 R1 x; `4 m( t: h: U* v - ... our previous package.json keys
: a3 d; V6 ~3 m a x2 w# {0 B; e \ - "scripts": {
0 D( L' V# `& I, D# Z- T - ... previous scripts keys
( `: j, k) C+ y0 c5 L$ P - "test": "yarn jest"6 |. R3 b/ V0 n; _: u3 t
- }
* J0 n/ g O# i& _ R8 ?, x% k - }
复制代码 创建合约实例为了编写第一个测试,我们需要了解如何借助 sandbox. 我们之前讨论过,我们的合约代码在编译后会存储为一个 Cell。在 build/main.compiled.json 文件中,我们有一个 Cell 的十六进制表示法。让我们把它导入我们的测试文件,并从以下文件中导入 Cell 类型 ton-core: - import { Cell } from "@ton/core";1 V" L3 N! N9 z2 E8 k
- import { hex } from "../build/main.compiled.json";
6 T% G/ B5 B2 E: B - 7 \8 X0 B }8 |# A5 ], Y& P
- describe("main.fc contract tests", () => {
+ w# ? R0 _' P' o1 k/ K# h - 1 [- |+ _: Z+ l5 J
- it("our first test", async () => {
5 u I% I; K0 G4 H7 R) ?: V$ _
: c! V m0 ` u8 X5 x& h- L- });
" `% p! j( u& Z7 P. c* W/ |0 f, V
4 D* q; Z! P& M/ R: ~- });
复制代码现在,要恢复十六进制并获得真正的 Cell,我们将使用这条命令: const codeCell = Cell.fromBoc(Buffer.from(hex, "hex"))[0]. 我们用十六进制字符串创建一个缓冲区,并将其传递给 .fromBoc 方法。 让我们从 sandbox 库,并调用它的 .create() 方法。 - import { Cell } from "@ton/core";; U& v4 w6 y" t1 @8 q
- import { hex } from "../build/main.compiled.json";
, L- |1 D% m( S, Z( |' j3 { - import { Blockchain } from "@ton/sandbox";
7 M. J7 k; _8 x9 D, r6 x
@3 X. A j b- describe("main.fc contract tests", () => {: P1 \) d" h9 Y
- it("our first test", async () => {# i& {3 R9 |7 Y- l
- const blockchain = await Blockchain.create();7 {# v y* j Z9 @
7 Z" A# R) R: V9 t) G+ u- });8 Q4 g! C0 k# R2 j# J
- });
复制代码现在,让我们准备好获取一个合约实例来进行交互。Sandbox 的文档指出,推荐的使用方法是使用 Contract 从 ton-core 这对我们意味着什么?让我们创建一个新文件夹 wrappers 和一个名为 MainContract.ts 其内. 该文件将实现并导出我们合约的包装器。 - mkdir wrappers && cd wrappers && touch MainContract.ts
; U: c+ I; ~# w$ H/ F/ Y0 J% C
复制代码确保从项目根目录运行此命令序列 打开 MainContract.ts 编辑用. 让我们导入一个 Contract 从 ton-core 库,然后定义并导出一个实现 Contract. - import { Contract } from '@ton/core';9 e3 i, w. D0 H4 i% i
* ^& b4 t+ u7 [; s- export class MainContract implements Contract {; {4 x( ]9 V" c6 Q% a
- 7 Z" p g4 |) N% P0 C, | H
- }
复制代码如果您查看一下 Contract 接口 - 你会看到它需要 address, init 和 abi 参数。 我们只会将 address 和 init 用于我们的目的。 为了使用它们,我们要为我们的 MainContract 类定义一个构造函数。 如果您不知道我们这里所说的类和构造函数是什么意思,那么您最好多读一些关于面向对象编程(Object Oriented Programming)的内容。FunC 并不要求这样做,但为了更好地使用 TypeScript 进行测试,您应该了解这些基本概念。 - import { Address, Cell, Contract } from "@ton/core";- P, q# V7 a* q4 q. y
: ^: _4 `7 P( f6 d- export class MainContract implements Contract {
M$ f2 S! [! S( r- Y1 l - constructor(8 W3 W$ g, A* \8 N
- readonly address: Address,: c/ a+ q C% ^) U7 D0 L2 z+ S% G- S
- readonly init?: { code: Cell; data: Cell }
$ ?! y% t" y$ q: o% ?# o: J, V - ) {}( C- t5 c; X% F, k( r
- }
复制代码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";
( W0 N4 C- b8 D7 p. ?: u b1 x - - R( h- j2 {( t! m! G7 d! ? Z" c
- export class MainContract implements Contract {6 s+ ?/ Y p* G% a4 ^
- constructor(- c: ?6 w9 b) r7 ^
- readonly address: Address," `* O$ M0 @8 U
- readonly init?: { code: Cell; data: Cell } o% P* q" I) ?; O4 U
- ) {}. D' g- ]1 j! `# D- a! ^" [
8 n6 J; I2 K% I+ c) W7 r- static createFromConfig(config: any, code: Cell, workchain = 0) {
& \/ O$ j ^- R - const data = beginCell().endCell();
) c' j( M- ~1 f- M6 {, U - const init = { code, data };/ ^0 D* U2 V2 ` g1 p
- const address = contractAddress(workchain, init);# l1 C* u5 G/ k3 k( ^
- % m' a7 y0 k+ { x" j5 k' B: C x
- return new MainContract(address, init);/ m. W! x2 U4 a
- }( I7 I. S$ S" R' X' s: Y
- }
复制代码我们的 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 并执行以下步骤:
* F" p' L/ q) w( H- import { Cell } from "@ton/core";7 S# W3 K8 y0 `' L$ t2 r+ [. _0 n
- import { hex } from "../build/main.compiled.json";
* n/ m. V. h0 x/ O - import { Blockchain } from "@ton/sandbox";
5 S& v% M3 p! J/ w& A - import { MainContract } from "../wrappers/MainContract";
" Y, A9 B; o: ]# _ - 3 r! \; @) \8 G, j' N" M* m9 Z
- describe("main.fc contract tests", () => {) D8 A$ Z3 `% f; x2 e/ E/ V
- it("our first test", async () => {" y, T" y. I2 f$ `8 C2 d% ]* H/ s
- const blockchain = await Blockchain.create();
8 h) x/ K! _3 `$ l/ k7 |5 A0 x - const codeCell = Cell.fromBoc(Buffer.from(hex, "hex"))[0];
# T* d: Q% L- v( X. R
; q+ Z; i1 Y* M- const myContract = blockchain.openContract(+ `/ B$ R* O# {- L5 [0 v
- await MainContract.createFromConfig({}, codeCell)
6 V7 k# R, B2 n - );. h6 T# i/ C1 B. c
- });7 p6 G" g8 e, ^+ ?3 I! f
- });
复制代码至此,我们就有了一个智能合约实例,我们可以通过许多与真实合约类似的方式与之交互,以测试预期行为。 与合约互动@ton/core 库为我们提供了另一个伟大的组件,名为 Address. 众所周知,TON 区块链上的每个实体都有一个地址。在实际中,如果您想从一个合约(例如钱包合约)向另一个合约发送信息,您需要知道两个合约的地址。 在编写测试时,我们会模拟一个合约,当我们与合约交互时,我们模拟的合约地址是已知的。不过,我们还需要一个钱包,用来部署我们的合约,另一个钱包用来与我们的合约交互。 在 sandbox 这样做的方式是,我们称之为 treasure 方法,并为其提供一个助记词: const senderWallet = await blockchain.treasury("sender"); 模仿内部信息让我们继续编写测试。既然我们已经有了一个合约实例,那就向它发送一条内部信息吧,这样我们的发件人地址就会保存在 c4 存储空间中。我们还记得,要与我们的合约实例交互,我们需要使用包装器。 回到我们的文件 wrappers/MainContract.ts 并在封装器中创建一个名为 sendInternalMessage. 最初看起来是这样的: - async sendInternalMessage( f: }. ^1 c2 T, I. B
- provider: ContractProvider,. S* Y$ C. O6 W% u; }% v+ R6 t3 _
- sender: Sender,4 L4 @/ a* U( S6 J
- value: bigint
. M/ O% ]: I+ D8 O* o5 a* ` - ){) Z( \& c* ^* F- v4 i
-
0 F" T5 C# Y. `4 z - }
复制代码我们的新方法将接收 ContractProvider 类型的参数、Sender 类型的信息发送者和信息值 value. 通常情况下,使用此方法时我们不必担心传递 ContractProvider,它将作为合约实例内置功能的一部分在底层传递。不过,不要忘了从 ton-core 库中导入这些类型. 让我们在新方法中实现发送内部信息的逻辑。它看起来是这样的: - async sendInternalMessage(4 F" q* {& W% T( n
- provider: ContractProvider,9 a; T( \+ Q+ }- p2 Q4 C
- sender: Sender,9 s5 i7 o/ }0 j) W! N8 l
- value: bigint. _6 m0 O/ K$ @) r' g
- ) {
. ]9 F! H8 C# _, \) J" M - await provider.internal(sender, {
( i/ d( p+ l0 ~0 R( S% r - value,
7 T6 R( O* F. Z7 R. t y( k - sendMode: SendMode.PAY_GAS_SEPARATELY,
/ e; R5 G, L3 f3 u: ` - body: beginCell().endCell(),
7 g6 X8 Q2 K; \! [ - });
8 n: K( d: ]7 x( t5 c& v a - }
复制代码您可以看到,我们正在使用 provider 并调用其名为 internal. 我们通过了 sender 作为第一个参数,然后我们用参数组成一个对象,其中包括: 请不要忘记将我们使用的所有新类型和实体从 @ton/core 库. 因此,wrappers/MainContract.ts 的最终代码如下所示:
1 M: H; |; u- u4 d! o0 n8 D; P( o! [3 w- import { Address, beginCell, Cell, Contract, contractAddress, ContractProvider, Sender, SendMode } from "@ton/core";! f6 I' }, j$ q! W
- : w) b: m, s, i1 W3 Q K1 d. o
- export class MainContract implements Contract {. v) f+ L. ^% P$ H' m
- constructor(1 ^) O$ J/ J' E
- readonly address: Address,
2 `* O( f( o+ H. q2 w$ S8 b. V& e - readonly init?: { code: Cell; data: Cell }
, T! _) m% a6 C1 N - ) {}
9 s% o# c: N) i- S/ B, u - ' A- s8 a" h x
- static createFromConfig(config: any, code: Cell, workchain = 0) {
v* R y6 g: [ - const data = beginCell().endCell(); }6 P* n% z5 ~; @4 J
- const init = { code, data };+ i' u: q, G8 |1 }
- const address = contractAddress(workchain, init);
; u7 i& d/ _" L: V6 @" |3 K* L3 L( A
! D$ o6 m$ ^. O0 v4 L- return new MainContract(address, init);7 _0 q* e& X3 [$ }
- }( C& t0 T& }1 h) ]2 j
- 5 ]4 n( Z/ }! C7 y. L* b
- async sendInternalMessage(& t3 ^* x0 p# j/ `: W$ t) ?& I
- provider: ContractProvider,: i# X D' @1 W
- sender: Sender,, t4 n- v; L1 o1 h- E% s
- value: bigint
4 s3 Y8 ~5 q1 g- F - ) {
& [$ D. |0 a5 W7 u; e - await provider.internal(sender, {* q+ q. e% A9 o9 M
- value,
, Y5 o4 r/ f# o$ f& U - sendMode: SendMode.PAY_GAS_SEPARATELY,
. C. `: d( C# S - body: beginCell().endCell(),1 o! {4 j7 g4 t" i1 L0 |: D# y$ q6 f7 C" `
- });
$ c% B- Y. N" q& V5 ] - }
C7 p* n3 A. D - }
复制代码在我们的 tests/main.spec.ts 中调用该方法会是这样: - const senderWallet = await blockchain.treasury("sender");$ K, O2 D: _2 S s# M: G& c. R
- myContract.sendInternalMessage(senderWallet.getSender(), toNano("0.05"));
复制代码请注意我们是如何使用 toNano 辅助函数 @ton/core 库将字符串值转换为nano gram格式。
- }' R- m4 R9 ~" u3 j3 ?3 z/ _& D: V0 U; l2 i- ~! {
调用 getter 方法我们需要在合约包装器上再创建一个方法,即 getData 方法,该方法将运行合约的 getter 方法,并返回 c4 存储器中的结果。 这就是我们的 getter 方法: - async getData(provider: ContractProvider) {
( |* q8 P9 r9 S - const { stack } = await provider.get("get_the_latest_sender", []);2 Q& n: \8 b: s" j2 | _
- return {
0 C. k4 p5 e& s; m0 ^* z( V/ n - recent_sender: stack.readAddress(),
4 n: `7 D5 w" W1 k. U - };4 } X2 y6 [- G' O. v0 a
- }
复制代码就像我们发送内部信息一样,我们使用的是提供发送者及其方法。在本例中,我们使用的是 get 方法。 然后,我们从接收到的 stack 并将其作为结果返回。 编写测试就是这样。我们已经完成了所有的准备工作,现在要编写实际的测试逻辑。下面是我们的测试场景: 看起来非常简单可行!让我们行动起来吧 沙盒团队又为我们提供了一个神奇的测试工具。我们可以安装额外的 @ton/test-utils 软件包,方法是运行 yarn add @ton/test-utils -D 这将使我们能够使用 .toHaveTransaction 为 jest matcher 添加额外的辅助工具,以方便测试。我们还需要在我们的 tests/main.spec.ts 安装后。 让我们看看基于上述场景的测试代码是怎样的。 - import { Cell, toNano } from "@ton/core";" e: u: h' I6 C# J1 M% W
- import { hex } from "../build/main.compiled.json";
! e8 l- M& L3 U# k, _4 V. { - import { Blockchain } from "@ton/sandbox";
/ x0 e; K: a- v# l$ k. [ - import { MainContract } from "../wrappers/MainContract";
) H x6 i: Q8 ~# Z( p7 y - import "@ton/test-utils";
' X0 N1 F4 A, E' i! I
/ L' c: g7 d% g" r; M2 N4 j- describe("main.fc contract tests", () => {
) C: V3 M M; L" k - it("should get the proper most recent sender address", async () => {
% h1 p1 P8 Y, j ~4 W, }) v - const blockchain = await Blockchain.create();
+ K/ P7 \. \- ]! i$ q - const codeCell = Cell.fromBoc(Buffer.from(hex, "hex"))[0];
* L, o" Z7 ~ ]# @$ X$ q1 Z: I4 v9 H - " ^; F# J$ m0 r3 ~2 p; E
- const myContract = blockchain.openContract( j3 J# v9 M( R5 G
- await MainContract.createFromConfig({}, codeCell)8 x1 K$ l+ B$ C: C
- );$ {2 j1 K- ]. M; u! g
- 2 v: i& q9 C3 a# F7 {( `6 U) I, c) H
- const senderWallet = await blockchain.treasury("sender");9 V' _% j1 E1 S$ V
- - O z) i" E2 r, k/ |, u; W3 n$ A
- const sentMessageResult = await myContract.sendInternalMessage(
: i) C1 Q- `! [+ D - senderWallet.getSender(),# p. }. u* t& E/ Q, y( e! V9 D
- toNano("0.05"), ^7 I. l2 A& h* X
- );
0 } K; G7 ~# \. p& V% Q/ H - , T: Z1 j7 N* f) s( O; U, p( k+ c
- expect(sentMessageResult.transactions).toHaveTransaction({
s' b. _$ N$ D/ P/ Y) X - from: senderWallet.address,
( E( l7 d7 Z% @ - to: myContract.address,. L! V6 L0 @6 L) [& f4 K2 L, _
- success: true,
+ Y0 @5 O7 `( k - });
9 Q* P; y. j0 T/ Z
/ ^. {6 Y# Q" D8 e; B3 K- const data = await myContract.getData();$ @1 M1 o N {+ ^+ [ f
. e+ ~: G9 \3 h" Y2 }( r, V- expect(data.recent_sender.toString()).toBe(senderWallet.address.toString());% I, p8 ]$ A @& V( ^
- });& _/ u( t# N6 E J9 q; i- e! O
- });
复制代码我们使用 Jest 功能来确保这一点: 我们还将 "我们的第一次测试 "更名为 "应获得正确的最新发件人地址",因为我们希望测试名称始终可读。 运行测试瞧!我们已经准备好运行测试了。只需运行我们的命令 yarn test 在终端中,如果你和我一起做了所有的事情,你也会得到类似的结果: - PASS tests/main.spec.ts% `$ t/ D; a) M" W& H1 k9 T
- main.fc contract tests) W$ d$ X+ A5 `
- ✓ should get the proper most recent sender address (444 ms)
( o3 G$ a' ?7 A
. ?, X( r. x( e- Test Suites: 1 passed, 1 total. `$ w% j: f* m: \* c+ o0 M3 R2 Q
- Tests: 1 passed, 1 total, v* R. J7 B ~2 U+ G: {
- Snapshots: 0 total
& t: Z. L" {6 j1 Y$ k - Time: 4.13 s, estimated 5 s
复制代码我们要做的最后一件事是确保每次运行测试时,同时运行编译器脚本。这有助于提高我们的工作效率。大部分开发工作都是编写功能代码,然后运行测试。让我们简化这一切: 更新 package.json 文件,使其看起来像这样: - {* n) d4 K( x; I6 F1 \
- ... our previous package.json keys, G" s# `2 h( G L8 n) |& o
- "scripts": {
; v0 Z4 d; D6 g# l( \0 I5 N* p! A: E& v - ... previous scripts keys" z# h- Z1 p. B/ o
- "test": "yarn compile && yarn jest"
, A6 S9 q8 j3 O1 V( o5 m) p - }
+ d* r, _. l. l( ~4 \ - }
复制代码 在下一课中,我们将构建部署途径,并学习如何在链上测试真正的部署合同
. K! V5 g: |7 w& _) B# j# L! j' I6 L9 m D9 Z) S+ W
& w6 u( ~3 X5 b
|