Write unit tests for Clarity

Learn how to write and run comprehensive unit tests for your Clarity smart contracts using the Clarinet JS SDK and Vitest.

Deployment plans minimize the inherent complexity of deployments, such as smart contract dependencies and interactions, transaction chaining limits, deployment costs, and more, while ensuring reproducible deployments critical for testing purposes.

In this guide, you will learn how to:

  1. Generate and configure a deployment plan.
  2. Set deployment plan specifications.
  3. Add project contract requirements.
  4. Customize your deployment plan.

Prerequisites

The SDK requires Node.js >= 18.0 and NPM to be installed. Volta is a great tool to install and manage JS tooling.

To follow this tutorial, you must have the Clarinet CLI installed as well.


Requirements

The SDK requires Node.js >= 18.0 and NPM to be installed. Volta is a great tool to install and manage JS tooling.

To follow this tutorial, you must have the Clarinet CLI installed as well.

Set Up the Clarity Contract and Unit Tests

Let us consider a counter smart contract to understand how to write unit tests for our application requirements.

First, create a new Clarinet project with a counter contract.

clarinet new counter
cd counter
clarinet contract new counter

Below will be the content of our smart contract. It keeps track of an initialized value, allows for incrementing and decrementing, and prints actions as a log.

;; counter.clar
(define-data-var count uint u1)

(define-public (increment (step uint))
  (let ((new-val (+ (var-get count) step)))
    (var-set count new-val)
    (print { object: "count", action: "incremented", value: new-val })
    (ok new-val)
  )
)

(define-public (decrement (step uint))
  (let ((new-val (- (var-get count) step)))
    (var-set count new-val)
    (print { object: "count", action: "decremented", value: new-val })
    (ok new-val)
  )
)

(define-read-only (read-count)
  (ok (var-get count))
)

Migrating Between Clarinet v1 and Clarinet v2

Executing this script in a Clarinet v1 project will initialise NPM and Vitest. It will also create a sample test file.

npx @hirosystems/clarinet-sdk@latest

This script will ask you if you want to run npm install now; you can press enter to do so. This can take a few seconds.

The file tests/counter_test.ts that was created by clarinet contract new counter can be deleted.

You can also have a look at tests/contract.test.ts. It's a sample file showing how to use the SDK with Vitest. It can safely be deleted.

Unit Tests for counter Example

Create a file tests/counter.test.ts with the following content:

import { Cl } from '@stacks/transactions';
import { describe, expect, it } from 'vitest';

const accounts = simnet.getAccounts();
const address1 = accounts.get('wallet_1')!;

describe('test `increment` public function', () => {
  it('increments the count by the given value', () => {
    const incrementResponse = simnet.callPublicFn('counter', 'increment', [Cl.uint(1)], address1);
    console.log(Cl.prettyPrint(incrementResponse.result)); // (ok u2)
    expect(incrementResponse.result).toBeOk(Cl.uint(2));

    const count1 = simnet.getDataVar('counter', 'count');
    expect(count1).toBeUint(2);

    simnet.callPublicFn('counter', 'increment', [Cl.uint(40)], address1);
    const count2 = simnet.getDataVar('counter', 'count');
    expect(count2).toBeUint(42);
  });

  it('sends a print event', () => {
    const incrementResponse = simnet.callPublicFn('counter', 'increment', [Cl.uint(1)], address1);

    expect(incrementResponse.events).toHaveLength(1);
    const printEvent = incrementResponse.events[0];
    expect(printEvent.event).toBe('print_event');
    expect(printEvent.data.value).toBeTuple({
      object: Cl.stringAscii('count'),
      action: Cl.stringAscii('incremented'),
      value: Cl.uint(2),
    });
  });
});

To run the test, go back to your console and run the npm test command. It should display a report telling you that tests succeeded.

npm test

There is a very important thing happening under the hood here. The simnet object is available globally in the tests, and is automatically initialized before each test.

You don't need to know much more about that, but if you want to know in detail how it works, you can have a look at the vitest.config.js file at the root of you project.

Getting back to the tests, we just implemented two of them:

  • The first test checks that the increment function returns the new value and saves it to the count variable.
  • The second test checks that an print_event is emitted when the increment function is called.

You can use Cl.prettyPrint(value: ClarityValue) to format any Clarity value into readable Clarity code. It can be useful to debug function results or event values.

