Developers

The Definitive Guide to Testing Smart Contracts on Stellar

Author

Leigh McCulloch

Publishing date

Why Test?

How do we know that something works?

Scientists have used the scientific method for centuries. Wikipedia describes the scientific method as:

“A method for acquiring knowledge that involves careful observation coupled with rigorous skepticism, because cognitive assumptions can distort the interpretation of observation.”

– Wikipedia “scientific method” (CC BY-SA 4.0)

This is really a way of saying that if we aren’t rigorously skeptical about whether something works, we are making assumptions. There are plenty of things said about assumptions:

  • Assumptions make a donkey out of you and me.
  • Assumptions are the mother of all mistakes.

Untested assumptions become bugs.

Dijkstra highlights that bug creation is a natural part of development:

“If debugging is the process of removing software bugs, then programming must be the process of putting them in.”

– Edsger W. Dijkstra

Therefore, for testing to be effective at identifying bugs early, it must be integrated with development, just like bug creation. Not an afterthought, or added in post.

There’s a limit to our ability as humans to create things that work perfectly. That’s why so much of life—the buildings we live in and the cars we drive—must be observed and tested.

For software, it is much the same. As software engineers, blockchain builders, and contract developers, we use the scientific method, careful observation, and rigorous skepticism.

We test.

Automated testing is one of the most powerful tools for creating more reliable, safer software.

Good tests:

  • Confirm that the software does what was intended the first time it is built;
  • Provide confidence to refactor code knowing that functionality has not changed;
  • Help developers understand and prove the scope of a change;
  • Are a multiplier on teams because they give others the confidence to change code that they have no prior experience with.

Good testing tools and strategies make our skepticism much more rigorous than we alone are capable of.

How is Testing Stellar Contracts Different?

Stellar is one of the OG blockchains, online since 2014, and launched smart contracts to Mainnet in 2024.

