Фреймворк модульного тестирования
Reading time: 24 min
Matchstick - это фреймворк модульного тестирования, разработанный компанией , который позволяет разработчикам субграфов тестировать логику мэппинга в изолированной среде и уверенно развертывать свои субграфы!
Чтобы использовать вспомогательные методы тестирования и запускать тесты, Вам необходимо будет установить следующие зависимости:
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
Вы можете использовать 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]( /), чтобы не приходилось перестраивать образ docker каждый раз, когда выполняется команда graph test -d
. В качестве альтернативы Вы можете следовать инструкциям из репозитория для запуска 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/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 , () => {})
- Определяет тестовую группу.
Примечания:
- Описания не являются обязательными. Вы по-прежнему можете использовать 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-го Gravatarassert.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/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")// код, который должен обновить displayName до 1-го Gravatarassert.fieldEquals("Gravatar", "0x0", "displayName", "1st Gravatar")})test("Updates the imageUrl", () => {assert.fieldEquals("Gravatar", "0x0", "imageUrl", "")// код, который должен изменить imageUrl на 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")// код, который должен обновить displayName до 1-го Gravatarassert.fieldEquals("Gravatar", "0x0", "displayName", "1st Gravatar")})test("Updates the imageUrl", () => {assert.fieldEquals("Gravatar", "0x0", "imageUrl", "")// код, который должен изменить imageUrl на 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 в .
Предположим, у нас есть следующая функция-обработчик (наряду с двумя вспомогательными функциями, облегчающими нашу жизнь):
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', () => {// Создайте тестовый объект и сохраните его в хранилище как исходное состояние (необязательно)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
И если все пойдет хорошо, Вы увидите следующее приветствие:
Пользователи могут наполнять хранилище известным набором объектов. Вот пример инициализации хранилища с помощью объекта 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 с помощью функции 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.mapexport function processGravatar(value: JSONValue, userData: Value): void {// Смотрите документацию по JsonValue для получения подробной информации о работе// со значениями JSONlet 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.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()}
Пользователи могут утверждать конечное (или промежуточное) состояние хранилища с помощью утверждающих объектов. Для этого пользователь должен указать тип объекта, конкретный идентификатор объекта, имя поля этого объекта и ожидаемое значение поля. Вот небольшой пример:
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')
С помощью этой вспомогательной функции можно вывести всё хранилище на консоль:
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() вызывается в конце. Это происходит потому, что значения запоминаются при их изменении, и их необходимо сбросить, если Вы хотите вернуться к значениям по умолчанию.
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))})
Используя 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 -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.
Критично: Не удалось создать WasmInstance из допустимого модуля с контекстом: неизвестный импорт: wasi_snapshot_preview1::fd_write не определен
Это означает, что Вы использовали в своем коде console.log
, который не поддерживается AssemblyScript. Пожалуйста, рассмотрите возможность использования
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.