使用 JavaScript 为 Cadence 智能合约编写单元测试

Tags
Tech
Cadence
Published
Mar 27, 2022 07:52 AM
Language
ZH
notion image

使用 JavaScript 为 Cadence 智能合约编写单元测试

本文假设读者熟悉了 Cadence 智能合约的编写,熟悉 Flow 区块链的基础知识,并具备一定的 JavaScript 语言基础。

写在前面

使用 Js 来编写智能合约单元测试的目的是能够,将区块链等复杂的逻辑进行封装,最终通过简单的接口完成智能合约逻辑的测试,最终的效果如下:
// setup test case export const setupTest = () => describe('Contract setup test cases', () => { beforeAll(() => { // 在每次用例之前调用 fcl 的配置 return fclInit() dotenv.config() }) // test unit test('init admin resource failed', async () => { const res = await buildSetupTrx('initFlownsAdminStorage') expect(res).toBeNull() }) test('init admin cap with root domain collection', async () => { const res = await buildSetupTrx('setupAdminServer', []) expect(res).not.toBeNull() const { status } = res // check trx status expect(status).toBe(4) }) // ... })
这些代码几乎和我们经常使用的 Js 测试代码相同,这是怎么做到的呢。
开始之前我们需要先了解目前对 Cadence 进行单元测试需要解决的一些问题:
  • 非托管模式的签名工具不完善
  • Cadence 开发环境变量过度依赖 flow.json 配置,也不支持配置扩展
  • 脚本文件比较多不好管理,需要动态加载脚本文件完成 Js 的调用
  • 环境切换所需要的配置方法和流程不同,需要做自动化处理
虽然基于 solidity 已经有很多成熟的智能合约测试工具,但对于异构的 Cadence 语言来说,这些工具无法复用。
因为笔者比较熟悉 Js 语言,也倾向于使用统一的技术栈去构建去中心化应用,那么选择使用 Js 语言来完成包括应用编写和合约测试一整套流程,对一个项目来说,会降低整体维护的复杂度,测试脚本也可以在前后端复用。
目前 Flow 已经有非常优秀的 Js 框架的开源测试flow-js-testing和工具库flow-cadut,由于这两个框架对代码的结构影响较大,根据我的需求进行测试脚本的定制的时候会有比较多的冗余代码出现,加上这两个库封装了对测试环境和主网的运维支持并不够灵活,就萌生了自己动手的想法。
和一些同样在编写 Cadence 脚本的技术人员交流时也发现,他们对测试的需求略有不同,所以这套流程也不一定适用于所有需要进行 Cadence 单元测试的项目和流程。
为了能够方便大家理解和使用 Js 对 Cadence 进行测试,我将围绕一下问题讲解如何通过 Js 解决 Cadence 脚本测试中遇到的问题。

测试环境初始化与切换

环境切换是测试和生产使用的时候需要解决的大问题,Cadence 的本地环境的配置和 testnet 环境有很大不同,而 testnet 和 mainnet 也有一些区别,所以我们在编写测试之前需要解决环境初始化和配置的问题。
环境初始化在实际的开发过程中其实包含三个部分:
  • 本地环境 - localnet
  • 测试环境 - testnet
  • 主网环境 - mainnet
这里面针对不同的环境需要使用不同的环境初始化脚本,因为本地网络依赖 Flow 的 emulator 模拟器完成环境的搭建和配置,主要涉及到一些命令行的执行与配置。
所以我们很多的设置会放在 flow.json 中,将合约所依赖的变量通过配置文件进行管理,同样也方便未来维护。
测试环境比较类似于主网环境,合约的部署和调试都通过命令行和网络请求完成,测试网也支持合约的更新和升级,但需要借助 Flow faucet 来完成测试账户的创建和初始化。
主网目前需要审计后由授权账号部署合约,其余的流程与测试网相同, 但引用的合约地址会有所变化,且账户创建需要手动完成,但主网环境更多的是进行一些合约的管理和脚本的运行,也需要使用到我们的测试相关的脚本来更好的进行管理。
这里笔者采用了 flow.json + dotenv 两种模式混合管理环境变量的方案

