Estrutura de Testes de Unidades
Reading time: 26 min
O Matchstick é uma estrutura de testes de unidades, desenvolvida pela , que permite que programadores de subgraph testem a sua lógica de mapeamento num ambiente de sandbox e lancem os seus subgraphs com confiança!
Para usar os métodos de test helper e executar os testes, instale as seguintes dependências:
yarn add --dev matchstick-as
❗ O graph-node
depende do PostgreSQL, então se ainda não o tens, precisa instalá-lo. Recomendamos fortemente usar os comandos abaixo, pois adicioná-lo de qualquer outra maneira pode causar erros inesperados!
Comando de instalação do Postgres:
brew install postgresql
Crie um symlink ao último libpq.5.lib Você pode precisar criar este diretório primeiro /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
Comando de instalação do Postgres (depende da sua distro):
sudo apt install postgresql
Pode usar o Matchstick no WSL tanto com a abordagem do Docker quanto com a abordagem binária. Como o WSL pode ser um pouco complicado, aqui estão algumas dicas caso encontre problemas
static BYTES = Symbol("Bytes") SyntaxError: Unexpected token =
ou
<PROJECT_PATH>/node_modules/gluegun/build/index.js:13 throw up;
Tenha ciência que está em uma versão mais recente do Node.js. O graph-cli não apoia mais a v10.19.0, que ainda é a versão padrão para novas imagens de Ubuntu no WSL. Por exemplo, se o Matchstick é confirmado como funcional no WSL com a v18.1.0, pode trocar para essa versão através do nvm ou ao atualizar o seu Node.js global. Não se esqueça de apagar o node_modules
e executar o npm install
novamente após atualizar o seu nodejs! Depois, garanta que tem o libpq instalado. Pode fazer isto ao executar
sudo apt-get install libpq-dev
E finalmente, não use o graph test
(que usa a sua instalação global do graph-cli, e por alguma razão, parece não funcionar no WSL no momento). Em vez disto, use o yarn test
ou o npm run test
(que usará a instância local do graph-cli; esta funciona muito bem). Para isto, obviamente precisaria de um script test
no seu arquivo package.json
, que pode ser algo simples como
{"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"}}
Para usar o Matchstick no seu projeto de subgraph, basta abrir um terminal, navegar à pasta raiz do seu projeto e executar graph test [options] <datasource>
— este baixa o binário mais recente do Matchstick, e executa o teste especificado, ou todos os testes especificados em uma pasta de teste (ou todos os testes existentes se não for especificado nenhum flag de fontes de dados).
Isto executará todos os testes na pasta-teste:
graph test
Isto executará um teste chamado gravity.test.ts e/ou todos os testes dentro de uma pasta chamada gravity:
graph test gravity
Isto só executará esse arquivo de teste específico:
graph test path/to/file.test.ts
Opções:
-c, --coverage Executa os testes em modo de cobertura-d, --docker Executa os testes em um docker container (Nota: Favor executar da pasta raiz do subgraph)-f --force Binário: Baixa o binário novamente. Docker: Baixa o Dockerfile novamente e reconstroi a imagem do docker.-h, --help Mostra informações de uso-l, --logs Mostra no console informações sobre o sistema operacional, modelo de CPU, e URL de download (para propósitos de debugging)-r, --recompile Força os testes a serem recompilados-v, --version <tag> Escolhe a versão do binário rust que você deseja baixar/usar
Desde o graph-cli 0.25.2
, o comando graph test
apoia a execução do matchstick
em um container docker com a flag -d
. A implementação do docker utiliza o para que não precise reconstruir a imagem do docker toda vez que o comando graph test -d
é executado. Alternativamente, siga as instruções do repositório do para executar o docker manualmente.
❗ graph test -d
força o docker run
a ser executado com o flag -t
. Isto deve ser removido para rodar dentro de ambientes não interativos (como o GitHub CI).
❗ Caso já tenha executado o graph test
anteriormente, pode encontrar o seguinte erro durante a construção do docker:
error from sender: failed to xattr node_modules/binary-install-raw/bin/binary-<platform>: permission denied
Neste caso, crie um .dockerignore
na pasta raiz e adicione node_modules/binary-install-raw/bin
O Matchstick pode ser configurado para usar um caminho personalizado de tests, libs e manifest através do arquivo de configuração matchstick.yaml
:
testsFolder: path/to/testslibsFolder: path/to/libsmanifestPath: path/to/subgraph.yaml
Podes experimentar e experimentar com os exemplos deste guia ao clonar a repo do
Também pode conferir a série em vídeo sobre
IMPORTANTE: A estrutura de teste descrita abaixo depende da versão >=0.5.0 do matchstick-as
describe(name: String , () => {})
— Define um grupo de teste.
Notas:
- Describes (descrições) não são obrigatórias. Ainda pode usar o test() da maneira antiga, fora dos blocos describe()
Exemplo:
import { describe, test } from "matchstick-as/assembly/index"import { handleNewGravatar } from "../../src/gravity"describe("handleNewGravatar()", () => {test("Should create a new Gravatar entity", () => {...})})
Exemplo aninhado de 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)
— Define um caso de teste. Pode usar o test() em blocos describe() ou de maneira independente.
Exemplo:
import { describe, test } from "matchstick-as/assembly/index"import { handleNewGravatar } from "../../src/gravity"describe("handleNewGravatar()", () => {test("Should create a new Entity", () => {...})})
ou
test("handleNewGravatar() should create a new entity", () => {...})
Executa um bloco de código antes de quaisquer dos testes no arquivo. Se o beforeAll
for declarado dentro de um bloco describe
, ele é executado no começo daquele bloco describe
.
Exemplos:
O código dentro do beforeAll
será executado uma vez antes de todos os testes no arquivo.
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", () => {...})})
O código antes do beforeAll
será executado uma vez antes de todos os testes no primeiro bloco describe
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", () => {...})})
Executa um bloco de código depois de todos os testes no arquivo. Se o afterAll
for declarado dentro de um bloco describe
, ele será executado no final daquele bloco describe
.
Exemplo:
O código dentro do afterAll
será executado uma vez depois de todos os testes no arquivo.
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", () => {...})})
O código dentro do afterAll
será executado uma vez depois de todos os testes no bloco describe
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", () => {...})})
Executa um bloco de código antes de cada teste no arquivo. Se o beforeEach
for declarado dentro de um bloco describe
, ele será executado antes de cada teste naquele bloco describe
.
Exemplos: O código dentro do beforeEach
será executado antes de cada teste.
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", () => {...})})...
O código antes do beforeEach
será executado antes de cada teste no describe
import { describe, test, beforeEach } from 'matchstick-as/assembly/index'import { handleUpdatedGravatar, handleNewGravatar } from '../../src/gravity'describe('handleUpdatedGravatars', () => {beforeEach(() => {let gravatar = new Gravatar('0x0')gravatar.displayName = 'First Gravatar'gravatar.imageUrl = ''gravatar.save()})test('Upates the displayName', () => {assert.fieldEquals('Gravatar', '0x0', 'displayName', 'First Gravatar')// code that should update the displayName to 1st Gravatarassert.fieldEquals('Gravatar', '0x0', 'displayName', '1st Gravatar')store.remove('Gravatar', '0x0')})test('Updates the imageUrl', () => {assert.fieldEquals('Gravatar', '0x0', 'imageUrl', '')// code that should changes the imageUrl to https://www.gravatar.com/avatar/0x0assert.fieldEquals('Gravatar', '0x0', 'imageUrl', 'https://www.gravatar.com/avatar/0x0')store.remove('Gravatar', '0x0')})})
Executa um bloco de código depois de cada teste no arquivo. Se o afterEach
for declarado dentro de um bloco describe
, será executado após cada teste naquele bloco describe
.
Exemplos:
O código dentro do afterEach
será executado após cada teste.
import { describe, test, beforeEach, afterEach } from "matchstick-as/assembly/index"import { handleUpdatedGravatar, handleNewGravatar } from "../../src/gravity"beforeEach(() => {let gravatar = new Gravatar("0x0")gravatar.displayName = “First Gravatar”gravatar.save()})afterEach(() => {store.remove("Gravatar", "0x0")})describe("handleNewGravatar", () => {...})describe("handleUpdatedGravatar", () => {test("Upates the displayName", () => {assert.fieldEquals("Gravatar", "0x0", "displayName", "First Gravatar")// code that should update the displayName to 1st Gravatarassert.fieldEquals("Gravatar", "0x0", "displayName", "1st Gravatar")})test("Updates the imageUrl", () => {assert.fieldEquals("Gravatar", "0x0", "imageUrl", "")// code that should changes the imageUrl to https://www.gravatar.com/avatar/0x0assert.fieldEquals("Gravatar", "0x0", "imageUrl", "https://www.gravatar.com/avatar/0x0")})})
O código dentro do afterEach
será executado após cada teste naquele describe
import { describe, test, beforeEach, afterEach } from "matchstick-as/assembly/index"import { handleUpdatedGravatar, handleNewGravatar } from "../../src/gravity"describe("handleNewGravatar", () => {...})describe("handleUpdatedGravatar", () => {beforeEach(() => {let gravatar = new Gravatar("0x0")gravatar.displayName = "First Gravatar"gravatar.imageUrl = ""gravatar.save()})afterEach(() => {store.remove("Gravatar", "0x0")})test("Upates the displayName", () => {assert.fieldEquals("Gravatar", "0x0", "displayName", "First Gravatar")// code that should update the displayName to 1st Gravatarassert.fieldEquals("Gravatar", "0x0", "displayName", "1st Gravatar")})test("Updates the imageUrl", () => {assert.fieldEquals("Gravatar", "0x0", "imageUrl", "")// code that should changes the imageUrl to https://www.gravatar.com/avatar/0x0assert.fieldEquals("Gravatar", "0x0", "imageUrl", "https://www.gravatar.com/avatar/0x0")})})
fieldEquals(entityType: string, id: string, fieldName: string, expectedVal: string)equals(expected: ethereum.Value, actual: ethereum.Value)notInStore(entityType: string, id: string)addressEquals(address1: Address, address2: Address)bytesEquals(bytes1: Bytes, bytes2: Bytes)i32Equals(number1: i32, number2: i32)bigIntEquals(bigInt1: BigInt, bigInt2: BigInt)booleanEquals(bool1: boolean, bool2: boolean)stringEquals(string1: string, string2: string)arrayEquals(array1: Array<ethereum.Value>, array2: Array<ethereum.Value>)tupleEquals(tuple1: ethereum.Tuple, tuple2: ethereum.Tuple)assertTrue(value: boolean)assertNull<T>(value: T)assertNotNull<T>(value: T)entityCount(entityType: string, expectedCount: i32)
A partir da versão 0.6.0, asserts também apoiam mensagens de erro personalizadas
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',)
Vamos ver como um simples teste de unidade pareceria, com os exemplos do Gravatar no .
Suponhamos que temos a seguinte função de handler (com duas funções de helper para facilitar):
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}
Primeiro, devemos criar um arquivo de teste no nosso projeto. Este é um exemplo de como ele pode ficar:
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', () => {// Cria uma entidade de teste e salve-a no armazenamento como um estado inicial (opcional)let gravatar = new Gravatar('gravatarId0')gravatar.save()// Cria eventos falsoslet newGravatarEvent = createNewGravatarEvent(12345, '0x89205A3A3b2A69De6Dbf7f01ED13B2108B2c43e7', 'cap', 'pac')let anotherGravatarEvent = createNewGravatarEvent(3546, '0x89205A3A3b2A69De6Dbf7f01ED13B2108B2c43e7', 'cap', 'pac')// Chama funções de mapeamento ao passar os eventos que acabamos de criarhandleNewGravatars([newGravatarEvent, anotherGravatarEvent])// Afirma o estado do armazenamentoassert.fieldEquals('Gravatar', 'gravatarId0', 'id', 'gravatarId0')assert.fieldEquals('Gravatar', '12345', 'owner', '0x89205A3A3b2A69De6Dbf7f01ED13B2108B2c43e7')assert.fieldEquals('Gravatar', '3546', 'displayName', 'cap')// Limpa o armazenamento para começar o próximo teste do zeroclearStore()})test('Next test', () => {//...})
Quanta coisa! Primeiro, note que estamos a impoortar coisas do matchstick-as
, nossa biblioteca de helper do AssemblyScript (distribuída como um módulo npm). Pode encontrar o repositório . matchstick-as
nos dá alguns métodos de teste úteis e define a função test()
, que usaremos para construir os nossos blocos de teste. O resto é bem simples — veja o que acontece:
- Configuramos nosso estado inicial e adicionamos uma entidade de Gravatar personalizada;
- Definimos dois eventos
NewGravatar
com os seus dados, usando a funçãocreateNewGravatarEvent()
; - Chamamos métodos de handlers para estes eventos —
handleNewGravatars()
e passamos a lista dos nossos eventos personalizados; - Garantimos o estado da loja. Como isto funciona? — Passamos uma combinação do tipo e da id da Entidade. Depois conferimos um campo específico naquela Entidade e garantimos que ela tem o valor que esperamos que tenha. Estamos a fazer isto tanto para a Entidade Gravatar inicial adicionada ao armazenamento, quanto para as duas entidades Gravatar adicionadas ao chamar a função de handler;
- E por último — limpamos o armazenamento com
clearStore()
, para que o nosso próximo teste comece com um objeto de armazenamento novo em folha. Podemos definir quantos blocos de teste quisermos.
Prontinho — criamos o nosso primeiro teste! 👏
Para executar os nossos testes, basta apenas executar o seguinte na pasta raiz do seu subgraph:
graph test Gravity
E se tudo der certo, deve receber a seguinte resposta:
Os utilizadores podem hidratar o armazenamento com um conjunto conhecido de entidades. Aqui está um exemplo para inicializar o armazenamento com uma entidade Gravatar:
let gravatar = new Gravatar('entryId')gravatar.save()
Um utilizador pode criar um evento personalizado e passá-lo a uma função de mapeamento ligada ao armazenamento:
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)
Os utilizadores podem chamar os mapeamentos com fixações de teste.
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);});}
Os utilizadores podem simular chamadas de contratos:
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))
Como demonstrado, para simular uma chamada de contrato e conseguir um valor de retorno de linha-dura, o utilizador deve fornecer um endereço de contrato, nome de função, assinatura de função, arranjo de argumentos — e claro, o valor de retorno.
Os utilizadores também podem simular regressos de funções:
let contractAddress = Address.fromString('0x89205A3A3b2A69De6Dbf7f01ED13B2108B2c43e7')createMockedFunction(contractAddress, 'getGravatar', 'getGravatar(address):(string,string)').withArgs([ethereum.Value.fromAddress(contractAddress)]).reverts()
Os utilizadores podem simular arquivos IPFS com a função mockIpfsFile(hash, filePath)
. A função aceita dois argumentos: o primeiro é o hash/caminho do arquivo IPFS, e o segundo é o caminho a um arquivo local.
NOTA: Ao testar o ipfs.map/ipfs.mapJSON
, a função de callback deve ser exportada do arquivo de teste para que o matchstick o detete, como a função processGravatar()
no exemplo de teste abaixo:
Arquivo .test.ts
:
import { assert, test, mockIpfsFile } from 'matchstick-as/assembly/index'import { ipfs } from '@graphprotocol/graph-ts'import { gravatarFromIpfs } from './utils'// Exporta o callback do ipfs.map() para que o matchstick o deteteexport { 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')})
Arquivo utils.ts
:
import { Address, ethereum, JSONValue, Value, ipfs, json, Bytes } from "@graphprotocol/graph-ts"import { Gravatar } from "../../generated/schema"...// callback do ipfs.mapexport function processGravatar(value: JSONValue, userData: Value): void {// See the JSONValue documentation for details on dealing// with JSON valueslet obj = value.toObject()let id = obj.get('id')if (!id) {return}// Callbacks também podem criar entidadeslet gravatar = new Gravatar(id.toString())gravatar.displayName = userData.toString() + id.toString()gravatar.save()}// função que chama o 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()}
Os utilizadores podem afirmar o estado final (ou parcial) do armazenamento através de entidades de afirmação. Para isto, o utilizador precisa fornecer um tipo de Entidade, a ID específica de uma Entidade, o nome de um campo naquela Entidade, e o valor esperado do campo. Aqui vai um exemplo rápido:
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')
A função assert.fieldEquals() conferirá a igualdade do campo dado contra o valor dado esperado. O teste acabará em erro, com mensagem correspondente, caso os valores NÃO sejam iguais. Caso contrário, o teste terá êxito.
Os utilizadores podem usar metadados-padrão de transações, que podem ser retornados como um ethereum.Event com a função newMockEvent()
. O seguinte exemplo mostra como pode ler/escrever a estes campos no objeto de Evento:
// Leituralet logType = newGravatarEvent.logType// Escritalet UPDATED_ADDRESS = '0xB16081F360e3847006dB660bae1c6d1b2e17eC2A'newGravatarEvent.address = Address.fromString(UPDATED_ADDRESS)
assert.equals(ethereum.Value.fromString("hello"); ethereum.Value.fromString("hello"));
Os utilizadores podem afirmar que uma entidade não existe no armazenamento. A função toma um tipo e uma id de entidade. Caso a entidade esteja, de facto, na loja, o teste acabará em erro, com uma mensagem de erro relevante. Veja um exemplo rápido de como usar esta funcionalidade:
assert.notInStore('Gravatar', '23')
Pode imprimir o armazenamento inteiro na consola com esta função de helper:
import { logStore } from 'matchstick-as/assembly/store'logStore()
Desde a versão 0.6.0, o logStore
não imprime mais campos derivados; em vez disto, utilizadores podem usar a nova função logEntity
. O logEntity
pode ser usado para imprimir qualquer entidade, não só as que têm campos derivados. O logEntity
pega o tipo e a ID da entidade e um flag showRelated
para indicar se utilizadores querem imprimir as entidades derivadas relacionadas.
import { logEntity } from 'matchstick-as/assembly/store'logEntity("Gravatar", 23, true)
Os utilizadores podem encontrar falhas esperadas, com o flag shouldFail nas funções test()
:
test('Should throw an error',() => {throw new Error()},true,)
Caso o teste seja marcado com shouldFail = true
mas NÃO falhe, isto será mostrado como um erro nos logs e o bloco de teste não terá êxito. E se for marcado com shouldFail = false
(o estado normal), o executor de teste travará.
Ter logs personalizados nos testes de unidade é a mesma coisa que logar nos mapeamentos. A diferença é que o objeto do log deve ser importado do matchstick-as, em vez do graph-ts. Aqui vai um exemplo simples com todos os tipos de log não-críticos:
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!", []);});
Os utilizadores também podem simular uma falha crítica, como no seguinte:
test('Blow everything up', () => {log.critical('Boom!')})
Logar erros críticos interromperá a execução dos testes e causará um desastre. Afinal, queremos ter certeza que o seu código não tenha logs críticos no lançamento; perceberia imediatamente se isto acontecer.
Testar campos derivados permite aos utilizadores configurar um campo numa entidade e atualizar outra automaticamente, caso ela derive um dos seus campos da primeira entidade.
Antes da versão 0.6.0
, era possível resgatar as entidades derivadas ao acessá-las como propriedades ou campos de entidade, como no seguinte exemplo:
let entity = ExampleEntity.load('id')let derivedEntity = entity.derived_entity
Desde a versão 0.6.0
, isto é feito com a função loadRelated
do graph-node. As entidades derivadas podem ser acessadas como são nos 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)})
Desde a versão 0.6.0
, é possível testar o loadInBlock
com o mockInBlockStore
, que permite o mocking de entidades no cache de blocos.
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)})})
Testar fontes de dados dinâmicas pode ser feito ao falsificar o valor de retorno das funções context()
, address()
e network()
do namespace do dataSource. Estas funções atualmente retornam o seguinte: context()
— retorna uma entidade vazia (DataSourceContext), address()
— retorna 0x0000000000000000000000000000000000000000
, network()
— retorna mainnet
. As funções create(...)
e createWithContext(...)
são falsificadas para não terem uso, para que não precisem ser chamadas nos teste. Dá para mudar os valores de retorno podem através das funções do namespace dataSourceMock
no matchstick-as
(versão 0.3.0+).
Exemplo abaixo:
Primeiro temos o seguinte handler de eventos (que foi apropriado intencionalmente para demonstrar a falsificação de fontes de dados):
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()}
E então, temos o teste que usa um dos métodos do namespace dataSourceMock para determinar um novo valor de retorno para todas as funções do 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()})
Note que o dataSourceMock.resetValues()
é chamado no final. Isto é porque os valores são lembrados quando mudados, e devem ser reconfigurados caso queira voltar aos valores padrão.
Desde a versão 0.6.0
, é possível testar se uma nova fonte de dados foi criada de um modelo. Este recurso apoia modelos do ethereum/contract e do file/ipfs. Há quatro funçôes para isto:
assert.dataSourceCount(templateName, expectedCount)
pode ser usado para impor a contagem esperada de fontes de dados do modelo especificadoassert.dataSourceExists(templateName, address/ipfsHash)
impõe que foi criada uma fonte de dados com o identificador especificado (pode ser um endereço de contrato ou um hash de arquivo ipfs) de um modelo especificadologDataSources(templateName)
imprime todas as fontes de dados do modelo especificado ao console para propósitos de debuggingreadFile(path)
lê um arquivo JSON que representa um arquivo IPFS e retorna o conteúdo como Bytes
test('ethereum/contract dataSource creation example', () => {// Garanta que não há dataSources criadas do modelo do GraphTokenLockWalletassert.dataSourceCount('GraphTokenLockWallet', 0)// Crie uma nova fonte de dados GraphTokenLockWallet com o endereço0xa16081f360e3847006db660bae1c6d1b2e17ec2aGraphTokenLockWallet.create(Address.fromString('0xA16081F360e3847006dB660bae1c6d1b2e17eC2A'))// Garanta que a dataSource foi criadaassert.dataSourceCount('GraphTokenLockWallet', 1)// Adicione uma segunda dataSource com contextolet context = new DataSourceContext()context.set('contextVal', Value.fromI32(325))GraphTokenLockWallet.createWithContext(Address.fromString('0xA16081F360e3847006dB660bae1c6d1b2e17eC2B'), context)// Garanta que agora há 2 dataSourcesassert.dataSourceCount('GraphTokenLockWallet', 2)// Garanta que uma dataSource com o endereço "0xA16081F360e3847006dB660bae1c6d1b2e17eC2B" foi criada// Lembre-se que o tipo `Address` foi transformado em letra minúscula após decodificado, então é necessário passar o endereço em letras minúsculas ao garantir que ele existeassert.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}}}}
Assim como as fontes dinâmicas de dados de contrato, os utilizadores podem testar fontes de dados de arquivos e os seus 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 que têm GRT trancado"""type TokenLockMetadata @entity {"Endereço da token lock wallet"id: ID!"Começo da agenda de lançamento"startTime: BigInt!"Final da agenda de lançamento"endTime: BigInt!"Número de períodos entre o início e o fim"periods: BigInt!"Hora quando os lançamentos começam"releaseStartTime: BigInt!}
{"startTime": 1,"endTime": 1,"periods": 1,"releaseStartTime": 1}
export function handleMetadata(content: Bytes): void {// dataSource.stringParams() retorna a CID File DataSource// haverá um mock de stringParam() no teste de handler// para mais informações, leia 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', () => {// Gere a CID dataSource do arquivo de caminho ipfsHash + ipfs// Por exemplo QmaXzZhcYnsisuue5WRdQDH6FDvqkLQX1NckLqBYeYYEfm/example.jsonconst ipfshash = 'QmaXzZhcYnsisuue5WRdQDH6FDvqkLQX1NckLqBYeYYEfm'const CID = `${ipfshash}/example.json`// Crie uma nova dataSource com a CID geradaGraphTokenLockMetadata.create(CID)// Garanta que a dataSource foi criadaassert.dataSourceCount('GraphTokenLockMetadata', 1)assert.dataSourceExists('GraphTokenLockMetadata', CID)logDataSources('GraphTokenLockMetadata')// Agora tempos que fazer um mock dos metadados da dataSource e especificamente utilizar dataSource.stringParam()// dataSource.stringParams usa o valor do dataSource.address(), então faremos um mock do endereço com odataSourceMock do matchstick-as// Primeiro reiniciaremos os valores e depois usaremos o dataSourceMock.setAddress() para configurar a CIDdataSourceMock.resetValues()dataSourceMock.setAddress(CID)// Agora precisamos gerar os Bytes para repassar ao handler dataSource// Para este caso, apresentamos uma nova função readFile que lê um json local e retorna o conteúdo como Bytesconst content = readFile(`path/to/metadata.json`)handleMetadata(content)// Agora testaremos se um TokenLockMetadata foi criadoconst 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))})
Com o Matchstick, os programadores de subgraph podem executar um script que calcula a cobertura de teste das unidades de teste escritas.
A ferramenta de cobertura de testes pega os binários de teste wasm
compilados e os converte a arquivos wat
, que podem então ser facilmente vistoriados para ver se os handlers definidos em subgraph.yaml
foram chamados ou não. Como a cobertura de código (e os testes em geral) está em estado primitivo no AssemblyScript e WebAssembly, o Matchstick não pode procurar por coberturas de branch. Em vez disto, presumimos que se um handler foi chamado, o evento/a função correspondente já passou por testes com êxito.
Para executar a funcionalidade da cobertura de teste providenciada no Matchstick, precisa preparar algumas coisas com antecedência:
Para que o Matchstick confira quais handlers são executados, estes handlers devem ser exportados do arquivo de teste primeiro. No nosso exemplo, temos o seguinte handler a ser importado no nosso arquivo gravity.test.ts:
import { handleNewGravatar } from '../../src/gravity'
Para que essa função seja visível (para ser incluída no arquivo wat
por nome) também precisamos exportá-la assim:
export { handleNewGravatar }
Assim que tudo estiver pronto, para executar a ferramenta de cobertura de testes, basta:
graph test -- -c
Também pode adicionar um comando coverage
personalizado ao seu arquivo package.json
, assim:
"scripts": {/.../"coverage": "graph test -- -c"},
Isto executará a ferramenta de cobertura. Verás algo parecido com isto no terminal:
$ 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).
A saída do log inclui a duração do teste. Veja um exemplo:
[Thu, 31 Mar 2022 13:54:54 +0300] Program executed in: 42.270ms.
Critical: Could not create WasmInstance from valid module with context: unknown import: wasi_snapshot_preview1::fd_write has not been defined
Isto significa que usou o console.log
no seu código, que não é apoiado pelo AssemblyScript. Considere usar a
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)
A diferença nos argumentos é causada pela diferença no graph-ts
e no matchstick-as
. Problemas como este são melhor resolvidos ao atualizar tudo para a versão mais recente.
Caso tenha qualquer pergunta, opinião, pedidos de recursos, ou só quer entrar em contacto, venha para o Discord do The Graph — lá, temos um canal dedicado ao Matchstick, chamado 🔥| unit-testing.