Writing integration tests
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.
What you'll learn
Set up your project
Create a new Clarinet project and install dependencies:
$clarinet new stx-defi$cd stx-defi$npm install
Create the enhanced contract
We'll use an enhanced DeFi contract that supports both deposits and borrowing. Create the contract:
$clarinet contract new defi
Replace contracts/defi.clar
with this enhanced version:
;; Error constants(define-constant err-overborrow (err u300));; Interest rate (10%)(define-data-var loan-interest-rate uint u10);; Total deposits in the contract(define-data-var total-deposits uint u0);; User deposits(define-map deposits { owner: principal } { amount: uint });; User loans(define-map loans principal { amount: uint, last-interaction-block: uint });; Deposit STX into the contract(define-public (deposit (amount uint))(let((current-balance (default-to u0 (get amount (map-get? deposits { owner: tx-sender })))))(try! (stx-transfer? amount tx-sender (as-contract tx-sender)))(map-set deposits { owner: tx-sender } { amount: (+ current-balance amount) })(var-set total-deposits (+ (var-get total-deposits) amount))(ok true)));; Borrow STX based on deposits (up to 50% of deposit)(define-public (borrow (amount uint))(let((user-deposit (default-to u0 (get amount (map-get? deposits { owner: tx-sender }))))(allowed-borrow (/ user-deposit u2))(current-loan-details (default-to { amount: u0, last-interaction-block: u0 }(map-get? loans tx-sender)))(accrued-interest (calculate-accrued-interest(get amount current-loan-details)(get last-interaction-block current-loan-details)))(total-due (+ (get amount current-loan-details) (unwrap-panic accrued-interest)))(new-loan (+ amount)))(asserts! (<= new-loan allowed-borrow) err-overborrow)(try! (as-contract (stx-transfer? amount tx-sender tx-sender)))(map-set loans tx-sender { amount: new-loan, last-interaction-block: block-height })(ok true)));; Get user's balance(define-read-only (get-balance-by-sender)(ok (map-get? deposits { owner: tx-sender })));; Get amount owed including interest(define-read-only (get-amount-owed)(let((current-loan-details (default-to { amount: u0, last-interaction-block: u0 }(map-get? loans tx-sender)))(accrued-interest (calculate-accrued-interest(get amount current-loan-details)(get last-interaction-block current-loan-details)))(total-due (+ (get amount current-loan-details) (unwrap-panic accrued-interest))))(ok total-due)));; Calculate interest(define-private (calculate-accrued-interest (principal uint) (start-block uint))(let((elapsed-blocks (- block-height start-block))(interest (/ (* principal (var-get loan-interest-rate) elapsed-blocks) u10000)))(asserts! (not (is-eq start-block u0)) (ok u0))(ok interest)))
Run clarinet check
to ensure your contract is valid:
$clarinet check[32m✔ 1 contract checked[0m
Write integration tests
Create a test that simulates a complete user workflow - depositing and then borrowing:
import { describe, it, expect } from 'vitest';import { Cl } from '@stacks/transactions';const accounts = simnet.getAccounts();const wallet1 = accounts.get('wallet_1')!;describe('stx-defi integration', () => {it('allows deposit and borrow workflow', () => {// Step 1: User deposits STXconst depositResult = simnet.callPublicFn('defi','deposit',[Cl.uint(1000)],wallet1);expect(depositResult.result).toBeOk(Cl.bool(true));// Verify deposit was recordedconst totalDeposits = simnet.getDataVar('defi', 'total-deposits');expect(totalDeposits).toBeUint(1000);// Step 2: User borrows against depositconst borrowResult = simnet.callPublicFn('defi','borrow',[Cl.uint(400)], // Borrowing 40% of depositwallet1);expect(borrowResult.result).toBeOk(Cl.bool(true));// Step 3: Check amount owedconst { result } = simnet.callReadOnlyFn('defi','get-amount-owed',[],wallet1);expect(result).toBeOk(Cl.uint(400)); // No interest yet at same block});it('prevents over-borrowing', () => {// Setup: deposit firstsimnet.callPublicFn('defi', 'deposit', [Cl.uint(1000)], wallet1);// Attempt to borrow more than allowed (>50%)const borrowResult = simnet.callPublicFn('defi','borrow',[Cl.uint(600)],wallet1);// Should fail with err-overborrowexpect(borrowResult.result).toBeErr(Cl.uint(300));});});
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 break down the key aspects:
callPublicFn
- Simulates calling public functions just as on the actual blockchaingetDataVar
- Retrieves the value of contract data variablescallReadOnlyFn
- Calls read-only functions without modifying state- Custom matchers -
toBeOk()
andtoBeErr()
validate Clarity response types
Try it out
Run your integration tests:
$npm run test
Common issues
Issue | Solution |
---|---|
State pollution between tests | Each test runs in isolation - state doesn't carry over |
Timing issues | Use simnet.mineEmptyBlocks() to advance block height |
Complex assertions | Break down into smaller, focused tests |
Next steps
- Test multi-user interactions
- Simulate edge cases and attack vectors
- Add time-based testing with block mining
- Test contract upgrades and migrations