flow.json 配置

flow.json 是由 FCL 命令行工具生成,其起到了环境配置的作用,其文件结构和配置相关的文档可以看这里
{ "emulators": { "default": { "port": 3569, "serviceAccount": "emulator-account" } }, "networks": { "emulator": "127.0.0.1:3569", "mainnet": "access.mainnet.nodes.onflow.org:9000", "testnet": "access.devnet.nodes.onflow.org:9000" }, "accounts": { "emulator-account": { "address": "f8d6e0586b0a20c7", "key": "ae1b44c0f5e8f6992ef2348898a35e50a8b0b9684000da8b1dade1b3bcd6ebee", } }, "deployments": {}, // "contracts": {} // contracts }
  • networks: 配置不同网络的节点
  • accounts: 配置测试环节所需要使用到的账户信息(包含私钥和地址,会有私钥泄露的风险)这里在主网环境中需要谨慎填写,或者使用环境变量替代(后面会讲到)
  • deployments: 配置合约所需要部署的账户关系。
  • contracts: 配置合约关联关系
配置的细节可以参考上面提到的文档。 这里的 flow.json 主要满足本地和测试网部署合约时执行命令行所需的参数 测试中涉及到多账户的管理页需要提前配置在 flow.json 中,方便执行命令行调用。 光有 flow.json 还不够,关于一些定制化的环境配置,我们还需要 dotenv 进行补充。

dotenv

dotenv 是比较通用的基于 Js 的环境变量管理工具,可以使用不同的 .env 文件在不修改项目代码的情况下完成对环境依赖变量的替换,且 dotenv 支持 node.js 和前端项目同构使用。
另外,现在大部分的运维工具和托管平台都支持通过 .env 的方式安全的注入与替换变量。
在实际的测试中,我们会把一些环境相关的变量进行配置,例如
  • access node 地址
  • 管理员地址与私钥
  • 依赖合约的地址
  • 开发平台或第三方平台的 apiKey 等等
FLOW_ACCOUNT_ADDRESS=0xb05b2abb42335e88 FLOW_ACCOUNT_KEY_ID=0 FLOW_ACCOUNT_PRIVATE_KEY=<key> FLOW_ACCOUNT_PUBLIC_KEY=acec1c454....1656a4d4cc592e6f FLOW_TOKEN_ADDRESS=0x7e60df042a9c0868 FUSD_TOKEN_ADDRESS=0xe223d8a629e49c68 FLOW_FUNGIBLE_ADDRESS=0x9a0766d93b6608b7 FLOW_NONFUNGIBLE_ADDRESS=0x631e88ae7f1d7c20 ALCHEMY_KEY=jzmz.....laqhxy9r
在配置读取端我们这么处理:
import dotenv from 'dotenv' dotenv.config() export const nodeUrl = process.env.FLOW_ACCESS_NODE export const privateKey = process.env.FLOW_ACCOUNT_PRIVATE_KEY export const accountKeyId = process.env.FLOW_ACCOUNT_KEY_ID export const accountAddr = process.env.FLOW_ACCOUNT_ADDRESS export const flowTokenAddr = process.env.FLOW_TOKEN_ADDRESS export const FUSDTokenAddr = process.env.FUSD_TOKEN_ADDRESS export const KibbleTokenAddr = process.env.KIBBLE_TOKEN_ADDRESS export const flowFungibleAddr = process.env.FLOW_FUNGIBLE_ADDRESS export const flowNonFungibleAddr = process.env.FLOW_NONFUNGIBLE_ADDRESS export const publicKey = process.env.FLOW_ACCOUNT_PUBLIC_KEY export const alchemyKey = process.env.ALCHEMY_KEY
然后通过 fcl 提供的配置函数进行变量替换:
export const fclInit = () => { fcl.config() .put('accessNode.api', nodeUrl) .put('0xDomains', accountAddr) .put('0xFlowns', accountAddr) .put('0xNonFungibleToken', flowNonFungibleAddr) .put('0xFungibleToken', flowFungibleAddr) .put('0xFlowToken', flowTokenAddr) .put("grpc.metadata", {"api_key": alchemyKey}) }
这里如果未来需要根据环境切换来更新变量,只需要替换更新对应的 .env 文件即可
并且为了安全考虑 .env 文件要设置到 .gitignore 文件中,不随代码提交,避免核心的运维参数泄露。
详情请参阅 Flowns 源码

配置调用初始化命令

在本地的模拟器环境并不会记录区块的信息和数据,也就是说,一旦 emulator 停止运行,就会清除掉原有产生的区块数据,包括合约和账户信息,但好在模拟器有个特点是轻量,能够快速的执行交易和测试脚本。也可以随时重新初始化,帮助我们对合约进行 break change,本地模拟器能够根据相同的公钥产生出相同的地址,所以我们可以直接通过命令整合并参数化地址,在多次执行中获得相同的结果。
这里我使用了 npm script 帮助我完成本地测试的环境初始化下工作:
  • 本地多账户创建
  • 合约部署
  • 测试脚本整合
  • 自定义脚本配置
因为 package.json 中支持在 script 中配置命令,且命令之前也可以进行调用,那么我们就可以根据自身的需求,把命令所需要执行的命令行进行配置:
{ "scripts": { //... "test": "node --experimental-vm-modules node_modules/.bin/jest", "setup:env": "node scripts/setupEnv.js", "init:emulator": "flow project deploy && yarn acc:test1 && yarn acc:test2 && yarn setup:env ", "acc:test1": "flow accounts create --key 05013fc02bde69176dd7668422e834411bd38189ffe5db4c63083c39de75cc61b850929065b6a68411653a1790183c99012077761335d5ecd4a70a89d4bc2627", "acc:test2": "flow accounts create --key d44419da1d05f4d448e48501dfa1069513b71de08b57904e7599369296754dc42da4207edc561f5fd47c06b677d9d3cdf18274d3911201f02a019f96510f1572", "setup:testnet": "node scripts/setupTestnetContract.js" } }
以上的脚本配置中我们看到 init:emulator 脚本依赖了三个脚本
  • acc:test1 : 在模拟器中生成测试账户1
  • acc:test2: 在模拟器中生成测试账户2
  • setup:env: 根据账户配置初始化账户余额和一些测试需要的参数
这样我们就可以根据自身的需求将命令行的执行进行拆分与脚本化配置,同样也不需要记录复杂的命令行脚本和参数,通过 npm 脚本统一管理。
注意: 因为在本地环境相同的 key 创建出来的账户地址始终相同,那么我们可以将测试多账户的对应的地址进行变量配置。

用 JavaScript 实现私钥签名

因为合约的开发和部署需要开发者自己管理私钥,从而更快速的通过程序快速签名交易,我们需要自己实现一套能够兼容多账户的私钥签名函数,基于 fcl-dev-wallet 的本地签名算法,支持不同私钥签名发送Cadence 交易函数
// local export const test1Addr = '0x01cf0e2f2f715450' export const test2Addr = '0x179b6b1cb6755e31' export function authFunc(opt = {}) { const { addr, keyId = 0, tempId = 'SERVICE_ACCOUNT', key } = opt return (account) => { return { ...account, tempId, addr: fcl.sansPrefix(addr), keyId: Number(keyId), signingFunction: (signable) => ({ addr: fcl.withPrefix(addr), // must match the address that requested the signature, but with a prefix keyId: Number(keyId), // must match the keyId in the account that requested the signature signature: sign(key, signable.message), // signable.message |> hexToBinArray |> hash |> sign |> binArrayToHex }), } } } export function test1Authz() { const authz = authFunc({ addr: test1Addr, key: '368083923398158...cc173dbd5c78b6b4', keyId: 0, }) return authz } export function test2Authz() { const authz = authFunc({ addr: test2Addr, key: '5f10a1fd823...238688967', keyId: 0, }) return authz }
如此我们就可以在测试脚本里通过不同的授权函数test1Authztest2Authz代表不同的用户发起交易。
这里为了安全起见,也可以将 key 参数配置在环境变量里,因为这里只用在本地和测试环境,就不做单独处理。

Cadence 查询与交易脚本管理

在 Cadence 合约中,查询和发起交易的脚本都是单独的 cdc 文件,如果想要方便的让 Js 脚本调用,需要我们提供一些封装的函数来简化整个调用的过程。
使用buildPath将脚本 cdc 文件路径和具体的 key 对应起来生成一个 map 结构。
const buildPath = (fileName, type) => { let filePath = '' switch (type) { case 'setup': filePath = `../cadence/transactions/setup/${fileName}` break case 'script': filePath = `../cadence/scripts/${fileName}` break default: filePath = `../cadence/transactions/${fileName}` } return filePath } export const paths = { setup: { initDomainCollection: buildPath('init_domains_collection.cdc', 'setup'), initFlownsAdminStorage: buildPath('init_flowns_admin_storage.cdc', 'setup'), //... }, scripts: { checkDomainCollection: buildPath('check_domain_collection.cdc', 'script'), checkFlownsAdmin: buildPath('check_flowns_admin.cdc', 'script'), //... }, transactions: { registerDomain: buildPath('register_domain.cdc'), registerDomainBatch: buildPath('register_domain_batch.cdc'), //... }, }
接着封装成工具函数将具体授权和脚本的执行结合起来
通过 key 来获取到对应 cdc 脚本文件的路径,紧接着使用 readCode 加载脚本中的内容。
// 支持传入自定义的签名函数,执行交易脚本 export const buildAndSendTrx = async (key, args = [], authFunc = null, limit=9999) => { try { // 读取对应的脚本内容 const trxScript = await readCode(transactions[key]) const trxId = await sendTrx(trxScript, args, authFunc, limit) const txStatus = await fcl.tx(trxId).onceSealed() return txStatus } catch (error) { console.log(error) return null } } export const sendTrx = async (CODE, args, auth = null, limit = 9999) => { const authFunc = auth || authz const txId = await fcl.send([ fcl.transaction(CODE), fcl.args(args), fcl.proposer(authFunc), fcl.payer(authFunc), fcl.authorizations([authFunc]), fcl.limit(limit), ]).then(fcl.decode) return txId } export const buildAndExecScript = async (key, args = []) => { // 读取映射中的 script 脚本内容 const script = await readCode(scripts[key]) const result = await execScript(script, args) return result } export const readCode = async (path) => { const data = fs.readFileSync(resolve(__dirname, path), 'utf-8') return data } export const sleep = async (time) => { return new Promise((resolve) => setTimeout(resolve, time)) }
如此一来我们就可以在测试脚本中通过调用封装函数来完成交易的执行和验证
// 查询脚本结果 key 与 buildpath 中的 key 对应 const result = await buildAndExecScript('checkDomainCollection', [ fcl.arg('0xf8d6e0586b0a20c7', t.Address), ]) // 使用 test1 和 test2 的账户授权初始化账户资源 await buildSetupTrx('initTokens', [], test1Authz()) await buildSetupTrx('initTokens', [], test2Authz())

变量替换

在之前的内容里我们提到了通过 dotenv 的方式完成了变量的替换,例如:fcl.config().put('0xDomains', accountAddr),fcl 在执行脚本之前会将脚本中与 0xDomains 对应的代码替换为我们的环境变量:
import Domains from 0xDomains pub fun main(address: Address) : Bool { return getAccount(address).getCapability<&{Domains.CollectionPublic}>(Domains.CollectionPublicPath).check() }
所以按照这个标准,在我们编写脚本时,配置对应的所需替换的地址,之后就可以通过 .env 文件完成对全部查询与交易脚本中的地址替换,达到切换环境同时切换对应脚本的效果。

测试用例编写

这里我们使用常用的 Js 测试框架 Jest 来完成单元测试逻辑的编写,当然根据每个人的习惯,我们也可以使用 Mocha 等测试框架替代,其核心的使用就是把之前准备的工具脚本进行组合,并编写成可以拆分粒度的单元测试。
这里需要注意的一点是因为区块链是串行的,而且很多测试用例的状态需要依赖前置状态,所以我们需要将并行的测试框架修改为串行。 因为本地模拟器再不发生交易的时候不会产生新的区块,所以我们基本上在本地的测试串行频率搞的情况下也不会出现任何问题,但在测试环境,需要依赖状态的交易需要等待区块确认完成之后再进行第二笔的测试用例执行。
所以我们会使用到 sleep 函数来完成区块的等待,同样也需要修改测试框架中配置 timeout 的配置,以方便我们模拟真实的测试流程。
我们来看测试文件的整体结构:
// 并行变串行 import { setupTest } from './testSuite/setup.js' import { userTest } from './testSuite/userCase.js' describe('flowns test case', () => { setupTest() userTest() })
// setup test case export const setupTest = () => describe('Contract setup test cases', () => { beforeAll(() => { // 在每次用例之前调用 fcl 的配置 return fclInit() dotenv.config() }) // test unit test('init admin resource failed', async () => { const res = await buildSetupTrx('initFlownsAdminStorage') expect(res).toBeNull() }) test('init admin cap with root domain collection', async () => { const res = await buildSetupTrx('setupAdminServer', []) expect(res).not.toBeNull() const { status } = res // check expect(status).toBe(4) }) // ... })
我们也可以直接通过 Js 文件编写管理脚本
import t from '@onflow/types' import { fclInit, buildSetupTrx, buildAndExecScript, buildAndSendTrx } from '../utils/index.js' import fcl from '@onflow/fcl' import { accountAddr, flowTokenAddr } from '../config/constants.js' import { test1Addr, test2Addr, test1Authz, test2Authz } from '../utils/authz.js' // use admin to mint flow token export const mintFlowToken = async (address, amount) => { await buildSetupTrx('mintFlowToken', [fcl.arg(address, t.Address), fcl.arg(amount, t.UFix64)]) } // set test env const main = async () => { // fcl init and load config fclInit() // mint token await mintFlowToken(test1Addr, '1000.00000000') await mintFlowToken(test2Addr, '1000.00000000') const balance = await buildAndExecScript('queryFlowTokenBalance', [fcl.arg(test2Addr, t.Address)]) console.log(balance) // 通过测试账户初始化 Vault 资源 await buildSetupTrx('initTokens', [], test1Authz()) await buildSetupTrx('initTokens', [], test2Authz()) // .... } main().then(() => process.exit(0)) .catch((error) => { console.error(error) process.exit(1) })
以上的脚本实现了通过自定义的逻辑帮助测试账户完成资产和余额的初始化。

总结

到这里我们已经使用 Js 把 Cadence 智能合约测试的几个大问题解决了,这些工具类的脚本能够帮我们快速的验证智能合约逻辑,方便的编写复杂的测试场景和用例。
不过测试框架的使用和选择,需要根据自身的需求来定制,没有通用的方法,但本文想通过几个思路来阐述笔者在进行单元测试时所遇到的问题,和代码层面的解决方案,方案还有很多值得改进和提升的地方,但已经能够满足我进行复杂环境和条件的测试,也可以满足日常维护和管理合约的需求。
2022-03-26