Testing with Ledger Snapshot
Ledger snapshots can be used to test smart contracts with a local copy of the ledger’s entries. Learn how to create a ledger snapshot and use it for smart contract testing in a few simple steps. The examples here will cover the following use cases:
- Read stored value from the ledger data
- Read stored value by calling a smart contract function
- Read the balance of an account
1. Create a default project
Before getting into creating a ledger snapshot and getting into the testing, let’s first create a simple smart contract that can be used for the test examples.
Get started by creating the default Hello World contract:
stellar contract init <project_name>
The smart contract has two functions: set_value()
for storing a value, and get_value()
for reading the value from storage. Replace the default hello()
function with these two functions:
#![no_std]
use soroban_sdk::{contract, contractimpl, vec, Env, String, Vec};
#[contract]
pub struct Contract;
#[contractimpl]
impl Contract {
pub fn set_value(env: Env, key: String, value: u32) {
env.storage().persistent().set(&key, &value);
}
pub fn get_value(env: Env, key: String) -> u32 {
env.storage().persistent().get(&key).unwrap_or(0)
}
}
Since we want to write a test that reads data stored on the ledger, or more precisely, in a snapshot of the ledger, we must build, deploy, and invoke the smart contract.
Build contract
Use the Stellar CLI to build the contract:
stellar contract build
Deploy contract
Next, we deploy the contract to Testnet:
stellar contract deploy \
--wasm target/wasm32v1-none/release/hello_world.wasm \
--source alice \
--network testnet
The deploy command will return the contract’s address (e.g., CB5QCALXDP2N6H473AQBNIFEAPCNHWCIWOASRNGTHCSC4WNC3SOROBAN
), which will be used in the following steps.
####Invoke contract
Finally, we invoke the set_value() function to store a value on the Testnet ledger:
stellar contract invoke \
--id CB5QCALXDP2N6H473AQBNIFEAPCNHWCIWOASRNGTHCSC4WNC3SOROBAN \
--source alice \
--network testnet \
-- \
set_value \
--key "count" --value 123456
Now we have a working, deployed smart contract function that has stored a value on the ledger. You can verify the value by invoking the get_value()
function.
2. Create a ledger snapshot
A snapshot of the ledger can be created using the Stellar CLI. The CLI command allows you to customize and limit the scope of the snapshot, since it’s most likely not necessary for you to create a snapshot of all ledger entries. See the documentation for full details about how to limit the snapshot.
For the examples used here, we want to limit the ledger snapshot to include entries related to:
- The smart contract
- The user
alice
- The native token address (XLM)
From the smart contract project root, create the ledger snapshot with this command:
stellar snapshot create \
--output json \
--network testnet \
--address CB5QCALXDP2N6H473AQBNIFEAPCNHWCIWOASRNGTHCSC4WNC3SOROBAN \
--address alice \
--address CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC
The command will store the snapshot as a JSON file named snapshot.json
.
This is a sample of the snapshot, which shows the value stored when the contract function set_value()
was invoked:
{
"protocol_version": 23,
"sequence_number": 801343,
"timestamp": 0,
"network_id": "cee0302d59844d32bdca915c8203dd44b33fbb7edc19051ea37abedf28ecd472",
"base_reserve": 1,
"min_persistent_entry_ttl": 0,
"min_temp_entry_ttl": 0,
"max_entry_ttl": 0,
"ledger_entries": [
...
[
{
"contract_data": {
"contract": "CB5QCALXDP2N6H473AQBNIFEAPCNHWCIWOASRNGTHCSC4WNC3SOROBAN",
"key": {
"string": "count"
},
"durability": "persistent"
}
},
[
{
"last_modified_ledger_seq": 801330,
"data": {
"contract_data": {
"ext": "v0",
"contract": "CB5QCALXDP2N6H473AQBNIFEAPCNHWCIWOASRNGTHCSC4WNC3SOROBAN",
"key": {
"string": "count"
},
"durability": "persistent",
"val": {
"u32": 123456
}
}
},
"ext": "v0"
},
4294967295
]
],
]
...
}
We can now start to write a test script that loads the snapshot into the test environment.
If the smart contract function set_value()
is invoked again, run the stellar snapshot create
command again to ensure the snapshot reflects the ledger.
3. Read stored values and balances
To illustrate how smart contract tests can utilize the ledger snapshot data, we will read the stored value in two different ways and get the balance of the user alice
.
Loading snapshot data
As a starting point, we use the test file test.rs
included in the default smart contract project we created and modified. Since the hello()
function was removed and replaced with the functions to store and get a value, the test for hello()
can also be removed.
First, the ledger snapshot data is loaded into the environment:
#[test]
fn test() {
let env = Env::from_ledger_snapshot_file(
"../../snapshot.json",
;
}
Read stored value from the ledger
In this first example, we read the stored value directly from the ledger using env.storage()
. The method env.as_contract()
allows us to execute code in the context of a given contract ID. This means we can execute code as if we were inside the contract.
First, we define the contract ID for the test, which is the contract ID returned when the smart contract was deployed.
Then env.as_contract()
is used to get the value from storage, which means from the ledger. When the contract function set_value()
was invoked, we used count
as the key, so the same key is used to get the value.
#[test]
fn test() {
let env = Env::from_ledger_snapshot_file(
"../../snapshot.json",
);
let contract_id = Address::from_str(
&env,
"CB5QCALXDP2N6H473AQBNIFEAPCNHWCIWOASRNGTHCSC4WNC3SOROBAN"
);
env.as_contract(&contract_id, || {
let val: u32 = env.storage().persistent().get(
&String::from_str(&env, "count")
).unwrap_or(0);
assert_eq!(val, 123456);
});
}
Now run the test:
cargo test
The expected value is 123456
, and the value is checked by assert_eq!()
. If the test doesn’t pass, the test will panic.
Read stored value with contract function
The more typical way of reading a stored value in tests is to call the contract function that reads the value. The test for this approach is the same as if the default environment were loaded, except we don’t need to register the contract in the test; we use a real contract ID, like in the previous example, because in the ledger snapshot, the value is tied to the specific contract ID.
#[test]
fn test() {
let env = Env::from_ledger_snapshot_file(
"../../snapshot.json",
);
let contract_id = Address::from_str(
&env,
"CB5QCALXDP2N6H473AQBNIFEAPCNHWCIWOASRNGTHCSC4WNC3SOROBAN"
);
let client = ContractClient::new(&env, &contract_id);
let value: u32 = client.get_value(&String::from_str(&env, "count"));
assert_eq!(value, 123456);
}
This example shows there are only minor differences between testing contract functions with the default environment and the snapshot environment.
Read account balance
In this last example, we want to read the balance of an account. It can be the balance of a user account or of a smart contract - any account that can hold a balance. In this example, we check the balance of the user account alice
.
In this example, we assume that alice
already has a balance in XLM, and since the examples are based on Testnet, the alice
account was funded by FriendBot if this guide was followed while creating the account. The address of alice
can be looked up by running this command:
stellar keys address alice
Tokens, such as XLM, are wrapped in a contract interface called the Stellar Asset Contract (SAC). This interface provides a convenient way to query an account’s balance. The first step of getting the account balance is to create a contract client for XLM tokens.
The SAC address for XLM is a reserved address and will be the same for all projects. USDC and other assets will have another SAC address, but they do not change. We use the SAC address to define a client using TokenClient
.
Then we define the address of the account we want to look up the balance of, and call client.balance()
with the account address as the argument.
#[test]
fn test() {
let env = Env::from_ledger_snapshot_file(
"../../snapshot.json",
);
let client = TokenClient::new(
&env,
&Address::from_string(&String::from_str(
&env,
"CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC"
))
);
let account = Address::from_string(&String::from_str(
&env,
"GDKOYSOKU4TNHGGR763NOL6VNY52WKJL3XI33TOUKDNF4AZN7SOROBAN"
));
assert_eq!(client.balance(&account), 99602342515);
}
The account can be a user, like in this case, but it can also be a contract holding assets - like a smart contract wallet.
Guides in this category:
📄️ Unit Tests
Unit tests are small tests that test smart contracts.
📄️ Mocking
Mocking dependency contracts in tests.
📄️ Test Authorization
Write tests that test contract authorization.
📄️ Test Events
Write tests that test contract events.
📄️ Integration Tests
Integration testing uses dependency contracts instead of mocks.
📄️ Fork Testing
Integration testing using mainnet data.
📄️ Fuzzing
Fuzzing and property testing to find unexpected behavior.
📄️ Differential Tests
Differential testing detects unintended changes.
📄️ Differential Tests with Test Snapshots
Differential testing using automatic test snapshots.
📄️ Mutation Testing
Mutation testing finds code not tested.
📄️ Code Coverage
Code coverage tools find code not tested.
📄️ Testing with Ledger Snapshot
Use ledger snapshots to test contracts with ledger data