跳转至

编写和测试交互

原文:https://docs.elrond.com/sdk-and-tools/erdjs/writing-and-testing-erdjs-interactions

##### 注

本教程使用了erdjs 10erdjs-snippets 3。这里的一切都是为了测试&审计智能合约。这不是写 dApps 的教程。

本教程将通过使用 erdjserdjs 片段,借助实际的合约交互,引导您完成智能合约的(系统)测试过程。

重要

不要引用 erdjs-snippets库作为你的项目(Node / dApp)的常规依赖(即dependencies段)。仅将其作为开发依赖项(即devDependencies部分)引用。

IDE 先决条件

为了遵循本教程中的步骤,您需要安装了以下扩展的 Visual Studio 代码:

设置步骤

设置工作区

首先,您需要在 Visual Studio 代码中打开一个包含智能合约和交互片段的文件夹。打开一个文件夹时,需要调用命令Elrond: Setup workspace

确保您的环境中有最新的Elrond SDK。为此,调用命令Elrond: Install SDK

添加一个或多个智能合约

在Elrond IDE 的模板视图中,选择模板adder,点击新建合约。然后,选择模板lottery-esdt,点击新建合约。这样,Elrond IDE 将为每个选择的智能合约创建一个文件夹

一个名为erdjs-snippets附加文件夹也被创建。那是一个 nodejs 包,保存着合约交互和测试片段的源代码。

在继续之前,确保构建了两个合约(根据需要,从Elrond IDE 的智能合约视图或使用命令行)。

设置片段

现在,您已经使用提供的模板创建了两个合约(并构建了它们),让我们通过调用命令Elrond: Setup erdjs-snippets来告诉 IDE(和 Mocha 测试浏览器)代码片段所在的位置。当要求指定包含代码片段的文件夹时,选择已经存在的文件夹erdjs-snippets

如前所述,文件夹erdjs-snippets是一个 nodejs 包。让我们通过在集成终端中运行以下命令来安装它的依赖项:

cd ./erdjs-snippets
npm install 

mocha 测试资源管理器(Visual Studio 代码扩展)现在应该将交互片段作为常规的 Mocha 测试,并在测试视图中列出它们,如下所示:

erdjs-snippets in Mocha Test Explorer

通过利用 Mocha 测试资源管理器,您可以运行调试一个、多个或所有步骤的代码片段。

现在您的工作空间和代码片段已经设置好了,让我们更深入地研究一下。在下一节中,我们将学习什么是,实际上是一个交互片段。

解剖一个 erdjs 片段

一个 erdjs 片段实际上是一个定义了一套摩卡测试的文件,扩展名为*.spec.ts*.snippet.ts。一个片段步骤是一个独立的类似测试的构造。

当执行一个或多个步骤时,它们在一个测试会话中执行,该测试会话由代码片段的以下指令选择:

session = await TestSession.load("nameOfMySession", __dirname); 

会话配置

测试会话通过一个nameOfMySession.session.json文件进行配置,该文件位于代码片段附近或上一层。在这个文件中,您可以配置网络提供商的 URL、要使用的测试钱包等。例如:

{
    "networkProvider": {
        "type": "ProxyNetworkProvider",
        "url": "https://devnet-gateway.elrond.com",
        "timeout": 5000
    },
    "users": {
        "individuals": [
            {
                "name": "alice",
                "pem": "~/elrondsdk/testwallets/latest/users/alice.pem"
            },
            {
                "name": "bob",
                "pem": "~/elrondsdk/testwallets/latest/users/bob.pem"
            }
        ],
        "groups": [
            {
                "name": "friends",
                "folder": "~/elrondsdk/testwallets/latest/users"
            }
        ]
    }
} 

另一个例子,用ApiNetworkProvider代替ProxyNetworkProvider:

{
    "networkProvider": {
        "type": "ApiNetworkProvider",
        "url": "https://devnet-api.elrond.com",
        "timeout": 5000
    },
    "users": {
        ...
    }
} 

会话状态

测试会话对象的主要职责之一是在步骤之间保持状态(直到它被显式销毁)。在底层,状态保存在位于nameOfMySession.session.json文件附近的轻量级 sqlite 数据库中。

销毁会话的一种方法是删除它的*.sqlite文件。另一种方法是在代码片段中定义一个特殊的步骤,如下所示:

it("destroy session", async function () {
    await session.destroy();
}); 

但是,在实践中,会话可以无限地重用。

例如,在早期步骤中,您可以保存已部署合约的地址、已颁发代币的标识符或一些任意数据:

await session.saveAddress({ name: "myContractAddress", address: addressOfMyContract });
...
await session.saveToken({ name: "lotteryToken", token: myLotteryToken });
...
await session.saveBreadcrumb({ name: "someArbitraryData", value: { someValue: 42 } }); 

然后,在后续步骤中,您可以加载先前存储的约定地址、代币和任意数据:

const myLotteryToken = await session.loadToken("lotteryToken");
...
const addressOfMyContract = await session.loadAddress("myContractAddress");
...
const someArbitraryData = await session.loadBreadcrumb("someArbitraryData"); 

断言

建议使用 assert 语句,这使得代码片段更有价值和意义。例如:

assert.isTrue(returnCode.isSuccess()); ... assert.equal(lotteryInfo.getFieldValue("token_identifier"), "myToken");
assert.equal(lotteryStatus, "someStatus"); 

测试用户

测试会话提供了一组测试用户来参与智能合约交互。给定上面作为示例提供的会话配置,用户可以按如下方式访问测试用户:

const alice: ITestUser = session.users.getUser("alice");
const bob: ITestUser = session.users.getUser("bob");
const friends: ITestUser[] = session.users.getGroup("friends"); 

为测试用户生成密钥

erdjs-snippets也允许你生成测试用户(秘密密钥)。在这个问题上,您首先必须提供一个配置文件,它为生成过程指定了一些参数。

例如,让我们创建文件myGenerator.json:

{
    "individuals": [
        {
            "shard": 0,
            "pem": "~/test-wallets/zero.pem"
        },
        {
            "shard": 1,
            "pem": "~/test-wallets/one.pem"
        },
        {
            "shard": 2,
            "pem": "~/test-wallets/two.pem"
        }
    ],
    "groups": [
        {
            "size": 3,
            "shard": 0,
            "pem": "~/test-wallets/manyZero.pem"
        },
        {
            "size": 3,
            "shard": 1,
            "pem": "~/test-wallets/manyOne.pem"
        },
        {
            "size": 3,
            "shard": 2,
            "pem": "~/test-wallets/manyTwo.pem"
        }
    ]
} 

然后,为了实际生成测试用户(密钥),在任意代码片段文件中添加一个步骤并运行它:

describe("user operations snippet", async function () {
    it("generate keys", async function () {
        this.timeout(OneMinuteInMilliseconds);

        const config = readJson<ISecretKeysGeneratorConfig>("myGenerator.json");
        await generateSecretKeys(config);
    });
}); 

如章节会话配置所示,可以使用生成的密钥。

将事件写入审计日志

在代码片段或交互器对象(稍后将详细介绍)中的某个点,记录事件(例如发送交易接收合约结果、或者在交互发生之前和/或之后记录状态快照)是很有用的(对于调试和审计智能合约来说)。为此,调用Audit对象的实用函数。

记录的事件将在会议报告中列出,稍后会详细介绍。

例如,在交互器中:

const transactionHash = await this.networkProvider.sendTransaction(transaction);
await this.audit.onTransactionSent({ action: "add", args: [value], transactionHash: transactionHash });

const transactionOnNetwork = await this.transactionWatcher.awaitCompleted(transaction);
await this.audit.onTransactionCompleted({ transactionHash: transactionHash, transaction: transactionOnNetwork }); 

例如,在代码片段文件中:

const sumBefore = await interactor.getSum();
const snapshotBefore = await session.audit.onSnapshot({ state: { sum: sumBefore } });

const returnCode = await interactor.add(owner, 3);
await session.audit.onContractOutcome({ returnCode });

const sumAfter = await interactor.getSum();
await session.audit.onSnapshot({ state: { sum: sumBefore }, comparableTo: snapshotBefore }); 

上面,注意快照功能的comparableTo参数。如果提供,则生成的会话报告将包括两个相关快照之间的差异(此功能从erdjs-snippets 3.0.0 起不可用)。

生成会话报告

重要

erdjs-snippets 3.0.0开始,报告生成是试验性的。它会随着时间的推移而改善。

erdjs-snippets可以根据测试会话中积累的数据和事件生成 HTML 报告。

为了配置报告功能,请在会话配置文件中定义一个附加条目:

"reporting": {
    "explorerUrl": "https://devnet-explorer.elrond.com",
    "apiUrl": "https://devnet-api.elrond.com",
    "outputFolder": "~/reports"
} 

然后,为了生成报告,添加一个额外的代码片段步骤:

it("generate report", async function () {
    await session.generateReport();
}); 

