Developing > Фреймворк модульного тестирования

Фреймворк модульного тестирования

Reading time: 24 min

Matchstick - это фреймворк модульного тестирования, разработанный компанией LimeChain, который позволяет разработчикам субграфов тестировать логику мэппинга в изолированной среде и уверенно развертывать свои субграфы!

Начало работы

Ссылка на этот раздел

Установка зависимостей

Ссылка на этот раздел

Чтобы использовать вспомогательные методы тестирования и запускать тесты, Вам необходимо будет установить следующие зависимости:

yarn add --dev matchstick-as

graph-node зависит от PostgreSQL, поэтому, если у Вас его еще нет, Вам нужно будет его установить. Мы настоятельно рекомендуем использовать приведенные ниже команды, так как добавление их любым другим способом может привести к непредвиденным ошибкам!

Команда установки Postgres:

brew install 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 install postgresql

WSL (Windows Subsystem for Linux)

Ссылка на этот раздел

Вы можете использовать Matchstick в WSL как с помощью подхода Docker, так и с помощью бинарного подхода. Поскольку 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 и по-прежнему является версией по умолчанию для новых образов Ubuntu на WSL. Например, подтверждено, что Matchstick работает на WALL с v18.1.0. Вы можете переключиться на него либо через nvm, либо, если обновите свой глобальный Node.js. Не забудьте удалить node_modules и повторно запустить npm install после обновления nodejs! Затем убедитесь, что у Вас установлена 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 в своём проекте subgraph, просто откройте терминал, перейдите в корневую папку своего проекта и запустите graph test [options] <datasource> - он загрузит последний двоичный файл Matchstick и запустит указанный тест или все тесты в тестовой папке (или все существующие тесты, если флаг источника данных не указан).

Это запустит все тесты в тестовой папке:

graph test

Это запустит тест с именем gravity.test.ts и/или все тесты внутри папки с именем gravity:

graph test gravity

Это запустит только конкретный тестовый файл:

graph test path/to/file.test.ts

Параметры:

-c, --coverage Запускает тесты в режиме покрытия
-d, --docker Запускает тесты в docker-контейнере (Примечание: пожалуйста, выполняйте из корневой папки субграфа)
-f, --force Binary: повторно загружает двоичный файл. Docker: Повторно загружает файл Docker и перестраивает образ docker
-h, --help Показывает информацию об использовании
-l, --logs Выводит на консоль информацию об операционной системе, модели процессора и URL-адресе загрузки (в целях отладки)
-r, --recompile Принудительно перекомпилирует тесты
-v, --version <tag> Выберите версию бинарного файла rust, которую хотите загрузить/использовать

Из graph-cli 0.25.2 команда graph test поддерживает запуск matchstick в контейнере docker с флагом -d. Реализация docker использует [bind mount](https://docs.docker.com/storage/bind-mounts /), чтобы не приходилось перестраивать образ docker каждый раз, когда выполняется команда graph test -d. В качестве альтернативы Вы можете следовать инструкциям из репозитория 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/binary-install-raw/bin

Конфигурация

Ссылка на этот раздел

Matchstick можно настроить на использование пользовательских тестов, библиотек и пути к манифесту через файл конфигурации matchstick.yaml:

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

Демонстрационный субграф

Ссылка на этот раздел

Вы можете попробовать и поиграть с примерами из этого руководства, клонировав Демонстрационный репозиторий субграфов

Также Вы можете посмотреть серию видеороликов >"Как использовать Matchstick для написания модульных тестов для Ваших субграфов"

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

describe(name: String , () => {}) - Определяет тестовую группу.

Примечания:

  • Описания не являются обязательными. Вы по-прежнему можете использовать test() как и раньше, вне блоков describe()

Пример:

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) - Определяет тестовый пример. Вы можете использовать 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 будет выполнен один раз перед всеми тестами в файле.

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 будет выполнен один раз после всех тестов в файле.

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')
// код, который должен обновить displayName до 1-го Gravatar
assert.fieldEquals('Gravatar', '0x0', 'displayName', '1st Gravatar')
store.remove('Gravatar', '0x0')
})
test('Updates the imageUrl', () => {
assert.fieldEquals('Gravatar', '0x0', 'imageUrl', '')
// код, который должен изменить imageUrl на https://www.gravatar.com/avatar/0x0
assert.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")
// код, который должен обновить displayName до 1-го Gravatar
assert.fieldEquals("Gravatar", "0x0", "displayName", "1st Gravatar")
})
test("Updates the imageUrl", () => {
assert.fieldEquals("Gravatar", "0x0", "imageUrl", "")
// код, который должен изменить imageUrl на 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")
// код, который должен обновить displayName до 1-го Gravatar
assert.fieldEquals("Gravatar", "0x0", "displayName", "1st Gravatar")
})
test("Updates the imageUrl", () => {
assert.fieldEquals("Gravatar", "0x0", "imageUrl", "")
// код, который должен изменить imageUrl на 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',
)

Напишите юнит-тест

Ссылка на этот раздел

Давайте посмотрим, как будет выглядеть простой юнит-тест, используя примеры 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', () => {
// Создайте тестовый объект и сохраните его в хранилище как исходное состояние (необязательно)
let gravatar = new Gravatar('gravatarId0')
gravatar.save()
// Создайте фиктивные события
let newGravatarEvent = createNewGravatarEvent(12345, '0x89205A3A3b2A69De6Dbf7f01ED13B2108B2c43e7', 'cap', 'pac')
let anotherGravatarEvent = createNewGravatarEvent(3546, '0x89205A3A3b2A69De6Dbf7f01ED13B2108B2c43e7', 'cap', 'pac')
// Вызовите функции мэппинга, передающие события, которые мы только что создали
handleNewGravatars([newGravatarEvent, anotherGravatarEvent])
// Подтвердите состояние хранилища
assert.fieldEquals('Gravatar', 'gravatarId0', 'id', 'gravatarId0')
assert.fieldEquals('Gravatar', '12345', 'owner', '0x89205A3A3b2A69De6Dbf7f01ED13B2108B2c43e7')
assert.fieldEquals('Gravatar', '3546', 'displayName', 'cap')
// Очистите хранилище, чтобы начать следующий тест с чистого листа
clearStore()
})
test('Next test', () => {
//...
})

Предстоит очень многое распаковать! Прежде всего, важно отметить, что мы импортируем данные из matchstick-as, нашей вспомогательной библиотеки AssemblyScript (распространяемой как модуль npm). Репозиторий Вы можете найти здесь. matchstick-as предоставляет нам полезные методы тестирования, а также определяет функцию test(), которую мы будем использовать для построения наших тестовых блоков. В остальном все довольно просто - вот что происходит:

  • Мы настраиваем наше исходное состояние и добавляем один пользовательский объект Gravatar;
  • Мы определяем два объекта события NewGravatar вместе с их данными, используя функцию create New Gravatar Event();
  • Мы вызываем методы-обработчики этих событий - обрабатываем новые Gravatars() и передаем список наших пользовательских событий;
  • Мы утверждаем состояние хранилища. Как это происходит? - Мы передаем уникальную комбинацию типа объекта и идентификатора. Затем мы проверяем конкретное поле в этом объекте и утверждаем, что оно имеет то значение, которое мы ожидаем от него получить. Мы делаем это как для исходного объекта Gravatar, который мы добавили в хранилище, так и для двух объектов Gravatar, которые добавляются при вызове функции-обработчика;
  • И, наконец, мы очищаем хранилище с помощью clear Store(), чтобы наш следующий тест можно было начать с нового и пустого объекта хранилища. Мы можем определить столько тестовых блоков, сколько захотим.

Вот и все - мы создали наш первый тест! 👏

Теперь, чтобы запустить наши тесты, Вам просто нужно запустить в корневой папке своего субграфа следующее:

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 (из matchstick 0.4.1)

Ссылка на этот раздел

Пользователи могут имитировать файлы IPFS с помощью функции mockIpfsFile(hash, filePath). Функция принимает два аргумента, первый из которых - хэш/путь к файлу IPFS, а второй - путь к локальному файлу.

ПРИМЕЧАНИЕ: При тестировании ipfs.map/ipfs.mapJSON функция обратного вызова должна быть экспортирована из тестового файла, чтобы matchstck мог ее обнаружить, подобно функции processGravatar() в приведенном ниже примере теста:

Файл .test.ts:

import { assert, test, mockIpfsFile } from 'matchstick-as/assembly/index'
import { ipfs } from '@graphprotocol/graph-ts'
import { gravatarFromIpfs } from './utils'
// Экспортируйте обратный вызов ipfs.map(), чтобы matchstick мог его обнаружить
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:

import { Address, ethereum, JSONValue, Value, ipfs, json, Bytes } from "@graphprotocol/graph-ts"
import { Gravatar } from "../../generated/schema"
...
// обратный вызов ipfs.map
export function processGravatar(value: JSONValue, userData: Value): void {
// Смотрите документацию по JsonValue для получения подробной информации о работе
// со значениями JSON
let obj = value.toObject()
let id = obj.get('id')
if (!id) {
return
}
// Обратные вызовы также могут создавать объекты
let gravatar = new Gravatar(id.toString())
gravatar.displayName = userData.toString() + id.toString()
gravatar.save()
}
// функция, которая вызывает 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()
}

Подтверждение состояния хранилища

Ссылка на этот раздел

Пользователи могут утверждать конечное (или промежуточное) состояние хранилища с помощью утверждающих объектов. Для этого пользователь должен указать тип объекта, конкретный идентификатор объекта, имя поля этого объекта и ожидаемое значение поля. Вот небольшой пример:

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.field Equals() проверит соответствие данного поля заданному ожидаемому значению. Тест завершится неудачей, и будет выведено сообщение об ошибке, если значения НЕ равны. В противном случае тест пройдет успешно.

Взаимодействие с метаданными событий

Ссылка на этот раздел

Пользователи могут по умолчанию использовать метаданные транзакции, которые могут быть возвращены в виде ethereum.Event с помощью функции new MockEvent(). В следующем примере показано, как можно считывать/записывать данные в эти поля объекта Event:

// Чтение
let logType = newGravatarEvent.logType
// Запись
let UPDATED_ADDRESS = '0xB16081F360e3847006dB660bae1c6d1b2e17eC2A'
newGravatarEvent.address = Address.fromString(UPDATED_ADDRESS)

Утверждение равенства переменных

Ссылка на этот раздел
assert.equals(ethereum.Value.fromString("hello"); ethereum.Value.fromString("hello"));

Утверждение о том, что объект отсутствует в хранилище

Ссылка на этот раздел

Пользователи могут утверждать, что объект отсутствует в хранилище. Функция принимает тип объекта и идентификатор. Если объект действительно находится в хранилище, тест завершится неудачей с соответствующим сообщением об ошибке. Вот краткий пример использования этой функции:

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)

Ожидаемый сбой

Ссылка на этот раздел

Пользователи могут ожидать сбоев тестирования, используя флаг shouldFail в функциях test():

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

Если тест помечен как shouldFail = 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)
})

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)
})
})

Тестирование динамических источников данных

Ссылка на этот раздел

Тестирование динамических источников данных может быть выполнено путем имитации возвращаемого значения функций context(), address() и network() пространства имен dataSource. В настоящее время эти функции возвращают следующее: context() - возвращает пустой объект (DataSourceContext), address() - возвращает 0x0000000000000000000000000000000000000000 network() - возвращает mainnet. Функции create(...) и createWithContext(...) замаскированы так, что они не выполняют никаких действий, поэтому их вообще не нужно вызывать в тестах. Изменения возвращаемых значений могут быть выполнены с помощью функций пространства имен dataSourceMock в matchstick-as (версия 0.3.0+).

Пример ниже:

Во-первых, у нас есть следующий обработчик событий (который был намеренно перепрофилирован для демонстрации искусственного искажения источника данных):

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()
})

Обратите внимание, что функция DataSource Mock.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

...
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
"""
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-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()
}
}
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, которые затем можно легко проверить, были ли вызваны обработчики, определенные в subgraph.yaml. Поскольку покрытие кода (и тестирование в целом) в AssemblyScript и WebAssembly находится на очень ранних стадиях, Matchstick не может проверить покрытие ветвей. Вместо этого мы полагаемся на утверждение, что если был вызван данный обработчик, то событие/функция для него были должным образом имитированы.

Предварительные требования

Ссылка на этот раздел

Чтобы запустить функцию тестового покрытия, представленную в Matchstick, необходимо заранее подготовить несколько вещей:

Экспортируйте свои обработчики

Ссылка на этот раздел

Для того чтобы Matchstick мог проверить, какие обработчики запущены, эти обработчики необходимо экспортировать из тестового файла. Так, например, в файле gravity.test.ts импортируется следующий обработчик:

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

Чтобы эта функция была видимой (чтобы она была включена в файл wat под именем), нам нужно также экспортировать ее, например, так:

export { handleNewGravatar }

После того как всё это будет настроено, чтобы запустить инструмент тестового покрытия, просто запустите:

graph test -- -c

Вы также можете добавить пользовательскую команду coverage в свой файл 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).

Продолжительность выполнения теста в выходных данных лога

Ссылка на этот раздел

Выходные данные лога включают в себя продолжительность тестового запуска. Вот пример:

[Thu, 31 Mar 2022 13:54:54 +0300] Program executed in: 42.270ms.

Типичные ошибки компилятора

Ссылка на этот раздел

Критично: Не удалось создать WasmInstance из допустимого модуля с контекстом: неизвестный импорт: wasi_snapshot_preview1::fd_write не определен

Это означает, что Вы использовали в своем коде console.log, который не поддерживается AssemblyScript. Пожалуйста, рассмотрите возможность использования API логирования

ERROR TS2554: Expected ? arguments, but got ?.

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: Expected ? arguments, but got ?.

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. Лучший способ устранить проблемы, подобные этой, - обновить всё до последней выпущенной версии.

Обратная связь

Ссылка на этот раздел

Если у Вас есть какие-либо вопросы, отзывы, пожелания по функциям или Вы просто хотите связаться с нами, лучшим местом будет Graph Discord, где у нас есть выделенный канал для Matchstick под названием 🔥| unit-testing.

Редактировать страницу

Предыдущий
Распространенные проблемы с AssemblyScript
Следующий
Часто задаваемы вопросы для разработчиков
Редактировать страницу