How to Test Clarity Smart Contracts using Clarinet’s TypeScript Test Suite
Testing is an important part of any software development project, but has proven especially crucial to the success of Web3 projects. Where you may have faulty logging or failed payments with Web2 apps, bugs introduced in Web3 apps can allow users to deflate the value of tokens by minting excess tokens, change the rules of how a DAO operates or even permanently freeze funds attached to a smart contract.
With Web2 projects, bugs can be fixed with simple fix branches that are deployed before further data is corrupted or sales are lost. This is not the case with Web3. Bugs in a Web3 app are permanent as any smart contract deployed to the blockchain is immutable. You may find a fix, but once your contract is live, it’s impossible to implement the fix.
How are we expected to write attack invulnerable code? The idea of perfect security, even in Web3, is an impossible aspiration. However with proper testing and full coverage we can decrease attack surfaces for malicious actors.
Clarity projects created with Clarinet come with a built in TypeScript testing framework. Using this framework we can deploy our smart contracts on mock chains and run our various functions against it. This allows us to simulate both public and read-only functions and check how they access and change chain state.
There are two lines that are common to all Clarity TypeScript test files:
In this article I would like to look more in depth at what these import objects are including their properties and how they are used to write cleaner, error free code.
Clarinet
The first class imported from the index.ts file is one of the most important for us. This class, is Clarinet, which is the key to all of our test setups and ability to create and test on the blockchain. With Clarinet, we can call Clarinet.test() which is our wrapper function for all unit tests we will want to perform on our smart contracts.
We will break down the Clarinet import object step by step and look at the different items that it is addressing within our unit test setup. First we need to instantiate our Clarinet instance:
To start we declare the test class method, which is our main and only method in the Clarinet class. This method will receive a hash like object with a UnitTestOptions type including our name, which is the description of the specific unit test, optional only and optional ignore values and finally our function for running the unit test. The rest of this section is preamble for our test chain setup.
After this is complete, we move on to setting up our test chain and test accounts:
If we have a value for beforeContractsDeployment then we will do three things. First, we will create a new instance of our Chain object using the current session_id. Then we will create an empty Map instance with string keys and Account instance values. Finally, we will seed this empty accounts Map using the accounts returned from our result option created in the preamble logic performed in the previous figure.
We then use these two seeded objects to call our beforeContractsDeployment method and then set our sessionId to be the sessionId of our new test chain instance.
Our final section of the Chain class code handles creating the chain and accounts if they were not already completed:
It also create a map for our different contracts and calls the options.fn using our new test chain, test accounts and collection of contracts relating to our test suite. Nothing can run without this class, so I hope this deep dive helps you understand the test setup for Clarity TypeScript test suites.
Tx
The Tx class object is used to call 3 different class methods. These methods are transferSTX, contractCall and deployContract. All 3 of these methods perform transactions on the chain. Before we get into those methods, let’s first look at instantiating Tx:
While we don’t explicitly instantiate Tx objects, all 3 method listed above do instantiate Tx objects within their respective method calls. Each method has a number: 1, 2 or 3 which corresponds with which method is calling. Based off of the method calls, we also have access to any of the three optional interfaces which are listed below:
Each of these interfaces is used to format and enforce the types of the data returned for the method calls. The Tx class itself is mainly concerned with setting and tracking the type of transaction method it is calling and the principal address that is calling for the transaction to occur.
The first method, transferSTX accepts an amount, a recipient and a sender:
It creates a new instance of Tx using type 1 and the sender’s principal address. It then uses the TxTransfer interface to return an object with the recipient and the amount which should move the amount of coins from the sender to the recipient.
Our second Tx method is contractCall. It again creates an instance of Tx using the sender principal and the type, 2. After this, it uses the TxContractCall interface to create an object including the smart contract, method being called and our arguments for the method:
Our final method for the Tx class is deployContract. This method accepts a name, code and the sender and returns an instance of the TxDeployContract interface which:
These three class methods handle the main transactions we will like to test including deploying a contract to the blockchain, transferring tokens between standard principals and finally making method calls to existing smart contract methods.
Chain
For Chain in index.ts, there are two different objects exported from the file. The first is a Chain interface which has a single attribute, sessionId:
The more interesting object comes later with the class Chain object. Similar to the interface, we need a sessionId to instantiate the Chain class. It uses the constructor to set the sessionId and then has a default blockHeight value set to 1:
Once we have our instance of Chain, we can use any of its 5 different instance methods to add to or read from the chain. The first instance methods is mineBlock:
To be able to test public functions we need to add them as transactions within a block to the blockchain. In order to do this we will need to alter the blockchain state which requires adding a new block to it. The mineBlock method accepts an array of transactions or Tx objects.
We parse the JSON returned by the mine_block method and place the values in a result variable. From this result we are able to determine and assign the new blockHeight and create a block object using the new height and the receipts or results from running each of the transactions in our array of transactions.
The next method, mineEmptyBlock is similar, but instead of accepting an array of transactions to perform before adding the blocks to the chain, we specify a number of empty blocks to add to the blockchain:
Our third instance method for the Chain class is mineEmptyBlockUntil. This is very useful when we are testing chain state that occurs far into the future. The method accepts an argument, targetBlockHeight, which allows us to specify which block we’d like to get to and then calls mineEmptyBlock until the blockchain’s height is the same as the requested height:
Read-only functions don’t change state on the blockchain or add more blocks to it. They do need to access the chain’s state and for this we have the callReadOnlyFn Chain instance method. Just like calling a public function, callReadOnlyFn requires a contract to test, a method to call, an array of arguments for the given method and an address as a string to determine who is calling this method:
We call call_read_only_fn using these arguments to get a result object with a session_id, result and events. These items, which are returned to us as readOnlyFn are good to determine the events that have occurred and whatever chain state we may have attempted to access with our read-only method.
Our final Chain instance method is getAssetsMaps. This is a helpful cli command that you can run with clarinet console as ::get_assets_maps. Using this in the terminal prints out a table of all the addresses and their current STX balances. This method makes a call to get_assets_maps using the current chain’s sessionId and return an object with the sessionId as well as a result.assets object:
Account
The Account object is a simple TypeScript interface with 5 fields:
These five fields are all we need to populate the principal object. Here we have the address, or the string value to call for sending and receiving funds. Then we have the balance, or the amount of STX the account has rights to. Next there is a name that is used to more easily identify and call the account during tests. Finally we have a mnemonic, used for securing the account and a derivation string value.
types
The types import is not a class, but a TypeScript namespace. Namespaces are a way to accomplish two important things when writing clean and clear code. First, they group similar code into units. These units are enclosed in a wrapper within the namespace that allows for name reuse between namespaces. Why is this important? With namespaces we can keep meaningful, similar names for different functions without running into name collisions.
This types namespace is extremely long and repetitive so I will give a condensed version of what it accomplishes it. Clarity has a number of different types that are not native to TypeScript or JavaScript. In order to accommodate the different types, this types namespace serves as a collection of functions that may or may not accept, then take in and output a type and value that matches what Clarity uses.
An example of these methods are the err and ok response types:
When we call types.ok or types.err we also pass in a string for the values. These methods accept that string and return the “ok `value`” or “err `value`” responses that we’d expect for Clarity responses. If you understand how these response methods work, then you should understand the majority of the methods in the types namespace.
Conclusion
If good test coverage is crucial to good Web3 projects, then knowing your testing tools is crucial to writing great, full coverage tests. I hope that after reading this you have a better understanding of what the main Clarity method imports are in your Clarinet TypeScript test files!
Notes