在运行该步骤时,outputFolder应该包含生成的会话报告。

对帮工的依赖

片段最重要的依赖项是合约交互器,它负责创建和执行基于 erdjs 的交互和合约查询。

解剖互动者

在我们的工作空间中,交互者是:adderInteractor.tslotteryInteractor.ts。它们包含几乎生产就绪的代码来调用和查询你的合约,这些代码通常可以复制粘贴到你的 dApps 中。

一般来说,交互组件(类)依赖于以下对象(由erdjserdjs的附属定义):

  • a SmartContract(由其SmartContractAbi组成)
  • 一个INetworkProvider,用于广播/检索交易并执行合约查询
  • INetworkConfig的快照
  • 一个TransactionWatcher,用于正确检测交易的完成
  • 一个ResultsParser,用于解析合约查询或合约交互的结果
  • 可选地,一个IAudit对象记录测试会话中的某些事件

创建一个交互器

让我们看看如何构造一个交互器(我们以彩票合约为例)。

首先,您必须加载 ABI:

const registry = await loadAbiRegistry(PathToAbi);
const abi = new SmartContractAbi(registry); 
重要

确保你已经提前看了一遍秘籍

然后,创建一个SmartContract对象,如下所示:

const contract = new SmartContract({ address: address, abi: abi }); 

如果合约的地址未知(例如之前的部署),则忽略上面的地址参数。

之后,保存对测试会话提供的NetworkProviderNetworkConfig快照的引用:

const networkProvider = session.networkProvider;
const networkConfig = session.getNetworkConfig(); 

最后,创建交互器:

const interactor = new LotteryInteractor(contract, networkProvider, networkConfig); 

在我们的例子中,TransactionWatcherResultsParser通常由 interactor 类实例化(例如在构造函数中),而不是作为依赖项提供。但是这不应该被认为是一个指导方针。下面是创建交易观察器和结果解析器的方法:

const transactionWatcher = new TransactionWatcher(networkProvider);
const resultsParser = new ResultsParser(); 

最后,创建交互器的代码如下所示:

export async function createLotteryInteractor(session: ITestSession, contractAddress?: IAddress): Promise<LotteryInteractor> {
    const registry = await loadAbiRegistry(PathToAbi);
    const abi = new SmartContractAbi(registry);
    const contract = new SmartContract({ address: contractAddress, abi: abi });
    const networkProvider = session.networkProvider;
    const networkConfig = session.getNetworkConfig();
    const audit = session.audit;
    const interactor = new LotteryInteractor(contract, networkProvider, networkConfig, audit);
    return interactor;
} 

其中类LotteryInteractor定义如下:

export class LotteryInteractor {
    private readonly contract: SmartContract;
    private readonly networkProvider: INetworkProvider;
    private readonly networkConfig: INetworkConfig;
    private readonly transactionWatcher: TransactionWatcher;
    private readonly resultsParser: ResultsParser;
    private readonly audit: IAudit;

    constructor(contract: SmartContract, networkProvider: INetworkProvider, networkConfig: INetworkConfig, audit: IAudit) {
        this.contract = contract;
        this.networkProvider = networkProvider;
        this.networkConfig = networkConfig;
        this.transactionWatcher = new TransactionWatcher(networkProvider);
        this.resultsParser = new ResultsParser();
        this.audit = audit;
    }

    // ... methods of the interactor (see next section)
} 

互动者的方法

一般来说,在编写交互器时,您希望智能合约的每个端点都有一个函数(方法)。虽然这在针对readonly / get端点编写查询函数时很简单,但是对于您需要构建的executable / do端点,签署(使用签署/钱包供应商)并广播一个交易,然后可选地等待它的执行并解析结果(如果有的话)。

调用executable端点的流程的中断性质和一些签名/钱包供应商(例如,在网页中导航)所要求的最终上下文切换,使得在交互器的单个函数(方法)中以普遍适用的方式捕获(流程)变得更加困难。然而,示例交互器遵循每个端点一个方法的准则,因为它们使用一个测试用户对象对交易进行签名(也就是说,没有外部签名供应商)。

编写一个用于合约查询的交互器方法

重要

确保你已经提前看了一遍秘籍

为了将合约查询实现为交互器的一种方法,您首先需要准备Interaction对象:

// Example 1 (adder contract)
const interaction = <Interaction>this.contract.methods.getSum();

// Example 2 - automatic type inference of parameters (lottery contract)
const interaction = <Interaction>this.contract.methods.status(["my-lottery"]);

