subgraphs > Developing > Creating > 单元测试框架

单元测试框架

Reading time: 32 min

Learn how to use Matchstick, a unit testing framework developed by LimeChain. Matchstick enables subgraph developers to test their mapping logic in a sandboxed environment and sucessfully deploy their subgraphs.

Benefits of Using Matchstick

链到本节
  • It's written in Rust and optimized for high performance.
  • It gives you access to developer features, including the ability to mock contract calls, make assertions about the store state, monitor subgraph failures, check test performance, and many more.

开始

链到本节

Install Dependencies

链到本节

In order to use the test helper methods and run tests, you need to install the following dependencies:

yarn add --dev matchstick-as

Install PostgreSQL

链到本节

graph-node depends on PostgreSQL, so if you don't already have it, then you will need to install it.

Note: It's highly recommended to use the commands below to avoid unexpected errors.

Using MacOS

链到本节

Installation command:

brew install postgresql

创建到最新 libpq.5. lib* 的符号链接,可能需要首先创建这个目录*/usr/local/opt/postgreql/lib/

ln -sf /usr/local/opt/postgresql@14/lib/postgresql@14/libpq.5.dylib /usr/local/opt/postgresql/lib/libpq.5.dylib

Using Linux

链到本节

Installation command (depends on your distro):

sudo apt install postgresql

Using WSL (Windows Subsystem for Linux)

链到本节

可以使用 Docker 方法和二进制方法在 WSL 上使用 Matchstick。由于 WSL 可能有点复杂,所以这里有一些提示,以防您遇到诸如

static BYTES = Symbol("Bytes") SyntaxError: Unexpected token =

或者

<PROJECT_PATH>>/node_modules/gluegun/build/index.js:13 抛出;

请确保您使用的是新版本的 Node.js graph-cli 不再支持 v10.19.0,而且这仍然是 WSL 上新 Ubuntu 映像的默认版本。例如,已经证实Matchstick 可以在使用 v18.1.0的 WSL 上工作,您可以通过 nvm 或者更新全局 Node.js 切换到 Matchstick。不要忘记删除 node _ module,并在更新 nodejs 之后再次运行 npm install!然后,确保已经安装了 libpq,可以通过运行

sudo apt-get install libpq-dev

最后,不要使用graph test (使用全局安装的 graph-cli,并且由于某些原因,它看起来像是在 WSL 上已经坏掉了) ,而是使用yarn testnpm run test(这将使用本地的项目级的 graph-cli 实例,它的工作原理非常有趣。)为此,您当然需要在 package.json 文件中有一个“ test”脚本,它可以简单到

{
"name": "demo-subgraph",
"version": "0.1.0",
"scripts": {
"test": "graph test",
...
},
"dependencies": {
"@graphprotocol/graph-cli": "^0.56.0",
"@graphprotocol/graph-ts": "^0.31.0",
"matchstick-as": "^0.6.0"
}
}

Using Matchstick

链到本节

要在子图项目中使用Matchstick,只需打开一个终端,导航到项目的根文件夹,然后简单地运行graph test [options] <datasource>-它下载最新的Matchstick二进制文件,并在测试文件夹中运行指定的测试或所有测试(如果未指定数据源标志,则运行所有现有测试)。

CLI 选项

链到本节

这将运行测试文件夹中的所有测试:

graph test

这将运行名为gravity.test.ts的测试和/或名为gravity的文件夹中的所有测试:

graph test Gravity

这将仅运行特定的测试文件:

graph test path/to/file.test.ts

选项

-c, --coverage Run the tests in coverage mode
-d, --docker Run the tests in a docker container (Note: Please execute from the root folder of the subgraph)
-f, --force Binary: Redownloads the binary. Docker: Redownloads the Dockerfile and rebuilds the docker image.
-h, --help Show usage information
-l, --logs Logs to the console information about the OS, CPU model and download url (debugging purposes)
-r, --recompile Forces tests to be recompiled
-v, --version <tag> Choose the version of the rust binary that you want to be downloaded/used

Docker

链到本节

graph cli 0.25.2中,graph test命令支持在带有-d标志的docker容器中运行matchstick 。docker实现使用bind mount,因此它不必在每次执行graph test-d命令时重新构建docker映像。或者,您可以按照matchstick存储库中的说明手动运行docker。

graph test -d forces docker run to run with flag -t. This must be removed to run inside non-interactive environments (like GitHub CI).

❗如果您以前运行过graph test,则在docker构建过程中可能会遇到以下错误:

error from sender: failed to xattr node_modules/binary-install-raw/bin/binary-<platform>: permission denied

在本例中,在根文件夹中创建一个.dockerignore,并添加node_modules/bibinary install raw/bin

配置

链到本节

Matchstick可以通过Matchstick.yaml配置文件配置为使用自定义测试、库和清单路径:

testsFolder: path/to/tests
libsFolder: path/to/libs
manifestPath: path/to/subgraph.yaml

演示子图

链到本节

您可以通过克隆Demo Subgraph repo来尝试并使用本指南中的示例。

视频教程

链到本节

此外,您还可以查看“如何使用Matchstick为子图编写单元测试”系列视频

Tests structure

链到本节

IMPORTANT: The test structure described below depens on matchstick-as version >=0.5.0

描述()

链到本节

describe(name: String , () => {}) - 定义测试组。

注意:

  • 描述不是强制性的。您仍然可以在describe()区块之外以旧的方式使用test()

例子:

import { describe, test } from "matchstick-as/assembly/index"
import { handleNewGravatar } from "../../src/gravity"
describe("handleNewGravatar()", () => {
test("Should create a new Gravatar entity", () => {
...
})
})

嵌套describe() 示例:

import { describe, test } from "matchstick-as/assembly/index"
import { handleUpdatedGravatar } from "../../src/gravity"
describe("handleUpdatedGravatar()", () => {
describe("When entity exists", () => {
test("updates the entity", () => {
...
})
})
describe("When entity does not exists", () => {
test("it creates a new entity", () => {
...
})
})
})

测试()

链到本节

test(name: String, () =>, should_fail: bool) -定义测试用例。您可以在describe()区块内部或独立使用test()。

例子:

import { describe, test } from "matchstick-as/assembly/index"
import { handleNewGravatar } from "../../src/gravity"
describe("handleNewGravatar()", () => {
test("Should create a new Entity", () => {
...
})
})

或者

test("handleNewGravatar() should create a new entity", () => {
...
})

beforeAll()

链到本节

在文件中的任何测试之前运行代码区块。如果beforeAll描述区块内声明,它将在该描述区块的开头运行。

例子:

beforeAll中的代码将在文件中的all测试之前执行一次。

import { describe, test, beforeAll } from "matchstick-as/assembly/index"
import { handleUpdatedGravatar, handleNewGravatar } from "../../src/gravity"
import { Gravatar } from "../../generated/schema"
beforeAll(() => {
let gravatar = new Gravatar("0x0")
gravatar.displayName = “First Gravatar”
gravatar.save()
...
})
describe("When the entity does not exist", () => {
test("it should create a new Gravatar with id 0x1", () => {
...
})
})
describe("When entity already exists", () => {
test("it should update the Gravatar with id 0x0", () => {
...
})
})

beforeAll中的代码将在第一个描述区块中的所有测试之前执行一次

import { describe, test, beforeAll } from "matchstick-as/assembly/index"
import { handleUpdatedGravatar, handleNewGravatar } from "../../src/gravity"
import { Gravatar } from "../../generated/schema"
describe("handleUpdatedGravatar()", () => {
beforeAll(() => {
let gravatar = new Gravatar("0x0")
gravatar.displayName = “First Gravatar”
gravatar.save()
...
})
test("updates Gravatar with id 0x0", () => {
...
})
test("creates new Gravatar with id 0x1", () => {
...
})
})

afterAll()

链到本节

在文件中的所有测试之后运行代码区块。如果afterAlldescribe区块内声明,它将在该describe区块的末尾运行。

例子:

afterAll中的代码将在文件中的all测试之后执行一次。

import { describe, test, afterAll } from "matchstick-as/assembly/index"
import { handleUpdatedGravatar, handleNewGravatar } from "../../src/gravity"
import { store } from "@graphprotocol/graph-ts"
afterAll(() => {
store.remove("Gravatar", "0x0")
...
})
describe("handleNewGravatar, () => {
test("creates Gravatar with id 0x0", () => {
...
})
})
describe("handleUpdatedGravatar", () => {
test("updates Gravatar with id 0x0", () => {
...
})
})

afterAll中的代码将在第一个描述区块中的所有测试之后执行一次

import { describe, test, afterAll, clearStore } from "matchstick-as/assembly/index"
import { handleUpdatedGravatar, handleNewGravatar } from "../../src/gravity"
describe("handleNewGravatar", () => {
afterAll(() => {
store.remove("Gravatar", "0x1")
...
})
test("It creates a new entity with Id 0x0", () => {
...
})
test("It creates a new entity with Id 0x1", () => {
...
})
})
describe("handleUpdatedGravatar", () => {
test("updates Gravatar with id 0x0", () => {
...
})
})

beforeEach()

链到本节

在每次测试之前运行代码块。如果beforeEachdescribe区块中声明,则它在该describe块中的每个测试之前运行。

示例:beforeEach内部的代码将在每次测试之前执行。

import { describe, test, beforeEach, clearStore } from "matchstick-as/assembly/index"
import { handleNewGravatars } from "./utils"
beforeEach(() => {
clearStore() // <-- clear the store before each test in the file
})
describe("handleNewGravatars, () => {
test("A test that requires a clean store", () => {
...
})
test("Second that requires a clean store", () => {
...
})
})
...

beforeEach中的代码将仅在描述中的每个测试之前执行

import { describe, test, beforeEach } from 'matchstick-as/assembly/index'
import { handleUpdatedGravatar, handleNewGravatar } from '../../src/gravity'
describe('handleUpdatedGravatars', () => {
beforeEach(() => {
let gravatar = new Gravatar('0x0')
gravatar.displayName = 'First Gravatar'
gravatar.imageUrl = ''
gravatar.save()
})
test('Upates the displayName', () => {
assert.fieldEquals('Gravatar', '0x0', 'displayName', 'First Gravatar')
// code that should update the displayName to 1st Gravatar
assert.fieldEquals('Gravatar', '0x0', 'displayName', '1st Gravatar')
store.remove('Gravatar', '0x0')
})
test('Updates the imageUrl', () => {
assert.fieldEquals('Gravatar', '0x0', 'imageUrl', '')
// code that should changes the imageUrl to https://www.gravatar.com/avatar/0x0
assert.fieldEquals('Gravatar', '0x0', 'imageUrl', 'https://www.gravatar.com/avatar/0x0')
store.remove('Gravatar', '0x0')
})
})

afterEach()

链到本节

在每次测试后运行代码区块。如果afterEachdescribe 区块中声明,则在该describe 区块中的每个测试之后运行。

例子:

afterEach内部的代码将在每次测试后执行。

import { describe, test, beforeEach, afterEach } from "matchstick-as/assembly/index"
import { handleUpdatedGravatar, handleNewGravatar } from "../../src/gravity"
beforeEach(() => {
let gravatar = new Gravatar("0x0")
gravatar.displayName = “First Gravatar”
gravatar.save()
})
afterEach(() => {
store.remove("Gravatar", "0x0")
})
describe("handleNewGravatar", () => {
...
})
describe("handleUpdatedGravatar", () => {
test("Upates the displayName", () => {
assert.fieldEquals("Gravatar", "0x0", "displayName", "First Gravatar")
// code that should update the displayName to 1st Gravatar
assert.fieldEquals("Gravatar", "0x0", "displayName", "1st Gravatar")
})
test("Updates the imageUrl", () => {
assert.fieldEquals("Gravatar", "0x0", "imageUrl", "")
// code that should changes the imageUrl to https://www.gravatar.com/avatar/0x0
assert.fieldEquals("Gravatar", "0x0", "imageUrl", "https://www.gravatar.com/avatar/0x0")
})
})

AfterEach中的代码将仅在描述中的每个测试之后执行

import { describe, test, beforeEach, afterEach } from "matchstick-as/assembly/index"
import { handleUpdatedGravatar, handleNewGravatar } from "../../src/gravity"
describe("handleNewGravatar", () => {
...
})
describe("handleUpdatedGravatar", () => {
beforeEach(() => {
let gravatar = new Gravatar("0x0")
gravatar.displayName = "First Gravatar"
gravatar.imageUrl = ""
gravatar.save()
})
afterEach(() => {
store.remove("Gravatar", "0x0")
})
test("Upates the displayName", () => {
assert.fieldEquals("Gravatar", "0x0", "displayName", "First Gravatar")
// code that should update the displayName to 1st Gravatar
assert.fieldEquals("Gravatar", "0x0", "displayName", "1st Gravatar")
})
test("Updates the imageUrl", () => {
assert.fieldEquals("Gravatar", "0x0", "imageUrl", "")
// code that should changes the imageUrl to https://www.gravatar.com/avatar/0x0
assert.fieldEquals("Gravatar", "0x0", "imageUrl", "https://www.gravatar.com/avatar/0x0")
})
})

断言

链到本节
fieldEquals(entityType: string, id: string, fieldName: string, expectedVal: string)
equals(expected: ethereum.Value, actual: ethereum.Value)
notInStore(entityType: string, id: string)
addressEquals(address1: Address, address2: Address)
bytesEquals(bytes1: Bytes, bytes2: Bytes)
i32Equals(number1: i32, number2: i32)
bigIntEquals(bigInt1: BigInt, bigInt2: BigInt)
booleanEquals(bool1: boolean, bool2: boolean)
stringEquals(string1: string, string2: string)
arrayEquals(array1: Array<ethereum.Value>, array2: Array<ethereum.Value>)
tupleEquals(tuple1: ethereum.Tuple, tuple2: ethereum.Tuple)
assertTrue(value: boolean)
assertNull<T>(value: T)
assertNotNull<T>(value: T)
entityCount(entityType: string, expectedCount: i32)

As of version 0.6.0, asserts support custom error messages as well

assert.fieldEquals('Gravatar', '0x123', 'id', '0x123', 'Id should be 0x123')
assert.equals(ethereum.Value.fromI32(1), ethereum.Value.fromI32(1), 'Value should equal 1')
assert.notInStore('Gravatar', '0x124', 'Gravatar should not be in store')
assert.addressEquals(Address.zero(), Address.zero(), 'Address should be zero')
assert.bytesEquals(Bytes.fromUTF8('0x123'), Bytes.fromUTF8('0x123'), 'Bytes should be equal')
assert.i32Equals(2, 2, 'I32 should equal 2')
assert.bigIntEquals(BigInt.fromI32(1), BigInt.fromI32(1), 'BigInt should equal 1')
assert.booleanEquals(true, true, 'Boolean should be true')
assert.stringEquals('1', '1', 'String should equal 1')
assert.arrayEquals([ethereum.Value.fromI32(1)], [ethereum.Value.fromI32(1)], 'Arrays should be equal')
assert.tupleEquals(
changetype<ethereum.Tuple>([ethereum.Value.fromI32(1)]),
changetype<ethereum.Tuple>([ethereum.Value.fromI32(1)]),
'Tuples should be equal',
)
assert.assertTrue(true, 'Should be true')
assert.assertNull(null, 'Should be null')
assert.assertNotNull('not null', 'Should be not null')
assert.entityCount('Gravatar', 1, 'There should be 2 gravatars')
assert.dataSourceCount('GraphTokenLockWallet', 1, 'GraphTokenLockWallet template should have one data source')
assert.dataSourceExists(
'GraphTokenLockWallet',
Address.zero().toHexString(),
'GraphTokenLockWallet should have a data source for zero address',
)

编写一个单元测试

链到本节

让我们看看使用Demo Subgraph中的Gravatar示例进行简单的单元测试的样子。

假设我们有以下处理程序函数(以及两个帮助函数,以使我们的生活更轻松):

export function handleNewGravatar(event: NewGravatar): void {
let gravatar = new Gravatar(event.params.id.toHex())
gravatar.owner = event.params.owner
gravatar.displayName = event.params.displayName
gravatar.imageUrl = event.params.imageUrl
gravatar.save()
}
export function handleNewGravatars(events: NewGravatar[]): void {
events.forEach((event) => {
handleNewGravatar(event)
})
}
export function createNewGravatarEvent(
id: i32,
ownerAddress: string,
displayName: string,
imageUrl: string,
): NewGravatar {
let mockEvent = newMockEvent()
let newGravatarEvent = new NewGravatar(
mockEvent.address,
mockEvent.logIndex,
mockEvent.transactionLogIndex,
mockEvent.logType,
mockEvent.block,
mockEvent.transaction,
mockEvent.parameters,
)
newGravatarEvent.parameters = new Array()
let idParam = new ethereum.EventParam('id', ethereum.Value.fromI32(id))
let addressParam = new ethereum.EventParam(
'ownderAddress',
ethereum.Value.fromAddress(Address.fromString(ownerAddress)),
)
let displayNameParam = new ethereum.EventParam('displayName', ethereum.Value.fromString(displayName))
let imageUrlParam = new ethereum.EventParam('imageUrl', ethereum.Value.fromString(imageUrl))
newGravatarEvent.parameters.push(idParam)
newGravatarEvent.parameters.push(addressParam)
newGravatarEvent.parameters.push(displayNameParam)
newGravatarEvent.parameters.push(imageUrlParam)
return newGravatarEvent
}

