This guide covers how to run and write tests for Relaycode. The project uses Jest with React Testing Library for unit and integration testing.
jest.config.ts - Main Jest configuration:
import type { Config } from "jest";
import nextJest from "next/jest";
const createJestConfig = nextJest({
dir: "./",
});
const config: Config = {
coverageProvider: "v8",
testEnvironment: "jsdom",
setupFiles: ["<rootDir>/jest.polyfills.ts"],
setupFilesAfterEnv: ["<rootDir>/jest.setup.ts"],
moduleNameMapper: {
"^@/(.*)$": "<rootDir>/$1",
},
};ESM Package Handling: Several dependencies ship ESM-only builds. The config transforms these packages:
dedot,@dedot/*- Polkadot client@noble/hashes,@noble/curves- Cryptography@scure/base- Base encoding@luno-kit/*- Wallet integration
jest.polyfills.ts - Polyfills for Node.js environment (TextEncoder, crypto, etc.)
jest.setup.ts - Test utilities and global mocks
# Run all tests
yarn test
# Run tests in watch mode (re-runs on file changes)
yarn test:watch
# Run tests with coverage report
yarn test --coverage
# Run specific test file
yarn test __tests__/lib/validation.test.ts
# Run tests matching a pattern
yarn test --testPathPattern="validation"In watch mode, press:
a- Run all testsf- Run only failed testsp- Filter by filename patternt- Filter by test name patternq- Quit watch mode
__tests__/
├── hooks/
│ ├── use-chain-token.test.ts
│ ├── use-recent-addresses.test.ts
│ └── use-ss58.test.ts
├── lib/
│ ├── denominations.test.ts
│ ├── parser.test.ts
│ └── validation.test.ts
└── input-map.test.ts # 52 tests covering all type registrations
Current test count: 163 tests across 7 test suites.
- Test files:
*.test.tsor*.test.tsx - Test files mirror source structure:
lib/validation.ts→__tests__/lib/validation.test.ts - Describe blocks match module/function names
- Test names describe expected behavior
Example from __tests__/lib/validation.test.ts:
import {
validateVectorConstraints,
isValidAddressFormat,
validateAmount,
} from "../../lib/validation";
describe("validateVectorConstraints", () => {
describe("minItems validation", () => {
it("should pass when items >= minItems", () => {
const result = validateVectorConstraints([1, 2, 3], 2, undefined);
expect(result.valid).toBe(true);
});
it("should fail when items < minItems", () => {
const result = validateVectorConstraints([1], 2, undefined, "Items");
expect(result.valid).toBe(false);
expect(result.error).toContain("at least 2");
});
});
});
describe("isValidAddressFormat", () => {
it("should return true for valid Polkadot address", () => {
expect(isValidAddressFormat("5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY")).toBe(true);
});
it("should return false for invalid characters", () => {
expect(isValidAddressFormat("0GrwvaEF...")).toBe(false); // '0' not in Base58
});
});Example from __tests__/hooks/use-ss58.test.ts:
import { renderHook } from "@testing-library/react";
import { useSS58 } from "../../hooks/use-ss58";
describe("useSS58", () => {
describe("when client is null", () => {
it("should use default ss58Prefix of 42", () => {
const { result } = renderHook(() => useSS58(null));
expect(result.current.ss58Prefix).toBe(42);
});
});
describe("isValidAddress", () => {
it("should return true for valid SS58 addresses", () => {
const { result } = renderHook(() => useSS58(null));
expect(result.current.isValidAddress("5GrwvaEF...")).toBe(true);
});
});
describe("with mock client", () => {
it("should use client ss58Prefix when available", () => {
const mockClient = {
consts: { system: { ss58Prefix: 0 } },
} as any;
const { result } = renderHook(() => useSS58(mockClient));
expect(result.current.ss58Prefix).toBe(0);
});
});
});Example from __tests__/input-map.test.ts:
// Mock components to avoid dependency issues
jest.mock("../components/params/inputs/account", () => ({
Account: { displayName: "Account", schema: {} },
}));
import { findComponent } from "../lib/input-map";
import { Account } from "../components/params/inputs/account";
describe("findComponent", () => {
describe("Account types (Priority 100)", () => {
it("should return Account for AccountId", () => {
expect(findComponent("AccountId").component).toBe(Account);
});
it("should match AccountId regex patterns", () => {
expect(findComponent("AccountIdOf").component).toBe(Account);
});
});
describe("Priority ordering", () => {
it("should prefer Bytes over Vector for Vec<u8>", () => {
expect(findComponent("Vec<u8>").component).toBe(Bytes);
});
});
});// Mock env.mjs at the top of test files
jest.mock("../../env.mjs", () => ({
env: {},
}));const mockClient = {
metadata: {
latest: {
pallets: [
{ index: 0, name: "System", calls: 1, docs: [] },
{ index: 4, name: "Balances", calls: 2, docs: [] },
],
},
},
registry: {
findType: jest.fn((typeId) => ({
typeDef: { type: "Primitive", value: "u128" },
})),
findCodec: jest.fn((typeId) => ({
tryEncode: jest.fn(),
tryDecode: jest.fn(),
})),
},
consts: {
system: { ss58Prefix: 0 },
balances: { existentialDeposit: BigInt(10000000000) },
},
} as any;// Mock wallet hooks
jest.mock("../../hooks/use-wallet-safe", () => ({
useSafeAccounts: () => ({
accounts: [
{ address: "5GrwvaEF...", name: "Alice" },
],
}),
useSafeBalance: () => ({
transferable: BigInt(100000000000),
formattedTransferable: "10",
}),
}));jest.mock("../components/params/inputs/account", () => ({
Account: Object.assign(
(props: any) => <div data-testid="account-input" />,
{ schema: {}, displayName: "Account" }
),
}));const localStorageMock = (() => {
let store: Record<string, string> = {};
return {
getItem: (key: string) => store[key] || null,
setItem: (key: string, value: string) => { store[key] = value; },
removeItem: (key: string) => { delete store[key]; },
clear: () => { store = {}; },
};
})();
Object.defineProperty(window, 'localStorage', { value: localStorageMock });describe("validateAmount", () => {
it("should pass for valid positive numbers", () => {
expect(validateAmount("100").valid).toBe(true);
expect(validateAmount("1.5").valid).toBe(true);
});
it("should fail for negative values", () => {
const result = validateAmount("-5");
expect(result.valid).toBe(false);
expect(result.error).toContain("negative");
});
it("should include field name in error", () => {
const result = validateAmount("", "Transfer Amount");
expect(result.error).toContain("Transfer Amount");
});
});describe("createSectionOptions", () => {
it("should filter pallets without calls", () => {
const mockMetadata = {
pallets: [
{ index: 0, name: "System", calls: 1, docs: [] },
{ index: 1, name: "Timestamp", calls: null, docs: [] },
],
} as any;
const result = createSectionOptions(mockMetadata);
expect(result).toHaveLength(1);
expect(result?.[0].text).toBe("System");
});
it("should sort pallets alphabetically", () => {
const mockMetadata = {
pallets: [
{ index: 2, name: "Utility", calls: 1, docs: [] },
{ index: 0, name: "Balances", calls: 2, docs: [] },
],
} as any;
const result = createSectionOptions(mockMetadata);
expect(result?.map(p => p.text)).toEqual(["Balances", "Utility"]);
});
});import { renderHook, act } from "@testing-library/react";
describe("useRecentAddresses", () => {
beforeEach(() => {
localStorage.clear();
});
it("should add address to recent list", () => {
const { result } = renderHook(() => useRecentAddresses());
act(() => {
result.current.addRecent("5GrwvaEF...");
});
expect(result.current.recentAddresses).toContainEqual(
expect.objectContaining({ address: "5GrwvaEF..." })
);
});
});Run coverage report:
yarn test --coverageTarget coverage metrics:
- Statements: 80%
- Branches: 75%
- Functions: 80%
- Lines: 80%
After running with --coverage:
- Check terminal summary
- Open
coverage/lcov-report/index.htmlin browser for detailed report
Example workflow (.github/workflows/test.yml):
name: Tests
on:
push:
branches: [main, master]
pull_request:
branches: [main, master]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'yarn'
- name: Install dependencies
run: yarn install --frozen-lockfile
- name: Run tests
run: yarn test --coverage --ci
- name: Upload coverage
uses: codecov/codecov-action@v3
with:
file: ./coverage/lcov.infoyarn test --verboseyarn test -t "should return Account for AccountId"Add debugger statements and run:
node --inspect-brk node_modules/.bin/jest --runInBandThen open Chrome DevTools at chrome://inspect.
- Isolate tests - Each test should be independent
- Mock external dependencies - Don't rely on network or real clients
- Test edge cases - Empty values, invalid inputs, boundary conditions
- Use descriptive names - Test names should explain expected behavior
- Keep tests focused - One assertion per test when possible
- Clean up - Reset mocks and state between tests
Follow these steps to verify the builder works end-to-end on a testnet:
- Install a Polkadot wallet extension (Talisman, Polkadot.js, or SubWallet)
- Create or import a testnet account
- Get testnet tokens from the Westend faucet
- Chain Selector: Use the chain dropdown in the navbar to switch to Westend (testnet badge visible)
- Wallet Connect: Click "Connect" and approve the connection in your wallet
- Metadata Load: After connecting, verify the builder shows pallets in the Section dropdown
- Build Extrinsic: Select
System>remarkand enter a bytes value (e.g.,0x1234) - Type Badges: Verify the type badge (e.g.,
Bytes) appears next to the field label - Encode: Confirm the hex pane updates with the encoded call data
- Decode: Copy the hex, clear the form, paste it back — verify the form repopulates
- Balance Transfer: Select
Balances>transferKeepAlive- Verify
destshows Account input with type badgeMultiAddress - Verify
valueshows Balance input with denomination selector and type badgeCompact<Balance>
- Verify
- Submit (optional): Sign and submit the remark extrinsic, verify transaction success toast
- BTreeMap: Find a pallet/method that uses BTreeMap (or verify via input-map tests)
- VectorFixed: Verify
[u8; 32]pattern matches in input-map tests - Hash variants: Verify H160 and H512 resolve to correct components in tests
- Jest Documentation
- React Testing Library
- Validation API - Functions being tested