Skip to content

Latest commit

 

History

History
783 lines (570 loc) · 20.5 KB

File metadata and controls

783 lines (570 loc) · 20.5 KB

Testing Guide

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.


Table of Contents

  1. Philosophy & Coverage Goals
  2. Smart Contract Testing (Rust)
  3. Frontend Testing Setup (Vitest)
  4. Writing Component Tests
  5. Writing Integration Tests
  6. Test Coverage
  7. CI/CD Integration
  8. Best Practices
  9. Common Patterns
  10. Troubleshooting

1. Philosophy & Coverage Goals

  • 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.

2. Smart Contract Testing (Rust)

Tests live in contracts/vault/src/test.rs and run entirely in the Soroban simulation environment — no live network is required.

2.1 Running Tests

# 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 -- --nocapture

2.2 Test Structure

Every 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.

2.3 The Test Environment (Env)

let env = Env::default();
env.mock_all_auths(); // Skip cryptographic auth checks — use in unit tests

Env::default() creates an isolated simulation environment. Each test gets a fresh one, so there is no shared state between tests.

2.4 Mocking Addresses

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 contract

2.5 Registering and Using the Contract

let 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.

2.6 Standard Test Setup

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)
}

2.7 Testing Multi-Sig Logic

#[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);
}

2.8 Testing RBAC (Role-Based Access Control)

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)));
}

2.9 Testing Timelocks

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)));
}

2.10 Testing Error Cases

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)));

3. Frontend Testing Setup (Vitest)

Note: Frontend tests do not exist yet. Use this section to set up the test infrastructure before writing your first test.

3.1 Install Dependencies

cd frontend
npm install -D vitest @testing-library/react @testing-library/user-event \
  @testing-library/jest-dom jsdom

3.2 Configure Vitest

Create 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 },
    },
  },
});

3.3 Create the Setup File

Create frontend/src/test/setup.ts:

import "@testing-library/jest-dom";

3.4 Add Test Scripts to package.json

"scripts": {
  "test": "vitest",
  "test:coverage": "vitest run --coverage"
}

3.5 Running Tests

cd frontend

# Watch mode (during development)
npm test

# Single run with coverage
npm run test:coverage

4. Writing Component Tests

Place test files next to the component they test: src/components/ProposalCard.test.tsx.

4.1 Basic Component Test

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();
  });
});

4.2 Testing User Interactions

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);
  });
});

4.3 Testing Hooks

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");
  });
});

4.4 Mocking the Freighter Wallet

// 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.

4.5 Mocking Contract Calls

// 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([]),
}));

5. Writing Integration Tests

Integration tests verify complete user flows end-to-end.

5.1 Testing a Complete Proposal Flow

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(),
    );
  });
});

5.2 Testing Transaction Signing

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);
  });
});

6. Test Coverage

6.1 Rust Coverage with cargo-tarpaulin

# 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.html

6.2 Frontend Coverage with Vitest

cd 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

6.3 Coverage Requirements

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.


7. CI/CD Integration

Every push to main and every pull request triggers the VaultDAO CI workflow (.github/workflows/test.yml).

7.1 What Runs Automatically

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.

7.2 Running the Same Checks Locally

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 lint

7.3 Adding Frontend Tests to CI

Once 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: frontend

8. Best Practices

Naming Conventions

Use 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", () => {});
});

Arrange-Act-Assert

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)));
}

DRY Principle

Extract shared setup into helper functions (see the setup_vault example in §2.6). Avoid copy-pasting initialization code across tests.

Test Edge Cases

  • 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

Avoid Flaky Tests

  • 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 Env and does not share mutable state.

9. Common Patterns

Testing Async Operations

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());
});

Testing Loading States

it("shows a spinner while fetching", () => {
  render(<ProposalList />);
  expect(screen.getByRole("status")).toBeInTheDocument(); // spinner aria role
});

Testing Error Handling

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"),
  );
});

Testing Form Validation

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();
});

Testing Modals

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();
});

10. Troubleshooting

cargo test fails with "contract not initialized"

You likely forgot client.initialize(...) in your test setup. Every test that calls contract functions besides initialize must call it first.

env.mock_all_auths() still requires auth

Some test utilities require mock_all_auths_allowing_non_root_auth() for nested auth calls. Try:

env.mock_all_auths_allowing_non_root_auth();

Test panics with "already borrowed"

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.

Vitest — "Cannot find module '@testing-library/jest-dom'"

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"],

Vitest — component renders blank / hooks return undefined

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(),
}));

Tests pass locally but fail in CI

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.


Additional Resources