Introduction to writing tests for Secret Smart Contracts
Tests help ensure the quality, stability, and correctness of your Secret smart contracts. A single bug or vulnerability in a smart contract can lead to consequences such as the loss of funds or the compromise of sensitive information. By thoroughly testing Secret smart contracts with unit tests, developers can identify and address issues early in the development process, reducing the risk of errors and vulnerabilities for contracts that are deployed to Mainnet. Let's begin by learning about unit tests in Secret Contracts. 🎉
How to write Unit Tests for Secret Contracts
To ensure that Secret smart contract code is reliable and free of errors, it's important to test it thoroughly. One effective way to achieve this is through unit testing.
Unit testing involves breaking down a program into its smallest components or units and testing each one in isolation. By testing each unit separately, developers can pinpoint the root cause of any errors or bugs and fix them quickly.
In Rust, unit testing is supported by the Rust testing framework, which provides a set of macros and utilities for writing and running tests. Rust's testing framework is built into the standard library and is designed to:
Set up any needed data or state.
Run the code you want to test.
Assert the results are what you expect.
Secret Contracts utilize the Rust testing framework as well as additional cosmwasm-std
utilities, such as mock_dependencies
and mock_env
functions.
We will explore the basics of unit testing for Secret Contracts, including how to write and run tests, organize test code, and use test-driven development (TDD) to ensure code quality. With this foundation, you'll be well on your way to writing robust and reliable Secret contracts.
For a practical understanding of writing unit tests for Secret Contracts, navigate to the contract.rs
file of the Secret Counter template. Here you will find 3 unit tests:
proper_initialization()
increment()
reset()
Let's examine proper_initialization()
to understand how unit tests check for correctness of various execution messages in Secret Smart Contracts.
Review line 71 of the contract.rs file, which contains the proper_initialization()
test and tests whether or not the counter contract is properly instantiated. Let's break this function down line-by-line so we have a thorough understanding of each piece of the code.
The use super::*;
line imports all the modules from the parent module (ie everything that is imported at the top of the contract.rs
file). This allows the test module to access contract.rs
's imports and test its functionality.
The use cosmwasm_std::testing::*;
line imports all the testing utilities from the cosmwasm_std
crate.
The #[cfg(test)]
annotation on the tests module tells Rust to compile and run this test code only when you run cargo test
, and not when you run cargo build
. This saves compile time when you only want to build the library and saves space in the resulting compiled artifact because the tests are not included.
The mod tests { }
block defines a new module named tests
. This is a conventional way of organizing test functions in Rust. Tests can be run with cargo test
.
If you run cargo test
, the terminal will return 3 passing tests!
Since Rust's testing framework, Cargo, runs tests in parallel by default, this can lead to nondeterministic behavior if the code being tested is not designed to handle concurrent execution safely (for example, keymap
with iterator
and AppendStore
). The immediate fix for this issue is to enforce the tests to run serially, thus avoiding the problems caused by concurrent access and modification. This can be achieved by using the following command:
This command tells Cargo to run the tests with only one thread, effectively serializing test execution and preventing concurrent access to shared resources.
Mock dependenices
The let mut deps = mock_dependencies();
line creates a new set of mock dependencies, which simulate the necessary dependencies of a smart contract in a testing environment. These dependencies include storage, a message handler, and an API client.
The let info = mock_info("creator", &[Coin { denom: "earth".to_string(), amount: Uint128::new(1000), }], );
line creates a new mock transaction context, which simulates the context in which a transaction would be executed on the blockchain. This context includes information about the sender of the transaction (in this case, the creator), as well as any tokens (in this case, a single "earth" token with a balance of 1000).
Taken together, these lines set up a mock environment in which the counter contract can be tested. The proper_initialization()
function can now perform its tests using these mock dependencies and transaction context.
Init_msg
The let init_msg = InstantiateMsg { count: 17 };
line creates a new InstantiateMsg
struct with an initial count value of 17. This message is used to initialize the counter contract's state.
Remember, we defined an instantiate message in msg.rs, which is why this struct is required to instantiate the contract. If there was no instantiation struct with a starting count, the test would fail.
The let res = instantiate(deps.as_mut(), mock_env(), info, init_msg).unwrap();
line instantiates the smart contract with the given dependencies that we defined above, as well as the transaction context, and instantiation message. This initializes the smart contract's state and returns a Response
struct that contains any messages that the contract emits during initialization.
The assert_eq!(0, res.messages.len());
line checks that no messages were emitted during smart contract instantiation. If any messages were emitted, this assertion would fail and the test would panic.
The let res = query(deps.as_ref(), mock_env(), QueryMsg::GetCount {}).unwrap();
line queries the smart contract's state using the QueryMsg::GetCount
message. This retrieves the current count value stored in the smart contract's state.
The let value: CountResponse = from_binary(&res).unwrap();
line deserializes the query response data (which is in binary format) into a CountResponse
struct. This struct represents the result of the query and contains the current count value.
Finally, the assert_eq!(17, value.count);
line checks that the current count value retrieved from the smart contract's state is equal to the expected value of 17. If the current count value is not equal to 17, this assertion would fail and the test would panic.
With this information, examine the other testing functions in the counter contract. You should have all of the resources you need to start writing your very own unit tests! Now let's learn about Multitests! ✨
We recommend when writing Secret Contracts to leverage a CI platform to create a continuous integration flow for your contract.
The two types of tests to focus on:
Unit Tests - tests that are executed only in the context of the contract itself
Integration Tests - tests that include executing contract flows on a running chain (LocalSecret or Secret testnet)
Our platform of choice is GitHub Actions. GitHub Actions is a continuous integration and continuous delivery (CI/CD) platform that allows you to automate your build, test, and deployment pipeline. You can create workflows that build and test every pull request to your repository, or deploy merged pull requests to production.
Here are examples of how to implement these flows in your own contracts:
Integration Tests - https://github.com/scrtlabs/SecretJack/blob/master/.github/workflows/Integration.yml\
Note that in this example the unit test flow executes the main command make unit-test
, while the integration tests initialize a LocalSecret instance, and execute a Typescript file that handles deployment, initialization and execution of the test logic. You can find that file here.
Uint128
is a data structure designed to work with usigned 128-bit integers.
If you are familiar with Rust, you might know that it has it's own native primitive - u128
. Uint128
differs from u128
in that it is a string encoded integer, rather than the traditional little/big-endian.
Simliarly, cosmwasm-std
also has Uint64
and Uint256
and all of the following applies there as well.
Uint128
is a thin wrapper around u128
that uses strings for JSON encoding/decoding, such that the full u128
range can be used for clients that convert JSON numbers to floats, like JavaScript and jq (source).
If you are familiar with Messages, you already know that most of the time we will use serde to deserialize them from JSON (if not, you should read on contract entrypoints and the concept of Messages). Output will often be serialized in the same way.
In general, JSON implementations usually accept [-(2^53)+1,(2^53)-1]
as an acceptable range for numbers. So if we need more than that (for example for 64(unsigned), 128 and 256 numbers) we'll want to use a string-encoded numbers. That's why we'll prefer to use Uint128
in entrypoint messages, for example:
Depends on the needs of your contract, you can choose to use either Uint128
or u128
.
As a rule of thumb, most of the time you will want to store numbers as u128
rather than Uint128
.
More specifically, since Uint128
is a string encoded number the storage space it'll consume will depend on the number of digits of the number you are storing. u128
on the other hand will always take a constant amount of storage space. That's why Uint128
will be more efficient for very small numbers (and then, why use 128-bit integer to begin with?), while u128
will be more efficient for most use cases.
Example:
Floating points are a big no-no in blockchain. The reason being, and without diving into too much detail, that floating point operations might be non-deterministic, so different nodes in the blockchain might get different results and not reach consensus.
That being said, there are different ways to overcome this.
Sometimes you can absorb some lack of precision, and you can use integer division. For example, if you want to divide 1 million tokens between three addresses:
Note - integer division in Rust will always round down towards zero (source).
You can often increase integer division's precision by enlarging your inputs by some factor. When you are done with the calculations, you can shrink the number to the original scale.
Let's look at an example calculation with the following inputs:
Not scaling up before the calculation causes loss of precision:
Instead, we can first scale up the inputs by a constant SCALE_FACTOR
, and shrink them back down at the end:
Scale factors can be as big as possible, provided they don't cause overflows.
If you still need decimals in your code, a fixed-point decimals library can assist you.
There are several Rust libraries that implement fixed-point decimals, but you'd probably be best to use Cosmwasm's own Decimal
library.
Keep in mind that using fixed-point decimals comes with an overhead (both efficiency and ease of use), so you would prefer to avoid it if possible.
Sometimes even when you don't use floats directly, one of your contract's dependencies do. In that case you'd want to turn off the feature that using the floats or just replace the library altogether.
But the hard part is to identify what causes the problem to begin with. It might get pretty complicated, and probably a bit too involved for this doc, but there's this greate article that was published in the Cosmwasm blog that is very helpful for this.