Note that we are importing describe, expect and it from Vitest.

  • it allows us to write a test.
  • describe is not necessary but allows to organize tests.
  • expect is use to make assertions on value.

You can learn more about Vitest on their website. We also implemented some custom matchers to make assertions on Clarity variables (like toBeUint). The full list of custom matchers is available at the end of this guide.

Comprehensive Unit Tests for counter

Let us now write a higher coverage test suite by testing the decrement and get-counter functions.

These two code blocks can be added at the end of tests/counter.test.ts.

describe('test `decrement` public function', () => {
  it('decrements the count by the given value', () => {
    const decrementResponse = simnet.callPublicFn('counter', 'decrement', [Cl.uint(1)], address1);
    expect(decrementResponse.result).toBeOk(Cl.uint(0));

    const count1 = simnet.getDataVar('counter', 'count');
    expect(count1).toBeUint(0);

    // increase the count so that it can be descreased without going < 0
    simnet.callPublicFn('counter', 'increment', [Cl.uint(10)], address1);

    simnet.callPublicFn('counter', 'decrement', [Cl.uint(5)], address1);
    const count2 = simnet.getDataVar('counter', 'count');
    expect(count2).toBeUint(5);
  });

  it('sends a print event', () => {
    const decrementResponse = simnet.callPublicFn('counter', 'decrement', [Cl.uint(1)], address1);

    expect(decrementResponse.events).toHaveLength(1);
    const printEvent = decrementResponse.events[0];
    expect(printEvent.event).toBe('print_event');
    expect(printEvent.data.value).toBeTuple({
      object: Cl.stringAscii('count'),
      action: Cl.stringAscii('decremented'),
      value: Cl.uint(0),
    });
  });
});
describe('test `get-count` read only function', () => {
  it('returns the counter value', () => {
    const count1 = simnet.callReadOnlyFn('counter', 'read-count', [], address1);
    expect(count1.result).toBeOk(Cl.uint(1));

    simnet.callPublicFn('counter', 'increment', [Cl.uint(10)], address1);
    const count2 = simnet.callReadOnlyFn('counter', 'read-count', [], address1);
    expect(count2.result).toBeOk(Cl.uint(11));
  });
});

Measure and Increase Code Coverage

To help developers maximize their test coverage, the test framework can produce a lcov report, using --coverage flag. You can set it in the scripts in the project package.json:

  "scripts": {
    "test:coverage": "vitest run -- --coverage",
    // ...
  },

Then run the script with the following command. It will produce a file named ./lcov.info.

npm run test:coverage

From there, you can use the lcov tooling suite to produce HTML reports.

brew install lcov
genhtml --branch-coverage -o coverage lcov.info
open coverage/index.html

Costs Optimization

The test framework can also be used to optimize costs. When you execute a test suite, Clarinet keeps track of all costs being computed when executing the contract-call, and displays the most expensive ones in a table.

To help developers maximize their test coverage, the test framework can produce a lcov report, using --coverage flag. You can set it in the scripts in the project package.json:

  "scripts": {
    "test:costs": "vitest run -- --costs",
    // ...
  },

And run the script with the following command. It will produce a file named ./costs-reports.json.

npm run test:costs

For now, there isn't much you can do out of the box with a costs report. But in future versions of the Clarinet JS SDK, we will implement features to help keep track of your costs, such as checking that function calls do not go above a certain threshold.

Produce Both Coverage and Costs Reports

In your package.json, you should already have a script called test:report like so:

  "scripts": {
    "test:report": "vitest run -- --coverage --costs",
    // ...
  },

Run it to produce both the coverage and the costs reports:

npm run test:report

Run Tests in CI

Because the tests only require Node.js and NPM run, they can also be run in GitHub actions and CIs just like any other Node test.

In GitHub, you can directly set up a Node.js workflow like this one:

name: Test counter contract

on:
  push:
    branches: ['main']
  pull_request:
    branches: ['main']

jobs:
  build:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: [20.x]

    steps:
      - uses: actions/checkout@v3
      - name: Use Node.js ${{ matrix.node-version }}
        uses: actions/setup-node@v3
        with:
          node-version: ${{ matrix.node-version }}
          cache: 'npm'
      - run: npm ci
      - run: npm run test:reports