本帖最后由 riyad 于 2025-3-8 23:13 编辑 5 @% d7 h* B4 m4 z3 i
0 @5 _3 H1 Y; L" o我们已经编写了第一个 FunC 合约,并已成功编译。接下来要做什么呢? 在这一课中,我们将学习如何确保我们的合约代码确实能按预期运行。让我提醒你我们对代码的期望: 我们的合约应该在每次收到消息时保存一个发件人地址,并且在调用 getter 方法时返回最新的发件人地址。 很好,但我们如何确保它能正常工作呢?我们可以将它部署到区块链上(我们很快就会在下一课中这样做), TON 有一些工具,可以让我们在本地模拟某些行为. 这需要借助 sandbox. 这 library 允许您模拟任意的 TON 智能合约,向它们发送消息并运行获取方法,就像它们部署在真实网络上一样。 因为我们有自己的 TypeScript 环境,所以我们可以借助另一个库创建一系列测试 - jest. 这样,我们就有了一个测试套件,可以用不同的输入数据模拟所有重要行为,并检查结果。这是编写、调试和全面测试合约的最佳方法,然后再将它们发布到网络上。
5 \1 o7 ~7 k/ D9 J' \准备测试套件首先,假设你现在位于项目的根目录,让我们安装 sandbox, jest 以及一个我们需要与 TON 实体交互的库 - ton:
, _; `' D `6 o- yarn add @ton/sandbox jest ts-jest @types/jest @ton/ton --dev
; t8 ]! U4 X. p
复制代码我们还需要在项目根目录下为 jest 创建一个 jest.config.js 文件,内容如下: - module.exports = {
4 B" C; @, v) h& N% ]$ m - preset: 'ts-jest',
. W; _+ e: R) M. g. n9 J4 }/ w' E( S - testEnvironment: 'node',: H7 K7 V; i a$ u' f
- };
复制代码现在,我们创建一个新文件夹 test,其中包含文件 main.spec.ts: - mkdir tests && cd tests && touch main.spec.ts
$ v+ ?: B# z* H0 d5 G
复制代码我们设置好 main.spec.ts 文件,以便编写第一个测试: - describe("main.fc contract tests", () => {
4 X; f; s. B" @& ]
7 V1 k+ P. X" c% ~2 I4 R+ z- it("our first test", async () => {9 i9 V/ Z7 f4 O& x8 l8 e7 T4 G4 S
6 ?* m* N/ ^# Q$ S4 x: h% ]- });
( i% u- A! J% \! l( u - 8 d8 i' i! V# m7 `% C6 C" z
- });
复制代码如果你从未编写过 TypeScript 测试,你一定要试试这种方法。在编写 TON 智能合约时,你会将一半以上的编程时间花在编写测试上。还记得我们的太空卫星例子吗?在这里也是一样,我们甚至在将合约部署到 testnet 之前,就已经模拟了每一种重要情况。 现在尝试运行命令 yarn jest 的根目录中。如果你已经正确安装了所有程序(跟我一起一步步安装),你应该会看到下面的内容: - PASS tests/main.spec.ts
" Y# A1 n/ n2 b/ t& u; n& d - main.fc contract tests
. h" v" m4 y, I; F1 s/ W% R& d - ✓ our first test (1 ms)
复制代码很好,让我们立即在 package.json 文件中创建另一个脚本运行快捷方式: ; p6 q) t( e. ~6 M4 i+ r
- {9 B& J2 Q9 w6 B Q) e2 m" I
- ... our previous package.json keys9 O, i) ~8 V' F" \7 @, N
- "scripts": {. r$ Y9 Q4 s5 q0 v0 _! T
- ... previous scripts keys
. a+ i3 E1 P% B# c - "test": "yarn jest"8 C8 f0 u6 L, ?. ?8 {' `1 m7 N
- }% U* r& h6 L8 Y6 G, d# Q
- }
复制代码 创建合约实例为了编写第一个测试,我们需要了解如何借助 sandbox. 我们之前讨论过,我们的合约代码在编译后会存储为一个 Cell。在 build/main.compiled.json 文件中,我们有一个 Cell 的十六进制表示法。让我们把它导入我们的测试文件,并从以下文件中导入 Cell 类型 ton-core: - import { Cell } from "@ton/core";
* K8 Q3 g( F9 \ - import { hex } from "../build/main.compiled.json";
; E3 S, F% Z: _; j - 2 ~, v: p& X$ r2 Q. i4 q2 }$ i
- describe("main.fc contract tests", () => {# Q x% Y' O+ X, K
: y' `" O- ^2 y+ }4 x2 q: z- it("our first test", async () => {
X" R% q1 p- w0 f
- `) ~! t8 _" J1 p: P/ V- });! \& G! J2 r* W& O, s! S/ b
, m% X. u: F2 N7 B* \1 B5 { s+ D% s. O, N- });
复制代码现在,要恢复十六进制并获得真正的 Cell,我们将使用这条命令: const codeCell = Cell.fromBoc(Buffer.from(hex, "hex"))[0]. 我们用十六进制字符串创建一个缓冲区,并将其传递给 .fromBoc 方法。 让我们从 sandbox 库,并调用它的 .create() 方法。 - import { Cell } from "@ton/core";. B7 @8 ?. C/ W3 R
- import { hex } from "../build/main.compiled.json";" l; O S. l7 ?1 y, M! A1 c# w7 R
- import { Blockchain } from "@ton/sandbox";6 d4 o2 L |) S: n& w; u
' ~2 P t4 ?7 u( ^: C H- describe("main.fc contract tests", () => {( d/ g0 h' Y u5 E- B
- it("our first test", async () => {8 u* W% |8 Y, x0 C
- const blockchain = await Blockchain.create();% C; r0 w9 }9 r, m
- " i: f9 N4 a; x
- });0 W7 @# ]6 `# z3 O1 Q1 z" x1 x. N+ ?
- });
复制代码现在,让我们准备好获取一个合约实例来进行交互。Sandbox 的文档指出,推荐的使用方法是使用 Contract 从 ton-core 这对我们意味着什么?让我们创建一个新文件夹 wrappers 和一个名为 MainContract.ts 其内. 该文件将实现并导出我们合约的包装器。 - mkdir wrappers && cd wrappers && touch MainContract.ts: ~6 h, e& h2 a( ?' {5 k
复制代码确保从项目根目录运行此命令序列 打开 MainContract.ts 编辑用. 让我们导入一个 Contract 从 ton-core 库,然后定义并导出一个实现 Contract. - import { Contract } from '@ton/core';
5 c$ O1 O. R" X$ s& j, o
6 u3 H i% ]3 z, U: k- export class MainContract implements Contract {3 V- g, E) h9 l5 d- K
- $ P0 g3 E) w. f: }) P
- }
复制代码如果您查看一下 Contract 接口 - 你会看到它需要 address, init 和 abi 参数。 我们只会将 address 和 init 用于我们的目的。 为了使用它们,我们要为我们的 MainContract 类定义一个构造函数。 如果您不知道我们这里所说的类和构造函数是什么意思,那么您最好多读一些关于面向对象编程(Object Oriented Programming)的内容。FunC 并不要求这样做,但为了更好地使用 TypeScript 进行测试,您应该了解这些基本概念。 - import { Address, Cell, Contract } from "@ton/core";
" K" T3 I: @* b5 ^- {4 { - - w8 v8 d2 @0 U
- export class MainContract implements Contract {
& Q% _( w5 o5 Q" y8 b t9 h5 N - constructor( [, _$ [: M2 ?2 u6 L, n6 q1 o7 s
- readonly address: Address,7 l( i- ]' `2 c7 w& _
- readonly init?: { code: Cell; data: Cell }3 `6 K" M$ j! s/ W( P
- ) {}0 j9 T1 B6 T) l4 J* @
- }
复制代码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";# d9 H7 ?* h [/ p- H: ~& [
- 9 F Z8 s7 W/ Z& d
- export class MainContract implements Contract {
1 V0 _8 ] T: K& i - constructor(3 y4 S% E3 i( m
- readonly address: Address,, O6 b3 `1 ~) u" p: M
- readonly init?: { code: Cell; data: Cell }! e( P" k$ u/ f. d m
- ) {}
& n5 L3 p/ u2 K7 }: F8 W
6 T7 W, @( A; R- static createFromConfig(config: any, code: Cell, workchain = 0) {0 c# K' E* u4 O
- const data = beginCell().endCell();
2 d' z5 P. X# u" h }( f7 q - const init = { code, data }; }- `* r6 r* ^( ^8 ~& q
- const address = contractAddress(workchain, init);
+ a3 l: u1 S4 ^3 ?
7 u: E" t; F) K5 T% C5 i2 o5 f6 ~% ~- return new MainContract(address, init);
* C- t$ [9 Q- A0 M - }
6 S; x2 w I( f$ x7 w6 h( i. i' _ - }
复制代码我们的 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 并执行以下步骤: $ ?4 j2 i' v' y b8 e
- import { Cell } from "@ton/core";
, Q# O8 Q" U S1 O; n - import { hex } from "../build/main.compiled.json";
9 C0 p3 k {. H - import { Blockchain } from "@ton/sandbox";5 W( F- U! M& X$ b- l( k$ D3 \( f- A% L
- import { MainContract } from "../wrappers/MainContract";
( K1 G/ \2 D; m8 H2 S/ ?) r
: q# P4 m/ q5 i3 ^) ]8 E3 q$ ^* A- describe("main.fc contract tests", () => {9 ] N1 _2 t# j; u/ I! E, D
- it("our first test", async () => {3 z2 C+ A% x \$ w) w2 \2 \5 D p
- const blockchain = await Blockchain.create();, ^3 z8 o' Y! d$ E% \
- const codeCell = Cell.fromBoc(Buffer.from(hex, "hex"))[0];
t5 w3 i8 l% R- u9 f; Z; t
2 i$ ^; V4 h4 s8 \+ x9 W( W2 r- const myContract = blockchain.openContract(
3 ?; k. J- `' H - await MainContract.createFromConfig({}, codeCell)2 s! b' |7 n- c* M) o6 ~
- );) ~8 f/ A* u2 x) `9 Y+ O9 |5 o
- });' s0 Z8 d4 j& Q8 [- c1 M
- });
复制代码至此,我们就有了一个智能合约实例,我们可以通过许多与真实合约类似的方式与之交互,以测试预期行为。 与合约互动@ton/core 库为我们提供了另一个伟大的组件,名为 Address. 众所周知,TON 区块链上的每个实体都有一个地址。在实际中,如果您想从一个合约(例如钱包合约)向另一个合约发送信息,您需要知道两个合约的地址。 在编写测试时,我们会模拟一个合约,当我们与合约交互时,我们模拟的合约地址是已知的。不过,我们还需要一个钱包,用来部署我们的合约,另一个钱包用来与我们的合约交互。 在 sandbox 这样做的方式是,我们称之为 treasure 方法,并为其提供一个助记词: const senderWallet = await blockchain.treasury("sender"); 模仿内部信息让我们继续编写测试。既然我们已经有了一个合约实例,那就向它发送一条内部信息吧,这样我们的发件人地址就会保存在 c4 存储空间中。我们还记得,要与我们的合约实例交互,我们需要使用包装器。 回到我们的文件 wrappers/MainContract.ts 并在封装器中创建一个名为 sendInternalMessage. 最初看起来是这样的: - async sendInternalMessage(# s8 ?( g% G) P. {9 W' q. W, @
- provider: ContractProvider,& ]& f4 \ z: _9 e1 p7 r' |4 o
- sender: Sender,6 |% R. i2 f' T0 Y7 V7 f, e
- value: bigint
4 D! W0 A5 @* ]. z9 ] - ){
6 x4 E8 k h2 A! C' h -
0 K2 j% u- t% f f - }
复制代码我们的新方法将接收 ContractProvider 类型的参数、Sender 类型的信息发送者和信息值 value. 通常情况下,使用此方法时我们不必担心传递 ContractProvider,它将作为合约实例内置功能的一部分在底层传递。不过,不要忘了从 ton-core 库中导入这些类型. 让我们在新方法中实现发送内部信息的逻辑。它看起来是这样的: - async sendInternalMessage(
" d* l$ T3 Z' D/ s* K - provider: ContractProvider,
6 X- e) v& N- n J/ W" {& n - sender: Sender,5 F. q% y7 ^+ R' t! M( p
- value: bigint
& v; s8 N [2 Y% n' I& } - ) {
6 D* }' w. o/ h2 w A) V3 N6 | - await provider.internal(sender, {
& `+ _# \1 h1 n& [ A - value,
* V4 i' {/ U3 F/ A - sendMode: SendMode.PAY_GAS_SEPARATELY," X O n+ N" x4 V
- body: beginCell().endCell(),5 l& c7 d3 W0 s& V! c# {
- });
|" ^! f, V. P9 U - }
复制代码您可以看到,我们正在使用 provider 并调用其名为 internal. 我们通过了 sender 作为第一个参数,然后我们用参数组成一个对象,其中包括: 请不要忘记将我们使用的所有新类型和实体从 @ton/core 库. 因此,wrappers/MainContract.ts 的最终代码如下所示:
5 t1 G( t) A% q8 M% [3 h7 e- import { Address, beginCell, Cell, Contract, contractAddress, ContractProvider, Sender, SendMode } from "@ton/core";
) q* T ]0 u9 [$ |2 L& l - $ u- W) }- Y1 C, }0 A
- export class MainContract implements Contract {
2 F5 ~) S7 @! g$ x+ r4 b. F# Z - constructor(+ G' y' |. V* g, n0 Q" K
- readonly address: Address,
' G3 d3 A) H2 _! @% q$ c* C - readonly init?: { code: Cell; data: Cell }
4 q/ h9 {" }* L0 E" e; b0 h) J% F# B - ) {}
: V' ?! u7 g5 M- n+ ^2 j+ @6 W - 8 O' E2 f7 q0 e& {
- static createFromConfig(config: any, code: Cell, workchain = 0) {6 h9 ?4 l8 D* ^2 D+ O) s( T0 h. {& n
- const data = beginCell().endCell();* R7 W. K) D7 N. V6 Z, E
- const init = { code, data };4 U- u, B' H- J$ V! T& @
- const address = contractAddress(workchain, init);
4 \: F3 P( p; O- W% L7 V9 n - 5 P1 T: Y/ ^$ w
- return new MainContract(address, init);1 m: D0 k4 m4 d
- }
$ N! W8 L- S. Z) I& w; Z - , g8 R' j2 A, F
- async sendInternalMessage(
) C0 ~. c* |9 A! Y( b - provider: ContractProvider,
8 H4 m. M- k* X2 v( O5 {5 x5 ~ - sender: Sender,7 s8 n( W/ R- X, d# Z: X
- value: bigint
3 X& N; ]; {0 B* u5 T& C - ) {
' J5 j1 s, e E: P# E4 z - await provider.internal(sender, {2 E$ a$ ?7 i8 d6 Q" O; [9 s$ h' f; \
- value,9 Z+ s2 c( u, p' B
- sendMode: SendMode.PAY_GAS_SEPARATELY,
1 ]4 g- b8 W! i$ J0 ^4 V - body: beginCell().endCell(),
5 A- n4 f, w% c' L! y/ K# Q - });8 h5 p5 ]! T* |' ?6 F
- }
* G+ V' I; P' K" r/ F - }
复制代码在我们的 tests/main.spec.ts 中调用该方法会是这样: - const senderWallet = await blockchain.treasury("sender");
# _3 w z* n" M- Z" d0 ~/ a - myContract.sendInternalMessage(senderWallet.getSender(), toNano("0.05"));
复制代码请注意我们是如何使用 toNano 辅助函数 @ton/core 库将字符串值转换为nano gram格式。 H9 V; s9 H, {$ n' N
2 ^3 f7 s- C* t- r
调用 getter 方法我们需要在合约包装器上再创建一个方法,即 getData 方法,该方法将运行合约的 getter 方法,并返回 c4 存储器中的结果。 这就是我们的 getter 方法: - async getData(provider: ContractProvider) {! d% M7 n. D8 ?3 |' {2 Y' Z" I
- const { stack } = await provider.get("get_the_latest_sender", []);
I8 n+ A ?; i) O1 j - return {5 T" S4 X/ m7 u+ ^3 K
- recent_sender: stack.readAddress()," d' p, n7 s. W- E
- };
) ^& X! Z' j" K8 z - }
复制代码就像我们发送内部信息一样,我们使用的是提供发送者及其方法。在本例中,我们使用的是 get 方法。 然后,我们从接收到的 stack 并将其作为结果返回。 编写测试就是这样。我们已经完成了所有的准备工作,现在要编写实际的测试逻辑。下面是我们的测试场景: 看起来非常简单可行!让我们行动起来吧 沙盒团队又为我们提供了一个神奇的测试工具。我们可以安装额外的 @ton/test-utils 软件包,方法是运行 yarn add @ton/test-utils -D 这将使我们能够使用 .toHaveTransaction 为 jest matcher 添加额外的辅助工具,以方便测试。我们还需要在我们的 tests/main.spec.ts 安装后。 让我们看看基于上述场景的测试代码是怎样的。 - import { Cell, toNano } from "@ton/core";
/ u' [6 F8 O7 K% E& y( p: |8 S! K( f - import { hex } from "../build/main.compiled.json";9 L% R! i$ ^2 N. p- E7 Q
- import { Blockchain } from "@ton/sandbox";
; F2 b2 x% e% t; x. P: P5 S - import { MainContract } from "../wrappers/MainContract";
7 E) W0 \, {5 }* n' p2 {5 d( h" i! ? - import "@ton/test-utils";8 O4 ` W3 a# k8 O5 ?, O
- 3 u( Z9 G: q2 q# ~
- describe("main.fc contract tests", () => {& p" q9 ?( f; ~2 s
- it("should get the proper most recent sender address", async () => {' G2 _ L3 g5 Z/ I4 l
- const blockchain = await Blockchain.create();' c! ~/ Q& k- L. q* l. d+ |
- const codeCell = Cell.fromBoc(Buffer.from(hex, "hex"))[0];
# H6 k2 c( o E" S9 M
6 m( m7 p8 V7 Z& {1 }, X9 d" z- const myContract = blockchain.openContract(
7 W9 h" u1 |% L5 z: l7 M! t$ D - await MainContract.createFromConfig({}, codeCell)
7 M( k' {$ g% C. W7 P- X! W - );
' C- ~1 d) m1 [* O0 P) P - : M3 K Z2 f) q
- const senderWallet = await blockchain.treasury("sender");
! s# i' c. A- T& K9 f2 i - 2 R( u( ^" c6 k! S
- const sentMessageResult = await myContract.sendInternalMessage(
8 S5 n* i4 J- j; j - senderWallet.getSender(),
! l: H( C; g( w" E* X6 [0 i - toNano("0.05")
! f1 T( t) ~$ F) z! d4 I - );
% _' v. L* u8 a1 s; u& B - 1 i& V0 [. e ]6 `
- expect(sentMessageResult.transactions).toHaveTransaction({0 O# b6 W* Q5 Z! U/ I* O3 w
- from: senderWallet.address, I+ _* F# G& J9 {. T% u
- to: myContract.address,7 w N9 P- V; f" H
- success: true,3 w3 y2 L7 h3 }: F* R R
- });8 x# d. K: m: ]/ V
. z; A% k0 Y4 P5 y* \3 O- const data = await myContract.getData();
( N, t+ k& d) O; g9 E2 N - & S& m/ z" l# L& G
- expect(data.recent_sender.toString()).toBe(senderWallet.address.toString());9 U. t$ e4 G5 c7 g9 i/ \7 S# K
- });& y, ]8 @; I. a: X9 E
- });
复制代码我们使用 Jest 功能来确保这一点: 我们还将 "我们的第一次测试 "更名为 "应获得正确的最新发件人地址",因为我们希望测试名称始终可读。 运行测试瞧!我们已经准备好运行测试了。只需运行我们的命令 yarn test 在终端中,如果你和我一起做了所有的事情,你也会得到类似的结果: - PASS tests/main.spec.ts
1 p1 N g! K2 `1 W G, r" @ - main.fc contract tests
( d" C$ l, v3 k - ✓ should get the proper most recent sender address (444 ms)6 d2 X; Z/ J1 f$ O0 w7 ~& P
- ! F& L2 M* W) I6 Y
- Test Suites: 1 passed, 1 total; R# d/ w2 d. T$ n3 S# [
- Tests: 1 passed, 1 total: A5 \( \7 m4 t: ^0 F+ P" R w" j
- Snapshots: 0 total
- ~ E9 ^2 n2 s. R% Y; } - Time: 4.13 s, estimated 5 s
复制代码我们要做的最后一件事是确保每次运行测试时,同时运行编译器脚本。这有助于提高我们的工作效率。大部分开发工作都是编写功能代码,然后运行测试。让我们简化这一切: 更新 package.json 文件,使其看起来像这样: - {! A) g' d& I$ t& p( u& I+ k8 ^
- ... our previous package.json keys3 |/ ~1 r4 V, j; I! N- a7 k
- "scripts": {) y( ?4 |! Q- g9 G. e! B; i
- ... previous scripts keys
0 t$ }& E% s, }2 A* Z' ~- e - "test": "yarn compile && yarn jest"
8 N/ [1 [' g& q: J - }
: V# l, I; d. a6 ` - }
复制代码 在下一课中,我们将构建部署途径,并学习如何在链上测试真正的部署合同
|# ^) F* |( m3 z% t; [1 _- {5 M6 d; J/ z1 B H
+ o2 G, J" L- J9 P, A |