Developers
Author
Leigh McCulloch
Publishing date
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:
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:
Good testing tools and strategies make our skepticism much more rigorous than we alone are capable of.
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:
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 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 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 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
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 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 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
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
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.
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: