This guide explains how to write and run tests for the SolFoundry on-chain programs:
bounty-registry—contracts/bounty-registry/fndry-staking—contracts/staking-program/
- Prerequisites
- Test Architecture
- Running Tests Locally
- Writing New Tests
- Test Coverage
- CI Integration
- Test Count Reference
Install all toolchain dependencies before running tests.
# Install Rust (stable, 1.79+)
curl https://sh.rustup.rs -sSf | sh
rustup component add clippy rustfmt
# Install Solana CLI (1.18.x)
sh -c "$(curl -sSfL https://release.solana.com/v1.18.26/install)"
# Generate a local keypair (test wallet)
solana-keygen new --no-bip39-passphrase -o ~/.config/solana/id.json
# Configure for localnet
solana config set --url localnetnpm install -g @coral-xyz/anchor-cli@0.30.1cd contracts/bounty-registry && npm install
cd contracts/staking-program && npm installcontracts/
├── tests/
│ ├── helpers/
│ │ └── index.ts # Shared TypeScript test utilities
│ └── integration/
│ └── multi-program.ts # Cross-program integration tests
│
├── bounty-registry/
│ ├── Cargo.toml # Workspace root (for cargo test)
│ ├── programs/bounty-registry/
│ │ └── src/
│ │ ├── lib.rs
│ │ ├── state.rs
│ │ └── tests.rs ← Rust unit tests
│ └── tests/
│ ├── bounty-registry.ts # Anchor integration tests (localnet)
│ └── bankrun/
│ └── bounty-registry.bankrun.ts ← bankrun tests (in-process)
│
└── staking-program/
├── Cargo.toml # Workspace root (for cargo test)
├── programs/fndry-staking/
│ ├── Cargo.toml
│ └── src/
│ ├── lib.rs
│ └── tests.rs ← Rust unit tests
└── tests/
├── fndry-staking.ts # Anchor integration tests (localnet)
└── bankrun/
└── staking.bankrun.ts ← bankrun tests (in-process)
| Layer | Tool | Validator | Speed | Best for |
|---|---|---|---|---|
| Rust unit | cargo test |
None | ~1s | Pure logic: math, state machines, validation |
| bankrun | solana-bankrun |
In-process | ~5s | Instruction dispatch, account reads, clock |
| Integration | anchor test |
Local (8899) | ~60s | Full stack: tokens, CPIs, real program state |
# bounty-registry
cd contracts/bounty-registry
cargo test --manifest-path programs/bounty-registry/Cargo.toml
# fndry-staking
cd contracts/staking-program
cargo test --manifest-path programs/fndry-staking/Cargo.tomlExpected output: all tests pass in under 1 second.
Build first, then run:
# bounty-registry
cd contracts/bounty-registry
anchor build
npm run test:bankrun
# fndry-staking
cd contracts/staking-program
anchor build
npm run test:bankrunbankrun boots a Solana program-test context in-process — no external process needed.
# bounty-registry
cd contracts/bounty-registry
anchor test
# fndry-staking
cd contracts/staking-program
anchor testanchor test automatically:
- Starts a local Solana test validator on port 8899
- Builds and deploys the program
- Runs all
tests/**/*.tsfiles - Shuts down the validator
To connect to an already-running validator (e.g. started with solana-test-validator):
anchor test --skip-local-validatorThe integration tests require both programs to be deployed in the same Anchor workspace. They are designed to run as part of the standard anchor test suite when both workspace references are configured:
# From the contracts/ root (once workspace Anchor.toml is configured)
anchor testUntil then, run them in the bounty-registry workspace (they skip gracefully if the staking program isn't present):
cd contracts/bounty-registry
# Copy tests/integration/ into tests/ then run:
anchor testAdd a new #[test] to the relevant src/tests.rs:
// In contracts/bounty-registry/programs/bounty-registry/src/tests.rs
#[test]
fn my_new_validation_test() {
// Arrange
let input = "some value";
// Act
let result = validate_something(input);
// Assert
assert!(result, "Expected validation to pass for: {}", input);
}Guidelines:
- Use
assert!,assert_eq!,assert_ne!— no external crates needed. - Test one thing per test function.
- Name tests in
snake_casedescribing the scenario:status_open_cannot_transition_to_completed.
Add a new it() block inside any describe() in tests/bounty-registry.ts:
it("should do something specific", async () => {
// Arrange: set up state
const bountyId = getNextBountyId();
await registerDefaultBounty(bountyId);
// Act: call the instruction
const [bountyPda] = derivePda(bountyId, program.programId);
await program.methods
.updateStatus(1, contributor.publicKey)
.accounts({ admin: admin.publicKey, bountyRecord: bountyPda })
.rpc();
// Assert: verify on-chain state
const record = await program.account.bountyRecord.fetch(bountyPda);
expect(record.status).to.deep.include({ claimed: {} });
});Import from the shared helpers module:
import {
createFundedKeypair,
deriveBountyPda,
advanceClock,
mockPrHash,
expectAnchorError,
BRONZE_MIN,
} from "../../tests/helpers";Use expectAnchorError from the helpers module:
import { expectAnchorError } from "../../tests/helpers";
await expectAnchorError(
() => program.methods.registerBounty(...).rpc(),
"TitleTooLong"
);Or use try/catch directly:
try {
await program.methods.someInstruction(...).rpc();
expect.fail("Expected error not thrown");
} catch (error: any) {
expect(error).to.be.instanceOf(AnchorError);
expect(error.error.errorCode.code).to.equal("ExpectedErrorCode");
}import { start, ProgramTestContext } from "solana-bankrun";
import { PublicKey } from "@solana/web3.js";
const PROGRAM_ID = new PublicKey("Your...ProgramId");
describe("my bankrun tests", function () {
let context: ProgramTestContext;
before(async function () {
context = await start(
[{ name: "my_program", programId: PROGRAM_ID }],
[]
);
});
it("does something fast", async function () {
const clock = await context.banksClient.getClock();
// advance clock by 7 days
await context.setClock({
...clock,
unixTimestamp: clock.unixTimestamp + BigInt(7 * 24 * 60 * 60),
});
// ... assert
});
});# bounty-registry
cd contracts/bounty-registry
npm run test:coverage
# fndry-staking
cd contracts/staking-program
npm run test:coverageReports are written to coverage/ in LCOV and text formats.
open coverage/index.html # macOS
xdg-open coverage/index.html # Linux| Metric | Target |
|---|---|
| Lines | ≥ 80% |
| Functions | ≥ 80% |
| Branches | ≥ 70% |
Tests run automatically on every pull request and push to main that touches contracts/.
bounty-registry-build ─┐
bounty-registry-rust-tests ─┤─→ bounty-registry-integration-tests → coverage
│
staking-build ─┤
staking-rust-tests ─┤─→ staking-integration-tests → coverage
│
rust-quality (clippy/fmt/audit)─┘
└─→ anchor-status (summary)
anchor buildsucceeds for both programs- All Rust unit tests (
cargo test) pass — required job, blocks merge - All TypeScript integration tests (
anchor test) pass - Clippy produces no errors
- Rustfmt formatting is consistent
cargo auditfinds no known vulnerabilities- Coverage reports are uploaded as artifacts
- Open the PR on GitHub
- Click Details next to the "Anchor CI" check
- Click "Anchor CI — Summary" job for the table overview
- Download coverage artifacts from the Artifacts section
Current test counts (as of this writing):
| Location | Count | Description |
|---|---|---|
bounty-registry/src/tests.rs |
30 | Rust: state machine (11), from_u8 (7), constants (5), tiers (6), scores (6) |
staking-program/src/tests.rs |
32 | Rust: constants (9), thresholds (5), rewards (9), tier helpers (9) |
bounty-registry/tests/bounty-registry.ts |
20+ | TS: register (11), update_status (6+), record_completion (3+) |
staking-program/tests/fndry-staking.ts |
15+ | TS: init (2), stake (4+), unstake (3+), rewards (3+), tiers (3+) |
bounty-registry/tests/bankrun/ |
9 | bankrun: PDAs (3), payer (1), clock (3), program deploy (1), payer (1) |
staking-program/tests/bankrun/ |
14 | bankrun: PDAs (5), rewards (5), clock (3), deploy (1) |
tests/integration/multi-program.ts |
8 | Integration: 7 lifecycle steps + cross-program coherence |
| Total | ≥ 128 |
Ensure you are in the correct subdirectory:
cd contracts/bounty-registry # not contracts/
anchor buildRun anchor build first to compile the .so:
anchor build
npm run test:bankrun# Kill any existing validator
pkill -f solana-test-validator
# Or use a different port
solana-test-validator --rpc-port 8900 &
anchor test --provider.cluster http://127.0.0.1:8900The fndry_staking program's calculate_rewards is pub(crate). If you need to test it from an external crate, add #[cfg(test)] helper re-exports. The current unit tests in src/tests.rs access it directly (same crate).
Increase the mocha timeout in Anchor.toml:
[scripts]
test = "npx ts-mocha -p ./tsconfig.json -t 300000 tests/**/*.ts"