// Example 2 - explicit types (lottery contract)
const interaction = <Interaction>this.contract.methodsExplicit.status([
    BytesValue.fromUTF8("my-lottery")
]);

// Example 3 - automatic type inference of parameters (lottery contract)
const interaction = <Interaction>this.contract.methodsAuto.getLotteryWhitelist(["my-lottery"]);

// Example 3 - explicit types (lottery contract)
const interaction = <Interaction>this.contract.methodsExplicit.getLotteryWhitelist([
    BytesValue.fromUTF8("my-lottery")
]);

// Example 4 - automatic type inference of parameters (lottery contract)
const interaction = <Interaction>this.contract.methods.getLotteryInfo(["my-lottery"]);

// Example 4 - explicit types (lottery contract)
const interaction = <Interaction>this.contract.methodsExplicit.getLotteryInfo([
    BytesValue.fromUTF8("my-lottery")
]); 

上面,您可能会注意到有两种可能的方式为交互提供参数:显式模式和隐式模式,也称为自动模式——因为它执行自动类型推断(在 erdjs 自己的类型系统中)关于端点定义(更准确地说,关于输入参数的 ABI 类型)。您可以选择任何模式来为交互提供参数。选择最适合您的编程风格的一个。

然后,你应该根据 ABI 验证交互对象(如果你使用自动模式,跳过这一步)。如果不遵循 ABI(更具体地说,是端点的输入参数),它将抛出一个错误:

interaction.check(); 

现在让我们运行查询:

let queryResponse = await this.networkProvider.queryContract(query); 

然后解析结果:

// Example 1
const { firstValue } = this.resultsParser.parseQueryResponse(queryResponse, interaction.getEndpoint());

// Example 2
const { firstValue, secondValue, thirdValue } = this.resultsParser.parseQueryResponse(queryResponse, interaction.getEndpoint());

// Example 3
const { values, returnCode } = this.resultsParser.parseQueryResponse(queryResponse, interaction.getEndpoint());

// Example 4
const bundle = this.resultsParser.parseQueryResponse(queryResponse, interaction.getEndpoint()); 

最后,在将包中的值返回给交互器函数(方法)的调用方之前,您可以(可选地)进行强制转换,然后解释包中的值(必要时):

// Example 1
const firstValueAsBigUInt = <BigUIntValue>firstValue;
return firstValueAsBigUInt.valueOf().toNumber();

// Example 2
const firstValueAsEnum = <EnumValue>firstValue;
return firstValueAsEnum.name;

// Example 3
const firstValueAsVariadic = <VariadicValue>firstValue;
return firstValueAsVariadic.valueOf();

// Example 4 (not calling valueOf())
const firstValueAsStruct = <Struct>firstValue;
return firstValueAsStruct; 

现在让我们把代码放在一起,看看一些完整的例子。

获取彩票的状态(枚举):

// Interactor method:
async getStatus(lotteryName: string): Promise<string> {
    // Prepare the interaction
    const interaction = <Interaction>this.contract.methods.status([lotteryName]);
    const query = interaction.check().buildQuery();

    // Let's run the query and parse the results:
    const queryResponse = await this.networkProvider.queryContract(query);
    const { firstValue } = this.resultsParser.parseQueryResponse(queryResponse, interaction.getEndpoint());

    // Now let's interpret the results.
    const firstValueAsEnum = <EnumValue>firstValue;
    return firstValueAsEnum.name;
}

// Caller:
let status: string = await interactor.getStatus("my-lottery");
console.log(status); 

获取彩票信息 (struct) :

// Interactor method:
async getLotteryInfo(lotteryName: string): Promise<Struct> {
    // Prepare the interaction
    const interaction = <Interaction>this.contract.methods.getLotteryInfo([lotteryName]);
    const query = interaction.check().buildQuery();

    // Let's run the query and parse the results:
    const queryResponse = await this.networkProvider.queryContract(query);
    const { firstValue } = this.resultsParser.parseQueryResponse(queryResponse, interaction.getEndpoint());

    // Now let's interpret the results.
    const firstValueAsStruct = <Struct>firstValue;
    return firstValueAsStruct;
}

// Caller:
const lotteryInfo: Struct = await interactor.getLotteryInfo("my-lottery");
console.log(lotteryInfo.valueOf());
console.log(lotteryInfo.getFieldValue("token_identifier"));
console.log(lotteryInfo.getFieldValue("prize_pool")); 

编写一个用于合约调用的 interactor 方法

重要

确保你已经提前看了一遍秘籍