我们首先必须在项目中创建一个测试文件。这是一个示例,说明它可能是什么样子的:

import { clearStore, test, assert } from 'matchstick-as/assembly/index'
import { Gravatar } from '../../generated/schema'
import { NewGravatar } from '../../generated/Gravity/Gravity'
import { createNewGravatarEvent, handleNewGravatars } from '../mappings/gravity'
test('Can call mappings with custom events', () => {
// Create a test entity and save it in the store as initial state (optional)
let gravatar = new Gravatar('gravatarId0')
gravatar.save()
// Create mock events
let newGravatarEvent = createNewGravatarEvent(12345, '0x89205A3A3b2A69De6Dbf7f01ED13B2108B2c43e7', 'cap', 'pac')
let anotherGravatarEvent = createNewGravatarEvent(3546, '0x89205A3A3b2A69De6Dbf7f01ED13B2108B2c43e7', 'cap', 'pac')
// Call mapping functions passing the events we just created
handleNewGravatars([newGravatarEvent, anotherGravatarEvent])
// Assert the state of the store
assert.fieldEquals('Gravatar', 'gravatarId0', 'id', 'gravatarId0')
assert.fieldEquals('Gravatar', '12345', 'owner', '0x89205A3A3b2A69De6Dbf7f01ED13B2108B2c43e7')
assert.fieldEquals('Gravatar', '3546', 'displayName', 'cap')
// Clear the store in order to start the next test off on a clean slate
clearStore()
})
test('Next test', () => {
//...
})

这太多了!首先,需要注意的一件重要事情是,我们将从matchstick-as中导入东西,作为我们的AssemblyScript助手库(作为npm模块分发)。您可以在此处找到存储库。matchstick as为我们提供了有用的测试方法,还定义了我们将用来构建测试块的test()函数。剩下的部分很简单——下面是发生的事情:

  • 我们正在设置我们的初始状态并添加一个自定义的 Gravatar 实体。
  • 我们使用createNewGravatarEvent()函数定义了两个NewGravatar事件对象以及它们的数据。
  • 我们正在为这些事件调用处理方法--handleNewGravatars(),并传入我们的自定义事件列表。
  • 我们断定存储的状态。那是怎么实现的呢?- 我们传递一个实体类型和 id 的唯一组合。然后我们检查该实体的一个特定字段,并断定它具有我们期望的值。我们为我们添加到存储的初始 Gravatar 实体,以及当处理函数被调用时被添加的两个 Gravatar 实体都做这个。
  • 最后--我们用clearStore()清理存储,这样我们的下一个测试就可以从一个新的空存储对象开始。我们可以定义任意多的测试块。

好了,我们创建了第一个测试!👏

现在,为了运行我们的测试,您只需在子图根文件夹中运行以下命令:

graph test Gravity

如果一切顺利,您应该会收到以下信息:

Matchstick写着“所有测试都通过了!”

常见测试场景

链到本节

使用特定状态来填充存储

链到本节

用户能够用一组已知的实体来补充存储。下面是一个用Gravatar实体初始化存储的例子。

let gravatar = new Gravatar('entryId')
gravatar.save()

用一个事件调用一个映射函数

链到本节

用户可以创建自定义事件并将其传递给绑定到存储的映射函数:

import { store } from 'matchstick-as/assembly/store'
import { NewGravatar } from '../../generated/Gravity/Gravity'
import { handleNewGravatars, createNewGravatarEvent } from './mapping'
let newGravatarEvent = createNewGravatarEvent(12345, '0x89205A3A3b2A69De6Dbf7f01ED13B2108B2c43e7', 'cap', 'pac')
handleNewGravatar(newGravatarEvent)

用事件夹具调用所有的映射关系

链到本节

用户可以用测试夹具调用所有的映射关系。

import { NewGravatar } from '../../generated/Gravity/Gravity'
import { store } from 'matchstick-as/assembly/store'
import { handleNewGravatars, createNewGravatarEvent } from './mapping'
let newGravatarEvent = createNewGravatarEvent(12345, '0x89205A3A3b2A69De6Dbf7f01ED13B2108B2c43e7', 'cap', 'pac')
let anotherGravatarEvent = createNewGravatarEvent(3546, '0x89205A3A3b2A69De6Dbf7f01ED13B2108B2c43e7', 'cap', 'pac')
handleNewGravatars([newGravatarEvent, anotherGravatarEvent])
export function handleNewGravatars(events: NewGravatar[]): void {
events.forEach(event => {
handleNewGravatar(event);
});
}

模拟合约调用

链到本节

用户可以模拟合约调用:

import { addMetadata, assert, createMockedFunction, clearStore, test } from 'matchstick-as/assembly/index'
import { Gravity } from '../../generated/Gravity/Gravity'
import { Address, BigInt, ethereum } from '@graphprotocol/graph-ts'
let contractAddress = Address.fromString('0x89205A3A3b2A69De6Dbf7f01ED13B2108B2c43e7')
let expectedResult = Address.fromString('0x90cBa2Bbb19ecc291A12066Fd8329D65FA1f1947')
let bigIntParam = BigInt.fromString('1234')
createMockedFunction(contractAddress, 'gravatarToOwner', 'gravatarToOwner(uint256):(address)')
.withArgs([ethereum.Value.fromSignedBigInt(bigIntParam)])
.returns([ethereum.Value.fromAddress(Address.fromString('0x90cBa2Bbb19ecc291A12066Fd8329D65FA1f1947'))])
let gravity = Gravity.bind(contractAddress)
let result = gravity.gravatarToOwner(bigIntParam)
assert.equals(ethereum.Value.fromAddress(expectedResult), ethereum.Value.fromAddress(result))

如图所示,为了模拟合约调用并实现返回值,用户必须提供合约地址、函数名、函数签名、参数数组,当然还有返回值。

用户还可以模拟函数还原:

let contractAddress = Address.fromString('0x89205A3A3b2A69De6Dbf7f01ED13B2108B2c43e7')
createMockedFunction(contractAddress, 'getGravatar', 'getGravatar(address):(string,string)')
.withArgs([ethereum.Value.fromAddress(contractAddress)])
.reverts()

模拟IPFS文件(from matchstick 0.4.1)

链到本节

用户可以使用mockIpfsFile(hash, filePath)函数模拟IPFS文件。该函数接受两个参数,第一个是IPFS文件hash/路径,第二个是本地文件的路径。

注意:在测试ipfs.map/ipfs.mapJSON,时,必须从测试文件中导出回调函数,以便matchstck检测到它,如下面测试示例中的processGravatar() 函数:

.test.ts file:

import { assert, test, mockIpfsFile } from 'matchstick-as/assembly/index'
import { ipfs } from '@graphprotocol/graph-ts'
import { gravatarFromIpfs } from './utils'
// Export ipfs.map() callback in order for matchstck to detect it
export { processGravatar } from './utils'
test('ipfs.cat', () => {
mockIpfsFile('ipfsCatfileHash', 'tests/ipfs/cat.json')
assert.entityCount(GRAVATAR_ENTITY_TYPE, 0)
gravatarFromIpfs()
assert.entityCount(GRAVATAR_ENTITY_TYPE, 1)
assert.fieldEquals(GRAVATAR_ENTITY_TYPE, '1', 'imageUrl', 'https://i.ytimg.com/vi/MELP46s8Cic/maxresdefault.jpg')
clearStore()
})
test('ipfs.map', () => {
mockIpfsFile('ipfsMapfileHash', 'tests/ipfs/map.json')
assert.entityCount(GRAVATAR_ENTITY_TYPE, 0)
ipfs.map('ipfsMapfileHash', 'processGravatar', Value.fromString('Gravatar'), ['json'])
assert.entityCount(GRAVATAR_ENTITY_TYPE, 3)
assert.fieldEquals(GRAVATAR_ENTITY_TYPE, '1', 'displayName', 'Gravatar1')
assert.fieldEquals(GRAVATAR_ENTITY_TYPE, '2', 'displayName', 'Gravatar2')
assert.fieldEquals(GRAVATAR_ENTITY_TYPE, '3', 'displayName', 'Gravatar3')
})

utils.ts file:

import { Address, ethereum, JSONValue, Value, ipfs, json, Bytes } from "@graphprotocol/graph-ts"
import { Gravatar } from "../../generated/schema"
...
// ipfs.map callback
export function processGravatar(value: JSONValue, userData: Value): void {
// See the JSONValue documentation for details on dealing
// with JSON values
let obj = value.toObject()
let id = obj.get('id')
if (!id) {
return
}
// Callbacks can also created entities
let gravatar = new Gravatar(id.toString())
gravatar.displayName = userData.toString() + id.toString()
gravatar.save()
}
// function that calls ipfs.cat
export function gravatarFromIpfs(): void {
let rawData = ipfs.cat("ipfsCatfileHash")
if (!rawData) {
return
}
let jsonData = json.fromBytes(rawData as Bytes).toObject()
let id = jsonData.get('id')
let url = jsonData.get("imageUrl")
if (!id || !url) {
return
}
let gravatar = new Gravatar(id.toString())
gravatar.imageUrl = url.toString()
gravatar.save()
}

断定存储的状态

链到本节

用户能够通过断定实体断定存储的最终(或中途)状态。为此,用户必须提供实体类型、实体的特定ID、该实体上的字段名称以及字段的预期值。下面是一个快速示例:

import { assert } from 'matchstick-as/assembly/index'
import { Gravatar } from '../generated/schema'
let gravatar = new Gravatar('gravatarId0')
gravatar.save()
assert.fieldEquals('Gravatar', 'gravatarId0', 'id', 'gravatarId0')

运行assert.fieldEquals()函数将检查给定字段是否与给定的预期值相等。如果值相等,测试将失败,并输出错误消息。否则,测试将成功通过。

与事件元数据交互

链到本节

用户可以使用默认的交易元数据,该元数据可以通过使用newMockEvent()函数作为ethereum.Event返回。以下示例显示了如何读取/写入Event对象上的这些字段:

// Read
let logType = newGravatarEvent.logType
// Write
let UPDATED_ADDRESS = '0xB16081F360e3847006dB660bae1c6d1b2e17eC2A'
newGravatarEvent.address = Address.fromString(UPDATED_ADDRESS)

断定变量相等

链到本节
assert.equals(ethereum.Value.fromString("hello"); ethereum.Value.fromString("hello"));

断定实体在存储中

链到本节

用户可以断定实体在存储中不存在。该函数接受实体类型和id。如果实体实际上在存储中,测试将失败,并显示相关错误消息。以下是如何使用此功能的快速示例:

assert.notInStore('Gravatar', '23')

Printing the whole store, or single entities from it (for debug purposes)

链到本节

您可以使用此助手功能将整个存储登载到控制台:

import { logStore } from 'matchstick-as/assembly/store'
logStore()

As of version 0.6.0, logStore no longer prints derived fields, instead users can use the new logEntity function. Of course logEntity can be used to print any entity, not just ones that have derived fields. logEntity takes the entity type, entity id and a showRelated flag to indicate if users want to print the related derived entities.

import { logEntity } from 'matchstick-as/assembly/store'
logEntity("Gravatar", 23, true)

预期故障

链到本节

使用test()函数上的shouldFail标志,用户可能会出现预期的测试失败:

test(
'Should throw an error',
() => {
throw new Error()
},
true,
)

如果测试标记为should fail=true但不失败,则将在日志中显示为错误,测试区块将失败。此外,如果标记为shouldFail=false(默认状态),测试执行器将崩溃。

日志

链到本节

在单元测试中记录自定义日志与在映射中记录完全相同。不同之处在于,日志对象需要从matchstick-as 而不是graph-ts导入。下面是一个所有非关键日志类型的简单示例:

import { test } from "matchstick-as/assembly/index";
import { log } from "matchstick-as/assembly/log";
test("Success", () => {
log.success("Success!". []);
});
test("Error", () => {
log.error("Error :( ", []);
});
test("Debug", () => {
log.debug("Debugging...", []);
});
test("Info", () => {
log.info("Info!", []);
});
test("Warning", () => {
log.warning("Warning!", []);
});

用户还可以模拟严重故障,如下所示:

test('Blow everything up', () => {
log.critical('Boom!')
})

记录关键错误将停止测试的执行,并使一切崩溃。毕竟,我们希望确保您的代码在部署中没有关键日志,如果发生这种情况,您应该立即注意。

测试派生字段

链到本节

Testing derived fields is a feature which allows users to set a field on a certain entity and have another entity be updated automatically if it derives one of its fields from the first entity.

Before version 0.6.0 it was possible to get the derived entities by accessing them as entity fields/properties, like so:

let entity = ExampleEntity.load('id')
let derivedEntity = entity.derived_entity

As of version 0.6.0, this is done by using the loadRelated function of graph-node, the derived entities can be accessed the same way as in the handlers.

test('Derived fields example test', () => {
let mainAccount = GraphAccount.load('12')!
assert.assertNull(mainAccount.get('nameSignalTransactions'))
assert.assertNull(mainAccount.get('operatorOf'))
let operatedAccount = GraphAccount.load('1')!
operatedAccount.operators = [mainAccount.id]
operatedAccount.save()
mockNameSignalTransaction('1234', mainAccount.id)
mockNameSignalTransaction('2', mainAccount.id)
mainAccount = GraphAccount.load('12')!
assert.assertNull(mainAccount.get('nameSignalTransactions'))
assert.assertNull(mainAccount.get('operatorOf'))
const nameSignalTransactions = mainAccount.nameSignalTransactions.load()
const operatorsOfMainAccount = mainAccount.operatorOf.load()
assert.i32Equals(2, nameSignalTransactions.length)
assert.i32Equals(1, operatorsOfMainAccount.length)
assert.stringEquals('1', operatorsOfMainAccount[0].id)
mockNameSignalTransaction('2345', mainAccount.id)
let nst = NameSignalTransaction.load('1234')!
nst.signer = '11'
nst.save()
store.remove('NameSignalTransaction', '2')
mainAccount = GraphAccount.load('12')!
assert.i32Equals(1, mainAccount.nameSignalTransactions.load().length)
})

Testing loadInBlock

链到本节

As of version 0.6.0, users can test loadInBlock by using the mockInBlockStore, it allows mocking entities in the block cache.

import { afterAll, beforeAll, describe, mockInBlockStore, test } from 'matchstick-as'
import { Gravatar } from '../../generated/schema'
describe('loadInBlock', () => {
beforeAll(() => {
mockInBlockStore('Gravatar', 'gravatarId0', gravatar)
})
afterAll(() => {
clearInBlockStore()
})
test('Can use entity.loadInBlock() to retrieve entity from cache store in the current block', () => {
let retrievedGravatar = Gravatar.loadInBlock('gravatarId0')
assert.stringEquals('gravatarId0', retrievedGravatar!.get('id')!.toString())
})
test("Returns null when calling entity.loadInBlock() if an entity doesn't exist in the current block", () => {
let retrievedGravatar = Gravatar.loadInBlock('IDoNotExist')
assert.assertNull(retrievedGravatar)
})
})

测试动态数据源

链到本节

可以通过模拟dataSource命名空间的context(), address()network() 函数的返回值来测试动态数据源。这些函数当前返回以下内容:context() -返回一个空实体(DataSourceContext),address()返回0x0000000000000000000000000000000000000000, network() - 返回mainnetcreate(...)createWithContext(...)函数被模拟为什么都不做,因此根本不需要在测试中调用它们。返回值的更改可以通过matchstick-as (版本 0.3.0+) 中dataSourceMock命名空间的函数来完成。

示例如下:

首先,我们有以下事件处理程序(它被有意地重新用于展示模拟数据源):

export function handleApproveTokenDestinations(event: ApproveTokenDestinations): void {
let tokenLockWallet = TokenLockWallet.load(dataSource.address().toHexString())!
if (dataSource.network() == 'rinkeby') {
tokenLockWallet.tokenDestinationsApproved = true
}
let context = dataSource.context()
if (context.get('contextVal')!.toI32() > 0) {
tokenLockWallet.setBigInt('tokensReleased', BigInt.fromI32(context.get('contextVal')!.toI32()))
}
tokenLockWallet.save()
}

然后,我们使用dataSourceMock命名空间中的一个方法进行测试,为所有dataSource函数设置一个新返回值:

import { assert, test, newMockEvent, dataSourceMock } from 'matchstick-as/assembly/index'
import { BigInt, DataSourceContext, Value } from '@graphprotocol/graph-ts'
import { handleApproveTokenDestinations } from '../../src/token-lock-wallet'
import { ApproveTokenDestinations } from '../../generated/templates/GraphTokenLockWallet/GraphTokenLockWallet'
import { TokenLockWallet } from '../../generated/schema'
test('Data source simple mocking example', () => {
let addressString = '0xA16081F360e3847006dB660bae1c6d1b2e17eC2A'
let address = Address.fromString(addressString)
let wallet = new TokenLockWallet(address.toHexString())
wallet.save()
let context = new DataSourceContext()
context.set('contextVal', Value.fromI32(325))
dataSourceMock.setReturnValues(addressString, 'rinkeby', context)
let event = changetype<ApproveTokenDestinations>(newMockEvent())
assert.assertTrue(!wallet.tokenDestinationsApproved)
handleApproveTokenDestinations(event)
wallet = TokenLockWallet.load(address.toHexString())!
assert.assertTrue(wallet.tokenDestinationsApproved)
assert.bigIntEquals(wallet.tokensReleased, BigInt.fromI32(325))
dataSourceMock.resetValues()
})

