Skip to main content

Testing

Skittles ships built in testing utilities so you can test your contracts with zero boilerplate. Call setup() from skittles/testing and you get an in memory EVM, pre-funded accounts, and deploy/utility functions, all with automatic lifecycle management.

Quick Setup

If you scaffolded your project with skittles init, you already have a working test file and config. Otherwise, install the testing dependencies:

npm install --save-dev ethers hardhat vitest

Create a vitest.config.ts:

vitest.config.ts
import { defineConfig } from "vitest/config";

export default defineConfig({
test: {
environment: "node",
include: ["test/**/*.test.ts"],
testTimeout: 30000,
server: {
deps: {
inline: ["skittles"],
},
},
},
});

Create a hardhat.config.ts:

hardhat.config.ts
import { defineConfig } from "hardhat/config";

export default defineConfig({});

Writing Tests

Import setup from skittles/testing and call it inside a describe block. It automatically creates an in memory EVM before your tests run and shuts it down afterwards:

test/Token.test.ts
import { describe, it, expect, beforeAll } from "vitest";
import { setup } from "skittles/testing";

const INITIAL_SUPPLY = 1_000_000n;

describe("Token", () => {
const env = setup();
let token: any;

beforeAll(async () => {
token = await env.deploy("Token", [INITIAL_SUPPLY]);
});

it("has the correct name", async () => {
expect(await token.name()).toBe("MyToken");
});

it("assigns initial supply to the deployer", async () => {
const [deployer] = env.accounts;
const balance = await token.balanceOf(await deployer.getAddress());
expect(balance).toBe(INITIAL_SUPPLY);
});

it("transfers tokens between accounts", async () => {
const [, alice] = env.accounts;
const aliceAddr = await alice.getAddress();

await token.transfer(aliceAddr, 100n);
expect(await token.balanceOf(aliceAddr)).toBe(100n);
});

it("reverts on insufficient balance", async () => {
const [, alice, bob] = env.accounts;
const bobAddr = await bob.getAddress();
const aliceToken = env.connectAs(token, alice);

await expect(
aliceToken.transfer(bobAddr, 999_999_999n)
).rejects.toThrow();
});
});

That's it. No helpers file, no manual EVM setup, no afterAll cleanup.

Running Tests

Use the built in test command, which compiles your contracts and then runs vitest:

npx skittles test

Or in watch mode:

npx skittles test --watch

Add these as scripts in your package.json:

{
"scripts": {
"test": "skittles test",
"test:watch": "skittles test --watch"
}
}

API Reference

setup()

The recommended way to create a test environment. Call it inside a describe block and it automatically registers beforeAll/afterAll hooks to start and stop the in memory EVM.

Returns a SkittlesTestContext with:

PropertyTypeDescription
accountsSigner[]Ten pre-funded signer accounts
providerJsonRpcProviderethers.js JSON RPC provider
deploy(name, args?, opts?) => Promise<Contract>Deploy a compiled contract
connectAs(contract, signer) => ContractConnect as a different signer
getBalance(address) => Promise<bigint>Get ETH balance of an address

Access accounts and provider inside lifecycle hooks or test blocks (after beforeAll has run). Functions like deploy, connectAs, and getBalance can be called any time after beforeAll.

The deploy function automatically loads ABI and bytecode from the build/ directory. Options:

OptionTypeDescription
buildDirstringOverride build directory
valuebigintETH to send (payable constructors)
fromnumberAccount index to deploy from (default 0)

env.connectAs(contract, signer)

Returns a new contract instance connected to a different signer. Use this to test multi account scenarios:

const [, alice] = env.accounts;
const aliceToken = env.connectAs(token, alice);
await aliceToken.transfer(bobAddr, 100n);

env.getBalance(address)

Returns the ETH balance of an address as a bigint:

const balance = await env.getBalance(aliceAddr);

Testing Payable Functions

Send ETH with deployment or function calls:

const env = setup();

// Payable constructor (inside beforeAll)
const vault = await env.deploy("Staking", [], { value: ethers.parseEther("1") });

// Payable function
const aliceVault = env.connectAs(vault, env.accounts[1]);
await aliceVault.deposit({ value: ethers.parseEther("1") });

Testing Events

Parse events from transaction receipts using the contract interface:

const tx = await token.transfer(aliceAddr, 100n);
const receipt = await tx.wait();

const iface = token.interface;
const log = receipt.logs.find(
(l) => iface.parseLog(l)?.name === "Transfer"
);

expect(log).toBeTruthy();
const parsed = iface.parseLog(log!);
expect(parsed!.args[0]).toBe(ownerAddr);
expect(parsed!.args[1]).toBe(aliceAddr);
expect(parsed!.args[2]).toBe(100n);

Testing Reverts

Use Vitest's rejects.toThrow() matcher:

await expect(
token.transfer(aliceAddr, 999_999_999n)
).rejects.toThrow();

Advanced: Manual Lifecycle

If you need more control (e.g., multiple environments in one file, or custom lifecycle timing), you can use the lower level API directly. Note that Vitest is still required since skittles/testing imports it at module load time:

import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { createTestEnv, deploy, connectAs, TestEnv } from "skittles/testing";

describe("Token", () => {
let env: TestEnv;
let token: any;

beforeAll(async () => {
env = await createTestEnv();
token = await deploy(env, "Token", [1_000_000n]);
});

afterAll(async () => {
await env.close();
});

it("works", async () => {
expect(await token.name()).toBe("MyToken");
});
});

The standalone deploy(env, name, args, options) and getBalance(env, address) functions take a TestEnv as the first argument instead of using the automatic context.

Working Example

The example project contains a full test suite for an ERC20 token and a staking contract.

git clone https://github.com/chase-manning/skittles.git
cd skittles/example
yarn install
yarn test