ユニットテストフレームワーク
Reading time: 35 min
Matchstick はが開発したユニットテストフレームワークで、サブグラフの開発者がサンドボックス環境でマッピングロジックをテストし、自信を持ってサブグラフをデプロイすることができます!
テストヘルパーメソッドを使用し、テストを実行するためには、以下の依存関係をインストールする必要があります:
yarn add --dev matchstick-as
❗ graph-node
はPostgreSQLに依存しているので、もしまだ持っていなければ、インストールする必要があります。他の方法で追加すると予期しないエラーが発生する可能性があるので、以下のコマンドを使用することを強くお勧めします!
Postgresのインストールコマンド:
postgresql をインストールします。
最新の libpq.5.lib へのシンボリック リンクを作成します 最初にこのディレクトリを作成する必要がある場合があります /usr/local/opt/postgresql/lib/
ln -sf /usr/local/opt/postgresql@14/lib/postgresql@14/libpq.5.dylib /usr/local/opt/postgresql/lib/libpq.5.dylib
Postgresのインストールコマンド(お使いのディストロに依存します)。
sudo apt postgresql をインストール
WSLでは、Dockerアプローチとバイナリアプローチの両方でMatchstickを使用することができます。WSLは少しトリッキーなので、以下のような問題に遭遇した場合のヒントを紹介します。
static BYTES = Symbol("Bytes") SyntaxError: Unexpected token =
または
<PROJECT_PATH>/node_modules/gluegun/build/index.js:13 throw up;
Node.jsの新しいバージョンを使っていることを確認してください graph-cliはもうv10.19.0 をサポートしておらず、これはまだWSL上の新しいUbuntuイメージのデフォルトバージョンになっています。例えばマッチスティックはv18.1.0 でWSL上で動作することが確認されており、nvm を経由するか、グローバルNode.jsを更新すれば切り替えることができます。nodejsを更新したら、node_modules
を削除し、npm install
を再度実行するのを忘れないでください! それから、libpq がインストールされていることを確認してください。
sudo apt-get install libpq-dev (インストール)
最後に、graph test
(グローバルにインストールされたgraph-cliを使用します。なぜかWSLでは壊れているようです)を使用せず、yarn test
や npm run test
(ローカル、プロジェクトレベルのgraph-cliを使用し、魅力的に動作します。)を使用するようにしてください。そのためには、もちろん "test"
スクリプトを package.json
ファイルに記述する必要がありますが、これは以下のような簡単なものです。
{"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"}}
Matchstick をサブグラフ・プロジェクトで使用するには、ターミナルを開き、プロジェクトのルート・フォルダに移動して、 graph test [options] <datasource>
と実行するだけで、最新の Matchstick バイナリがダウンロードされて、テスト・フォルダにある指定したテストまたは全てのテストが実行されます (datasource flag が指定されていなければ既存の全てのテスト).
これにより、test フォルダ内のすべてのテストが実行されます。
グラフテスト
これは、gravity.test.tsという名前のテストと、gravityというフォルダの中にあるすべてのテストを実行します:
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
graph-cli 0.25.2
からは、graph test
コマンドが -d
フラグの付いた docker コンテナでの matchstick
の実行をサポートしています。docker の実装では、 を使用しているので、graph test -d
コマンドを実行するたびに docker イメージを再構築する必要はありません。また、 リポジトリの説明に従って、手動で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 build 中に以下のようなエラーが発生することがあります。
送信者からのエラー: xattr node_modules/binary-install-raw/bin/binary-<platform> へのアクセスに失敗しました: パーミッションが拒否されました。
この場合、ルートフォルダに .dockerignore
を作成し、 node_modules/binary-install-raw/bin
を追加してください。
Matchstick は、matchstick.yaml
設定ファイルによって、カスタムテスト、ライブラリ、マニフェストのパスを使用するように設定することができます。
testsFolder: path/to/testslibsFolder: path/to/libsmanifestPath: path/to/subgraph.yaml
をクローンすることで、このガイドのサンプルを試したり、遊んだりすることができます。
IMPORTANT: The test structure described below depens on matchstick-as
version >=0.5.0
describe(name: String , () => {})
- Defines a test group.
注:
- ディスクリートは必須ではありません。describe() ブロックの外側で test() を旧来の方法で使用することができます。
例:
import { describe, test } from "matchstick-as/assembly/index"import { handleNewGravatar } from "../../src/gravity"describe("handleNewGravatar()", () => {test("Should create a new Gravatar entity", () => {...})})
Nested describe()
example:
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)
- テスト ケースを定義します。 test() は、describe() ブロック内または独立して使用できます
例:
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
が describe
ブロックの中で宣言された場合、その describe
ブロックの先頭で実行されます。
例
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
が describe
ブロックの中で宣言された場合、その 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
が describe
ブロックの中で宣言された場合、その 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 Gravatarassert.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/0x0assert.fieldEquals('Gravatar', '0x0', 'imageUrl', 'https://www.gravatar.com/avatar/0x0')store.remove('Gravatar', '0x0')})})
各テストの後にコードブロックを実行します。もし afterEach
が describe
ブロックの中で宣言されていれば、その 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 Gravatarassert.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/0x0assert.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 Gravatarassert.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/0x0assert.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',)
にある Gravatar の例を使って、簡単なユニットテストがどのように見えるか見てみましょう。
次のようなハンドラ関数があるとします(さらに、生活を便利にするための2つのヘルパー関数もあります)。
export function handleNewGravatar(event: NewGravatar): void {let gravatar = new Gravatar(event.params.id.toHex())gravatar.owner = event.params.ownergravatar.displayName = event.params.displayNamegravatar.imageUrl = event.params.imageUrlgravatar.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 eventslet newGravatarEvent = createNewGravatarEvent(12345, '0x89205A3A3b2A69De6Dbf7f01ED13B2108B2c43e7', 'cap', 'pac')let anotherGravatarEvent = createNewGravatarEvent(3546, '0x89205A3A3b2A69De6Dbf7f01ED13B2108B2c43e7', 'cap', 'pac')// Call mapping functions passing the events we just createdhandleNewGravatars([newGravatarEvent, anotherGravatarEvent])// Assert the state of the storeassert.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 slateclearStore()})test('Next test', () => {//...})
このように様々な形で紐解いてみました。まず最初に、重要なことは、AssemblyScript のヘルパーライブラリである matchstick-as
からインポートしていることです (npm モジュールとして配布されています)。リポジトリはにあります。matchstick-as
は便利なテストメソッドを提供し、テストブロックを構築するために使用する test()
関数を定義しています。残りの部分はとても簡単で、次のようなことが起こります。
- 初期状態を設定し、カスタムGravatarエンティティを1つ追加しています;
createNewGravatarEvent()
関数を使用して、2 つのNewGravatar
イベント オブジェクトとそれらのデータを定義します。- これらのイベントのハンドラメソッド -
handleNewGravatars()
を呼び出し、カスタムイベントのリストを渡しています; - storeの状態をアサートする場合、これはどのように行われるのでしょうか。- Entityの種類とidの一意の組み合わせを渡します。そして、そのEntityの特定のフィールドをチェックし、期待通りの値を持っていることを表明します。これはstoreに追加した最初の Gravatar Entity と、ハンドラ関数が呼び出されたときに追加される 2 つの Gravatar Entity の両方に対して行っているのです。
- 最後に、
clearStore()
を使ってストアを掃除し、次のテストが新鮮で空のストア・オブジェクトで始められるようにしています。テストブロックは必要に応じていくつでも定義できます。
これで最初のテストが完成しました! 👏
テストを実行するには、サブグラフのルートフォルダで以下を実行する必要があります:
gravityのテスト
すべてがうまくいくと、以下のメッセージが表示されます:
ユーザーは、既知のエンティティのセットでストアをハイドレートすることができます。ここでは、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()
mockIpfsFile(hash, filePath)
関数を使用することにより、IPFSファイルのモックを作成することができます。最初の引数はIPFSファイルのハッシュ/パス、2番目の引数はローカルファイルへのパスです。
注意: ipfs.map/ipfs.mapJSON
をテストするとき、下記のテスト例の processGravatar()
関数のように、コールバック関数は matchstck がそれを検出するためにテストファイルからエクスポートされなければなりません。
.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 itexport { 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 callbackexport function processGravatar(value: JSONValue, userData: Value): void {// See the JSONValue documentation for details on dealing// with JSON valueslet obj = value.toObject()let id = obj.get('id')if (!id) {return}// Callbacks can also created entitieslet gravatar = new Gravatar(id.toString())gravatar.displayName = userData.toString() + id.toString()gravatar.save()}// function that calls ipfs.catexport 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として返されるデフォルトのトランザクションのメタデータを使用することができます。以下の例では、イベントオブジェクトのこれらのフィールドを読み書きする方法を示しています:
// Readlet logType = newGravatarEvent.logType// Writelet UPDATED_ADDRESS = '0xB16081F360e3847006dB660bae1c6d1b2e17eC2A'newGravatarEvent.address = Address.fromString(UPDATED_ADDRESS)
assert.equals(ethereum.Value.fromString("hello"); ethereum.Value.fromString("hello"));
ユーザーは、あるエンティティがストアに存在しないことをアサートできます。この関数は、エンティティタイプと id を受け取ります。エンティティが実際にストア内にある場合、テストは関連するエラーメッセージを表示して失敗します。この機能を使った簡単な例をご紹介します:
assert.notInStore('Gravatar', '23')
このヘルパー関数を使って、ストア全体をコンソールに出力することができます:
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,)
ShouldFail = true とマークされているにもかかわらずテストが失敗した場合は、ログにエラーとして表示され、テストブロックは失敗します。また、shouldFail = false (デフォルトの状態) と設定されている場合は、テスト実行者がクラッシュします。
ユニットテストにカスタムログを持たせることは、マッピングにログを持たせることと全く同じです。違いは、ログオブジェクトをgraph-tsではなくmatchstick-asからインポートする必要があることです。以下は、すべての非重要なログタイプを使った簡単な例です:
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)})
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()
- 0x000000000000000000000000
を返す、 network()
- mainnet
を返す、です。create(...)
とcreateWithContext(...)
関数は何もしないようにモックされているので、テストの中で呼ばれる必要は全くないでしょう。戻り値の変更は matchstick-as
(version 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 namespaceのメソッドの1つを使用して、すべての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()が呼び出されていることに注目してください。これは、値が変更されると記憶されるため、デフォルトの値に戻したい場合はリセットする必要があるからです。
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 templateassert.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 createdlogDataSources(templateName)
prints all data sources from the specified template to the console for debugging purposesreadFile(path)
reads a JSON file that represents an IPFS file and returns the content as Bytes
test('ethereum/contract dataSource creation example', () => {// Assert there are no dataSources created from GraphTokenLockWallet templateassert.dataSourceCount('GraphTokenLockWallet', 0)// Create a new GraphTokenLockWallet datasource with address 0xA16081F360e3847006dB660bae1c6d1b2e17eC2AGraphTokenLockWallet.create(Address.fromString('0xA16081F360e3847006dB660bae1c6d1b2e17eC2A'))// Assert the dataSource has been createdassert.dataSourceCount('GraphTokenLockWallet', 1)// Add a second dataSource with contextlet context = new DataSourceContext()context.set('contextVal', Value.fromI32(325))GraphTokenLockWallet.createWithContext(Address.fromString('0xA16081F360e3847006dB660bae1c6d1b2e17eC2B'), context)// Assert there are now 2 dataSourcesassert.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 existsassert.dataSourceExists('GraphTokenLockWallet', '0xA16081F360e3847006dB660bae1c6d1b2e17eC2B'.toLowerCase())logDataSources('GraphTokenLockWallet')})
🛠 {"0xa16081f360e3847006db660bae1c6d1b2e17ec2a": {"kind": "ethereum/contract","name": "GraphTokenLockWallet","address": "0xa16081f360e3847006db660bae1c6d1b2e17ec2a","context": null},"0xa16081f360e3847006db660bae1c6d1b2e17ec2b": {"kind": "ethereum/contract","name": "GraphTokenLockWallet","address": "0xa16081f360e3847006db660bae1c6d1b2e17ec2b","context": {"contextVal": {"type": "Int","data": 325}}}}
Similarly to contract dynamic data sources, users can test test file datas sources and their handlers
...templates:- kind: file/ipfsname: GraphTokenLockMetadatanetwork: mainnetmapping:kind: ethereum/eventsapiVersion: 0.0.6language: wasm/assemblyscriptfile: ./src/token-lock-wallet.tshandler: handleMetadataentities:- TokenLockMetadataabis:- name: GraphTokenLockWalletfile: ./abis/GraphTokenLockWallet.json
"""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!}
{"startTime": 1,"endTime": 1,"periods": 1,"releaseStartTime": 1}
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-fileslet 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()}}
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.jsonconst ipfshash = 'QmaXzZhcYnsisuue5WRdQDH6FDvqkLQX1NckLqBYeYYEfm'const CID = `${ipfshash}/example.json`// Create a new dataSource using the generated CIDGraphTokenLockMetadata.create(CID)// Assert the dataSource has been createdassert.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 CIDdataSourceMock.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 Bytesconst content = readFile(`path/to/metadata.json`)handleMetadata(content)// Now we will test if a TokenLockMetadata was createdconst 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))})
マッチスティック を使用すると、サブグラフ開発者は、記述された単体テストのテスト カバレッジを計算するスクリプトを実行できます。
テスト カバレッジ ツールは、コンパイルされたテスト wasm
バイナリを取得して、それらを wat
ファイルに変換します。このファイルは、subgraph.yaml
で定義されたハンドラーが呼び出されているかどうかを簡単に検査して確認できます。 AssemblyScript と WebAssembly ではコード カバレッジ (およびテスト全体) が非常に初期段階にあるため、Matchstick はブランチ カバレッジをチェックできません。代わりに、特定のハンドラーが呼び出された場合、そのイベント/関数が適切にモック化されているというアサーションに依存します。
Matchstick で提供されているテストカバレッジ機能を実行するには、事前に準備しておくことがいくつかあります:
Matchstick がどのハンドラが実行されているかをチェックするために、それらのハンドラは test file からエクスポートされる必要があります。例えばこの例では、gravity.test.ts ファイルに次のハンドラがインポートされています:
import { handleNewGravatar } from '../../src/gravity'
その関数が見えるようにする(wat
ファイル名前に含める)には、次のようにエクスポートも必要です。
export { handleNewGravatar }
設定が完了したら、テストカバレッジツールを実行するために実行します:
graph test -- -c
次のように、カスタムの coverage
コマンドを package.json
ファイルに追加することもできます。
"scripts": {/.../"coverage": "graph test -- -c"},
これによりカバレッジ ツールが実行され、ターミナルに次のような内容が表示されるはずです。
$ graph test -cSkipping 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).
ログ出力にテスト実行時間が含まれるようになりました。以下はその例です:
[Thu, 31 Mar 2022 13:54:54 +0300] Program executed in: 42.270ms.
Critical: 有効なモジュールから WasmInstance を作成できない。コンテキストが不明 インポート: wasi_snapshot_preview1::fd_write が定義されていない
これは、コード内でconsole.log
を使用していることを意味し、AssemblyScriptではサポートされていません。 の利用をご検討ください。
ERROR TS2554: 期待された引数は?
return new 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: 期待された引数は?
return new ethereum.Transaction(defaultAddressBytes, defaultBigInt, defaultAddress, defaultAddress, defaultBigInt, defaultBigInt, defaultBigInt, defaultAddressBytes, defaultBigInt);
in ~lib/matchstick-as/assembly/defaults.ts(24,12)
引数の不一致は、graph-ts
とmatchstick-as
の不一致によって起こります。このような問題を解決する最善の方法は、すべてを最新のリリース版にアップデートすることです。
質問、フィードバックなどがありましたら、The Graph DiscordにMatchstick専用のチャンネル🔥| unit-testing がありますので、そちらにお問い合わせください。