注意,dataSourceMock.resetValues()在末尾被调用。这是因为值在更改时会被记住,如果要返回到默认值,则需要重新设置。

Testing dynamic data source creation

链到本节

As of version 0.6.0, it is possible to test if a new data source has been created from a template. This feature supports both ethereum/contract and file/ipfs templates. There are four functions for this:

  • assert.dataSourceCount(templateName, expectedCount) can be used to assert the expected count of data sources from the specified template
  • assert.dataSourceExists(templateName, address/ipfsHash) asserts that a data source with the specified identifier (could be a contract address or IPFS file hash) from a specified template was created
  • logDataSources(templateName) prints all data sources from the specified template to the console for debugging purposes
  • readFile(path) reads a JSON file that represents an IPFS file and returns the content as Bytes

Testing ethereum/contract templates

链到本节
test('ethereum/contract dataSource creation example', () => {
// Assert there are no dataSources created from GraphTokenLockWallet template
assert.dataSourceCount('GraphTokenLockWallet', 0)
// Create a new GraphTokenLockWallet datasource with address 0xA16081F360e3847006dB660bae1c6d1b2e17eC2A
GraphTokenLockWallet.create(Address.fromString('0xA16081F360e3847006dB660bae1c6d1b2e17eC2A'))
// Assert the dataSource has been created
assert.dataSourceCount('GraphTokenLockWallet', 1)
// Add a second dataSource with context
let context = new DataSourceContext()
context.set('contextVal', Value.fromI32(325))
GraphTokenLockWallet.createWithContext(Address.fromString('0xA16081F360e3847006dB660bae1c6d1b2e17eC2B'), context)
// Assert there are now 2 dataSources
assert.dataSourceCount('GraphTokenLockWallet', 2)
// Assert that a dataSource with address "0xA16081F360e3847006dB660bae1c6d1b2e17eC2B" was created
// Keep in mind that `Address` type is transformed to lower case when decoded, so you have to pass the address as all lower case when asserting if it exists
assert.dataSourceExists('GraphTokenLockWallet', '0xA16081F360e3847006dB660bae1c6d1b2e17eC2B'.toLowerCase())
logDataSources('GraphTokenLockWallet')
})
Example logDataSource output
链到本节
🛠 {
"0xa16081f360e3847006db660bae1c6d1b2e17ec2a": {
"kind": "ethereum/contract",
"name": "GraphTokenLockWallet",
"address": "0xa16081f360e3847006db660bae1c6d1b2e17ec2a",
"context": null
},
"0xa16081f360e3847006db660bae1c6d1b2e17ec2b": {
"kind": "ethereum/contract",
"name": "GraphTokenLockWallet",
"address": "0xa16081f360e3847006db660bae1c6d1b2e17ec2b",
"context": {
"contextVal": {
"type": "Int",
"data": 325
}
}
}
}

Testing file/ipfs templates

链到本节

Similarly to contract dynamic data sources, users can test test file datas sources and their handlers

Example subgraph.yaml
链到本节
...
templates:
- kind: file/ipfs
name: GraphTokenLockMetadata
network: mainnet
mapping:
kind: ethereum/events
apiVersion: 0.0.6
language: wasm/assemblyscript
file: ./src/token-lock-wallet.ts
handler: handleMetadata
entities:
- TokenLockMetadata
abis:
- name: GraphTokenLockWallet
file: ./abis/GraphTokenLockWallet.json
Example schema.graphql
链到本节
"""
Token Lock Wallets which hold locked GRT
"""
type TokenLockMetadata @entity {
"The address of the token lock wallet"
id: ID!
"Start time of the release schedule"
startTime: BigInt!
"End time of the release schedule"
endTime: BigInt!
"Number of periods between start time and end time"
periods: BigInt!
"Time when the releases start"
releaseStartTime: BigInt!
}
Example metadata.json
链到本节
{
"startTime": 1,
"endTime": 1,
"periods": 1,
"releaseStartTime": 1
}
Example handler
链到本节
export function handleMetadata(content: Bytes): void {
// dataSource.stringParams() returns the File DataSource CID
// stringParam() will be mocked in the handler test
// for more info https://thegraph.com/docs/en/developing/creating-a-subgraph/#create-a-new-handler-to-process-files
let tokenMetadata = new TokenLockMetadata(dataSource.stringParam())
const value = json.fromBytes(content).toObject()
if (value) {
const startTime = value.get('startTime')
const endTime = value.get('endTime')
const periods = value.get('periods')
const releaseStartTime = value.get('releaseStartTime')
if (startTime && endTime && periods && releaseStartTime) {
tokenMetadata.startTime = startTime.toBigInt()
tokenMetadata.endTime = endTime.toBigInt()
tokenMetadata.periods = periods.toBigInt()
tokenMetadata.releaseStartTime = releaseStartTime.toBigInt()
}
tokenMetadata.save()
}
}
Example test
链到本节
import { assert, test, dataSourceMock, readFile } from 'matchstick-as'
import { Address, BigInt, Bytes, DataSourceContext, ipfs, json, store, Value } from '@graphprotocol/graph-ts'
import { handleMetadata } from '../../src/token-lock-wallet'
import { TokenLockMetadata } from '../../generated/schema'
import { GraphTokenLockMetadata } from '../../generated/templates'
test('file/ipfs dataSource creation example', () => {
// Generate the dataSource CID from the ipfsHash + ipfs path file
// For example QmaXzZhcYnsisuue5WRdQDH6FDvqkLQX1NckLqBYeYYEfm/example.json
const ipfshash = 'QmaXzZhcYnsisuue5WRdQDH6FDvqkLQX1NckLqBYeYYEfm'
const CID = `${ipfshash}/example.json`
// Create a new dataSource using the generated CID
GraphTokenLockMetadata.create(CID)
// Assert the dataSource has been created
assert.dataSourceCount('GraphTokenLockMetadata', 1)
assert.dataSourceExists('GraphTokenLockMetadata', CID)
logDataSources('GraphTokenLockMetadata')
// Now we have to mock the dataSource metadata and specifically dataSource.stringParam()
// dataSource.stringParams actually uses the value of dataSource.address(), so we will mock the address using dataSourceMock from matchstick-as
// First we will reset the values and then use dataSourceMock.setAddress() to set the CID
dataSourceMock.resetValues()
dataSourceMock.setAddress(CID)
// Now we need to generate the Bytes to pass to the dataSource handler
// For this case we introduced a new function readFile, that reads a local json and returns the content as Bytes
const content = readFile(`path/to/metadata.json`)
handleMetadata(content)
// Now we will test if a TokenLockMetadata was created
const metadata = TokenLockMetadata.load(CID)
assert.bigIntEquals(metadata!.endTime, BigInt.fromI32(1))
assert.bigIntEquals(metadata!.periods, BigInt.fromI32(1))
assert.bigIntEquals(metadata!.releaseStartTime, BigInt.fromI32(1))
assert.bigIntEquals(metadata!.startTime, BigInt.fromI32(1))
})

