Test Contract
The 3rd step is to test your contract. xSuite makes testing super simple.
xSuite provides a simulated blockchain, called simulnet, that runs on your machine and simulates the real blockchain (devnet / testnet / mainnet) while having 2 main advantages:
-
The simulnet executes transactions instantly whereas the real blockchain takes 6 seconds to execute each transaction. Therefore, a test with 10 transactions runs instantly against the simulnet whereas it would take 1 minute against the real blockchain.
-
The simulnet authorizes adminstrator operations (e.g.
setAccount
) that the real blockchain doesn't authorize. Therefore, a test can easily configure a rare state of the blockchain using the simulnet whereas it would have required many transactions using the real blockchain.
The simulnet implements the same API as the real blockchain with additional adminstrator endpoints. Therefore, if your code is able to interact with the simulnet, it will also be able to interact with the real blockchain and vice versa.
At the moment, the simulnet doesn't reproduce the exact behavior of the real blockchain. It is just a wrapper around the VM and uses the same engine as the MultiversX scenarios executor (opens in a new tab).
In addition to the simulnet, xSuite provides a JavaScript library to make super easy interacting with the simulnet.
Your first test
Tests can be written and run using the JavaScript testing library of your choice. As we prefer vitest (opens in a new tab), we are going to teach using this one.
First, install vitest
in your contract with npm install vitest
. You can then add the script "test": "vitest run"
in the package.json
of your contract. This allows you to run the tests with the command npm run test
.
A test is just a .test.ts
(or .test.js
) file in the tests
directory. Here is a basic test that can be put in tests/contract.test.ts
and that checks if a contract has been successfully deployed:
import { test, beforeEach, afterEach } from "vitest";
import { assertAccount, LSWorld, LSWallet, LSContract, e } from "xsuite";
let world: LSWorld;
let deployer: LSWallet;
let contract: LSContract;
beforeEach(async () => {
world = await LSWorld.start();
deployer = await world.createWallet();
({ contract } = await deployer.deployContract({
code: "file:output/contract.wasm",
codeMetadata: [],
gasLimit: 10_000_000,
codeArgs: [e.Str("ourString")],
}));
});
afterEach(async () => {
world.terminate();
});
test("Test", async () => {
assertAccount(await contract.getAccount(), {
balance: 0n,
hasKvs: [e.kvs.Mapper("ourStringStorageKey").Value(e.Str("ourString"))],
});
});
Let's break the file down and explain it.
Imports
The following imports are needed at the top of the file:
import { test, beforeEach, afterEach } from "vitest";
import { assertAccount, LSWorld, LSWallet, LSContract, e } from "xsuite";
We use the test
, beforeEach
and afterEach
hooks from vitest
. These are functions in which we will write our test's code as well as code that we want to run before/after each test respectively.
assertAccount
is used to assert that an address (account) on MultiversX contains the data we expect (token balances, storage, etc).
The impots LSWorld, LSWallet, LSContract
and e
will be detailed later.
"Simulated" world, wallet & contract
Next, we need to define some variables:
let world: LSWorld;
let deployer: LSWallet;
let contract: LSContract;
The LSWorld
simplifies all the interactions with the simulnet.
The LSWallet
represents a wallet on the simulnet and simplifies interactions with it.
The LSContract
represents a contract on the simulnet and simplifies interactions with it.
beforeEach & afterEach hooks (initializing variables)
Now that we have defined the imports and the variables, we need to initialize them.
beforeEach(async () => {
world = await LSWorld.start();
deployer = await world.createWallet();
({ contract } = await deployer.deployContract({
code: "file:output/contract.wasm",
codeMetadata: [],
gasLimit: 10_000_000,
codeArgs: [e.Str("ourString")],
}));
});
afterEach(async () => {
world.terminate();
});
Before the test starts, in the beforeEach
hook:
- We start the simulnet using
LSWorld.start()
and save a reference to it in theworld
variable. - We create the
deployer
wallet usingworld.createWallet()
. - We deploy our contract using
deployer.deployContract()
. This will deploy the contract with the code of the file and the specified code arguments.
After the test ends, in the afterEach
hook, we stop the simulnet using world.terminate()
.
The e
is a helper that lets you easily encode data in the format that the MultiversX blockchain understands. Here, e.Str()
will convert the data in a format that can be recognized by the ManagedBuffer
type in a Rust Smart Contract.
More details about encoding blockchain data on the Data Encoding page.
Test case
Finally, we can write our first basic test case:
test("Test", async () => {
assertAccount(await contract.getAccount(), {
balance: 0n,
hasKvs: [
e.kvs.Mapper("ourStringStorageKey").Value(e.Str("ourString")),
],
});
});
Here, using assertAccount
, we will test that the contract we deployed has the correct balance and ESDTs/storage we expect. The ESDTs/storage of an account are stored as a list of key-value in the blockchain. To refer to the list of key-value of an account, we use the abbreviation "kvs".
contract.getAccount()
will fetch the full contract data from the simulnet.
Using hasKvs
we can test that the contract contains the kvs provided (use kvs
if you want to test that the contract contains exactly all the kvs provided).
The e.kvs
is a helper that lets you easily encode storage mappers in the format that the MultiversX blockchain understands. Here, e.kvs.Mapper()
represents a simple SingleValueMapper
type from a Rust Smart Contract. Using .Value()
we can assert that it contains the value we expect.
More details about storage mappers encoding here.
Creating wallets
You can create wallets from a LSWorld
object using the createWallet
method. When creating a wallet you can specify its EGLD balance as well as kvs, like ESDT tokens or storage.
const deployer: LSWallet = await world.createWallet({
balance: 10_000_000_000n,
kvs: [
e.kvs.Esdts([
{ id: "TOKEN-123456", amount: 100_000 },
]),
],
});
Contract operations
Deploy contract
A contract can be deployed by an LSWallet
object using the deployContract
method:
const { contract, address } = await deployer.deployContract({
code: "file:output/contract.wasm",
codeMetadata: ["upgradeable"],
gasLimit: 100_000_000,
codeArgs: [
e.Str('ourString'),
],
});
The contract
is an instance of LSContract
and address
is a string containing the Bech32 address of the contract.
When deploying a contract you can specify:
code
- in this case from a wasm file, but it can also be the string of the hex contract code,codeMetadata
- flags likeupgradeable
,payable
, etc,gasLimit
- the gas limit to use for the transaction,codeArgs
- arguments used in theinit
function of the contract.
Use the e
helper to encode the data (see Data Encoding).
Call contract
A contract can be called by an LSWallet
object using the callContract
method and specifying which contract to call in the callee
field.
const result = await wallet.callContract({
callee: contract,
gasLimit: 5_000_000,
funcName: "addWhitelistedToken",
funcArgs: [e.Str("TOKEN-123456")],
value: 1_000, // egld to send in the transaction
});
The result
contains the tx
(transaction) and returnData
fields.
Call contract with ESDTs
To call a contract with ESDT tokens use the esdts
field:
const result = await wallet.callContract({
callee: contract,
gasLimit: 20_000_000,
funcName: "swap",
funcArgs: [
e.Str("TOKEN-123456"),
],
esdts: [{ id: "TOKEN-123456", amount: BigInt(1_000) }],
});
Check errors
You can also easily assert that a transaction failed with the appropriate code or message using the assertFail
method:
await deployer.callContract({
callee: contract,
gasLimit: 10_000_000,
funcName: "test",
funcArgs: [],
}).assertFail({ code: 4, message: "Some error" });
Query contract
You can query a contract directly from a LSWorld
object using the query
method and specifying which contract to query in the callee
field.
const { returnData, returnCode, returnMessage } = await world.query({
callee: contract,
funcName: "getWhitelistedToken",
funcArgs: [e.Str("TOKEN-123456")],
});
In order to decode the returnData
you can use the d
helper from the xsuite
package. More info here.
import { expect } from "vitest";
import { d } from "xsuite";
const tokenData = d.Tuple({
token: d.Str(),
}).fromTop(returnData[0]);
expect(tokenData.token).toEqual("TOKEN-123456");
Asserting account data
Kvs & accounts
In MultiversX, ESDT and storage data is actually stored in the same place, the kvs
of an account (short for "key-value pairs"). That is why all the objects that abstract the blockchain from xSuite use the general term kvs
instead of specifying separately the ESDTs and storage data.
An account is an address that exists on the MultiversX blockchain. It can represent a wallet address or a smart contract address (which have an additional code
field). Since they are both accounts, they can both have ESDTs or storage data associated with them.
We can assert that an account has the kvs we want using assertAccount
function. Here, we have two fields we can use:
kvs
- will check if the account contains the kvs we want and not more, if it has extra kvs the assertion will fail (this is similar to how MultiversX Scenarios work)hasKvs
- will check if the account has the kvs we want, but the account can also have other kvs that we have not specified
You can use whichever works best for you. We recommend using kvs
to make sure your contract doesn't have any unwanted kvs, but sometimes it may be easier to use hasKvs
, when there are a lot of storage or when debugging.
Checking balance & ESDTs
We can use the assertAccount
function to test that an account has a specific EGLD balance and has specific ESDTs. The ESDTs can be fungible tokens, SFTs or NFTs.
const account = await contract.getAccount();
assertAccount(account, {
balance: 1_000,
kvs: [
e.kvs.Esdts([
{ id: "TOKEN-123456", amount: 2_000 },
{ id: "NFT-123456", nonce: 1, name: "Nft Name", uris: ["url"] },
{ id: "SFT-123456", nonce: 1, amount: 3_000, name: "Sft Name", uris: ["url"] },
{ id: "META-123456", nonce: 1, amount: 3_000, attrs: e.Tuple(e.Str("test")) },
]),
],
});
Checking storage
The helper e.kvs
can be used to check storage. More details about storage mappers encoding here.
const account = await contract.getAccount();
assertAccount(account, {
balance: 0n,
kvs: [
e.kvs.Mapper("pause_module:paused").Value(e.Bool(false)),
e.kvs.Mapper("fee_percent").Value(e.U64(100)),
],
});
Mocking account data
Sometimes it is hard to arrive to a specific state of your smart contract, maybe because it will take too many transactions to arrive to that state or because there are some tricky errors that appear only in very specific cases. In those cases you can directly set the storage keys of a smart contract or any ESDTs amount & roles to whatever you want using the contract.setAccount()
function:
await contract.setAccount({
...(await contract.getAccount()),
owner: deployer,
codeMetadata: ["upgradeable", "payable"],
kvs: [
// Manually setting storage keys
e.kvs.Mapper("mock_address").Value(e.Addr(mockAddress)),
e.kvs.Mapper("supported_tokens", e.Str("TOKEN-123456")).Value(e.Tuple(
e.U8(1),
e.Str("OTHER-654321"),
e.U(0),
)),
// Manually setting ESDTs & roles
e.kvs.Esdts([{ id: "TOKEN-123456", amount: 1_000, roles: ["ESDTRoleLocalBurn", "ESDTRoleLocalMint"] }]),
],
});
This overrides the kvs
of the account to what we set here. Make sure to also specify the owner
and appropriate codeMetadata
for the contract since they will be lost when using the setAccount
function.
Due to a current bug in the underlying MultiversX Scenarios executor, you
should always specify payable
in the codeMetadata
of the setAccount
method in order for payable endpoints to continue to work properly.
Advanced tests
With xSuite, you can also write advanced tests easily.
test("Validate", async () => {
await contract.setAccount({
...(await contract.getAccount()),
owner: deployer,
codeMetadata: ["payable"],
kvs: [
// Manually set next_epoch
e.kvs.Mapper("next_epoch", e.Str("TOKEN-123456")).Value(e.U64(10)),
],
});
await deployer.callContract({
callee: contract,
gasLimit: 10_000_000,
funcName: "validate",
funcArgs: [
e.Str("TOKEN-123456"),
],
value: 1_000,
}).assertFail({ code: 4, message: "Invalid epoch" });
world.setCurrentBlockInfo({
epoch: 10,
});
await deployer.callContract({
callee: contract,
gasLimit: 10_000_000,
funcName: "validate",
funcArgs: [
e.Str("TOKEN-123456"),
],
value: 1_000,
});
assertAccount(await contract.getAccount(), {
balance: 1_000, // Assert that balance changed
kvs: [
// Test that next_epoch was modified accordingly
e.kvs.Mapper("next_epoch", e.Str("TOKEN-123456")).Value(e.U64(20)),
],
});
assertAccount(await deployer.getAccount(), {
balance: 0, // Assuming initial balance was 1_000 for deployer
});
});
Above we first mock the account data to set it to what we want using setAccount
. Then we do a contract call using callContract
which fails, and we can assert that using assertFail
.
Then, we set the world epoch
to what we want and do the transaction again, this time we expect it to succeed.
Then we check that the kvs
of the contract and the deployer account changed to what we expect.
In this case next_epoch
value was changed to 20
and 1_000
EGLD was transfered from the deployer account to the contract.