Writing Test Cases

In this section, we will go over the different ways we can write test cases for our stateful precompile.

Adding Config Tests

Precompile generation tool generates skeletons for unit tests as well. Generated config tests will be under ./precompile/contracts/helloworld/config_test.go for Subnet-EVM and ./helloworld/config_test.go for Precompile-EVM. There are mainly two functions we need to test: Verify and Equal. Verify checks if the precompile is configured correctly. Equal checks if the precompile is equal to another precompile. Generated Verify tests contain a valid case.

You can add more invalid cases depending on your implementation. Equal tests generate some invalid cases to test different timestamps, types, and AllowList cases. You can check each config_test.go files for other precompiles under the Subnet-EVM's ./precompile/contracts directory for more examples.

Adding Contract Tests

The tool also generates contract tests to make sure our precompile is working correctly. Generated tests include cases to test allow list capabilities, gas costs, and calling functions in read-only mode. You can check other contract_test.go files in the /precompile/contracts. Hello World contract tests will be under ./precompile/contracts/helloworld/contract_test.go for Subnet-EVM and ./helloworld/contract_test.go for Precompile-EVM.

We will also add more test to cover functionalities of sayHello() and setGreeting(). Contract tests are defined in a standard structure that each test can customize to their needs. The test structure is as follows:

// PrecompileTest is a test case for a precompile
type PrecompileTest struct {
	Name string
	// Caller is the address of the precompile caller
	Caller common.Address
	// Input the raw input bytes to the precompile
	Input []byte
	// InputFn is a function that returns the raw input bytes to the precompile
	// If specified, Input will be ignored.
	InputFn func(t testing.TB) []byte
	// SuppliedGas is the amount of gas supplied to the precompile
	SuppliedGas uint64
	// ReadOnly is whether the precompile should be called in read only
	// mode. If true, the precompile should not modify the state.
	ReadOnly bool
	// Config is the config to use for the precompile
	// It should be the same precompile config that is used in the
	// precompile's configurator.
	// If nil, Configure will not be called.
	Config precompileconfig.Config
	// BeforeHook is called before the precompile is called.
	BeforeHook func(t testing.TB, state *extstate.StateDB)
	// Predicates that the precompile should have access to.
	Predicates []predicate.Predicate
	// SetupBlockContext sets the expected calls on MockBlockContext for the test execution.
	SetupBlockContext func(*contract.MockBlockContext)
	// AfterHook is called after the precompile is called.
	AfterHook func(t testing.TB, state *extstate.StateDB)
	// ExpectedRes is the expected raw byte result returned by the precompile
	ExpectedRes []byte
	// ExpectedErr is the expected error returned by the precompile
	ExpectedErr error
	// ChainConfigFn returns the chain config to use for the precompile's block context
	// If nil, the default chain config will be used.
	ChainConfigFn func(*gomock.Controller) precompileconfig.ChainConfig
	// Rules is the rules to use for the precompile's block context.
	// If not set (zero-value), will be derived from ChainConfig.
	Rules extras.AvalancheRules
}

Each test can populate the fields of the PrecompileTest struct to customize the test. This test uses an AllowList helper function allowlisttest.RunPrecompileWithAllowListTests(t, Module, tests) which can run all specified tests plus AllowList test suites. If you don't plan to use AllowList, you can directly run them as follows:

	precompiletest.RunPrecompileTests(t, Module, tests)

Adding VM Tests (Optional)

This is only applicable for direct Subnet-EVM forks as test files are not directly exported in Golang. If you use Precompile-EVM you can skip this step.

VM tests are tests that run the precompile by calling it through the Subnet-EVM. These are the most comprehensive tests that we can run. If your precompile modifies how the Subnet-EVM works, for example changing blockchain rules, you should add a VM test. For example, you can take a look at the TestRewardManagerPrecompileSetRewardAddress function in here.

For this Hello World example, we don't modify any Subnet-EVM rules, so we don't need to add any VM tests.

Adding Solidity Test Contracts

Subnet-EVM no longer has a contracts directory. Each precompile keeps its Solidity test contract in a bindings directory, for example precompile/contracts/feemanager/feemanagertest/bindings/FeeManagerTest.sol. Add your test contract to precompile/contracts/helloworld/helloworldtest/bindings. In the snippet below, change the import to import "precompile/contracts/helloworld/IHelloWorld.sol";, and compile with --base-path set to the graft/subnet-evm directory. This smart contract lets us interact with our precompile! We cast the HelloWorld precompile address to the IHelloWorld interface. In doing so, helloWorld is now a contract of type IHelloWorld and when we call any functions on that contract, we will be redirected to the HelloWorld precompile address.

The below code snippet can be copied and pasted into a new file called ExampleHelloWorld.sol:

//SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "./IHelloWorld.sol";

