Testing with NodeJS

When developing programs on Solana, ensuring their correctness and reliability is crucial. Until now devs have been using solana-test-validator for testing. This document covers testing your Solana program with Node.js
using solana-bankrun.

Overview #

There are two ways to test programs on Solana:

  1. solana-test-validator: That spins up a local emulator of the Solana Blockchain on your local machine which receives the transactions to be processed by the validator.
  2. The various BanksClient-based test frameworks for SBF (Solana Bytecode Format) programs: Bankrun is a framework that simulates a Solana bank's operations, enabling developers to deploy, interact with, and assess the behavior of programs under test conditions that mimic the mainnet. It helps set up the test environment and offers tools for detailed transaction insights, enhancing debugging and verification. With the client, we can load programs, and simulate and process transactions seamlessly. solana-program-test (Rust), solana-bankrun (Rust, JavaScript), anchor-bankrun (Anchor, JavaScript), solders.bankrun (Python) are examples of the BanksClient-based testing framework.
Info

pnpm create solana-program can help you generate JS and Rust clients including tests. Anchor is not yet supported.

In this guide, we are using Solana Bankrun. Bankrun is a superfast, powerful, and lightweight framework for testing Solana programs in Node.js.

  • The biggest advantage of using Solana Bankrun is that you don't have to set up
    an environment to test programs like you'd have to do while using the
    solana-test-validator. Instead, you can do that with a piece of code, inside
    the tests.
  • It also dynamically sets time and account data, which isn't possible with
    solana-test-validator

Installation #

Add solana-bankrun as a dev dependency to your node project. If your Solana program is not a node project yet, you can initialize it using npm init.

npm i -D solana-bankrun

Usage #

Program Directory #

Firstly, the program's .so file must be present in one of the following directories:

  • ./tests/fixtures (just create this directory if it doesn't exist already).
  • Your current working directory.
  • A directory you define in the BPF_OUT_DIR or SBF_OUT_DIR environment variables. export BPF_OUT_DIR='/path/to/binary'
  • Build your program specifying the correct directory so that library can pick the file up from directory just from the name. cargo build-sbf --manifest-path=./program/Cargo.toml --sbf-out-dir=./tests/fixtures

Testing Framework #

solana-bankrun is used in JavaScript or TypeScript with testing frameworks like ts-mocha, ava, Jest,
etc. Make sure to get started with any of the above.

Add an npm script to test your program and create your test.ts file inside tests folder.

{
  "scripts": {
    "test": "pnpm ts-mocha -p ./tsconfig.json -t 1000000 ./tests/test.ts"
  }
}

Start #

start function from solana-bankrun spins up a BanksServer and a BanksClient, deploy programs and add accounts as instructed.

import { start } from "solana-bankrun";
import { PublicKey } from "@solana/web3.js";
 
test("testing program instruction", async () => {
  const programId = PublicKey.unique();
  const context = await start([{ name: "program_name", programId }], []);
 
  const client = context.banksClient;
  const payer = context.payer;
  // write tests
});

Bankrun context #

  • We get access to the Bankrun context from the start function. Context contains a BanksClient, a recent blockhash and a funded payer keypair.

  • context has a payer, which is a funded keypair that can be used to sign transactions.

  • context also has context.lastBlockhash or context.getLatestBlockhash to make fetching Blockhash convenient during tests.

  • context.banksClient is used to send transactions and query account data from the ledger state. For example, sometimes Rent (in lamports) is
    required to build a transaction to be submitted, for example, when using the SystemProgram's
    createAccount() instruction. You can do that using BanksClient:

    const rent = await client.getRent();
     
    const Ix: TransactionInstruction = SystemProgram.createAccount({
      // ...
      lamports: Number(rent.minimumBalance(BigInt(ACCOUNT_SIZE))),
      //....
    });
  • You can read account data from BanksClient using getAccount function

    AccountInfo = await client.getAccount(counter);

Process Transaction #

The processTransaction() function executes the transaction with the loaded programs
and accounts from the start function and will return a transaction.

let transaction = await client.processTransaction(tx);

Example #

Here's an example to write test for a hello world program :

import {
  PublicKey,
  Transaction,
  TransactionInstruction,
} from "@solana/web3.js";
import { start } from "solana-bankrun";
import { describe, test } from "node:test";
import { assert } from "chai";
 
describe("hello-solana", async () => {
  // load program in solana-bankrun
  const PROGRAM_ID = PublicKey.unique();
  const context = await start(
    [{ name: "hello_solana_program", programId: PROGRAM_ID }],
    [],
  );
  const client = context.banksClient;
  const payer = context.payer;
 
  test("Say hello!", async () => {
    const blockhash = context.lastBlockhash;
    // We set up our instruction first.
    let ix = new TransactionInstruction({
      // using payer keypair from context to sign the txn
      keys: [{ pubkey: payer.publicKey, isSigner: true, isWritable: true }],
      programId: PROGRAM_ID,
      data: Buffer.alloc(0), // No data
    });
 
    const tx = new Transaction();
    tx.recentBlockhash = blockhash;
    // using payer keypair from context to sign the txn
    tx.add(ix).sign(payer);
 
    // Now we process the transaction
    let transaction = await client.processTransaction(tx);
 
    assert(transaction.logMessages[0].startsWith("Program " + PROGRAM_ID));
    assert(transaction.logMessages[1] === "Program log: Hello, Solana!");
    assert(
      transaction.logMessages[2] ===
        "Program log: Our program's Program ID: " + PROGRAM_ID,
    );
    assert(
      transaction.logMessages[3].startsWith(
        "Program " + PROGRAM_ID + " consumed",
      ),
    );
    assert(transaction.logMessages[4] === "Program " + PROGRAM_ID + " success");
    assert(transaction.logMessages.length == 5);
  });
});

This is how the output looks like after running the tests for hello world program.

[2024-06-04T12:57:36.188822000Z INFO  solana_program_test] "hello_solana_program" SBF program from tests/fixtures/hello_solana_program.so, modified 3 seconds, 20 ms, 687 µs and 246 ns ago
[2024-06-04T12:57:36.246838000Z DEBUG solana_runtime::message_processor::stable_log] Program 11111111111111111111111111111112 invoke [1]
[2024-06-04T12:57:36.246892000Z DEBUG solana_runtime::message_processor::stable_log] Program log: Hello, Solana!
[2024-06-04T12:57:36.246917000Z DEBUG solana_runtime::message_processor::stable_log] Program log: Our program's Program ID: 11111111111111111111111111111112
[2024-06-04T12:57:36.246932000Z DEBUG solana_runtime::message_processor::stable_log] Program 11111111111111111111111111111112 consumed 2905 of 200000 compute units
[2024-06-04T12:57:36.246937000Z DEBUG solana_runtime::message_processor::stable_log] Program 11111111111111111111111111111112 success
▶ hello-solana
  ✔ Say hello! (5.667917ms)
▶ hello-solana (7.047667ms)
 
ℹ tests 1
ℹ suites 1
ℹ pass 1
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 63.52616