Test Contract
Warning:
clarinet test
will soon be deprecated in favor of a new way of testing smart contracts. Follow this guide to learn how to test smart contract with Vitest and the Clarinet SDK.
Clarinet 1 supports automatic testing, where your blockchain application requirements can be converted to test cases. Clarinet comes with a testing harness based on Deno that applies the unit tests you write in TypeScript to your smart contracts.
Topics covered in this guide:
- Clarity contracts and unit tests
- Measure and increase code coverage
- Cost optimization
- Load contracts in a console
- Spawn a local Devnet
- Interacting with contracts
- Use Clarinet in your CI workflow as a GitHub Action
Clarity contracts and unit tests
Let us consider a counter
smart contract to understand how to write unit tests for our application requirements.
;; counter
(define-data-var counter uint u1) ;; counter initialized to 1
(define-public (increment (step uint)) ;; increment counter, print new-val
(let ((new-val (+ step (var-get counter))))
(var-set counter new-val)
(print { object: "counter", action: "incremented", value: new-val })
(ok new-val)))
(define-public (decrement (step uint)) ;; decrement counter, print new-val
(let ((new-val (- step (var-get counter))))
(var-set counter new-val)
(print { object: "counter", action: "decremented", value: new-val })
(ok new-val)))
(define-read-only (read-counter) ;; read value of counter
(ok (var-get counter)))
Our counter
application keeps track of an initialized value, allows for incrementing and decrementing, and prints actions as a log. Let us turn these requirements into unit tests.
Unit tests for counter
example
When you created your Clarity contract with clarinet contract new <my-project>
, Clarinet automatically created a test file for the contract within the tests directory: tests/my-projects_test.ts
. Other files under the tests/
directory following the Deno test naming convention will also be included:
- named test.{ts, tsx, mts, js, mjs, jsx, cjs, cts},
- or ending with .test.{ts, tsx, mts, js, mjs, jsx, cjs, cts},
- or ending with _test.{ts, tsx, mts, js, mjs, jsx, cjs, cts}
Within these tests, developers can simulate mining a block containing transactions using their contract and then examine the results of those transactions and the events generated by them.
NOTE:
If you see an error in Visual Studio Code (VS Code) on the imports in the generated test file(s) that says, "An import path cannot end with a '.ts' extension" (example below), follow the below steps to resolve the error:
- Install the Deno extension in VS Code
- Install Deno on your computer
- In VS Code, open the command palette (
Ctrl+Shift+P
inWindows
;Cmd+Shift+P
onMac
) and run theDeno: Initialize Workspace Configuration
andDeno: Cache Dependencies
commands- Open Command Prompt (Terminal on a Mac); navigate to the tests folder in your project and run
deno run test-file-name.ts
(Make sure to replacetest-file-name
with the actual name of the test file,counter_test.ts
in the current example )- Quit and restart VS Code
Clarinet allows you to instantly initialize wallets and populate them with tokens, which helps to interactively or programmatically test the behavior of the smart contract. Blocks are mined instantly, so you can control the number of blocks that are mined between testing transactions.
To define a Clarinet test, you need to register it with a call to Clarinet.test()
. In the example unit test below, you see us
- Importing the relevant classes from the Clarinet module on Deno
- Instantiating and passing common Clarinet objects to our
Clarinet.test()
API call - Defining a user
wallet_1
, callingincrement
, and asserting its results
// counter_test.ts - A unit test file
import { Clarinet, Tx, Chain, Account, types } from 'https://deno.land/x/clarinet@v1.0.5/index.ts';
Clarinet.test({
name: "Ensure that increment works.",
async fn(chain: Chain, accounts: Map<string, Account>) {
let wallet_1 = accounts.get("wallet_1")!; // instantiate a user
let block = chain.mineBlock([
Tx.contractCall("counter", "increment", [types.uint(3)], wallet_1.address) // increment counter by 3
]);
block.receipts[0].result // ensure that counter returned 3
.expectOk()
.expectUint(3)
},
});
We run this test with
clarinet test
For a complete list of classes, objects, and interfaces available, see Deno's Clarinet module index.
You can watch a step-by-step walkthrough of using clarinet test
and watch Executing Tests and Checking Code Coverage.
Comprehensive unit tests for counter
Let us now write a higher coverage test suite.
// counter_test.ts - a comprehensive unit test file
import { Clarinet, Tx, Chain, Account, Contract, types } from 'https://deno.land/x/clarinet@v1.0.2/index.ts';
import { assertEquals } from "https://deno.land/std@0.90.0/testing/asserts.ts";
Clarinet.test({
name: "Ensure that counter can be incremented multiples per block, accross multiple blocks",
async fn(chain: Chain, accounts: Map<string, Account>, contracts: Map<string, Contract>) {
let wallet_1 = accounts.get("wallet_1")!;
let wallet_2 = accounts.get("wallet_2")!; // multiple users
let block = chain.mineBlock([
Tx.contractCall("counter", "increment", [types.uint(1)], wallet_1.address),
Tx.contractCall("counter", "increment", [types.uint(4)], wallet_1.address),
Tx.contractCall("counter", "increment", [types.uint(10)], wallet_1.address)
]); // multiple contract calls
assertEquals(block.height, 2); // asserting block height
block.receipts[0].result // checking log for expected results
.expectOk()
.expectUint(2);
block.receipts[1].result
.expectOk()
.expectUint(6);
block.receipts[2].result
.expectOk()
.expectUint(16);
block = chain.mineBlock([
Tx.contractCall("counter", "increment", [types.uint(1)], wallet_1.address),
Tx.contractCall("counter", "increment", [types.uint(4)], wallet_1.address),
Tx.contractCall("counter", "increment", [types.uint(10)], wallet_1.address),
Tx.transferSTX(1, wallet_2.address, wallet_1.address),
]); // more contract calls, and an STX transfer
assertEquals(block.height, 3);
block.receipts[0].result
.expectOk()
.expectUint(17);
block.receipts[1].result
.expectOk()
.expectUint(21);
block.receipts[2].result
.expectOk()
.expectUint(31);
let result = chain.getAssetsMaps(); // asserting account balances
assertEquals(result.assets["STX"][wallet_1.address], 99999999999999);
let call = chain.callReadOnlyFn("counter", "read-counter", [], wallet_1.address)
call.result
.expectOk()
.expectUint(31); // asserting a final counter value
"0x0001020304".expectBuff(new Uint8Array([0, 1, 2, 3, 4])); // asserting buffers
"ST1HTBVD3JG9C05J7HBJTHGR0GGW7KXW28M5JS8QE.plaid-token".expectPrincipal('ST1HTBVD3JG9C05J7HBJTHGR0GGW7KXW28M5JS8QE.plaid-token'); // asserting principals
},
});
Here, variously, we:
- instantiated multiple accounts
- called functions across multiple blocks
- asserted block heights between transactions
- tested transfers and balances
Measure and increase code coverage
To help developers maximizing their test coverage, Clarinet can produce a lcov
report, using the following option:
clarinet test --coverage
From there, you can use the lcov
tooling suite to produce HTML reports.
brew install lcov
genhtml --branch-coverage -o coverage coverage.lcov
open coverage/index.html
Cost optimization
Clarinet can also be used for optimizing costs. When you execute a test suite, Clarinet keeps track of all costs being computed when executing the contract-call
, and display the most expensive ones in a table:
clarinet test --costs
The --costs
option can be used in conjunction with --watch
and filters to maximize productivity, as illustrated here:
Load contracts in a console
The Clarinet console is an interactive Clarity Read, Evaluate, Print, Loop (REPL) console that runs in-memory. Any contracts in the current project are automatically loaded into memory.
clarinet console
You can use the ::help
command in the console for a list of valid commands, which can control the state of the REPL chain, and let you advance the chain tip. Additionally, you may enter Clarity commands into the console and observe
the result of the command.
You may exit the console by pressing Ctrl + C
twice.
Changes to contracts are not loaded into the console while it is running. If you make any changes to your contracts you must exit the console and run it again.
Spawn a local Devnet
You can use Clarinet to deploy your contracts to your own local offline environment for testing and evaluation on a blockchain.
Use the following command:
clarinet integrate
Make sure that you have a working installation of Docker running locally.
Interacting with contracts
Deployed on Mainnet
Composition and interactions between protocols and contracts are one of the key innovations in blockchains. Clarinet was designed to handle these types of interactions.
Before referring to contracts deployed on Mainnet, they should be explicitily be listed as a requirement
in the manifest Clarinet.toml
, either manually:
[project]
name = "my-project"
[[project.requirements]]
contract_id = "SP2KAF9RF86PVX3NEE27DFV1CQX0T4WGR41X3S45C.bitcoin-whales"
or with the command:
clarinet requirements add SP2KAF9RF86PVX3NEE27DFV1CQX0T4WGR41X3S45C.bitcoin-whales
From there, Clarinet will be able to resolve the contract-call?
statements invoking requirements present in your local contracts by downloading and caching a copy of these contracts and using them during the execution of your test suites, in addition to all the different features available in clarinet
.
When deploying your protocol to Devnet / Testnet, for the contracts involving requirements, the setting remap_requirements
in your deployment plans must be set.
As a step-by-step example, we use here the following contract, bitcoin-whales
If you examine this contract, you will see that there are 3 different dependencies: two from the same project (included in the same Clarinet.toml
file), and one referring to a contract deployed outside of the current project.
Same Project
In the contract snippet below, there are dependencies on the contracts conversion and conversion-v2 which are included in the same Clarinet.toml
file.
(define-read-only (get-token-uri (token-id uint))
(if (< token-id u5001)
(ok (some (concat (concat (var-get ipfs-root) (unwrap-panic (contract-call? .conversion lookup token-id))) ".json")))
(ok (some (concat (concat (var-get ipfs-root) (unwrap-panic (contract-call? .conversion-v2 lookup (- token-id u5001)))) ".json")))
)
)
External Deployer
In this snippet, there is a dependency on the nft-trait
deployed by 'SP2PABAF9FTAJYNFZH93XENAJ8FVY99RRM50D2JG9
.
(impl-trait 'SP2PABAF9FTAJYNFZH93XENAJ8FVY99RRM50D2JG9.nft-trait.nft-trait)
Dependencies from external contracts should be set in [[project.requirements]]
.
[project]
name = "my-project"
[[project.requirements]]
contract_id = "SP2PABAF9FTAJYNFZH93XENAJ8FVY99RRM50D2JG9.nft-trait"
boot_contracts = ["pox", "costs-v2", "bns"]
[project.cache_location]
path = ".requirements"
[contracts.bitcoin-whales]
path = "contracts/bitcoin-whales.clar"
[contracts.conversion]
path = "contracts/conversion.clar"
[contracts.conversion-v2]
path = "contracts/conversion-v2.clar"
[repl]
costs_version = 2
parser_version = 2
[repl.analysis]
passes = ["check_checker"]
[repl.analysis.check_checker]
strict = false
trusted_sender = false
trusted_caller = false
callee_filter = false
As a next step, we may generate a deployment plan for this project.
If running clarinet integrate
for the first time, this file should be created by Clarinet.
In addition, you may run clarinet deployment generate --devnet
to create or overwrite.
---
id: 0
name: Devnet deployment
network: devnet
stacks-node: "http://localhost:20443"
bitcoin-node: "http://devnet:devnet@localhost:18443"
plan:
batches:
- id: 0
transactions:
- requirement-publish:
contract-id: SP2PABAF9FTAJYNFZH93XENAJ8FVY99RRM50D2JG9.nft-trait
remap-sender: ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM
remap-principals:
SP2PABAF9FTAJYNFZH93XENAJ8FVY99RRM50D2JG9: ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM
cost: 4680
path: ".requirements\\SP2PABAF9FTAJYNFZH93XENAJ8FVY99RRM50D2JG9.nft-trait.clar"
- contract-publish:
contract-name: conversion
expected-sender: ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM
cost: 340250
path: "contracts\\conversion.clar"
anchor-block-only: true
- contract-publish:
contract-name: conversion-v2
expected-sender: ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM
cost: 351290
path: "contracts\\conversion-v2.clar"
anchor-block-only: true
- contract-publish:
contract-name: bitcoin-whales
expected-sender: ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM
cost: 87210
path: "contracts\\bitcoin-whales.clar"
anchor-block-only: true
As you can see from the example above, Clarinet will remap the external contract to our Devnet address. In addition, Clarinet will also create a copy of it in the folder requirements
Use Clarinet in your CI workflow as a GitHub Action
Clarinet may be used in GitHub Actions as a step of your CI workflows.
You may set-up a simple workflow by adding the following steps in a file .github/workflows/github-actions-clarinet.yml
:
name: CI
on: [push]
jobs:
tests:
name: "Test contracts with Clarinet"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: "Execute unit tests"
uses: docker://hirosystems/clarinet:latest
with:
args: test --coverage --manifest-path=./Clarinet.toml
- name: "Export code coverage"
uses: codecov/codecov-action@v1
with:
files: ./coverage.lcov
verbose: true
Or add the steps above in your existing workflows. The generated code coverage output can then be used as is with GitHub Apps like https://codecov.io.