Before launching smart contracts, Stellar already had a great testing experience, including:

  • A very stable and accessible test network;
  • Publicly hosted and free API and RPCs on the test network;
  • Reliable friendbot for getting test lumens;
  • The Lab (https://lab.stellar.org) for inspecting and building transactions, and exploring APIs;
  • A lightweight docker image for running a full local and deployable real network with APIs and a test lumen bot, and accelerated ledger closes— ideal for easy and fast testing locally and in CI.

When Stellar added smart contracts, the focus was again on a great testing experience that included a rich toolbox leading developers to write tests as early as their first contract. Testing smart contracts on Stellar includes:

  • Testing is seamless with a single language—Rust—and a unified toolchain. Developers write contracts in Rust and test them using the same APIs, enabling unit tests, integration tests against Mainnet contracts, and testing with real production data. They can also leverage fuzz and property testing—all in Rust, all with the same APIs—for a consistent experience.
  • Tests are powered by the same Soroban contract runtime that runs on Mainnet. There are no emulators or test frameworks playing catch up with reality.
  • Reduce context switching without additional frameworks to learn.
  • A rich ecosystem of test tooling that already exists. Use the Rust tooling that you already know and love.
  • Stellar tests are supported by a robust ecosystem of IDE tools that already exist, with language servers and step-through-debugging, so that you can develop with the IDE you already know and with an immersive experience.
  • Whether that be VSCode, Cursor, RustRover, Sublime, Vim, Zed, Windsurf, etc. Anything that supports the Rust language server rust-analyzer is going to help write tests, and anything that supports LLDB will help you step-through debug your code.

What is testing?

Testing is often thought of as unit testing, but testing is so much more. In the Stellar ecosystem writing tests for smart contracts involves, at the very least, all of the following strategies.

How to Write Tests

Unit Tests

Unit tests are small tests that test one piece of functionality within a contract. They’re a great place to get started and in many ecosystems, they are the simplest test to write. Unit tests are helpful when the code to be tested is a narrow part of the program without few or no dependencies.

Below is an example of a unit test that tests the functionality of an increment contract.

#![cfg(test)]
use soroban_sdk::Env;
use crate::{IncrementContract, IncrementContractClient};

#[test]
fn test() {
   let env = Env::default();
   let contract_id = env.register(IncrementContract, ());
   let client = IncrementContractClient::new(&env, &contract_id);

   assert_eq!(client.increment(), 1);
   assert_eq!(client.increment(), 2);
   assert_eq!(client.increment(), 3);
}

To learn more about the example above and writing unit tests, see the how-to guide on Unit Tests.

How to Write Tests

Integration Tests

Integration tests are tests that span multiple components and include the integration points between them, so they naturally test a larger scope. An integration test for a contract is likely to test other contracts that the contract depends on.

The Soroban Rust SDK makes integration testing easy by providing utilities for testing against real contracts fetched from Mainnet, Testnet, or the local file system. Because all tests use the real Soroban Environment there is no difference in tooling or test setup to write unit or integration tests.

To integration test against contracts deployed to Mainnet or Testnet, use the stellar-cli to fetch the contract and then import it into the test.

$ stellar contract fetch --id C... --out-file pause.wasm

Below is an example of an integration test that tests the functionality of an increment contract along with a dependency, the pause contract that was downloaded from Testnet.

#![cfg(test)]
use soroban_sdk::Env;
use crate::{Error, IncrementContract, IncrementContractClient};

mod pause {
   soroban_sdk::contractimport!(file = "pause.wasm");
}

#[test]
fn test() {
   let env = Env::default();

   let pause_id = env.register(pause::WASM, ());
   let pause_client = pause::Client::new(&env, &pause_id);

   let contract_id = env.register(
       IncrementContract,
       IncrementContractArgs::__constructor(&pause_id),
   );
   let client = IncrementContractClient::new(&env, &contract_id);

   pause_client.set(&false);
   assert_eq!(client.increment(), 1);

   pause_client.set(&true);
   assert_eq!(client.try_increment(), Err(Ok(Error::Paused)));

   pause_client.set(&false);
   assert_eq!(client.increment(), 2);
}

To learn more about the example above and writing integration tests, see the how-to guide on Integration Tests.

How to Write Tests

Fork Tests

Integration tests that test with Mainnet contracts can also pull in data from Mainnet so that the integration test is as similar as possible to a transaction submitted to Mainnet.

To integration test against contracts and their data from Mainnet or Testnet, use the stellar-cli to take a snapshot of the contract and its data, then load that snapshot into the test.

$ stellar snapshot create --address C... --output json --out snapshot.json

Below is an example of an integration test that tests the functionality of an increment contract along with a dependency, the pause contract that was snapshotted along with its data and loaded into the environment in the test.

use soroban_sdk::Env;
use crate::{IncrementContract, IncrementContractClient};

#[test]
fn test() {
   let env = Env::from_ledger_snapshot_file("snapshot.json");

   let pause_id = Address::from_str(&env, "C...");
   let contract_id = env.register(
       IncrementContract,
       IncrementContractArgs::__constructor(&pause_id),
   );
   let client = IncrementContractClient::new(&env, &contract_id);

   assert_eq!(client.increment(), 1);
}

How to Write Tests

Fuzzing

Fuzzing is the process of providing random data to the program to identify unexpected behavior, such as crashes and panics. Fuzz tests can also be written as property tests that go a step further and assert on some property remaining true regardless of the input. For example, a token contract may have a property that all balances never become negative, and a property test can use a fuzzing toolset to test a large variety of inputs to test that it remains true.

Below is an example of a fuzz test that tests that repeated calls to an increment contract return a value that is greater than the last, and the only errors seen are errors defined by the contract.

#![no_main]
use libfuzzer_sys::fuzz_target;
use soroban_increment_with_fuzz_contract::{IncrementContract, IncrementContractClient};
use soroban_sdk::{
   testutils::arbitrary::{arbitrary, Arbitrary},
   Env,
};

#[derive(Debug, Arbitrary)]
pub struct Input {
   pub by: u64,
}

fuzz_target!(|input: Input| {
   let env = Env::default();
   let id = env.register(IncrementContract, ());
   let client = IncrementContractClient::new(&env, &id);

   let mut last: Option<u32> = None;
   for _ in input.by.. {
       match client.try_increment() {
           Ok(Ok(current)) => assert!(Some(current) > last),
           Err(Ok(_)) => {} // Expected error
           Ok(Err(_)) => panic!("success with wrong type returned"),
           Err(Err(_)) => panic!("unrecognised error"),
       }
   }
});

To learn more about the example and writing fuzz tests and property tests, see the how-to guide on Fuzzing.

How to Write Tests

Differential Tests

Differential testing is the testing of two things to discover differences in their behavior. The goal is to prove that the two things behave consistently and that they do not diverge in behavior.

This strategy is effective when building something new that should behave like something that already exists. That could be a new version of a contract, or it could be the same contract built with a new version of an SDK or other dependency, or it could be a refactor that expects no functional changes.

When differential testing against a deployed contract, use the stellar-cli to fetch the contract from Mainnet or Testnet.

$ stellar contract fetch --id C... –-out-file pause.wasm

Below is an example of a differential test that tests that a deployed version of a contract and a new version of the contract behave the same and emit the same events.

#![cfg(test)]
use crate::{IncrementContract, IncrementContractClient};
use soroban_sdk::{testutils::Events as _, Env};

mod deployed {
   soroban_sdk::contractimport!(file = "contract.wasm");
}

#[test]
fn differential_test() {
   let env = Env::default();
   assert_eq!(
       // Baseline – the deployed contract
       {
           let contract_id = env.register(deployed::WASM, ());
           let client = IncrementContractClient::new(&env, &contract_id);
           (
               // Return Values
               (
                   client.increment(),
                   client.increment(),
                   client.increment(),
               ),
               // Events
               env.events.all(),
           )
       },
       // Local – the changed or refactored contract
       {
           let contract_id = env.register(IncrementContract, ());
           let client = IncrementContractClient::new(&env, &contract_id);
           (
               // Return Values
               (
                   client.increment(),
                   client.increment(),
                   client.increment(),
               ),
               // Events
               env.events.all(),
           )
       },
   );
}

To learn more about writing differential tests, see the how-to guide on Differential Tests.

How to Write Tests

Differential Tests with Test Snapshots

All contracts built with the Soroban Rust SDK have a form of differential testing built-in and enabled by default. The Soroban Rust SDK generates a test snapshot at the end of every test that involves the Soroban Environment. The snapshot is written to a JSON file in the test_snapshots directory.

Commit the snapshots to version control and on future changes the test snapshots will change if the outcome changed. If the test snapshot changes at times you don’t expect, such as updating an SDK or refactoring, it may be a signal that observable functionality of the contract has changed too.

#![cfg(test)]
use soroban_sdk::Env;

use crate::{Contract, ContractClient};

#[test]
fn test_abc() {
   let env = Env::default();
   let contract_id = env.register_contract(None, Contract);
   let client = ContractClient::new(&env, &contract_id);

   assert_eq!(client.increment(), 1);

   // At the end of the test the Env will automatically write a test snapshot
   // to the following directory: test_snapshots/test_abc.1.json
}

To learn more about how to use test snapshots, see the how-to guide on Differential Tests with Test Snapshots.

How to Write Tests

Mutation Testing and Code Coverage

Measuring code coverage uses tools to identify lines of code that are and aren't executed by tests. Code coverage stats can give us an idea of how much of a contract is actually tested by its tests.

One way to identify code not tested is to use code coverage tools to count and identify lines of code that are not executed by tests.

In unit and integration tests the Rust tool cargo-llvm-cov will report code coverage stats and identify lines. See Code Coverage.

In fuzz tests, the Rust tool cargo-fuzz contains coverage functionality. See Fuzzing.

Another way to identify code not tested is with mutation testing. Mutation testing is making changes to a program to identify changes that can be made that don't get caught by tests.

The cargo-mutants Rust tool can be used to mutation test contracts. See Mutation Testing.

Conclusion

Writing tests in Stellar contracts is fully integrated into the Soroban Rust SDK and comes with the real Soroban Environment, so the contract isn’t being tested against an emulator or simulator, it’s the real deal.

Bulk up on tests with the following how-to guides: