> ## Documentation Index
> Fetch the complete documentation index at: https://docs.goldsky.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Testing subgraphs

> Unit test subgraph mappings with Matchstick: mock events and eth_calls, assert on entities, and gate deploys on green tests in CI.

A mapping bug usually surfaces as a fatal indexing error hours into a sync: the subgraph [stalls](/subgraphs/stalled-subgraphs), and the fix costs you a redeploy and a re-sync. Catching the same bug in a unit test takes seconds.

Goldsky runs standard `graph-node`, so the standard subgraph testing toolchain works unchanged. [Matchstick](https://github.com/LimeChain/matchstick) runs your mapping handlers against mocked events in a local WASM runtime: no chain, no deploy, and no Goldsky account needed to run tests.

This guide walks through a complete setup: installing Matchstick, testing a `Transfer` handler, mocking eth\_calls, and gating deploys on tests in CI.

## What you'll need

1. A code-based subgraph project: a `schema.graphql`, `subgraph.yaml`, and mapping code in `src/`. If you're starting from scratch, see [deploying from source code](/subgraphs/deploying-subgraphs).
2. Node.js and the `@graphprotocol/graph-cli` package (Matchstick's test runner is the `graph test` command).

<Note>
  Instant (no-code) subgraphs generate their mapping code from configuration, so there's nothing to unit test. This guide applies to subgraphs with hand-written AssemblyScript mappings.
</Note>

## Install Matchstick

Add the `matchstick-as` assertion library as a dev dependency:

```shell theme={"dark"}
npm install --save-dev matchstick-as
```

The test runner itself ships with `graph-cli`: the first `graph test` run downloads the Matchstick binary for your platform. If the binary doesn't support your OS, you can run the tests in Docker instead with `graph test -d`.

## The handler under test

We'll test an ERC-20 `Transfer` handler that records an immutable transfer log and caches token metadata on first sight, following the patterns from the [performance guide](/subgraphs/guides/performance).

```graphql schema.graphql theme={"dark"}
type Token @entity(immutable: false) {
  id: Bytes!      # token address
  symbol: String!
  decimals: Int!
}

type Transfer @entity(immutable: true) {
  id: Bytes!      # txHash ++ logIndex
  token: Token!
  from: Bytes!
  to: Bytes!
  amount: BigInt!
  timestamp: BigInt!
}
```

```typescript src/mapping.ts theme={"dark"}
import { Address } from "@graphprotocol/graph-ts"
import { Transfer as TransferEvent, ERC20 } from "../generated/MyToken/ERC20"
import { Token, Transfer } from "../generated/schema"

export function getOrCreateToken(address: Address): Token {
  let token = Token.load(address)
  if (token == null) {
    token = new Token(address)
    let contract = ERC20.bind(address)
    let symbol = contract.try_symbol()
    token.symbol = symbol.reverted ? "???" : symbol.value
    let decimals = contract.try_decimals()
    token.decimals = decimals.reverted ? 18 : decimals.value
    token.save()
  }
  return token
}

export function handleTransfer(event: TransferEvent): void {
  let token = getOrCreateToken(event.address)

  let transfer = new Transfer(
    event.transaction.hash.concatI32(event.logIndex.toI32())
  )
  transfer.token = token.id
  transfer.from = event.params.from
  transfer.to = event.params.to
  transfer.amount = event.params.value
  transfer.timestamp = event.block.timestamp
  transfer.save()
}
```

Note the rename: the event parameter is `value`, but the entity field is `amount`. Tests assert on entity fields, so this is exactly the kind of detail a test pins down.

## Write the test

Tests live in a `tests/` directory and end in `.test.ts`. A test file has three parts: a helper that builds a typed mock event, mocks for any contract calls the handler makes, and the tests themselves.

```typescript tests/token.test.ts theme={"dark"}
import {
  assert,
  beforeEach,
  clearStore,
  createMockedFunction,
  describe,
  test,
} from "matchstick-as/assembly/index"
import { newMockEvent } from "matchstick-as"
import { Address, BigInt, ethereum } from "@graphprotocol/graph-ts"
import { Transfer as TransferEvent } from "../generated/MyToken/ERC20"
import { handleTransfer } from "../src/mapping"

const TOKEN = Address.fromString("0x9c8ff314c9bc7f6e59a9d9225fb22946427edc03")
const ALICE = Address.fromString("0x0000000000000000000000000000000000000001")
const BOB = Address.fromString("0x0000000000000000000000000000000000000002")

// Build a typed Transfer event from Matchstick's generic mock event.
// Parameters must be pushed in the same order as the event signature.
function createTransferEvent(
  from: Address,
  to: Address,
  value: BigInt
): TransferEvent {
  let event = changetype<TransferEvent>(newMockEvent())
  event.address = TOKEN
  event.parameters = []
  event.parameters.push(
    new ethereum.EventParam("from", ethereum.Value.fromAddress(from))
  )
  event.parameters.push(
    new ethereum.EventParam("to", ethereum.Value.fromAddress(to))
  )
  event.parameters.push(
    new ethereum.EventParam("value", ethereum.Value.fromUnsignedBigInt(value))
  )
  return event
}

// getOrCreateToken calls symbol() and decimals(), so every test that
// creates a new token needs these mocked.
function mockTokenMetadata(): void {
  createMockedFunction(TOKEN, "symbol", "symbol():(string)").returns([
    ethereum.Value.fromString("TEST"),
  ])
  createMockedFunction(TOKEN, "decimals", "decimals():(uint8)").returns([
    ethereum.Value.fromI32(18),
  ])
}

describe("handleTransfer", () => {
  beforeEach(() => {
    clearStore()
  })

  test("creates a Transfer entity", () => {
    mockTokenMetadata()

    let event = createTransferEvent(ALICE, BOB, BigInt.fromI32(100))
    handleTransfer(event)

    let id = event.transaction.hash
      .concatI32(event.logIndex.toI32())
      .toHexString()

    assert.entityCount("Transfer", 1)
    assert.fieldEquals("Transfer", id, "from", ALICE.toHexString())
    assert.fieldEquals("Transfer", id, "to", BOB.toHexString())
    // Entity field is `amount` — the event parameter is `value`
    assert.fieldEquals("Transfer", id, "amount", "100")
  })

  test("caches token metadata instead of re-fetching it", () => {
    mockTokenMetadata()

    handleTransfer(createTransferEvent(ALICE, BOB, BigInt.fromI32(100)))
    handleTransfer(createTransferEvent(BOB, ALICE, BigInt.fromI32(50)))

    assert.entityCount("Token", 1)
    assert.fieldEquals("Token", TOKEN.toHexString(), "symbol", "TEST")
  })

  test("defaults decimals to 18 when the contract reverts", () => {
    // A non-ERC-20 contract whose decimals() reverts is one of the most
    // common causes of a crashed subgraph in production.
    createMockedFunction(TOKEN, "symbol", "symbol():(string)").returns([
      ethereum.Value.fromString("TEST"),
    ])
    createMockedFunction(TOKEN, "decimals", "decimals():(uint8)").reverts()

    handleTransfer(createTransferEvent(ALICE, BOB, BigInt.fromI32(100)))

    assert.fieldEquals("Token", TOKEN.toHexString(), "decimals", "18")
    assert.entityCount("Transfer", 1) // the handler still saved the transfer
  })

  test("handles zero-value self-transfers", () => {
    mockTokenMetadata()

    handleTransfer(createTransferEvent(ALICE, ALICE, BigInt.zero()))

    assert.entityCount("Transfer", 1)
  })

  test("handles max uint256 values", () => {
    mockTokenMetadata()

    let maxUint256 = BigInt.fromString(
      "115792089237316195423570985008687907853269984665640564039457584007913129639935"
    )
    handleTransfer(createTransferEvent(ALICE, BOB, maxUint256))

    assert.entityCount("Transfer", 1)
  })
})
```

A few things to notice:

* `newMockEvent()` returns a generic event; `changetype<TransferEvent>()` casts it to your generated event type so the handler accepts it.
* `clearStore()` in `beforeEach` resets the mock store, so tests stay independent.
* The expected entity id is computed from the mock event itself, the same way the handler computes it, with no hardcoded hashes.
* `assert.entityCount`, `assert.fieldEquals`, and `assert.notInStore` cover most assertions. All field values are compared as strings.

## Run the tests

Generate types first (the test file imports from `../generated`), then run the suite:

```shell theme={"dark"}
graph codegen
graph test
```

To run a single test file, pass its name:

```shell theme={"dark"}
graph test token
```

## Mocking eth\_calls

Any contract call your handler makes must be mocked, or the test fails when the call happens. `createMockedFunction` takes the contract address, the function name, and the full function signature:

```typescript theme={"dark"}
// A call with arguments: match them with withArgs
createMockedFunction(TOKEN, "balanceOf", "balanceOf(address):(uint256)")
  .withArgs([ethereum.Value.fromAddress(ALICE)])
  .returns([ethereum.Value.fromUnsignedBigInt(BigInt.fromI32(1000))])

// A call that reverts: exercises your try_ fallback path
createMockedFunction(TOKEN, "decimals", "decimals():(uint8)").reverts()
```

Use `.reverts()` deliberately: every `try_` call in your mappings has a revert branch, and untested revert branches are where subgraphs crash in production. If your subgraph uses data source templates, `dataSourceMock` lets you simulate the template's context in tests.

## Edge cases worth a test

These are the inputs that most often crash subgraphs in production. Cover them for every handler:

* **Zero-value transfers**: emitted by many tokens, easy to divide by.
* **Self-transfers**: `from` equals `to`; double-counting bugs live here.
* **Reverting metadata calls**: a non-ERC-20 contract whose `decimals()` or `symbol()` reverts.
* **Max `BigInt` values**: the full `uint256` range, not what fits in an `i32`.

Beyond inputs, write tests that would catch the classic mapping bugs: a force-unwrapped `Entity.load(id)!` that panics when the entity is missing (use get-or-create instead), division without a zero check, an early `return` that skips `.save()` and leaves a later `load()` to panic, and a stale `.save()` that overwrites fields a helper function already updated.

## Lint before you deploy

Static analysis catches those same mapping mistakes without writing a test for each one. The Subgraph Linter's high-value checks map one-to-one onto the failures that become fatal indexing errors in production:

| Check                 | Catches                                                                                                   |
| --------------------- | --------------------------------------------------------------------------------------------------------- |
| `unchecked-load`      | `Entity.load(id)!` force-unwrap that panics when the entity is missing; use get-or-create                 |
| `unexpected-null`     | A handler path that can produce null, such as a missing required field or mutating a `@derivedFrom` field |
| `division-guard`      | Division without a zero check; use `safeDiv`                                                              |
| `entity-overwrite`    | A stale `.save()` after a helper already modified the entity, clobbering fields                           |
| `undeclared-eth-call` | An `eth_call` that should be [declared](/subgraphs/guides/declared-eth-calls) for performance             |

Run the linter in CI alongside `graph test` so none of these reach a deploy.

## Run tests in CI

Gate every deploy on a green build and test run:

```shell theme={"dark"}
graph codegen && graph build   # types compile, mappings build
graph test                     # Matchstick unit tests
goldsky subgraph deploy my-subgraph/1.0.0 --path .   # only on green
```

Here's the same gate as a GitHub Actions workflow. Tests run on every push and pull request; the deploy job only runs on `main`, and only after tests pass. Create an API key in your [project settings](https://app.goldsky.com/dashboard/settings) and store it as a repository secret named `GOLDSKY_TOKEN`.

```yaml .github/workflows/subgraph.yml theme={"dark"}
name: Subgraph CI

on:
  push:
    branches: [main]
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - run: npx graph codegen
      - run: npx graph build
      - run: npx graph test

  deploy:
    needs: test
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - run: npm install -g @goldskycom/cli
      - run: npx graph codegen
      - run: npx graph build
      - run: goldsky login --token "$GOLDSKY_TOKEN"
        env:
          GOLDSKY_TOKEN: ${{ secrets.GOLDSKY_TOKEN }}
      - run: goldsky subgraph deploy my-subgraph/1.0.0 --path .
```

Update the `my-subgraph/1.0.0` name and version to match your project; see [Deploy a subgraph](/subgraphs/deploying-subgraphs) for the deploy command's options.


## Related topics

- [Subgraph performance](/subgraphs/guides/performance.md)
- [Blocks Subgraphs](/subgraphs/blocks-subgraphs.md)
- [Index onchain data with Subgraphs](/subgraphs/introduction.md)
- [Agent Skills](/ai-skills.md)
- [Subgraph tags](/subgraphs/tags.md)
