Docs
Testing

Testing

Testing runtime modules allows you to ensure your module works properly, before you plug it into the rest of your app-chain. This section of the documentation builds on top of the starter kit. (using the jest test runner)

Runtime module

The example module we will be testing is a simple balances module, which allows tokens to be minted at the genesis block. Minting after the genesis block should result in an error.

mintery.ts
import "reflect-metadata";
import { Balances, TokenId, UInt64 } from "@proto-kit/library";
import { RuntimeModule, runtimeModule, runtimeMethod } from "@proto-kit/module";
import { assert } from "@proto-kit/protocol";
import { PublicKey } from "o1js";
import { inject } from "tsyringe";
 
export const errors = {
  mintOnlyAtGenesis: "Minting is only allowed at the genesis block",
};
 
@runtimeModule()
export class Mintery extends RuntimeModule<Record<string, never>> {
  public constructor(@inject("Balances") public balances: Balances) {
    super();
  }
 
  @runtimeMethod()
  public mint(tokenId: TokenId, address: PublicKey, amount: UInt64) {
    assert(
      UInt64.from(this.network.block.height).equals(UInt64.from(0)),
      errors.mintOnlyAtGenesis
    );
    this.balances.setBalance(tokenId, address, amount);
  }
}
 

Acceptance criteria

The two easiest layers at which the runtime module can be tested are: state and transaction. That means we should use a user signed tranasction, to create a change in the state - or not, depending on the test scenario.

In our case, we can implement two sequential tests:

  • ✅ mint at the genesis block
  • ❌ mint again in the second block and expect it to fail

Acceptance criteria for both of these tests will be based on the transaction status, and the state of the balances module. Meaning if the user's balance has changed or not.

Testing environment

The most straightforward way to test your runtime is by wrapping it in an app-chain. This will make your runtime callable by test transactions, and also the underlying runtime data fetchable by the query API.

You can setup a TestingAppChain by passing your runtime module, setting up the transaction API signer and starting the app-chain like this:

mintery.test.ts
import { TestingAppChain } from "@proto-kit/sdk";
import { PrivateKey } from "o1js";
import { Mintery, errors } from "../src/mintery";
import { BalancesKey, TokenId, UInt64 } from "@proto-kit/library";
 
describe("balances", () => {
  let appChain: ReturnType<
    typeof TestingAppChain.fromRuntime<{ Mintery: typeof Mintery }>
  >;
 
  let mintery: Mintery;
 
  const alicePrivateKey = PrivateKey.random();
  const alice = alicePrivateKey.toPublicKey();
  const tokenId = TokenId.from(0);
 
  beforeAll(async () => {
    appChain = TestingAppChain.fromRuntime({
      Mintery,
    });
 
    appChain.configurePartial({
      Runtime: {
        Balances: {},
        Mintery: {},
      },
    });
 
    await appChain.start();
 
    appChain.setSigner(alicePrivateKey);
 
    mintery = appChain.runtime.resolve("Mintery");
  });
});
 

Once your testing app-chain is started, you can start writing your actual tests.

Test cases

✅ Mint at the genesis block

First test case is to mint at the genesis block, expect the transaction to succeed and modify the state of the balances module.

Here's what needs to happen for our test case to pass:

  1. Forge and send a transaction to the mintery.mint method
  2. Produce a block on the testing app-chain
  3. Retrieve the balance of the transaction sender (alice).
  4. Assert the transaction status to be truthy, and the balance to be the minted amount.
mintery.test.ts
it("should mint at the genesis block", async () => {
  const tx = await appChain.transaction(alice, () => {
    mintery.mint(tokenId, alice, UInt64.from(1000));
  });
 
  await tx.sign();
  await tx.send();
 
  const block = await appChain.produceBlock();
  const balance = await appChain.query.runtime.Balances.balances.get(
    new BalancesKey({ tokenId, address: alice })
  );
 
  expect(block?.transactions[0].status.toBoolean()).toBe(true);
  expect(balance?.toBigInt()).toBe(1000n);
}, 1_000_000);

❌ Mint again in the second block and expect it to fail

Second test case is to mint at the second block, expect the transaction to fail and not modify the state of the balances module.

Here's what needs to happen for our test case to pass:

  1. Forge and send a transaction to the mintery.mint method
  2. Produce the second block on the testing app-chain
  3. Retrieve the balance of the transaction sender (alice).
  4. Assert the transaction status to be false, and the balance to be the originally minted amount.
mintery.test.ts
it("should not mint at the second block", async () => {
  const tx = await appChain.transaction(alice, () => {
    mintery.mint(tokenId, alice, UInt64.from(1000));
  });
 
  await tx.sign();
  await tx.send();
 
  const block = await appChain.produceBlock();
  const balance = await appChain.query.runtime.Balances.balances.get(
    new BalancesKey({ tokenId, address: alice })
  );
 
  expect(block?.transactions[0].status.toBoolean()).toBe(false);
  expect(block?.transactions[0].statusMessage).toBe(errors.mintOnlyAtGenesis);
  expect(balance?.toBigInt()).toBe(1000n);
}, 1_000_000);