VaultDAO maintains a high standard of code quality through comprehensive testing. This guide covers everything you need to know to run existing tests and write new ones — whether you're working on the Rust smart contract or the TypeScript frontend.
- Philosophy & Coverage Goals
- Smart Contract Testing (Rust)
- Frontend Testing Setup (Vitest)
- Writing Component Tests
- Writing Integration Tests
- Test Coverage
- CI/CD Integration
- Best Practices
- Common Patterns
- Troubleshooting
- Test behaviour, not implementation. Tests should verify what the system does, not how it does it internally.
- Every PR must keep tests green. No merge happens with a failing CI run.
- Coverage targets: ≥ 80% for smart contract logic; aim for the same on critical frontend utilities and hooks.
- Test both happy paths and failure cases. Especially important for on-chain logic where bugs cannot be patched after deployment.
Tests live in contracts/vault/src/test.rs and run entirely in the Soroban simulation environment — no live network is required.
# From the repo root
cd contracts/vault
# Run all tests
cargo test
# Run a specific test by name
cargo test test_multisig_approval
# Show stdout (useful for debugging)
cargo test -- --nocaptureEvery test file starts with:
#![cfg(test)]
use super::*;
use crate::{InitConfig, VaultDAO, VaultDAOClient};
use soroban_sdk::{
testutils::{Address as _, Ledger},
Env, Symbol, Vec,
};The #![cfg(test)] attribute means this module is only compiled when running cargo test, keeping the WASM binary lean.
let env = Env::default();
env.mock_all_auths(); // Skip cryptographic auth checks — use in unit testsEnv::default() creates an isolated simulation environment. Each test gets a fresh one, so there is no shared state between tests.
Soroban provides Address::generate(&env) to create unique, deterministic dummy addresses:
let admin = Address::generate(&env);
let signer = Address::generate(&env);
let token = Address::generate(&env); // stand-in for a token contractlet contract_id = env.register(VaultDAO, ());
let client = VaultDAOClient::new(&env, &contract_id);VaultDAOClient is auto-generated by the Soroban macro. Call contract functions through it exactly as you would on-chain.
Most tests share the same initialization pattern:
fn setup_vault(env: &Env, threshold: u32) -> (VaultDAOClient, Address, Address, Address) {
let admin = Address::generate(env);
let signer1 = Address::generate(env);
let signer2 = Address::generate(env);
let contract_id = env.register(VaultDAO, ());
let client = VaultDAOClient::new(env, &contract_id);
let mut signers = Vec::new(env);
signers.push_back(admin.clone());
signers.push_back(signer1.clone());
signers.push_back(signer2.clone());
let config = InitConfig {
signers,
threshold,
spending_limit: 1000,
daily_limit: 5000,
weekly_limit: 10_000,
timelock_threshold: 500,
timelock_delay: 100,
};
client.initialize(&admin, &config);
(client, admin, signer1, signer2)
}#[test]
fn test_multisig_approval() {
let env = Env::default();
env.mock_all_auths();
let (client, admin, signer1, signer2) = setup_vault(&env, 2); // 2-of-3
client.set_role(&admin, &signer1, &Role::Treasurer);
client.set_role(&admin, &signer2, &Role::Treasurer);
let token = Address::generate(&env);
let proposal_id = client.propose_transfer(
&signer1, &signer2, &token, &100, &Symbol::new(&env, "pay"),
);
// One approval — threshold not met yet
client.approve_proposal(&signer1, &proposal_id);
assert_eq!(client.get_proposal(&proposal_id).status, ProposalStatus::Pending);
// Second approval — threshold met
client.approve_proposal(&signer2, &proposal_id);
assert_eq!(client.get_proposal(&proposal_id).status, ProposalStatus::Approved);
}Use try_* variants to capture errors without panicking:
#[test]
fn test_unauthorized_proposal() {
let env = Env::default();
env.mock_all_auths();
let (client, _, _, _) = setup_vault(&env, 1);
let non_member = Address::generate(&env);
let token = Address::generate(&env);
let res = client.try_propose_transfer(
&non_member, &non_member, &token, &100, &Symbol::new(&env, "fail"),
);
assert!(res.is_err());
assert_eq!(res.err(), Some(Ok(VaultError::InsufficientRole)));
}Control the ledger sequence number to simulate the passage of time:
#[test]
fn test_timelock_enforcement() {
let env = Env::default();
env.mock_all_auths();
env.ledger().set_sequence_number(100); // Start at ledger 100
let (client, admin, signer1, _) = setup_vault(&env, 1);
client.set_role(&admin, &signer1, &Role::Treasurer);
let token = Address::generate(&env);
// Amount (600) > timelock_threshold (500) — will trigger a timelock
let proposal_id = client.propose_transfer(
&signer1, &admin, &token, &600, &Symbol::new(&env, "big"),
);
client.approve_proposal(&signer1, &proposal_id);
let proposal = client.get_proposal(&proposal_id);
assert_eq!(proposal.unlock_ledger, 300); // 100 (current) + 200 (delay)
// Cannot execute before unlock
let res = client.try_execute_proposal(&signer1, &proposal_id);
assert_eq!(res.err(), Some(Ok(VaultError::TimelockNotExpired)));
// Advance past unlock
env.ledger().set_sequence_number(301);
let res = client.try_execute_proposal(&signer1, &proposal_id);
assert_ne!(res.err(), Some(Ok(VaultError::TimelockNotExpired)));
}Always verify that invalid operations return the correct VaultError variant using try_* methods:
let res = client.try_some_operation(&caller, &args);
assert_eq!(res.err(), Some(Ok(VaultError::SomeExpectedError)));Note: Frontend tests do not exist yet. Use this section to set up the test infrastructure before writing your first test.
cd frontend
npm install -D vitest @testing-library/react @testing-library/user-event \
@testing-library/jest-dom jsdomCreate frontend/vitest.config.ts:
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
globals: true,
setupFiles: ["./src/test/setup.ts"],
coverage: {
provider: "v8",
reporter: ["text", "html"],
thresholds: { lines: 80, functions: 80, branches: 80 },
},
},
});Create frontend/src/test/setup.ts:
import "@testing-library/jest-dom";"scripts": {
"test": "vitest",
"test:coverage": "vitest run --coverage"
}cd frontend
# Watch mode (during development)
npm test
# Single run with coverage
npm run test:coveragePlace test files next to the component they test: src/components/ProposalCard.test.tsx.
import { render, screen } from "@testing-library/react";
import { describe, it, expect } from "vitest";
import { ProposalCard } from "./ProposalCard";
const mockProposal = {
id: 1,
recipient: "GABC...XYZ",
amount: 500,
status: "Pending",
approvals: 1,
threshold: 2,
};
describe("ProposalCard", () => {
it("renders proposal details", () => {
render(<ProposalCard proposal={mockProposal} />);
expect(screen.getByText("GABC...XYZ")).toBeInTheDocument();
expect(screen.getByText("500")).toBeInTheDocument();
expect(screen.getByText("Pending")).toBeInTheDocument();
});
it("shows approval progress", () => {
render(<ProposalCard proposal={mockProposal} />);
expect(screen.getByText("1 / 2")).toBeInTheDocument();
});
});import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { vi, describe, it, expect } from "vitest";
import { ApproveButton } from "./ApproveButton";
describe("ApproveButton", () => {
it("calls onApprove when clicked", async () => {
const user = userEvent.setup();
const onApprove = vi.fn();
render(<ApproveButton proposalId={1} onApprove={onApprove} />);
await user.click(screen.getByRole("button", { name: /approve/i }));
expect(onApprove).toHaveBeenCalledOnce();
expect(onApprove).toHaveBeenCalledWith(1);
});
});import { renderHook, act } from "@testing-library/react";
import { vi, describe, it, expect, beforeEach } from "vitest";
import { useProposals } from "./useProposals";
// Mock the contract call module
vi.mock("../lib/contract", () => ({
fetchProposals: vi.fn(),
}));
import { fetchProposals } from "../lib/contract";
describe("useProposals", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("fetches proposals on mount", async () => {
(fetchProposals as ReturnType<typeof vi.fn>).mockResolvedValue([
{ id: 1, status: "Pending" },
]);
const { result } = renderHook(() => useProposals());
await act(async () => {});
expect(result.current.proposals).toHaveLength(1);
expect(result.current.loading).toBe(false);
});
it("handles fetch errors", async () => {
(fetchProposals as ReturnType<typeof vi.fn>).mockRejectedValue(
new Error("Network error"),
);
const { result } = renderHook(() => useProposals());
await act(async () => {});
expect(result.current.error).toBe("Network error");
});
});// src/test/mocks/freighter.ts
import { vi } from "vitest";
vi.mock("@stellar/freighter-api", () => ({
isConnected: vi.fn().mockResolvedValue(true),
getPublicKey: vi.fn().mockResolvedValue("GABC...MOCK"),
signTransaction: vi.fn().mockResolvedValue("signed_xdr_here"),
getNetworkDetails: vi.fn().mockResolvedValue({
network: "TESTNET",
networkUrl: "https://soroban-testnet.stellar.org",
}),
}));Import this mock at the top of any test file that touches wallet connection, or add it to setupFiles for global availability.
// src/test/mocks/contract.ts
import { vi } from "vitest";
vi.mock("../lib/vaultContract", () => ({
proposeTransfer: vi.fn().mockResolvedValue({ success: true, proposalId: 42 }),
approveProposal: vi.fn().mockResolvedValue({ success: true }),
getProposals: vi.fn().mockResolvedValue([]),
}));Integration tests verify complete user flows end-to-end.
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { vi, describe, it, expect } from "vitest";
import { App } from "../App";
import "../test/mocks/freighter";
import "../test/mocks/contract";
describe("Proposal flow", () => {
it("allows a treasurer to create and approve a proposal", async () => {
const user = userEvent.setup();
render(<App />);
// Connect wallet
await user.click(screen.getByRole("button", { name: /connect wallet/i }));
await waitFor(() =>
expect(screen.getByText("GABC...MOCK")).toBeInTheDocument(),
);
// Navigate to proposals
await user.click(screen.getByRole("link", { name: /proposals/i }));
// Submit new proposal
await user.click(screen.getByRole("button", { name: /new proposal/i }));
await user.type(screen.getByLabelText(/recipient/i), "GDEST...XYZ");
await user.type(screen.getByLabelText(/amount/i), "100");
await user.click(screen.getByRole("button", { name: /submit/i }));
await waitFor(() =>
expect(screen.getByText(/proposal created/i)).toBeInTheDocument(),
);
});
});import { signTransaction } from "@stellar/freighter-api";
import { vi, describe, it, expect } from "vitest";
describe("Transaction signing", () => {
it("signs and submits a transaction", async () => {
const mockSign = vi.mocked(signTransaction);
mockSign.mockResolvedValue("signed_xdr");
const result = await submitProposal({ recipient: "GDEST", amount: 100 });
expect(mockSign).toHaveBeenCalledOnce();
expect(result.success).toBe(true);
});
});# Install tarpaulin (one-time)
cargo install cargo-tarpaulin
# Run coverage
cd contracts/vault
cargo tarpaulin --out Html --output-dir coverage/
# Open the report
open coverage/tarpaulin-report.htmlcd frontend
# Install the v8 coverage provider (one-time)
npm install -D @vitest/coverage-v8
# Generate report
npm run test:coverage
# HTML report is written to coverage/index.html| Layer | Minimum | Target |
|---|---|---|
| Smart Contract | 80% | 100% |
| Frontend hooks | 80% | 90% |
| UI Components | 60% | 80% |
PRs that drop coverage below the minimum thresholds should include a justification in the PR description.
Every push to main and every pull request triggers the VaultDAO CI workflow (.github/workflows/test.yml).
| Job | Steps |
|---|---|
test-contracts |
cargo fmt --check, cargo clippy, cargo test |
build-frontend |
npm install, npm run build |
A build failure or test failure will block the PR from merging.
Before pushing, run exactly what CI runs:
# Smart contract
cd contracts/vault
cargo fmt --all -- --check
cargo clippy --all-targets --all-features -- -D warnings
cargo test
# Frontend
cd frontend
npm run build
npm run lintOnce frontend tests are set up, add this job to test.yml:
test-frontend:
name: Test Frontend
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- name: Install dependencies
run: npm install
working-directory: frontend
- name: Run tests with coverage
run: npm run test:coverage
working-directory: frontendUse the test_<what>_<condition>_<expected> pattern for Rust and describe/it for TypeScript:
// Rust
#[test] fn test_approve_proposal_below_threshold_stays_pending() { }
#[test] fn test_approve_proposal_meets_threshold_becomes_approved() { }// TypeScript
describe("approveProposal", () => {
it("keeps status Pending when below threshold", () => {});
it("changes status to Approved when threshold is met", () => {});
});Structure every test in three clear sections:
#[test]
fn test_spending_limit_exceeded() {
// Arrange
let env = Env::default();
env.mock_all_auths();
let (client, admin, signer, _) = setup_vault(&env, 1);
client.set_role(&admin, &signer, &Role::Treasurer);
let token = Address::generate(&env);
// Act
let res = client.try_propose_transfer(
&signer, &admin, &token, &99999, &Symbol::new(&env, "big"),
);
// Assert
assert_eq!(res.err(), Some(Ok(VaultError::SpendingLimitExceeded)));
}Extract shared setup into helper functions (see the setup_vault example in §2.6). Avoid copy-pasting initialization code across tests.
- Zero amounts, min/max
u128 - Exactly at threshold (not one above or below)
- Empty signers list
- Duplicate approvals from the same signer
- Calling functions before
initialize
- Never rely on wall-clock time — use
env.ledger().set_sequence_number()in Rust tests. - Mock all external dependencies (Freighter, contract calls) in frontend tests.
- Ensure each test creates its own
Envand does not share mutable state.
import { waitFor } from "@testing-library/react";
it("loads proposals asynchronously", async () => {
render(<ProposalList />);
// Element is absent while loading
expect(screen.queryByRole("list")).not.toBeInTheDocument();
// Wait for data to appear
await waitFor(() => expect(screen.getByRole("list")).toBeInTheDocument());
});it("shows a spinner while fetching", () => {
render(<ProposalList />);
expect(screen.getByRole("status")).toBeInTheDocument(); // spinner aria role
});it("displays an error message on failed fetch", async () => {
vi.mocked(fetchProposals).mockRejectedValue(new Error("RPC error"));
render(<ProposalList />);
await waitFor(() =>
expect(screen.getByRole("alert")).toHaveTextContent("RPC error"),
);
});it("disables submit when amount is zero", async () => {
const user = userEvent.setup();
render(<TransferForm />);
await user.clear(screen.getByLabelText(/amount/i));
await user.type(screen.getByLabelText(/amount/i), "0");
expect(screen.getByRole("button", { name: /submit/i })).toBeDisabled();
});it("opens and closes the confirm modal", async () => {
const user = userEvent.setup();
render(<ProposalActions />);
await user.click(screen.getByRole("button", { name: /execute/i }));
expect(screen.getByRole("dialog")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /cancel/i }));
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});You likely forgot client.initialize(...) in your test setup. Every test that calls contract functions besides initialize must call it first.
Some test utilities require mock_all_auths_allowing_non_root_auth() for nested auth calls. Try:
env.mock_all_auths_allowing_non_root_auth();Soroban's Env uses interior mutability. Avoid holding a borrow across an await or another env.* call. Release it (end of scope / drop) before the next call.
Ensure the package is installed and the setup file is referenced in vitest.config.ts:
npm install -D @testing-library/jest-dom// vitest.config.ts
setupFiles: ["./src/test/setup.ts"],The jsdom environment doesn't include every browser API. Mock what's missing:
// src/test/setup.ts
global.ResizeObserver = vi.fn().mockImplementation(() => ({
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn(),
}));Check that all environment variables expected by the app at build time are provided in the CI job (VITE_NETWORK, VITE_CONTRACT_ID, VITE_RPC_URL). Missing env vars can cause silent failures during import.