测试覆盖率

链到本节

使用Matchstick,子图开发者可以运行一个脚本,计算编写的单元测试的测试覆盖率。

测试覆盖工具非常简单——它接受编译后的测试wasm二进制文件并将其转换为wat文件,然后可以很容易地检查这些文件,以查看submap.yaml中定义的处理程序是否真正被调用。由于代码覆盖率(以及整个测试)在AssemblyScript和WebAssembly中处于非常早期的阶段,Matchstick无法检查分支覆盖率。相反,我们依赖于这样一个断定,即如果调用了给定的处理程序,那么它的事件/函数就会被正确地模拟。

先决条件

链到本节

要运行Matchstick中提供的测试覆盖功能,您需要事先准备以下几件事:

导出处理程序

链到本节

为了让Matchstick检查正在运行的处理程序,需要从测试文件中导出这些处理程序。例如,在我们的示例中,在gravity.test.ts文件中,我们导入了以下处理程序

import { handleNewGravatar } from '../../src/gravity'

为了使该函数可见(使其包含在 wat 文件中按名称),我们还需要导出它,例如这:

export { handleNewGravatar }

使用方法

链到本节

设置好后,要运行测试覆盖工具,只需运行:

graph test -- -c

您还可以向package.json文件中添加自定义覆盖率命令,如下所示:

"scripts": {
/.../
"coverage": "graph test -- -c"
},

希望这可以毫无问题地执行覆盖工具。您应该在终端中看到类似的内容:

$ graph test -c
Skipping download/install step because binary already exists at /Users/petko/work/demo-subgraph/node_modules/binary-install-raw/bin/0.4.0
___ ___ _ _ _ _ _
| \/ | | | | | | | (_) | |
| . . | __ _| |_ ___| |__ ___| |_ _ ___| | __
| |\/| |/ _` | __/ __| '_ \/ __| __| |/ __| |/ /
| | | | (_| | || (__| | | \__ \ |_| | (__| <
\_| |_/\__,_|\__\___|_| |_|___/\__|_|\___|_|\_\
Compiling...
Running in coverage report mode.
Reading generated test modules... 🔎️
Generating coverage report 📝
Handlers for source 'Gravity':
Handler 'handleNewGravatar' is tested.
Handler 'handleUpdatedGravatar' is not tested.
Handler 'handleCreateGravatar' is tested.
Test coverage: 66.7% (2/3 handlers).
Handlers for source 'GraphTokenLockWallet':
Handler 'handleTokensReleased' is not tested.
Handler 'handleTokensWithdrawn' is not tested.
Handler 'handleTokensRevoked' is not tested.
Handler 'handleManagerUpdated' is not tested.
Handler 'handleApproveTokenDestinations' is not tested.
Handler 'handleRevokeTokenDestinations' is not tested.
Test coverage: 0.0% (0/6 handlers).
Global test coverage: 22.2% (2/9 handlers).

日志输出中的测试运行持续时间

链到本节

日志输出包括测试运行持续时间。下面是一个示例:

[2022 年 3 月 31 日星期四 13:54:54 +0300] 程序执行时间:42.270 毫秒。

常见编译器错误

链到本节

关键:无法从具有背景的有效模块创建WasmInstance:未知导入:wasi_snapshot_preview1::尚未定义fd_write

This means you have used console.log in your code, which is not supported by AssemblyScript. Please consider using the Logging API

ERROR TS2554: Expected ? arguments, but got ?.

返回ethereum.Block(defaultAddressBytes, defaultAddressBytes, defaultAddressBytes, defaultAddress, defaultAddressBytes, defaultAddressBytes, defaultAddressBytes, defaultBigInt, defaultBigInt, defaultBigInt, defaultBigInt, defaultBigInt, defaultBigInt, defaultBigInt, defaultBigInt);

in ~lib/matchstick-as/assembly/defaults.ts(18,12)

ERROR TS2554: Expected ? arguments, but got ?.

返回新ethereum.Transaction(defaultAddressBytes, defaultBigInt, defaultAddress, defaultAddress, defaultBigInt, defaultBigInt, defaultBigInt, defaultAddressBytes, defaultBigInt);

in ~lib/matchstick-as/assembly/defaults.ts(24,12)

参数不匹配是由graph-ts and matchstick-as不匹配造成的。解决此类问题的最佳方法是将所有内容更新到最新发布的版本。

其他资源

链到本节

For any additional support, check out this demo subgraph repo using Matchstick.

反馈

链到本节

如果您有任何问题、反馈、特征请求或只是想与我们联系,最好的地方是 Graph Discord,我们有一个专门的 Matchstick 频道,名为 🔥| 单元测试。

编辑

上页
AssemblyScript的常见问题
下页
Deploy Using Subgraph Studio
编辑