Integration testing

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

Integration testing is a crucial step in smart contract development that involves testing how different components of your system work together. The Clarinet JS SDK provides powerful tools for writing and running integration tests, allowing you to simulate complex scenarios and interactions between multiple contracts.

By using integration tests, you can ensure that your smart contracts function correctly as part of a larger system and catch potential issues that might not be apparent in unit tests alone.

In this guide, you will:

  1. Set up a Clarinet project with a defi contract.
  2. Write an integration test for the smart contract.
  3. Run tests and generate coverage reports.

Set up a Clarinet project

Start by creating a new Clarinet project. This command will create a new directory named defi and set up a basic Clarinet project inside it.

Terminal
clarinet new stx-defi
cd stx-defi

After changing into your project directory, run npm install to install the package dependencies for testing.

Terminal
npm install

We are going to use the same defi contract that we used in the unit testing guide, but with some additional functionality - the ability to borrow STX from the contract. If you don't have this project set up already, follow the steps below:

Terminal
clarinet contract new defi

Then, inside your defi.clar file, copy and paste the following contract code:

;; Error constants for various failure scenarios.
(define-constant err-overborrow (err u300))

;; The interest rate for loans, represented as 10% (out of a base of 100).
(define-data-var loan-interest-rate uint u10) ;; Representing 10% interest rate


;; Holds the total amount of deposits in the contract, initialized to 0.
(define-data-var total-deposits uint u0)

;; Maps a user's principal address to their deposited amount.
(define-map deposits { owner: principal } { amount: uint })

;; Maps a borrower's principal address to their loan details: amount and the last interaction block.
(define-map loans principal { amount: uint, last-interaction-block: uint })

;; Public function for users to deposit STX into the contract.
;; Updates their balance and the total deposits in the contract.
(define-public (deposit (amount uint))
  (let
    (
      ;; Fetch the current balance or default to 0 if none exists.
      (current-balance (default-to u0 (get amount (map-get? deposits { owner: tx-sender }))))
    )
    ;; Transfer the STX from sender = "ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM" to recipient = "ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM.defi (ie: contract identifier on the chain!)".
    (try! (stx-transfer? amount tx-sender (as-contract tx-sender)))
    ;; Update the user's deposit amount in the map.
    (map-set deposits { owner: tx-sender } { amount: (+ current-balance amount) })
    ;; Update the total deposits variable.
    (var-set total-deposits (+ (var-get total-deposits) amount))
    ;; Return success.
    (ok true)
  )
)

;; Public function for users to borrow STX based on their deposits.
(define-public (borrow (amount uint))
  (let
    (
      ;; Fetch user's deposit or default to 0.
      (user-deposit (default-to u0 (get amount (map-get? deposits { owner: tx-sender }))))
      ;; Calculate the maximum amount the user is allowed to borrow. (which will be upto HALF of what they deposited)
      (allowed-borrow (/ user-deposit u2))
      ;; Fetch current loan details or default to initial values.
      (current-loan-details (default-to { amount: u0, last-interaction-block: u0 } (map-get? loans tx-sender )))
      ;; Calculate accrued interest on the current loan.
      (accrued-interest (calculate-accrued-interest (get amount current-loan-details) (get last-interaction-block current-loan-details)))
      ;; Calculate the total amount due including interest.
      (total-due (+ (get amount current-loan-details) (unwrap-panic accrued-interest)))
      ;; Calculate the new loan total after borrowing additional amount.
      (new-loan (+ amount))
    )
    ;; Ensure the requested borrow amount does not exceed the allowed amount.
    (asserts! (<= new-loan allowed-borrow) err-overborrow)
    ;; Transfer the borrowed STX to the user.
    (let
      (
        (recipient tx-sender)
      )
      (try! (as-contract (stx-transfer? amount tx-sender recipient)))
    )
    ;; Update the user's loan details in the map.
    (map-set loans tx-sender { amount: new-loan, last-interaction-block: block-height })
    ;; Return success.
    (ok true)
  )
)