为了将合约调用实现为交互器的方法,首先需要准备Interaction对象:

// Example 1 (adder)
const interaction = <Interaction>this.contract.methods
    .add([new BigUIntValue(value)])
    .withGasLimit(new GasLimit(10000000))
    .withNonce(caller.account.getNonceThenIncrement()); 
// Example 2 - automatic type inference (lottery)
const interaction = <Interaction>this.contract.methods
    .start([
        lotteryName,
        token_identifier,
        price,
        null,
        null,
        1
        null,
        whitelist
        // not provided
    ])
    .withGasLimit(new GasLimit(20000000))
    .withNonce(owner.account.getNonceThenIncrement()); 
// Example 2 - explicit types (lottery)
const interaction = <Interaction>this.contract.methodsExplicit
    .start([
        BytesValue.fromUTF8(lotteryName),
        new TokenIdentifierValue(token_identifier),
        new BigUIntValue(price),
        OptionValue.newMissing(),
        OptionValue.newMissing(),
        OptionValue.newProvided(new U32Value(1)),
        OptionValue.newMissing(),
        OptionValue.newProvided(createListOfAddresses(whitelist)),
        OptionalValue.newMissing()
    ])
    .withGasLimit(new GasLimit(20000000))
    .withNonce(owner.account.getNonceThenIncrement()); 
// Example 3 - automatic type inference (lottery)
const interaction = <Interaction>this.contract.methods
    .buy_ticket([lotteryName])
    .withGasLimit(new GasLimit(50000000))
    .withSingleESDTTransfer(amount)
    .withNonce(user.account.getNonceThenIncrement()); 

一般来说,您可以在交互器中指定默认的气体限制并在合约调用上应用支付(代币转移)(见上文withGasLimitwithSingleESDTTransfer),但是根据您的需要,也有其他的设计方法。

重要

帐户 nonce 必须事先同步(即在调用 interactor 方法之前)。

然后,您应该根据 ABI 验证交互对象(如果您使用自动模式,则跳过这一步),然后构建交易对象:

let transaction = interaction.check().buildTransaction(); 

然后,使用签名人(例如 dApp 提供商)对交易进行签名。在代码片段中,我们使用ITestUser对象来执行签名:

await owner.signer.sign(transaction); 

现在让我们广播交易并等待其完成:

await this.networkProvider.sendTransaction(transaction);
const transactionOnNetwork = await this.transactionWatcher.awaitCompleted(transaction); 

最后,我们将结果解析到一个名为TypedOutcomeBundle的对象中(就像查询响应一样):

// Example 1
const { returnCode } = this.resultsParser.parseOutcome(transactionOnNetwork, interaction.getEndpoint());

// Example 2
const bundle = this.resultsParser.parseOutcome(transactionOnNetwork, interaction.getEndpoint());

// Example 3
const { returnCode, firstValue } = this.resultsParser.parseOutcome(transactionOnNetwork, interaction.getEndpoint()); 

然后,为了解释结果,遵循与查询结果相同的准则(上一节)。

现在让我们把代码放在一起,看一个完整的例子:

async buyTicket(user: ITestUser, lotteryName: string, amount: TokenPayment): Promise<ReturnCode> {
    console.log(`LotteryInteractor.buyTicket(): address = ${user.address}, amount = ${amount.toPrettyString()}`);

    // Prepare the interaction
    let interaction = <Interaction>this.contract.methods
        .buy_ticket([
            lotteryName
        ])
        .withGasLimit(50000000)
        .withSingleESDTTransfer(amount)
        .withNonce(user.account.getNonceThenIncrement())
        .withChainID(this.networkConfig.ChainID);

    // Let's check the interaction, then build the transaction object.
    let transaction = interaction.check().buildTransaction();

    // Let's sign the transaction. For dApps, use a wallet provider instead.
    await user.signer.sign(transaction);

    // Let's broadcast the transaction and await its completion:
    const transactionHash = await this.networkProvider.sendTransaction(transaction);
    await this.audit.onTransactionSent({ action: "buyTicket", args: [lotteryName, amount.toPrettyString()], transactionHash: transactionHash });

    const transactionOnNetwork = await this.transactionWatcher.awaitCompleted(transaction);
    await this.audit.onTransactionCompleted({ transactionHash: transactionHash, transaction: transactionOnNetwork });

    // In the end, parse the results:
    let { returnCode } = this.resultsParser.parseOutcome(transactionOnNetwork, interaction.getEndpoint());
    return returnCode;
} 


回到顶部