// ExampleHelloWorld shows how the HelloWorld precompile can be used in a smart contract.
contract ExampleHelloWorld {
  address constant HELLO_WORLD_ADDRESS =
    0x0300000000000000000000000000000000000000;
  IHelloWorld helloWorld = IHelloWorld(HELLO_WORLD_ADDRESS);

  function sayHello() public view returns (string memory) {
    return helloWorld.sayHello();
  }

  function setGreeting(string calldata greeting) public {
    helloWorld.setGreeting(greeting);
  }
}

Warning

Hello World Precompile is a different contract than ExampleHelloWorld and has a different address. Since the precompile uses AllowList for a permissioned access, any call to the precompile including from ExampleHelloWorld will be denied unless the caller is added to the AllowList.

Please note that this contract is simply a wrapper and is calling the precompile functions. The reason why we add another example smart contract is to have a simpler stateless tests.

Add a compile.go file to the same bindings directory. It runs solc and abigen through go generate to create Go bindings for the contract. See precompile/contracts/feemanager/feemanagertest/bindings/compile.go for an example. The DS-Test contract below is from the archived Subnet-EVM v0.6.0 branch.

//SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "../ExampleHelloWorld.sol";
import "../interfaces/IHelloWorld.sol";
import "./AllowListTest.sol";

contract ExampleHelloWorldTest is AllowListTest {
  IHelloWorld helloWorld = IHelloWorld(HELLO_WORLD_ADDRESS);

  function step_getDefaultHelloWorld() public {
    ExampleHelloWorld example = new ExampleHelloWorld();
    address exampleAddress = address(example);
    assertRole(helloWorld.readAllowList(exampleAddress), AllowList.Role.None);
    assertEq(example.sayHello(), "Hello World!");
  }

  function step_doesNotSetGreetingBeforeEnabled() public {
    ExampleHelloWorld example = new ExampleHelloWorld();
    address exampleAddress = address(example);
    assertRole(helloWorld.readAllowList(exampleAddress), AllowList.Role.None);
    try example.setGreeting("testing") {
      assertTrue(false, "setGreeting should fail");
    } catch {}
  }

  function step_setAndGetGreeting() public {
    ExampleHelloWorld example = new ExampleHelloWorld();
    address exampleAddress = address(example);
    assertRole(helloWorld.readAllowList(exampleAddress), AllowList.Role.None);
    helloWorld.setEnabled(exampleAddress);
    assertRole(
      helloWorld.readAllowList(exampleAddress),
      AllowList.Role.Enabled
    );
    string memory greeting = "testgreeting";
    example.setGreeting(greeting);
    assertEq(example.sayHello(), greeting);
  }
}

Precompile-EVM is archived, and the @avalabs/subnet-evm-contracts npm package is deprecated. Do not use this tab for new work:

//SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "../ExampleHelloWorld.sol";
import "../interfaces/IHelloWorld.sol";
import "@avalabs/subnet-evm-contracts/contracts/test/AllowListTest.sol";

contract ExampleHelloWorldTest is AllowListTest {
  IHelloWorld helloWorld = IHelloWorld(HELLO_WORLD_ADDRESS);

  function step_getDefaultHelloWorld() public {
    ExampleHelloWorld example = new ExampleHelloWorld();
    address exampleAddress = address(example);
    assertRole(helloWorld.readAllowList(exampleAddress), AllowList.Role.None);
    assertEq(example.sayHello(), "Hello World!");
  }

  function step_doesNotSetGreetingBeforeEnabled() public {
    ExampleHelloWorld example = new ExampleHelloWorld();
    address exampleAddress = address(example);
    assertRole(helloWorld.readAllowList(exampleAddress), AllowList.Role.None);
    try example.setGreeting("testing") {
      assertTrue(false, "setGreeting should fail");
    } catch {}
  }

  function step_setAndGetGreeting() public {
    ExampleHelloWorld example = new ExampleHelloWorld();
    address exampleAddress = address(example);
    assertRole(helloWorld.readAllowList(exampleAddress), AllowList.Role.None);
    helloWorld.setEnabled(exampleAddress);
    assertRole(
      helloWorld.readAllowList(exampleAddress),
      AllowList.Role.Enabled
    );
    string memory greeting = "testgreeting";
    example.setGreeting(greeting);
    assertEq(example.sayHello(), greeting);
  }
}

Adding DS-Test Case

Subnet-EVM removed its Hardhat and npm test framework in December 2025. Write the test in Go: deploy the test contract with its generated binding on the simulated backend, then call its functions. See precompile/contracts/feemanager/simulated_test.go for an example. The Hardhat script below works only with the archived Subnet-EVM v0.6.0 branch. You can find more information about the test framework here. We also can test the events emitted by the precompile. The test script looks like this:

The test script looks like this:

// (c) 2019-2022, Ava Labs, Inc. All rights reserved.
// See the file LICENSE for licensing terms.

