Integration test a contract in Rust#
This guide describes how to write integration tests in Rust for your smart contracts using the Concordium smart contract testing library.
Note
Unit testing your contracts with the test_infrastructure
has been deprecated in favor of concordium-smart-contract-testing
.
To migrate your contracts and tests see Migrate contracts for concordium-std 8.1.
You can read the old documentation for unit testing here if you are not ready to migrate your contracts.
The library allows you to test individual contracts in isolation, but, notably, also interactions between multiple contracts. When running the tests, they are executed locally on the exact contract code that is deployed on the chain, and using the same execution engine that the nodes use. V0 smart contracts are not supported, but all V1 smart contract features are, including upgrades, and it is also possible to see the energy usage of your contracts. This allows you to refactor and optimize your contracts for speed and efficiency with greater ease and confidence.
The high-level process of adding integration tests to your existing smart contract project is as follows:
Add the testing library to your
Cargo.toml
file and use Rust edition2021
:[package] # ... edition = "2021" [dev-dependencies] concordium-smart-contract-testing = "1.0"
By putting it under
dev-dependencies
, it is only included for tests. You must use edition2021
or greater as that is a requirement for the testing library.Write tests in files residing in
my-project/tests/
. Example content:use concordium_smart_contract_testing::*; #[test] fn my_test() { ... let module = module_load_v1("concordium-out/module.wasm.v1").unwrap(); ... }
where you specify the path to the Wasm file built in the next step.
Run your tests with
cargo concordium test --out concordium-out/module.wasm.v1
This command also builds the Wasm module and outputs the Wasm module to the specified path.
With a high-level understanding of the workflow, you are ready to write the actual tests. Each section below covers part of a typical integration test.
Creating a chain#
The primary construct used for testing is the Chain
type, which you should only create one of per test.
It represents the blockchain and has methods for creating accounts and deploying and working with contracts.
Use the Chain::new
method to create a chain with default settings.
#[test]
fn my_test() {
let mut chain = Chain::new();
}
You can also create a Chain
with custom values for exchange rates and the block time using a builder pattern where you configure a number of options and finish off by calling the build
method.
#[test]
fn my_test() {
let mut chain = Chain::builder()
// Specify a block time.
.block_time(Timestamp::from_timestamp_millis(10000))
// Specify the Euro to energy exchange rate.
.euro_per_energy(ExchangeRate::new_unchecked(1, 50000))
// Specify the microCCD to Euro exchange rate.
.micro_ccd_per_euro(ExchangeRate::new_unchecked(50000, 1))
// Try to build the Chain using the configured parameters.
.build()
// The parameters might be invalid, so it returns a `Result` which is unwrapped.
.unwrap();
}
It is even possible to connect to an external Concordium node and get the exchange rates or block time from it.
#[test]
fn my_test() {
let mut chain = Chain::builder()
// Connect to the public testnet node on its gRPCv2 port 20000.
.external_node_connection(Endpoint::from_static(
"http://node.testnet.concordium.com:20000",
))
// Specify which block to use for queries. If omitted, the last final block will be used.
.external_query_block(
"b22466d87a273be64df283f8db0435aab945b2dd54f4df07b82fd02418be0c96"
.parse()
.unwrap(),
)
// Specify that the exchange rates and block time should
// be set to match the queried values from the node.
.euro_per_energy_from_external()
.micro_ccd_per_euro_from_external()
.block_time_from_external()
// Try to build the Chain using the configured parameters.
.build()
// The parameters might be invalid, so it returns a `Result` which is unwrapped.
.unwrap();
}
When getting values from an external node, it will use the same block for all the queries.
The block will either be the one you specify with external_query_block
or the last final block at the time.
Also note that you can mix and match configuration options, for example by specifying your own block time while using the microCCD to Euro exchange rate from an external node.
You can find all the configuration options including examples in the documentation for ChainBuilder
.
Creating accounts#
The next step is to create one or more Account
entities and add them to the chain.
Accounts have multiple constructors that allow you to specify more details.
The simplest one is Account::new
, which takes an AccountAddress
and a total balance of the account.
Once constructed, use the create_account
method to add it to the chain.
This step is important, as simply constructing an Account
does not make the chain aware of it.
#[test]
fn my_test() {
let mut chain = Chain::new();
let account_address = AccountAddress([0u8;32]);
let account = Account::new(account_address, Amount::from_ccd(123));
chain.create_account(account);
}
The account address is [0u8;32]
, which is a Rust shorthand for creating a byte array with all zeroes.
Also note that account addresses are aliases of one another if they match on the first 29 bytes.
Creating accounts [0u8;32]
, [1u8;32]
, [3u8;32]
, etc. will ensure that they aren’t aliases, which is what you want in most cases.
It is important to set an appropriate balance for the account, as executing transactions, for example deploying modules, on the chain deducts CCD from the account’s balance, and running out of CCD gives you an error.
You can check the account balance with account_balance_available
after each of the transactions you execute in the following sections to see that the transaction fees are subtracted from the balance.
Note
It is also possible to use real account addresses from the chain, which are shown in base58 encoding, but still represent 32 bytes. For example:
let my_chain_account: AccountAddress =
"3kBx2h5Y2veb4hZgAJWPrr8RyQESKm5TjzF3ti1QQ4VSYLwK1G".parse().unwrap();
Deploy modules#
Deploying smart contract modules is a two-step process.
First, you load the module with the function module_load_v1
, then you deploy it to the chain with the method module_deploy_v1
.
Loading as a separate step allows you to reuse the loaded module across multiple tests for efficiency.
The module to load must be a wasm
module compiled with cargo concordium build
or, if using cargo concordium version 2.9.0+, cargo concordium test --out path/to/wasm/module
.
Using the test command is ideal, as that will both compile the module and run the tests.
By compiling the module every time, you ensure that the tests run on the newest version of your code.
For example, for cargo concordium test --embed-schema --out my_module.wasm.v1
, you write:
#[test]
fn my_test() {
// .. Lines omitted for brevity
let module = module_load_v1("my_module.wasm.v1").unwrap();
}
Loading a module can fail in multiple ways, for example because it is missing or corrupt, so the function returns Result
, which you unwrap
here because you know it will succeed.
If it doesn’t succeed, the test will fail and you can fix your mistake.
You can also use .expect("Loading module should succeed")
instead to provide better error messages on failures, but the remainder of this guide will use unwrap
for brevity.
With the module loaded, you are ready to deploy it.
Since this is a transaction, it involves an account that pays for the cost.
Additionally, you must specify a Signer
with a number of keys.
This mimics the behavior on the real chain, where one or more keys must sign a transaction.
The only observable difference between using one or more keys is the cost of the transaction, where each extra key increases the cost slightly.
#[test]
fn my_test() {
let mut chain = Chain::new();
let account_address = AccountAddress([0u8;32]);
// .. Lines omitted for brevity
let module = module_load_v1("my_module.wasm.v1").unwrap();
let deployment = chain
.module_deploy_v1(
Signer::with_one_key(),
account_address,
module)
.unwrap();
}
Since deployment can fail, for example if the account doesn’t have sufficient CCD to cover the cost, the method returns Result
, which is unwrapped.
The struct returned has information about the energy used, transaction fee, and a ModuleReference
that you use for initializing contracts.
Note
If you are familiar with the anyhow crate, you can use it to replace unwrap
/ expect
with the more ergonomic ?
operator.
For example:
#[test]
fn my_test() -> anyhow::Result<()> {
let mut chain = Chain::new();
let account_address = AccountAddress([0u8;32]);
// .. Lines omitted for brevity
let module = module_load_v1("my_module.wasm.v1")?;
let deployment = chain
.module_deploy_v1(
Signer::with_one_key(),
account_address,
module)?;
Ok(())
}
Initialize contracts#
With the module deployed, you are ready to initialize a contract with the chain method contract_init
.
The method has the following parameters:
A
Signer
to sign the transaction.An
AccountAddress
, which pays for the transaction.A maximum
Energy
that the contract initialization can use.A
ModuleReference
, which you got from the deployment section above.An
OwnedContractName
that specifies which contract in the module you want to initialize. Contract names are prefixed withinit_
on the chain to distinguish them from receive functions (entrypoints). You constuct it with eitherOwnedContractName::new
, which checks the validity and returns aResult
, orOwnedContractName::new_unchecked
, which performs no checking.An
OwnedParameter
, which is a wrapper over a byte array that you construct with one of the following methods:OwnedParameter::from_serial
, which serializes the input and checks that the parameter size is valid,TryFrom::<Vec<u8>>::try_from(..)
, which also checks the parameter size,or
OwnedParameter::empty
, which always succeeds.
An
Amount
to send to the contract.
#[test]
fn my_test() {
// .. Lines omitted for brevity
let initialization = chain
.contract_init(
Signer::with_one_key(),
account_address,
Energy::from(10000),
InitContractPayload {
mod_ref: deployment.module_reference,
init_name: OwnedContractName::new_unchecked("init_my_contract".to_string()),
param: OwnedParameter::from_serial(&"my_param").unwrap(),
amount: Amount::zero(),
}
)
.unwrap();
}
Initialization can fail for several different reasons, and thus returns a Result
, which is unwrapped.
The struct returned contains information about the energy used, transaction fee, contract events (logs) produced, and a ContractAddress
that you use for updating the contract in the next section.
Update contract entrypoints#
With the contract initialized, you are ready to update it with the chain method contract_update
, which has the following parameters:
A
Signer
to sign the transaction.An
invoker
of typeAccountAddress
, which pays for the transaction.An
sender
of typeAddress
, which can either be anAccountAddress
or aContractAddress
.The main utility of the parameter is that it allows you to test internal calls in your contracts directly.
For example, if you have a more complex scenario where an account calls contract
A
which internally calls contractB
.In this case you can test the complete integration by calling
A
.But you can also test
B
as its own unit by calling it directly and specifyingA
as thesender
.
A maximum
Energy
that the contract update can use.A
ContractAddress
, which you got from the initialization section above.An
OwnedReceiveName
that specifies which receive name in the module you want to initialize.A “receive name” is the contract name concatenated with the entrypoint name and a dot in between.
In this example, the contract
my_contract
and the entrypointmy_entrypoint
combine to become the receive namemy_contract.my_entrypoint
.You construct it with either
OwnedReceiveName::new
, which checks the format and returns aResult
, orOwnedReceiveName::new_unchecked
, which performs no checks.
An
OwnedParameter
, which is a wrapper over a byte array that you construct with one of the following methods:OwnedParameter::from_serial
, which serializes the input and checks that the parameter size is valid,TryFrom::<Vec<u8>>::try_from(..)
, which also checks the parameter size,or
OwnedParameter::empty
, which always succeeds.
An
Amount
to send to the contract.
#[test]
fn my_test(){
// .. Lines omitted for brevity.
let update = chain
.contract_update(
Signer::with_one_key(),
account_address,
Address::Account(account_address),
Energy::from(10000),
UpdateContractPayload {
address: initialization.contract_address,
receive_name: OwnedReceiveName::new_unchecked("my_contract.my_entrypoint".to_string()),
message: OwnedParameter::from_serial(&42u8).unwrap(),
amount: Amount::from_ccd(100),
}
)
.unwrap();
}
Updates can also fail, and thus return a Result
, which is unwrapped here.
The struct returned on success contains information about the energy used, the transaction fee, the return value from the entrypoint, a vector of ContractTraceElement
, whether the contract state has changed, and the contract’s new balance.
The trace elements describe calls to other contracts, transfers to accounts, module upgrades, and whether each of these actions succeeded or not.
A method related to contract_update
is contract_invoke
, which also executes an entrypoint, but without it being a transaction.
Invoke contract entrypoints#
The method contract_invoke
is similar to contract_update
in that it allows you to execute contract entrypoints.
The difference is that an invoke is not a transaction and is not persisted, so contract states, account balances, etc. remain unchanged after the call.
For seasoned Rust programmers, that is easily seen by its function signature, which takes an immutable reference to the chain (&self
), as opposed to the mutable reference (&mut self
) used in the update method.
The primary purpose of contract_invoke
is to get the return value of an entrypoint.
It has all the same parameters as a contract update, except for the signer
, which is only needed for transactions.
While the result of the invocation isn’t saved on the chain, all the entities referred, e.g. contracts and accounts, must still exist in the chain
.
In this example, you get the result of calling the entrypoint called my_view
with the contract itself as the sender
.
#[test]
fn my_test(){
// .. Lines omitted for brevity.
let invoke = chain
.contract_invoke(
account_address,
Address::Contract(initialization.contract_address),
Energy::from(10000),
UpdateContractPayload {
address: initialization.contract_address,
receive_name: OwnedReceiveName::new_unchecked("my_contract.my_view".to_string()),
message: OwnedParameter::empty(),
amount: Amount::zero(),
}
)
.unwrap();
}
This concludes the introduction to the primary methods on the Chain
type.
Next section covers how to access the common data needed for assertions in smart contract integration tests.
Data for assertions#
This section covers how to get the data most commonly used for assertions in smart contract integration tests.
Return values#
Both contract_update
and contract_invoke
have return values when they succeed, or if they fail in a specific way.
On success, you can access the return value directly, for example update.return_value
, which is a byte array, Vec<u8>
.
But the methods can fail in multiple ways, for example if the contract runs out of energy or it panics, and the return value is only available when the contract rejects on its own.
The helper method return_value
on the ContractInvokeError
struct tries to extract the return value and returns an Option<Vec<u8>>
.
It is common to deserialize the return values into structered data and thus both the success and error types have helper methods called parse_return_value
, which returns a Result<T, ParseError>
, where T
is the type you want to parse.
For example:
let chain = Chain::new();
// .. Creation of accounts and contracts omitted for brevity.
// On success:
let update = chain.contract_update(..).unwrap();
let returned_string: String = update.parse_return_value().unwrap();
assert_eq!(returned_string, "My expected string");
// On error:
let update_error = chain.contract_update(..).unwrap_err();
let returned_contract_error: MyContractError = update_error.parse_return_value().unwrap();
assert_eq!(returned_contract_error, MyContractError::NotOwner);
Balances#
You can query the balance of accounts and contracts with the Chain
.
Since accounts can stake part of their balance and also receive transfers with a schedule, their balance has three parts.
The total balance, part of which might be staked or locked.
The staked amount of CCD.
The locked amount which is unreleased, but can be used for staking.
The method account_balance
returns all three elements, and the method account_balance_available
returns only the amount of CCD available for making transactions and transfers, i.e. the part which isn’t staked and/or locked.
Contracts only have one balance which you can query with contract_balance
.
All the balance methods return an Option
as the account or contract might not exist.
Example:
let chain = Chain::new();
// .. Creation of accounts and contracts omitted for brevity.
let account_balance = chain.account_balance_available(account_address);
let contract_balance = chain.contract_balance(initialization.contract_address);
assert_eq!(account_balance, Some(Amount::from_ccd(111)));
assert_eq!(contract_balance, Some(Amount::from_ccd(22)));
Contract trace elements#
The contract trace elements describe the contract calls, transfers to accounts, module upgrades, and the success of these during a contract_update
or contract_invoke
.
The struct returned on success from these calls has an effective_trace_elements
method which returns a list of all the effective elements in the order that they occurred.
To understand what effective refers to, an example is useful:
Contract
A
calls contractB
B
then calls contractC
Then
B
fails
A
returns successfully
In this case, the internal call from B
to C
is not effective as it has no effect; the only thing that matters for the outcome is that B
failed and everything B
did is rolled back as if it never occurred.
However, in a testing and debugging scenario, it can be useful to see all the calls, effective or not.
To do this, the returned struct has a field called trace_elements
, which is a list of DebugTraceElement
.
Debug trace elements include information about the failed traces, e.g. the call from B
to C
in the example above, along with additional information such as the energy used when each element was produced.
Multiple helper methods exist for extracting information from the debug trace elements. To view the effective trace elements grouped per contract address, use the method trace_elements
.
Example:
let chain = Chain::new();
// .. Creation of accounts and contracts omitted for brevity.
let update = chain.contract_update(..).unwrap();
let elements_per_contract = update.trace_elements();
// No events occured for contract <123, 0>.
assert_eq!(elements_per_contract.get(&ContractAddress(123,0))), None);
// Check that the contract was updated.
assert_eq!(elements_per_contract[&initialization.contract_address], [
ContractTraceElement::Updated {
data: InstanceUpdatedEvent {
address: contract_address,
amount: Amount::zero(),
receive_name: OwnedReceiveName::new_unchecked("my_contract.my_entrypoint".to_string()),
contract_version: concordium_base::smart_contracts::WasmVersion::V1,
instigator: Address::Account(account_address),
message: OwnedParameter::empty(),
events: Vec::new(),
},
}
])
Writing out all the fields in the trace elements can be cumbersome, so using a matches!
macro can be beneficial, as it allows you to use the pattern matching syntax for extracting only that parts you need.
This example checks that the correct types of trace elements are there (Interrupted
, Upgraded
, Resumed
, Updated
), and that the module references of the upgrade are correct.
assert!(matches!(update.trace_elements[..], [
ContractTraceElement::Interrupted { .. },
ContractTraceElement::Upgraded { from, to, .. },
ContractTraceElement::Resumed { .. },
ContractTraceElement::Updated { .. },
] if from == old_module_reference && to == new_module_reference));
Transfers to accounts#
One of the trace elements from the previous section, Transferred
, describes a transfer from an contract to an account.
With the helper method account_transfers
, you can get an iterator over all transfers to accounts in the order that they occured in a single call of contract_update
or contract_invoke
.
Example:
let chain = Chain::new();
// .. Creation of accounts and contracts omitted for brevity.
let update = chain.contract_update(..).unwrap();
// Collect the iterator into a vector.
let account_transfers: Vec<Transfer> = update.account_transfers().collect();
// Check that a single transfer of 10 CCD occurred.
assert_eq!(
account_transfers, [Transfer {
from: ContractAddress::new(1, 0),
amount: Amount::from_ccd(10),
to: AccountAddress([0u8;32]),
}]);