Pure Unit Testing FunC Smart Contract (A New Approach)

PUBLISHED ON

January 28, 2025

WRITTEN BY

Mujtaba Roohani

DURATION

5 Min

CATEGORY

Pure Unit Testing FunC Smart Contract (A New Approach)

After spending significant time in the TON ecosystem, I noticed a recurring issue: the default @ton/sandbox tests often lean more toward integration tests rather than true unit tests. While there are benefits to testing the end-to-end flow of a message, it has some downsides.

  1. The tests lack isolation; for example, modifying the behavior of the jetton wallet contract may cause the jetton minter contract tests to fail unexpectedly.
  2. Tests that depend heavily on the blockchain configuration are fragile towards minor changes in configuration. Hence, the repository hosting the standard token contract is also facing failing tests.
  3. Setting a smart contract’s state typically involves sending messages to it, which increases dependency on other functionalities, reducing test isolation.
  4. The current testing setup makes it difficult to take a Test-Driven Development (TDD) approach, where functionality is incrementally built and tested in isolation.

 

This article introduces a solution: a testing suite configuration designed to isolate and independently verify single transactions, enabling a smoother TDD workflow for FunC smart contracts.

 

Solution

Our approach addresses these issues by ensuring tests are isolated, independent, and focused on verifying single transactions. This allows for a true TDD workflow, where each change can be validated incrementally.

This blog will walk you through setting up this test suite and will then demonstrate completing the contract functionality in a TDD fashion.

Setup

Create a new project using @ton/blueprint, I am naming the project as unit_tests_ton and the first contract name will be MessageCounter .

 

npm create ton@latest -- unit_tests_ton --type func-empty --contractName MessageCounter

 

Smart Contract

For demonstration, we take an example of the MessageCounter smart contract, a proxy that forwards messages while keeping track of the number of successful forwards.

The contract will accept the message with the following schema and in return will send a message to the given address to and its body will be the forward_payload.

forward_message#36e135d3 to:MsgAddress forward_payload:Either Cell ^Cell = InternalTransferMsg

Here’s the initial implementation of our MessageCounter smart contract. This contract serves as a message proxy while maintaining a counter of successful forwarded messages. We’ll build on this incrementally using TDD.

#include "imports/stdlib.fc";

const int op::forward_message = 0x36e135d3;

const int error::unknown_op = 0xffff;
const int error::malformed_forward_payload = 100;
const int error::not_enough_ton = 101;

const min_ton_for_storage = 100000;
;; storage scheme
{- storage#_
       count:uint256 = Storage;
-}
(int) load_data() inline {
   slice ds = get_data().begin_parse();
   return (
           ds~load_uint(256)
   );
}
() save_data(int count) impure inline {
   set_data(begin_cell()
           .store_uint(count, 256)
           .end_cell());
}
() forward_message(slice to_address, slice forward_payload) impure inline {
}
() recv_internal(int my_balance, int msg_value, cell in_msg_full, slice in_msg_body) impure {
   if (in_msg_body.slice_empty?()) { ;; ignore empty messages
       return ();
   }
   slice cs = in_msg_full.begin_parse();
   int flags = cs~load_uint(4);
   (
       int count
   ) = load_data();
   if (flags & 1) {
       return ();
   }
   slice sender_address = cs~load_msg_addr();
   cs~load_msg_addr(); ;; skip dst
   cs~load_coins(); ;; skip value
   cs~skip_bits(1); ;; skip extracurrency collection
   cs~load_coins(); ;; skip ihr_fee
   int fwd_fee = muldiv(cs~load_coins(), 3, 2); ;; we use message fwd_fee for estimation of forward_payload costs
   int op = in_msg_body~load_uint(32);
   slice to_address = in_msg_body~load_msg_addr();
  
   if (op == op::forward_message) {
       throw_unless(error::malformed_forward_payload, slice_bits(in_msg_body) >= 1);
       forward_message(to_address, in_msg_body);
       count += 1;
       save_data(count);
       return ();
   }
   throw(error::unknown_op);
}

 

Wrapper

The wrapper implementation diverges from the default by focusing solely on simulating contract behavior. This ensures that each test can target a specific contract state or message. We achieve this by,

  1. Composing the wrapper with a SmartContract object in addition to init and address.
  2. Directly sending the message to our contract with receiveMessage function and returning the transaction.
import { Address, beginCell, Cell, Contract, contractAddress, Message, toNano } from '@ton/core';
import { AccountStateActive } from '@ton/core/dist/types/AccountState';
import { Blockchain, createShardAccount, internal, SmartContract, SmartContractTransaction } from '@ton/sandbox';

export type MessageCounterConfig = {
   count: bigint
};

export function messageCounterConfigToCell(config: MessageCounterConfig): Cell {
   return beginCell().storeUint(config.count, 256).endCell();
}
function cellToMessageCounterConfig(cell: Cell): MessageCounterConfig {
   return {
       count: cell.beginParse().loadUintBig(256)
   }
}

export class MessageCounter implements Contract {
   constructor(readonly address: Address, private readonly smc: SmartContract, readonly init?: { code: Cell; data: Cell }) { }
   static createFromAddress(blockchain: Blockchain, address: Address) {
       return new MessageCounter(address, SmartContract.empty(blockchain, address));
   }
   static createFromConfig(blockchain: Blockchain, config: MessageCounterConfig, code: Cell, workchain = 0, balance = toNano(1n)) {
       const data = messageCounterConfigToCell(config);
       const init = { code, data };
       const smc = SmartContract.create(blockchain, {
           address: contractAddress(workchain, init),
           balance,
           code,
           data
       })
       return new MessageCounter(contractAddress(workchain, init), smc, init);
   }

   sendDeploy(from: Address, balance: bigint = toNano("0.01")): Promise<SmartContractTransaction> {
       return this.receiveMessage(internal({
           to: this.address,
           from,
           body: beginCell().endCell(),
           value: balance
       }));
   }

   static forwardMessage(user_address: Address, forwardMessage: string) {
       return beginCell()
                   .storeUint(0x36e135d3, 32) // op
                   .storeAddress(user_address)
                   .storeStringTail(forwardMessage)
               .endCell()
   }
   sendForwardMessage(from: Address,
                               user_address: Address,
                               forwardMessage: string,
                               amount: bigint = toNano(1)) {
       return this.receiveMessage(internal({
           from,
           to: this.address,
           body: MessageCounter.forwardMessage(user_address, forwardMessage),
           value: amount,
       }));
   }

   async getCount(): Promise<bigint> {
       let res = await this.smc.get('get_count', []);
       let count = res.stackReader.readBigNumber();
       return count;
   }

   receiveMessage(message: Message) {
       return this.smc.receiveMessage(message);
   }
  
   get config() {
       let cell = (this.smc.account.account?.storage.state as AccountStateActive).state.data
       if(!cell) throw "contract state data not found"
       return cellToMessageCounterConfig(cell!)
   }
   set config(config: MessageCounterConfig) {
       this.smc.account = createShardAccount({
           address: this.address,
           code: this.init?.code!,
           data: messageCounterConfigToCell(config),
           balance: 0n
       })
   }

   get balance() {
       return this.smc.balance;
   }
   set balance(balance: bigint) {
       this.smc.balance = balance;
   }
}

 

Unit Tests

With the groundwork ready, we can now write isolated unit tests to verify the contract’s behavior. Each test will focus on a specific aspect of the contract’s functionality. We begin with this suite as our starting point.

import { Blockchain, internal, SandboxContract, TreasuryContract } from '@ton/sandbox';
import { beginCell, Cell, toNano } from '@ton/core';
import { MessageCounter } from '../wrappers/MessageCounter';
import '@ton/test-utils';
import { compile } from '@ton/blueprint';
import { randomAddress } from '@ton/test-utils';

const minTonForStorage: bigint = 100000n;

describe('MessageCounter', () => {
   let code: Cell;

   beforeAll(async () => {
       code = await compile('MessageCounter');
   });

   let blockchain: Blockchain;
   let deployer: SandboxContract<TreasuryContract>;
   let sut: MessageCounter;

   beforeEach(async () => {
       blockchain = await Blockchain.create();
       sut = MessageCounter.createFromConfig(blockchain, { count: 0n }, code);
       deployer = await blockchain.treasury('deployer');
   });

   it('should deploy', async () => {
       // when
       const deployResult = await sut.sendDeploy(deployer.address);
      
       // then
       expect(deployResult.endStatus).toEqual('active');
   });
});

Now head to the terminal and try running the test suites with npm test

The result of the test suite at this point

 

Completing the contract with TDD

Setting the contract state

Since our unit tests need to work in isolation, we shouldn’t rely on the deploy test to deploy our contract, instead we will manually set the config of our contract in a beforeEach block.

describe("given contract is active", () => {
   beforeEach(() => {
       sut.config = { count: 0n };
   });
});

 

Testing Getters

The simplest candidate to begin our test-driven development is the get_count function that will return us the current counter.

it("when get_count method is invoked then return current count", async () => {
   // given
   const expectedCount = toNano(Math.random());
   sut.config = { count: expectedCount };

   // when
   let actualCount = await sut.getCount()

   // then
   expect(actualCount.count).toEqual(expectedCount);
})

If you run the test suite at the moment, you will witness the failing test with error “Unable to execute get method. Got exit_code: 11”. This is because we haven’t defined the get_count function yet in our contract. Head to our contract code and add this function at the very bottom.

(int) get_count() method_id {
 return load_data();
}

You can run the tests now and verify that they are green now.

The result of the test suite at this point

 

Testing Messages

For the scope of this article, I will only focus on testing the internal message, but this approach can be extended to test external messages as well.

Let’s start with a couple of tests that has an implementation in our contract already.

describe("when forward_message is received", () => {
     it("and forward payload is not provided then throw 100", async () => {
         // given
         let message = beginCell()
                             .storeUint(0x36e135d3, 32) // op
                             .storeAddress(randomAddress(0))
                         .endCell() // no forward payload

         // when
         let transaction = await sut.receiveMessage(internal({
             from: deployer.address,
             to: sut.address,
             body: message,
             value: toNano(1),
         }));

         // then
         expect(transaction).toHaveTransaction({
             exitCode: 100
         });
     })

     describe("and forward payload is provided", () => {
         it("then update counter", async () => {
             // given
             const counter = sut.config.count;
             const toAddress = randomAddress(0);

             // when
             await sut.sendForwardMessage(deployer.address, toAddress, "Hello", toNano(1));

             // then
             expect(sut.config.count).toEqual(counter + 1n);
         })
     })
 })

These tests should pass because our starting point has the implementation of these functions already.

The result of the test suite at this point

Let’s add another test in the last branch that expects the contract to forward the provided payload.

it("then send message with given body to the specified address", async () => {
   // given
   const toAddress = randomAddress(0);

   // when
   const transaction = await sut.sendForwardMessage(deployer.address, toAddress, "Hello", toNano(1));

   // then
   expect(transaction).toHaveTransaction({
       success: true
   });
   expect(transaction.outMessagesCount).toBeGreaterThanOrEqual(1);
   expect(transaction.outMessages.get(0)?.body.beginParse().loadStringTail()).toEqual("Hello");
})

The test suite will fail, now we can head to the contract and replace forward_message function with the below implementation.

() forward_message(slice to_address, slice forward_payload) impure inline {
   var msg = begin_cell()
               .store_uint(BOUNCEABLE, 6)
               .store_slice(to_address)
               .store_coins(0)
               .store_uint(0, 1 + 4 + 4 + 64 + 32 + 1 + 1)
               .store_slice(forward_payload);
          
   send_raw_message(msg.end_cell(), SEND_MODE_CARRY_ALL_REMAINING_MESSAGE_VALUE);
}

The result of the test suite at this point

 

Testing Bounced Messages

Having tested basic functionality, let’s now focus on handling edge cases like bounced messages. Every message that our contract sends increments its counter, hence if a message is bounced we should revert the effect of that message (decrement the counter). This ensures that the contract maintains an accurate count, even when messages fail. Let’s write a test for it,

it("when bounced message is received then decrease counter", async() => {
   // given
   const count = 1n;
   sut.config = { count };
   const toAddress = randomAddress(0);

   // when
   await sut.receiveMessage(internal({
       from: toAddress,
       to: sut.address,
       body: beginCell().storeStringTail("Hello").endCell(),
       bounced: true,
       value: toNano("0.01")
   }));

   // then
   expect(sut.config.count).toEqual(count - 1n);
})

Now let’s implement the functionality to make this green. Head to the contract and modify the conditional branch handling bounced messages.

if (flags & 1) {
   count -= 1;
   save_data(count);
   return ();
}

Awesome! You have written a contract on FunC and wrote 100% independent and isolated unit tests on it with @ton/sandbox.

 

Summary

By following this approach, we’ve demonstrated how to write isolated, independent, and robust unit tests for FunC smart contracts. This methodology not only ensures code correctness but also facilitates seamless TDD practices, paving the way for more reliable and maintainable smart contracts.

You can also find the complete source code for reference here. 

Sample projects are like practice runs—things might seem smooth, but the real work always finds new ways to surprise you. Now, here’s the feature-length version: my PR to the standard token contract repository implementing this approach!

I would appreciate your feedback, thoughts, or questions in the comments. It will encourage us to write more articles facilitating development on the TON blockchain.

Tell
us about your Project

Related Blogs

Terms & Condition | Privacy Policy
Copyright © 2024 BlockApex. All rights reserved.
Clients & Partners
0 +
not sure where to start?

    Clients & Partners
    0 +
    Clients & Partners
    0 +

      Access the
      Audit Checklist