;; Read-only function to get the total balance by tx-sender
(define-read-only (get-balance-by-sender)
  (ok (map-get? deposits { owner: tx-sender }))
)

;; Read-only function to get the total amount owed by the user.
(define-read-only (get-amount-owed)
  (let
    (
      ;; Fetch current loan details or default to initial values.
      (current-loan-details (default-to { amount: u0, last-interaction-block: u0 } (map-get? loans tx-sender )))
      ;; Calculate accrued interest on the current loan.
      (accrued-interest (calculate-accrued-interest (get amount current-loan-details) (get last-interaction-block current-loan-details)))
      ;; Calculate the total amount due including interest.
      (total-due (+ (get amount current-loan-details) (unwrap-panic accrued-interest)))
    )
    ;; Return the total amount due.
    (ok total-due)
  )
)

;; Private function to calculate the accrued interest on a loan.
(define-private (calculate-accrued-interest (principal uint) (start-block uint))
  (let
    (
      ;; Calculate the number of blocks elapsed since the last interaction.
      (elapsed-blocks (- block-height start-block))
      ;; Calculate the interest based on the principal, rate, and elapsed time.
      (interest (/ (* principal (var-get loan-interest-rate) elapsed-blocks) u10000))
    )
    ;; Ensure the loan started in the past (not at block 0).
    (asserts! (not (is-eq start-block u0)) (ok u0))
    ;; Return the calculated interest.
    (ok interest)
  )
)

Run clarinet check to ensure that your smart contract is valid and ready for testing.

You can find the full code for this project in this repo.

Test the deposit and borrow functionality

In order to borrow STX from the contract, users must first deposit STX into it. Therefore, we need to write an integration test that simulates the interaction between these two functions.

Inside of your defi.test.ts file, replace the boilerplate code and add the following:

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

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

describe('stx-defi', () => {
  it('borrows 10 and verifies the amount owed to be 10', () => {
    simnet.callPublicFn('defi', 'deposit', [Cl.uint(1000)], wallet1);
    const totalDeposits = simnet.getDataVar('defi', 'total-deposits');
    expect(totalDeposits).toBeUint(1000);

    simnet.callPublicFn('defi', 'borrow', [Cl.uint(10)], wallet1);
    const { result } = simnet.callReadOnlyFn('defi', 'get-amount-owed', [], wallet1);
    expect(result).toBeOk(Cl.uint(10));
  });
});

In this integration test, we're simulating a scenario where a user deposits STX into the DeFi contract and then borrows against that deposit. Let's walk through the process step by step.

We start by simulating a deposit of 1000 STX from wallet1. To do this, we use the callPublicFn method from the Clarinet JS SDK simnet object, which allows us to call public functions in our smart contract just as we would on the actual blockchain.

After making the deposit, we want to verify that it was successful. We do this by checking the total deposits in the contract using getDataVar.

This handy method lets us peek at the value of data variables defined in your contract.

To learn more about available methods for integration testing, check out the reference page.

To ensure the deposit was recorded correctly, we use a custom matcher, toBeUint. This matcher is specifically designed to check if a value is a Clarity unsigned integer with the exact value we expect.

With the deposit confirmed, we simulate wallet1 borrowing 10 STX. We do this with another call to callPublicFn, this time invoking the borrow function of our contract.

After the borrowing operation, we want to check how much wallet1 owes. We use callReadOnlyFn to call a read-only function named get-amount-owed in our contract.

Finally, we verify the amount owed using another custom matcher, toBeOk(Cl.uint(10)). This matcher is particularly useful because it checks two things at once:

  1. That our contract returned a successful Clarity response type.
  2. That the value returned is a Clarity unsigned integer with the exact value we expect (10).

These custom matchers and simnet methods are powerful tools and allow you to simulate complex interactions with your smart contracts and make detailed assertions about the results.

Run tests and generate coverage reports

To run your tests, use:

Terminal
npm run test

To generate a coverage report, use:

Terminal
npm run coverage

This will run your tests and produce a detailed coverage report, helping you identify any untested parts of your contract.


Next steps