本帖最后由 riyad 于 2025-3-8 23:13 编辑 ( @7 P7 s% n8 Y; }& H9 Q$ @
) F: L I4 K. h
我们已经编写了第一个 FunC 合约,并已成功编译。接下来要做什么呢? 在这一课中,我们将学习如何确保我们的合约代码确实能按预期运行。让我提醒你我们对代码的期望: 我们的合约应该在每次收到消息时保存一个发件人地址,并且在调用 getter 方法时返回最新的发件人地址。 很好,但我们如何确保它能正常工作呢?我们可以将它部署到区块链上(我们很快就会在下一课中这样做), TON 有一些工具,可以让我们在本地模拟某些行为. 这需要借助 sandbox. 这 library 允许您模拟任意的 TON 智能合约,向它们发送消息并运行获取方法,就像它们部署在真实网络上一样。 因为我们有自己的 TypeScript 环境,所以我们可以借助另一个库创建一系列测试 - jest. 这样,我们就有了一个测试套件,可以用不同的输入数据模拟所有重要行为,并检查结果。这是编写、调试和全面测试合约的最佳方法,然后再将它们发布到网络上。
2 s$ ?; b$ X, [2 i& U2 @准备测试套件首先,假设你现在位于项目的根目录,让我们安装 sandbox, jest 以及一个我们需要与 TON 实体交互的库 - ton: 7 e8 O5 }8 J a, m3 o% F
- yarn add @ton/sandbox jest ts-jest @types/jest @ton/ton --dev8 m* ]$ I8 _4 {. o; Y# P
复制代码我们还需要在项目根目录下为 jest 创建一个 jest.config.js 文件,内容如下: - module.exports = {
/ }; F; K7 ?/ W7 U" | - preset: 'ts-jest',
% Q" {% c) Z, ?5 Z w4 u! D. b - testEnvironment: 'node',+ U- W5 [' y6 C5 h3 C* V1 T3 z4 z9 E
- };
复制代码现在,我们创建一个新文件夹 test,其中包含文件 main.spec.ts: - mkdir tests && cd tests && touch main.spec.ts
/ [, b. Y0 R" X: I" b$ O
复制代码我们设置好 main.spec.ts 文件,以便编写第一个测试: - describe("main.fc contract tests", () => {% [3 ^8 }. ~1 v6 S7 l) ^/ Q
- : r# g! u, l! s7 z: |
- it("our first test", async () => {
/ C2 M7 U8 J9 C" H, C. A7 L - - L8 w! a. ?! D0 @3 i7 I: R
- });
* h; U; a; K4 ` H6 B* @0 l
% w& |$ G J8 R- });
复制代码如果你从未编写过 TypeScript 测试,你一定要试试这种方法。在编写 TON 智能合约时,你会将一半以上的编程时间花在编写测试上。还记得我们的太空卫星例子吗?在这里也是一样,我们甚至在将合约部署到 testnet 之前,就已经模拟了每一种重要情况。 现在尝试运行命令 yarn jest 的根目录中。如果你已经正确安装了所有程序(跟我一起一步步安装),你应该会看到下面的内容: - PASS tests/main.spec.ts! R4 u4 Q7 G& Q& x1 r6 u
- main.fc contract tests7 @/ l u G0 e8 q0 n+ a4 ?! t! \" j
- ✓ our first test (1 ms)
复制代码很好,让我们立即在 package.json 文件中创建另一个脚本运行快捷方式: 3 p% q k7 Q) V# o" K" x! L
- {
/ B; G% P1 a1 F# E% Y; X. e+ ] - ... our previous package.json keys
' F S: l7 r/ y% M/ k - "scripts": {
O: ~1 d6 k9 F - ... previous scripts keys
$ @! z* R/ Q6 p* n; \5 E9 \7 E2 K - "test": "yarn jest"
4 D) W: m( Z J9 S7 q5 o; d- a0 z u+ d) w - }
; j: P# n8 X* Q: g - }
复制代码 创建合约实例为了编写第一个测试,我们需要了解如何借助 sandbox. 我们之前讨论过,我们的合约代码在编译后会存储为一个 Cell。在 build/main.compiled.json 文件中,我们有一个 Cell 的十六进制表示法。让我们把它导入我们的测试文件,并从以下文件中导入 Cell 类型 ton-core: - import { Cell } from "@ton/core";
8 M1 J- e$ O! H6 T: k$ N3 T - import { hex } from "../build/main.compiled.json";
1 g/ C& w/ O& R; F
' q/ o2 b& N2 f7 w; D- describe("main.fc contract tests", () => {2 b! ?# f9 K: ]# M) l) N: B
- 9 f* T) s( p- o3 ^& G- ]0 D1 P) b
- it("our first test", async () => {6 m' Q3 V) O1 j5 m& P- C: b% U" n
5 R6 m% k! P# C- });, O. q& B, }6 J$ ^
- 5 s! q+ E( v/ M' u9 ^. I
- });
复制代码现在,要恢复十六进制并获得真正的 Cell,我们将使用这条命令: const codeCell = Cell.fromBoc(Buffer.from(hex, "hex"))[0]. 我们用十六进制字符串创建一个缓冲区,并将其传递给 .fromBoc 方法。 让我们从 sandbox 库,并调用它的 .create() 方法。 - import { Cell } from "@ton/core";
! R6 W# P* H' w6 X - import { hex } from "../build/main.compiled.json";
( [2 q8 J2 C: H# K3 I" k - import { Blockchain } from "@ton/sandbox";7 N7 I# V4 M; m* J F( p0 i n
( ?: @' d! D; A2 c! [- describe("main.fc contract tests", () => {
! g2 O7 o6 H* |7 U - it("our first test", async () => {( N2 h8 y0 N# _7 X
- const blockchain = await Blockchain.create();
4 {; Z2 G, N$ ]* W5 r2 R5 I1 ^ - 5 W# v! N: V- o+ q
- });% q4 A. \5 a( ?. O% U: q- |
- });
复制代码现在,让我们准备好获取一个合约实例来进行交互。Sandbox 的文档指出,推荐的使用方法是使用 Contract 从 ton-core 这对我们意味着什么?让我们创建一个新文件夹 wrappers 和一个名为 MainContract.ts 其内. 该文件将实现并导出我们合约的包装器。 - mkdir wrappers && cd wrappers && touch MainContract.ts
3 {6 H0 k1 `1 ]" l
复制代码确保从项目根目录运行此命令序列 打开 MainContract.ts 编辑用. 让我们导入一个 Contract 从 ton-core 库,然后定义并导出一个实现 Contract. - import { Contract } from '@ton/core';; m& q/ g9 t t) X4 X! ]; k- j
- ' o; R p5 b0 e: ]7 |9 w1 p- a
- export class MainContract implements Contract {
4 ]# h* U$ Z) `9 h4 j& X: a -
% W$ A0 S# s9 w( `- y$ J# u: C - }
复制代码如果您查看一下 Contract 接口 - 你会看到它需要 address, init 和 abi 参数。 我们只会将 address 和 init 用于我们的目的。 为了使用它们,我们要为我们的 MainContract 类定义一个构造函数。 如果您不知道我们这里所说的类和构造函数是什么意思,那么您最好多读一些关于面向对象编程(Object Oriented Programming)的内容。FunC 并不要求这样做,但为了更好地使用 TypeScript 进行测试,您应该了解这些基本概念。 - import { Address, Cell, Contract } from "@ton/core";
" X0 G* g. v& {, `- Z/ p - ( a% O, n. |2 m* ~, N
- export class MainContract implements Contract {8 I4 D2 S1 z/ F+ D/ \7 \
- constructor(5 j) y f6 h% m& g
- readonly address: Address," R" m* E% W9 e# T0 }( ~
- readonly init?: { code: Cell; data: Cell }
9 s H( r3 b7 [, [: {, p0 s - ) {}
# L$ l. ?0 \7 |: H, w - }
复制代码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";" ?5 ~5 d1 @- \1 G4 ^$ |
- 0 T6 x' A- j0 [! s/ K! i( L; \
- export class MainContract implements Contract {
( {) S5 |2 p5 w. Y) r& ] - constructor(: k9 K& w. Y) r6 {5 C4 z& y( t
- readonly address: Address,
, ]1 W' w K; z0 B, x - readonly init?: { code: Cell; data: Cell }
) o0 C K" G X H - ) {}
z: m y4 R8 m, N
. @( f' w9 \6 \) |- static createFromConfig(config: any, code: Cell, workchain = 0) {
) v- _/ d" M' g- p6 L; ~; J - const data = beginCell().endCell();8 g/ ^& A$ ?8 ]" h/ T6 q
- const init = { code, data };
8 }2 x3 l! m; @) |6 Z/ f - const address = contractAddress(workchain, init);
6 v$ R# K8 Q+ N1 ]4 l
0 \: A& h" @5 K, J- return new MainContract(address, init);
* H: a+ o3 _4 h/ V5 O4 ^% J - }* \ P8 M7 b9 w# A6 u( W0 t
- }
复制代码我们的 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 并执行以下步骤: 1 P: B: |+ | q8 R. a; U5 O
- import { Cell } from "@ton/core";' [/ k! q' U2 y" M
- import { hex } from "../build/main.compiled.json";5 I/ d5 L( @2 ]5 m
- import { Blockchain } from "@ton/sandbox";
8 G2 Y# C7 v0 ? - import { MainContract } from "../wrappers/MainContract";" }0 o8 n2 b) W2 N. A4 o
' _6 @2 q9 ]' B6 x" x% f9 U- describe("main.fc contract tests", () => {
, J7 y& k5 V- B; ~+ G7 u - it("our first test", async () => {3 v. e# g5 d7 m6 a# L- p9 a2 j
- const blockchain = await Blockchain.create();3 l* t: J! Q; a9 l
- const codeCell = Cell.fromBoc(Buffer.from(hex, "hex"))[0];
. |3 d, T B* z - . c8 K. N0 A, ^, A0 b- ~
- const myContract = blockchain.openContract(& z9 u( U1 X+ c3 G
- await MainContract.createFromConfig({}, codeCell)
! b. d# K/ G2 c& G/ z/ c* } - );; \- S8 f$ E+ U# X
- });
- D3 H" A+ X* J R! H, z - });
复制代码至此,我们就有了一个智能合约实例,我们可以通过许多与真实合约类似的方式与之交互,以测试预期行为。 与合约互动@ton/core 库为我们提供了另一个伟大的组件,名为 Address. 众所周知,TON 区块链上的每个实体都有一个地址。在实际中,如果您想从一个合约(例如钱包合约)向另一个合约发送信息,您需要知道两个合约的地址。 在编写测试时,我们会模拟一个合约,当我们与合约交互时,我们模拟的合约地址是已知的。不过,我们还需要一个钱包,用来部署我们的合约,另一个钱包用来与我们的合约交互。 在 sandbox 这样做的方式是,我们称之为 treasure 方法,并为其提供一个助记词: const senderWallet = await blockchain.treasury("sender"); 模仿内部信息让我们继续编写测试。既然我们已经有了一个合约实例,那就向它发送一条内部信息吧,这样我们的发件人地址就会保存在 c4 存储空间中。我们还记得,要与我们的合约实例交互,我们需要使用包装器。 回到我们的文件 wrappers/MainContract.ts 并在封装器中创建一个名为 sendInternalMessage. 最初看起来是这样的: - async sendInternalMessage(
& \' d" A' I; s# p: y! X$ j - provider: ContractProvider,
. q6 `8 y z$ M$ U8 l - sender: Sender,- a% \) |% D4 s/ O8 B1 r
- value: bigint
8 Z0 T/ W0 b a: t1 r# I - ){1 ^. n' ?) y" `% E$ w
-
' Y- F' T6 w. b; c5 q$ ~+ d - }
复制代码我们的新方法将接收 ContractProvider 类型的参数、Sender 类型的信息发送者和信息值 value. 通常情况下,使用此方法时我们不必担心传递 ContractProvider,它将作为合约实例内置功能的一部分在底层传递。不过,不要忘了从 ton-core 库中导入这些类型. 让我们在新方法中实现发送内部信息的逻辑。它看起来是这样的: - async sendInternalMessage(" V4 s( n0 k3 a! x' J
- provider: ContractProvider,
. l0 v3 I) u5 k/ q - sender: Sender,
5 A6 B4 I" H" e. M4 S+ _1 f2 B; T - value: bigint/ L: c% s% |$ j6 J6 {8 R
- ) {
' b c( P: T. w; l' n - await provider.internal(sender, {
4 G! T! X9 t+ f- Z5 ` - value,# C1 u, I* `- h- b. B8 h2 v
- sendMode: SendMode.PAY_GAS_SEPARATELY,
9 ?# C7 |6 g: S9 B* ^3 B3 _ - body: beginCell().endCell(),
7 C, E2 h; I' n, f: J, \' S: l, Q - });4 l8 r6 C$ p7 k
- }
复制代码您可以看到,我们正在使用 provider 并调用其名为 internal. 我们通过了 sender 作为第一个参数,然后我们用参数组成一个对象,其中包括: 请不要忘记将我们使用的所有新类型和实体从 @ton/core 库. 因此,wrappers/MainContract.ts 的最终代码如下所示: 2 q, F a3 d6 E& X' Q% C
- import { Address, beginCell, Cell, Contract, contractAddress, ContractProvider, Sender, SendMode } from "@ton/core";
% Q3 ^' w5 {) a9 H9 B( w
" Q1 F+ ^: l! y; ]" ?9 e) E0 K- export class MainContract implements Contract {$ \% p9 D; \1 I- }( d3 o, r
- constructor(
! b- d2 p2 V% ]8 W r2 a - readonly address: Address,1 t' E% |/ ~+ I- N4 R# R5 F
- readonly init?: { code: Cell; data: Cell }% n* C# G8 l: I' D' Y
- ) {}
5 x; P) w6 U7 f/ w g* b - : ~2 o' \. P/ r7 z0 ~4 ~
- static createFromConfig(config: any, code: Cell, workchain = 0) {$ b3 |, m$ D! T% O8 }: ^. @4 V3 ~% a
- const data = beginCell().endCell();' q; U3 e9 p+ w, |; V4 l( J; q
- const init = { code, data };
. `0 }5 Z$ X2 d" w - const address = contractAddress(workchain, init);- @1 K+ W6 y/ y( G/ T
- $ `' H) r* j3 L! [8 `) G
- return new MainContract(address, init);
" M$ E# V1 q3 \: A8 M6 G - }
4 p; E6 l. u' J2 w( l! i0 E
3 i- h/ C; e' S P$ ^' k- async sendInternalMessage(
- g4 ~) ^6 M# u: N - provider: ContractProvider,
% `8 Z% @) |; l" } {6 i; E - sender: Sender," A, J$ X1 S& e- @
- value: bigint
1 D# G2 n7 s4 q. E5 V4 ? - ) {
. O; L2 a4 K+ d& l - await provider.internal(sender, {3 v u% @) B5 v4 |
- value,3 e1 ]" l' W P# j+ b
- sendMode: SendMode.PAY_GAS_SEPARATELY,
% K: M i+ m! u3 h- E* g* N' Y - body: beginCell().endCell(),& E0 |/ a( f2 d" D: u
- });8 |) ?# f& N) [. q% U
- }+ I, h% f& U/ g% A, g+ o3 d, U* Z, t) r
- }
复制代码在我们的 tests/main.spec.ts 中调用该方法会是这样: - const senderWallet = await blockchain.treasury("sender");
8 U6 ^: a6 e1 Y: }3 K' { Z - myContract.sendInternalMessage(senderWallet.getSender(), toNano("0.05"));
复制代码请注意我们是如何使用 toNano 辅助函数 @ton/core 库将字符串值转换为nano gram格式。 / |0 e6 p9 G; t) Q
! X0 o; e5 u2 d9 f调用 getter 方法我们需要在合约包装器上再创建一个方法,即 getData 方法,该方法将运行合约的 getter 方法,并返回 c4 存储器中的结果。 这就是我们的 getter 方法: - async getData(provider: ContractProvider) {# T8 z' i4 p$ O) }7 ?* i
- const { stack } = await provider.get("get_the_latest_sender", []);2 c3 |+ _( _' h+ H' P( B4 I
- return {# V# s# X4 V. [* P5 F; f
- recent_sender: stack.readAddress(),% J* j$ q$ H2 i R- i- X
- };. }& h, G$ Y0 j' X6 R' r5 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";
' W! O5 f0 n4 D9 R1 `8 B, _ - import { hex } from "../build/main.compiled.json";
( J6 n% a) N5 a d B) E - import { Blockchain } from "@ton/sandbox";8 D& y' Z8 C9 A9 k t: h
- import { MainContract } from "../wrappers/MainContract";
, g! U0 @. W {' |3 F: s' Q5 l - import "@ton/test-utils";# |, X* {5 n' K% U) l$ A% g
' ]: z; o4 i/ u3 R2 Z( `9 u- describe("main.fc contract tests", () => {
/ u6 T6 W# I6 T* A f - it("should get the proper most recent sender address", async () => {
6 ? b' z* ^- s& @9 i - const blockchain = await Blockchain.create();
7 L5 Q+ R# l2 z - const codeCell = Cell.fromBoc(Buffer.from(hex, "hex"))[0];3 A8 B5 D6 K& V ~8 j! L
( [. y: e$ A$ v) D9 E8 q' H. ]- const myContract = blockchain.openContract(
$ [% R& ]. d4 i0 o) }1 i; r, m) k- v - await MainContract.createFromConfig({}, codeCell)
l% H) Q* I- F: d; F( R& D - );
! I: h* I1 q+ U - - _3 V5 R5 g1 V/ {9 M
- const senderWallet = await blockchain.treasury("sender");7 m4 ^7 t6 x8 B
/ P/ `. i+ x/ h* ^7 d4 c2 _3 O0 r- const sentMessageResult = await myContract.sendInternalMessage(
4 L3 j: s1 |' @( p. Y( C& ~0 \( ?- R+ v - senderWallet.getSender(),% y' K0 X- k6 u1 t+ y2 d0 U5 q6 R2 P
- toNano("0.05")2 \# ]5 X, w( I+ l. O
- );
0 A' z2 M: I; e ^& l' C0 X - 1 s- q/ u5 b0 h+ ~- ^8 g0 y( P
- expect(sentMessageResult.transactions).toHaveTransaction({
! A. D# G R& c6 r& w - from: senderWallet.address,$ h/ T! g- b; B
- to: myContract.address,; P. F, S7 E% A. ^: |) C
- success: true,
- C5 O2 p R! R4 X9 P( a9 ? - });
! L7 [& W+ _+ E" {! ?( L2 N* @! F
" \9 G; C% W6 O! t% V- const data = await myContract.getData();- q4 u3 g, Q2 P6 R9 q- g4 U% z
! z9 Y- N9 d8 ~2 h- {- expect(data.recent_sender.toString()).toBe(senderWallet.address.toString());. r1 I0 i; M+ l* O& ?! x
- });
( N2 z. x7 ?2 H6 R! v - });
复制代码我们使用 Jest 功能来确保这一点: 我们还将 "我们的第一次测试 "更名为 "应获得正确的最新发件人地址",因为我们希望测试名称始终可读。 运行测试瞧!我们已经准备好运行测试了。只需运行我们的命令 yarn test 在终端中,如果你和我一起做了所有的事情,你也会得到类似的结果: - PASS tests/main.spec.ts0 A" F# W7 h+ n5 h
- main.fc contract tests7 V4 w1 v, u9 w, m f6 z
- ✓ should get the proper most recent sender address (444 ms)* G* O) X8 O4 a: z: G6 _
. w* d3 B0 W5 Y D- Test Suites: 1 passed, 1 total
3 h# M; H" m% u) L" R - Tests: 1 passed, 1 total
/ S5 F: } M, |1 ] - Snapshots: 0 total
) w ?3 n v, V3 \ - Time: 4.13 s, estimated 5 s
复制代码我们要做的最后一件事是确保每次运行测试时,同时运行编译器脚本。这有助于提高我们的工作效率。大部分开发工作都是编写功能代码,然后运行测试。让我们简化这一切: 更新 package.json 文件,使其看起来像这样: - {
9 X. M, i8 W' h( R1 t5 Q - ... our previous package.json keys6 r3 G3 i0 Q' i: d c2 M
- "scripts": {+ g: b0 F* v% E u
- ... previous scripts keys/ U8 x; \# f ~- ~) E/ z3 R/ q
- "test": "yarn compile && yarn jest"7 b, {/ q( h5 C9 f8 ]9 {1 u
- }! b/ g+ O( D n& _! [. B- Z! J
- }
复制代码 在下一课中,我们将构建部署途径,并学习如何在链上测试真正的部署合同
( j l( s# R: u) z1 h1 J
0 h- r" V5 K, p. J! X) A; B2 V0 I/ E/ Z+ B; s; D
|