Фреймворк модульного тестирования
Reading time: 24 min
Learn how to use Matchstick, a unit testing framework developed by . Matchstick enables subgraph developers to test their mapping logic in a sandboxed environment and sucessfully deploy their subgraphs.
- It's written in Rust and optimized for high performance.
- It gives you access to developer features, including the ability to mock contract calls, make assertions about the store state, monitor subgraph failures, check test performance, and many more.
In order to use the test helper methods and run tests, you need to install the following dependencies:
yarn add --dev matchstick-as
graph-node
depends on PostgreSQL, so if you don't already have it, then you will need to install it.
Note: It's highly recommended to use the commands below to avoid unexpected errors.
Installation command:
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
Installation command (depends on your distro):
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
принудительно запускает docker run
с флагом -t
. Этот флаг необходимо удалить для запуска в неинтерактивных средах (таких, например, как 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
Вы можете попробовать и поиграть с примерами из этого руководства, клонировав
Также Вы можете посмотреть серию видеороликов
ВАЖНО: Описанная ниже тестовая структура зависит от версии matchstick-as
>=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)
Начиная с версии 0.6.0, утверждения также поддерживают настраиваемые сообщения об ошибках
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()
Начиная с версии 0.6.0, logStore
больше не выводит производные поля. Вместо этого пользователи могут использовать новую функцию logEntity
. Конечно, logEntity
можно использовать для вывода любого объекта, а не только тех, у которых есть производные поля. logEntity
принимает тип объекта, идентификатор объекта и флаг showRelated
, указывающий, хочет ли пользователь вывести связанные производные объекты.
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!')})
Логирование критических ошибок остановит выполнение тестов и все испортит. В конце концов, мы хотим быть уверены, что Ваш код не содержит критических логов при развертывании, и Вы сразу заметите, если это произойдет.
Тестирование производных полей — это функция, которая позволяет пользователям устанавливать поле для определенного объекта и автоматически обновлять другой объект, если он извлекает одно из своих полей из первого объекта.
До версии 0.6.0
можно было получить производные объекты, обратившись к ним как к полям/свойствам объектов, например:
let entity = ExampleEntity.load('id')let derivedEntity = entity.derived_entity
Начиная с версии 0.6.0
это делается с помощью функции loadRelated
graph-node, к производным объектам можно получить доступ так же, как и в обработчиках.
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)})
Начиная с версии 0.6.0
, пользователи могут тестировать loadInBlock
с помощью mockInBlockStore
, он позволяет имитировать объекты в кеше блоков.
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() вызывается в конце. Это происходит потому, что значения запоминаются при их изменении, и их необходимо сбросить, если Вы хотите вернуться к значениям по умолчанию.
Начиная с версии 0.6.0
, можно проверить, был ли создан новый источник данных из шаблона. Эта функция поддерживает шаблоны ethereum/contract и file/ipfs. Для этого предусмотрены четыре функции:
assert.dataSourceCount(templateName,expectedCount)
можно использовать для подтверждения ожидаемого количества источников данных из указанного шаблонаassert.dataSourceExists(templateName, адрес/ipfsHash)
подтверждает, что источник данных с указанным идентификатором (может быть адресом контракта или хешем файла IPFS) из указанного шаблона был созданlogDataSources(templateName)
выводит все источники данных из указанного шаблона на консоль в целях отладкиreadFile(path)
считывает файл JSON, представляющий файл IPFS, и возвращает содержимое в виде байтов
test('ethereum/contract dataSource creation example', () => {// Подтверждаем, что не создано ни одного источника данных из шаблона GraphTokenLockWalletassert.dataSourceCount('GraphTokenLockWallet', 0)// Создаем новый источник данных GraphTokenLockWallet с адресом 0xA16081F360e3847006dB660bae1c6d1b2e17eC2AGraphTokenLockWallet.create(Address.fromString('0xA16081F360e3847006dB660bae1c6d1b2e17eC2A'))// Подтверждаем, что источник данных созданassert.dataSourceCount('GraphTokenLockWallet', 1)// Добавляем второй источник данных с контекстомlet context = new DataSourceContext()context.set('contextVal', Value.fromI32(325))GraphTokenLockWallet.createWithContext(Address.fromString('0xA16081F360e3847006dB660bae1c6d1b2e17eC2B'), context)// Подтверждаем, что теперь есть 2 источника данныхassert.dataSourceCount('GraphTokenLockWallet', 2)// Подтверждаем, что был создан источник данных с адресом "0xA16081F360e3847006dB660bae1c6d1b2e17eC2B"// Имейте в виду, что тип `Address` преобразуется в строчные буквы при декодировании, поэтому адрес нужно передавать полностью в нижнем регистре, когда Вы проверяете его наличие.assert.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}}}}
Аналогично контрактным источникам динамических данных пользователи могут тестировать источники данных тестовых файлов и их обработчики
...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() возвращает CID источника данных файла// stringParam() будет имитироваться в тесте обработчика// для получения дополнительной информации 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', () => {// Сгенерируйте the dataSource CID from the ipfsHash + ipfs path file// Например, QmaXzZhcYnsisuue5WRdQDH6FDvqkLQX1NckLqBYeYYEfm/example.jsonconst ipfshash = 'QmaXzZhcYnsisuue5WRdQDH6FDvqkLQX1NckLqBYeYYEfm'const CID = `${ipfshash}/example.json`// Создайте новый источник данных, используя сгенерированный CIDGraphTokenLockMetadata.create(CID)// Подтвердите, что источник данных созданassert.dataSourceCount('GraphTokenLockMetadata', 1)assert.dataSourceExists('GraphTokenLockMetadata', CID)logDataSources('GraphTokenLockMetadata')// Теперь нам нужно смоделировать метаданные dataSource, в частности,dataSource.stringParam()// dataSource.stringParams на самом деле использует значение dataSource.address(), поэтому мы будем имитировать адрес, используя dataSourceMock из matchstick-as// Сначала мы сбросим значения, а затем используем dataSourceMock.setAddress() для установки CIDdataSourceMock.resetValues()dataSourceMock.setAddress(CID)// Теперь нам нужно сгенерировать байты для передачи обработчику dataSource// Для этого случая мы ввели новую функцию readFile, которая считывает локальный json и возвращает содержимое в виде байтовconst content = readFile(`path/to/metadata.json`)handleMetadata(content)// Теперь проверим, был ли создан TokenLockMetadataconst 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
. Лучший способ устранить проблемы, подобные этой, - обновить всё до последней выпущенной версии.
For any additional support, check out this .
Если у Вас есть какие-либо вопросы, отзывы, пожелания по функциям или Вы просто хотите связаться с нами, лучшим местом будет Graph Discord, где у нас есть выделенный канал для Matchstick под названием 🔥| unit-testing.