import { expect } from "chai";
import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers";
import { Contract } from "ethers";
import { ethers } from "hardhat";
import { test } from "./utils";

// make sure this is always an admin for hello world precompile
const ADMIN_ADDRESS = "0x8db97C7cEcE249c2b98bDC0226Cc4C2A57BF52FC";
const HELLO_WORLD_ADDRESS = "0x0300000000000000000000000000000000000000";

describe("ExampleHelloWorldTest", function () {
  this.timeout("30s");

  beforeEach("Setup DS-Test contract", async function () {
    const signer = await ethers.getSigner(ADMIN_ADDRESS);
    const helloWorldPromise = ethers.getContractAt(
      "IHelloWorld",
      HELLO_WORLD_ADDRESS,
      signer
    );

    return ethers
      .getContractFactory("ExampleHelloWorldTest", { signer })
      .then((factory) => factory.deploy())
      .then((contract) => {
        this.testContract = contract;
        return contract.deployed().then(() => contract);
      })
      .then(() => Promise.all([helloWorldPromise]))
      .then(([helloWorld]) => helloWorld.setAdmin(this.testContract.address))
      .then((tx) => tx.wait());
  });

  test("should gets default hello world", ["step_getDefaultHelloWorld"]);

  test(
    "should not set greeting before enabled",
    "step_doesNotSetGreetingBeforeEnabled"
  );

  test(
    "should set and get greeting with enabled account",
    "step_setAndGetGreeting"
  );
});

describe("IHelloWorld events", function () {
  let owner: SignerWithAddress;
  let contract: Contract;
  let defaultGreeting = "Hello, World!";
  before(async function () {
    owner = await ethers.getSigner(ADMIN_ADDRESS);
    contract = await ethers.getContractAt(
      "IHelloWorld",
      HELLO_WORLD_ADDRESS,
      owner
    );

    // reset greeting
    let tx = await contract.setGreeting(defaultGreeting);
    await tx.wait();
  });

  it("should emit GreetingChanged event", async function () {
    let newGreeting = "helloprecompile";
    await expect(contract.setGreeting(newGreeting))
      .to.emit(contract, "GreetingChanged")
      .withArgs(
        owner.address,
        // old greeting
        defaultGreeting,
        // new greeting
        newGreeting
      );
  });
});

The test script looks like this:

// (c) 2019-2022, Ava Labs, Inc. All rights reserved.
// See the file LICENSE for licensing terms.

import { expect } from "chai";
import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers";
import { Contract } from "ethers";
import { ethers } from "hardhat";
import { test } from "@avalabs/subnet-evm-contracts";

// make sure this is always an admin for hello world precompile
const ADMIN_ADDRESS = "0x8db97C7cEcE249c2b98bDC0226Cc4C2A57BF52FC";
const HELLO_WORLD_ADDRESS = "0x0300000000000000000000000000000000000000";

describe("ExampleHelloWorldTest", function () {
  this.timeout("30s");

  beforeEach("Setup DS-Test contract", async function () {
    const signer = await ethers.getSigner(ADMIN_ADDRESS);
    const helloWorldPromise = ethers.getContractAt(
      "IHelloWorld",
      HELLO_WORLD_ADDRESS,
      signer
    );

    return ethers
      .getContractFactory("ExampleHelloWorldTest", { signer })
      .then((factory) => factory.deploy())
      .then((contract) => {
        this.testContract = contract;
        return contract.deployed().then(() => contract);
      })
      .then(() => Promise.all([helloWorldPromise]))
      .then(([helloWorld]) => helloWorld.setAdmin(this.testContract.address))
      .then((tx) => tx.wait());
  });

  test("should gets default hello world", ["step_getDefaultHelloWorld"]);

  test(
    "should not set greeting before enabled",
    "step_doesNotSetGreetingBeforeEnabled"
  );

  test(
    "should set and get greeting with enabled account",
    "step_setAndGetGreeting"
  );
});

describe("IHelloWorld events", function () {
  let owner: SignerWithAddress;
  let contract: Contract;
  let defaultGreeting = "Hello, World!";
  before(async function () {
    owner = await ethers.getSigner(ADMIN_ADDRESS);
    contract = await ethers.getContractAt(
      "IHelloWorld",
      HELLO_WORLD_ADDRESS,
      owner
    );

    // reset greeting
    let tx = await contract.setGreeting(defaultGreeting);
    await tx.wait();
  });

  it("should emit GreetingChanged event", async function () {
    let newGreeting = "helloprecompile";
    await expect(contract.setGreeting(newGreeting))
      .to.emit(contract, "GreetingChanged")
      .withArgs(
        owner.address,
        // old greeting
        defaultGreeting,
        // new greeting
        newGreeting
      );
  });
});

Is this guide helpful?